@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,116 @@
1
+ import { join, relative } from "node:path";
2
+ import { openStudy, plain, readLedger, readTextBounded, sha256 } from "../study.ts";
3
+ import { ATTEMPT_BYTES, listAttempts, type Attempt, type AttemptStatus } from "../exp/attempts.ts";
4
+ import { protocolHash, readProtocol } from "../exp/protocol.ts";
5
+ import { INVESTIGATE_BYTES, investigateResultPath, metricValue, readClosedInvestigation } from "../exp/investigate.ts";
6
+ import { ABLATE_BYTES, ablateResultPath, readClosedAblate } from "../exp/ablate.ts";
7
+ import { FINAL_RESULT_BYTES, finalResultPath, readClosedFinal, requirePosthocClosed } from "../exp/final.ts";
8
+
9
+ export const MANUSCRIPT_BYTES = 2 * 1024 * 1024, MAX_PLACEHOLDERS = 5000;
10
+ interface Position { start: number; end: number; line: number; column: number; raw: string }
11
+ export type Placeholder = Position & ({ kind: "number"; ref: string; format?: number } | { kind: "literal"; text: string });
12
+ export interface PlaceholderFinding extends Position { code: "bad-format" | "unresolved-ref"; message: string }
13
+ export interface ResolvedPlaceholder { placeholder: Placeholder; value: number | string; rendered: string; source?: { file: string; sha256: string } }
14
+ /** Offsets are UTF-16 string indices; line and column are 1-based, as used by the Markdown checker. */
15
+ export function parsePlaceholders(text: string): { placeholders: Placeholder[]; findings: PlaceholderFinding[] } {
16
+ if (Buffer.byteLength(text) > MANUSCRIPT_BYTES) throw new Error("paper manuscript: larger than 2 MiB; shorten paper/manuscript.md before checking or building");
17
+ const placeholders: Placeholder[] = [], findings: PlaceholderFinding[] = [];
18
+ const opening = /{{(?:n|lit):/g;
19
+ let match: RegExpExecArray | null, count = 0, scanned = 0, line = 1, column = 1;
20
+ while ((match = opening.exec(text))) {
21
+ const start = match.index, closing = text.indexOf("}}", opening.lastIndex), end = closing < 0 ? text.length : closing + 2;
22
+ while (scanned < start) { if (text[scanned++] === "\n") { line++; column = 1; } else column++; }
23
+ const at: Position = { start, end, line, column, raw: text.slice(start, end) };
24
+ if (++count > MAX_PLACEHOLDERS) { findings.push({ ...at, code: "bad-format", message: "paper placeholders: more than 5000; reduce the number of n/lit placeholders before building" }); break; }
25
+ const body = text.slice(opening.lastIndex, closing < 0 ? text.length : closing);
26
+ if (closing < 0 || body.includes("{{") || body.includes("}}")) {
27
+ findings.push({ ...at, code: "bad-format", message: "placeholder: missing closing braces or nested placeholder; use {{n:REF[|.Nf]}} or {{lit:TEXT}}" });
28
+ opening.lastIndex = end; continue;
29
+ }
30
+ opening.lastIndex = end;
31
+ if (match[0] === "{{lit:") {
32
+ if (!body.length) findings.push({ ...at, code: "bad-format", message: "literal placeholder: empty TEXT; supply the literal text for reviewer inspection" });
33
+ else placeholders.push({ ...at, kind: "literal", text: body });
34
+ continue;
35
+ }
36
+ // ponytail: metric names containing | or } cannot be represented by this grammar; rename them in a new protocol.
37
+ const parsed = /^([^{}|]+)(?:\|\.([0-6])f)?$/.exec(body);
38
+ if (!parsed || (/\s/.test(parsed[1]!) && !/^(attempt|posthoc):[a-f0-9]{16}\..+$/s.test(parsed[1]!))) findings.push({ ...at, code: "bad-format", message: "number placeholder: invalid ref or format; use {{n:REF}} or {{n:REF|.Nf}} with N from 0 to 6" });
39
+ else placeholders.push({ ...at, kind: "number", ref: parsed[1]!, ...(parsed[2] === undefined ? {} : { format: Number(parsed[2]) }) });
40
+ }
41
+ return { placeholders, findings };
42
+ }
43
+ function canonical(value: unknown): string {
44
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
45
+ if (plain(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
46
+ return JSON.stringify(value) ?? "null";
47
+ }
48
+ interface Source<T> { value: T; source: { file: string; sha256: string } }
49
+ function source<T>(dir: string, file: string, bound: number, value: T, expected?: string): Source<T> {
50
+ const text = readTextBounded(file, bound), hash = sha256(text);
51
+ if (expected === undefined && canonical(JSON.parse(text)) !== canonical(value)) throw new Error("source value changed during resolution");
52
+ if (expected !== undefined && hash !== expected) throw new Error("source bytes changed during resolution");
53
+ return { value, source: { file: relative(dir, file), sha256: hash } };
54
+ }
55
+ const statuses: readonly string[] = ["running", "succeeded", "failed", "cancelled", "unknown"];
56
+ function validValue(value: unknown): value is string | number { return typeof value === "string" ? value.length > 0 : typeof value === "number" && Number.isFinite(value); }
57
+ /** A per-call cache keeps repeated placeholders tied to the same checked source bytes. No writes, network or process execution. */
58
+ export function resolvePlaceholders(dirArg: string, text: string) {
59
+ const { dir, study } = openStudy(dirArg), parsed = parsePlaceholders(text), findings = [...parsed.findings], resolved: ResolvedPlaceholder[] = [], literals: Placeholder[] = [];
60
+ const cache = new Map<string, Source<unknown> | Error>();
61
+ const cached = <T>(key: string, read: () => Source<T>): Source<T> => {
62
+ if (!cache.has(key)) { try { cache.set(key, read()); } catch { cache.set(key, new Error("absent, unclosed, stale or invalid source")); } }
63
+ const value = cache.get(key)!; if (value instanceof Error) throw value; return value as Source<T>;
64
+ };
65
+ const investigate = () => cached("investigate", () => { const closed = readClosedInvestigation(dir); return source(dir, investigateResultPath(dir, closed.result.run), INVESTIGATE_BYTES, closed.result, closed.sha256); });
66
+ const ablate = () => cached("ablate", () => { const closed = readClosedAblate(dir); return source(dir, ablateResultPath(dir), ABLATE_BYTES, closed.result, closed.sha256); });
67
+ const final = () => cached("final", () => { const closed = readClosedFinal(dir); return source(dir, finalResultPath(dir), FINAL_RESULT_BYTES, closed.result, closed.sha256); });
68
+ let attemptIndex: Map<string, Attempt[]> | undefined;
69
+ const attempt = (id: string, posthoc: boolean): Source<Attempt> => cached(`attempt:${id}:${posthoc}`, () => {
70
+ if (!attemptIndex) {
71
+ attemptIndex = new Map();
72
+ for (const record of listAttempts(dir).attempts) { const same = attemptIndex.get(record.id) ?? []; same.push(record); attemptIndex.set(record.id, same); }
73
+ }
74
+ const found = attemptIndex.get(id) ?? [];
75
+ if (found.length !== 1) throw new Error("attempt missing or ambiguous");
76
+ const a = found[0]!, protocol = readProtocol(dir);
77
+ if (posthoc) requirePosthocClosed(dir);
78
+ if (a.studyId !== study.id || a.status !== "succeeded" || !protocol || a.protocolHash !== protocolHash(protocol) || a.protocolHash !== protocolHash(a.protocol) || (a.run === "posthoc") !== posthoc) throw new Error("attempt is not a current completed metric source");
79
+ if (!readLedger(dir).some(e => e.type === "attempt.started" && e.data.id === a.id && e.data.run === a.run && e.data.commit === a.commit && e.data.seed === a.seed)) throw new Error("attempt start receipt missing");
80
+ return source(dir, join(dir, "runs", a.run, "attempts", `${a.id}.json`), ATTEMPT_BYTES, a);
81
+ });
82
+ for (const placeholder of parsed.placeholders) {
83
+ if (placeholder.kind === "literal") { literals.push(placeholder); resolved.push({ placeholder, value: placeholder.text, rendered: placeholder.text }); continue; }
84
+ const ref = placeholder.ref;
85
+ try {
86
+ if (study.mode !== "experiment") throw new Error("experiment refs unavailable in survey");
87
+ let value: unknown, provenance: Source<unknown>, exploratory = false;
88
+ let match: RegExpExecArray | null;
89
+ if ((match = /^final\.(status|reason|delta|mean|baselineMean)$/.exec(ref))) { const read = final(); provenance = read; value = read.value[match[1] as "status"]; }
90
+ else if ((match = /^final\.attempts\.([a-z]+)$/.exec(ref)) && statuses.includes(match[1]!)) { const read = final(); provenance = read; value = read.value.attempts.filter(a => a.status === match![1]).length; }
91
+ else if ((match = /^investigate\.(winner|reason)$/.exec(ref))) { const read = investigate(); provenance = read; value = match[1] === "winner" ? read.value.winner?.commit.slice(0, 12) : read.value.reason; }
92
+ else if ((match = /^investigate\.winner\.(delta|mean)$/.exec(ref))) { const read = investigate(); provenance = read; value = read.value.winner?.[match[1] as "delta"]; }
93
+ else if (ref === "investigate.baseline.mean") { const read = investigate(); provenance = read; value = read.value.baseline.mean; }
94
+ else if ((match = /^(investigate|ablate)\.totals\.([a-z]+)$/.exec(ref)) && statuses.includes(match[2]!)) { const read = match[1] === "investigate" ? investigate() : ablate(); provenance = read; value = read.value.totals.byStatus[match[2] as AttemptStatus]; }
95
+ else if ((match = /^ablate:([A-Za-z0-9][A-Za-z0-9_-]{0,63})\.(delta|mean|referenceMean)$/.exec(ref))) { const read = ablate(); provenance = read; value = read.value.ablations.find(a => a.id === match![1])?.paired[match[2] as "delta"]; }
96
+ else if ((match = /^(attempt|posthoc):([a-f0-9]{16})\.(.+)$/.exec(ref))) {
97
+ exploratory = match[1] === "posthoc"; const read = attempt(match[2]!, exploratory); provenance = read;
98
+ const metric = read.value.protocol.metrics?.find(m => m.name === match![3]);
99
+ if (!metric) throw new Error("metric undeclared"); value = metricValue(read.value, metric);
100
+ } else throw new Error("unknown reference");
101
+ if (!validValue(value)) throw new Error("value unavailable or non-finite");
102
+ if (typeof value === "string" && placeholder.format !== undefined) {
103
+ findings.push({ ...placeholder, code: "bad-format", message: `placeholder ${ref}: text refs take no numeric format; remove |.Nf` }); continue;
104
+ }
105
+ const rendered = (typeof value === "number" && placeholder.format !== undefined ? value.toFixed(placeholder.format) : String(value)) + (exploratory ? " (exploratory)" : "");
106
+ resolved.push({ placeholder, value, rendered, source: provenance.source });
107
+ } catch {
108
+ findings.push({ ...placeholder, code: "unresolved-ref", message: `placeholder ${ref}: unknown ref or absent, unclosed, stale or invalid source; inspect the attempt and close the current phase results before retrying` });
109
+ }
110
+ }
111
+ const replacements = new Map(resolved.map(entry => [entry.placeholder.start, entry]));
112
+ let rendered = "", cursor = 0;
113
+ for (const placeholder of parsed.placeholders) { rendered += text.slice(cursor, placeholder.start) + (replacements.get(placeholder.start)?.rendered ?? placeholder.raw); cursor = placeholder.end; }
114
+ rendered += text.slice(cursor);
115
+ return { rendered, placeholders: resolved, findings: findings.sort((a, b) => a.start - b.start), literals };
116
+ }
@@ -0,0 +1,115 @@
1
+ import { boundedText, commit, onlyKeys, openStudy, plain, readLedger, sha256, type Actor, type LedgerEvent } from "../study.ts";
2
+ import { readCurrentBuild } from "./build.ts";
3
+ import { checkPaper } from "./check.ts";
4
+ export const REVIEW_KINDS = ["contradiction", "unsupported-claim", "number", "citation", "method", "clarity", "other"] as const;
5
+ export interface ReviewReport {
6
+ v: 1; reviewer: { id: string; modelFamily: string }; buildSha256: string;
7
+ checklist: { negativeResults: boolean; denominators: boolean; limitations: boolean; reproducibility: boolean };
8
+ findings: { id: string; kind: typeof REVIEW_KINDS[number]; location: string; text: string; blocking: boolean }[];
9
+ }
10
+ interface Resolution { reason: string; actor: Actor; at: string }
11
+ interface ReviewRecord { id: string; report: ReviewReport; actor: Actor; at: string; resolutions: Record<string, Resolution> }
12
+ function bad(message: string): never { throw new Error(`review: ${message}; inspect ares review show, correct the report or target, and retry`); }
13
+ function text(raw: unknown, field: string, max = 4000): string {
14
+ let value: string;
15
+ try { value = boundedText(raw, `review ${field}`, max); } catch { bad(`${field} must be nonblank text of at most ${max} bytes`); }
16
+ if (Buffer.byteLength(value) > max) bad(`${field} exceeds ${max} bytes`);
17
+ return value;
18
+ }
19
+ function identifier(raw: unknown, field: string): string { const value = text(raw, field, 128); if (value.includes("/")) bad(`${field} contains /; choose an id without slashes`); return value; }
20
+ function object(raw: unknown, keys: string[], field: string, strict: boolean): Record<string, unknown> {
21
+ if (!plain(raw)) bad(`${field} must be an object`);
22
+ if (strict) onlyKeys(raw, keys, `review ${field}`);
23
+ return raw;
24
+ }
25
+ export function validateReview(raw: unknown, strict = true): ReviewReport {
26
+ try {
27
+ const value = object(raw, ["v", "reviewer", "buildSha256", "checklist", "findings"], "report", strict);
28
+ if (value.v !== 1) throw new Error("review: unsupported report version; use an ares version that supports this report, keeping the original bytes unchanged");
29
+ const reviewer = object(value.reviewer, ["id", "modelFamily"], "reviewer", strict), checklist = object(value.checklist, ["negativeResults", "denominators", "limitations", "reproducibility"], "checklist", strict);
30
+ const buildSha256 = text(value.buildSha256, "buildSha256", 64);
31
+ if (!/^[a-f0-9]{64}$/.test(buildSha256)) bad("buildSha256 must be the 64-hex current evidence hash; run ares paper build");
32
+ for (const name of ["negativeResults", "denominators", "limitations", "reproducibility"]) if (typeof checklist[name] !== "boolean") bad(`checklist.${name} must be a boolean`);
33
+ if (!Array.isArray(value.findings) || value.findings.length > 200) bad("findings must be an array of at most 200 entries");
34
+ const ids = new Set<string>();
35
+ const findings = value.findings.map((raw, index) => {
36
+ const finding = object(raw, ["id", "kind", "location", "text", "blocking"], `finding[${index}]`, strict), id = identifier(finding.id, `finding[${index}].id`);
37
+ if (ids.has(id)) bad(`duplicate finding id ${id}`); ids.add(id);
38
+ if (!REVIEW_KINDS.includes(finding.kind as typeof REVIEW_KINDS[number])) bad(`finding ${id} has unknown kind; use ${REVIEW_KINDS.join(", ")}`);
39
+ if (typeof finding.blocking !== "boolean") bad(`finding ${id}.blocking must be a boolean`);
40
+ return { id, kind: finding.kind as typeof REVIEW_KINDS[number], location: text(finding.location, `finding ${id}.location`), text: text(finding.text, `finding ${id}.text`), blocking: finding.kind === "contradiction" || finding.blocking };
41
+ });
42
+ return { v: 1, reviewer: { id: text(reviewer.id, "reviewer.id", 128), modelFamily: text(reviewer.modelFamily, "reviewer.modelFamily", 128).trim().toLowerCase() }, buildSha256, checklist: { negativeResults: checklist.negativeResults as boolean, denominators: checklist.denominators as boolean, limitations: checklist.limitations as boolean, reproducibility: checklist.reproducibility as boolean }, findings };
43
+ } catch (error) {
44
+ const message = error instanceof Error ? error.message : "invalid report";
45
+ if (message.includes("; inspect ares review show") || message.includes("supports this report")) throw error;
46
+ bad(`${message}; correct the input before ares review add --file PATH`);
47
+ }
48
+ }
49
+ export function foldReviews(events: LedgerEvent[]): ReviewRecord[] {
50
+ const rows: ReviewRecord[] = [], counts = new Map<string, number>();
51
+ for (const event of events) {
52
+ if (event.type === "review.added") {
53
+ const id = identifier(event.data.id, "stored review id"), report = validateReview(event.data.review, false), count = (counts.get(report.buildSha256) ?? 0) + 1;
54
+ if (count > 100 || rows.some(row => row.id === id)) bad("review journal has duplicate ids or more than 100 reviews for one build; restore its last valid version");
55
+ counts.set(report.buildSha256, count); rows.push({ id, report, actor: event.actor, at: event.at, resolutions: Object.create(null) as Record<string, Resolution> });
56
+ } else if (event.type === "review.resolved") {
57
+ const row = rows.find(row => row.id === event.data.reviewId), finding = row?.report.findings.find(f => f.id === event.data.findingId);
58
+ if (!row || !finding || event.data.buildSha256 !== row.report.buildSha256 || row.resolutions[finding.id]) bad("resolution journal references a missing or already resolved finding; restore its last valid version");
59
+ row.resolutions[finding.id] = { reason: text(event.data.reason, "stored resolution reason"), actor: event.actor, at: event.at };
60
+ }
61
+ }
62
+ return rows;
63
+ }
64
+ /** Caller holds the study lock; report ids are generated, findings never disappear by vote. */
65
+ export function addReview(dirArg: string, raw: unknown, actor: Actor) {
66
+ const { dir } = openStudy(dirArg), report = validateReview(raw), current = readCurrentBuild(dir), events = readLedger(dir), rows = foldReviews(events);
67
+ if (report.buildSha256 !== current.evidenceSha256) bad("stale buildSha256; use the evidenceSha256 from the current ares paper build output");
68
+ if (rows.filter(row => row.report.buildSha256 === report.buildSha256).length >= 100) bad("100 reviews already recorded for this build; resolve existing findings or build a revised manuscript");
69
+ const id = sha256(JSON.stringify([report, events.length])).slice(0, 16);
70
+ if (rows.some(row => row.id === id)) bad("review id collision; inspect the journal before retrying");
71
+ // ponytail: the ledger's existing 64 KiB event limit is stricter than individual text/count limits combined.
72
+ if (Buffer.byteLength(JSON.stringify(report)) > 60 * 1024) bad("combined report exceeds the 60 KiB review payload ceiling; shorten texts or submit fewer findings per report");
73
+ commit(dir, [], [{ actor, type: "review.added", data: { id, review: report } }]); return { id, ...report };
74
+ }
75
+ export function resolveReview(dirArg: string, target: string, reason: string, actor: Actor) {
76
+ const { dir } = openStudy(dirArg), parts = target.split("/");
77
+ if (parts.length !== 2) bad("resolution target must be <review-id>/<finding-id>; use ids from ares review show");
78
+ const reviewId = identifier(parts[0], "review id"), findingId = identifier(parts[1], "finding id"), why = text(reason, "resolution reason"), row = foldReviews(readLedger(dir)).find(row => row.id === reviewId);
79
+ if (!row) bad(`unknown review ${reviewId}`);
80
+ if (!row.report.findings.some(f => f.id === findingId)) bad(`unknown finding ${reviewId}/${findingId}`);
81
+ if (row.resolutions[findingId]) bad(`finding ${reviewId}/${findingId} is already resolved`);
82
+ commit(dir, [], [{ actor, type: "review.resolved", data: { reviewId, findingId, buildSha256: row.report.buildSha256, reason: why } }]);
83
+ return { reviewId, findingId, reason: why, actor };
84
+ }
85
+ function canonical(raw: unknown): string {
86
+ if (Array.isArray(raw)) return `[${raw.map(canonical).join(",")}]`;
87
+ if (plain(raw)) return `{${Object.keys(raw).sort().map(key => `${JSON.stringify(key)}:${canonical(raw[key])}`).join(",")}}`;
88
+ return JSON.stringify(raw) ?? "null";
89
+ }
90
+ function binding(current: { manuscriptSha256: string; evidenceSha256: string }, events: LedgerEvent[], rows: ReviewRecord[]): string {
91
+ const selected = rows.filter(row => row.report.buildSha256 === current.evidenceSha256), ids = new Set(selected.map(row => row.id));
92
+ const added = events.filter(e => e.type === "review.added" && ids.has(e.data.id as string)).map(e => sha256(canonical(e))).sort();
93
+ const resolutions = events.filter(e => e.type === "review.resolved" && ids.has(e.data.reviewId as string)).map(e => sha256(canonical(e))).sort();
94
+ return sha256(JSON.stringify([current.manuscriptSha256, current.evidenceSha256, added, resolutions]));
95
+ }
96
+ export function reviewBinding(dirArg: string): string | undefined {
97
+ try { const { dir } = openStudy(dirArg), current = readCurrentBuild(dir), events = readLedger(dir); return binding(current, events, foldReviews(events)); } catch { return undefined; }
98
+ }
99
+ export function reviewReadiness(dirArg: string) {
100
+ const { dir } = openStudy(dirArg), events = readLedger(dir), unmet: { code: string; message: string }[] = [];
101
+ let current: ReturnType<typeof readCurrentBuild> | undefined, check: ReturnType<typeof checkPaper> | undefined;
102
+ try { current = readCurrentBuild(dir); check = current.check; } catch (error) { unmet.push({ code: "build", message: error instanceof Error ? error.message : "current build unavailable; run ares paper build" }); }
103
+ if (!check) { try { check = checkPaper(dir); } catch (error) { unmet.push({ code: "paper-check", message: error instanceof Error ? error.message : "paper check unavailable; restore paper/manuscript.md" }); } }
104
+ if (check && !check.ok) unmet.push({ code: "paper-check", message: `paper check has ${check.findings.length} finding(s); run ares paper check --json and fix every finding` });
105
+ let rows: ReviewRecord[] = [];
106
+ try { rows = foldReviews(events); } catch (error) { unmet.push({ code: "review-journal", message: error instanceof Error ? error.message : "review journal invalid; restore its valid events" }); }
107
+ const buildSha256 = current?.evidenceSha256, reviews = rows.filter(row => row.report.buildSha256 === buildSha256), families = [...new Set(reviews.map(row => row.report.reviewer.modelFamily))].sort();
108
+ if (families.length < 2) unmet.push({ code: "model-families", message: "fewer than two distinct model families reviewed the current evidence hash; add current-build reviews from two families" });
109
+ const openBlocking = reviews.flatMap(row => row.report.findings.filter(f => f.blocking && !row.resolutions[f.id]).map(f => ({ reviewId: row.id, findingId: f.id, kind: f.kind, location: f.location, text: f.text })));
110
+ if (openBlocking.length) unmet.push({ code: "blocking-findings", message: `open blocking findings: ${openBlocking.map(f => `${f.reviewId}/${f.findingId} (${f.kind})`).join(", ")}; resolve each with a reason using ares review resolve` });
111
+ return { ready: unmet.length === 0, buildSha256: buildSha256 ?? null, ...(current ? { manuscriptSha256: current.manuscriptSha256 } : {}), check: check ?? null, reviews, families, openBlocking, checklists: reviews.map(row => ({ reviewId: row.id, ...row.report.checklist })), claims: "unavailable (STAIxBWLB/agent-hub#931)", unmet };
112
+ }
113
+ export function requireReviewReady(dir: string): void {
114
+ const report = reviewReadiness(dir); if (!report.ready) throw new Error(`gate review: ${report.unmet.map(item => `${item.code}: ${item.message}`).join("; ")}; inspect ares review show before approval`);
115
+ }
@@ -0,0 +1,192 @@
1
+ import { boundHubTask, type HubClient, type HubObservation } from "./hub.ts";
2
+ import { assertPlanOwner, commit, openStudy, readLedger, requireGate, withLock, withPlanLock, StudyLockBusyError, type Actor, type PendingEvent, type PlanOwner } from "../study.ts";
3
+ import { canonical, nextDispatchId, planGateBinding, readPlanState, type NodeState, type PlanState } from "./state.ts";
4
+ import type { PlanNode } from "./definition.ts";
5
+
6
+ export type StepNode = Extract<PlanNode, { kind: "step" }>;
7
+ export type HubNode = Extract<PlanNode, { kind: "hub" }>;
8
+ export interface Observation { status: "dispatched" | "done" | "failed" | "cancelled" | "uncertain"; output?: unknown; reason?: string; progress?: string; taskId?: number; hubProject?: string }
9
+ export interface ControllerRuntime {
10
+ actor: Actor;
11
+ hub?: HubClient;
12
+ signal?: AbortSignal;
13
+ sleep?: (ms: number) => Promise<void>;
14
+ clock?: () => number;
15
+ prepare: (node: StepNode, state: NodeState) => Promise<{ attemptId?: string }>;
16
+ execute: (node: StepNode, state: NodeState) => Promise<Observation>;
17
+ reconcile: (node: StepNode, state: NodeState) => Promise<Observation>;
18
+ }
19
+ export interface ControllerOptions { parallel?: number }
20
+ const delay = (ms: number, signal?: AbortSignal): Promise<void> => new Promise(resolve => {
21
+ if (signal?.aborted) { resolve(); return; }
22
+ const finish = () => { clearTimeout(timer); signal?.removeEventListener("abort", finish); resolve(); };
23
+ const timer = setTimeout(finish, ms); signal?.addEventListener("abort", finish, { once: true });
24
+ });
25
+ async function tryTransaction<T>(dir: string, fn: () => T): Promise<{ value: T } | undefined> {
26
+ try { return { value: await withLock(dir, fn) }; }
27
+ catch (error) { if (error instanceof StudyLockBusyError) return undefined; throw error; }
28
+ }
29
+ async function transaction<T>(dir: string, fn: () => T): Promise<T> {
30
+ let wait = 50;
31
+ for (;;) {
32
+ const result = await tryTransaction(dir, fn);
33
+ if (result) return result.value;
34
+ await delay(wait); wait = Math.min(wait * 2, 1000);
35
+ }
36
+ }
37
+ /** One controller, short transactional study locks, and no replay of unobserved actions. */
38
+ export async function runPlan(dirArg: string, options: ControllerOptions, runtime: ControllerRuntime): Promise<PlanState> {
39
+ const { dir, study } = openStudy(dirArg), parallel = options.parallel ?? 1;
40
+ if (!Number.isSafeInteger(parallel) || parallel < 1 || parallel > 8) throw new Error("run --parallel: integer in 1..8 required; run ares run <dir> --parallel 1..8");
41
+ return withPlanLock(dir, async owner => {
42
+ const state = () => readPlanState(dir, readLedger(dir));
43
+ const clock = runtime.clock ?? (() => performance.now());
44
+ const sleep = (ms: number) => runtime.sleep ? runtime.sleep(ms) : delay(ms, runtime.signal);
45
+ const polls = new Map<string, { at: number; wait: number; signature: string }>();
46
+ const schedule = (node: NodeState, observation: Observation) => {
47
+ if (!node.dispatchId) return;
48
+ if (observation.status !== "dispatched") { polls.delete(node.dispatchId); return; }
49
+ const previous = polls.get(node.dispatchId), signature = canonical(observation);
50
+ const wait = previous?.signature === signature ? Math.min(previous.wait * 2, 30_000) : 1000;
51
+ polls.set(node.dispatchId, { at: clock() + wait, wait, signature });
52
+ };
53
+ const locked = <T>(fn: () => T) => transaction(dir, () => { assertPlanOwner(dir, owner); return fn(); });
54
+ const observe = async (node: NodeState, observation: Observation) => {
55
+ const target = state().nodes.find(n => n.id === node.id);
56
+ if (!target || target.dispatchId !== node.dispatchId || !["dispatched", "uncertain"].includes(target.status)) return;
57
+ if (observation.status === "dispatched" && target.lastObserved?.dispatchId === node.dispatchId && target.reason === observation.reason && (observation.taskId === undefined || target.taskId === observation.taskId) && (observation.hubProject === undefined || target.hubProject === observation.hubProject)) return;
58
+ return locked(() => {
59
+ const current = state(), target = current.nodes.find(n => n.id === node.id);
60
+ if (!target || target.dispatchId !== node.dispatchId || !["dispatched", "uncertain"].includes(target.status)) return;
61
+ if (observation.status === "dispatched" && target.status === "dispatched" && target.lastObserved && target.lastObserved.dispatchId === node.dispatchId && target.lastObserved.status === "dispatched" && target.reason === observation.reason && (observation.taskId === undefined || target.taskId === observation.taskId) && (observation.hubProject === undefined || target.hubProject === observation.hubProject)) return;
62
+ commit(dir, [], [{ actor: runtime.actor, type: "node.observed", data: { revision: current.revision, node: node.id, dispatchId: node.dispatchId, status: observation.status, ...(observation.output !== undefined ? { output: observation.output } : {}), ...(observation.reason !== undefined ? { reason: observation.reason } : {}), ...(observation.taskId !== undefined ? { taskId: observation.taskId } : {}), ...(observation.hubProject !== undefined ? { hubProject: observation.hubProject } : {}) } }]);
63
+ });
64
+ };
65
+ const reconcile = async (node: NodeState): Promise<void> => {
66
+ const definition = state().definition.nodes.find(n => n.id === node.id);
67
+ if (!definition || (definition.kind === "hub" && !runtime.hub)) return;
68
+ let observation: Observation;
69
+ if (definition.kind === "hub") {
70
+ try { observation = await runtime.hub!.observe(definition.campaign, definition.unit, node.taskId ?? boundHubTask(readLedger(dir), state().revision, node.id)); }
71
+ catch { observation = { status: "dispatched", reason: "hub read unavailable; check ahub study show and restore hub access" }; }
72
+ } else if (definition.kind !== "step" || definition.command !== "attempt.run") observation = { status: "uncertain", reason: "step result unavailable after interruption; explicitly retry or skip this node" };
73
+ else {
74
+ try { observation = await runtime.reconcile(definition, node); }
75
+ catch (error) { if (error instanceof StudyLockBusyError) { schedule(node, { status: "dispatched", reason: node.reason }); return; } observation = { status: "dispatched", reason: "attempt observation unavailable; check the executor and run ares attempt reconcile" }; }
76
+ }
77
+ await observe(node, observation); schedule(node, observation);
78
+ };
79
+ await locked(() => {
80
+ const current = state();
81
+ if (current.state === "running") commit(dir, [], [{ actor: runtime.actor, type: "plan.paused", data: { revision: current.revision, reason: "controller startup restores paused" } }]);
82
+ });
83
+ for (const node of state().nodes.filter(n => n.status === "dispatched")) await reconcile(node);
84
+ const active = new Map<string, Promise<void>>();
85
+ const launch = (definition: StepNode, node: NodeState) => {
86
+ const work = (async () => {
87
+ let observation: Observation;
88
+ let wait = 1000;
89
+ for (;;) {
90
+ const current = state().nodes.find(n => n.id === node.id);
91
+ if (runtime.signal?.aborted || !current || current.dispatchId !== node.dispatchId || current.status !== "dispatched") return;
92
+ assertPlanOwner(dir, owner);
93
+ try { observation = await runtime.execute(definition, node); break; }
94
+ catch (error) {
95
+ if (error instanceof StudyLockBusyError) { await sleep(wait); wait = Math.min(wait * 2, 30_000); continue; }
96
+ if (definition.command === "attempt.run") { await reconcile(node); return; }
97
+ observation = { status: "uncertain", reason: "step execution interrupted or failed without an observable result; inspect inputs, then retry or skip this node" }; break;
98
+ }
99
+ }
100
+ await observe(node, observation); schedule(node, observation);
101
+ })();
102
+ active.set(node.id, work);
103
+ // The main loop awaits every task; failures are consumed immediately to prevent unhandled rejection.
104
+ void work.catch(() => {});
105
+ };
106
+ let failure: unknown, wait = 500, fingerprint = canonical(state());
107
+ try {
108
+ while (!runtime.signal?.aborted) {
109
+ let current = state();
110
+ if (current.state === "done") break;
111
+ if (current.state === "running") {
112
+ // Pending auto gates are approved only inside a successful controller transaction.
113
+ if (study.autonomy === "auto" && current.nodes.some(n => n.kind === "gate" && n.status === "pending" && n.gate?.state === "pending")) await tryTransaction(dir, () => {
114
+ assertPlanOwner(dir, owner);
115
+ const events = readLedger(dir), value = readPlanState(dir, events), approvals: PendingEvent[] = [];
116
+ if (value.state !== "running") return;
117
+ for (const gate of value.nodes.filter(n => n.kind === "gate" && n.status === "pending" && n.gate?.state === "pending")) {
118
+ if (study.autonomy === "auto") approvals.push(...requireGate(dir, study, `plan:${gate.id}`, name => planGateBinding(value, name)));
119
+ }
120
+ if (approvals.length) commit(dir, [], [...approvals, { actor: runtime.actor, type: "plan.gates", data: { revision: value.revision } }]);
121
+ });
122
+ current = state();
123
+ for (const node of current.nodes) {
124
+ if (runtime.signal?.aborted || current.state !== "running") break;
125
+ const definition = current.definition.nodes.find(n => n.id === node.id)!;
126
+ if (node.status !== "ready" || active.has(node.id)) continue;
127
+ if (definition.kind === "hub" && runtime.hub) {
128
+ const binding = boundHubTask(readLedger(dir), current.revision, node.id);
129
+ let result: HubObservation;
130
+ try { result = await runtime.hub.observe(definition.campaign, definition.unit, binding); }
131
+ catch { result = { status: "dispatched", reason: "hub read unavailable; check ahub study show and restore hub access" }; }
132
+ const intent = await admit(dir, owner, runtime.actor, current, node, definition, { ...(result.taskId !== undefined ? { taskId: result.taskId } : {}), ...(result.hubProject !== undefined ? { hubProject: result.hubProject } : {}) }, parallel);
133
+ if (intent) { await observe(intent, result); schedule(intent, result); }
134
+ current = state(); continue;
135
+ }
136
+ if (definition.kind !== "step") continue;
137
+ const slots = stepSlots(current);
138
+ if (slots >= parallel) continue;
139
+ // A2 permits one active experiment attempt per study.
140
+ if (definition.command === "attempt.run" && current.nodes.some(n => n.status === "dispatched" && current.definition.nodes.find(d => d.id === n.id)?.kind === "step" && (current.definition.nodes.find(d => d.id === n.id) as StepNode).command === "attempt.run")) continue;
141
+ let prepared: { attemptId?: string };
142
+ try { prepared = await runtime.prepare(definition, node); }
143
+ catch { // Preparation is read-only; still record a correlated failed dispatch for visibility.
144
+ prepared = {};
145
+ const intent = await admit(dir, owner, runtime.actor, current, node, definition, prepared, parallel);
146
+ if (intent) await observe(intent, { status: "failed", reason: "step preparation failed; check command inputs and retry this node" });
147
+ current = state(); continue;
148
+ }
149
+ const intent = await admit(dir, owner, runtime.actor, current, node, definition, prepared, parallel);
150
+ if (intent) launch(definition, intent);
151
+ current = state();
152
+ }
153
+ }
154
+ // Detached attempts are observed while paused too, without admitting new work.
155
+ for (const node of state().nodes.filter(n => n.status === "dispatched" && !active.has(n.id))) {
156
+ if (clock() >= (polls.get(node.dispatchId!)?.at ?? 0)) await reconcile(node);
157
+ }
158
+ if (state().state === "done") break;
159
+ const next = canonical(state());
160
+ wait = next === fingerprint ? Math.min(wait * 2, 30_000) : 1000; fingerprint = next;
161
+ const live = new Set(state().nodes.filter(n => n.status === "dispatched").map(n => n.dispatchId));
162
+ for (const id of polls.keys()) if (!live.has(id)) polls.delete(id);
163
+ const due = [...polls.values()].map(p => Math.max(1, p.at - clock()));
164
+ const interval = Math.min(wait, ...(due.length ? due : [30_000]));
165
+ if (active.size) {
166
+ const settled = await Promise.race([...active].map(async ([id, work]) => { await work; return id; }).concat([sleep(interval).then(() => "")]));
167
+ if (settled) { await active.get(settled); active.delete(settled); }
168
+ } else await sleep(interval);
169
+ }
170
+ } catch (error) { failure = error; }
171
+ // Pause stops admission, drains this process's command calls, and leaves detached attempts alive.
172
+ await Promise.allSettled(active.values());
173
+ await locked(() => { const current = state(); if (current.state === "running") commit(dir, [], [{ actor: runtime.actor, type: "plan.paused", data: { revision: current.revision, reason: "controller stopped" } }]); });
174
+ if (failure) throw failure;
175
+ return state();
176
+ });
177
+ }
178
+ async function admit(dir: string, owner: PlanOwner, actor: Actor, snapshot: PlanState, node: NodeState, definition: StepNode | HubNode, prepared: { attemptId?: string; taskId?: number; hubProject?: string }, parallel: number): Promise<NodeState | undefined> {
179
+ const result = await tryTransaction(dir, () => {
180
+ assertPlanOwner(dir, owner);
181
+ const current = readPlanState(dir, readLedger(dir)), target = current.nodes.find(n => n.id === node.id);
182
+ if (current.state !== "running" || current.revision !== snapshot.revision || target?.status !== "ready" || canonical(current.definition.nodes.find(n => n.id === node.id)) !== canonical(definition) || (definition.kind === "step" && stepSlots(current) >= parallel)) return undefined;
183
+ const dispatchId = nextDispatchId(current, node.id);
184
+ commit(dir, [], [{ actor, type: "node.intent", data: { revision: current.revision, node: node.id, dispatchId, ...prepared } }]);
185
+ return readPlanState(dir, readLedger(dir)).nodes.find(n => n.id === node.id)!;
186
+ });
187
+ return result?.value;
188
+ }
189
+
190
+ function stepSlots(state: PlanState): number {
191
+ return state.nodes.filter(n => n.kind === "step" && n.status === "dispatched").length;
192
+ }
@@ -0,0 +1,156 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { COMMANDS } from "../commands-table.ts";
4
+ import { boundedText, commit, onlyKeys, plain, readJsonFile, sha256, type Actor } from "../study.ts";
5
+
6
+ export const PLAN_BYTES = 65_536, PLAN_ID_BYTES = 128;
7
+ export const STEP_COMMANDS = ["lit.search", "lit.verify", "lit.bib", "cite.check", "attempt.run", "investigate.close", "ablate.close", "final.close", "paper.build", "watch.run"] as const;
8
+ export type StepCommand = typeof STEP_COMMANDS[number];
9
+ export type StepArgs = Record<string, string | number | boolean | string[]>;
10
+ type BaseNode = { id: string; needs: string[] };
11
+ export type PlanNode = BaseNode & (
12
+ { kind: "step"; command: StepCommand; args: StepArgs } |
13
+ { kind: "hub"; campaign: string; unit: string } |
14
+ { kind: "gate"; binds: string[] }
15
+ );
16
+ export interface PlanDefinition { v: 1; revision: number; nodes: PlanNode[]; edges: [string, string][] }
17
+ const retry = "correct the definition and run ares plan set again";
18
+ const compatible = "use an ares version that supports this plan, keeping plan.json unchanged";
19
+ function nodeId(raw: unknown, what = "plan node id"): string {
20
+ const id = boundedText(raw, what, PLAN_ID_BYTES);
21
+ if (Buffer.byteLength(id) > PLAN_ID_BYTES) throw new Error(`${what}: at most ${PLAN_ID_BYTES} bytes`);
22
+ return id;
23
+ }
24
+ function references(raw: unknown, name: string): string[] {
25
+ if (!Array.isArray(raw) || raw.length > 128) throw new Error(`${name}: at most 128 node ids required`);
26
+ return raw.map((id, i) => nodeId(id, `${name}[${i}]`));
27
+ }
28
+ function stepArgs(raw: unknown, command: StepCommand, id: string): StepArgs {
29
+ const where = `plan step ${id} (${command})`;
30
+ if (!plain(raw)) throw new Error(`${where}.args: object required`);
31
+ const spec = COMMANDS.find(c => c.name === command.replace(".", " "))!;
32
+ const args: StepArgs = {};
33
+ for (const [key, value] of Object.entries(raw)) {
34
+ const field = `${where}.args.${key}`;
35
+ if (key === "json" || (command === "attempt.run" && key === "detach")) throw new Error(`${field}: controller-owned option; remove ${key} and run ares plan set again`);
36
+ if (command === "lit.search" && key === "query") {
37
+ const query = boundedText(value, field, 1000);
38
+ if (Buffer.byteLength(query) > 1000) throw new Error(`${field}: at most 1000 bytes`);
39
+ args[key] = query; continue;
40
+ }
41
+ if (command === "cite.check" && key === "draft") {
42
+ args[key] = boundedText(value, field, 4096); continue;
43
+ }
44
+ if (command === "lit.verify" && key === "ids") {
45
+ if (!Array.isArray(value) || value.length > 500) throw new Error(`${field}: array of at most 500 candidate ids required`);
46
+ args[key] = value.map((item, i) => boundedText(item, `${field}[${i}]`, 300)); continue;
47
+ }
48
+ const option = Object.hasOwn(spec.options, `--${key}`) ? spec.options[`--${key}`] : undefined;
49
+ if (!option) throw new Error(`${field}: unknown option ${key}`);
50
+ if (option === "flag" ? typeof value !== "boolean" : !["string", "number"].includes(typeof value)) throw new Error(`${field}: ${option === "flag" ? "boolean flag" : "string or number value"} required`);
51
+ if (typeof value === "number" && !Number.isFinite(value)) throw new Error(`${field}: finite value required`);
52
+ if (typeof value === "string") boundedText(value, field, 4096);
53
+ args[key] = value as string | number | boolean;
54
+ }
55
+ const required = command === "attempt.run" ? ["repo", "commit"] : command === "lit.search" ? ["query"] : command === "cite.check" ? ["draft"] : [];
56
+ for (const key of required) if (!Object.hasOwn(args, key)) throw new Error(`${where}: required option ${key}; add args.${key} and run ares plan set again`);
57
+ return args;
58
+ }
59
+ export function validatePlan(raw: unknown): PlanDefinition {
60
+ try { return validateDefinition(raw); }
61
+ catch (error) {
62
+ const message = error instanceof Error ? error.message : "plan: invalid definition";
63
+ throw new Error(message.includes(compatible) || message.includes("run ares plan set again") ? message : `${message}; ${retry}`);
64
+ }
65
+ }
66
+ function validateDefinition(raw: unknown): PlanDefinition {
67
+ if (!plain(raw)) throw new Error("plan: object required");
68
+ if (raw.v !== 1) throw new Error(`plan: unsupported v ${String(raw.v)} (expected 1); ${compatible}`);
69
+ onlyKeys(raw, ["v", "revision", "nodes", "edges"], "plan");
70
+ const revision = raw.revision ?? 1;
71
+ if (!Number.isSafeInteger(revision) || (revision as number) < 1) throw new Error("plan: revision must be a positive safe integer");
72
+ if (!Array.isArray(raw.nodes) || raw.nodes.length > 128) throw new Error("plan: at most 128 nodes required");
73
+ if (!Array.isArray(raw.edges) || raw.edges.length > 512) throw new Error("plan: at most 512 edges required");
74
+ const nodes: PlanNode[] = raw.nodes.map((n: unknown, index: number) => {
75
+ if (!plain(n)) throw new Error(`plan node[${index}]: object required`);
76
+ const id = nodeId(n.id, `plan node[${index}].id`), needs = n.needs === undefined ? [] : references(n.needs, `plan node ${String(n.id)}.needs`);
77
+ const base = { id, needs };
78
+ switch (n.kind) {
79
+ case "step": {
80
+ onlyKeys(n, ["id", "kind", "needs", "command", "args"], `plan step ${id}`);
81
+ if (!STEP_COMMANDS.includes(n.command as StepCommand)) throw new Error(`plan step ${id}: unknown command ${String(n.command)} (allowed: ${STEP_COMMANDS.join(", ")})`);
82
+ const command = n.command as StepCommand;
83
+ return { ...base, kind: "step", command, args: stepArgs(n.args ?? {}, command, id) };
84
+ }
85
+ case "hub": {
86
+ onlyKeys(n, ["id", "kind", "needs", "campaign", "unit"], `plan hub ${id}`);
87
+ return { ...base, kind: "hub", campaign: nodeId(n.campaign, `plan hub ${id}.campaign`), unit: nodeId(n.unit, `plan hub ${id}.unit`) };
88
+ }
89
+ case "gate":
90
+ onlyKeys(n, ["id", "kind", "needs", "binds"], `plan gate ${id}`);
91
+ return { ...base, kind: "gate", binds: references(n.binds, `plan gate ${id}.binds`) };
92
+ default: throw new Error(`plan node ${id}: unknown node kind ${String(n.kind)}; ${compatible}`);
93
+ }
94
+ });
95
+ const ids = new Set(nodes.map(n => n.id));
96
+ const seen = new Set<string>();
97
+ for (const n of nodes) {
98
+ if (seen.has(n.id)) throw new Error(`plan: duplicate node id ${n.id}; rename the duplicate node and update its references`);
99
+ seen.add(n.id);
100
+ }
101
+ const edges: [string, string][] = raw.edges.map((e: unknown, index: number) => {
102
+ if (!Array.isArray(e) || e.length !== 2) throw new Error(`plan edge[${index}]: pair required`);
103
+ return [nodeId(e[0], `plan edge[${index}].from`), nodeId(e[1], `plan edge[${index}].to`)];
104
+ });
105
+ const parents = new Map(nodes.map(n => [n.id, new Set(n.needs)]));
106
+ for (const [from, to] of edges) {
107
+ if (!ids.has(from) || !ids.has(to)) throw new Error(`plan edge [${from}, ${to}]: missing endpoint, no node ${!ids.has(from) ? from : to}; add that node or remove this edge`);
108
+ parents.get(to)!.add(from);
109
+ }
110
+ for (const n of nodes) for (const p of n.needs) if (!ids.has(p)) throw new Error(`plan node ${n.id}.needs: missing endpoint, no node ${p}; add that node or remove this dependency`);
111
+ const visiting = new Set<string>(), visited = new Set<string>();
112
+ const stack: string[] = [];
113
+ function visit(id: string): void {
114
+ if (visiting.has(id)) throw new Error(`plan: cycle through ${[...stack.slice(stack.indexOf(id)), id].reverse().join(" -> ")}; remove one dependency and run ares plan set again`);
115
+ if (visited.has(id)) return;
116
+ visiting.add(id); stack.push(id);
117
+ for (const parent of parents.get(id)!) visit(parent);
118
+ stack.pop(); visiting.delete(id); visited.add(id);
119
+ }
120
+ for (const n of nodes) visit(n.id);
121
+ const ancestorCache = new Map<string, Set<string>>();
122
+ function ancestors(id: string): Set<string> {
123
+ const cached = ancestorCache.get(id);
124
+ if (cached) return cached;
125
+ const result = new Set<string>();
126
+ for (const parent of parents.get(id)!) {
127
+ result.add(parent);
128
+ for (const ancestor of ancestors(parent)) result.add(ancestor);
129
+ }
130
+ ancestorCache.set(id, result);
131
+ return result;
132
+ }
133
+ for (const n of nodes) if (n.kind === "gate") for (const bound of n.binds) {
134
+ if (!ids.has(bound) || !ancestors(n.id).has(bound)) throw new Error(`plan gate ${n.id} binds ${bound}, which is not upstream; add an upstream dependency or remove this binding`);
135
+ }
136
+ const result: PlanDefinition = { v: 1, revision: revision as number, nodes, edges };
137
+ if (Buffer.byteLength(JSON.stringify(result, null, 2) + "\n") > PLAN_BYTES) throw new Error(`plan: exceeds ${PLAN_BYTES} bytes`);
138
+ return result;
139
+ }
140
+ export function readPlan(dir: string): PlanDefinition | undefined {
141
+ const file = join(dir, "plan.json");
142
+ if (!existsSync(file)) return undefined;
143
+ const raw: unknown = readJsonFile(file, PLAN_BYTES);
144
+ const definition = validatePlan(raw);
145
+ if (!plain(raw) || raw.revision === undefined) throw new Error(`plan.json: stored revision required; restore a revisioned definition before running ares plan set again`);
146
+ return definition;
147
+ }
148
+ /** Caller holds the study lock; definition and initial state share one transaction. */
149
+ export function setPlan(dir: string, raw: unknown, actor: Actor) {
150
+ const definition = validatePlan(raw), previous = readPlan(dir);
151
+ definition.revision = (previous?.revision ?? 0) + 1;
152
+ if (!Number.isSafeInteger(definition.revision)) throw new Error("plan.revision: exhausted; create a new study before setting another plan");
153
+ const text = JSON.stringify(definition, null, 2) + "\n";
154
+ commit(dir, [[join(dir, "plan.json"), text, PLAN_BYTES]], [{ actor, type: "plan.set", data: { revision: definition.revision, definition, state: "paused", sha256: sha256(text) } }]);
155
+ return { definition, state: "paused" as const };
156
+ }