@staix/agent-research 0.1.0 → 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.
package/src/commands.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { validateAutonomyMode, validatePhase, PHASES, ACCEPT_ARTIFACTS, resolveAutonomy, type AcceptArtifact, type Autonomy } from "./autonomy.ts";
2
+ import { copilotBinding, requireCopilotAccept } from "./copilot.ts";
1
3
  import { homedir } from "node:os";
2
4
  import { addStudy, removeStudy, readRegistry } from "./console/registry.ts";
3
- import { readWatchSnapshot, setWatch, runWatch, showWatch, type WatchSnapshot } from "./lit/watch.ts";
5
+ import { readWatchSnapshot, setWatch, runWatch, showWatch, recordWatchFailure, watchFailureClass, type WatchSnapshot } from "./lit/watch.ts";
4
6
  import { runSetup, type SetupMode } from "./setup.ts";
5
7
  import { proposeSkill, decideSkill, listSkills } from "./memory/skills.ts";
6
8
  import { rankLessons, type LessonRankOptions } from "./memory/lessons.ts";
@@ -21,11 +23,12 @@ import { spawnRunner } from "./exp/executor.ts";
21
23
  import { foldPlan, readPlanState, planGateBinding, prepareControl, type ControlRequest, type ControlResult } from "./plan/state.ts";
22
24
  import { setPlan } from "./plan/definition.ts";
23
25
  export { manifest } from "./manifest.ts";
26
+ import { exportBundle } from "./study-export.ts";
24
27
  import { existsSync, readdirSync, utimesSync } from "node:fs";
25
28
  import { join, resolve } from "node:path";
26
29
  import type { Http } from "./http.ts";
27
30
  import { readScope, scopeHash, setScope } from "./scope.ts";
28
- import { boundedText, commit, decideGate, gateState, GATES, initStudy, openStudy, readBytesBounded, readLedger, readTextBounded, requireGate, sha256, withLock, readPlanOwner, type Actor, type Binder, type GateName, type Study } from "./study.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";
29
32
  import { candidatesFile, includedHash, isVerified, loadCandidates, mergeCandidates, sameWork, verifySubject, type Candidate } from "./lit/candidates.ts";
30
33
  import { searchSource, SOURCES, type SourceName } from "./lit/sources.ts";
31
34
  import { verifyCandidates } from "./lit/verify.ts";
@@ -43,10 +46,10 @@ export interface Ctx extends ExperimentRuntime { actor: Actor; http: Http; signa
43
46
  const MAX_IDS = 200, DRAFT_BYTES = 2 * 1024 * 1024, BIB_BYTES = 16 * 1024 * 1024, CARD_BYTES = 65_536;
44
47
 
45
48
  export function binder(dir: string): Binder {
46
- return gate => 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));
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));
47
50
  }
48
51
  function gateName(value: string): GateName {
49
- if (!GATES.includes(value as GateName) && !value.startsWith("plan:")) throw new Error(`unknown gate: ${value} (${GATES.join(", ")})`);
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");
50
53
  return value as GateName;
51
54
  }
52
55
  function ids(values: string[]): string[] {
@@ -65,6 +68,24 @@ export function init(dir: string, options: { question: string; mode?: string; au
65
68
  return { created: resolve(dir), study };
66
69
  }
67
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
+
68
89
  export async function protocol(dirArg: string, raw: unknown, ctx: Ctx) {
69
90
  const { dir } = openStudy(dirArg);
70
91
  return withLock(dir, () => {
@@ -103,6 +124,8 @@ export function status(dirArg: string) {
103
124
  };
104
125
  }
105
126
 
127
+ export const studyExport = (dir: string, options: { bundle: string; list?: boolean }) => exportBundle(dir, options);
128
+
106
129
  export async function scope(dirArg: string, raw: unknown, ctx: Ctx) {
107
130
  const { dir } = openStudy(dirArg);
108
131
  return withLock(dir, () => {
@@ -114,7 +137,8 @@ export async function scope(dirArg: string, raw: unknown, ctx: Ctx) {
114
137
  export function gateList(dirArg: string) {
115
138
  const { dir, study } = openStudy(dirArg), events = readLedger(dir), bind = binder(dir);
116
139
  const plan = foldPlan(events);
117
- const gates: GateName[] = [...GATES.filter(g => (g !== "design" && g !== "final") || study.mode === "experiment"), ...(plan?.nodes.filter(n => n.kind === "gate").map(n => `plan:${n.id}` as GateName) ?? [])];
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) ?? [])];
118
142
  return gates.map(g => gateState(g, events, bind));
119
143
  }
120
144
 
@@ -262,23 +286,26 @@ export async function planControl(dirArg: string, request: ControlRequest, ctx:
262
286
  const { dir } = openStudy(dirArg);
263
287
  const prepared = await withLock(dir, () => {
264
288
  const events = readLedger(dir), state = readPlanState(dir, events);
265
- const prepared = prepareControl(state, events, request, ctx.actor);
289
+ const prepared = prepareControl(state, events, request, ctx.actor, openStudy(dir).study.autonomy);
266
290
  if (!prepared.event) return { result: prepared.result };
267
291
  const node = state.nodes.find(n => n.id === request.node);
268
292
  const attempt = node?.attemptId ? listAttempts(dir).attempts.find(a => a.id === node.attemptId) : undefined;
269
- if (request.action === "cancel" && attempt) return { attemptId: attempt.id };
293
+ if (request.action === "cancel" && attempt) return { attemptId: attempt.id, dispatchId: node?.dispatchId };
270
294
  // A missing record after a crash is uncertain, and explicit retry/cancel may release it.
271
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`);
272
- commit(dir, [], [prepared.event]);
296
+ commit(dir, [], [prepared.event, ...(prepared.pause ? [prepared.pause] : [])]);
273
297
  return { result: prepared.result };
274
298
  });
275
299
  if (prepared.result) return prepared.result;
276
300
  let result: ControlResult | undefined;
277
301
  await cancelAttempt(dir, prepared.attemptId!, "plan cancellation", ctx, () => {
278
302
  const events = readLedger(dir), state = readPlanState(dir, events);
279
- const prepared = prepareControl(state, events, request, ctx.actor);
280
- result = prepared.result;
281
- return prepared.event ? [prepared.event] : [];
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] : [])] : [];
282
309
  });
283
310
  return result!;
284
311
  }
@@ -317,6 +344,7 @@ export async function planRun(dirArg: string, options: { parallel?: number }, ct
317
344
  actor: ctx.actor, ...(ctx.signal ? { signal: ctx.signal } : {}), ...(ctx.sleep ? { sleep: ctx.sleep } : {}),
318
345
  prepare: async (node, state) => {
319
346
  if (node.command !== "attempt.run") return {};
347
+ requireCopilotAccept(dir, "hypotheses");
320
348
  const design = readDesign(dir);
321
349
  if (!design) throw new Error("plan attempt: protocol missing; run ares protocol before resume");
322
350
  const options = attemptOptions(node, state.retryOf), snapshot = await resolveSnapshot(options.repo, options.commit, ctx.runner ?? spawnRunner);
@@ -495,19 +523,32 @@ export async function watchRun(dirArg: string, ctx: Ctx) {
495
523
  }
496
524
  export interface WatchStarted { started: true; configSha256: string }
497
525
  /** Acknowledge only validated admission; the finite pass keeps the same lock until its receipt is written. */
498
- export function watchRunDetached(dirArg: string, ctx: Ctx, track?: (completion: Promise<void>) => void): Promise<WatchStarted> {
526
+ export function watchRunDetached(dirArg: string, ctx: Ctx, track?: (completion: Promise<void>, terminate: () => void) => void): Promise<WatchStarted> {
499
527
  const { dir, study } = openStudy(dirArg);
500
528
  let accept!: (value: WatchStarted) => void, reject!: (cause: unknown) => void, started = false;
529
+ let terminate = () => {};
501
530
  const admission = new Promise<WatchStarted>((resolve, refuse) => { accept = resolve; reject = refuse; });
502
531
  const completion = withLock(dir, async () => {
503
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
+ };
504
539
  started = true; accept({ started: true, configSha256: snapshot.sha256 });
505
- await watchRunLocked(dir, snapshot, ctx);
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
+ }
506
547
  }).then(() => {}, cause => {
507
548
  if (!started) reject(cause);
508
- else console.error("ares watch: background pass failed; inspect ares watch show and lit list before retrying");
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`);
509
550
  });
510
- track?.(completion);
551
+ track?.(completion, () => terminate());
511
552
  return admission;
512
553
  }
513
554
  export const watchShow = (dir: string) => ({ ...showWatch(dir), newCandidates: list(dir, { status: "new", limit: 500 }) });
package/src/copilot.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { lstatSync, opendirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ACCEPT_ARTIFACTS, resolveAutonomy, type AcceptArtifact } from "./autonomy.ts";
4
+ import { canonical } from "./plan/state.ts";
5
+ import { gateState, openStudy, readBytesBounded, readLedger, readTextBounded, sha256 } from "./study.ts";
6
+
7
+ export const COPILOT_HYPOTHESES_BYTES = 2 * 1024 * 1024;
8
+ export const COPILOT_PAPER_BYTES = 64 * 1024 * 1024;
9
+ export const COPILOT_PAPER_FILES = 500, COPILOT_PAPER_DIRECTORIES = 1000, COPILOT_PAPER_DEPTH = 64;
10
+ export class CopilotAcceptError extends Error {}
11
+ class DraftReadError extends CopilotAcceptError {}
12
+ function refuse(artifact: AcceptArtifact, reason: string): never {
13
+ throw new DraftReadError(`copilot draft ${artifact}: ${reason}; restore the last valid bounded regular draft files and ledger, then run ares gate list and ares gate approve <dir> accept:${artifact} before retrying`);
14
+ }
15
+ function draftLedger(dir: string, artifact: AcceptArtifact) {
16
+ try { return readLedger(dir); }
17
+ catch (error) {
18
+ if (error instanceof Error && /^ledger\.jsonl line [0-9]+: unsupported schema version/.test(error.message)) throw new DraftReadError(`copilot draft ${artifact}: unsupported ledger version; keep its bytes unchanged and use a compatible ares version before ares gate approve <dir> accept:${artifact}`);
19
+ refuse(artifact, "acceptance ledger is unreadable, malformed or over-bound");
20
+ }
21
+ }
22
+ function metadata(file: string) {
23
+ try { return lstatSync(file); }
24
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
25
+ }
26
+ function paperTree(dir: string): [string, string][] {
27
+ const root = join(dir, "paper"), entries: [string, string][] = [];
28
+ const rootStat = metadata(root);
29
+ if (rootStat === undefined) return entries;
30
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) refuse("paper", "paper source root must be a directory without symbolic links");
31
+ const derived = metadata(join(root, "build"));
32
+ let bytes = 0, directories = 0;
33
+ function walk(relative: string, depth: number): void {
34
+ // paper/build is derived output; manuscript sources include all other notes and assets.
35
+ if (relative === "build") return;
36
+ const file = relative ? join(root, relative) : root, stat = lstatSync(file);
37
+ // Respect the filesystem's case aliases without excluding a distinct source directory on case-sensitive hosts.
38
+ if (depth === 1 && relative.toLowerCase() === "build" && derived && derived.dev === stat.dev && derived.ino === stat.ino) return;
39
+ if (stat.isSymbolicLink()) refuse("paper", "symbolic links refused");
40
+ if (stat.isDirectory()) {
41
+ if (depth > COPILOT_PAPER_DEPTH || ++directories > COPILOT_PAPER_DIRECTORIES) refuse("paper", `source tree exceeds ${COPILOT_PAPER_DIRECTORIES} directories or ${COPILOT_PAPER_DEPTH} levels`);
42
+ const directory = opendirSync(file);
43
+ try { for (let entry = directory.readSync(); entry; entry = directory.readSync()) walk(relative ? `${relative}/${entry.name}` : entry.name, depth + 1); }
44
+ finally { directory.closeSync(); }
45
+ } else {
46
+ if (!relative || !stat.isFile()) refuse("paper", "regular files within the paper directory required");
47
+ if (entries.length >= COPILOT_PAPER_FILES) refuse("paper", `file count exceeds ${COPILOT_PAPER_FILES}`);
48
+ if (stat.size > COPILOT_PAPER_BYTES - bytes) refuse("paper", `aggregate bytes exceed ${COPILOT_PAPER_BYTES}`);
49
+ const content = readBytesBounded(file, COPILOT_PAPER_BYTES - bytes, { noFollow: true });
50
+ bytes += content.byteLength; entries.push([relative, sha256(content)]);
51
+ }
52
+ }
53
+ walk("", 0);
54
+ return entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
55
+ }
56
+
57
+ /** Only active, present drafts have accept bindings. Irrelevant and acceptance events are excluded. */
58
+ export function copilotBinding(dirArg: string, artifact: AcceptArtifact): string | undefined {
59
+ if (!ACCEPT_ARTIFACTS.includes(artifact)) throw new Error("copilot draft: unknown artifact; select hypotheses, paper or reviews and retry ares gate list");
60
+ const { dir, study } = openStudy(dirArg);
61
+ if (resolveAutonomy(study.autonomy, `accept:${artifact}`) !== "copilot") return undefined;
62
+ try {
63
+ if (artifact === "paper") {
64
+ const files = paperTree(dir);
65
+ return files.length ? sha256(canonical(files)) : undefined;
66
+ }
67
+ const events = draftLedger(dir, artifact).filter(event => artifact === "hypotheses" ? event.type === "idea.added" || event.type === "idea.decided" : event.type === "review.added" || event.type === "review.resolved");
68
+ if (artifact === "reviews") return events.length ? sha256(canonical(events)) : undefined;
69
+ const file = join(dir, "hypotheses.md"), stat = metadata(file);
70
+ let hypotheses: string | undefined;
71
+ if (stat !== undefined) {
72
+ if (stat.isSymbolicLink() || !stat.isFile()) refuse(artifact, "regular hypotheses file required; symbolic links refused");
73
+ if (stat.size > COPILOT_HYPOTHESES_BYTES) refuse(artifact, `hypotheses source exceeds ${COPILOT_HYPOTHESES_BYTES} bytes`);
74
+ hypotheses = readTextBounded(file, COPILOT_HYPOTHESES_BYTES, { noFollow: true });
75
+ }
76
+ return hypotheses !== undefined || events.length ? sha256(canonical([hypotheses === undefined ? null : sha256(hypotheses), events])) : undefined;
77
+ } catch (error) {
78
+ if (error instanceof DraftReadError) throw error;
79
+ refuse(artifact, "artifact or ledger is unreadable, non-regular, invalid UTF-8 or over-bound");
80
+ }
81
+ }
82
+
83
+ /** Acceptance records the existing gate actor; neither agents nor people receive an implicit approval. */
84
+ export function requireCopilotAccept(dir: string, artifact: AcceptArtifact): void {
85
+ const binding = copilotBinding(dir, artifact);
86
+ if (binding === undefined) return;
87
+ const events = draftLedger(dir, artifact);
88
+ const gate = `accept:${artifact}` as const, state = gateState(gate, events, () => binding);
89
+ if (state.state === "approved") return;
90
+ throw new CopilotAcceptError(`copilot draft ${artifact}: ${state.state} acceptance; run ares gate approve <dir> ${gate} for the current draft before retrying`);
91
+ }
@@ -1,3 +1,5 @@
1
+ import { requireCopilotAccept } from "../copilot.ts";
2
+ import { resolveAutonomy } from "../autonomy.ts";
1
3
  import { readFinalSeeds, authorizeFinalAttempt, finalGateBinding, requirePosthocClosed } from "./final.ts";
2
4
  import { closeSync, existsSync, lstatSync, openSync, opendirSync, statSync, unlinkSync, writeSync } from "node:fs";
3
5
  import { hostname } from "node:os";
@@ -28,6 +30,8 @@ export interface AttemptContext { actor: Actor; runner?: ProcessRunner; now?: ()
28
30
  export const ATTEMPT_BYTES = 1024 * 1024;
29
31
  // Startup includes bounded image pulls and two 5-minute transfers; claims never survive indefinitely after a crash.
30
32
  export const CONTROL_STALE_MS = 30 * 60_000;
33
+ // Filesystem mtime has sub-ms precision; Date.now is integral. Tolerate small future skew on live locks.
34
+ export const CONTROL_FUTURE_SKEW_MS = 60_000;
31
35
  const controlScope = new AsyncLocalStorage<{ dir: string; token: string }>();
32
36
  const STATUSES: AttemptStatus[] = ["running", "succeeded", "failed", "cancelled", "unknown"];
33
37
  const inspectFields = (prefix: string, fields: string[]) => fields.map(field => `${JSON.stringify(field)}:{{json ${prefix}.${field}}}`).join(",");
@@ -159,7 +163,7 @@ async function withControl<T>(dir: string, fn: () => Promise<T>): Promise<T> {
159
163
  if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
160
164
  let age = 0; try { age = Date.now() - statSync(path).mtimeMs; } catch { /* owner removed it */ }
161
165
  if (performance.now() - waitingSince >= 2000) throw new Error("attempt control: another operation is active; retry reconcile or cancel when it finishes (abandoned claims expire after 30 minutes)");
162
- if (age < 0 || age >= CONTROL_STALE_MS) {
166
+ if (age < -CONTROL_FUTURE_SKEW_MS || age >= CONTROL_STALE_MS) {
163
167
  try { unlinkSync(path); }
164
168
  catch (failure) { if ((failure as NodeJS.ErrnoException).code !== "ENOENT") throw new Error("attempt control: abandoned lock could not be removed; restore study-directory ownership and permissions, then retry"); }
165
169
  continue;
@@ -240,10 +244,12 @@ async function recordVerifiedStage(dir: string, attempt: Attempt, state: Observe
240
244
  if (current.stage === "candidate") {
241
245
  current.sealing.candidateHasNoEvaluator = true;
242
246
  if (approveDesign && current.pendingAutoApproval === current.sealBinding && designBinding(dir) === current.sealBinding) {
243
- approvals.push(...requireGate(dir, { ...openStudy(dir).study, autonomy: "auto" }, "design", () => current.sealBinding));
247
+ const study = openStudy(dir).study;
248
+ if (resolveAutonomy(study.autonomy, "design") === "auto") approvals.push(...requireGate(dir, study, "design", () => current.sealBinding));
244
249
  }
245
250
  if (approveDesign && current.pendingFinalApproval === current.finalBinding && current.finalBinding && finalGateBinding(dir) === current.finalBinding) {
246
- approvals.push(...requireGate(dir, { ...openStudy(dir).study, autonomy: "auto" }, "final", () => current.finalBinding));
251
+ const study = openStudy(dir).study;
252
+ if (resolveAutonomy(study.autonomy, "final") === "auto") approvals.push(...requireGate(dir, study, "final", () => current.finalBinding));
247
253
  }
248
254
  delete current.pendingAutoApproval; delete current.pendingFinalApproval;
249
255
  } else {
@@ -319,6 +325,7 @@ async function captureStage(dir: string, attempt: Attempt, ctx: AttemptContext,
319
325
  export async function runAttempt(dirArg: string, options: AttemptOptions, ctx: AttemptContext): Promise<Attempt> {
320
326
  const dir = studyDir(dirArg), { study } = openStudy(dir);
321
327
  const frozen = await withLock(dir, () => {
328
+ requireCopilotAccept(dir, "hypotheses");
322
329
  const design = readDesign(dir), executor = readExecutor(dir);
323
330
  if (!design) throw new Error("protocol: missing; run ares protocol <dir> --file PATH before attempt run");
324
331
  if (!executor) throw new Error("executor: missing; run ares executor set <dir> --kind local|ssh --workdir PATH before attempt run");
@@ -338,13 +345,14 @@ export async function runAttempt(dirArg: string, options: AttemptOptions, ctx: A
338
345
  if (options.expectedId !== undefined && wanted !== options.expectedId) throw new Error("attempt identity: changed since plan intent; reconcile the plan node before retrying");
339
346
  let created = false;
340
347
  let attempt = await withLock(dir, () => {
348
+ requireCopilotAccept(dir, "hypotheses");
341
349
  if (run === "posthoc") requirePosthocClosed(dir);
342
350
  if (finalAuthorization && authorizeFinalAttempt(dir, options.commit, seed).binding !== finalAuthorization.binding) throw new Error("final inputs changed during snapshot resolution; approve the current final gate before retrying");
343
351
  const attempts = load(dir), existing = attempts.find(a => a.id === wanted); if (existing) return existing;
344
352
  if (designBinding(dir) !== initialBinding) throw new Error("design: changed while resolving the snapshot; approve the current design and retry attempt run");
345
353
  if (options.retry && !attempts.some(a => a.id === options.retry)) throw new Error("retry: attempt not found; choose an existing id from ares attempt list");
346
354
  if (attempts.some(a => a.status === "running")) throw new Error("attempt: another attempt is running; reconcile or cancel it before starting a new one");
347
- const binding = designBinding(dir), approval = requireGate(dir, study, "design", () => binding);
355
+ const binding = designBinding(dir), approval = requireGate(dir, openStudy(dir).study, "design", () => binding);
348
356
  enforceBudget(protocol, attempts, now(ctx));
349
357
  const root = posix.join(executor.workdir, study.id, wanted);
350
358
  const record: Attempt = { v: 1, id: wanted, studyId: study.id, run, protocolHash: hash, sealBinding: binding!, ...snapshot, seed, label, ...(options.retry ? { retryOf: options.retry } : {}), executor, protocol, paths: { snapshot: posix.join(root, "snapshot"), output: posix.join(root, "output"), evaluator: posix.join(root, "task", "evaluator"), result: posix.join(root, "result") }, status: "running", stage: "candidate", startedAt: at(ctx), stages: {}, metrics: [], outputs: [], sealing: { separateContainers: false, evaluatorReadOnly: false, candidateHasNoEvaluator: false, verifiedBy: "ares-observed" }, launching: owner(ctx) };
@@ -1,3 +1,4 @@
1
+ import { requireCopilotAccept } from "../copilot.ts";
1
2
  import { readFinalSeeds, finalSeedsHash } from "./final.ts";
2
3
  import { foldIdeas } from "./ideas.ts";
3
4
  import { existsSync } from "node:fs";
@@ -162,7 +163,9 @@ export function showInvestigation(dirArg: string): InvestigateResult {
162
163
  }
163
164
  /** Caller holds the study lock; every refusal precedes the single file/event commit. */
164
165
  export function closeInvestigation(dirArg: string, actor: Actor): InvestigateResult {
165
- const input = inputs(dirArg), file = investigateResultPath(input.dir, input.rule.run), previous = previousResult(file);
166
+ const input = inputs(dirArg);
167
+ requireCopilotAccept(input.dir, "hypotheses");
168
+ const file = investigateResultPath(input.dir, input.rule.run), previous = previousResult(file);
166
169
  if (input.attempts.some(a => a.status === "running")) throw new Error("investigate close: an attempt in the selected run is running; run ares attempt reconcile before closing");
167
170
  const result = calculate(input), text = JSON.stringify(result, null, 2) + "\n";
168
171
  if (previous !== text) commit(input.dir, [[file, text, INVESTIGATE_BYTES]], [{ actor, type: "investigate.closed", data: { run: input.rule.run, sha256: sha256(text), ruleSha256: input.ruleSha, winner: result.winner?.commit ?? null } }]);
@@ -172,7 +175,9 @@ export function closeInvestigation(dirArg: string, actor: Actor): InvestigateRes
172
175
 
173
176
  /** Read only a current, committed investigation result, never a hand-edited or stale winner. */
174
177
  export function readClosedInvestigation(dirArg: string): { result: InvestigateResult; sha256: string } {
175
- const input = inputs(dirArg), text = previousResult(investigateResultPath(input.dir, input.rule.run));
178
+ const input = inputs(dirArg);
179
+ requireCopilotAccept(input.dir, "hypotheses");
180
+ const text = previousResult(investigateResultPath(input.dir, input.rule.run));
176
181
  if (text === undefined) throw new Error("investigate result: not closed; run ares investigate close before the next phase");
177
182
  const hash = sha256(text), receipt = input.events.findLast(e => e.type === "investigate.closed" && e.data.run === input.rule.run);
178
183
  if (!receipt || receipt.data.sha256 !== hash) throw new Error("investigate result: bytes do not match the latest close receipt; run ares investigate close to restore the committed result");
@@ -1,3 +1,4 @@
1
+ import { requireCopilotAccept } from "../copilot.ts";
1
2
  import { finalSeedsHash } from "./final.ts";
2
3
  import { selectionHash } from "./selection.ts";
3
4
  import { existsSync, lstatSync, opendirSync } from "node:fs";
@@ -136,7 +137,9 @@ export function readDesign(dir: string): { protocol: Protocol; treeHash: string;
136
137
  /** Caller holds the study lock, as with setScope. Validate everything before any write. */
137
138
  export function setProtocol(dir: string, raw: unknown, actor: Actor): Protocol {
138
139
  requireExperiment(openStudy(dir).study);
139
- const protocol = validateProtocol(raw), treeHash = taskTreeHash(dir), text = JSON.stringify(protocol, null, 2) + "\n";
140
+ const protocol = validateProtocol(raw);
141
+ requireCopilotAccept(dir, "hypotheses");
142
+ const treeHash = taskTreeHash(dir), text = JSON.stringify(protocol, null, 2) + "\n";
140
143
  // Never commit a protocol that makes its existing selection unreadable.
141
144
  selectionHash(dir, protocol);
142
145
  finalSeedsHash(dir, protocol);
package/src/http.ts CHANGED
@@ -15,6 +15,47 @@ export interface Http {
15
15
  const TIMEOUT_MS = 20_000, MAX_BYTES = 5 * 1024 * 1024;
16
16
  const EMAIL = /^[^\s@<>()]{1,64}@[^\s@<>()]{1,190}$/;
17
17
 
18
+ export const S2_START_INTERVAL_MS = 1_100;
19
+ export interface HttpPacingClock {
20
+ now(): number;
21
+ sleep(ms: number): Promise<void>;
22
+ }
23
+ export interface HttpPacing {
24
+ start<T>(request: () => Promise<T>): Promise<T>;
25
+ }
26
+
27
+ /** Serialize admissions only: an in-flight response never holds the next start. */
28
+ export function createHttpPacing(clock: HttpPacingClock = {
29
+ now: () => performance.now(),
30
+ sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
31
+ }): HttpPacing {
32
+ let tail = Promise.resolve(), lastStart: number | undefined;
33
+ return {
34
+ start<T>(request: () => Promise<T>): Promise<T> {
35
+ const admitted = tail.then(async () => {
36
+ if (lastStart !== undefined) {
37
+ let remaining = S2_START_INTERVAL_MS - (clock.now() - lastStart);
38
+ while (remaining > 0) {
39
+ await clock.sleep(remaining);
40
+ remaining = S2_START_INTERVAL_MS - (clock.now() - lastStart);
41
+ }
42
+ }
43
+ // Measure after invocation, conservatively spacing actual fetch starts.
44
+ let response: Promise<T>;
45
+ try { response = request(); } finally { lastStart = clock.now(); }
46
+ // Box the promise so admission releases without waiting for the response.
47
+ // A later admission must not delay handling a rejected response.
48
+ void response.catch(() => {});
49
+ return { response };
50
+ });
51
+ tail = admitted.then(() => {}, () => {});
52
+ return admitted.then(({ response }) => response);
53
+ },
54
+ };
55
+ }
56
+
57
+ const processS2Pacing = createHttpPacing();
58
+
18
59
  /** Read the body while counting bytes; past the limit the stream is cancelled, never buffered whole. */
19
60
  async function readBounded(response: Response, host: string): Promise<string> {
20
61
  if (!response.body) return "";
@@ -38,11 +79,14 @@ export function userAgent(env: NodeJS.ProcessEnv): string {
38
79
  }
39
80
 
40
81
  /** 200 returns the body, 404 returns `body: undefined`; 429, 5xx, other statuses and network failures throw HttpError. */
41
- export function createHttp(env: NodeJS.ProcessEnv = process.env, fetchImpl: FetchLike = fetch as FetchLike): Http {
82
+ export function createHttp(env: NodeJS.ProcessEnv = process.env, fetchImpl: FetchLike = fetch as FetchLike, pacing: HttpPacing = processS2Pacing): Http {
42
83
  async function get(url: string, headers: Record<string, string> = {}): Promise<HttpResult<string>> {
43
84
  const host = new URL(url).host;
44
85
  let response: Response;
45
- try { response = await fetchImpl(url, { headers: { "user-agent": userAgent(env), ...headers }, signal: AbortSignal.timeout(TIMEOUT_MS) }); }
86
+ try {
87
+ const request = () => fetchImpl(url, { headers: { "user-agent": userAgent(env), ...headers }, signal: AbortSignal.timeout(TIMEOUT_MS) });
88
+ response = await (new URL(url).hostname === "api.semanticscholar.org" ? pacing.start(request) : request());
89
+ }
46
90
  catch { throw new HttpError("network", host); }
47
91
  if (response.status === 404) return { status: 404, body: undefined };
48
92
  if (response.status === 429) throw new HttpError("rate", host, 429);
package/src/lit/watch.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { HttpError } from "../http.ts";
1
2
  import { join } from "node:path";
2
3
  import { assertWritable, boundedText, commit, onlyKeys, openStudy, plain, readLedger, readBytesBounded, sha256, type Actor } from "../study.ts";
3
4
  import { loadCandidates } from "./candidates.ts";
@@ -10,7 +11,8 @@ export interface WatchSnapshot { config: WatchConfig; sha256: string }
10
11
  export interface SearchResult { added: number; merged: number; dropped: number; linked: number; sources: { source: SourceName; ok: boolean; returned: number; error?: string }[] }
11
12
  export interface WatchError { host: string; status: number | null }
12
13
  export interface WatchQueryResult { id: string; added: number; merged: number; dropped: number; linked: number; sources: { source: SourceName; ok: boolean; returned: number }[]; errors: WatchError[] }
13
- export interface WatchRun { configSha256: string; queries: WatchQueryResult[]; added: number; addedIds: string[]; addedIdsTotal: number; ok: boolean }
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 }
14
16
  const HOSTS: Record<SourceName, string> = { openalex: "api.openalex.org", s2: "api.semanticscholar.org", arxiv: "export.arxiv.org" };
15
17
  class WatchInputError extends Error {}
16
18
  class WatchVersionError extends WatchInputError {}
@@ -80,6 +82,17 @@ function sourceErrors(sources: SearchResult["sources"]): WatchError[] {
80
82
  return { host, status: match?.[1] === undefined ? null : Number(match[1]) };
81
83
  });
82
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
+ }
83
96
  /** One finite pass, using the caller's existing locked lit-search operation. No timer or screening. */
84
97
  export async function runWatch(dir: string, snapshot: WatchSnapshot, actor: Actor, search: (query: WatchQuery) => Promise<SearchResult>): Promise<WatchRun> {
85
98
  const { config } = snapshot;
package/src/manifest.ts CHANGED
@@ -5,7 +5,7 @@ import { ARES_TOOLS } from "./mcp-tools.ts";
5
5
  /** Implemented family-readable records only; private inputs and future integrations are not contracts. */
6
6
  export const FORMAT_VERSIONS = {
7
7
  manifest: 1,
8
- study: 1,
8
+ study: 2,
9
9
  paperEvidence: 1,
10
10
  skillProposal: 1,
11
11
  studyRegistry: 1,
@@ -20,10 +20,11 @@ export const FORMAT_VERSIONS = {
20
20
  investigateResult: 1,
21
21
  executor: 1,
22
22
  experimentAttempt: 1,
23
+ studyBundle: 1,
23
24
  } as const;
24
25
  export const MEMORY_LEDGER_EVENTS = ["archive.exported", "skill.proposed", "skill.decided"] as const;
25
26
  export const WATCH_LEDGER_EVENTS = ["watch.set", "watch.ran"] as const;
26
- export const LEDGER_EVENTS = [...MEMORY_LEDGER_EVENTS, ...WATCH_LEDGER_EVENTS] as const;
27
+ export const LEDGER_EVENTS = [...MEMORY_LEDGER_EVENTS, ...WATCH_LEDGER_EVENTS, "autonomy.set"] as const;
27
28
  export interface Manifest {
28
29
  v: 1;
29
30
  name: "ares";
package/src/mcp-tools.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { PHASES, AUTONOMY_MODES } from "./autonomy.ts";
1
2
  import { SETUP_TARGETS, type SetupMode } from "./setup.ts";
2
3
  import { isAbsolute } from "node:path";
3
4
  import * as ops from "./commands.ts";
@@ -30,8 +31,8 @@ const SKILL = obj({ name: text(64), summary: text(16384), steps: { ...texts(1638
30
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"]);
31
32
  const CARD = obj({ summary: text(4000), claims: texts(50, 1000), methods: text(4000), findings: text(4000), limitations: text(4000), relevance: text(4000) }, ["summary"]);
32
33
  const integer = (minimum: number, maximum: number) => ({ type: "integer", minimum, maximum });
33
- const WATCH = obj({ v: { type: "integer", enum: [1] }, queries: { type: "array", minItems: 1, maxItems: 20,
34
- 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) }, ["v", "queries"]);
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"]);
35
36
  const argv = { type: "array", minItems: 1, maxItems: 128, items: { type: "string", maxLength: 4096 } };
36
37
  const image = { ...text(512), description: "Digest-pinned image: name@sha256:<64 lowercase hex digits>" };
37
38
  const stage = { type: "string", enum: ["candidate", "evaluator"] };
@@ -53,7 +54,7 @@ const planId = text(PLAN_ID_BYTES);
53
54
  const PLAN = obj({
54
55
  v: { type: "integer", enum: [1] }, revision: integer(1, Number.MAX_SAFE_INTEGER),
55
56
  nodes: { type: "array", maxItems: 128, items: obj({
56
- id: planId, kind: { type: "string", enum: ["step", "hub", "gate"] },
57
+ id: planId, kind: { type: "string", enum: ["step", "hub", "gate"] }, phase: { type: "string", enum: [...PHASES] },
57
58
  needs: { type: "array", maxItems: 128, items: planId },
58
59
  command: { type: "string", enum: [...STEP_COMMANDS] },
59
60
  args: { type: "object", maxProperties: 32, properties: {}, additionalProperties: { type: ["string", "number", "boolean", "array"], maxLength: 4096, maxItems: 500, items: text(300) } },
@@ -72,6 +73,8 @@ const SELECTION = obj({
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"]);
73
74
 
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 },
75
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 } },
76
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 },
77
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 },
@@ -89,8 +92,9 @@ export const ARES_TOOLS: readonly McpTool[] = [
89
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 },
90
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 },
91
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 },
92
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 },
93
- { name: "ares_gate", description: "List gates, or approve/reject scope, screen, design, final, review or plan:node-id. 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 },
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 },
94
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 },
95
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 },
96
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 },
@@ -132,13 +136,15 @@ function absolute(value: unknown, what: string): string {
132
136
  }
133
137
 
134
138
  /** MCP calls always come from an agent; the actor id is the detected agent or `mcp`. */
135
- export function createAresMcp(env: NodeJS.ProcessEnv, http: Http = createHttp(env), runtime: ops.ExperimentRuntime = {}): { tools: readonly McpTool[]; handlers: Record<string, McpHandler>; waitForIdle(): Promise<void> } {
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 } {
136
140
  const detected = detectActor(env), actor: Actor = { kind: "agent", id: detected.kind === "agent" ? detected.id! : "mcp" };
137
141
  const ctx = { ...runtime, actor, http }, memoryEnv = { ...env, AGENTHUB_PEER_ID: actor.id };
138
142
  const dir = (a: Record<string, unknown>) => absolute(a.study, "study");
139
- const background = new Set<Promise<void>>();
140
- const track = (completion: Promise<void>) => { background.add(completion); void completion.then(() => { background.delete(completion); }); };
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); }); };
141
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),
142
148
  ares_watch_set: a => ops.watchSet(dir(a), a.watch, ctx),
143
149
  ares_watch_run: a => ops.watchRunDetached(dir(a), ctx, track),
144
150
  ares_watch_show: a => ops.watchShow(dir(a)),
@@ -156,6 +162,7 @@ export function createAresMcp(env: NodeJS.ProcessEnv, http: Http = createHttp(en
156
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),
157
163
  ares_plan_show: a => ops.planShow(dir(a)),
158
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 } : {}) }),
159
166
  ares_scope_set: a => ops.scope(dir(a), a.scope, ctx),
160
167
  ares_idea_add: a => ops.ideaAdd(dir(a), a.idea, ctx),
161
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),
@@ -196,5 +203,8 @@ export function createAresMcp(env: NodeJS.ProcessEnv, http: Http = createHttp(en
196
203
  ares_cite_check: a => ops.cite(dir(a), absolute(a.draft, "draft")),
197
204
  ares_playbook: () => ({ playbook: PLAYBOOK }),
198
205
  };
199
- return { tools: ARES_TOOLS, handlers, waitForIdle: async () => { await Promise.all([...background]); } };
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()]); } };
200
210
  }
package/src/mcp.ts CHANGED
@@ -12,6 +12,8 @@ const plain = (v: unknown): v is Record<string, unknown> => !!v && typeof v ===
12
12
 
13
13
  /** Runtime validation for the small schema vocabulary the tools use. Unknown keywords never widen acceptance. */
14
14
  export function validArguments(value: unknown, raw: unknown): boolean {
15
+ // An empty JSON schema delegates a frame-bounded field (watch version) to the shared validator.
16
+ if (plain(raw) && Object.keys(raw).length === 0) return true;
15
17
  const s = raw as Record<string, any>, type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
16
18
  const types: unknown[] = Array.isArray(s.type) ? s.type : [s.type];
17
19
  if (!types.includes(type) && !(type === "number" && Number.isSafeInteger(value) && types.includes("integer"))) return false;
@@ -1,3 +1,5 @@
1
+ import { ACCEPT_ARTIFACTS } from "../autonomy.ts";
2
+ import { requireCopilotAccept } from "../copilot.ts";
1
3
  import { commit, prepareCommit, openStudy, plain, sha256, type Actor, type LedgerEvent } from "../study.ts";
2
4
  import { canonical } from "../plan/state.ts";
3
5
  import { createAmemClient, type AmemNote } from "./amem.ts";
@@ -35,6 +37,7 @@ function exported(events: LedgerEvent[]): Set<string> {
35
37
  }
36
38
  /** No lock, subprocess or write: confirmation binds the full list, receipts only narrow pending writes. */
37
39
  export function previewArchive(dir: string): ArchivePreview {
40
+ for (const artifact of ACCEPT_ARTIFACTS) requireCopilotAccept(dir, artifact);
38
41
  const sources = readLessonSources(dir), notes = deriveLessons(sources), known = exported(sources.events);
39
42
  const unique = new Map(notes.map(note => [sha256(canonical(note)), note]));
40
43
  const pending = [...unique].filter(([hash]) => !known.has(hash)).map(([sha256, note]) => ({ sha256, note }));
@@ -1,3 +1,4 @@
1
+ import { requireCopilotAccept } from "../copilot.ts";
1
2
  import { existsSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { assertWritable, commit, openStudy, plain, readLedger, readTextBounded, sha256, type Actor } from "../study.ts";
@@ -50,7 +51,9 @@ function calculatePaper(dir: string) {
50
51
  }
51
52
  /** Caller holds the study lock. Refusals precede the single output-and-receipt commit. */
52
53
  export function buildPaper(dirArg: string, actor: Actor) {
53
- const { dir } = openStudy(dirArg), paths = paperBuildPaths(dir), oldEvidence = evidenceBytes(dir);
54
+ const { dir } = openStudy(dirArg);
55
+ requireCopilotAccept(dir, "hypotheses");
56
+ const paths = paperBuildPaths(dir), oldEvidence = evidenceBytes(dir);
54
57
  const { report, text, manuscriptSha256, evidenceSha256 } = calculatePaper(dir), oldManuscript = previous(paths.manuscript, MANUSCRIPT_BYTES);
55
58
  const receipt = readLedger(dir).findLast(event => event.type === "paper.built");
56
59
  const changed = oldManuscript !== report.rendered || oldEvidence !== text || receipt?.data.manuscriptSha256 !== manuscriptSha256 || receipt.data.evidenceSha256 !== evidenceSha256;