@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,561 @@
1
+ import { validateAutonomyMode, validatePhase, PHASES, ACCEPT_ARTIFACTS, resolveAutonomy, type AcceptArtifact, type Autonomy } from "./autonomy.ts";
2
+ import { copilotBinding, requireCopilotAccept } from "./copilot.ts";
3
+ import { homedir } from "node:os";
4
+ import { addStudy, removeStudy, readRegistry } from "./console/registry.ts";
5
+ import { readWatchSnapshot, setWatch, runWatch, showWatch, recordWatchFailure, watchFailureClass, type WatchSnapshot } from "./lit/watch.ts";
6
+ import { runSetup, type SetupMode } from "./setup.ts";
7
+ import { proposeSkill, decideSkill, listSkills } from "./memory/skills.ts";
8
+ import { rankLessons, type LessonRankOptions } from "./memory/lessons.ts";
9
+ import { createAmemClient } from "./memory/amem.ts";
10
+ import { previewArchive, exportArchive, type ArchivePreview, type ArchiveResult } from "./memory/archive.ts";
11
+ import { addReview, resolveReview, reviewReadiness, reviewBinding, requireReviewReady } from "./paper/review.ts";
12
+ import { buildPaper } from "./paper/build.ts";
13
+ import { checkPaper } from "./paper/check.ts";
14
+ import { addAblation, showAblate, closeAblate } from "./exp/ablate.ts";
15
+ import { setFinalSeeds, finalGateBinding, requireFinalInputs, closeFinal, showPosthoc } from "./exp/final.ts";
16
+ import { addIdea, decideIdea } from "./exp/ideas.ts";
17
+ import { showInvestigation, closeInvestigation } from "./exp/investigate.ts";
18
+ import { setSelection } from "./exp/selection.ts";
19
+ import { createHubClient } from "./plan/hub.ts";
20
+ import { runPlan, type ControllerRuntime, type Observation, type StepNode } from "./plan/controller.ts";
21
+ import { resolveSnapshot } from "./exp/snapshot.ts";
22
+ import { spawnRunner } from "./exp/executor.ts";
23
+ import { foldPlan, readPlanState, planGateBinding, prepareControl, type ControlRequest, type ControlResult } from "./plan/state.ts";
24
+ import { setPlan } from "./plan/definition.ts";
25
+ export { manifest } from "./manifest.ts";
26
+ import { exportBundle } from "./study-export.ts";
27
+ import { existsSync, readdirSync, utimesSync } from "node:fs";
28
+ import { join, resolve } from "node:path";
29
+ import type { Http } from "./http.ts";
30
+ import { readScope, scopeHash, setScope } from "./scope.ts";
31
+ import { boundedText, commit, decideGate, gateState, GATES, initStudy, openStudy, readBytesBounded, readLedger, readTextBounded, requireGate, sha256, withLock, readPlanOwner, type Actor, type Binder, type GateName, type Study, STUDY_BYTES } from "./study.ts";
32
+ import { candidatesFile, includedHash, isVerified, loadCandidates, mergeCandidates, sameWork, verifySubject, type Candidate } from "./lit/candidates.ts";
33
+ import { searchSource, SOURCES, type SourceName } from "./lit/sources.ts";
34
+ import { verifyCandidates } from "./lit/verify.ts";
35
+ import { assignCitekeys, renderBib } from "./lit/bibtex.ts";
36
+ import { checkCitations } from "./lit/cite.ts";
37
+ import { cardPath, validateCard } from "./lit/cards.ts";
38
+ import { designBinding, protocolHash, readDesign, setProtocol } from "./exp/protocol.ts";
39
+ import { requireExperiment } from "./study.ts";
40
+ import { checkExecutor, setExecutor } from "./exp/executor.ts";
41
+ import { cancelAttempt, listAttempts, listAttemptSummaries, reconcileAttempts, runAttempt, showAttempt, type AttemptContext, type AttemptListOptions, type AttemptOptions } from "./exp/attempts.ts";
42
+ import { readAttemptLogs, type LogOptions } from "./exp/logs.ts";
43
+
44
+ export type ExperimentRuntime = Omit<AttemptContext, "actor">;
45
+ export interface Ctx extends ExperimentRuntime { actor: Actor; http: Http; signal?: AbortSignal }
46
+ const MAX_IDS = 200, DRAFT_BYTES = 2 * 1024 * 1024, BIB_BYTES = 16 * 1024 * 1024, CARD_BYTES = 65_536;
47
+
48
+ export function binder(dir: string): Binder {
49
+ return gate => gate.startsWith("accept:") ? copilotBinding(dir, gate.slice(7) as AcceptArtifact) : gate === "review" ? reviewBinding(dir) : gate === "final" ? finalGateBinding(dir) : gate.startsWith("plan:") ? planGateBinding(readPlanState(dir, readLedger(dir)), gate) : gate === "scope" ? scopeHash(dir) : gate === "design" ? designBinding(dir) : includedHash(loadCandidates(dir));
50
+ }
51
+ function gateName(value: string): GateName {
52
+ if (!GATES.includes(value as GateName) && !ACCEPT_ARTIFACTS.some(artifact => value === `accept:${artifact}`) && !value.startsWith("plan:")) throw new Error("unknown gate: unsupported name; run ares gate list <dir> and choose a listed gate before approval or rejection");
53
+ return value as GateName;
54
+ }
55
+ function ids(values: string[]): string[] {
56
+ if (!values.length) throw new Error("at least one candidate id required");
57
+ if (values.length > MAX_IDS) throw new Error(`at most ${MAX_IDS} ids per call`);
58
+ return [...new Set(values.map(v => boundedText(v, "id", 300)))];
59
+ }
60
+ function find(list: Candidate[], id: string): Candidate {
61
+ const c = list.find(x => x.id === id);
62
+ if (!c) throw new Error(`unknown candidate: ${id}`);
63
+ return c;
64
+ }
65
+
66
+ export function init(dir: string, options: { question: string; mode?: string; autonomy?: string }, ctx: Pick<Ctx, "actor">) {
67
+ const study = initStudy(dir, options, ctx.actor);
68
+ return { created: resolve(dir), study };
69
+ }
70
+
71
+ export function autonomyShow(dirArg: string) {
72
+ const { study } = openStudy(dirArg), policy = study.autonomy;
73
+ const effective = Object.fromEntries(PHASES.map(phase => [phase, typeof policy === "string" ? policy : policy.phases[phase] ?? policy.default]));
74
+ return { autonomy: policy, effective };
75
+ }
76
+ export async function autonomySet(dirArg: string, rawMode: string, rawPhase: string | undefined, ctx: Pick<Ctx, "actor">) {
77
+ const mode = validateAutonomyMode(rawMode), phase = rawPhase === undefined ? undefined : validatePhase(rawPhase);
78
+ const { dir } = openStudy(dirArg);
79
+ return withLock(dir, () => {
80
+ const { study } = openStudy(dir), from = study.autonomy;
81
+ const to: Autonomy = phase !== undefined ? { default: typeof from === "string" ? from : from.default, phases: { ...(typeof from === "string" ? {} : from.phases), [phase]: mode } }
82
+ : typeof from === "string" ? mode : { default: mode, phases: { ...from.phases } };
83
+ const v = study.v === 2 || typeof to !== "string" || to === "copilot" || to === "step" ? 2 : 1;
84
+ commit(dir, [[join(dir, "study.json"), JSON.stringify({ ...study, v, autonomy: to }, null, 2) + "\n", STUDY_BYTES]], [{ actor: ctx.actor, type: "autonomy.set", data: { from, to, phase: phase ?? null } }]);
85
+ return autonomyShow(dir);
86
+ });
87
+ }
88
+
89
+ export async function protocol(dirArg: string, raw: unknown, ctx: Ctx) {
90
+ const { dir } = openStudy(dirArg);
91
+ return withLock(dir, () => {
92
+ const value = setProtocol(dir, raw, ctx.actor);
93
+ return { protocol: value, protocolHash: protocolHash(value), gate: gateState("design", readLedger(dir), binder(dir)) };
94
+ });
95
+ }
96
+ export async function executorSet(dirArg: string, raw: unknown, ctx: Ctx) {
97
+ const { dir } = openStudy(dirArg);
98
+ return withLock(dir, () => setExecutor(dir, raw, ctx.actor));
99
+ }
100
+ export const executorCheck = (dir: string, ctx: Ctx) => checkExecutor(dir, ctx.runner);
101
+ export const attemptRun = (dir: string, options: AttemptOptions, ctx: Ctx) => runAttempt(dir, options, ctx);
102
+ export const attemptList = (dir: string, options: AttemptListOptions = {}) => listAttemptSummaries(dir, options);
103
+ export const attemptShow = (dir: string, id: string) => showAttempt(dir, id);
104
+ export const attemptCancel = (dir: string, id: string, reason: string | undefined, ctx: Ctx) => cancelAttempt(dir, id, reason, ctx);
105
+ export const attemptReconcile = (dir: string, ctx: Ctx) => reconcileAttempts(dir, ctx);
106
+ export const attemptLogs = (dir: string, id: string, options: LogOptions, ctx: Ctx) => readAttemptLogs(dir, id, options, ctx.runner);
107
+
108
+ export function status(dirArg: string) {
109
+ const { dir, study } = openStudy(dirArg), events = readLedger(dir), list = loadCandidates(dir), bind = binder(dir);
110
+ const cardsDir = join(dir, "literature", "cards"), bibPath = join(dir, "refs", "references.bib");
111
+ const count = (s: Candidate["status"]) => list.filter(c => c.status === s).length;
112
+ const included = list.filter(c => c.status === "included");
113
+ return {
114
+ study: dir, question: study.question, mode: study.mode, autonomy: study.autonomy,
115
+ scope: { set: scopeHash(dir) !== undefined },
116
+ gates: GATES.filter(g => (g !== "design" && g !== "final") || study.mode === "experiment").map(g => gateState(g, events, bind)),
117
+ literature: {
118
+ candidates: list.length, new: count("new"), included: included.length, excluded: count("excluded"),
119
+ verified: included.filter(isVerified).length, unverified: included.filter(c => !isVerified(c)).length,
120
+ cards: existsSync(cardsDir) ? readdirSync(cardsDir).filter(f => f.endsWith(".json")).length : 0,
121
+ },
122
+ bib: existsSync(bibPath) ? { path: bibPath, sha256: sha256(readBytesBounded(bibPath, BIB_BYTES)) } : null,
123
+ events: events.length,
124
+ };
125
+ }
126
+
127
+ export const studyExport = (dir: string, options: { bundle: string; list?: boolean }) => exportBundle(dir, options);
128
+
129
+ export async function scope(dirArg: string, raw: unknown, ctx: Ctx) {
130
+ const { dir } = openStudy(dirArg);
131
+ return withLock(dir, () => {
132
+ const result = setScope(dir, raw, ctx.actor);
133
+ return { ...result, gate: gateState("scope", readLedger(dir), binder(dir)) };
134
+ });
135
+ }
136
+
137
+ export function gateList(dirArg: string) {
138
+ const { dir, study } = openStudy(dirArg), events = readLedger(dir), bind = binder(dir);
139
+ const plan = foldPlan(events);
140
+ const accepts = ACCEPT_ARTIFACTS.filter(artifact => resolveAutonomy(study.autonomy, `accept:${artifact}`) === "copilot").map(artifact => `accept:${artifact}` as GateName);
141
+ const gates: GateName[] = [...accepts, ...GATES.filter(g => (g !== "design" && g !== "final") || study.mode === "experiment"), ...(plan?.nodes.filter(n => n.kind === "gate").map(n => `plan:${n.id}` as GateName) ?? [])];
142
+ return gates.map(g => gateState(g, events, bind));
143
+ }
144
+
145
+ export async function gateDecide(dirArg: string, gate: string, decision: "approve" | "reject", reason: string | undefined, ctx: Ctx, guard?: () => void) {
146
+ const { dir, study } = openStudy(dirArg), name = gateName(gate), why = reason === undefined ? undefined : boundedText(reason, "--reason", 500);
147
+ if (name === "design" || name === "final") requireExperiment(study);
148
+ return withLock(dir, () => {
149
+ guard?.();
150
+ if (name === "final") requireFinalInputs(dir);
151
+ if (name === "review" && decision === "approve") requireReviewReady(dir);
152
+ if (name.startsWith("plan:") && !readPlanState(dir, readLedger(dir)).definition.nodes.some(n => n.id === name.slice(5) && n.kind === "gate")) throw new Error(`gate ${name}: no gate node ${name.slice(5)} in the plan; run ares gate list and select an existing gate`);
153
+ return decideGate(dir, name, decision, ctx.actor, binder(dir), why);
154
+ });
155
+ }
156
+
157
+ export async function search(dirArg: string, query: string, options: { source?: string; limit?: number; yearFrom?: number }, ctx: Ctx) {
158
+ const { dir } = openStudy(dirArg); return withLock(dir, () => searchLocked(dir, query, options, ctx));
159
+ }
160
+ async function searchLocked(dirArg: string, query: string, options: { source?: string; limit?: number; yearFrom?: number }, ctx: Ctx) {
161
+ const { dir, study } = openStudy(dirArg), q = boundedText(query, "query", 500);
162
+ const source = options.source ?? "all";
163
+ if (source !== "all" && !SOURCES.includes(source as SourceName)) throw new Error("--source: openalex, s2, arxiv or all");
164
+ const limit = options.limit ?? 20;
165
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50) throw new Error("--limit: 1..50");
166
+ if (options.yearFrom !== undefined && (!Number.isSafeInteger(options.yearFrom) || options.yearFrom < 1000 || options.yearFrom > 2100)) throw new Error("--year-from: year 1000..2100");
167
+ const sc = readScope(dir);
168
+ if (!sc) throw new Error("gate scope: no scope yet (run ares scope)");
169
+ const yearFrom = options.yearFrom ?? sc.yearFrom;
170
+ if (yearFrom !== undefined && sc.yearTo !== undefined && yearFrom > sc.yearTo) throw new Error(`--year-from: after the scope's yearTo ${sc.yearTo}`);
171
+ const approval = requireGate(dir, study, "scope", binder(dir));
172
+ const names = source === "all" ? SOURCES : [source as SourceName];
173
+ const results = [];
174
+ for (const name of names) results.push(await searchSource(ctx.http, name, q, { limit, ...(yearFrom !== undefined ? { yearFrom } : {}), ...(sc.yearTo !== undefined ? { yearTo: sc.yearTo } : {}) }));
175
+ const list = loadCandidates(dir), counts = mergeCandidates(list, results.flatMap(r => r.records), sc.maxCandidates);
176
+ const sources = results.map(r => ({ source: r.source, ok: r.ok, returned: r.records.length, ...(r.error ? { error: r.error } : {}) }));
177
+ // A search where every source failed is recorded but is not a success, so it approves nothing.
178
+ const answered = sources.some(r => r.ok);
179
+ commit(dir, [candidatesFile(dir, list)], [...(answered ? approval : []), { actor: ctx.actor, type: "lit.search", data: { query: q, sources, ...counts } }]);
180
+ return { query: q, sources, ...counts, candidates: list.length };
181
+
182
+ }
183
+
184
+ export function list(dirArg: string, options: { status?: string; limit?: number }) {
185
+ const { dir } = openStudy(dirArg), limit = options.limit ?? 50;
186
+ if (options.status !== undefined && !["new", "included", "excluded"].includes(options.status)) throw new Error("--status: new, included or excluded");
187
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) throw new Error("--limit: 1..500");
188
+ const all = loadCandidates(dir).filter(c => !options.status || c.status === options.status);
189
+ return {
190
+ total: all.length,
191
+ candidates: all.slice(0, limit).map(c => ({
192
+ id: c.id, title: c.title, authors: c.authors.slice(0, 5), year: c.year, venue: c.venue, sources: c.sources, status: c.status,
193
+ ...(c.reason ? { reason: c.reason } : {}), ...(c.citekey ? { citekey: c.citekey } : {}), ...(c.duplicates?.length ? { duplicates: c.duplicates } : {}),
194
+ verified: isVerified(c), ...(c.verify ? { verify: { syntax: c.verify.syntax, exists: c.verify.exists, match: c.verify.match } } : {}),
195
+ ...(c.abstract ? { abstract: c.abstract.slice(0, 600) } : {}),
196
+ })),
197
+ };
198
+ }
199
+
200
+ export async function screen(dirArg: string, values: string[], decision: "include" | "exclude", reason: string | undefined, ctx: Ctx, guard?: () => void) {
201
+ const { dir, study } = openStudy(dirArg), wanted = ids(values), why = reason === undefined ? undefined : boundedText(reason, "--reason", 500);
202
+ return withLock(dir, () => {
203
+ guard?.();
204
+ const all = loadCandidates(dir), at = new Date().toISOString(), status = decision === "include" ? "included" : "excluded";
205
+ for (const id of wanted) find(all, id);
206
+ const approval = requireGate(dir, study, "scope", binder(dir));
207
+ for (const id of wanted) {
208
+ const c = find(all, id);
209
+ c.status = status; c.decidedBy = ctx.actor; c.decidedAt = at;
210
+ if (why) c.reason = why; else delete c.reason;
211
+ }
212
+ commit(dir, [candidatesFile(dir, all)], [...approval, { actor: ctx.actor, type: "lit.screen", data: { ids: wanted, decision, ...(why ? { reason: why } : {}) } }]);
213
+ return { decision, ids: wanted, gate: gateState("screen", readLedger(dir), binder(dir)) };
214
+ });
215
+ }
216
+
217
+ export async function card(dirArg: string, id: string, raw: unknown, ctx: Ctx) {
218
+ const { dir, study } = openStudy(dirArg), value = validateCard(raw);
219
+ return withLock(dir, () => {
220
+ const c = find(loadCandidates(dir), id);
221
+ if (c.status !== "included") throw new Error(`${id}: cards are recorded for included candidates only`);
222
+ const approval = requireGate(dir, study, "scope", binder(dir));
223
+ const path = cardPath(dir, id), text = JSON.stringify({ id, ...value }, null, 2) + "\n";
224
+ commit(dir, [[path, text, CARD_BYTES]], [...approval, { actor: ctx.actor, type: "lit.card", data: { id, sha256: sha256(text) } }]);
225
+ return { id, path };
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Without ids: included candidates never checked, checked against other identifiers, or left unknown, up to 200.
231
+ * Final results (no identifier, not found, title or year mismatch) are skipped so the batch always advances;
232
+ * re-check one by naming its id.
233
+ */
234
+ export async function verify(dirArg: string, values: string[], ctx: Ctx) {
235
+ const { dir, study } = openStudy(dirArg), wanted = values.length ? ids(values) : undefined;
236
+ return withLock(dir, async () => {
237
+ const all = loadCandidates(dir);
238
+ const open = (c: Candidate) => !c.verify || c.verify.subject !== verifySubject(c) || (c.verify.syntax === "ok" && c.verify.exists === "unknown");
239
+ const pending = wanted ? wanted.map(id => find(all, id)) : all.filter(c => c.status === "included" && !isVerified(c) && open(c));
240
+ const approval = requireGate(dir, study, "scope", binder(dir));
241
+ const batch = pending.slice(0, MAX_IDS);
242
+ const results = await verifyCandidates(ctx.http, batch, ctx.sleep ? { sleep: ctx.sleep } : {});
243
+ for (const c of batch) { const v = results.get(c.id); if (v) c.verify = v; }
244
+ const tally = { checked: batch.length, verified: batch.filter(isVerified).length, notFound: batch.filter(c => c.verify?.exists === "no").length,
245
+ unknown: batch.filter(c => c.verify?.exists === "unknown").length, mismatched: batch.filter(c => c.verify?.exists === "yes" && c.verify.match !== "yes").length,
246
+ noIdentifier: batch.filter(c => c.verify?.syntax !== "ok").length, remaining: pending.length - batch.length,
247
+ final: wanted ? 0 : all.filter(c => c.status === "included" && !isVerified(c) && !open(c)).length };
248
+ commit(dir, [candidatesFile(dir, all)], [...approval, { actor: ctx.actor, type: "lit.verify", data: tally }]);
249
+ return { ...tally, results: batch.map(c => ({ id: c.id, ...c.verify })) };
250
+ });
251
+ }
252
+
253
+ export async function bib(dirArg: string, ctx: Ctx) {
254
+ const { dir, study } = openStudy(dirArg);
255
+ return withLock(dir, () => {
256
+ const approval = requireGate(dir, study, "screen", binder(dir));
257
+ const all = loadCandidates(dir), eligible = all.filter(c => c.status === "included" && isVerified(c));
258
+ const twins = sameWork(all).map(g => g.filter(id => all.find(c => c.id === id)!.status === "included")).filter(g => g.length > 1);
259
+ if (twins.length) throw new Error(`included candidates that are the same work: ${twins.map(g => g.join(" = ")).join("; ")}; exclude all but one of each`);
260
+ const assigned = assignCitekeys(all, eligible);
261
+ const { text, entries } = renderBib(all), path = join(dir, "refs", "references.bib"), hash = sha256(text);
262
+ commit(dir, [...(assigned ? [candidatesFile(dir, all)] : []), [path, text, BIB_BYTES]], [...approval, { actor: ctx.actor, type: "lit.bib", data: { entries, sha256: hash } }]);
263
+ const left = all.filter(c => c.status === "included" && !isVerified(c)).map(c => ({ id: c.id, verify: c.verify ? `${c.verify.syntax}/${c.verify.exists}/${c.verify.match}` : "not checked" }));
264
+ return { path, entries, sha256: hash, newKeys: assigned, notInBib: left };
265
+ });
266
+ }
267
+
268
+ export function cite(dirArg: string, draft: string) {
269
+ const { dir } = openStudy(dirArg), path = resolve(draft);
270
+ return { draft: path, ...checkCitations(readTextBounded(path, DRAFT_BYTES), loadCandidates(dir)) };
271
+ }
272
+
273
+ export async function planSet(dirArg: string, raw: unknown, ctx: Pick<Ctx, "actor">) {
274
+ const { dir } = openStudy(dirArg);
275
+ return withLock(dir, () => setPlan(dir, raw, ctx.actor));
276
+ }
277
+ export function planShow(dirArg: string) {
278
+ const { dir } = openStudy(dirArg);
279
+ let controller: ReturnType<typeof readPlanOwner> | "none" | { error: string };
280
+ try { controller = readPlanOwner(dir) ?? "none"; }
281
+ catch { controller = { error: "controller owner unreadable; inspect .ares.plan.lock before ares run" }; }
282
+ return { ...readPlanState(dir, readLedger(dir)), controller };
283
+ }
284
+
285
+ export async function planControl(dirArg: string, request: ControlRequest, ctx: Ctx): Promise<ControlResult> {
286
+ const { dir } = openStudy(dirArg);
287
+ const prepared = await withLock(dir, () => {
288
+ const events = readLedger(dir), state = readPlanState(dir, events);
289
+ const prepared = prepareControl(state, events, request, ctx.actor, openStudy(dir).study.autonomy);
290
+ if (!prepared.event) return { result: prepared.result };
291
+ const node = state.nodes.find(n => n.id === request.node);
292
+ const attempt = node?.attemptId ? listAttempts(dir).attempts.find(a => a.id === node.attemptId) : undefined;
293
+ if (request.action === "cancel" && attempt) return { attemptId: attempt.id, dispatchId: node?.dispatchId };
294
+ // A missing record after a crash is uncertain, and explicit retry/cancel may release it.
295
+ if ((request.action === "retry" || request.action === "skip") && attempt?.status === "running") throw new Error(`plan node ${node!.id}: attempt is still running; cancel it or run ares attempt reconcile before retry`);
296
+ commit(dir, [], [prepared.event, ...(prepared.pause ? [prepared.pause] : [])]);
297
+ return { result: prepared.result };
298
+ });
299
+ if (prepared.result) return prepared.result;
300
+ let result: ControlResult | undefined;
301
+ await cancelAttempt(dir, prepared.attemptId!, "plan cancellation", ctx, () => {
302
+ const events = readLedger(dir), state = readPlanState(dir, events);
303
+ // An identical cancel may have committed while this request waited for the attempt control lock.
304
+ const current = prepareControl(state, events, request, ctx.actor, openStudy(dir).study.autonomy);
305
+ if (!current.event) { result = current.result; return []; }
306
+ if (state.nodes.find(node => node.id === request.node)?.dispatchId !== prepared.dispatchId) throw new Error("plan cancel: dispatch changed while cancelling the attempt; inspect ares plan show and retry with a new command-id");
307
+ result = current.result;
308
+ return current.event ? [current.event, ...(current.pause ? [current.pause] : [])] : [];
309
+ });
310
+ return result!;
311
+ }
312
+
313
+
314
+ function stepText(node: StepNode, key: string): string {
315
+ return boundedText(node.args[key], `plan step ${node.id}.${key}`, 4096);
316
+ }
317
+ function stepNumber(node: StepNode, key: string): number | undefined {
318
+ const value = node.args[key];
319
+ if (value === undefined) return undefined;
320
+ if ((typeof value !== "string" && typeof value !== "number") || !/^-?\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error(`plan step ${node.id}.${key}: integer required; correct the argument with ares plan set`);
321
+ return Number(value);
322
+ }
323
+ export async function planRun(dirArg: string, options: { parallel?: number }, ctx: Ctx) {
324
+ const { dir } = openStudy(dirArg);
325
+ const attemptOptions = (node: StepNode, retryOf?: string) => {
326
+ const parent = retryOf ?? (node.args.retry !== undefined ? stepText(node, "retry") : undefined);
327
+ const retry = parent && listAttempts(dir).attempts.some(a => a.id === parent) ? parent : undefined;
328
+ return {
329
+ repo: resolve(dir, stepText(node, "repo")), commit: stepText(node, "commit"),
330
+ ...(stepNumber(node, "seed") !== undefined ? { seed: stepNumber(node, "seed")! } : {}),
331
+ ...(node.args.run !== undefined ? { run: stepText(node, "run") } : {}),
332
+ ...(node.args.label !== undefined ? { label: stepText(node, "label") } : {}),
333
+ ...(retry ? { retry } : {}), detach: true,
334
+ };
335
+ };
336
+ const observed = (attempt: ReturnType<typeof showAttempt>): Observation => ({
337
+ progress: sha256(JSON.stringify(attempt)),
338
+ status: attempt.status === "running" ? "dispatched" : attempt.status === "succeeded" ? "done" : attempt.status === "unknown" ? "uncertain" : attempt.status,
339
+ ...(attempt.status === "succeeded" ? { output: { attemptId: attempt.id, sha256: sha256(JSON.stringify(attempt)) } } : {}),
340
+ ...(attempt.status !== "running" && attempt.status !== "succeeded" ? { reason: `attempt ${attempt.status}; inspect ares attempt show before retrying this node` } : {}),
341
+ });
342
+ const runtime: ControllerRuntime = {
343
+ hub: createHubClient(dir, ctx.runner),
344
+ actor: ctx.actor, ...(ctx.signal ? { signal: ctx.signal } : {}), ...(ctx.sleep ? { sleep: ctx.sleep } : {}),
345
+ prepare: async (node, state) => {
346
+ if (node.command !== "attempt.run") return {};
347
+ requireCopilotAccept(dir, "hypotheses");
348
+ const design = readDesign(dir);
349
+ if (!design) throw new Error("plan attempt: protocol missing; run ares protocol before resume");
350
+ const options = attemptOptions(node, state.retryOf), snapshot = await resolveSnapshot(options.repo, options.commit, ctx.runner ?? spawnRunner);
351
+ const { attemptId } = await import("./exp/attempts.ts");
352
+ return { attemptId: attemptId(protocolHash(design.protocol), snapshot.commit, snapshot.treeId, options.seed ?? design.protocol.seeds[0]!, options.run ?? "main", options.retry) };
353
+ },
354
+ execute: async (node, state) => {
355
+ if (node.command === "attempt.run") return observed(await runAttempt(dir, { ...attemptOptions(node, state.retryOf), expectedId: state.attemptId! }, ctx));
356
+ let result: unknown, ok = true;
357
+ switch (node.command) {
358
+ case "watch.run": {
359
+ const result = await watchRun(dir, ctx);
360
+ return result.ok ? { status: "done", output: { added: result.added } } : { status: "failed", reason: "watch: every source failed; inspect ares watch show and retry the plan node" };
361
+ }
362
+ case "lit.search": {
363
+ const value = await search(dir, stepText(node, "query"), { ...(node.args.source !== undefined ? { source: stepText(node, "source") } : {}), ...(stepNumber(node, "limit") !== undefined ? { limit: stepNumber(node, "limit")! } : {}), ...(stepNumber(node, "year-from") !== undefined ? { yearFrom: stepNumber(node, "year-from")! } : {}) }, ctx);
364
+ ok = value.sources.some(s => s.ok); result = value; break;
365
+ }
366
+ case "lit.verify": result = await verify(dir, (node.args.ids as string[] | undefined) ?? [], ctx); break;
367
+ case "lit.bib": result = await bib(dir, ctx); break;
368
+ case "investigate.close": {
369
+ const result = await investigateClose(dir, ctx);
370
+ return { status: "done", output: { sha256: sha256(JSON.stringify(result, null, 2) + "\n"), winner: result.winner } };
371
+ }
372
+ case "ablate.close": {
373
+ const result = await ablateClose(dir, ctx);
374
+ return { status: "done", output: { sha256: sha256(JSON.stringify(result, null, 2) + "\n"), status: "closed" } };
375
+ }
376
+ case "final.close": {
377
+ const result = await finalClose(dir, ctx);
378
+ return { status: "done", output: { sha256: sha256(JSON.stringify(result, null, 2) + "\n"), status: result.status } };
379
+ }
380
+ case "paper.build": {
381
+ const result = await paperBuild(dir, ctx);
382
+ return { status: "done", output: { sha256: result.evidenceSha256 } };
383
+ }
384
+ case "cite.check": { const value = cite(dir, resolve(dir, stepText(node, "draft"))); result = value; ok = value.ok; break; }
385
+ }
386
+ return { status: ok ? "done" : "failed", ...(ok ? { output: { sha256: sha256(JSON.stringify(result)) } } : { reason: "step reported failure; inspect its command result and inputs before retry" }) };
387
+ },
388
+ reconcile: async (_node, state) => {
389
+ if (!state.attemptId || !listAttempts(dir).attempts.some(a => a.id === state.attemptId)) return { status: "uncertain", reason: "expected attempt record missing; explicitly retry or skip this node" };
390
+ const before = showAttempt(dir, state.attemptId);
391
+ if (before.status === "running") await reconcileAttempts(dir, ctx);
392
+ return observed(showAttempt(dir, state.attemptId));
393
+ },
394
+ };
395
+ return runPlan(dir, options, runtime);
396
+ }
397
+
398
+
399
+ export async function selection(dirArg: string, raw: unknown, ctx: Pick<Ctx, "actor">) {
400
+ const { dir, study } = openStudy(dirArg); requireExperiment(study);
401
+ return withLock(dir, () => {
402
+ const design = readDesign(dir);
403
+ if (!design) throw new Error("selection: protocol missing; run ares protocol <dir> --file PATH before setting a selection");
404
+ const result = setSelection(dir, raw, design.protocol, ctx.actor);
405
+ return { ...result, gate: gateState("design", readLedger(dir), binder(dir)) };
406
+ });
407
+ }
408
+
409
+
410
+ export const investigateShow = (dir: string) => showInvestigation(dir);
411
+ export async function investigateClose(dirArg: string, ctx: Pick<Ctx, "actor">) {
412
+ const { dir, study } = openStudy(dirArg); requireExperiment(study);
413
+ return withLock(dir, () => closeInvestigation(dir, ctx.actor));
414
+ }
415
+
416
+
417
+ export async function ideaAdd(dirArg: string, raw: unknown, ctx: Pick<Ctx, "actor">) {
418
+ const { dir, study } = openStudy(dirArg); requireExperiment(study);
419
+ return withLock(dir, () => addIdea(dir, raw, ctx.actor));
420
+ }
421
+ export async function ideaDecide(dirArg: string, id: string, raw: unknown, ctx: Pick<Ctx, "actor">) {
422
+ const { dir, study } = openStudy(dirArg); requireExperiment(study);
423
+ return withLock(dir, () => decideIdea(dir, id, raw, ctx.actor));
424
+ }
425
+
426
+
427
+ export async function finalSeeds(dirArg: string, raw: unknown, ctx: Pick<Ctx, "actor">) {
428
+ const { dir, study } = openStudy(dirArg); requireExperiment(study);
429
+ return withLock(dir, () => {
430
+ const design = readDesign(dir);
431
+ if (!design) throw new Error("final seeds: protocol missing; run ares protocol <dir> --file PATH first");
432
+ const result = setFinalSeeds(dir, raw, design.protocol, ctx.actor, () => {
433
+ const attempts = listAttempts(dir).attempts;
434
+ if (attempts.some(attempt => !Number.isSafeInteger(attempt.seed))) throw new Error("final seeds: attempt seed metadata is malformed; restore valid attempt records before reserving seeds");
435
+ return new Set(attempts.map(attempt => attempt.seed));
436
+ });
437
+ return { ...result, gate: gateState("design", readLedger(dir), binder(dir)) };
438
+ });
439
+ }
440
+
441
+
442
+ export async function ablateAdd(dirArg: string, raw: unknown, ctx: Pick<Ctx, "actor">) {
443
+ const { dir, study } = openStudy(dirArg); requireExperiment(study);
444
+ return withLock(dir, () => addAblation(dir, raw, ctx.actor));
445
+ }
446
+ export const ablateShow = (dir: string) => showAblate(dir);
447
+ export async function ablateClose(dirArg: string, ctx: Pick<Ctx, "actor">) {
448
+ const { dir, study } = openStudy(dirArg); requireExperiment(study);
449
+ return withLock(dir, () => closeAblate(dir, ctx.actor));
450
+ }
451
+
452
+
453
+ export async function finalClose(dirArg: string, ctx: Pick<Ctx, "actor">) {
454
+ const { dir, study } = openStudy(dirArg); requireExperiment(study);
455
+ return withLock(dir, () => closeFinal(dir, ctx.actor));
456
+ }
457
+
458
+ export const posthocShow = (dir: string) => showPosthoc(dir);
459
+
460
+ export const paperCheck = (dir: string) => checkPaper(dir);
461
+
462
+ export async function paperBuild(dirArg: string, ctx: Pick<Ctx, "actor">) {
463
+ const { dir } = openStudy(dirArg); return withLock(dir, () => buildPaper(dir, ctx.actor));
464
+ }
465
+
466
+ export async function reviewAdd(dirArg: string, raw: unknown, ctx: Pick<Ctx, "actor">) {
467
+ const { dir } = openStudy(dirArg); return withLock(dir, () => addReview(dir, raw, ctx.actor));
468
+ }
469
+ export async function reviewResolve(dirArg: string, target: string, reason: string, ctx: Pick<Ctx, "actor">) {
470
+ const { dir } = openStudy(dirArg); return withLock(dir, () => resolveReview(dir, target, reason, ctx.actor));
471
+ }
472
+ export const reviewShow = (dir: string) => reviewReadiness(dir);
473
+
474
+ /** Preview is read-only; confirmed outward writes and receipts share the study lock. */
475
+ export async function archive(dirArg: string, options: { confirm?: string }, ctx: Pick<Ctx, "actor" | "runner">, env: NodeJS.ProcessEnv = process.env, guard?: () => void): Promise<ArchivePreview | ArchiveResult> {
476
+ const { dir } = openStudy(dirArg);
477
+ if (options.confirm === undefined) return previewArchive(dir);
478
+ return withLock(dir, () => { guard?.(); return exportArchive(dir, options.confirm!, ctx.actor, ctx.runner, env); });
479
+ }
480
+
481
+ /** The study cwd binds amem's project; search and optional intent brief never write study files. */
482
+ export async function lessons(dirArg: string, options: LessonRankOptions & { for?: string }, ctx: Pick<Ctx, "runner" | "now"> = {}, env: NodeJS.ProcessEnv = process.env) {
483
+ const { dir, study } = openStudy(dirArg), now = ctx.now?.() ?? Date.now();
484
+ rankLessons([], now, options);
485
+ if (options.for !== undefined && (!options.for.trim() || options.for.includes("\0") || options.for.length > 1000)) throw new Error("lessons intent: blank, NUL or over 1000 characters; pass --for with nonblank intent text within that bound");
486
+ const client = createAmemClient(dir, ctx.runner, env), hits = await client.search(options.for ?? study.question);
487
+ const ranked = rankLessons(hits, now, options);
488
+ const brief = options.for === undefined ? undefined : await client.brief(options.for, 2048);
489
+ return { lessons: ranked, ...(brief === undefined ? {} : { brief }) };
490
+ }
491
+
492
+ export async function skillPropose(dirArg: string, raw: unknown, ctx: Pick<Ctx, "actor">) {
493
+ const { dir } = openStudy(dirArg); return withLock(dir, () => proposeSkill(dir, raw, ctx.actor));
494
+ }
495
+ export async function skillDecide(dirArg: string, name: string, decision: string, reason: string, pr: string | undefined, ctx: Pick<Ctx, "actor">, guard?: () => void) {
496
+ const { dir } = openStudy(dirArg); return withLock(dir, () => { guard?.(); return decideSkill(dir, name, decision, reason, pr, ctx.actor); });
497
+ }
498
+ export const skillList = (dir: string) => listSkills(dir);
499
+
500
+ export const setup = (target: string, mode: SetupMode, env: NodeJS.ProcessEnv, options: { cwd?: string; root?: string } = {}) => runSetup(target, mode, { ...options, env });
501
+
502
+ export async function watchSet(dirArg: string, raw: unknown, ctx: Pick<Ctx, "actor">) {
503
+ const { dir } = openStudy(dirArg); return withLock(dir, () => setWatch(dir, raw, ctx.actor));
504
+ }
505
+ function watchPreflight(dir: string, study: Study): WatchSnapshot {
506
+ const snapshot = readWatchSnapshot(dir), scope = readScope(dir);
507
+ if (!snapshot) throw new Error("watch: no config; run ares watch set <dir> --file PATH before watch run");
508
+ if (!scope) throw new Error("gate scope: no scope yet; run ares scope before watch run");
509
+ requireGate(dir, study, "scope", binder(dir));
510
+ for (const query of snapshot.config.queries) if ((query.yearFrom ?? scope.yearFrom ?? 1000) > (scope.yearTo ?? 2100)) throw new Error(`watch query ${query.id}: yearFrom exceeds scope yearTo; correct that query or the scope before running`);
511
+ return snapshot;
512
+ }
513
+ function watchRunLocked(dir: string, snapshot: WatchSnapshot, ctx: Ctx) {
514
+ return runWatch(dir, snapshot, ctx.actor, query => {
515
+ // Refresh the held lock between bounded searches, with no recurring timer.
516
+ const time = new Date(); utimesSync(join(dir, ".ares.lock"), time, time);
517
+ return searchLocked(dir, query.query, query, ctx);
518
+ });
519
+ }
520
+ export async function watchRun(dirArg: string, ctx: Ctx) {
521
+ const { dir, study } = openStudy(dirArg);
522
+ return withLock(dir, () => watchRunLocked(dir, watchPreflight(dir, study), ctx));
523
+ }
524
+ export interface WatchStarted { started: true; configSha256: string }
525
+ /** Acknowledge only validated admission; the finite pass keeps the same lock until its receipt is written. */
526
+ export function watchRunDetached(dirArg: string, ctx: Ctx, track?: (completion: Promise<void>, terminate: () => void) => void): Promise<WatchStarted> {
527
+ const { dir, study } = openStudy(dirArg);
528
+ let accept!: (value: WatchStarted) => void, reject!: (cause: unknown) => void, started = false;
529
+ let terminate = () => {};
530
+ const admission = new Promise<WatchStarted>((resolve, refuse) => { accept = resolve; reject = refuse; });
531
+ const completion = withLock(dir, async () => {
532
+ const snapshot = watchPreflight(dir, study);
533
+ let settled = false;
534
+ terminate = () => {
535
+ if (settled) return; settled = true;
536
+ recordWatchFailure(dir, snapshot, ctx.actor, "terminated");
537
+ console.error("ares watch: terminated; inspect ares watch show and lit list before retrying");
538
+ };
539
+ started = true; accept({ started: true, configSha256: snapshot.sha256 });
540
+ try { await watchRunLocked(dir, snapshot, ctx); settled = true; }
541
+ catch (cause) {
542
+ if (settled) return; settled = true;
543
+ const kind = watchFailureClass(cause);
544
+ recordWatchFailure(dir, snapshot, ctx.actor, kind);
545
+ console.error(`ares watch: background pass failed (${kind}); inspect ares watch show and lit list before retrying`);
546
+ }
547
+ }).then(() => {}, cause => {
548
+ if (!started) reject(cause);
549
+ else console.error(`ares watch: failure receipt could not be written (${watchFailureClass(cause)}); restore study storage, inspect ares watch show and lit list, then retry`);
550
+ });
551
+ track?.(completion, () => terminate());
552
+ return admission;
553
+ }
554
+ export const watchShow = (dir: string) => ({ ...showWatch(dir), newCandidates: list(dir, { status: "new", limit: 500 }) });
555
+
556
+ function consoleHome(env: NodeJS.ProcessEnv): string {
557
+ return env.AGENTHUB_HOME?.trim() || join(env.HOME?.trim() || homedir(), ".agenthub");
558
+ }
559
+ export const consoleAdd = (dir: string, env: NodeJS.ProcessEnv) => addStudy(consoleHome(env), dir);
560
+ export const consoleRemove = (dir: string, env: NodeJS.ProcessEnv) => removeStudy(consoleHome(env), dir);
561
+ export const consoleList = (env: NodeJS.ProcessEnv) => readRegistry(consoleHome(env));