@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,222 @@
1
+ import { CopilotAcceptError } from "../copilot.ts";
2
+ import { boundHubTask, type HubClient, type HubObservation } from "./hub.ts";
3
+ import { assertPlanOwner, commit, openStudy, readLedger, prepareCommit, requireGate, withLock, withPlanLock, StudyLockBusyError, type Actor, type PendingEvent, type PlanOwner } from "../study.ts";
4
+ import { canonical, foldPlan, hasRemainingWork, TERMINAL_NODE_STATUSES, nextDispatchId, planGateBinding, readPlanState, type NodeState, type PlanState } from "./state.ts";
5
+ import { guardedPlanPhase, type PlanNode } from "./definition.ts";
6
+ import { resolveAutonomy, resolvePhaseAutonomy, type Autonomy } from "../autonomy.ts";
7
+
8
+ export type StepNode = Extract<PlanNode, { kind: "step" }>;
9
+ export type HubNode = Extract<PlanNode, { kind: "hub" }>;
10
+ export interface Observation { status: "dispatched" | "done" | "failed" | "cancelled" | "uncertain"; output?: unknown; reason?: string; progress?: string; taskId?: number; hubProject?: string }
11
+ export interface ControllerRuntime {
12
+ actor: Actor;
13
+ hub?: HubClient;
14
+ signal?: AbortSignal;
15
+ sleep?: (ms: number) => Promise<void>;
16
+ clock?: () => number;
17
+ prepare: (node: StepNode, state: NodeState) => Promise<{ attemptId?: string }>;
18
+ execute: (node: StepNode, state: NodeState) => Promise<Observation>;
19
+ reconcile: (node: StepNode, state: NodeState) => Promise<Observation>;
20
+ }
21
+ export interface ControllerOptions { parallel?: number }
22
+ const delay = (ms: number, signal?: AbortSignal): Promise<void> => new Promise(resolve => {
23
+ if (signal?.aborted) { resolve(); return; }
24
+ const finish = () => { clearTimeout(timer); signal?.removeEventListener("abort", finish); resolve(); };
25
+ const timer = setTimeout(finish, ms); signal?.addEventListener("abort", finish, { once: true });
26
+ });
27
+ async function tryTransaction<T>(dir: string, fn: () => T): Promise<{ value: T } | undefined> {
28
+ try { return { value: await withLock(dir, fn) }; }
29
+ catch (error) { if (error instanceof StudyLockBusyError) return undefined; throw error; }
30
+ }
31
+ async function transaction<T>(dir: string, fn: () => T): Promise<T> {
32
+ let wait = 50;
33
+ for (;;) {
34
+ const result = await tryTransaction(dir, fn);
35
+ if (result) return result.value;
36
+ await delay(wait); wait = Math.min(wait * 2, 1000);
37
+ }
38
+ }
39
+ /** One controller, short transactional study locks, and no replay of unobserved actions. */
40
+ export async function runPlan(dirArg: string, options: ControllerOptions, runtime: ControllerRuntime): Promise<PlanState> {
41
+ const { dir } = openStudy(dirArg), parallel = options.parallel ?? 1;
42
+ if (!Number.isSafeInteger(parallel) || parallel < 1 || parallel > 8) throw new Error("run --parallel: integer in 1..8 required; run ares run <dir> --parallel 1..8");
43
+ return withPlanLock(dir, async owner => {
44
+ const state = () => readPlanState(dir, readLedger(dir));
45
+ const clock = runtime.clock ?? (() => performance.now());
46
+ const sleep = (ms: number) => runtime.sleep ? runtime.sleep(ms) : delay(ms, runtime.signal);
47
+ const polls = new Map<string, { at: number; wait: number; signature: string }>();
48
+ const schedule = (node: NodeState, observation: Observation) => {
49
+ if (!node.dispatchId) return;
50
+ if (observation.status !== "dispatched") { polls.delete(node.dispatchId); return; }
51
+ const previous = polls.get(node.dispatchId), signature = canonical(observation);
52
+ const wait = previous?.signature === signature ? Math.min(previous.wait * 2, 30_000) : 1000;
53
+ polls.set(node.dispatchId, { at: clock() + wait, wait, signature });
54
+ };
55
+ const locked = <T>(fn: () => T) => transaction(dir, () => { assertPlanOwner(dir, owner); return fn(); });
56
+ const observe = async (node: NodeState, observation: Observation) => {
57
+ const target = state().nodes.find(n => n.id === node.id);
58
+ if (!target || target.dispatchId !== node.dispatchId || !["dispatched", "uncertain"].includes(target.status)) return;
59
+ if (observation.status === "dispatched" && target.lastObserved?.dispatchId === node.dispatchId && target.reason === observation.reason && (observation.taskId === undefined || target.taskId === observation.taskId) && (observation.hubProject === undefined || target.hubProject === observation.hubProject)) return;
60
+ return locked(() => {
61
+ const ledger = readLedger(dir), current = readPlanState(dir, ledger), target = current.nodes.find(n => n.id === node.id);
62
+ if (!target || target.dispatchId !== node.dispatchId || !["dispatched", "uncertain"].includes(target.status)) return;
63
+ if (observation.status === "dispatched" && target.status === "dispatched" && target.lastObserved && target.lastObserved.dispatchId === node.dispatchId && target.lastObserved.status === "dispatched" && target.reason === observation.reason && (observation.taskId === undefined || target.taskId === observation.taskId) && (observation.hubProject === undefined || target.hubProject === observation.hubProject)) return;
64
+ const events: PendingEvent[] = [{ actor: runtime.actor, type: "node.observed", data: { revision: current.revision, node: node.id, dispatchId: node.dispatchId, status: observation.status, ...(observation.output !== undefined ? { output: observation.output } : {}), ...(observation.reason !== undefined ? { reason: observation.reason } : {}), ...(observation.taskId !== undefined ? { taskId: observation.taskId } : {}), ...(observation.hubProject !== undefined ? { hubProject: observation.hubProject } : {}) } }];
65
+ const definition = current.definition.nodes.find(value => value.id === node.id)!;
66
+ if (definition.kind !== "gate" && nodeMode(openStudy(dir).study.autonomy, definition) === "step") {
67
+ const next = foldPlan([...ledger, ...prepareCommit(dir, [], events).events])!;
68
+ // A hub binding conflict can turn a dispatched observation into a terminal failure in the fold.
69
+ if (TERMINAL_NODE_STATUSES.includes(next.nodes.find(value => value.id === node.id)!.status)) {
70
+ events[0]!.data.step = true; // Preserve the completion-time policy when replaying terminal failure.
71
+ if (hasRemainingWork(next)) events.push({ actor: runtime.actor, type: "plan.paused", data: { revision: current.revision, reason: "step", node: node.id, dispatchId: node.dispatchId } });
72
+ }
73
+ }
74
+ commit(dir, [], events);
75
+ });
76
+ };
77
+ const reconcile = async (node: NodeState): Promise<void> => {
78
+ const definition = state().definition.nodes.find(n => n.id === node.id);
79
+ if (!definition || (definition.kind === "hub" && !runtime.hub)) return;
80
+ let observation: Observation;
81
+ if (definition.kind === "hub") {
82
+ try { observation = await runtime.hub!.observe(definition.campaign, definition.unit, node.taskId ?? boundHubTask(readLedger(dir), state().revision, node.id)); }
83
+ catch { observation = { status: "dispatched", reason: "hub read unavailable; check ahub study show and restore hub access" }; }
84
+ } else if (definition.kind !== "step" || definition.command !== "attempt.run") observation = { status: "uncertain", reason: "step result unavailable after interruption; explicitly retry or skip this node" };
85
+ else {
86
+ try { observation = await runtime.reconcile(definition, node); }
87
+ catch (error) { if (error instanceof StudyLockBusyError) { schedule(node, { status: "dispatched", reason: node.reason }); return; } observation = { status: "dispatched", reason: "attempt observation unavailable; check the executor and run ares attempt reconcile" }; }
88
+ }
89
+ await observe(node, observation); schedule(node, observation);
90
+ };
91
+ await locked(() => {
92
+ const current = state();
93
+ if (current.state === "running") commit(dir, [], [{ actor: runtime.actor, type: "plan.paused", data: { revision: current.revision, reason: "controller startup restores paused" } }]);
94
+ });
95
+ for (const node of state().nodes.filter(n => n.status === "dispatched")) await reconcile(node);
96
+ const active = new Map<string, Promise<void>>();
97
+ const launch = (definition: StepNode, node: NodeState) => {
98
+ const work = (async () => {
99
+ let observation: Observation;
100
+ let wait = 1000;
101
+ for (;;) {
102
+ const current = state().nodes.find(n => n.id === node.id);
103
+ if (runtime.signal?.aborted || !current || current.dispatchId !== node.dispatchId || current.status !== "dispatched") return;
104
+ assertPlanOwner(dir, owner);
105
+ try { observation = await runtime.execute(definition, node); break; }
106
+ catch (error) {
107
+ if (error instanceof StudyLockBusyError) { await sleep(wait); wait = Math.min(wait * 2, 30_000); continue; }
108
+ if (error instanceof CopilotAcceptError) { observation = { status: "failed", reason: error.message }; break; }
109
+ if (definition.command === "attempt.run") { await reconcile(node); return; }
110
+ observation = { status: "uncertain", reason: "step execution interrupted or failed without an observable result; inspect inputs, then retry or skip this node" }; break;
111
+ }
112
+ }
113
+ await observe(node, observation); schedule(node, observation);
114
+ })();
115
+ active.set(node.id, work);
116
+ // The main loop awaits every task; failures are consumed immediately to prevent unhandled rejection.
117
+ void work.catch(() => {});
118
+ };
119
+ let failure: unknown, wait = 500, fingerprint = canonical(state());
120
+ try {
121
+ while (!runtime.signal?.aborted) {
122
+ let current = state();
123
+ if (current.state === "done" || current.state === "failed") break;
124
+ if (current.state === "running") {
125
+ // Pending auto gates are approved only inside a successful controller transaction.
126
+ const pendingGates = current.nodes.filter(n => n.kind === "gate" && n.status === "pending" && n.gate?.state === "pending");
127
+ // Advisory read avoids contending with manual approvals; the transaction still rechecks fresh policy.
128
+ const policy = pendingGates.length ? openStudy(dir).study.autonomy : undefined;
129
+ if (policy !== undefined && pendingGates.some(gate => resolveAutonomy(policy, `plan:${gate.id}`, guardedPlanPhase(current.definition, `plan:${gate.id}`)) === "auto")) await tryTransaction(dir, () => {
130
+ assertPlanOwner(dir, owner);
131
+ const events = readLedger(dir), value = readPlanState(dir, events), approvals: PendingEvent[] = [];
132
+ const { study } = openStudy(dir);
133
+ if (value.state !== "running") return;
134
+ for (const gate of value.nodes.filter(n => n.kind === "gate" && n.status === "pending" && n.gate?.state === "pending")) {
135
+ const name = `plan:${gate.id}` as const, phase = guardedPlanPhase(value.definition, name);
136
+ if (resolveAutonomy(study.autonomy, name, phase) === "auto") approvals.push(...requireGate(dir, study, name, gateName => planGateBinding(value, gateName), phase));
137
+ }
138
+ if (approvals.length) commit(dir, [], [...approvals, { actor: runtime.actor, type: "plan.gates", data: { revision: value.revision } }]);
139
+ });
140
+ current = state();
141
+ for (const node of current.nodes) {
142
+ if (runtime.signal?.aborted || current.state !== "running") break;
143
+ const definition = current.definition.nodes.find(n => n.id === node.id)!;
144
+ if (node.status !== "ready" || active.has(node.id)) continue;
145
+ if (!modeAllowsAdmission(current, openStudy(dir).study.autonomy, definition)) continue;
146
+ if (definition.kind === "hub" && runtime.hub) {
147
+ const binding = boundHubTask(readLedger(dir), current.revision, node.id);
148
+ let result: HubObservation;
149
+ try { result = await runtime.hub.observe(definition.campaign, definition.unit, binding); }
150
+ catch { result = { status: "dispatched", reason: "hub read unavailable; check ahub study show and restore hub access" }; }
151
+ const intent = await admit(dir, owner, runtime.actor, current, node, definition, { ...(result.taskId !== undefined ? { taskId: result.taskId } : {}), ...(result.hubProject !== undefined ? { hubProject: result.hubProject } : {}) }, parallel);
152
+ if (intent) { await observe(intent, result); schedule(intent, result); }
153
+ current = state(); continue;
154
+ }
155
+ if (definition.kind !== "step") continue;
156
+ const slots = stepSlots(current);
157
+ if (slots >= parallel) continue;
158
+ // A2 permits one active experiment attempt per study.
159
+ if (definition.command === "attempt.run" && current.nodes.some(n => n.status === "dispatched" && current.definition.nodes.find(d => d.id === n.id)?.kind === "step" && (current.definition.nodes.find(d => d.id === n.id) as StepNode).command === "attempt.run")) continue;
160
+ let prepared: { attemptId?: string };
161
+ try { prepared = await runtime.prepare(definition, node); }
162
+ catch (error) { // Preparation is read-only; still record a correlated failed dispatch for visibility.
163
+ prepared = {};
164
+ const intent = await admit(dir, owner, runtime.actor, current, node, definition, prepared, parallel);
165
+ if (intent) await observe(intent, { status: "failed", reason: error instanceof CopilotAcceptError ? error.message : "step preparation failed; check command inputs and retry this node" });
166
+ current = state(); continue;
167
+ }
168
+ const intent = await admit(dir, owner, runtime.actor, current, node, definition, prepared, parallel);
169
+ if (intent) launch(definition, intent);
170
+ current = state();
171
+ }
172
+ }
173
+ // Detached attempts are observed while paused too, without admitting new work.
174
+ for (const node of state().nodes.filter(n => n.status === "dispatched" && !active.has(n.id))) {
175
+ if (clock() >= (polls.get(node.dispatchId!)?.at ?? 0)) await reconcile(node);
176
+ }
177
+ if (["done", "failed"].includes(state().state)) break;
178
+ const next = canonical(state());
179
+ wait = next === fingerprint ? Math.min(wait * 2, 30_000) : 1000; fingerprint = next;
180
+ const live = new Set(state().nodes.filter(n => n.status === "dispatched").map(n => n.dispatchId));
181
+ for (const id of polls.keys()) if (!live.has(id)) polls.delete(id);
182
+ const due = [...polls.values()].map(p => Math.max(1, p.at - clock()));
183
+ const interval = Math.min(wait, ...(due.length ? due : [30_000]));
184
+ if (active.size) {
185
+ const settled = await Promise.race([...active].map(async ([id, work]) => { await work; return id; }).concat([sleep(interval).then(() => "")]));
186
+ if (settled) { await active.get(settled); active.delete(settled); }
187
+ } else await sleep(interval);
188
+ }
189
+ } catch (error) { failure = error; }
190
+ // Pause stops admission, drains this process's command calls, and leaves detached attempts alive.
191
+ await Promise.allSettled(active.values());
192
+ await locked(() => { const current = state(); if (current.state === "running") commit(dir, [], [{ actor: runtime.actor, type: "plan.paused", data: { revision: current.revision, reason: "controller stopped" } }]); });
193
+ if (failure) throw failure;
194
+ return state();
195
+ });
196
+ }
197
+ async function admit(dir: string, owner: PlanOwner, actor: Actor, snapshot: PlanState, node: NodeState, definition: StepNode | HubNode, prepared: { attemptId?: string; taskId?: number; hubProject?: string }, parallel: number): Promise<NodeState | undefined> {
198
+ const result = await tryTransaction(dir, () => {
199
+ assertPlanOwner(dir, owner);
200
+ const current = readPlanState(dir, readLedger(dir)), target = current.nodes.find(n => n.id === node.id);
201
+ if (current.state !== "running" || current.revision !== snapshot.revision || target?.status !== "ready" || canonical(current.definition.nodes.find(n => n.id === node.id)) !== canonical(definition) || (definition.kind === "step" && stepSlots(current) >= parallel)) return undefined;
202
+ if (!modeAllowsAdmission(current, openStudy(dir).study.autonomy, definition)) return undefined;
203
+ const dispatchId = nextDispatchId(current, node.id);
204
+ commit(dir, [], [{ actor, type: "node.intent", data: { revision: current.revision, node: node.id, dispatchId, ...prepared } }]);
205
+ return readPlanState(dir, readLedger(dir)).nodes.find(n => n.id === node.id)!;
206
+ });
207
+ return result?.value;
208
+ }
209
+
210
+ function stepSlots(state: PlanState): number {
211
+ return state.nodes.filter(n => n.kind === "step" && n.status === "dispatched").length;
212
+ }
213
+
214
+ function nodeMode(policy: Autonomy, node: PlanNode) {
215
+ return resolvePhaseAutonomy(policy, node.kind === "hub" ? undefined : node.phase);
216
+ }
217
+ /** Step effects are exclusive even when ordinary steps may run in parallel. */
218
+ function modeAllowsAdmission(state: PlanState, policy: Autonomy, next: PlanNode): boolean {
219
+ const active = state.nodes.filter(node => node.status === "dispatched" && node.kind !== "gate");
220
+ if (active.some(node => nodeMode(policy, state.definition.nodes.find(value => value.id === node.id)!) === "step")) return false;
221
+ return nodeMode(policy, next) !== "step" || active.length === 0;
222
+ }
@@ -0,0 +1,168 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { validatePhase, type Phase } from "../autonomy.ts";
4
+ import { COMMANDS } from "../commands-table.ts";
5
+ import { boundedText, commit, onlyKeys, plain, readJsonFile, sha256, type Actor } from "../study.ts";
6
+
7
+ export const PLAN_BYTES = 65_536, PLAN_ID_BYTES = 128;
8
+ export const STEP_COMMANDS = ["lit.search", "lit.verify", "lit.bib", "cite.check", "attempt.run", "investigate.close", "ablate.close", "final.close", "paper.build", "watch.run"] as const;
9
+ export type StepCommand = typeof STEP_COMMANDS[number];
10
+ export type StepArgs = Record<string, string | number | boolean | string[]>;
11
+ type BaseNode = { id: string; needs: string[]; phase?: Phase };
12
+ export type PlanNode = BaseNode & (
13
+ { kind: "step"; command: StepCommand; args: StepArgs } |
14
+ { kind: "hub"; campaign: string; unit: string } |
15
+ { kind: "gate"; binds: string[] }
16
+ );
17
+ export interface PlanDefinition { v: 1; revision: number; nodes: PlanNode[]; edges: [string, string][] }
18
+ const retry = "correct the definition and run ares plan set again";
19
+ const compatible = "use an ares version that supports this plan, keeping plan.json unchanged";
20
+ function nodeId(raw: unknown, what = "plan node id"): string {
21
+ const id = boundedText(raw, what, PLAN_ID_BYTES);
22
+ if (Buffer.byteLength(id) > PLAN_ID_BYTES) throw new Error(`${what}: at most ${PLAN_ID_BYTES} bytes`);
23
+ return id;
24
+ }
25
+ function references(raw: unknown, name: string): string[] {
26
+ if (!Array.isArray(raw) || raw.length > 128) throw new Error(`${name}: at most 128 node ids required`);
27
+ return raw.map((id, i) => nodeId(id, `${name}[${i}]`));
28
+ }
29
+ function stepArgs(raw: unknown, command: StepCommand, id: string): StepArgs {
30
+ const where = `plan step ${id} (${command})`;
31
+ if (!plain(raw)) throw new Error(`${where}.args: object required`);
32
+ const spec = COMMANDS.find(c => c.name === command.replace(".", " "))!;
33
+ const args: StepArgs = {};
34
+ for (const [key, value] of Object.entries(raw)) {
35
+ const field = `${where}.args.${key}`;
36
+ if (key === "json" || (command === "attempt.run" && key === "detach")) throw new Error(`${field}: controller-owned option; remove ${key} and run ares plan set again`);
37
+ if (command === "lit.search" && key === "query") {
38
+ const query = boundedText(value, field, 1000);
39
+ if (Buffer.byteLength(query) > 1000) throw new Error(`${field}: at most 1000 bytes`);
40
+ args[key] = query; continue;
41
+ }
42
+ if (command === "cite.check" && key === "draft") {
43
+ args[key] = boundedText(value, field, 4096); continue;
44
+ }
45
+ if (command === "lit.verify" && key === "ids") {
46
+ if (!Array.isArray(value) || value.length > 500) throw new Error(`${field}: array of at most 500 candidate ids required`);
47
+ args[key] = value.map((item, i) => boundedText(item, `${field}[${i}]`, 300)); continue;
48
+ }
49
+ const option = Object.hasOwn(spec.options, `--${key}`) ? spec.options[`--${key}`] : undefined;
50
+ if (!option) throw new Error(`${field}: unknown option ${key}`);
51
+ if (option === "flag" ? typeof value !== "boolean" : !["string", "number"].includes(typeof value)) throw new Error(`${field}: ${option === "flag" ? "boolean flag" : "string or number value"} required`);
52
+ if (typeof value === "number" && !Number.isFinite(value)) throw new Error(`${field}: finite value required`);
53
+ if (typeof value === "string") boundedText(value, field, 4096);
54
+ args[key] = value as string | number | boolean;
55
+ }
56
+ const required = command === "attempt.run" ? ["repo", "commit"] : command === "lit.search" ? ["query"] : command === "cite.check" ? ["draft"] : [];
57
+ for (const key of required) if (!Object.hasOwn(args, key)) throw new Error(`${where}: required option ${key}; add args.${key} and run ares plan set again`);
58
+ return args;
59
+ }
60
+ export function validatePlan(raw: unknown): PlanDefinition {
61
+ try { return validateDefinition(raw); }
62
+ catch (error) {
63
+ const message = error instanceof Error ? error.message : "plan: invalid definition";
64
+ throw new Error(message.includes(compatible) || message.includes("run ares plan set again") ? message : `${message}; ${retry}`);
65
+ }
66
+ }
67
+ function validateDefinition(raw: unknown): PlanDefinition {
68
+ if (!plain(raw)) throw new Error("plan: object required");
69
+ if (raw.v !== 1) throw new Error(`plan: unsupported v ${String(raw.v)} (expected 1); ${compatible}`);
70
+ onlyKeys(raw, ["v", "revision", "nodes", "edges"], "plan");
71
+ const revision = raw.revision ?? 1;
72
+ if (!Number.isSafeInteger(revision) || (revision as number) < 1) throw new Error("plan: revision must be a positive safe integer");
73
+ if (!Array.isArray(raw.nodes) || raw.nodes.length > 128) throw new Error("plan: at most 128 nodes required");
74
+ if (!Array.isArray(raw.edges) || raw.edges.length > 512) throw new Error("plan: at most 512 edges required");
75
+ const nodes: PlanNode[] = raw.nodes.map((n: unknown, index: number) => {
76
+ if (!plain(n)) throw new Error(`plan node[${index}]: object required`);
77
+ const id = nodeId(n.id, `plan node[${index}].id`), needs = n.needs === undefined ? [] : references(n.needs, `plan node ${String(n.id)}.needs`);
78
+ const base = { id, needs, ...(n.phase === undefined ? {} : { phase: validatePhase(n.phase) }) };
79
+ switch (n.kind) {
80
+ case "step": {
81
+ onlyKeys(n, ["id", "kind", "needs", "phase", "command", "args"], `plan step ${id}`);
82
+ if (!STEP_COMMANDS.includes(n.command as StepCommand)) throw new Error(`plan step ${id}: unknown command ${String(n.command)} (allowed: ${STEP_COMMANDS.join(", ")})`);
83
+ const command = n.command as StepCommand;
84
+ return { ...base, kind: "step", command, args: stepArgs(n.args ?? {}, command, id) };
85
+ }
86
+ case "hub": {
87
+ onlyKeys(n, ["id", "kind", "needs", "phase", "campaign", "unit"], `plan hub ${id}`);
88
+ return { ...base, kind: "hub", campaign: nodeId(n.campaign, `plan hub ${id}.campaign`), unit: nodeId(n.unit, `plan hub ${id}.unit`) };
89
+ }
90
+ case "gate":
91
+ onlyKeys(n, ["id", "kind", "needs", "phase", "binds"], `plan gate ${id}`);
92
+ return { ...base, kind: "gate", binds: references(n.binds, `plan gate ${id}.binds`) };
93
+ default: throw new Error(`plan node ${id}: unknown node kind ${String(n.kind)}; ${compatible}`);
94
+ }
95
+ });
96
+ const ids = new Set(nodes.map(n => n.id));
97
+ const seen = new Set<string>();
98
+ for (const n of nodes) {
99
+ if (seen.has(n.id)) throw new Error(`plan: duplicate node id ${n.id}; rename the duplicate node and update its references`);
100
+ seen.add(n.id);
101
+ }
102
+ const edges: [string, string][] = raw.edges.map((e: unknown, index: number) => {
103
+ if (!Array.isArray(e) || e.length !== 2) throw new Error(`plan edge[${index}]: pair required`);
104
+ return [nodeId(e[0], `plan edge[${index}].from`), nodeId(e[1], `plan edge[${index}].to`)];
105
+ });
106
+ const parents = new Map(nodes.map(n => [n.id, new Set(n.needs)]));
107
+ for (const [from, to] of edges) {
108
+ if (!ids.has(from) || !ids.has(to)) throw new Error(`plan edge [${from}, ${to}]: missing endpoint, no node ${!ids.has(from) ? from : to}; add that node or remove this edge`);
109
+ parents.get(to)!.add(from);
110
+ }
111
+ for (const n of nodes) for (const p of n.needs) if (!ids.has(p)) throw new Error(`plan node ${n.id}.needs: missing endpoint, no node ${p}; add that node or remove this dependency`);
112
+ const visiting = new Set<string>(), visited = new Set<string>();
113
+ const stack: string[] = [];
114
+ function visit(id: string): void {
115
+ if (visiting.has(id)) throw new Error(`plan: cycle through ${[...stack.slice(stack.indexOf(id)), id].reverse().join(" -> ")}; remove one dependency and run ares plan set again`);
116
+ if (visited.has(id)) return;
117
+ visiting.add(id); stack.push(id);
118
+ for (const parent of parents.get(id)!) visit(parent);
119
+ stack.pop(); visiting.delete(id); visited.add(id);
120
+ }
121
+ for (const n of nodes) visit(n.id);
122
+ const ancestorCache = new Map<string, Set<string>>();
123
+ function ancestors(id: string): Set<string> {
124
+ const cached = ancestorCache.get(id);
125
+ if (cached) return cached;
126
+ const result = new Set<string>();
127
+ for (const parent of parents.get(id)!) {
128
+ result.add(parent);
129
+ for (const ancestor of ancestors(parent)) result.add(ancestor);
130
+ }
131
+ ancestorCache.set(id, result);
132
+ return result;
133
+ }
134
+ for (const n of nodes) if (n.kind === "gate") for (const bound of n.binds) {
135
+ if (!ids.has(bound) || !ancestors(n.id).has(bound)) throw new Error(`plan gate ${n.id} binds ${bound}, which is not upstream; add an upstream dependency or remove this binding`);
136
+ }
137
+ const result: PlanDefinition = { v: 1, revision: revision as number, nodes, edges };
138
+ if (Buffer.byteLength(JSON.stringify(result, null, 2) + "\n") > PLAN_BYTES) throw new Error(`plan: exceeds ${PLAN_BYTES} bytes`);
139
+ return result;
140
+ }
141
+ /** A plan gate inherits only one explicitly declared phase shared by all immediate targets. */
142
+ export function guardedPlanPhase(definition: PlanDefinition, gate: string): Phase | undefined {
143
+ if (!gate.startsWith("plan:")) return undefined;
144
+ const id = gate.slice(5);
145
+ if (!definition.nodes.some(node => node.id === id && node.kind === "gate")) return undefined;
146
+ const targets = definition.nodes.filter(node => node.needs.includes(id) || definition.edges.some(([from, to]) => from === id && to === node.id));
147
+ if (!targets.length || targets.some(node => node.kind === "hub" || node.phase === undefined)) return undefined;
148
+ const phases = new Set(targets.map(node => node.phase));
149
+ return phases.size === 1 ? targets[0]!.phase : undefined;
150
+ }
151
+
152
+ export function readPlan(dir: string): PlanDefinition | undefined {
153
+ const file = join(dir, "plan.json");
154
+ if (!existsSync(file)) return undefined;
155
+ const raw: unknown = readJsonFile(file, PLAN_BYTES);
156
+ const definition = validatePlan(raw);
157
+ if (!plain(raw) || raw.revision === undefined) throw new Error(`plan.json: stored revision required; restore a revisioned definition before running ares plan set again`);
158
+ return definition;
159
+ }
160
+ /** Caller holds the study lock; definition and initial state share one transaction. */
161
+ export function setPlan(dir: string, raw: unknown, actor: Actor) {
162
+ const definition = validatePlan(raw), previous = readPlan(dir);
163
+ definition.revision = (previous?.revision ?? 0) + 1;
164
+ if (!Number.isSafeInteger(definition.revision)) throw new Error("plan.revision: exhausted; create a new study before setting another plan");
165
+ const text = JSON.stringify(definition, null, 2) + "\n";
166
+ commit(dir, [[join(dir, "plan.json"), text, PLAN_BYTES]], [{ actor, type: "plan.set", data: { revision: definition.revision, definition, state: "paused", sha256: sha256(text) } }]);
167
+ return { definition, state: "paused" as const };
168
+ }
@@ -0,0 +1,56 @@
1
+ import { isAbsolute, resolve } from "node:path";
2
+ import { spawnRunner, type ProcessRunner } from "../exp/executor.ts";
3
+ import { plain, sha256, type LedgerEvent } from "../study.ts";
4
+ import type { Observation } from "./controller.ts";
5
+
6
+ export interface HubObservation extends Observation { taskId?: number }
7
+ export interface HubClient { observe(campaign: string, unit: string, taskId?: number): Promise<HubObservation> }
8
+ export const HUB_READ_BYTES = 1_048_576;
9
+ const states = ["backlog", "proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled", "unknown"];
10
+ const id = (value: unknown): value is string => typeof value === "string" && !!value.trim() && !value.includes("\0") && Buffer.byteLength(value) <= 128;
11
+ const task = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) > 0;
12
+ function readError(kind: string): HubObservation { return { status: "dispatched", reason: `hub read ${kind}; check ahub study show for this campaign and restore hub access` }; }
13
+ function failed(reason: string, taskId?: number): HubObservation { return { status: "failed", reason: `hub ${reason}; inspect ahub study show, correct the plan reference and retry explicitly`, ...(taskId !== undefined ? { taskId } : {}) }; }
14
+ /** Only bounded status/study-show argv reads; no task creation, activation, approval or other hub write. */
15
+ export function createHubClient(project: string, runner: ProcessRunner = spawnRunner): HubClient {
16
+ async function read(args: string[]): Promise<{ data?: unknown; error?: HubObservation }> {
17
+ let result;
18
+ try { result = await runner({ command: "ahub", args: ["--project", resolve(project), ...args], timeoutMs: 10_000, maxOutputBytes: HUB_READ_BYTES }); }
19
+ catch { return { error: readError("unavailable") }; }
20
+ if (result.timedOut) return { error: readError("timeout") };
21
+ if (result.truncated || Buffer.byteLength(result.stdout) > HUB_READ_BYTES) return { error: readError("oversize") };
22
+ if (result.exitCode !== 0) return { error: readError("exit") };
23
+ try { return { data: JSON.parse(result.stdout) }; } catch { return { error: readError("format") }; }
24
+ }
25
+ return { observe: async (campaign, unit, expected) => {
26
+ if (!id(campaign) || campaign.startsWith("-") || !id(unit) || (expected !== undefined && !task(expected))) return failed("reference invalid");
27
+ const status = await read(["status", "--json"]);
28
+ if (status.error) return { ...status.error, reason: "hub read project-unavailable: no reachable agent-hub project for this study; run ahub status --json from the study directory" };
29
+ if (!plain(status.data) || typeof status.data.cwd !== "string" || !isAbsolute(status.data.cwd) || status.data.cwd.includes("\0") || Buffer.byteLength(status.data.cwd) > 4096) return readError("project-format");
30
+ const hubProject = status.data.cwd;
31
+ const finish = (result: HubObservation): HubObservation => ({ ...result, hubProject });
32
+ const result = await read(["study", "show", campaign, "--json"]);
33
+ if (result.error) return finish(result.error);
34
+ const raw = result.data;
35
+ if (!plain(raw) || raw.id !== campaign || !task(raw.revision) || typeof raw.held !== "boolean" || !Array.isArray(raw.reasons) || !raw.reasons.every(r => typeof r === "string") || !Array.isArray(raw.units) || raw.units.length > 128) return finish(readError("format"));
36
+ // The shipped projection has no status field; it names non-current approval in reasons.
37
+ if (raw.reasons.includes("campaign approval is not current")) return finish({ status: "dispatched", reason: "hub campaign approval pending; a person approves it in ahub study" });
38
+ const units = raw.units.filter(u => plain(u) && u.unitId === unit);
39
+ if (!units.length) return finish(failed("unit missing from current campaign revision", expected));
40
+ if (units.length !== 1) return finish(readError("format"));
41
+ const found = units[0] as Record<string, unknown>;
42
+ if (found.taskId === null) return finish({ status: "dispatched", reason: "hub campaign activation pending: no child taskId; a person activates the campaign in ahub study" });
43
+ if (found.state === "unknown") return finish(failed("child task missing", expected));
44
+ if (!task(found.taskId) || typeof found.state !== "string" || !states.includes(found.state)) return finish(readError("unsupported"));
45
+ if (expected !== undefined && found.taskId !== expected) return finish(failed("child task binding changed", expected));
46
+ if (found.state === "cancelled") return finish(failed("child task cancelled", found.taskId));
47
+ const reference = { campaign, unit, campaignRevision: raw.revision, taskId: found.taskId, state: found.state };
48
+ return finish({ status: found.state === "approved" ? "done" : "dispatched", taskId: found.taskId,
49
+ ...(found.state === "approved" ? { output: { ...reference, sha256: sha256(JSON.stringify(reference)) } } : {}),
50
+ progress: JSON.stringify(reference) });
51
+ } };
52
+ }
53
+ /** The first known binding of this node/revision remains pinned through retries. */
54
+ export function boundHubTask(events: LedgerEvent[], revision: number, node: string): number | undefined {
55
+ return events.find(e => (e.type === "node.intent" || e.type === "node.observed") && e.data.revision === revision && e.data.node === node && task(e.data.taskId))?.data.taskId as number | undefined;
56
+ }