@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.
- package/CHANGELOG.md +51 -0
- package/LICENSE +21 -0
- package/README.md +666 -2
- package/bin/ares.js +11 -0
- package/package.json +37 -4
- package/src/autonomy.ts +50 -0
- package/src/cli.ts +308 -0
- package/src/commands-table.ts +75 -0
- package/src/commands.ts +561 -0
- package/src/console/registry.ts +97 -0
- package/src/copilot.ts +91 -0
- package/src/exp/ablate.ts +140 -0
- package/src/exp/artifacts.ts +229 -0
- package/src/exp/attempts.ts +483 -0
- package/src/exp/executor.ts +207 -0
- package/src/exp/final.ts +197 -0
- package/src/exp/ideas.ts +117 -0
- package/src/exp/investigate.ts +187 -0
- package/src/exp/logs.ts +63 -0
- package/src/exp/metrics.ts +132 -0
- package/src/exp/protocol.ts +148 -0
- package/src/exp/selection.ts +53 -0
- package/src/exp/snapshot.ts +147 -0
- package/src/http.ts +106 -0
- package/src/index.ts +4 -0
- package/src/lit/bibtex.ts +61 -0
- package/src/lit/candidates.ts +148 -0
- package/src/lit/cards.ts +21 -0
- package/src/lit/cite.ts +26 -0
- package/src/lit/sources.ts +122 -0
- package/src/lit/verify.ts +110 -0
- package/src/lit/watch.ts +122 -0
- package/src/manifest.ts +39 -0
- package/src/mcp-tools.ts +210 -0
- package/src/mcp.ts +113 -0
- package/src/memory/amem.ts +97 -0
- package/src/memory/archive.ts +66 -0
- package/src/memory/lessons.ts +88 -0
- package/src/memory/skills.ts +99 -0
- package/src/paper/build.ts +73 -0
- package/src/paper/check.ts +181 -0
- package/src/paper/placeholders.ts +118 -0
- package/src/paper/review.ts +127 -0
- package/src/plan/controller.ts +222 -0
- package/src/plan/definition.ts +168 -0
- package/src/plan/hub.ts +56 -0
- package/src/plan/state.ts +270 -0
- package/src/playbook.ts +141 -0
- package/src/scope.ts +53 -0
- package/src/setup.ts +296 -0
- package/src/study-export.ts +254 -0
- package/src/study.ts +324 -0
- package/src/ui/panel.css +11 -0
- package/src/ui/panel.js +161 -0
- package/src/ui/plugin.ts +375 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { resolvePhaseAutonomy, type Autonomy } from "../autonomy.ts";
|
|
2
|
+
import { gateState, plain, sha256, boundedText, onlyKeys, type Actor, type GateName, type GateState, type LedgerEvent, type PendingEvent } from "../study.ts";
|
|
3
|
+
import { PLAN_ID_BYTES, readPlan, validatePlan, type PlanDefinition, type PlanNode } from "./definition.ts";
|
|
4
|
+
|
|
5
|
+
export const PLAN_GATE_BYTES = "plan:".length + PLAN_ID_BYTES;
|
|
6
|
+
|
|
7
|
+
export type NodeStatus = "pending" | "ready" | "dispatched" | "done" | "failed" | "cancelled" | "uncertain" | "stale" | "skipped";
|
|
8
|
+
export interface NodeState { stepCancellation?: { revision: number; dispatchId: string; commandId: string }; id: string; kind: PlanNode["kind"]; status: NodeStatus; dispatchId?: string; dispatchCount?: number; retryOf?: string; output?: unknown; attemptId?: string; taskId?: number; hubProject?: string; reason?: string; gate?: GateState; lastObserved?: { revision: number; dispatchId: string; status: NodeStatus; at: string; output?: unknown; reason?: string; step?: true } }
|
|
9
|
+
export interface ControlRequest { action: "pause" | "resume" | "retry" | "cancel" | "skip"; node?: string; commandId: string; expectedRevision: number }
|
|
10
|
+
export interface ControlResult { commandId: string; revision: number; action: ControlRequest["action"]; node?: string; state: "paused" | "running" | "done" | "failed"; status?: NodeStatus }
|
|
11
|
+
export interface PlanState { pauseReason?: "step"; definition: PlanDefinition; revision: number; state: "paused" | "running" | "done" | "failed"; nodes: NodeState[]; uncertain: string[] }
|
|
12
|
+
export const TERMINAL_NODE_STATUSES: readonly NodeStatus[] = ["done", "failed", "cancelled", "uncertain", "skipped"];
|
|
13
|
+
export function hasRemainingWork(state: PlanState): boolean { return state.nodes.some(node => !TERMINAL_NODE_STATUSES.includes(node.status)); }
|
|
14
|
+
function hasStepOutcome(node: NodeState): boolean {
|
|
15
|
+
if (node.status === "cancelled" && node.dispatchId === undefined && node.stepCancellation) return true;
|
|
16
|
+
return node.lastObserved?.step === true && node.lastObserved.dispatchId === node.dispatchId && node.lastObserved.status === node.status && TERMINAL_NODE_STATUSES.includes(node.status);
|
|
17
|
+
}
|
|
18
|
+
function matchesStepPause(state: PlanState, node: NodeState, data: Record<string, unknown>): boolean {
|
|
19
|
+
if (typeof data.dispatchId !== "string" || !hasStepOutcome(node)) return false;
|
|
20
|
+
const cancelled = node.stepCancellation;
|
|
21
|
+
if (cancelled && node.status === "cancelled" && node.dispatchId === undefined) return data.revision === state.revision && cancelled.revision === data.revision && cancelled.dispatchId === data.dispatchId && cancelled.commandId === data.commandId;
|
|
22
|
+
const observed = node.lastObserved;
|
|
23
|
+
return !!observed && node.dispatchId === data.dispatchId && observed.dispatchId === data.dispatchId && observed.revision === data.revision;
|
|
24
|
+
}
|
|
25
|
+
const statuses: readonly NodeStatus[] = ["pending", "ready", "dispatched", "done", "failed", "cancelled", "uncertain", "stale", "skipped"];
|
|
26
|
+
const recovery = "; run ares plan show and retry with its revision and a new --command-id";
|
|
27
|
+
function bad(message: string): never { throw new Error(`plan: ${message}${recovery}`); }
|
|
28
|
+
/** Stable JSON ordering makes hashes independent of dictionary insertion order. */
|
|
29
|
+
export function canonical(value: unknown): string {
|
|
30
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
31
|
+
if (plain(value)) return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
|
|
32
|
+
return JSON.stringify(value) ?? "null";
|
|
33
|
+
}
|
|
34
|
+
export function dependencies(definition: PlanDefinition, id: string): string[] {
|
|
35
|
+
return [...new Set([...(definition.nodes.find(n => n.id === id)?.needs ?? []), ...definition.edges.filter(e => e[1] === id).map(e => e[0])])].sort();
|
|
36
|
+
}
|
|
37
|
+
export function ancestors(definition: PlanDefinition, id: string): string[] {
|
|
38
|
+
const found = new Set<string>();
|
|
39
|
+
function visit(node: string) { for (const parent of dependencies(definition, node)) if (!found.has(parent)) { found.add(parent); visit(parent); } }
|
|
40
|
+
visit(id); return [...found].sort();
|
|
41
|
+
}
|
|
42
|
+
function descendants(definition: PlanDefinition, roots: Set<string>): Set<string> {
|
|
43
|
+
const found = new Set(roots);
|
|
44
|
+
for (let changed = true; changed;) {
|
|
45
|
+
changed = false;
|
|
46
|
+
for (const node of definition.nodes) if (!found.has(node.id) && dependencies(definition, node.id).some(p => found.has(p))) { found.add(node.id); changed = true; }
|
|
47
|
+
}
|
|
48
|
+
return found;
|
|
49
|
+
}
|
|
50
|
+
function reset(node: NodeState, status: NodeStatus): void {
|
|
51
|
+
node.status = status; delete node.output; delete node.reason; delete node.gate;
|
|
52
|
+
delete node.dispatchId; delete node.stepCancellation;
|
|
53
|
+
// A retry must create a child attempt, while hub task references stay read-only.
|
|
54
|
+
if (status === "pending" && node.attemptId) { node.retryOf = node.attemptId; delete node.attemptId; }
|
|
55
|
+
}
|
|
56
|
+
function consumed(node: NodeState): boolean { return node.status !== "pending" && node.status !== "ready"; }
|
|
57
|
+
export function nextDispatchId(state: PlanState, id: string): string {
|
|
58
|
+
const node = state.nodes.find(n => n.id === id);
|
|
59
|
+
if (!node) bad(`dispatch references missing node ${id}`);
|
|
60
|
+
return `${state.revision}:${id}:${(node.dispatchCount ?? 0) + 1}`;
|
|
61
|
+
}
|
|
62
|
+
function dispatchIdentity(value: unknown, node: string): string {
|
|
63
|
+
if (typeof value !== "string" || !value.trim() || value.includes("\0") || Buffer.byteLength(value) > 256) bad(`node ${node}: dispatchId must be nonempty text of at most 256 bytes; record the intent id on its observation`);
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
export function planGateBinding(state: PlanState, gate: GateName): string | undefined {
|
|
67
|
+
if (!gate.startsWith("plan:")) return undefined;
|
|
68
|
+
const id = gate.slice(5), node = state.definition.nodes.find(n => n.id === id);
|
|
69
|
+
if (!node || node.kind !== "gate") return undefined;
|
|
70
|
+
const upstream = ancestors(state.definition, id);
|
|
71
|
+
const refs = [];
|
|
72
|
+
for (const parent of upstream) {
|
|
73
|
+
const value = state.nodes.find(n => n.id === parent)!;
|
|
74
|
+
if (value.status !== "done" || value.output === undefined) return undefined;
|
|
75
|
+
refs.push({ node: parent, output: value.output ?? null });
|
|
76
|
+
}
|
|
77
|
+
return sha256(canonical({ gate: node, upstream: refs }));
|
|
78
|
+
}
|
|
79
|
+
function refresh(state: PlanState, decisions: Map<string, LedgerEvent>): void {
|
|
80
|
+
// A topological traversal prevents declaration order from changing gate release.
|
|
81
|
+
const visited = new Set<string>();
|
|
82
|
+
function visit(id: string) {
|
|
83
|
+
if (visited.has(id)) return;
|
|
84
|
+
for (const parent of dependencies(state.definition, id)) visit(parent);
|
|
85
|
+
visited.add(id);
|
|
86
|
+
const definition = state.definition.nodes.find(n => n.id === id)!, node = state.nodes.find(n => n.id === id)!;
|
|
87
|
+
if (definition.kind === "gate") {
|
|
88
|
+
const gate: GateName = `plan:${id}`;
|
|
89
|
+
const oldStatus = node.status;
|
|
90
|
+
node.gate = gateState(gate, [...decisions.values()], name => planGateBinding(state, name));
|
|
91
|
+
if (node.status === "stale" || node.status === "cancelled") return;
|
|
92
|
+
if (node.gate.state === "approved") { node.status = "done"; node.output = { binding: node.gate.current }; }
|
|
93
|
+
else { node.status = "pending"; delete node.output; }
|
|
94
|
+
if (oldStatus === "done" && node.status !== "done") for (const child of state.nodes) if (child.id !== id && descendants(state.definition, new Set([id])).has(child.id) && !["pending", "ready"].includes(child.status)) reset(child, "stale");
|
|
95
|
+
} else if (node.status === "pending" || node.status === "ready") {
|
|
96
|
+
node.status = dependencies(state.definition, id).every(parent => {
|
|
97
|
+
const previous = state.nodes.find(n => n.id === parent)!;
|
|
98
|
+
return previous.status === "done" || (previous.kind !== "gate" && previous.status === "skipped");
|
|
99
|
+
}) && ancestors(state.definition, id).every(parent => {
|
|
100
|
+
const previous = state.nodes.find(n => n.id === parent)!;
|
|
101
|
+
return previous.kind !== "gate" || previous.status === "done";
|
|
102
|
+
}) ? "ready" : "pending";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const node of state.definition.nodes) visit(node.id);
|
|
106
|
+
state.uncertain = state.nodes.filter(n => n.status === "uncertain").map(n => n.id);
|
|
107
|
+
if (state.nodes.length && state.nodes.every(n => n.status === "done" || n.status === "skipped")) { state.state = "done"; delete state.pauseReason; }
|
|
108
|
+
// Only completed step-policy effects introduce terminal failure; legacy plans keep their prior recovery flow.
|
|
109
|
+
else if (state.nodes.length && !hasRemainingWork(state) && state.nodes.some(hasStepOutcome)) { state.state = "failed"; delete state.pauseReason; }
|
|
110
|
+
else if (state.state === "done" || state.state === "failed") state.state = "paused";
|
|
111
|
+
}
|
|
112
|
+
/** Sole state authority: replay ledger events; no cache or snapshot is written. */
|
|
113
|
+
/** Optional observer sees each effective, refreshed event; it must not mutate the folded state. */
|
|
114
|
+
export function foldPlan(events: LedgerEvent[], onEvent?: (state: PlanState, event: LedgerEvent) => void): PlanState | undefined {
|
|
115
|
+
let state: PlanState | undefined;
|
|
116
|
+
const decisions = new Map<string, LedgerEvent>();
|
|
117
|
+
const dispatches = new Set<string>(), stepPauses = new Set<string>();
|
|
118
|
+
for (const event of events) {
|
|
119
|
+
const d = event.data;
|
|
120
|
+
if (event.type === "gate.approve" || event.type === "gate.reject") {
|
|
121
|
+
if (typeof d.gate === "string") decisions.set(d.gate, event);
|
|
122
|
+
if (state) { refresh(state, decisions); onEvent?.(state, event); }
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (event.type === "plan.set") {
|
|
126
|
+
const definition = validatePlan(d.definition);
|
|
127
|
+
if (definition.revision !== d.revision || (state && definition.revision <= state.revision)) bad("plan.set has invalid revision; restore the last valid ledger");
|
|
128
|
+
const previous = state;
|
|
129
|
+
const changed = new Set<string>();
|
|
130
|
+
if (previous) for (const node of previous.definition.nodes) {
|
|
131
|
+
const next = definition.nodes.find(n => n.id === node.id);
|
|
132
|
+
if ((!next || canonical(node) !== canonical(next) || canonical(dependencies(previous.definition, node.id)) !== canonical(dependencies(definition, node.id))) && !["pending", "ready"].includes(previous.nodes.find(n => n.id === node.id)!.status)) changed.add(node.id);
|
|
133
|
+
}
|
|
134
|
+
const stale = descendants(definition, descendants(previous?.definition ?? definition, changed));
|
|
135
|
+
state = { definition, revision: definition.revision, state: "paused", nodes: definition.nodes.map(n => {
|
|
136
|
+
const old = previous?.nodes.find(p => p.id === n.id);
|
|
137
|
+
if (!old) return { id: n.id, kind: n.kind, status: "pending" };
|
|
138
|
+
const value = { ...old, kind: n.kind };
|
|
139
|
+
if (stale.has(n.id) && consumed(value)) reset(value, "stale");
|
|
140
|
+
return value;
|
|
141
|
+
}), uncertain: [] };
|
|
142
|
+
refresh(state, decisions); onEvent?.(state, event); continue;
|
|
143
|
+
}
|
|
144
|
+
if (!state) continue;
|
|
145
|
+
if (!["plan.control", "plan.paused", "node.intent", "node.observed", "node.stale"].includes(event.type)) continue;
|
|
146
|
+
const stepPause = event.type === "plan.paused" && d.reason === "step";
|
|
147
|
+
if (event.type !== "node.observed" && !stepPause && d.revision !== state.revision) continue;
|
|
148
|
+
if (event.type === "plan.paused") {
|
|
149
|
+
if (stepPause) {
|
|
150
|
+
const target = state.nodes.find(node => node.id === d.node), key = canonical([d.node, d.dispatchId]);
|
|
151
|
+
if (!target || !matchesStepPause(state, target, d) || stepPauses.has(key)) continue;
|
|
152
|
+
stepPauses.add(key); state.pauseReason = "step";
|
|
153
|
+
} else delete state.pauseReason;
|
|
154
|
+
state.state = "paused";
|
|
155
|
+
}
|
|
156
|
+
else if (event.type === "plan.control") {
|
|
157
|
+
const request = validateControl(d.request);
|
|
158
|
+
if (request.expectedRevision !== state.revision) bad("plan.control revision mismatch; restore the last valid ledger");
|
|
159
|
+
if (request.action === "pause") { state.state = "paused"; delete state.pauseReason; }
|
|
160
|
+
else if (request.action === "resume") { state.state = "running"; delete state.pauseReason; }
|
|
161
|
+
else {
|
|
162
|
+
const target = state.nodes.find(n => n.id === request.node);
|
|
163
|
+
if (!target) bad(`control references missing node ${request.node}`);
|
|
164
|
+
const affected = descendants(state.definition, new Set([target.id]));
|
|
165
|
+
for (const node of state.nodes) if (affected.has(node.id) && node.id !== target.id && consumed(node)) reset(node, "stale");
|
|
166
|
+
let cancelled = request.action === "cancel" && hasStepOutcome(target)
|
|
167
|
+
? { revision: state.revision, dispatchId: target.stepCancellation?.dispatchId ?? target.lastObserved!.dispatchId, commandId: request.commandId } : undefined;
|
|
168
|
+
if (d.stepCancel !== undefined) {
|
|
169
|
+
if (request.action !== "cancel" || target.kind === "gate" || target.status !== "dispatched" || typeof d.stepCancel !== "string" || target.dispatchId !== d.stepCancel) bad("step cancellation binding does not match the dispatched effect; restore the valid ledger or inspect ares plan show before retrying with a new command-id");
|
|
170
|
+
cancelled = { revision: state.revision, dispatchId: d.stepCancel, commandId: request.commandId };
|
|
171
|
+
}
|
|
172
|
+
reset(target, request.action === "retry" ? "pending" : request.action === "skip" ? "skipped" : "cancelled");
|
|
173
|
+
if (cancelled) target.stepCancellation = cancelled;
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
const node = state.nodes.find(n => n.id === d.node);
|
|
177
|
+
if (!node) {
|
|
178
|
+
if (event.type === "node.observed") continue; // Removed nodes have no current dispatch.
|
|
179
|
+
bad(`${event.type} references missing node ${String(d.node)}`);
|
|
180
|
+
}
|
|
181
|
+
if (event.type === "node.stale") {
|
|
182
|
+
for (const value of state.nodes) if (descendants(state.definition, new Set([node.id])).has(value.id) && consumed(value)) reset(value, "stale");
|
|
183
|
+
} else if (event.type === "node.intent") {
|
|
184
|
+
if (node.status !== "ready") bad(`node.intent ${node.id} is ${node.status}, not ready; reconcile the ledger before dispatch`);
|
|
185
|
+
const dispatchId = dispatchIdentity(d.dispatchId, node.id), key = canonical([node.id, dispatchId]);
|
|
186
|
+
if (dispatches.has(key)) bad(`node ${node.id}: dispatchId ${dispatchId} is already used; choose a fresh dispatch id`);
|
|
187
|
+
dispatches.add(key); node.dispatchId = dispatchId; node.dispatchCount = (node.dispatchCount ?? 0) + 1;
|
|
188
|
+
node.status = "dispatched";
|
|
189
|
+
if (typeof d.attemptId === "string") node.attemptId = d.attemptId;
|
|
190
|
+
delete node.taskId;
|
|
191
|
+
if (Number.isSafeInteger(d.taskId)) node.taskId = d.taskId as number;
|
|
192
|
+
if (typeof d.hubProject === "string") node.hubProject = boundedText(d.hubProject, `node ${node.id} hub project`, 4096);
|
|
193
|
+
} else {
|
|
194
|
+
const dispatchId = dispatchIdentity(d.dispatchId, node.id);
|
|
195
|
+
if (dispatchId !== node.dispatchId || !Number.isSafeInteger(d.revision) || (d.revision as number) > state.revision) continue;
|
|
196
|
+
if (!statuses.includes(d.status as NodeStatus) || !["done", "failed", "cancelled", "uncertain", "dispatched"].includes(d.status as string)) bad(`node.observed ${node.id} has unknown status ${String(d.status)}`);
|
|
197
|
+
if (node.status !== "dispatched" && node.status !== "uncertain") continue;
|
|
198
|
+
if (d.taskId !== undefined) {
|
|
199
|
+
if (node.kind !== "hub" || !Number.isSafeInteger(d.taskId) || (d.taskId as number) < 1 || (node.taskId !== undefined && node.taskId !== d.taskId)) {
|
|
200
|
+
node.status = "failed"; delete node.output;
|
|
201
|
+
node.reason = "hub child task binding changed or invalid; inspect ahub study show and retry explicitly";
|
|
202
|
+
node.lastObserved = { revision: d.revision as number, dispatchId, status: "failed", at: event.at, reason: node.reason, ...(d.step === true ? { step: true as const } : {}) };
|
|
203
|
+
refresh(state, decisions); onEvent?.(state, event); continue;
|
|
204
|
+
}
|
|
205
|
+
node.taskId = d.taskId as number;
|
|
206
|
+
}
|
|
207
|
+
if (typeof d.hubProject === "string") node.hubProject = boundedText(d.hubProject, `node ${node.id} hub project`, 4096);
|
|
208
|
+
node.status = d.status as NodeStatus;
|
|
209
|
+
if (node.status === "done" && d.output !== undefined) node.output = d.output;
|
|
210
|
+
delete node.reason;
|
|
211
|
+
if (typeof d.reason === "string") node.reason = boundedText(d.reason, `node ${node.id} reason`, 500);
|
|
212
|
+
node.lastObserved = { revision: d.revision as number, dispatchId, status: node.status, at: event.at, ...(d.output !== undefined ? { output: d.output } : {}), ...(node.reason !== undefined ? { reason: node.reason } : {}), ...(d.step === true && TERMINAL_NODE_STATUSES.includes(node.status) ? { step: true as const } : {}) };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
refresh(state, decisions); onEvent?.(state, event);
|
|
216
|
+
}
|
|
217
|
+
return state;
|
|
218
|
+
}
|
|
219
|
+
export function readPlanState(dir: string, events: LedgerEvent[]): PlanState {
|
|
220
|
+
const definition = readPlan(dir), state = foldPlan(events);
|
|
221
|
+
if (!definition || !state) bad("no committed definition; run ares plan set first");
|
|
222
|
+
if (canonical(definition) !== canonical(state.definition)) bad("plan.json and ledger definition differ; restore the last committed plan.json");
|
|
223
|
+
return state;
|
|
224
|
+
}
|
|
225
|
+
export function validateControl(raw: unknown): ControlRequest {
|
|
226
|
+
try { return controlRequest(raw); }
|
|
227
|
+
catch (error) {
|
|
228
|
+
const message = error instanceof Error ? error.message : "plan control: invalid request";
|
|
229
|
+
throw new Error(message.includes(recovery) ? message : `${message}${recovery}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function controlRequest(raw: unknown): ControlRequest {
|
|
233
|
+
if (!plain(raw)) bad("control requires an object");
|
|
234
|
+
onlyKeys(raw, ["action", "node", "commandId", "expectedRevision"], "plan control");
|
|
235
|
+
if (!["pause", "resume", "retry", "cancel", "skip"].includes(raw.action as string)) bad(`unknown control action ${String(raw.action)}`);
|
|
236
|
+
const commandId = boundedText(raw.commandId, "plan control command-id", 128);
|
|
237
|
+
if (Buffer.byteLength(commandId) > 128) bad("control command-id exceeds 128 bytes");
|
|
238
|
+
if (!Number.isSafeInteger(raw.expectedRevision) || (raw.expectedRevision as number) < 1) bad("control expected-revision must be a positive safe integer");
|
|
239
|
+
const action = raw.action as ControlRequest["action"];
|
|
240
|
+
const node = raw.node === undefined ? undefined : boundedText(raw.node, "plan control node", 128);
|
|
241
|
+
if (node !== undefined && Buffer.byteLength(node) > 128) bad("control node exceeds 128 bytes");
|
|
242
|
+
if ((action === "pause" || action === "resume") ? node !== undefined : node === undefined) bad(`control ${action} ${node === undefined ? "requires a node" : "takes no node"}`);
|
|
243
|
+
return { action, commandId, expectedRevision: raw.expectedRevision as number, ...(node !== undefined ? { node } : {}) };
|
|
244
|
+
}
|
|
245
|
+
export function prepareControl(state: PlanState, events: LedgerEvent[], raw: unknown, actor: Actor, policy?: Autonomy): { result: ControlResult; event?: PendingEvent; pause?: PendingEvent } {
|
|
246
|
+
const request = validateControl(raw);
|
|
247
|
+
for (const e of events) if (e.type === "plan.control" && plain(e.data.request) && e.data.request.commandId === request.commandId) {
|
|
248
|
+
if (canonical(e.data.request) !== canonical(request)) bad(`command-id ${request.commandId} already has a different payload`);
|
|
249
|
+
if (!plain(e.data.result)) bad(`command-id ${request.commandId} has no receipt; restore its ledger entry`);
|
|
250
|
+
return { result: e.data.result as unknown as ControlResult };
|
|
251
|
+
}
|
|
252
|
+
if (request.expectedRevision !== state.revision) bad(`stale revision ${request.expectedRevision}, current is ${state.revision}`);
|
|
253
|
+
if (request.node !== undefined) {
|
|
254
|
+
const node = state.nodes.find(n => n.id === request.node);
|
|
255
|
+
if (!node) bad(`control references missing node ${request.node}`);
|
|
256
|
+
if (request.action === "cancel" && node.status === "done") bad(`cannot cancel completed node ${node.id}; retry it explicitly if another execution is needed`);
|
|
257
|
+
if (request.action === "skip" && (node.kind === "gate" || node.status === "dispatched" || node.status === "done")) bad(`cannot skip ${node.kind} node ${node.id} in ${node.status}; approve its gate or cancel running work first`);
|
|
258
|
+
if (request.action === "retry" && (node.status === "dispatched" || node.status === "ready" || node.status === "pending")) bad(`cannot retry node ${node.id} in ${node.status}; wait for an outcome first`);
|
|
259
|
+
}
|
|
260
|
+
const target = state.nodes.find(node => node.id === request.node), definition = state.definition.nodes.find(node => node.id === request.node);
|
|
261
|
+
const stepCancel = policy !== undefined && request.action === "cancel" && target?.status === "dispatched" && definition && definition.kind !== "gate" && resolvePhaseAutonomy(policy, definition.kind === "hub" ? undefined : definition.phase) === "step";
|
|
262
|
+
if (stepCancel && !target?.dispatchId) bad("dispatched step has no cancellation binding; restore its intent before retrying plan control");
|
|
263
|
+
const pending: PendingEvent = { actor, type: "plan.control", data: { revision: state.revision, request, ...(stepCancel ? { stepCancel: target!.dispatchId } : {}) } };
|
|
264
|
+
const at = new Date().toISOString(), projected = foldPlan([...events, { v: 1, at, ...pending }])!;
|
|
265
|
+
const pause: PendingEvent | undefined = stepCancel && hasRemainingWork(projected) ? { actor, type: "plan.paused", data: { revision: state.revision, node: target!.id, dispatchId: target!.dispatchId, commandId: request.commandId, reason: "step" } } : undefined;
|
|
266
|
+
const next = pause ? foldPlan([...events, { v: 1, at, ...pending }, { v: 1, at, ...pause }])! : projected;
|
|
267
|
+
const result: ControlResult = { commandId: request.commandId, revision: state.revision, action: request.action, ...(request.node ? { node: request.node, status: next.nodes.find(n => n.id === request.node)!.status } : {}), state: next.state };
|
|
268
|
+
pending.data.result = result;
|
|
269
|
+
return { result, event: pending, ...(pause ? { pause } : {}) };
|
|
270
|
+
}
|
package/src/playbook.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/** Standalone workflow for one agent session. Printed by `ares playbook` and the `ares_playbook` tool. */
|
|
2
|
+
export const PLAYBOOK = `ares research playbook (A1 literature, A2 experiments, A3 plan DAG, investigate/final phases, A4 paper/review, A5 memory/evolution, A6 setup/console/watch, autonomy policy, study export)
|
|
3
|
+
|
|
4
|
+
Before starting a phase, create its study once: ares init <dir> --question "..." [--mode experiment] [--autonomy auto|gates|copilot|step]. Use --mode experiment for experiment phases; reuse the initialized study thereafter. Phase lessons and archive call the amem CLI on PATH (its output parser was written against amem 0.20.3; no amem version is tested live here); these keep the study cwd and agent attribution.
|
|
5
|
+
|
|
6
|
+
Literature workflow
|
|
7
|
+
|
|
8
|
+
ares lessons <dir> --for "scope and verify the literature"
|
|
9
|
+
|
|
10
|
+
1. Use the initialized study; choose its scope before collecting literature.
|
|
11
|
+
2. Write scope.json (question, subquestions, include, exclude, yearFrom, yearTo, maxCandidates) and run ares scope <dir> --file scope.json.
|
|
12
|
+
In gates mode a person or the agent the workspace allows approves it: ares gate approve <dir> scope.
|
|
13
|
+
3. Search each subquestion: ares lit search <dir> "<query>" [--source all]. A source that is rate limited is reported, not fatal; retry later or set S2_API_KEY / OPENALEX_API_KEY.
|
|
14
|
+
4. Screen against include/exclude: ares lit screen <dir> <id>... --include|--exclude --reason "...". Read titles and abstracts with ares lit list.
|
|
15
|
+
5. For each included paper you read, record a card: summary, claims, methods, findings, limitations, relevance (ares lit card <dir> <id> --file card.json). Cards hold what the paper says, never what you hope it says.
|
|
16
|
+
6. Verify identifiers: ares lit verify <dir>. "verified" means the DOI or arXiv id exists and its registry title and year match. It never means the paper supports a claim.
|
|
17
|
+
unknown = the registry could not be reached; run verify again. no / partial / mismatch / no identifier are final: fix the record or exclude it,
|
|
18
|
+
and re-check one by naming it (ares lit verify <dir> <id>).
|
|
19
|
+
7. Approve the screened set (ares gate approve <dir> screen), then ares lit bib <dir>. Only included and verified papers enter refs/references.bib.
|
|
20
|
+
8. Cite as [@key] in the draft and run ares cite check <dir> <draft>. Fix every unknown or unverified key before sharing the draft.
|
|
21
|
+
|
|
22
|
+
Rules: do not invent references; cite only keys from references.bib; support for a claim needs the passage, judged by a person or a reviewer from a different model family.
|
|
23
|
+
|
|
24
|
+
Literature watch workflow
|
|
25
|
+
|
|
26
|
+
ares lessons <dir> --for "refresh the saved literature watch"
|
|
27
|
+
|
|
28
|
+
1. Use an initialized survey or experiment study with its current scope approved in gates mode. Save {v:1,queries:[{id,query,source?,yearFrom?,limit?}],rubric?} with ares watch set <dir> --file watch.json. At most 20 unique run-name ids, trimmed query 500 characters, limit 1..50, rubric 8000 bytes. The rubric is display-only, never a score or screening rule. Identical config bytes are a no-op; valid set input repairs corrupt files and malformed versions (string, null, missing, fractional or nonpositive values). Only a safe integer v greater than 1 is an unsupported future version and remains unchanged.
|
|
29
|
+
2. Run ares watch run <dir> once when a fresh literature pass is wanted. CLI waits for completion; the no-arg watch.run plan step also waits and emits completed {added}. Every-source failure is recorded and gives CLI exit 1 or a failed plan node. The pass holds the study lock through every saved query, reuses lit search gates/merging and records lit.search per query then one watch.ran. At maximum its sequential HTTP deadlines total 20 minutes (20x3x20seconds), plus local parsing/file work. Other mutating commands, including another watch start, refuse until the whole pass finishes. MCP ares_watch_run is always detached: config/scope/year preflight under the lock returns {started:true,configSha256}, which acknowledges admission rather than completion. The finite pass runs in the same MCP process, leaving ping and tools/list responsive; no timer, additional resident watcher or background daemon is spawned.
|
|
30
|
+
3. Inspect ares watch show <dir> (MCP ares_watch_show): current config/rubric, the ten recent runs, and still-new candidates. After a detached MCP start, use watch.ran to find the eventual outcome, including ok:false for every-source failure; wait for that outcome before another start. Source errors name only host/status. Run receipts hash the actual stored config bytes and retain at most 500 surviving new ids plus their full total; aggregate added remains the sum of per-query additions. Newly added records stay new. Screen/verify them through the normal literature workflow and the unchanged screen gate.
|
|
31
|
+
4. Repeat only through the person's machine-local .agenthub/config.local.json schedules entry or their own cron. Hub every is at least 15m and proposes a task for the configured peer; it never executes a command. A ready-queue example uses {id:"ares-watch",every:"1h",class:"implement",owner:"codex",queue:"ready",title:"Run one saved literature watch",detail:"Run one ares watch run for the intended absolute study path; leave screening to a person"}. Use an existing class/peer. User cron instead invokes one absolute Bun/ares entrypoint and absolute study directory directly. A user crontab has no username column; replace every path with its intended absolute value. Ares never registers a recurrence.
|
|
32
|
+
|
|
33
|
+
0 * * * * /absolute/path/to/bun --smol /absolute/path/to/agent-research/bin/ares.js watch run /absolute/path/to/study --json >> /absolute/path/to/ares-watch.log 2>&1
|
|
34
|
+
|
|
35
|
+
Experiment workflow
|
|
36
|
+
|
|
37
|
+
ares lessons <dir> --for "prepare reproducible experiments"
|
|
38
|
+
|
|
39
|
+
1. Use the initialized experiment study (--mode experiment at init). Survey studies refuse experiment commands; study mode is fixed at init.
|
|
40
|
+
2. Write task/task.md and task/evaluator/ entrypoint/data, optionally task/rubric.md. Use regular files only, at most 500 files and 64 MiB; symlinks are refused.
|
|
41
|
+
3. Set design/protocol.json: ares protocol <dir> --file protocol.json. Declare digest-pinned candidate/evaluator images, argv arrays, metrics (name/unit/max|min), seeds, limits, network "none" and budgets. Dependencies belong in images. ARES_SEED is reserved and supplied to both stages from the selected seed; never put secret-like keys in candidate.env.
|
|
42
|
+
4. Configure the executor: ares executor set <dir> --kind ssh --host ALIAS --workdir /private/owned/path, or --kind local with an explicit private workdir. Create that host directory owned by the executor user with mode 0700. Run ares executor check <dir> to check Docker, images and non-root uid:gid; it may pull missing images.
|
|
43
|
+
5. In gates mode approve the sealed inputs: ares gate approve <dir> design. The seal binds exact protocol bytes and the task tree. Editing either reopens approval; a budget-only edit also reopens it while preserving comparable attempt protocol identity.
|
|
44
|
+
6. Run a frozen baseline: ares attempt run <dir> --repo /absolute/repo --commit <full-40-hex-SHA> --seed <declared-seed> --run main --label baseline --detach. Run a variant with a different frozen commit or declared seed and --label variant. The same request returns the existing attempt; --retry <id> creates a linked attempt. A different seed or commit is a new attempt, not a reproduction. MCP attempt_run is always detached.
|
|
45
|
+
7. Run ares attempt reconcile <dir> to advance recorded containers, then inspect ares attempt show <dir> <id> for the full record. ares attempt list <dir> returns summaries, default 50 per page; use --limit N (at most 200) and --offset N. Reconcile again while a stage runs. Totals include failed, cancelled and unknown attempts over the whole study regardless of filters or page, all of which count against budgets. Use ares attempt cancel <dir> <id> --reason "..." to stop a stage. Lost containers become unknown; reconciliation never creates a new attempt.
|
|
46
|
+
8. Read logs explicitly with ares attempt logs <dir> <id> [--stage candidate|evaluator] [--stream stdout|stderr] [--tail 16384]. Tail size is 0..65536 bytes, default 16384; without stage/stream filters every recorded stream is returned after checking each copy's size and sha256. Retrieval is read-only; log content stays out of the study. Stage records keep log paths, hashes, sizes and truncation status. Attempt artifacts keep metadata rather than raw output bodies.
|
|
47
|
+
9. Numbers come only from the sealed evaluator's /result/metrics.json, an object of all declared metric names and finite numeric values. Candidate output and printed scores never become metrics. Candidate and evaluator run in separate isolated containers; evaluator inputs are read-only. Sealing is ares-observed (argv plus observed container configuration), never host-observed or runner-attested. It does not establish support for a research claim.
|
|
48
|
+
|
|
49
|
+
Investigation workflow
|
|
50
|
+
|
|
51
|
+
ares lessons <dir> --for "compare candidate hypotheses"
|
|
52
|
+
|
|
53
|
+
1. Use an experiment study with a protocol and evaluator task as above. Before collecting results, set ares selection <dir> --file selection.json with {v:1, run, metric, baseline:{commit}, minSeeds, minDelta}. metric must be declared; baseline.commit is a full frozen SHA; minSeeds is 1..declared seed count; minDelta is finite and nonnegative in the metric's unit. The metric direction comes from the protocol. The run names ablate, final and posthoc are reserved and refused. A new/changed selection joins the design seal and reopens approval; approve design again. Identical selection bytes record no event and do not move the counting cutoff. A study without selection keeps its prior seal.
|
|
54
|
+
2. Record an idea with ares idea add <dir> --file idea.json: {id,title,hypothesis,parent?,commit?}. Ids are unique run names; parent must exist; each commit has one idea owner. Limits: 1000 ideas, title500 characters and hypothesis4000 characters. Ares records hypotheses and decisions; it does not generate them.
|
|
55
|
+
3. Run frozen baseline/candidate attempts with --run matching the selection. Execution stays one active attempt per study. Hub peers can work on ideas in parallel; they run ares attempt run for frozen commits in their own tasks. Reconcile and inspect results; every status remains in totals. Only current-protocol attempts with attempt.started after the latest selection.set in ledger order count. For each commit/seed, only the earliest success counts; later successes remain listed as excluded.
|
|
56
|
+
4. After terminal results, record ares idea decide <dir> <id> refine --reason TEXT --attempts ID[,ID...] --commit SHA, or pivot --reason TEXT --attempts ID[,ID...]. Evidence must exist, be non-running and belong to that idea's existing commits, at most200 ids. Reason is at most2000 bytes. Refine attaches a uniquely owned commit; pivot refuses --commit and closes later decisions. An idea with no commit omits --attempts: refine --commit attaches its first commit, or pivot drops it before testing. Pivot accepts optional --cause and --retry-when (2000 bytes each); refine refuses them. Missing values export as not recorded. Pivot never deletes earlier attempts or totals.
|
|
57
|
+
5. Read ares investigate show <dir> --json. Each candidate pairs only seeds with counted baseline and candidate successes; >=minSeeds pairs, delta>0 and delta>=minDelta are required. Rank by direction-adjusted paired delta, not raw candidate mean; paired sets may differ. Exact best-delta tie gives no winner. Other no-winner reasons are no-baseline, baseline-seeds, no-candidate and below-margin. Means are descriptive; there is no significance test. Rows and winner include the owning idea id when one exists; excluded attempts retain reasons.
|
|
58
|
+
6. Run ares investigate close <dir> after every attempt in the selected run is terminal, including excluded ones; otherwise run ares attempt reconcile first. It commits timestamp-free runs/<run>/investigate.json and investigate.closed together; unchanged inputs record nothing. Unknown result/selection versions refuse without overwriting. No executor or hub is started by show/close.
|
|
59
|
+
7. In a conductor plan, use a step {id:"close",kind:"step",command:"investigate.close",args:{},needs:["agent-work"]} after the hub unit that finishes all attempts. Its output reference {sha256,winner} binds a downstream plan gate; winner:null is valid, with the file naming why. Survey studies refuse selection, idea and investigate commands. Archive the completed study after paper/review and skill proposals, as the final step below. All investigate acceptance here uses offline synthetic fixtures, not live hub or executor DAG proof.
|
|
60
|
+
|
|
61
|
+
Ablate, final and posthoc workflow
|
|
62
|
+
|
|
63
|
+
ares lessons <dir> --for "plan ablation and final evaluation"
|
|
64
|
+
|
|
65
|
+
1. Before any results, run ares final seeds <dir> --file final-seeds.json with {v:1,seeds:[...]}. Reserve 1..floor(protocol seed count/2) unique declared seeds. New/changed rules refuse every seed used by any existing attempt in any run/status; identical bytes record nothing. The final-seeds sha joins the design seal; approve design again. Protocol edits cannot drop reserved seeds or exceed the half bound. Investigate/ablate exclude reserved seeds; only run final may use them.
|
|
66
|
+
2. Close investigate with a current winner. Declare up to200 ablations with ares ablate add <dir> --file ablation.json: {id,commit,removes}, unique id/commit, not winner/baseline, removes <=2000 bytes. Run each declaration and a fresh winner replication under --run ablate on non-reserved seeds. Show/close pair shared current-protocol seeds after the selection cutoff, earliest successes only, never reusing investigate samples. Every declaration/status/exclusion stays visible; there is no ranking. ares ablate close <dir> needs a current closed investigate winner and no running ablate attempts; it writes runs/ablate/ablate.json plus ablate.closed, identical inputs a no-op.
|
|
67
|
+
Before final evaluation: ares lessons <dir> --for "evaluate reserved final seeds"
|
|
68
|
+
|
|
69
|
+
3. Approve ares gate approve <dir> final after current investigate closure and, when declarations exist, ablate closure. It binds SHA-256 of [investigate result sha, ablate result sha or none, design binding]. Changed inputs reopen approval. Run --run final with each reserved seed for both the frozen winner and baseline. Seed/commit/gate checks happen before Git/record/container; auto approval is committed only with a verified candidate start.
|
|
70
|
+
4. Run ares final close <dir> after final attempts stop. First attempt.started per commit/reserved seed decides whatever its status; later retries remain listed and never replace it. Missing pairs, a running final attempt or a non-current gate approval refuse. Qualification needs all decisive successes and direction-adjusted paired delta>0 and >=minDelta, with no significance test. Reapprove changed inputs but keep existing decisive data: old input/seal bindings alone never disqualify it. A changed protocol gives protocol-changed. A different winner after any final attempt (including baseline-only work) gives winner-changed and blocks another final start, with no new-winner pairs required. Reason order is winner-changed, protocol-changed, first decisive failure status, below-margin. Close writes timestamp-free runs/final/final.json plus final.closed; identical inputs record nothing.
|
|
71
|
+
Before posthoc exploration: ares lessons <dir> --for "explore posthoc follow-up"
|
|
72
|
+
|
|
73
|
+
5. After a historical final.closed receipt, additional attempts may use --run posthoc on non-reserved seeds. ares posthoc show <dir> lists every status and metrics labeled exploratory. They never change final.json, its fingerprint or close receipt, even while running. Survey studies refuse every new phase command on CLI/MCP.
|
|
74
|
+
6. In a conductor, put no-arg ablate.close and final.close after their attempt-completion dependencies. They emit committed result {sha256,status}: closed for ablate, qualified/not_qualified for final, both done. Downstream plan gates bind those outputs; the global final gate still authorizes final starts/closure separately. MCP tools start no controller loop.
|
|
75
|
+
7. Final here uses reserved seeds, a frozen snapshot and the sealed evaluator. Held-out data outside the study repo is deferred; task data is not hidden from agents. Acceptance is offline synthetic fixtures, with no live hub campaign or executor DAG coverage claimed.
|
|
76
|
+
|
|
77
|
+
Paper and review workflow
|
|
78
|
+
|
|
79
|
+
ares lessons <dir> --for "report and review closed evidence"
|
|
80
|
+
|
|
81
|
+
1. Write paper/manuscript.md, at most2MiB. In experiments, put measured values in {{n:REF}} or {{n:REF|.Nf}} (N=0..6); use {{lit:TEXT}} for explicit reviewer-visible literal exceptions. Combined limit 5000 placeholders. Refs: final.status/reason/delta/mean/baselineMean, final.attempts.<status>; investigate.winner/reason, investigate.winner.delta/mean, investigate.baseline.mean, investigate.totals.<status>; ablate:<id>.delta/mean/referenceMean, ablate.totals.<status>; attempt:<id>.<metric> or posthoc:<id>.<metric>. Counts use every status. Text refs take no format; winner is a12-hex prefix. Metric names are exact, including spaces/punctuation; names containing | or } cannot be referenced in this grammar and need a new protocol/name. Null, absent, unclosed or stale sources are unresolved-ref. Direct metrics require succeeded current-protocol ares-observed records; posthoc appends (exploratory), and the prefixes cannot substitute for each other. Final refs need current final-gate approval.
|
|
82
|
+
2. Run ares paper check <dir> --json. Prose digits without placeholders are bare-number, never matched heuristically to an existing metric. Code numbers are nonblocking but listed in codeNumbers (first500 value/line/column) with codeNumberCount total. Link targets, placeholder/literal payloads, line-start ordered-list markers and citation-key digits are exempt. Citation bracket prose is checked: only locator terms p., pp., sec., §, ch., chap., fig., tbl., table, eq., l., ll., para. followed by integer ranges/lists allow digits. Decimals/percent claims in brackets still fail. Citations inside lit still need included, identifier-verified references; this never judges claim support. Paragraph/list continuations remain prose. Every literal is listed for review.
|
|
83
|
+
3. With a final file, include {{n:final.status}} in visible prose; with no investigate winner, include {{n:investigate.reason}}. Code, link targets, HTML comments and reference-definition metadata cannot satisfy negative-result presence. Only reference numeric/reason fields when their value exists. A failed check exits1. Survey papers check citations without experiment bare-number restrictions but refuse experiment refs with unresolved-ref; citation-only build is supported.
|
|
84
|
+
4. Run ares paper build <dir> --json only after a clean check. It commits rendered paper/build/manuscript.md, paperEvidence v1 paper/build/evidence.json and paper.built with both output hashes together. Evidence binds the source manuscript sha and every active placeholder value/rendering/position/source path+raw hash; literals source the input manuscript. Bounds:2MiB rendered,8MiB evidence. Identical outputs with a matching receipt are a no-op; provenance changes rebuild even with identical rendered values. Unknown evidence versions preserve bytes. The output manuscriptSha256 is rendered; evidenceSha256 is the review buildSha256.
|
|
85
|
+
5. Have reviewers inspect the build, evidence, lit and codeNumbers. Add each report using ares review add <dir> --file report.json: {v:1,reviewer:{id,modelFamily},buildSha256,checklist:{negativeResults,denominators,limitations,reproducibility},findings:[{id,kind,location,text,blocking}]}. buildSha256 is the current evidence file hash. Family labels are self-reported, trimmed/case-normalized, not attested or launched by ares. Checklist values are booleans, displayed rather than an extra gate requirement. Kinds:contradiction,unsupported-claim,number,citation,method,clarity,other. Contradiction is always blocking even if false was supplied. Limits:100 reports/build,200 findings/report,4000-byte texts/reasons,128-byte identifiers; the existing ledger event ceiling limits combined report payload to60KiB. Unknown fields/stale hashes refuse before writes.
|
|
86
|
+
6. Run ares review show <dir> --json for current check/build, reports/families/checklists, open blocking findings and every unmet condition. Resolve an existing item with ares review resolve <dir> <returned-review-id>/<finding-id> --reason TEXT. Resolutions record the actor; clean votes never close a contradiction. Approve ares gate approve <dir> review only with a clean current check/build, two distinct model families for that evidence hash and no open blocker. Approval preflight lives in the shared command under its study lock; reject still needs a reason but no readiness, and no command currently auto-requires review. The binding covers both output hashes and sorted added/resolved event hashes; a new build/report/resolution reopens it, old reports remain in the ledger.
|
|
87
|
+
7. The no-arg paper.build conductor step emits committed evidence {sha256}; a downstream plan:<id> gate binds it, but never replaces the global manuscript review gate. MCP never starts the controller loop. Claim-passage evidence/contradictions await STAIxBWLB/agent-hub#931; claims readiness reports unavailable (STAIxBWLB/agent-hub#931), nonblocking. Claim verdicts remain outside ares. Automatic writing/reviewing, LaTeX/PDF container builds and venue templates are out of scope. Acceptance is offline synthetic fixtures, with no live hub/executor DAG proof claimed.
|
|
88
|
+
|
|
89
|
+
Console workflow
|
|
90
|
+
|
|
91
|
+
ares lessons <dir> --for "review the study in the console"
|
|
92
|
+
|
|
93
|
+
1. Preview ares setup <claude|codex|kimi|pi|qwen|jcode|agy|all|console> [--root ABS], then --apply only the inspected changes. Agent setup edits only the ares MCP entry, bun --smol <root>/bin/ares.js mcp. all covers user-level clients, not workspace-local agy or console. Setup records receipts before changes under $AGENTHUB_HOME/research/setup; --undo restores only an unchanged entry. Instruction blocks stay with dotfiles.
|
|
94
|
+
2. Register a readable study with ares console add <dir>; ares console list shows the explicit registry, and ares console remove <dir> removes only its registration, even after the study moved. Registry is a bounded private studies.json under the family research directory, at most 200 canonical absolute directories. No disk scan. Setup console registers API 1 ares.json, or ares@<version>.json under --root, with an absolute plugin module and its sha256.
|
|
95
|
+
3. Read Studies/Study panels and inbox decisions. Moved/unreadable studies stay listed as unavailable. Watch candidate items are still-new recorded ids; an overflow alert reports unrecorded ids without guessing their provenance. Only a fresh built manuscript of at most 2 MiB is served as text/plain, never a generated PDF or figure.
|
|
96
|
+
4. Decision wire ids are gate.approve, gate.reject, screen.include, screen.exclude, skill.accept, skill.reject, skill.withdraw and archive.confirm. Each returns a write-free preview first; the console binds confirmation to that input, and ares recomputes its study/content fingerprint under the command lock. The wire id supplies the decision. Gate rejection and every screen/skill decision require reasons; skill accept also needs its reviewed HTTP(S) PR URL. The person actor records verified meta.identity when supplied; page actor/meta fields and other unsupported action fields are refused. acon serve --read-only refuses writes before invoking the provider. Containers, network searches and plan control stay with CLI/MCP.
|
|
97
|
+
5. Use archive.confirm only as the final Archive workflow step below. Console archive has a 25-second total child budget inside the 30-second provider ceiling; partial returned ids are receipted, so refresh and review a new preview before continuing. No live console/amem/vault interoperability is claimed by offline tests.
|
|
98
|
+
|
|
99
|
+
Plan and conductor workflow
|
|
100
|
+
|
|
101
|
+
ares lessons <dir> --for "coordinate the study plan"
|
|
102
|
+
|
|
103
|
+
1. Use the initialized study and set any scope, screened set, design and executor required by its steps. Save a v1 definition with nodes and edges, then run ares plan set <dir> --file plan.json. A plan is revisioned and starts paused; read ares plan show <dir> --json for its revision, states and controller owner.
|
|
104
|
+
2. Use step nodes with command lit.search, lit.verify, lit.bib, cite.check, attempt.run, investigate.close, ablate.close, final.close, paper.build or watch.run; args are named CLI options without -- (including year-from). Search needs query, verify takes optional ids, cite.check needs draft, and attempt.run needs repo and a full frozen commit. Relative repo and draft are study-relative. Close/build and watch.run steps take no args. watch.run emits {added} from the saved finite pass; an all-source failure fails that node. investigate.close emits {sha256,winner}; ablate.close emits {sha256,status:closed}; final.close emits {sha256,status:qualified|not_qualified}. paper.build emits the committed evidence {sha256}. All are valid done outcomes, including no-winner and not_qualified, and downstream plan gates bind their references. json and detach are controller-owned and refused in args; attempt steps always detach. needs and edges define dependencies; gates bind only upstream nodes. Bounds: 128 nodes, 512 explicit edges, 128-byte ids, no cycles.
|
|
105
|
+
3. Start ares run <dir> [--parallel 1..8] in terminal A. It stays open, initially paused. In terminal B or MCP, resume with ares plan control <dir> resume --command-id <unique-id> --expected-revision <revision from show>. The default is one step at a time; independent steps may fill the parallel slots, but only one experiment attempt is active per study.
|
|
106
|
+
4. Read ares gate list <dir>. After a plan gate has complete upstream outputs, approve it with ares gate approve <dir> plan:<node-id>. Approvals bind all upstream output references; changed output reopens approval. In auto autonomy pending plan gates are approved with actor auto inside the controller commit. Local approval authority stays workspace policy.
|
|
107
|
+
5. Read ares plan show <dir> --json for pending, ready, dispatched, done, failed, cancelled, uncertain, stale and skipped nodes, the last observation and attempt/retry/task references. pause stops admission; resume allows it. retry <node> makes an eligible node available for a new dispatch once dependencies complete; cancel <node> stops its attempt if present; skip <node> produces no output and cannot bypass a gate. Every control needs --command-id and --expected-revision. Repeating the identical request returns its original receipt, even across revisions; a changed payload under that id or a stale revision on a new request refuses. Controls do not bump the definition revision.
|
|
108
|
+
6. Changing a consumed node definition or its dependencies marks it and consumed descendants stale; retry them explicitly. Never-run nodes wait normally for their dependencies. After a crash, the controller restores paused and reconciles recorded attempts; an unobservable step becomes uncertain, never an automatic replay. Retry links an existing parent attempt; a predicted but uncreated id is not passed as a retry parent. A live or foreign controller lock is refused; only a dead same-host pid/start identity can be recovered, never by age.
|
|
109
|
+
7. For hub work, use a hub node with campaign and unit ids from an agent-hub campaign. A planner drafts it and a person activates/approves it there. Ares reads ahub --project <study-dir> status --json and study show <campaign> --json only, using the unit's taskId and state. Only approved completes the node; activation pending waits. hubProject names the resolved board. Unreachable reads stay dispatched with a safe error class; missing units/tasks, cancelled tasks or a changed pinned binding fail. The task id is pinned per plan revision across restarts/retries; a new revision may bind a new task. Hub waits do not consume step slots, and cancelling one is local only. Ares never writes to the hub or redispatches agent work.
|
|
110
|
+
8. Waiting observations use 1 s to 30 s backoff, reset on progress, and unchanged polls write nothing. run exits 0 when done. SIGINT/SIGTERM stops admission, drains command calls and releases the controller lock; running detached attempts remain for ares attempt reconcile. MCP ares_plan_set, ares_plan_show and ares_plan_control never start a controller: run is CLI-only. Plan state comes from the ledger, not a second snapshot.
|
|
111
|
+
|
|
112
|
+
Memory and skill proposal workflow
|
|
113
|
+
|
|
114
|
+
ares lessons <dir> --for "derive reusable skills from study evidence"
|
|
115
|
+
|
|
116
|
+
1. Lessons search this study's amem project with the intent (or study question without --for) and return an intent brief when --for is supplied. Age is fractional days, relevance excludes amem recency, score = relevance * 0.5^(age/halfLife), rank is the 1-based position. Ties put fail first, then lexical id. Default half-life 180 days (1..3650); limit 50 (1..50). No study write or lock.
|
|
117
|
+
2. Propose a procedure for review: ares skill propose <dir> --file proposal.json, with {name,summary,steps:[...],evidence:[...]}. Require at least one step and 1..64 refs (32-hex amem ids or ares:<study-id>/...). Names follow the run-name rule; at most 100 proposals and 16 KiB per Markdown file, bound to its receipt.
|
|
118
|
+
3. List every state: ares skill list <dir>. Decide with ares skill decide <dir> <name> accept|reject|withdraw --reason TEXT [--pr URL]. Accept requires a reviewed HTTP(S) PR URL without credentials. Ares stores the actor's reference, never opens, checks or merges it. Every terminal state is final; withdraw is allowed only while proposed.
|
|
119
|
+
4. Survey and experiment studies support lessons, archive and the skill queue. Survey archives can derive resolved review contradictions; experiment pivots/selection/results remain experiment-only. Open contradictions are never exported. Durable vault promotion stays in amem vault-candidates; ares never writes the vault.
|
|
120
|
+
|
|
121
|
+
Autonomy policy
|
|
122
|
+
|
|
123
|
+
1. Read ares autonomy show <dir> for the stored policy and each phase's effective mode; change it with ares autonomy set <dir> <auto|gates|copilot|step> [--phase PHASE]. Phases: scope, literature, hypotheses, design, investigate, ablate, final, posthoc, write, review, archive. Only auto approves pending gates automatically; gates, copilot and step need a recorded approval. Policy changes keep earlier gate decisions and record actor, from, to and phase.
|
|
124
|
+
2. step: the CLI controller runs one effect node at a time and pauses (pauseReason step) after each terminal outcome while work remains; resume with ares plan control <dir> resume --command-id <new-id> --expected-revision <revision>. MCP resume changes the same plan state but starts no controller.
|
|
125
|
+
3. copilot: a producing phase in copilot mode makes its drafts usable only after ares gate approve <dir> accept:hypotheses|accept:paper|accept:reviews binds their current content hash. Source edits or new relevant journal entries make the accept pending again. Drafting and own-draft checks stay usable before acceptance; acceptance is a checkpoint, not scientific verification.
|
|
126
|
+
4. Plain auto|gates studies keep study v1; object policies, copilot and step write study v2, which 0.1.0 refuses without changing the file. A plan node may declare phase; 0.1.0 refuses such plans the same way.
|
|
127
|
+
|
|
128
|
+
Study export
|
|
129
|
+
|
|
130
|
+
1. ares study export <dir> --bundle <new-or-empty-dir-outside-the-study> [--list] writes manifest.json, ledger.jsonl, attempts.jsonl, results.json, reviews.jsonl and evidence.json from committed study files only, never the executor workdir. --list names the files and writes nothing.
|
|
131
|
+
2. Identities are hashes; values are metrics, seeds, statuses, exit codes, durations, counts and closed enums. Paths, mounts, container ids, argv, env, image names (digests kept) and free text are dropped. A planted path, URL, NAME=value assignment or key-like token in any decoded key or string refuses the whole export and writes nothing; remove it from the study record and retry.
|
|
132
|
+
|
|
133
|
+
Archive workflow
|
|
134
|
+
|
|
135
|
+
ares lessons <dir> --for "archive the completed study"
|
|
136
|
+
|
|
137
|
+
Archive is the last study step, after paper/review and skill decisions. Preview lists pending notes and a fingerprint over the full canonical derived note list. Confirm only the inspected fingerprint. Each call writes the first 200 pending notes and returns remaining; repeat the same confirmation for later batches or after a partial failure. Successful hashes/ids are receipted; exported notes are skipped. A changed source note invalidates confirmation. Fail notes retain recorded cause/retryWhen or not recorded; closed outcomes retain negative reasons; evidence is chunked into 16 refs without dropping attempts. Note text travels on stdin; amem option values starting -- receive one leading space for its parser. No raw stdout/stderr enters the study beyond returned ids.
|
|
138
|
+
|
|
139
|
+
ares archive <dir>
|
|
140
|
+
ares archive <dir> --confirm <fingerprint>
|
|
141
|
+
`;
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { boundedText, commit, onlyKeys, plain, readBytesBounded, readJsonFile, sha256, type Actor } from "./study.ts";
|
|
4
|
+
|
|
5
|
+
export interface Scope { question: string; subquestions: string[]; include: string[]; exclude: string[]; yearFrom?: number; yearTo?: number; maxCandidates: number }
|
|
6
|
+
const KEYS = ["question", "subquestions", "include", "exclude", "yearFrom", "yearTo", "maxCandidates"] as const;
|
|
7
|
+
export const MAX_CANDIDATES = 5000;
|
|
8
|
+
|
|
9
|
+
function list(value: unknown, what: string, maxItems: number, maxText: number): string[] {
|
|
10
|
+
if (value === undefined) return [];
|
|
11
|
+
if (!Array.isArray(value) || value.length > maxItems) throw new Error(`${what}: list of at most ${maxItems}`);
|
|
12
|
+
return value.map((item, i) => boundedText(item, `${what}[${i}]`, maxText));
|
|
13
|
+
}
|
|
14
|
+
function year(value: unknown, what: string): number | undefined {
|
|
15
|
+
if (value === undefined) return undefined;
|
|
16
|
+
if (!Number.isSafeInteger(value) || (value as number) < 1000 || (value as number) > 2100) throw new Error(`${what}: year 1000..2100`);
|
|
17
|
+
return value as number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function validateScope(raw: unknown): Scope {
|
|
21
|
+
if (!plain(raw)) throw new Error("scope: JSON object required");
|
|
22
|
+
onlyKeys(raw, KEYS, "scope");
|
|
23
|
+
const yearFrom = year(raw.yearFrom, "scope.yearFrom"), yearTo = year(raw.yearTo, "scope.yearTo");
|
|
24
|
+
if (yearFrom !== undefined && yearTo !== undefined && yearFrom > yearTo) throw new Error("scope: yearFrom after yearTo");
|
|
25
|
+
const max = raw.maxCandidates ?? 500;
|
|
26
|
+
if (!Number.isSafeInteger(max) || (max as number) < 1 || (max as number) > MAX_CANDIDATES) throw new Error(`scope.maxCandidates: 1..${MAX_CANDIDATES}`);
|
|
27
|
+
return {
|
|
28
|
+
question: boundedText(raw.question, "scope.question", 2000),
|
|
29
|
+
subquestions: list(raw.subquestions, "scope.subquestions", 30, 1000),
|
|
30
|
+
include: list(raw.include, "scope.include", 50, 500),
|
|
31
|
+
exclude: list(raw.exclude, "scope.exclude", 50, 500),
|
|
32
|
+
...(yearFrom !== undefined ? { yearFrom } : {}), ...(yearTo !== undefined ? { yearTo } : {}),
|
|
33
|
+
maxCandidates: max as number,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function scopePath(dir: string): string { return join(dir, "scope.json"); }
|
|
38
|
+
const SCOPE_BYTES = 65_536;
|
|
39
|
+
export function scopeHash(dir: string): string | undefined {
|
|
40
|
+
const path = scopePath(dir);
|
|
41
|
+
return existsSync(path) ? sha256(readBytesBounded(path, SCOPE_BYTES)) : undefined;
|
|
42
|
+
}
|
|
43
|
+
export function readScope(dir: string): Scope | undefined {
|
|
44
|
+
const path = scopePath(dir);
|
|
45
|
+
return existsSync(path) ? validateScope(readJsonFile(path, SCOPE_BYTES)) : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function setScope(dir: string, raw: unknown, actor: Actor): { scope: Scope; sha256: string; changed: boolean } {
|
|
49
|
+
const scope = validateScope(raw), text = JSON.stringify(scope, null, 2) + "\n", hash = sha256(text);
|
|
50
|
+
const changed = scopeHash(dir) !== hash;
|
|
51
|
+
if (changed) commit(dir, [[scopePath(dir), text, SCOPE_BYTES]], [{ actor, type: "scope.set", data: { sha256: hash } }]);
|
|
52
|
+
return { scope, sha256: hash, changed };
|
|
53
|
+
}
|