@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,110 @@
1
+ import { HttpError, type Http } from "../http.ts";
2
+ import { normTitle, verifySubject, type Candidate, type Registry, type Verification } from "./candidates.ts";
3
+ import { parseArxiv } from "./sources.ts";
4
+
5
+ export const DOI = /^10\.\d{4,9}\/\S+$/;
6
+ export const ARXIV_NEW = /^\d{4}\.\d{4,5}$/;
7
+ export const ARXIV_OLD = /^[a-z][a-z-]*(\.[a-z]{2})?\/\d{7}$/i;
8
+ const ARXIV_BATCH = 50;
9
+
10
+ const record = (v: unknown): Record<string, unknown> => (v && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : {});
11
+ const str = (v: unknown): string | undefined => (typeof v === "string" && v.trim() ? v.trim() : undefined);
12
+
13
+ export function syntax(c: Pick<Candidate, "doi" | "arxivId">): Verification["syntax"] {
14
+ if (c.doi) return DOI.test(c.doi) ? "ok" : "bad";
15
+ if (c.arxivId) return ARXIV_NEW.test(c.arxivId) || ARXIV_OLD.test(c.arxivId) ? "ok" : "bad";
16
+ return "none";
17
+ }
18
+
19
+ function tokens(title: string): Set<string> { return new Set(normTitle(title).split(" ").filter(Boolean)); }
20
+ export function titleSimilarity(a: string, b: string): number {
21
+ const x = tokens(a), y = tokens(b);
22
+ if (!x.size || !y.size) return 0;
23
+ let common = 0;
24
+ for (const t of x) if (y.has(t)) common++;
25
+ return common / (x.size + y.size - common);
26
+ }
27
+
28
+ /** yes: title Jaccard >= 0.85 and both years present and within one; partial: the title is close but the year differs or cannot be compared. */
29
+ export function matchRegistry(c: Pick<Candidate, "title" | "year">, r: Registry): Verification["match"] {
30
+ const similarity = titleSimilarity(c.title, r.title);
31
+ const yearOk = c.year !== undefined && r.year !== undefined && Math.abs(c.year - r.year) <= 1;
32
+ if (similarity >= 0.85 && yearOk) return "yes";
33
+ if (similarity >= 0.5) return "partial";
34
+ return "no";
35
+ }
36
+
37
+ export function parseCrossref(body: unknown): Registry {
38
+ const m = record(record(body).message);
39
+ const title = Array.isArray(m.title) ? str(m.title[0]) : str(m.title);
40
+ if (!title) throw new Error("crossref: record without title");
41
+ const authors = Array.isArray(m.author) ? m.author.map(a => { const r = record(a), family = str(r.family), given = str(r.given); return family ? (given ? `${family}, ${given}` : family) : str(r.name); }).filter((a): a is string => !!a) : [];
42
+ const dates = [m.issued, m["published-print"], m["published-online"], m.created].map(d => record(d)["date-parts"]);
43
+ let year: number | undefined;
44
+ for (const parts of dates) { const y = Array.isArray(parts) && Array.isArray(parts[0]) ? parts[0][0] : undefined; if (Number.isSafeInteger(y)) { year = y as number; break; } }
45
+ const venue = Array.isArray(m["container-title"]) ? str(m["container-title"][0]) : undefined;
46
+ return { title, authors, ...(year !== undefined ? { year } : {}), ...(venue ? { venue } : {}), ...(str(m.type) ? { type: str(m.type)! } : {}) };
47
+ }
48
+
49
+ export function parseDatacite(body: unknown): Registry {
50
+ const a = record(record(record(body).data).attributes);
51
+ const title = Array.isArray(a.titles) ? str(record(a.titles[0]).title) : undefined;
52
+ if (!title) throw new Error("datacite: record without title");
53
+ const authors = Array.isArray(a.creators) ? a.creators.map(c => { const r = record(c), family = str(r.familyName), given = str(r.givenName); return family ? (given ? `${family}, ${given}` : family) : str(r.name); }).filter((x): x is string => !!x) : [];
54
+ const year = Number.isSafeInteger(a.publicationYear) ? a.publicationYear as number : typeof a.publicationYear === "string" && /^\d{4}$/.test(a.publicationYear) ? Number(a.publicationYear) : undefined;
55
+ const venue = str(a.publisher) ?? str(record(a.publisher).name);
56
+ return { title, authors, ...(year !== undefined ? { year } : {}), ...(venue ? { venue } : {}), type: `datacite:${str(record(a.types).resourceTypeGeneral) ?? "other"}` };
57
+ }
58
+
59
+ const failure = (error: unknown) => (error instanceof HttpError ? error.message : error instanceof Error ? error.message.slice(0, 200) : "failed");
60
+ const done = (c: Candidate, v: Omit<Verification, "syntax" | "checkedAt">, now: string): Verification => ({ syntax: "ok", checkedAt: now, ...v, ...(v.registry ? { match: matchRegistry(c, v.registry) } : {}) });
61
+
62
+ async function verifyDoi(http: Http, c: Candidate, now: string): Promise<Verification> {
63
+ const doi = encodeURIComponent(c.doi!).replace(/%2F/gi, "/");
64
+ try {
65
+ const crossref = await http.json(`https://api.crossref.org/works/${doi}`);
66
+ if (crossref.body !== undefined) return done(c, { exists: "yes", match: "unknown", via: "crossref", registry: parseCrossref(crossref.body) }, now);
67
+ const datacite = await http.json(`https://api.datacite.org/dois/${doi}`);
68
+ if (datacite.body !== undefined) return done(c, { exists: "yes", match: "unknown", via: "datacite", registry: parseDatacite(datacite.body) }, now);
69
+ return { syntax: "ok", exists: "no", match: "unknown", checkedAt: now };
70
+ } catch (error) { return { syntax: "ok", exists: "unknown", match: "unknown", checkedAt: now, error: failure(error) }; }
71
+ }
72
+
73
+ /** One arXiv request per batch of up to 50 ids. A missing id is `no` only when arXiv answered with a valid feed. */
74
+ async function verifyArxiv(http: Http, batch: Candidate[], now: string, sleep: (ms: number) => Promise<void>): Promise<Map<string, Verification>> {
75
+ const out = new Map<string, Verification>();
76
+ for (let i = 0; i < batch.length; i += ARXIV_BATCH) {
77
+ if (i) await sleep(3000); // arXiv asks for three seconds between requests
78
+ const slice = batch.slice(i, i + ARXIV_BATCH);
79
+ try {
80
+ const result = await http.text(`https://export.arxiv.org/api/query?id_list=${slice.map(c => encodeURIComponent(c.arxivId!)).join(",")}&max_results=${slice.length}`);
81
+ const feed = result.body === undefined ? undefined : parseArxiv(result.body);
82
+ if (!feed || feed.total === undefined) throw new Error("arxiv: unexpected response");
83
+ if (feed.errors) throw new Error("arxiv: error response"); // a service error is never evidence that an id does not exist
84
+ if (!feed.complete || feed.entries.length !== feed.total) throw new Error("arxiv: incomplete response");
85
+ for (const c of slice) {
86
+ const entry = feed.entries.find(e => e.arxivId === c.arxivId);
87
+ out.set(c.id, entry
88
+ ? done(c, { exists: "yes", match: "unknown", via: "arxiv", registry: { title: entry.title, authors: entry.authors, ...(entry.year !== undefined ? { year: entry.year } : {}), type: "arxiv" } }, now)
89
+ : { syntax: "ok", exists: "no", match: "unknown", via: "arxiv", checkedAt: now });
90
+ }
91
+ } catch (error) {
92
+ for (const c of slice) out.set(c.id, { syntax: "ok", exists: "unknown", match: "unknown", checkedAt: now, error: failure(error) });
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+
98
+ export async function verifyCandidates(http: Http, list: Candidate[], options: { now?: string; sleep?: (ms: number) => Promise<void> } = {}): Promise<Map<string, Verification>> {
99
+ const now = options.now ?? new Date().toISOString(), sleep = options.sleep ?? (ms => new Promise(r => setTimeout(r, ms)));
100
+ const out = new Map<string, Verification>(), arxiv: Candidate[] = [];
101
+ for (const c of list) {
102
+ const s = syntax(c);
103
+ if (s !== "ok") { out.set(c.id, { syntax: s, exists: "unknown", match: "unknown", checkedAt: now }); continue; }
104
+ if (c.doi) out.set(c.id, await verifyDoi(http, c, now));
105
+ else arxiv.push(c);
106
+ }
107
+ for (const [id, v] of await verifyArxiv(http, arxiv, now, sleep)) out.set(id, v);
108
+ for (const c of list) { const v = out.get(c.id); if (v) v.subject = verifySubject(c); }
109
+ return out;
110
+ }
@@ -0,0 +1,122 @@
1
+ import { HttpError } from "../http.ts";
2
+ import { join } from "node:path";
3
+ import { assertWritable, boundedText, commit, onlyKeys, openStudy, plain, readLedger, readBytesBounded, sha256, type Actor } from "../study.ts";
4
+ import { loadCandidates } from "./candidates.ts";
5
+ import { SOURCES, type SourceName } from "./sources.ts";
6
+
7
+ export const WATCH_BYTES = 65_536, WATCH_QUERIES = 20, WATCH_IDS = 500, WATCH_RUN_BYTES = 60 * 1024, WATCH_RECENT = 10;
8
+ export interface WatchQuery { id: string; query: string; source?: SourceName | "all"; yearFrom?: number; limit?: number }
9
+ export interface WatchConfig { v: 1; queries: WatchQuery[]; rubric?: string }
10
+ export interface WatchSnapshot { config: WatchConfig; sha256: string }
11
+ export interface SearchResult { added: number; merged: number; dropped: number; linked: number; sources: { source: SourceName; ok: boolean; returned: number; error?: string }[] }
12
+ export interface WatchError { host: string; status: number | null }
13
+ export interface WatchQueryResult { id: string; added: number; merged: number; dropped: number; linked: number; sources: { source: SourceName; ok: boolean; returned: number }[]; errors: WatchError[] }
14
+ export type WatchFailureClass = "terminated" | "filesystem" | "network" | "rate" | "server" | "client" | "format" | "internal";
15
+ export interface WatchRun { cause?: WatchFailureClass; configSha256: string; queries: WatchQueryResult[]; added: number; addedIds: string[]; addedIdsTotal: number; ok: boolean }
16
+ const HOSTS: Record<SourceName, string> = { openalex: "api.openalex.org", s2: "api.semanticscholar.org", arxiv: "export.arxiv.org" };
17
+ class WatchInputError extends Error {}
18
+ class WatchVersionError extends WatchInputError {}
19
+ class WatchStoredError extends Error {}
20
+ function bad(message: string): never { throw new WatchInputError(`watch: ${message}; correct the watch input and rerun ares watch set before ares watch run`); }
21
+ function query(raw: unknown): WatchQuery {
22
+ if (!plain(raw)) bad("query must be an object");
23
+ onlyKeys(raw, ["id", "query", "source", "yearFrom", "limit"], "watch query");
24
+ if (typeof raw.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(raw.id)) bad("query id must follow the 1..64 run-name rule");
25
+ const text = boundedText(raw.query, "watch query", 500);
26
+ if (raw.source !== undefined && raw.source !== "all" && !SOURCES.includes(raw.source as SourceName)) bad("source must be openalex, s2, arxiv or all");
27
+ if (raw.yearFrom !== undefined && (!Number.isSafeInteger(raw.yearFrom) || (raw.yearFrom as number) < 1000 || (raw.yearFrom as number) > 2100)) bad("yearFrom must be an integer in 1000..2100");
28
+ if (raw.limit !== undefined && (!Number.isSafeInteger(raw.limit) || (raw.limit as number) < 1 || (raw.limit as number) > 50)) bad("limit must be an integer in 1..50");
29
+ return { id: raw.id, query: text, ...(raw.source === undefined ? {} : { source: raw.source as SourceName | "all" }), ...(raw.yearFrom === undefined ? {} : { yearFrom: raw.yearFrom as number }), ...(raw.limit === undefined ? {} : { limit: raw.limit as number }) };
30
+ }
31
+ export function validateWatch(raw: unknown): WatchConfig {
32
+ try {
33
+ if (!plain(raw)) bad("config must be an object");
34
+ if (raw.v !== 1) {
35
+ if (typeof raw.v === "number" && Number.isSafeInteger(raw.v) && raw.v > 1) throw new WatchVersionError("watch input: unsupported version is newer than this ares supports; use a compatible ares version or provide v1 input");
36
+ bad("config version is malformed; set v to 1");
37
+ }
38
+ onlyKeys(raw, ["v", "queries", "rubric"], "watch config");
39
+ if (!Array.isArray(raw.queries) || raw.queries.length < 1 || raw.queries.length > WATCH_QUERIES) bad("queries must contain 1..20 entries");
40
+ const queries = raw.queries.map(query);
41
+ if (new Set(queries.map(row => row.id)).size !== queries.length) bad("query ids must be unique");
42
+ const rubric = raw.rubric === undefined ? undefined : boundedText(raw.rubric, "watch rubric", 8000);
43
+ if (rubric !== undefined && Buffer.byteLength(rubric) > 8000) bad("rubric exceeds 8000 bytes");
44
+ const result: WatchConfig = { v: 1, queries, ...(rubric === undefined ? {} : { rubric }) };
45
+ assertWritable(JSON.stringify(result, null, 2) + "\n", WATCH_BYTES, "watch config"); return result;
46
+ } catch (error) {
47
+ const message = error instanceof Error ? error.message : "invalid input";
48
+ if (error instanceof WatchInputError) throw error;
49
+ bad(message);
50
+ }
51
+ }
52
+ export const watchPath = (dir: string) => join(dir, "literature", "watch.json");
53
+ function stored(dir: string): WatchSnapshot | undefined {
54
+ const file = watchPath(dir);
55
+ let bytes: Buffer, text: string;
56
+ try { bytes = readBytesBounded(file, WATCH_BYTES); text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
57
+ catch (error) {
58
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
59
+ throw new WatchStoredError("watch: literature/watch.json is unreadable, non-regular or over 65536 bytes; rerun ares watch set <dir> --file PATH with valid input, or restore literature/watch.json as a bounded regular file");
60
+ }
61
+ try { return { config: validateWatch(JSON.parse(text)), sha256: sha256(bytes) }; }
62
+ catch (error) {
63
+ if (error instanceof WatchVersionError) throw new WatchVersionError("watch config: unsupported version in literature/watch.json; keep literature/watch.json unchanged and use an ares version supporting this config");
64
+ throw new WatchStoredError("watch: literature/watch.json is invalid; rerun ares watch set <dir> --file PATH with valid v1 input, or restore literature/watch.json");
65
+ }
66
+ }
67
+ export const readWatchSnapshot = (dir: string): WatchSnapshot | undefined => stored(dir);
68
+ export function readWatch(dir: string): WatchConfig | undefined { return stored(dir)?.config; }
69
+ /** Caller holds study lock; identical bytes never add a second event. */
70
+ export function setWatch(dir: string, raw: unknown, actor: Actor) {
71
+ const config = validateWatch(raw), text = JSON.stringify(config, null, 2) + "\n";
72
+ let before: WatchSnapshot | undefined;
73
+ try { before = stored(dir); } catch (error) { if (!(error instanceof WatchStoredError)) throw error; }
74
+ if (before?.sha256 === sha256(text)) return { config, changed: false };
75
+ commit(dir, [[watchPath(dir), text, WATCH_BYTES]], [{ actor, type: "watch.set", data: { sha256: sha256(text) } }]);
76
+ return { config, changed: true };
77
+ }
78
+ function sourceErrors(sources: SearchResult["sources"]): WatchError[] {
79
+ return sources.filter(row => !row.ok).map(row => {
80
+ const host = HOSTS[row.source], prefix = `${host}: `, error = row.error ?? "";
81
+ const match = error.startsWith(prefix) ? /^(?:network|rate|server|client|format)(?: ([1-5][0-9]{2}))?$/.exec(error.slice(prefix.length)) : null;
82
+ return { host, status: match?.[1] === undefined ? null : Number(match[1]) };
83
+ });
84
+ }
85
+ /** A closed vocabulary only: thrown messages can contain request URLs, keys or response bodies. */
86
+ export function watchFailureClass(error: unknown): WatchFailureClass {
87
+ if (error instanceof HttpError) return error.kind;
88
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
89
+ return ["ENOENT", "EACCES", "EPERM", "ENOSPC", "EIO", "EROFS", "ENOTDIR", "EISDIR"].includes(String(code)) ? "filesystem" : "internal";
90
+ }
91
+ /** Caller still holds the study lock; no partial query details or diagnostic text are copied. */
92
+ export function recordWatchFailure(dir: string, snapshot: WatchSnapshot, actor: Actor, cause: WatchFailureClass): void {
93
+ const run: WatchRun = { configSha256: snapshot.sha256, queries: [], added: 0, addedIds: [], addedIdsTotal: 0, ok: false, cause };
94
+ commit(dir, [], [{ actor, type: "watch.ran", data: { ...run } }]);
95
+ }
96
+ /** One finite pass, using the caller's existing locked lit-search operation. No timer or screening. */
97
+ export async function runWatch(dir: string, snapshot: WatchSnapshot, actor: Actor, search: (query: WatchQuery) => Promise<SearchResult>): Promise<WatchRun> {
98
+ const { config } = snapshot;
99
+ const known = new Set(loadCandidates(dir).map(row => row.id)), addedIds = new Set<string>(), queries: WatchQueryResult[] = [];
100
+ for (const query of config.queries) {
101
+ const result = await search(query);
102
+ for (const row of loadCandidates(dir)) if (!known.has(row.id)) { addedIds.add(row.id); known.add(row.id); }
103
+ queries.push({ id: query.id, added: result.added, merged: result.merged, dropped: result.dropped, linked: result.linked,
104
+ sources: result.sources.map(({ source, ok, returned }) => ({ source, ok, returned })), errors: sourceErrors(result.sources) });
105
+ }
106
+ const surviving = new Set(loadCandidates(dir).map(row => row.id));
107
+ for (const id of addedIds) if (!surviving.has(id)) addedIds.delete(id);
108
+ const run: WatchRun = { configSha256: snapshot.sha256, queries, added: queries.reduce((sum, row) => sum + row.added, 0), addedIds: [], addedIdsTotal: addedIds.size,
109
+ ok: queries.some(row => row.sources.some(source => source.ok)) };
110
+ for (const id of addedIds) {
111
+ if (run.addedIds.length >= WATCH_IDS) break;
112
+ run.addedIds.push(id);
113
+ if (Buffer.byteLength(JSON.stringify(run)) > WATCH_RUN_BYTES) { run.addedIds.pop(); break; }
114
+ }
115
+ assertWritable(JSON.stringify(run), WATCH_RUN_BYTES, "watch run; reduce watched queries before the next run");
116
+ commit(dir, [], [{ actor, type: "watch.ran", data: { ...run } }]); return run;
117
+ }
118
+ export function showWatch(dirArg: string) {
119
+ const { dir } = openStudy(dirArg), config = readWatch(dir), events = readLedger(dir);
120
+ const runs = events.filter(event => event.type === "watch.ran").slice(-WATCH_RECENT).reverse().map(event => ({ ...event.data, at: event.at, actor: event.actor }));
121
+ return { config: config ?? null, runs };
122
+ }
@@ -0,0 +1,39 @@
1
+ import { version } from "../package.json";
2
+ import { COMMANDS } from "./commands-table.ts";
3
+ import { ARES_TOOLS } from "./mcp-tools.ts";
4
+
5
+ /** Implemented family-readable records only; private inputs and future integrations are not contracts. */
6
+ export const FORMAT_VERSIONS = {
7
+ manifest: 1,
8
+ study: 2,
9
+ paperEvidence: 1,
10
+ skillProposal: 1,
11
+ studyRegistry: 1,
12
+ watchConfig: 1,
13
+ studyPlan: 1,
14
+ ledgerEvent: 1,
15
+ experimentProtocol: 1,
16
+ experimentSelection: 1,
17
+ experimentFinalSeeds: 1,
18
+ ablateResult: 1,
19
+ finalResult: 1,
20
+ investigateResult: 1,
21
+ executor: 1,
22
+ experimentAttempt: 1,
23
+ studyBundle: 1,
24
+ } as const;
25
+ export const MEMORY_LEDGER_EVENTS = ["archive.exported", "skill.proposed", "skill.decided"] as const;
26
+ export const WATCH_LEDGER_EVENTS = ["watch.set", "watch.ran"] as const;
27
+ export const LEDGER_EVENTS = [...MEMORY_LEDGER_EVENTS, ...WATCH_LEDGER_EVENTS, "autonomy.set"] as const;
28
+ export interface Manifest {
29
+ v: 1;
30
+ name: "ares";
31
+ version: string;
32
+ commands: { name: string; usage: string }[];
33
+ formats: typeof FORMAT_VERSIONS;
34
+ ledgerEvents: typeof LEDGER_EVENTS[number][];
35
+ mcp: { server: "ares"; tools: { name: string; readOnly: boolean }[] };
36
+ }
37
+ export function manifest(): Manifest {
38
+ return { v: 1, name: "ares", version, commands: COMMANDS.map(({ name, usage }) => ({ name, usage })), formats: { ...FORMAT_VERSIONS }, ledgerEvents: [...LEDGER_EVENTS], mcp: { server: "ares", tools: ARES_TOOLS.map(tool => ({ name: tool.name, readOnly: tool.annotations.readOnlyHint === true && tool.annotations.openWorldHint !== true })) } };
39
+ }
@@ -0,0 +1,210 @@
1
+ import { PHASES, AUTONOMY_MODES } from "./autonomy.ts";
2
+ import { SETUP_TARGETS, type SetupMode } from "./setup.ts";
3
+ import { isAbsolute } from "node:path";
4
+ import * as ops from "./commands.ts";
5
+ import { createHttp, type Http } from "./http.ts";
6
+ import type { McpHandler, McpTool } from "./mcp.ts";
7
+ import { PLAYBOOK } from "./playbook.ts";
8
+ import { PLAN_ID_BYTES, STEP_COMMANDS } from "./plan/definition.ts";
9
+ import { PLAN_GATE_BYTES } from "./plan/state.ts";
10
+ import { detectActor, type Actor } from "./study.ts";
11
+
12
+ const study = { type: "string", minLength: 1, maxLength: 4096, description: "Absolute path of the study directory (holds study.json)" };
13
+ const id = { type: "string", minLength: 1, maxLength: 300 };
14
+ const ids = { type: "array", items: id, maxItems: 200 };
15
+ const reason = { type: "string", minLength: 1, maxLength: 500 };
16
+ const read = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
17
+ const write = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
18
+ const net = { ...write, openWorldHint: true };
19
+ const obj = (properties: Record<string, unknown>, required: string[]) => ({ type: "object", properties, required, additionalProperties: false });
20
+
21
+ const text = (max: number) => ({ type: "string", minLength: 1, maxLength: max });
22
+ const texts = (items: number, max: number) => ({ type: "array", items: text(max), maxItems: items });
23
+ const year = { type: "integer", minimum: 1000, maximum: 2100 };
24
+ const REVIEW = obj({
25
+ v: { type: "integer", enum: [1] }, reviewer: obj({ id: text(128), modelFamily: text(128) }, ["id", "modelFamily"]), buildSha256: { type: "string", minLength: 64, maxLength: 64 },
26
+ checklist: obj({ negativeResults: { type: "boolean" }, denominators: { type: "boolean" }, limitations: { type: "boolean" }, reproducibility: { type: "boolean" } }, ["negativeResults", "denominators", "limitations", "reproducibility"]),
27
+ findings: { type: "array", maxItems: 200, items: obj({ id: text(128), kind: { type: "string", enum: ["contradiction", "unsupported-claim", "number", "citation", "method", "clarity", "other"] }, location: text(4000), text: text(4000), blocking: { type: "boolean" } }, ["id", "kind", "location", "text", "blocking"]) },
28
+ }, ["v", "reviewer", "buildSha256", "checklist", "findings"]);
29
+ const SKILL = obj({ name: text(64), summary: text(16384), steps: { ...texts(16384, 16384), minItems: 1 },
30
+ evidence: { ...texts(64, 16384), minItems: 1, description: "32-hex amem ids or ares:<study-id>/... refs" } }, ["name", "summary", "steps", "evidence"]);
31
+ const SCOPE = obj({ question: text(2000), subquestions: texts(30, 1000), include: texts(50, 500), exclude: texts(50, 500), yearFrom: year, yearTo: year, maxCandidates: { type: "integer", minimum: 1, maximum: 5000 } }, ["question"]);
32
+ const CARD = obj({ summary: text(4000), claims: texts(50, 1000), methods: text(4000), findings: text(4000), limitations: text(4000), relevance: text(4000) }, ["summary"]);
33
+ const integer = (minimum: number, maximum: number) => ({ type: "integer", minimum, maximum });
34
+ const WATCH = obj({ v: {}, queries: { type: "array", minItems: 1, maxItems: 20,
35
+ items: obj({ id: text(64), query: text(4096), source: { type: "string", enum: ["openalex", "s2", "arxiv", "all"] }, yearFrom: year, limit: integer(1, 50) }, ["id", "query"]) }, rubric: text(8000) }, ["queries"]);
36
+ const argv = { type: "array", minItems: 1, maxItems: 128, items: { type: "string", maxLength: 4096 } };
37
+ const image = { ...text(512), description: "Digest-pinned image: name@sha256:<64 lowercase hex digits>" };
38
+ const stage = { type: "string", enum: ["candidate", "evaluator"] };
39
+ const stream = { type: "string", enum: ["stdout", "stderr"] };
40
+ const attemptId = { type: "string", minLength: 16, maxLength: 16, description: "16-hex attempt id from attempt list" };
41
+ const run = text(64), seed = integer(-2_147_483_648, 2_147_483_647);
42
+ const PROTOCOL = obj({
43
+ v: { type: "integer", enum: [1] }, method: text(4000),
44
+ candidate: obj({ image, command: argv, env: { type: "object", maxProperties: 64, properties: {}, additionalProperties: { type: "string", maxLength: 4096 }, description: "Non-secret environment values; secret-like keys and reserved ARES_SEED are refused" } }, ["image", "command"]),
45
+ evaluator: obj({ image, command: argv }, ["image", "command"]),
46
+ metrics: { type: "array", minItems: 1, maxItems: 100, items: obj({ name: text(128), unit: text(64), direction: { type: "string", enum: ["max", "min"] } }, ["name", "unit", "direction"]) },
47
+ seeds: { type: "array", minItems: 1, maxItems: 1000, items: seed },
48
+ limits: obj({ cpus: { type: "number", minimum: 0.1, maximum: 256 }, memoryMb: integer(16, 1_048_576), pidsLimit: integer(1, 65_536), wallSeconds: integer(1, 604_800), gpus: integer(0, 64) }, ["cpus", "memoryMb", "pidsLimit", "wallSeconds", "gpus"]),
49
+ network: { type: "string", enum: ["none"] },
50
+ budget: obj({ maxAttempts: integer(1, 1_000_000), maxWallHours: { type: "number", minimum: 0.000001, maximum: 1_000_000 }, maxGpuHours: { type: "number", minimum: 0, maximum: 64_000_000 } }, ["maxAttempts", "maxWallHours", "maxGpuHours"]),
51
+ }, ["v", "method", "candidate", "evaluator", "metrics", "seeds", "limits", "network", "budget"]);
52
+
53
+ const planId = text(PLAN_ID_BYTES);
54
+ const PLAN = obj({
55
+ v: { type: "integer", enum: [1] }, revision: integer(1, Number.MAX_SAFE_INTEGER),
56
+ nodes: { type: "array", maxItems: 128, items: obj({
57
+ id: planId, kind: { type: "string", enum: ["step", "hub", "gate"] }, phase: { type: "string", enum: [...PHASES] },
58
+ needs: { type: "array", maxItems: 128, items: planId },
59
+ command: { type: "string", enum: [...STEP_COMMANDS] },
60
+ args: { type: "object", maxProperties: 32, properties: {}, additionalProperties: { type: ["string", "number", "boolean", "array"], maxLength: 4096, maxItems: 500, items: text(300) } },
61
+ campaign: planId, unit: planId,
62
+ binds: { type: "array", maxItems: 128, items: planId },
63
+ }, ["id", "kind"]) },
64
+ edges: { type: "array", maxItems: 512, items: { type: "array", minItems: 2, maxItems: 2, items: planId } },
65
+ }, ["v", "nodes", "edges"]);
66
+
67
+ const SELECTION = obj({
68
+ v: { type: "integer", enum: [1] }, run: text(64), metric: text(128),
69
+ baseline: obj({ commit: { type: "string", minLength: 40, maxLength: 40 } }, ["commit"]),
70
+ minSeeds: integer(1, 1000), minDelta: { type: "number", minimum: 0 },
71
+ }, ["v", "run", "metric", "baseline", "minSeeds", "minDelta"]);
72
+
73
+ const IDEA = obj({ id: text(64), title: text(500), hypothesis: text(4000), parent: text(64), commit: { type: "string", minLength: 40, maxLength: 40 } }, ["id", "title", "hypothesis"]);
74
+
75
+ export const ARES_TOOLS: readonly McpTool[] = [
76
+ { name: "ares_autonomy_show", description: "Read the study autonomy policy and effective mode for each phase; no writes.", inputSchema: obj({ study }, ["study"]), annotations: read },
77
+ { name: "ares_autonomy_set", description: `Set the default or one phase autonomy mode (${AUTONOMY_MODES.join("|")}) and record from/to/phase. Only auto approves pending gates; existing gate decisions remain unchanged. Copilot draft acceptance and step pausing are separate features.`, inputSchema: obj({ study, mode: text(16), phase: text(32) }, ["study", "mode"]), annotations: write },
78
+ { name: "ares_watch_set", description: "Set bounded v1 literature queries and an optional display-only rubric. At most 20 queries; identical config is a no-op. Does not search, screen or start a timer. Survey and experiment studies.", inputSchema: obj({ study, watch: WATCH }, ["study", "watch"]), annotations: { ...write, idempotentHint: true } },
79
+ { name: "ares_watch_run", description: "Start one finite watch pass and return {started:true,configSha256} after config, scope gate and year preflight. Always detached: this ack is not completion. Inspect ares_watch_show for the watch.ran outcome. The background pass holds the study lock for up to about 20 minutes; duplicate starts refuse. Adds only new candidates, never screens. No timer or daemon is started.", inputSchema: obj({ study }, ["study"]), annotations: net },
80
+ { name: "ares_watch_show", description: "Read watch config, recent event-timed run receipts and new candidates. No lock, network or write.", inputSchema: obj({ study }, ["study"]), annotations: read },
81
+ { name: "ares_console_add", description: "Register one readable study directory in the private bounded family registry. Does not scan the disk or activate the console. An already registered directory is a no-op.", inputSchema: obj({ study }, ["study"]), annotations: { ...write, idempotentHint: true } },
82
+ { name: "ares_console_remove", description: "Remove only a listed study registration, including an unavailable path. Does not delete the study; absent entries are a no-op.", inputSchema: obj({ study }, ["study"]), annotations: { ...write, idempotentHint: true } },
83
+ { name: "ares_console_list", description: "Read the private bounded study registry without scanning the disk or removing unavailable entries. No lock, network or write.", inputSchema: obj({}, []), annotations: read },
84
+ { name: "ares_setup", description: "Preview, apply or undo a local agent-client MCP entry or console plugin manifest {api,tool,module,sha256}. Defaults to dry-run and lists config/receipt actions. Apply writes the receipt first; undo refuses entry drift. Root pins a release; cwd selects the workspace for agy. Never changes instruction blocks or contacts the network.", inputSchema: obj({ target: { type: "string", enum: [...SETUP_TARGETS, "all", "console"] }, mode: { type: "string", enum: ["dry-run", "apply", "undo"] }, root: text(4096), cwd: text(4096) }, ["target"]), annotations: write },
85
+ { name: "ares_manifest", description: "Read this ares version, implemented commands and shared schema versions without opening a study or reaching an executor.", inputSchema: obj({}, []), annotations: read },
86
+ { name: "ares_archive", description: "Preview pending derived lessons without writes. With the current full-list fingerprint, export up to 200 new amem notes and commit returned ids/hashes; remaining reports later batches. A partial failure records successes; repeat the same confirmation to continue. Applies to survey and experiment studies.", inputSchema: obj({ study, confirm: { type: "string", minLength: 64, maxLength: 64 } }, ["study"]), annotations: net },
87
+ { name: "ares_lessons", description: "Read project-scoped amem lessons with half-life ranking. Query intent when supplied, otherwise the study question; intent also returns a brief. Output relevance, decayed score, ordinal rank and age in days. No study write or lock. Survey and experiment.", inputSchema: obj({ study, for: text(1000), halfLife: { type: "number", minimum: 1, maximum: 3650 }, limit: integer(1, 50) }, ["study"]), annotations: { ...read, openWorldHint: true } },
88
+ { name: "ares_skill_propose", description: "Queue a reviewable skill proposal with at least one step and 1..64 evidence refs. At most 100 proposals, bounded 16 KiB Markdown plus receipt. Does not create a PR. Survey and experiment.", inputSchema: obj({ study, proposal: SKILL }, ["study", "proposal"]), annotations: write },
89
+ { name: "ares_skill_decide", description: "Record a final accept/reject/withdraw with reason and actor. Accept requires an HTTP(S) PR URL without credentials; never checks or merges the PR. Withdraw only while proposed. Survey and experiment.", inputSchema: obj({ study, name: text(64), decision: { type: "string", enum: ["accept", "reject", "withdraw"] }, reason: text(16384), pr: text(16384) }, ["study", "name", "decision", "reason"]), annotations: write },
90
+ { name: "ares_skill_list", description: "Read all skill proposal states after verifying bounded Markdown against receipts. No lock, subprocess or write. Survey and experiment.", inputSchema: obj({ study }, ["study"]), annotations: read },
91
+ { name: "ares_plan_set", description: "Validate a bounded revisioned DAG of existing steps, local gates and read-only hub references. Set it paused; changed consumed inputs become stale and need explicit retry. Never starts a controller.", inputSchema: obj({ study, plan: PLAN }, ["study", "plan"]), annotations: write },
92
+ { name: "ares_plan_control", description: "Pause/resume admission or retry/cancel/skip a node. Requires a durable command id and expected definition revision: identical replay returns its original receipt; changed payloads/stale new requests refuse. Does not start a controller; run ares run separately. Cancelling an attempt may contact its executor; hub cancellation stays local.", inputSchema: obj({ study, action: { type: "string", enum: ["pause", "resume", "retry", "cancel", "skip"] }, node: text(128), commandId: text(128), expectedRevision: integer(1, Number.MAX_SAFE_INTEGER) }, ["study", "action", "commandId", "expectedRevision"]), annotations: net },
93
+ { name: "ares_plan_show", description: "Read the definition revision, ledger-derived node states and last observations, gate decisions, uncertain nodes, controller owner and resolved hubProject without taking a lock or contacting an executor/hub.", inputSchema: obj({ study }, ["study"]), annotations: read },
94
+ { name: "ares_status", description: "Study summary: gates, candidate counts, verification counts, bibliography.", inputSchema: obj({ study }, ["study"]), annotations: read },
95
+ { name: "ares_study_export", description: "Write a content-free study bundle (manifest, reduced ledger, attempt rows, closed results, review counts, evidence hashes) from committed study files into a new or empty directory. Refuses the whole export if a path, URL, env value or key-like token would be written. list=true only names the files. No executor or network.", inputSchema: obj({ study, bundle: text(4096), list: { type: "boolean" } }, ["study", "bundle"]), annotations: write },
96
+ { name: "ares_scope_set", description: "Set scope.json (question, subquestions, include, exclude, yearFrom, yearTo, maxCandidates). A change returns the scope gate to pending.", inputSchema: obj({ study, scope: SCOPE }, ["study", "scope"]), annotations: write },
97
+ { name: "ares_gate", description: "List gates, or approve/reject scope, screen, design, final, review, plan:node-id or accept:hypotheses|paper|reviews. Copilot accepts bind current draft hashes and are never automatic. Plan approvals bind all upstream output references and need complete upstream outputs; skip never bypasses them. The ledger records the actor; approval authority is workspace policy.", inputSchema: obj({ study, action: { type: "string", enum: ["list", "approve", "reject"] }, gate: text(PLAN_GATE_BYTES), reason }, ["study", "action"]), annotations: write },
98
+ { name: "ares_idea_add", description: "Add an experiment idea to the ledger, with optional existing parent and frozen commit. Unique run-name ids and commit ownership; at most 1000 ideas. Starts no work.", inputSchema: obj({ study, idea: IDEA }, ["study", "idea"]), annotations: write },
99
+ { name: "ares_idea_decide", description: "Record refine/pivot with a reason of at most 2000 bytes. Ideas with commits require terminal own-commit attempts (at most 200). Untested ideas omit attempts to attach a first commit or pivot. Refine requires --commit with unique idea ownership; Pivot accepts optional cause/retryWhen (2000 bytes each); refine refuses them. Pivot rejects commit and closes further decisions. Starts no work.", inputSchema: obj({ study, id: text(64), decision: { type: "string", enum: ["refine", "pivot"] }, reason: text(2000), cause: text(2000), retryWhen: text(2000), attempts: { type: "array", maxItems: 200, items: attemptId }, commit: { type: "string", minLength: 40, maxLength: 40 } }, ["study", "id", "decision", "reason"]), annotations: write },
100
+ { name: "ares_investigate_show", description: "Read investigation candidate/baseline paired-seed scores, winner or no-winner reason and all run attempt totals/exclusions. No executor or network calls.", inputSchema: obj({ study }, ["study"]), annotations: read },
101
+ { name: "ares_investigate_close", description: "Close the selected investigation run after all its attempts stop. Write a deterministic winner or no-winner result and ledger receipt; identical inputs write nothing. The no-arg investigate.close plan step emits {sha256,winner} for gates. Starts no attempts.", inputSchema: obj({ study }, ["study"]), annotations: write },
102
+ { name: "ares_ablate_add", description: "Declare what a unique frozen ablation removes, at most 200 declarations. Winner/baseline commits are refused. Requires a current closed investigation winner; writes one journal event and starts no work.", inputSchema: obj({ study, ablation: obj({ id: text(64), commit: { type: "string", minLength: 40, maxLength: 40 }, removes: text(2000) }, ["id", "commit", "removes"]) }, ["study", "ablation"]), annotations: write },
103
+ { name: "ares_ablate_show", description: "Read every declared ablation against the investigate winner on shared non-reserved ablate-run seeds, with statuses/exclusions and no ranking. No executor or network calls.", inputSchema: obj({ study }, ["study"]), annotations: read },
104
+ { name: "ares_ablate_close", description: "Commit a timestamp-free ablation result and receipt after all ablate attempts stop. Current investigate winner required; identical inputs write nothing. The no-arg ablate.close plan step emits {sha256,status:closed} for gates; starts no work.", inputSchema: obj({ study }, ["study"]), annotations: write },
105
+ { name: "ares_posthoc_show", description: "Read all posthoc-run attempts and their metrics, including every status, labeled exploratory. Read-only, no executor or network calls; never changes the final result or its inputs.", inputSchema: obj({ study }, ["study"]), annotations: read },
106
+ { name: "ares_final_close", description: "Close one-shot reserved-seed evaluation under the currently approved final gate. Re-approval keeps first attempt data fixed; later retries never replace it. Winner changes cannot buy a new final; changed protocol is not comparable. Refuses running or missing decisive attempts; writes deterministic qualified/not_qualified with unchanged inputs a no-op. The no-arg final.close plan step emits {sha256,status}; both qualification outcomes complete the step.", inputSchema: obj({ study }, ["study"]), annotations: write },
107
+ { name: "ares_final_seeds", description: "Reserve unused unique declared protocol seeds for final evaluation (new/changed values reject prior use in every run/status), at least one and at most half the declared seeds. New/changed raw file bytes join the design seal; identical bytes are a no-op. Experiment mode only; starts no work.", inputSchema: obj({ study, final: obj({ v: { type: "integer", enum: [1] }, seeds: { type: "array", minItems: 1, maxItems: 500, items: seed } }, ["v", "seeds"]) }, ["study", "final"]), annotations: write },
108
+ { name: "ares_selection_set", description: "Set a declared investigation selection rule before results: run (not reserved ablate/final/posthoc), primary metric, frozen baseline commit, minimum paired seeds and nonnegative improvement. Changes reopen the design gate; identical bytes leave the cutoff unchanged. Requires experiment mode and a protocol; starts no investigation or executor.", inputSchema: obj({ study, selection: SELECTION }, ["study", "selection"]), annotations: write },
109
+ { name: "ares_protocol_set", description: "Set an experiment protocol after bounded validation of protocol and task/. Changes reopen design approval; attempt protocol identity excludes budget.", inputSchema: obj({ study, protocol: PROTOCOL }, ["study", "protocol"]), annotations: write },
110
+ { name: "ares_executor_set", description: "Configure local or SSH Docker execution with an explicit private absolute workdir. Stores the host alias only, never credentials.", inputSchema: obj({ study, kind: { type: "string", enum: ["local", "ssh"] }, host: text(128), workdir: text(4096) }, ["study", "kind", "workdir"]), annotations: write },
111
+ { name: "ares_executor_check", description: "Check executor Docker server, non-root uid:gid and digest-pinned image availability; pulls missing images.", inputSchema: obj({ study }, ["study"]), annotations: net },
112
+ { name: "ares_attempt_run", description: "Start a frozen-commit experiment attempt and return without waiting for completion. Always detached; requires approved design in gates mode. Duplicate requests return the existing attempt.", inputSchema: obj({ study, repo: text(4096), commit: { type: "string", minLength: 40, maxLength: 40 }, seed, run, label: text(128), retry: attemptId }, ["study", "repo", "commit"]), annotations: net },
113
+ { name: "ares_attempt_list", description: "List bounded attempt summaries without protocol or artifact lists. Limit defaults to 50 (maximum 200), with offset pagination. Totals cover every whole-study status regardless of filters or page.", inputSchema: obj({ study, run, status: { type: "string", enum: ["running", "succeeded", "failed", "cancelled", "unknown"] }, limit: integer(1, 200), offset: integer(0, 1_000_000) }, ["study"]), annotations: read },
114
+ { name: "ares_attempt_show", description: "Read an attempt record, its executor, stage receipts, metrics and artifact metadata. Logs remain on the executor.", inputSchema: obj({ study, id: attemptId }, ["study", "id"]), annotations: read },
115
+ { name: "ares_attempt_cancel", description: "Stop the running stage and record cancelled; repeated cancellation preserves the terminal attempt.", inputSchema: obj({ study, id: attemptId, reason }, ["study", "id"]), annotations: net },
116
+ { name: "ares_attempt_reconcile", description: "Observe recorded containers, advance candidate/evaluator stages and record terminal states. Lost containers become unknown; reconcile never starts a new attempt.", inputSchema: obj({ study }, ["study"]), annotations: net },
117
+ { name: "ares_attempt_logs", description: "Retrieve executor log text read-only without recording content in the study. Optionally select stage/stream; tailBytes is 0..65536 per selected stream, default 16384. Copies are checked against recorded size and sha256.", inputSchema: obj({ study, id: attemptId, stage, stream, tailBytes: integer(0, 65_536) }, ["study", "id"]), annotations: { ...read, openWorldHint: true } },
118
+ { name: "ares_lit_search", description: "Search OpenAlex, Semantic Scholar and arXiv and merge candidates. Needs the scope gate. Per-source failures are reported.", inputSchema: obj({ study, query: { type: "string", minLength: 1, maxLength: 500 }, source: { type: "string", enum: ["openalex", "s2", "arxiv", "all"] }, limit: { type: "integer", minimum: 1, maximum: 50 }, yearFrom: { type: "integer", minimum: 1000, maximum: 2100 } }, ["study", "query"]), annotations: net },
119
+ { name: "ares_lit_list", description: "List candidates with abstracts (first 600 characters), status and verification.", inputSchema: obj({ study, status: { type: "string", enum: ["new", "included", "excluded"] }, limit: { type: "integer", minimum: 1, maximum: 500 } }, ["study"]), annotations: read },
120
+ { name: "ares_lit_screen", description: "Include or exclude candidates. Changing the included set returns the screen gate to pending.", inputSchema: obj({ study, ids: { ...ids, minItems: 1 }, decision: { type: "string", enum: ["include", "exclude"] }, reason }, ["study", "ids", "decision"]), annotations: write },
121
+ { name: "ares_lit_card", description: "Record an extraction card for an included candidate: summary, claims, methods, findings, limitations, relevance.", inputSchema: obj({ study, id, card: CARD }, ["study", "id", "card"]), annotations: write },
122
+ { name: "ares_lit_verify", description: "Check DOI or arXiv identifiers at Crossref, DataCite or arXiv and compare title and year. Never judges whether a paper supports a claim.", inputSchema: obj({ study, ids }, ["study"]), annotations: net },
123
+ { name: "ares_lit_bib", description: "Write refs/references.bib from included and verified candidates. Needs the screen gate. Citekeys never change once assigned.", inputSchema: obj({ study }, ["study"]), annotations: write },
124
+ { name: "ares_review_add", description: "Record a bounded current-evidence-hash review with self-reported model family and checklist. Contradictions are always blocking; 100 reports per build, 200 findings per report, 4000-byte texts and a 60 KiB combined payload ceiling. Starts no build or controller.", inputSchema: obj({ study, review: REVIEW }, ["study", "review"]), annotations: write },
125
+ { name: "ares_review_resolve", description: "Resolve a recorded review-id/finding-id with an explicit reason and actor. No votes or automatic contradiction resolution; historical reports stay in the ledger.", inputSchema: obj({ study, target: text(257), reason: text(4000) }, ["study", "target", "reason"]), annotations: write },
126
+ { name: "ares_review_show", description: "Read current build/check readiness, review families, checklists and unresolved blocking findings. Claims readiness is unavailable (agent-hub#931) and nonblocking. Starts no network, build or controller.", inputSchema: obj({ study }, ["study"]), annotations: read },
127
+ { name: "ares_paper_build", description: "Check the bounded manuscript and refuse every finding before writing. Commit rendered Markdown, a v1 evidence map with source hashes and paper.built together; identical inputs/outputs record nothing. Unknown evidence versions are preserved. The no-arg paper.build plan step emits the committed evidence {sha256} for downstream gates. Starts no controller, executor or network calls.", inputSchema: obj({ study }, ["study"]), annotations: write },
128
+ { name: "ares_paper_check", description: "Read-only manuscript readiness check: positioned bare-number, placeholder, citation and missing-negative-result findings plus every literal escape. Experiment numbers require closed evidence; survey checks citations and refuses experiment refs. Reads bounded paper/manuscript.md, starts no build/controller/network calls.", inputSchema: obj({ study }, ["study"]), annotations: read },
129
+ { name: "ares_cite_check", description: "Check [@key] citations in a draft against verified references; lists unknown and unverified keys.", inputSchema: obj({ study, draft: { type: "string", minLength: 1, maxLength: 4096 } }, ["study", "draft"]), annotations: read },
130
+ { name: "ares_playbook", description: "Standalone literature, experiment and plan/conductor workflows, CLI-only controller startup, gate rules and evidence boundaries.", inputSchema: obj({}, []), annotations: read },
131
+ ];
132
+
133
+ function absolute(value: unknown, what: string): string {
134
+ if (typeof value !== "string" || !isAbsolute(value)) throw new Error(`${what}: absolute path required; pass the full filesystem path for ${what}`);
135
+ return value;
136
+ }
137
+
138
+ /** MCP calls always come from an agent; the actor id is the detected agent or `mcp`. */
139
+ export function createAresMcp(env: NodeJS.ProcessEnv, http: Http = createHttp(env), runtime: ops.ExperimentRuntime = {}): { tools: readonly McpTool[]; handlers: Record<string, McpHandler>; waitForIdle(): Promise<void>; terminate(): void } {
140
+ const detected = detectActor(env), actor: Actor = { kind: "agent", id: detected.kind === "agent" ? detected.id! : "mcp" };
141
+ const ctx = { ...runtime, actor, http }, memoryEnv = { ...env, AGENTHUB_PEER_ID: actor.id };
142
+ const dir = (a: Record<string, unknown>) => absolute(a.study, "study");
143
+ const background = new Map<Promise<void>, () => void>();
144
+ const track = (completion: Promise<void>, terminate: () => void) => { background.set(completion, terminate); void completion.then(() => { background.delete(completion); }); };
145
+ const handlers: Record<string, McpHandler> = {
146
+ ares_autonomy_show: a => ops.autonomyShow(dir(a)),
147
+ ares_autonomy_set: a => ops.autonomySet(dir(a), a.mode as string, a.phase as string | undefined, ctx),
148
+ ares_watch_set: a => ops.watchSet(dir(a), a.watch, ctx),
149
+ ares_watch_run: a => ops.watchRunDetached(dir(a), ctx, track),
150
+ ares_watch_show: a => ops.watchShow(dir(a)),
151
+ ares_console_add: a => ops.consoleAdd(dir(a), env),
152
+ ares_console_remove: a => ops.consoleRemove(dir(a), env),
153
+ ares_console_list: () => ops.consoleList(env),
154
+ ares_manifest: () => ops.manifest(),
155
+ ares_setup: a => ops.setup(a.target as string, (a.mode ?? "dry-run") as SetupMode, env, { ...(a.root === undefined ? {} : { root: absolute(a.root, "root") }), ...(a.cwd === undefined ? {} : { cwd: absolute(a.cwd, "cwd") }) }),
156
+ ares_archive: a => ops.archive(dir(a), { ...(a.confirm === undefined ? {} : { confirm: a.confirm as string }) }, ctx, memoryEnv),
157
+ ares_lessons: a => ops.lessons(dir(a), { ...(a.for === undefined ? {} : { for: a.for as string }), ...(a.halfLife === undefined ? {} : { halfLife: a.halfLife as number }), ...(a.limit === undefined ? {} : { limit: a.limit as number }) }, ctx, memoryEnv),
158
+ ares_skill_propose: a => ops.skillPropose(dir(a), a.proposal, ctx),
159
+ ares_skill_decide: a => ops.skillDecide(dir(a), a.name as string, a.decision as string, a.reason as string, a.pr as string | undefined, ctx),
160
+ ares_skill_list: a => ops.skillList(dir(a)),
161
+ ares_plan_set: a => ops.planSet(dir(a), a.plan, ctx),
162
+ ares_plan_control: a => ops.planControl(dir(a), { action: a.action as Parameters<typeof ops.planControl>[1]["action"], commandId: a.commandId as string, expectedRevision: a.expectedRevision as number, ...(a.node !== undefined ? { node: a.node as string } : {}) }, ctx),
163
+ ares_plan_show: a => ops.planShow(dir(a)),
164
+ ares_status: a => ops.status(dir(a)),
165
+ ares_study_export: a => ops.studyExport(dir(a), { bundle: absolute(a.bundle, "bundle"), ...(a.list === true ? { list: true } : {}) }),
166
+ ares_scope_set: a => ops.scope(dir(a), a.scope, ctx),
167
+ ares_idea_add: a => ops.ideaAdd(dir(a), a.idea, ctx),
168
+ ares_idea_decide: a => ops.ideaDecide(dir(a), a.id as string, { decision: a.decision, reason: a.reason, ...(a.cause !== undefined ? { cause: a.cause } : {}), ...(a.retryWhen !== undefined ? { retryWhen: a.retryWhen } : {}), ...(a.attempts !== undefined ? { attempts: a.attempts } : {}), ...(a.commit !== undefined ? { commit: a.commit } : {}) }, ctx),
169
+ ares_investigate_show: a => ops.investigateShow(dir(a)),
170
+ ares_investigate_close: a => ops.investigateClose(dir(a), ctx),
171
+ ares_ablate_add: a => ops.ablateAdd(dir(a), a.ablation, ctx),
172
+ ares_ablate_show: a => ops.ablateShow(dir(a)),
173
+ ares_ablate_close: a => ops.ablateClose(dir(a), ctx),
174
+ ares_posthoc_show: a => ops.posthocShow(dir(a)),
175
+ ares_final_close: a => ops.finalClose(dir(a), ctx),
176
+ ares_final_seeds: a => ops.finalSeeds(dir(a), a.final, ctx),
177
+ ares_selection_set: a => ops.selection(dir(a), a.selection, ctx),
178
+ ares_protocol_set: a => ops.protocol(dir(a), a.protocol, ctx),
179
+ ares_executor_set: a => ops.executorSet(dir(a), { v: 1, kind: a.kind, ...(a.host !== undefined ? { host: a.host } : {}), workdir: a.workdir }, ctx),
180
+ ares_executor_check: a => ops.executorCheck(dir(a), ctx),
181
+ ares_attempt_run: a => ops.attemptRun(dir(a), { repo: absolute(a.repo, "repo"), commit: a.commit as string, ...(a.seed !== undefined ? { seed: a.seed as number } : {}), ...(a.run !== undefined ? { run: a.run as string } : {}), ...(a.label !== undefined ? { label: a.label as string } : {}), ...(a.retry !== undefined ? { retry: a.retry as string } : {}), detach: true }, ctx),
182
+ ares_attempt_list: a => ops.attemptList(dir(a), { ...(a.run !== undefined ? { run: a.run as string } : {}), ...(a.status !== undefined ? { status: a.status as string } : {}), ...(a.limit !== undefined ? { limit: a.limit as number } : {}), ...(a.offset !== undefined ? { offset: a.offset as number } : {}) }),
183
+ ares_attempt_show: a => ops.attemptShow(dir(a), a.id as string),
184
+ ares_attempt_cancel: a => ops.attemptCancel(dir(a), a.id as string, a.reason as string | undefined, ctx),
185
+ ares_attempt_reconcile: a => ops.attemptReconcile(dir(a), ctx),
186
+ ares_attempt_logs: a => ops.attemptLogs(dir(a), a.id as string, { ...(a.stage !== undefined ? { stage: a.stage as "candidate" | "evaluator" } : {}), ...(a.stream !== undefined ? { stream: a.stream as "stdout" | "stderr" } : {}), ...(a.tailBytes !== undefined ? { tailBytes: a.tailBytes as number } : {}) }, ctx),
187
+ ares_gate: a => {
188
+ if (a.action === "list") return ops.gateList(dir(a));
189
+ if (typeof a.gate !== "string") throw new Error("gate: required for approve and reject");
190
+ return ops.gateDecide(dir(a), a.gate, a.action === "approve" ? "approve" : "reject", a.reason as string | undefined, ctx);
191
+ },
192
+ ares_lit_search: a => ops.search(dir(a), a.query as string, { ...(a.source ? { source: a.source as string } : {}), ...(a.limit ? { limit: a.limit as number } : {}), ...(a.yearFrom ? { yearFrom: a.yearFrom as number } : {}) }, ctx),
193
+ ares_lit_list: a => ops.list(dir(a), { ...(a.status ? { status: a.status as string } : {}), ...(a.limit ? { limit: a.limit as number } : {}) }),
194
+ ares_lit_screen: a => ops.screen(dir(a), a.ids as string[], a.decision as "include" | "exclude", a.reason as string | undefined, ctx),
195
+ ares_lit_card: a => ops.card(dir(a), a.id as string, a.card, ctx),
196
+ ares_lit_verify: a => ops.verify(dir(a), (a.ids as string[] | undefined) ?? [], ctx),
197
+ ares_lit_bib: a => ops.bib(dir(a), ctx),
198
+ ares_review_add: a => ops.reviewAdd(dir(a), a.review, ctx),
199
+ ares_review_resolve: a => ops.reviewResolve(dir(a), a.target as string, a.reason as string, ctx),
200
+ ares_review_show: a => ops.reviewShow(dir(a)),
201
+ ares_paper_build: a => ops.paperBuild(dir(a), ctx),
202
+ ares_paper_check: a => ops.paperCheck(dir(a)),
203
+ ares_cite_check: a => ops.cite(dir(a), absolute(a.draft, "draft")),
204
+ ares_playbook: () => ({ playbook: PLAYBOOK }),
205
+ };
206
+ return { tools: ARES_TOOLS, handlers,
207
+ // Only for the entrypoint immediately before process exit; pending async work must not resume.
208
+ terminate: () => { for (const stop of background.values()) { try { stop(); } catch { console.error("ares watch: termination receipt failed (storage); restore study storage and inspect ares watch show before retrying"); } } },
209
+ waitForIdle: async () => { await Promise.all([...background.keys()]); } };
210
+ }