@staix/agent-research 0.0.0-stage → 0.2.1

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 (55) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/LICENSE +21 -0
  3. package/README.md +666 -2
  4. package/bin/ares.js +11 -0
  5. package/package.json +37 -4
  6. package/src/autonomy.ts +50 -0
  7. package/src/cli.ts +308 -0
  8. package/src/commands-table.ts +75 -0
  9. package/src/commands.ts +561 -0
  10. package/src/console/registry.ts +97 -0
  11. package/src/copilot.ts +91 -0
  12. package/src/exp/ablate.ts +140 -0
  13. package/src/exp/artifacts.ts +229 -0
  14. package/src/exp/attempts.ts +483 -0
  15. package/src/exp/executor.ts +207 -0
  16. package/src/exp/final.ts +197 -0
  17. package/src/exp/ideas.ts +117 -0
  18. package/src/exp/investigate.ts +187 -0
  19. package/src/exp/logs.ts +63 -0
  20. package/src/exp/metrics.ts +132 -0
  21. package/src/exp/protocol.ts +148 -0
  22. package/src/exp/selection.ts +53 -0
  23. package/src/exp/snapshot.ts +147 -0
  24. package/src/http.ts +106 -0
  25. package/src/index.ts +4 -0
  26. package/src/lit/bibtex.ts +61 -0
  27. package/src/lit/candidates.ts +148 -0
  28. package/src/lit/cards.ts +21 -0
  29. package/src/lit/cite.ts +26 -0
  30. package/src/lit/sources.ts +122 -0
  31. package/src/lit/verify.ts +110 -0
  32. package/src/lit/watch.ts +122 -0
  33. package/src/manifest.ts +39 -0
  34. package/src/mcp-tools.ts +210 -0
  35. package/src/mcp.ts +113 -0
  36. package/src/memory/amem.ts +97 -0
  37. package/src/memory/archive.ts +66 -0
  38. package/src/memory/lessons.ts +88 -0
  39. package/src/memory/skills.ts +99 -0
  40. package/src/paper/build.ts +73 -0
  41. package/src/paper/check.ts +181 -0
  42. package/src/paper/placeholders.ts +118 -0
  43. package/src/paper/review.ts +127 -0
  44. package/src/plan/controller.ts +222 -0
  45. package/src/plan/definition.ts +168 -0
  46. package/src/plan/hub.ts +56 -0
  47. package/src/plan/state.ts +270 -0
  48. package/src/playbook.ts +141 -0
  49. package/src/scope.ts +53 -0
  50. package/src/setup.ts +296 -0
  51. package/src/study-export.ts +254 -0
  52. package/src/study.ts +324 -0
  53. package/src/ui/panel.css +11 -0
  54. package/src/ui/panel.js +161 -0
  55. package/src/ui/plugin.ts +375 -0
@@ -0,0 +1,97 @@
1
+ import { chmodSync, closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, realpathSync, unlinkSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { assertWritable, openStudy, plain, readTextBounded, writeAtomic } from "../study.ts";
4
+
5
+ export interface StudyRegistry { v: 1; studies: string[] }
6
+ export const STUDY_REGISTRY_BYTES = 1024 * 1024, MAX_REGISTERED_STUDIES = 200;
7
+ function fail(message: string): never { throw new Error(`study registry: ${message}; restore the bounded v1 registry or correct AGENTHUB_HOME and retry ares console add/remove/list`); }
8
+ function absolute(value: string, label: string): string {
9
+ if (!isAbsolute(value) || value.includes("\0")) fail(`${label} must be an absolute path`);
10
+ return resolve(value);
11
+ }
12
+ function paths(home: string) {
13
+ const base = absolute(home, "family home"), dir = join(base, "research"), file = join(dir, "studies.json");
14
+ return { base, dir, file, lock: `${file}.lock` };
15
+ }
16
+ /** Root-owned system ancestors are allowed; the mutable family/registry paths must belong to this user. */
17
+ function guard(home: string, file: string): void {
18
+ const uid = process.getuid!(), { base, dir } = paths(home), ancestors: string[] = [];
19
+ for (let current = resolve(file); ; current = dirname(current)) { ancestors.unshift(current); if (current === dirname(current)) break; }
20
+ for (const current of ancestors) {
21
+ let info;
22
+ try { info = lstatSync(current); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
23
+ if (info.isSymbolicLink()) fail("links are refused in registry paths");
24
+ if (current === file) {
25
+ if (!info.isFile()) fail("registry or lock is not a regular file");
26
+ if (info.uid !== uid) fail("registry or lock is owned by another user");
27
+ } else {
28
+ if (!info.isDirectory()) fail("registry parent is not a directory");
29
+ if (info.uid !== uid && (current === base || current === dir || info.uid !== 0)) fail("registry parent is owned by another user");
30
+ }
31
+ }
32
+ }
33
+ function validate(raw: unknown): StudyRegistry {
34
+ if (!plain(raw) || raw.v !== 1) fail("unsupported registry version or malformed object; keep studies.json unchanged and use a compatible ares version");
35
+ if (!Array.isArray(raw.studies) || raw.studies.length > MAX_REGISTERED_STUDIES) fail("studies must be an array of at most 200 directories");
36
+ if (raw.studies.some(value => typeof value !== "string" || !isAbsolute(value) || value.includes("\0") || value !== resolve(value))) fail("stored study directories must be absolute normalized paths");
37
+ if (new Set(raw.studies).size !== raw.studies.length) fail("duplicate study directory in registry");
38
+ return { ...raw, v: 1, studies: [...raw.studies] } as StudyRegistry;
39
+ }
40
+ export function readRegistry(home: string): StudyRegistry {
41
+ const { file } = paths(home); guard(home, file);
42
+ let content: string;
43
+ try { content = readTextBounded(file, STUDY_REGISTRY_BYTES, { noFollow: true, uid: process.getuid!() }); }
44
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { v: 1, studies: [] }; fail("studies.json is unreadable, non-regular or over 1 MiB"); }
45
+ let raw: unknown; try { raw = JSON.parse(content); } catch { fail("studies.json is not valid JSON"); }
46
+ return validate(raw);
47
+ }
48
+ function update(home: string, change: (current: StudyRegistry) => StudyRegistry): StudyRegistry {
49
+ const { dir, file, lock } = paths(home); guard(home, file); guard(home, lock);
50
+ const busy = () => fail(`registry is locked at ${lock}; wait for the active registry command, or remove this stale lock only after confirming no registry command is running`);
51
+ if (existsSync(lock)) busy();
52
+ mkdirSync(dir, { recursive: true, mode: 0o700 }); guard(home, file); chmodSync(dir, 0o700);
53
+ let fd: number;
54
+ try { fd = openSync(lock, "wx", 0o600); } catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") busy(); throw error; }
55
+ let owner: { dev: number; ino: number } | undefined;
56
+ try {
57
+ // Capture the created path before descriptor inspection so a failed fstat still has guarded cleanup.
58
+ try { owner = lstatSync(lock); } catch (error) {
59
+ try { owner = fstatSync(fd); } catch { /* Unknown identity must leave a stale lock for manual recovery. */ }
60
+ throw error;
61
+ }
62
+ owner = fstatSync(fd);
63
+ const current = readRegistry(home), next = change(current);
64
+ if (next === current) return current;
65
+ const content = JSON.stringify(next, null, 2) + "\n"; assertWritable(content, STUDY_REGISTRY_BYTES, "study registry");
66
+ guard(home, file); writeAtomic(file, content, STUDY_REGISTRY_BYTES, 0o600); return next;
67
+ } finally {
68
+ try { closeSync(fd); } finally {
69
+ if (owner) {
70
+ try { const current = lstatSync(lock); if (current.dev === owner.dev && current.ino === owner.ino) unlinkSync(lock); }
71
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
72
+ }
73
+ }
74
+ }
75
+ }
76
+ function canonical(dir: string, mustExist: boolean): string {
77
+ if (dir.includes("\0")) fail("study directory contains NUL");
78
+ const full = resolve(dir);
79
+ try { return realpathSync(full); } catch (error) { if (!mustExist && (error as NodeJS.ErrnoException).code === "ENOENT") return full; fail("study directory is unreadable; select a readable study for add or use its listed absolute directory for remove"); }
80
+ }
81
+ export function addStudy(home: string, dir: string): StudyRegistry {
82
+ const target = canonical(dir, true); openStudy(target);
83
+ const current = readRegistry(home); if (current.studies.includes(target)) return current;
84
+ if (current.studies.length >= MAX_REGISTERED_STUDIES) fail("200-study limit reached; remove an entry before adding another study");
85
+ return update(home, current => {
86
+ if (current.studies.includes(target)) return current;
87
+ if (current.studies.length >= MAX_REGISTERED_STUDIES) fail("200-study limit reached; remove an entry before adding another study");
88
+ return { ...current, studies: [...current.studies, target] };
89
+ });
90
+ }
91
+ export function removeStudy(home: string, dir: string): StudyRegistry {
92
+ const listed = resolve(dir), current = readRegistry(home);
93
+ // A moved or unreadable registered directory remains removable by its exact listed path.
94
+ const target = current.studies.includes(listed) ? listed : canonical(dir, false);
95
+ if (!current.studies.includes(target)) return current;
96
+ return update(home, current => current.studies.includes(target) ? { ...current, studies: current.studies.filter(value => value !== target) } : current);
97
+ }
package/src/copilot.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { lstatSync, opendirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ACCEPT_ARTIFACTS, resolveAutonomy, type AcceptArtifact } from "./autonomy.ts";
4
+ import { canonical } from "./plan/state.ts";
5
+ import { gateState, openStudy, readBytesBounded, readLedger, readTextBounded, sha256 } from "./study.ts";
6
+
7
+ export const COPILOT_HYPOTHESES_BYTES = 2 * 1024 * 1024;
8
+ export const COPILOT_PAPER_BYTES = 64 * 1024 * 1024;
9
+ export const COPILOT_PAPER_FILES = 500, COPILOT_PAPER_DIRECTORIES = 1000, COPILOT_PAPER_DEPTH = 64;
10
+ export class CopilotAcceptError extends Error {}
11
+ class DraftReadError extends CopilotAcceptError {}
12
+ function refuse(artifact: AcceptArtifact, reason: string): never {
13
+ throw new DraftReadError(`copilot draft ${artifact}: ${reason}; restore the last valid bounded regular draft files and ledger, then run ares gate list and ares gate approve <dir> accept:${artifact} before retrying`);
14
+ }
15
+ function draftLedger(dir: string, artifact: AcceptArtifact) {
16
+ try { return readLedger(dir); }
17
+ catch (error) {
18
+ if (error instanceof Error && /^ledger\.jsonl line [0-9]+: unsupported schema version/.test(error.message)) throw new DraftReadError(`copilot draft ${artifact}: unsupported ledger version; keep its bytes unchanged and use a compatible ares version before ares gate approve <dir> accept:${artifact}`);
19
+ refuse(artifact, "acceptance ledger is unreadable, malformed or over-bound");
20
+ }
21
+ }
22
+ function metadata(file: string) {
23
+ try { return lstatSync(file); }
24
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
25
+ }
26
+ function paperTree(dir: string): [string, string][] {
27
+ const root = join(dir, "paper"), entries: [string, string][] = [];
28
+ const rootStat = metadata(root);
29
+ if (rootStat === undefined) return entries;
30
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) refuse("paper", "paper source root must be a directory without symbolic links");
31
+ const derived = metadata(join(root, "build"));
32
+ let bytes = 0, directories = 0;
33
+ function walk(relative: string, depth: number): void {
34
+ // paper/build is derived output; manuscript sources include all other notes and assets.
35
+ if (relative === "build") return;
36
+ const file = relative ? join(root, relative) : root, stat = lstatSync(file);
37
+ // Respect the filesystem's case aliases without excluding a distinct source directory on case-sensitive hosts.
38
+ if (depth === 1 && relative.toLowerCase() === "build" && derived && derived.dev === stat.dev && derived.ino === stat.ino) return;
39
+ if (stat.isSymbolicLink()) refuse("paper", "symbolic links refused");
40
+ if (stat.isDirectory()) {
41
+ if (depth > COPILOT_PAPER_DEPTH || ++directories > COPILOT_PAPER_DIRECTORIES) refuse("paper", `source tree exceeds ${COPILOT_PAPER_DIRECTORIES} directories or ${COPILOT_PAPER_DEPTH} levels`);
42
+ const directory = opendirSync(file);
43
+ try { for (let entry = directory.readSync(); entry; entry = directory.readSync()) walk(relative ? `${relative}/${entry.name}` : entry.name, depth + 1); }
44
+ finally { directory.closeSync(); }
45
+ } else {
46
+ if (!relative || !stat.isFile()) refuse("paper", "regular files within the paper directory required");
47
+ if (entries.length >= COPILOT_PAPER_FILES) refuse("paper", `file count exceeds ${COPILOT_PAPER_FILES}`);
48
+ if (stat.size > COPILOT_PAPER_BYTES - bytes) refuse("paper", `aggregate bytes exceed ${COPILOT_PAPER_BYTES}`);
49
+ const content = readBytesBounded(file, COPILOT_PAPER_BYTES - bytes, { noFollow: true });
50
+ bytes += content.byteLength; entries.push([relative, sha256(content)]);
51
+ }
52
+ }
53
+ walk("", 0);
54
+ return entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
55
+ }
56
+
57
+ /** Only active, present drafts have accept bindings. Irrelevant and acceptance events are excluded. */
58
+ export function copilotBinding(dirArg: string, artifact: AcceptArtifact): string | undefined {
59
+ if (!ACCEPT_ARTIFACTS.includes(artifact)) throw new Error("copilot draft: unknown artifact; select hypotheses, paper or reviews and retry ares gate list");
60
+ const { dir, study } = openStudy(dirArg);
61
+ if (resolveAutonomy(study.autonomy, `accept:${artifact}`) !== "copilot") return undefined;
62
+ try {
63
+ if (artifact === "paper") {
64
+ const files = paperTree(dir);
65
+ return files.length ? sha256(canonical(files)) : undefined;
66
+ }
67
+ const events = draftLedger(dir, artifact).filter(event => artifact === "hypotheses" ? event.type === "idea.added" || event.type === "idea.decided" : event.type === "review.added" || event.type === "review.resolved");
68
+ if (artifact === "reviews") return events.length ? sha256(canonical(events)) : undefined;
69
+ const file = join(dir, "hypotheses.md"), stat = metadata(file);
70
+ let hypotheses: string | undefined;
71
+ if (stat !== undefined) {
72
+ if (stat.isSymbolicLink() || !stat.isFile()) refuse(artifact, "regular hypotheses file required; symbolic links refused");
73
+ if (stat.size > COPILOT_HYPOTHESES_BYTES) refuse(artifact, `hypotheses source exceeds ${COPILOT_HYPOTHESES_BYTES} bytes`);
74
+ hypotheses = readTextBounded(file, COPILOT_HYPOTHESES_BYTES, { noFollow: true });
75
+ }
76
+ return hypotheses !== undefined || events.length ? sha256(canonical([hypotheses === undefined ? null : sha256(hypotheses), events])) : undefined;
77
+ } catch (error) {
78
+ if (error instanceof DraftReadError) throw error;
79
+ refuse(artifact, "artifact or ledger is unreadable, non-regular, invalid UTF-8 or over-bound");
80
+ }
81
+ }
82
+
83
+ /** Acceptance records the existing gate actor; neither agents nor people receive an implicit approval. */
84
+ export function requireCopilotAccept(dir: string, artifact: AcceptArtifact): void {
85
+ const binding = copilotBinding(dir, artifact);
86
+ if (binding === undefined) return;
87
+ const events = draftLedger(dir, artifact);
88
+ const gate = `accept:${artifact}` as const, state = gateState(gate, events, () => binding);
89
+ if (state.state === "approved") return;
90
+ throw new CopilotAcceptError(`copilot draft ${artifact}: ${state.state} acceptance; run ares gate approve <dir> ${gate} for the current draft before retrying`);
91
+ }
@@ -0,0 +1,140 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { assertWritable, boundedText, commit, onlyKeys, openStudy, plain, readLedger, readTextBounded, requireExperiment, sha256, type Actor, type LedgerEvent } from "../study.ts";
4
+ import { listAttempts, type AttemptStatus } from "./attempts.ts";
5
+ import { readProtocol } from "./protocol.ts";
6
+ import { readFinalSeeds, finalSeedsHash } from "./final.ts";
7
+ import { metricValue, pairSamples, readClosedInvestigation, type Sample, type PairedSamples } from "./investigate.ts";
8
+
9
+ export const MAX_ABLATIONS = 200, ABLATE_BYTES = 1_048_576;
10
+ export interface Ablation { id: string; commit: string; removes: string }
11
+ type Exclusion = "other-protocol" | "before-selection" | "missing-start-event" | "undeclared-seed" | "reserved-seed" | "undeclared-ablation" | "later-success";
12
+ type AttemptsByStatus = Record<AttemptStatus, string[]>;
13
+ export interface AblateRow extends Ablation { attempts: AttemptsByStatus; perSeed: Sample[]; paired: PairedSamples; reason: "no-data" | "reference-seeds" | "no-shared-seeds" | "paired" }
14
+ export interface AblateResult {
15
+ v: 1; run: "ablate"; investigateSha256: string; inputsSha256: string; protocolHash: string;
16
+ metric: { name: string; unit: string; direction: "max" | "min" };
17
+ reference: { commit: string; attempts: AttemptsByStatus; perSeed: Sample[] };
18
+ ablations: AblateRow[];
19
+ totals: { attempts: number; byStatus: Record<AttemptStatus, number>; countedSuccesses: number; excluded: number };
20
+ excluded: { id: string; commit: string; seed: number; status: AttemptStatus; reasons: Exclusion[] }[];
21
+ }
22
+ function bad(reason: string): never { throw new Error(`ablate: ${reason}; inspect ares investigate show and ares attempt list, then correct the inputs and retry ablate`); }
23
+ function canonical(raw: unknown): string {
24
+ if (Array.isArray(raw)) return `[${raw.map(canonical).join(",")}]`;
25
+ if (plain(raw)) return `{${Object.keys(raw).sort().map(k => `${JSON.stringify(k)}:${canonical(raw[k])}`).join(",")}}`;
26
+ return JSON.stringify(raw) ?? "null";
27
+ }
28
+ function declaration(raw: unknown, strict: boolean): Ablation {
29
+ if (!plain(raw)) bad("declaration object required");
30
+ if (strict) onlyKeys(raw, ["id", "commit", "removes"], "ablate declaration");
31
+ if (typeof raw.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(raw.id)) bad("id must be a run name of 1..64 characters");
32
+ if (typeof raw.commit !== "string" || !/^[a-fA-F0-9]{40}$/.test(raw.commit)) bad("commit must be a full 40-hex SHA");
33
+ const removes = boundedText(raw.removes, "ablate removes", 2000);
34
+ if (Buffer.byteLength(removes) > 2000) bad("removes exceeds 2000 bytes");
35
+ return { id: raw.id, commit: raw.commit.toLowerCase(), removes };
36
+ }
37
+ export function foldAblations(events: LedgerEvent[]): Ablation[] {
38
+ const rows: Ablation[] = [];
39
+ for (const e of events) if (e.type === "ablation.added") {
40
+ const row = declaration(e.data.ablation, false);
41
+ if (rows.length >= MAX_ABLATIONS || rows.some(old => old.id === row.id || old.commit === row.commit)) bad("journal has duplicate ablation id/commit or exceeds 200 declarations; restore the last valid ledger");
42
+ rows.push(row);
43
+ }
44
+ return rows;
45
+ }
46
+ function context(dirArg: string) {
47
+ const { dir, study } = openStudy(dirArg); requireExperiment(study);
48
+ const closed = readClosedInvestigation(dir), protocol = readProtocol(dir)!;
49
+ if (!closed.result.winner) bad("investigation has no winner; finish and close an investigation with a winner first");
50
+ const events = readLedger(dir), declarations = foldAblations(events);
51
+ return { dir, study, closed, protocol, events, declarations, winner: closed.result.winner.commit, baseline: closed.result.baseline.commit };
52
+ }
53
+ /** Caller holds the study lock; declarations never dispatch work or rewrite earlier journal entries. */
54
+ export function addAblation(dirArg: string, raw: unknown, actor: Actor): Ablation {
55
+ try {
56
+ const input = context(dirArg), row = declaration(raw, true);
57
+ if (input.declarations.length >= MAX_ABLATIONS) bad("200 declarations reached; start a new study for more ablations");
58
+ if (input.declarations.some(old => old.id === row.id)) bad(`duplicate id ${row.id}; choose a unique id`);
59
+ if (input.declarations.some(old => old.commit === row.commit)) bad(`duplicate commit for ${row.id}; choose a unique frozen ablation`);
60
+ if (row.commit === input.winner || row.commit === input.baseline) bad("commit is the winner or selection baseline; use a distinct ablation commit");
61
+ commit(input.dir, [], [{ actor, type: "ablation.added", data: { ablation: row } }]); return row;
62
+ } catch (error) {
63
+ const message = error instanceof Error ? error.message : "ablate: invalid declaration";
64
+ throw new Error(message.includes("retry ablate") ? message : `${message}; correct the file and run ares ablate add --file PATH again`);
65
+ }
66
+ }
67
+ function empty(): AttemptsByStatus { return { running: [], succeeded: [], failed: [], cancelled: [], unknown: [] }; }
68
+ function calculate(input: ReturnType<typeof context>): { result: AblateResult; running: boolean } {
69
+ const { dir, study, closed, protocol, events, declarations, winner } = input;
70
+ const attempts = listAttempts(dir, { run: "ablate" }).attempts, reserved = readFinalSeeds(dir, protocol)?.seeds ?? [];
71
+ const cutoff = events.findLastIndex(e => e.type === "selection.set"), starts = new Map<string, { index: number; data: Record<string, unknown> }>();
72
+ events.forEach((e, index) => { if (e.type === "attempt.started" && typeof e.data.id === "string") { if (starts.has(e.data.id)) bad(`duplicate start receipt ${e.data.id}`); starts.set(e.data.id, { index, data: e.data }); } });
73
+ const ordered = [...attempts].sort((a, b) => (starts.get(a.id)?.index ?? -1) - (starts.get(b.id)?.index ?? -1) || a.id.localeCompare(b.id));
74
+ const reference = { commit: winner, attempts: empty(), perSeed: [] as Sample[] };
75
+ const rows = declarations.map(d => ({ ...d, attempts: empty(), perSeed: [] as Sample[] }));
76
+ const byStatus = { running: 0, succeeded: 0, failed: 0, cancelled: 0, unknown: 0 }, excluded: AblateResult["excluded"] = [];
77
+ for (const a of ordered) {
78
+ if (a.studyId !== study.id || typeof a.commit !== "string" || !/^[a-f0-9]{40}$/.test(a.commit) || !Number.isSafeInteger(a.seed)) bad(`attempt ${a.id} has invalid study/commit/seed metadata`);
79
+ byStatus[a.status]++;
80
+ const row = a.commit === winner ? reference : rows.find(r => r.commit === a.commit);
81
+ if (row) row.attempts[a.status].push(a.id);
82
+ const reasons: Exclusion[] = [], start = starts.get(a.id);
83
+ if (!row) reasons.push("undeclared-ablation");
84
+ if (a.protocolHash !== closed.result.protocolHash) reasons.push("other-protocol");
85
+ if (!start) reasons.push("missing-start-event");
86
+ else {
87
+ if (start.data.run !== a.run || start.data.commit !== a.commit || start.data.seed !== a.seed) bad(`attempt ${a.id} start receipt identity mismatch`);
88
+ if (start.index <= cutoff) reasons.push("before-selection");
89
+ }
90
+ if (!protocol.seeds.includes(a.seed)) reasons.push("undeclared-seed");
91
+ if (reserved.includes(a.seed)) reasons.push("reserved-seed");
92
+ if (!reasons.length && a.status === "succeeded" && row) {
93
+ if (row.perSeed.some(s => s.seed === a.seed)) reasons.push("later-success");
94
+ else row.perSeed.push({ seed: a.seed, attempt: a.id, value: metricValue(a, closed.result.metric) });
95
+ }
96
+ if (reasons.length) excluded.push({ id: a.id, commit: a.commit, seed: a.seed, status: a.status, reasons });
97
+ }
98
+ reference.perSeed.sort((a, b) => a.seed - b.seed);
99
+ const ablations: AblateRow[] = rows.map(row => {
100
+ row.perSeed.sort((a, b) => a.seed - b.seed);
101
+ const paired = pairSamples(row.perSeed, reference.perSeed, closed.result.metric.direction, row.commit);
102
+ const reason = !row.perSeed.length ? "no-data" : !reference.perSeed.length ? "reference-seeds" : !paired.pairedSeeds.length ? "no-shared-seeds" : "paired";
103
+ return { ...row, paired, reason };
104
+ });
105
+ const result: AblateResult = {
106
+ v: 1, run: "ablate", investigateSha256: closed.sha256, protocolHash: closed.result.protocolHash, metric: closed.result.metric,
107
+ inputsSha256: sha256(canonical({ investigate: closed.sha256, declarations, finalSeeds: finalSeedsHash(dir, protocol) ?? null, attempts: ordered.map(a => ({ record: sha256(canonical(a)), start: starts.get(a.id)?.index ?? null })) })),
108
+ reference, ablations, totals: { attempts: attempts.length, byStatus, countedSuccesses: reference.perSeed.length + rows.reduce((n, r) => n + r.perSeed.length, 0), excluded: excluded.length }, excluded,
109
+ };
110
+ assertWritable(JSON.stringify(result, null, 2) + "\n", ABLATE_BYTES, "ablate result; archive this study and use a new bounded run");
111
+ return { result, running: attempts.some(a => a.status === "running") };
112
+ }
113
+ export function ablateResultPath(dir: string): string { return join(dir, "runs", "ablate", "ablate.json"); }
114
+ function previous(file: string): string | undefined {
115
+ if (!existsSync(file)) return undefined;
116
+ let text: string;
117
+ try { text = readTextBounded(file, ABLATE_BYTES); } catch { bad("result is unreadable or oversized; restore the last valid bounded ablate.json"); }
118
+ let value: unknown; try { value = JSON.parse(text); } catch { bad("result JSON is malformed; restore the last valid ablate.json"); }
119
+ if (!plain(value) || value.v !== 1) throw new Error("ablate result: unsupported version; use an ares version that supports it, keeping ablate.json unchanged");
120
+ return text;
121
+ }
122
+ export function showAblate(dirArg: string): AblateResult { const input = context(dirArg); previous(ablateResultPath(input.dir)); return calculate(input).result; }
123
+ /** Caller holds the study lock; result and receipt share one commit. */
124
+ export function closeAblate(dirArg: string, actor: Actor): AblateResult {
125
+ const input = context(dirArg), file = ablateResultPath(input.dir), old = previous(file), next = calculate(input);
126
+ if (next.running) throw new Error("ablate close: an ablate attempt is running; run ares attempt reconcile before closing");
127
+ const text = JSON.stringify(next.result, null, 2) + "\n";
128
+ if (old !== text) commit(input.dir, [[file, text, ABLATE_BYTES]], [{ actor, type: "ablate.closed", data: { sha256: sha256(text), investigateSha256: input.closed.sha256 } }]);
129
+ return next.result;
130
+ }
131
+
132
+ export function readClosedAblate(dirArg: string): { result: AblateResult; sha256: string } {
133
+ const input = context(dirArg), text = previous(ablateResultPath(input.dir));
134
+ if (text === undefined) throw new Error("ablate result: not closed; run ares ablate close before final approval");
135
+ const hash = sha256(text), receipt = input.events.findLast(e => e.type === "ablate.closed");
136
+ if (!receipt || receipt.data.sha256 !== hash) throw new Error("ablate result: bytes disagree with the latest close receipt; run ares ablate close again");
137
+ const next = calculate(input);
138
+ if (next.running || text !== JSON.stringify(next.result, null, 2) + "\n") throw new Error("ablate result: stale inputs or running work; reconcile attempts and run ares ablate close again");
139
+ return { result: next.result, sha256: hash };
140
+ }
@@ -0,0 +1,229 @@
1
+ import { createHash, type Hash } from "node:crypto";
2
+ import { runOnExecutor, type Executor, type ProcessRunner } from "./executor.ts";
3
+
4
+ export const ARTIFACT_FILES = 500, ARTIFACT_BYTES = 256 * 1024 * 1024;
5
+ export const ARTIFACT_CAPTURE_BYTES = 65_536;
6
+ export const ARTIFACT_HEADERS = 2048, ARTIFACT_METADATA_BYTES = 65_536;
7
+ export const ARTIFACT_WIRE_BYTES = ARTIFACT_BYTES + 16 * 1024 * 1024;
8
+ export type ArtifactErrorCode = "invalid-artifact" | "artifact-limit" | "artifact-read";
9
+ export class ArtifactError extends Error {
10
+ constructor(readonly code: ArtifactErrorCode, cause: string) {
11
+ super(`artifact archive: ${cause}; ${code === "artifact-limit" ? "reduce artifact file count, bytes or metadata within the declared limits" : code === "artifact-read" ? "check the executor host and archive command" : "recreate the archive using regular files/directories, valid tar headers and safe relative paths"}, then rerun ares attempt reconcile <dir>`);
12
+ this.name = "ArtifactError";
13
+ }
14
+ }
15
+ export interface ArtifactFile { path: string; sha256: string; bytes: number }
16
+ export interface ArtifactArchive { files: ArtifactFile[]; captured?: Uint8Array }
17
+ export interface ArtifactArchiveOptions {
18
+ capture?: string; captureBytes?: number; maxBytes?: number; maxFiles?: number;
19
+ }
20
+ function invalid(cause: string): never { throw new ArtifactError("invalid-artifact", cause); }
21
+ function limit(cause: string): never { throw new ArtifactError("artifact-limit", cause); }
22
+ function utf8(bytes: Uint8Array): string {
23
+ try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
24
+ catch { return invalid("invalid UTF-8 metadata"); }
25
+ }
26
+ function field(header: Buffer, start: number, length: number, strict = true): string {
27
+ const bytes = header.subarray(start, start + length), end = bytes.indexOf(0);
28
+ const content = end === -1 ? bytes : bytes.subarray(0, end);
29
+ return strict ? utf8(content) : content.toString("utf8");
30
+ }
31
+ function octal(bytes: Uint8Array): number {
32
+ // GNU base-256 numbers are accepted only when positive and exactly representable.
33
+ if ((bytes[0]! & 128) !== 0) {
34
+ if ((bytes[0]! & 64) !== 0) return invalid("negative numeric header field");
35
+ let value = BigInt(bytes[0]! & 127);
36
+ for (const byte of bytes.subarray(1)) value = value * 256n + BigInt(byte);
37
+ if (value > BigInt(Number.MAX_SAFE_INTEGER)) return limit("numeric header field exceeds safe bounds");
38
+ return Number(value);
39
+ }
40
+ if (bytes.some(byte => byte !== 0 && byte !== 32 && (byte < 48 || byte > 55))) return invalid("malformed octal header field");
41
+ const text = Buffer.from(bytes).toString("ascii").replaceAll("\0", " ").trim();
42
+ if (!/^[0-7]+$/.test(text)) return invalid("malformed octal header field");
43
+ const value = Number.parseInt(text, 8);
44
+ if (!Number.isSafeInteger(value)) return limit("numeric header field exceeds safe bounds");
45
+ return value;
46
+ }
47
+ function decimal(value: string): number {
48
+ if (!/^[0-9]+$/.test(value)) return invalid("malformed PAX size field");
49
+ const number = Number(value);
50
+ if (!Number.isSafeInteger(number)) return limit("PAX size exceeds safe bounds");
51
+ return number;
52
+ }
53
+ function relative(value: string, directory = false): string {
54
+ if (!value || Buffer.byteLength(value) > 4096 || value.startsWith("/") || /^[A-Za-z]:/.test(value) || value.includes("\\") || /\p{Cc}/u.test(value)) return invalid("unsafe or oversized artifact path");
55
+ const parts = value.split("/");
56
+ if (parts.includes("..") || (!directory && value.endsWith("/"))) return invalid("traversal or ambiguous artifact path");
57
+ const normalized = parts.filter(part => part !== "" && part !== ".").join("/");
58
+ if (!normalized && !directory) return invalid("empty artifact file path");
59
+ return normalized;
60
+ }
61
+ function bound(value: number, max: number, what: string): number {
62
+ if (!Number.isSafeInteger(value) || value < 0 || value > max) return limit(`${what} must be an integer in 0..${max}`);
63
+ return value;
64
+ }
65
+ const ZERO_BLOCK = Buffer.alloc(65_536);
66
+ function allZero(bytes: Uint8Array): boolean {
67
+ const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
68
+ for (let offset = 0; offset < buffer.length; offset += ZERO_BLOCK.length) {
69
+ const part = buffer.subarray(offset, offset + ZERO_BLOCK.length);
70
+ if (!part.equals(ZERO_BLOCK.subarray(0, part.length))) return false;
71
+ }
72
+ return true;
73
+ }
74
+ type Pax = { path?: string | null; size?: string | null };
75
+ interface FileBody { kind: "file"; path: string; size: number; remaining: number; hash: Hash; capture?: Buffer; offset: number }
76
+ interface MetadataBody { kind: "metadata"; type: "x" | "g" | "L"; remaining: number; data: Buffer; offset: number }
77
+
78
+ class TarArtifacts {
79
+ readonly files: ArtifactFile[] = [];
80
+ captured?: Uint8Array;
81
+ private readonly header = Buffer.alloc(512);
82
+ private headerBytes = 0;
83
+ private body?: FileBody | MetadataBody;
84
+ private padding = 0;
85
+ private zeros = 0;
86
+ private ended = false;
87
+ private wireBytes = 0;
88
+ private headers = 0;
89
+ private totalBytes = 0;
90
+ private fileCount = 0;
91
+ private global: Pax = {};
92
+ private next: Pax = {};
93
+ private pendingMetadata = false;
94
+ private longName?: string;
95
+ private seen = new Map<string, "file" | "directory">();
96
+ constructor(private capture: string | undefined, private captureBytes: number, private maxBytes: number, private maxFiles: number) {}
97
+ push(chunk: Uint8Array): void {
98
+ if (!(chunk instanceof Uint8Array)) return invalid("non-binary archive stream");
99
+ this.wireBytes += chunk.byteLength;
100
+ if (this.wireBytes > this.maxBytes + ARTIFACT_WIRE_BYTES - ARTIFACT_BYTES) return limit("archive stream exceeds its payload and metadata overhead limits");
101
+ let offset = 0;
102
+ while (offset < chunk.byteLength) {
103
+ if (this.ended) { if (!allZero(chunk.subarray(offset))) return invalid("nonzero data after tar end markers"); return; }
104
+ if (this.padding && !this.body) {
105
+ const count = Math.min(this.padding, chunk.byteLength - offset);
106
+ if (chunk.subarray(offset, offset + count).some(byte => byte !== 0)) return invalid("nonzero tar entry padding");
107
+ this.padding -= count; offset += count; continue;
108
+ }
109
+ if (this.body) {
110
+ const body = this.body, count = Math.min(body.remaining, chunk.byteLength - offset), part = chunk.subarray(offset, offset + count);
111
+ if (body.kind === "metadata") body.data.set(part, body.offset);
112
+ else {
113
+ body.hash.update(part);
114
+ body.capture?.set(part, body.offset);
115
+ }
116
+ body.offset += count; body.remaining -= count; offset += count;
117
+ if (body.remaining === 0) this.completeBody();
118
+ continue;
119
+ }
120
+ const count = Math.min(512 - this.headerBytes, chunk.byteLength - offset);
121
+ this.header.set(chunk.subarray(offset, offset + count), this.headerBytes); this.headerBytes += count; offset += count;
122
+ if (this.headerBytes === 512) { this.headerBytes = 0; this.readHeader(); }
123
+ }
124
+ }
125
+ private readHeader(): void {
126
+ const header = this.header;
127
+ if (header.every(byte => byte === 0)) { if (++this.zeros === 2) this.ended = true; return; }
128
+ if (this.zeros) return invalid("entry after an incomplete tar end marker");
129
+ if (++this.headers > ARTIFACT_HEADERS) return limit(`archive exceeds ${ARTIFACT_HEADERS} headers`);
130
+ const expected = octal(header.subarray(148, 156));
131
+ let checksum = 0;
132
+ for (let i = 0; i < 512; i++) checksum += i >= 148 && i < 156 ? 32 : header[i]!;
133
+ if (checksum !== expected) return invalid("tar header checksum mismatch");
134
+ const magic = field(header, 257, 6);
135
+ if (magic !== "" && magic !== "ustar" && magic !== "ustar ") return invalid("unsupported tar header format");
136
+ const type = header[156] === 0 ? "0" : String.fromCharCode(header[156]!);
137
+ if (!["0", "5", "x", "g", "L"].includes(type)) return invalid("link or special tar entry is forbidden");
138
+ if (field(header, 157, 100) !== "") return invalid("link target on a regular tar entry is forbidden");
139
+ const hasPathOverride = ({ ...this.global, ...this.next }.path ?? this.longName) !== undefined;
140
+ const prefix = magic === "ustar" ? field(header, 345, 155, !hasPathOverride) : "", name = field(header, 0, 100, !hasPathOverride);
141
+ const headerPath = prefix ? `${prefix}/${name}` : name;
142
+ // Metadata entry paths never become artifacts, but still must be safe paths.
143
+ relative(headerPath, type === "5" || hasPathOverride);
144
+ const headerSize = octal(header.subarray(124, 136));
145
+ if (type === "x" || type === "g" || type === "L") {
146
+ if (headerSize > ARTIFACT_METADATA_BYTES) return limit(`tar metadata exceeds ${ARTIFACT_METADATA_BYTES} bytes`);
147
+ this.body = { kind: "metadata", type, remaining: headerSize, data: Buffer.alloc(headerSize), offset: 0 };
148
+ this.padding = (512 - headerSize % 512) % 512;
149
+ } else {
150
+ const overrides = { ...this.global, ...this.next }, path = relative(overrides.path ?? this.longName ?? headerPath, type === "5"), size = overrides.size === undefined || overrides.size === null ? headerSize : decimal(overrides.size);
151
+ this.next = {}; this.longName = undefined; this.pendingMetadata = false;
152
+ const parts = path.split("/");
153
+ for (let count = 1; count < parts.length; count++) if (this.seen.get(parts.slice(0, count).join("/")) === "file") return invalid("artifact file is used as a parent directory");
154
+ if (type === "5") {
155
+ if (size !== 0) return invalid("directory tar entry has a payload");
156
+ if (this.seen.get(path) === "file") return invalid("artifact path changes entry type");
157
+ this.seen.set(path, "directory"); return;
158
+ }
159
+ if (this.seen.has(path)) return invalid("duplicate artifact file path");
160
+ if ([...this.seen.keys()].some(existing => existing.startsWith(`${path}/`))) return invalid("artifact file replaces a parent directory");
161
+ this.seen.set(path, "file");
162
+ if (++this.fileCount > this.maxFiles) return limit(`archive exceeds ${this.maxFiles} regular files`);
163
+ if (size > this.maxBytes - this.totalBytes) return limit(`regular file bytes exceed ${this.maxBytes}`);
164
+ this.totalBytes += size;
165
+ if (path === this.capture && size > this.captureBytes) return limit(`selected capture exceeds ${this.captureBytes} bytes`);
166
+ this.body = { kind: "file", path, size, remaining: size, hash: createHash("sha256"), ...(path === this.capture ? { capture: Buffer.alloc(size) } : {}), offset: 0 };
167
+ this.padding = (512 - size % 512) % 512;
168
+ }
169
+ if (this.body.remaining === 0) this.completeBody();
170
+ }
171
+ private completeBody(): void {
172
+ const body = this.body!; this.body = undefined;
173
+ if (body.kind === "file") {
174
+ this.files.push({ path: body.path, sha256: body.hash.digest("hex"), bytes: body.size });
175
+ if (body.capture) this.captured = body.capture;
176
+ } else if (body.type === "L") {
177
+ let end = body.data.length;
178
+ while (end && body.data[end - 1] === 0) end--;
179
+ this.longName = utf8(body.data.subarray(0, end)); relative(this.longName, true); this.pendingMetadata = true;
180
+ } else {
181
+ const values: Pax = {}, seen = new Set<string>();
182
+ for (let offset = 0; offset < body.data.length;) {
183
+ const separator = body.data.indexOf(32, offset);
184
+ if (separator === -1) return invalid("malformed PAX record length");
185
+ const lengthText = body.data.subarray(offset, separator).toString("utf8");
186
+ if (!/^[1-9][0-9]*$/.test(lengthText)) return invalid("malformed PAX record length");
187
+ const length = Number(lengthText), end = offset + length;
188
+ if (!Number.isSafeInteger(length) || end > body.data.length || end <= separator + 1 || body.data[end - 1] !== 10) return invalid("truncated or malformed PAX record");
189
+ const record = utf8(body.data.subarray(separator + 1, end - 1)), equals = record.indexOf("=");
190
+ if (equals < 1) return invalid("malformed PAX key/value record");
191
+ const key = record.slice(0, equals), value = record.slice(equals + 1);
192
+ if (seen.has(key)) return invalid("duplicate PAX metadata key"); seen.add(key);
193
+ if (key === "linkpath" || key.startsWith("GNU.sparse.") || key === "SCHILY.filetype" || key === "SCHILY.realsize") return invalid("link, sparse or special PAX metadata is forbidden");
194
+ if (key === "path") { if (value) relative(value, true); values.path = value || null; }
195
+ if (key === "size") { if (value) decimal(value); values.size = value || null; }
196
+ offset = end;
197
+ }
198
+ if (body.type === "g") {
199
+ for (const key of ["path", "size"] as const) if (values[key] !== undefined) { if (values[key] === null) delete this.global[key]; else this.global[key] = values[key]; }
200
+ } else { this.next = { ...this.next, ...values }; this.pendingMetadata = true; }
201
+ }
202
+ }
203
+ finish(): ArtifactArchive {
204
+ if (this.body || this.padding || this.headerBytes || !this.ended || this.pendingMetadata) return invalid("truncated archive, missing end markers or dangling metadata");
205
+ return { files: this.files, ...(this.captured !== undefined ? { captured: this.captured } : {}) };
206
+ }
207
+ }
208
+
209
+ /** Hash regular artifacts, retaining no payload except an explicitly selected bounded capture. */
210
+ export async function readArtifactArchive(executor: Executor, command: string, args: string[], runner: ProcessRunner, options: ArtifactArchiveOptions = {}): Promise<ArtifactArchive> {
211
+ const capture = options.capture === undefined ? undefined : relative(options.capture), captureBytes = bound(options.captureBytes ?? ARTIFACT_CAPTURE_BYTES, ARTIFACT_CAPTURE_BYTES, "captureBytes");
212
+ const maxBytes = bound(options.maxBytes ?? ARTIFACT_BYTES, ARTIFACT_BYTES, "maxBytes"), maxFiles = bound(options.maxFiles ?? ARTIFACT_FILES, ARTIFACT_FILES, "maxFiles");
213
+ const parser = new TarArtifacts(capture, captureBytes, maxBytes, maxFiles), controller = new AbortController();
214
+ let parseError: ArtifactError | undefined;
215
+ try {
216
+ const result = await runOnExecutor(executor, command, args, runner, { signal: controller.signal, timeoutMs: 300_000,
217
+ stdoutSink: chunk => {
218
+ try { parser.push(chunk); }
219
+ catch (error) { parseError = error instanceof ArtifactError ? error : new ArtifactError("invalid-artifact", "malformed archive stream"); controller.abort(); throw parseError; }
220
+ } });
221
+ if (result.exitCode !== 0 || result.timedOut || result.truncated) throw new ArtifactError("artifact-read", "executor archive read failed");
222
+ return parser.finish();
223
+ } catch (error) {
224
+ controller.abort();
225
+ if (parseError) throw parseError;
226
+ if (error instanceof ArtifactError) throw error;
227
+ throw new ArtifactError("artifact-read", "executor archive read failed");
228
+ }
229
+ }