@staix/agent-research 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +108 -14
- package/package.json +4 -2
- package/src/autonomy.ts +50 -0
- package/src/cli.ts +18 -5
- package/src/commands-table.ts +6 -3
- package/src/commands.ts +56 -15
- package/src/copilot.ts +91 -0
- package/src/exp/attempts.ts +12 -4
- package/src/exp/investigate.ts +7 -2
- package/src/exp/protocol.ts +4 -1
- package/src/http.ts +46 -2
- package/src/lit/watch.ts +14 -1
- package/src/manifest.ts +3 -2
- package/src/mcp-tools.ts +18 -8
- package/src/mcp.ts +2 -0
- package/src/memory/archive.ts +3 -0
- package/src/paper/build.ts +4 -1
- package/src/paper/check.ts +4 -3
- package/src/paper/placeholders.ts +5 -3
- package/src/paper/review.ts +15 -3
- package/src/plan/controller.ts +42 -12
- package/src/plan/definition.ts +17 -5
- package/src/plan/state.ts +53 -17
- package/src/playbook.ts +14 -2
- package/src/study-export.ts +254 -0
- package/src/study.ts +45 -17
- package/src/ui/panel.js +8 -3
- package/src/ui/plugin.ts +21 -7
package/src/paper/check.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CopilotAcceptError } from "../copilot.ts";
|
|
1
2
|
import { existsSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { openStudy, readTextBounded, sha256 } from "../study.ts";
|
|
@@ -10,7 +11,7 @@ import { finalResultPath } from "../exp/final.ts";
|
|
|
10
11
|
import { MANUSCRIPT_BYTES, parsePlaceholders, resolvePlaceholders } from "./placeholders.ts";
|
|
11
12
|
|
|
12
13
|
export const MAX_CODE_NUMBERS = 500;
|
|
13
|
-
export interface PaperFinding { code: "bare-number" | "unresolved-ref" | "bad-format" | "unknown-citation" | "unverified-citation" | "missing-negative-result"; start: number; end: number; line: number; column: number; message: string }
|
|
14
|
+
export interface PaperFinding { code: "bare-number" | "unresolved-ref" | "unaccepted-draft" | "bad-format" | "unknown-citation" | "unverified-citation" | "missing-negative-result"; start: number; end: number; line: number; column: number; message: string }
|
|
14
15
|
function blank(text: string, mask: Uint8Array): string { return text.split("").map((char, index) => mask[index] && char !== "\n" && char !== "\r" ? " " : char).join(""); }
|
|
15
16
|
function mark(mask: Uint8Array, start: number, end: number) { mask.fill(1, start, end); }
|
|
16
17
|
function escaped(text: string, index: number): boolean { let slashes = 0; while (index > 0 && text[--index] === "\\") slashes++; return slashes % 2 === 1; }
|
|
@@ -169,9 +170,9 @@ export function checkPaper(dirArg: string) {
|
|
|
169
170
|
const protocol = readProtocol(dir), rule = protocol ? readSelection(dir, protocol) : undefined;
|
|
170
171
|
if (rule && existsSync(investigateResultPath(dir, rule.run))) {
|
|
171
172
|
try { if (readClosedInvestigation(dir).result.winner === null) requireRef("investigate.reason"); }
|
|
172
|
-
catch { findings.push(finding("unresolved-ref", 0, 0, "investigate result is not current and closed; run ares investigate close before checking the paper")); }
|
|
173
|
+
catch (error) { findings.push(error instanceof CopilotAcceptError ? finding("unaccepted-draft", 0, 0, error.message) : finding("unresolved-ref", 0, 0, "investigate result is not current and closed; run ares investigate close before checking the paper")); }
|
|
173
174
|
}
|
|
174
|
-
} catch { findings.push(finding("unresolved-ref", 0, 0, "experiment design or selection is invalid; restore the current validated inputs before paper check")); }
|
|
175
|
+
} catch (error) { findings.push(error instanceof CopilotAcceptError ? finding("unaccepted-draft", 0, 0, error.message) : finding("unresolved-ref", 0, 0, "experiment design or selection is invalid; restore the current validated inputs before paper check")); }
|
|
175
176
|
}
|
|
176
177
|
let rendered = "", cursor = 0;
|
|
177
178
|
for (const entry of resolution.placeholders) { rendered += manuscript.slice(cursor, entry.placeholder.start) + entry.rendered; cursor = entry.placeholder.end; }
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CopilotAcceptError } from "../copilot.ts";
|
|
1
2
|
import { join, relative } from "node:path";
|
|
2
3
|
import { openStudy, plain, readLedger, readTextBounded, sha256 } from "../study.ts";
|
|
3
4
|
import { ATTEMPT_BYTES, listAttempts, type Attempt, type AttemptStatus } from "../exp/attempts.ts";
|
|
@@ -9,7 +10,7 @@ import { FINAL_RESULT_BYTES, finalResultPath, readClosedFinal, requirePosthocClo
|
|
|
9
10
|
export const MANUSCRIPT_BYTES = 2 * 1024 * 1024, MAX_PLACEHOLDERS = 5000;
|
|
10
11
|
interface Position { start: number; end: number; line: number; column: number; raw: string }
|
|
11
12
|
export type Placeholder = Position & ({ kind: "number"; ref: string; format?: number } | { kind: "literal"; text: string });
|
|
12
|
-
export interface PlaceholderFinding extends Position { code: "bad-format" | "unresolved-ref"; message: string }
|
|
13
|
+
export interface PlaceholderFinding extends Position { code: "bad-format" | "unresolved-ref" | "unaccepted-draft"; message: string }
|
|
13
14
|
export interface ResolvedPlaceholder { placeholder: Placeholder; value: number | string; rendered: string; source?: { file: string; sha256: string } }
|
|
14
15
|
/** Offsets are UTF-16 string indices; line and column are 1-based, as used by the Markdown checker. */
|
|
15
16
|
export function parsePlaceholders(text: string): { placeholders: Placeholder[]; findings: PlaceholderFinding[] } {
|
|
@@ -59,7 +60,7 @@ export function resolvePlaceholders(dirArg: string, text: string) {
|
|
|
59
60
|
const { dir, study } = openStudy(dirArg), parsed = parsePlaceholders(text), findings = [...parsed.findings], resolved: ResolvedPlaceholder[] = [], literals: Placeholder[] = [];
|
|
60
61
|
const cache = new Map<string, Source<unknown> | Error>();
|
|
61
62
|
const cached = <T>(key: string, read: () => Source<T>): Source<T> => {
|
|
62
|
-
if (!cache.has(key)) { try { cache.set(key, read()); } catch { cache.set(key, new Error("absent, unclosed, stale or invalid source")); } }
|
|
63
|
+
if (!cache.has(key)) { try { cache.set(key, read()); } catch (error) { cache.set(key, error instanceof CopilotAcceptError ? error : new Error("absent, unclosed, stale or invalid source")); } }
|
|
63
64
|
const value = cache.get(key)!; if (value instanceof Error) throw value; return value as Source<T>;
|
|
64
65
|
};
|
|
65
66
|
const investigate = () => cached("investigate", () => { const closed = readClosedInvestigation(dir); return source(dir, investigateResultPath(dir, closed.result.run), INVESTIGATE_BYTES, closed.result, closed.sha256); });
|
|
@@ -104,7 +105,8 @@ export function resolvePlaceholders(dirArg: string, text: string) {
|
|
|
104
105
|
}
|
|
105
106
|
const rendered = (typeof value === "number" && placeholder.format !== undefined ? value.toFixed(placeholder.format) : String(value)) + (exploratory ? " (exploratory)" : "");
|
|
106
107
|
resolved.push({ placeholder, value, rendered, source: provenance.source });
|
|
107
|
-
} catch {
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error instanceof CopilotAcceptError) { findings.push({ ...placeholder, code: "unaccepted-draft", message: error.message }); continue; }
|
|
108
110
|
findings.push({ ...placeholder, code: "unresolved-ref", message: `placeholder ${ref}: unknown ref or absent, unclosed, stale or invalid source; inspect the attempt and close the current phase results before retrying` });
|
|
109
111
|
}
|
|
110
112
|
}
|
package/src/paper/review.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requireCopilotAccept, CopilotAcceptError } from "../copilot.ts";
|
|
1
2
|
import { boundedText, commit, onlyKeys, openStudy, plain, readLedger, sha256, type Actor, type LedgerEvent } from "../study.ts";
|
|
2
3
|
import { readCurrentBuild } from "./build.ts";
|
|
3
4
|
import { checkPaper } from "./check.ts";
|
|
@@ -63,7 +64,9 @@ export function foldReviews(events: LedgerEvent[]): ReviewRecord[] {
|
|
|
63
64
|
}
|
|
64
65
|
/** Caller holds the study lock; report ids are generated, findings never disappear by vote. */
|
|
65
66
|
export function addReview(dirArg: string, raw: unknown, actor: Actor) {
|
|
66
|
-
const { dir } = openStudy(dirArg)
|
|
67
|
+
const { dir } = openStudy(dirArg);
|
|
68
|
+
requireCopilotAccept(dir, "paper");
|
|
69
|
+
const report = validateReview(raw), current = readCurrentBuild(dir), events = readLedger(dir), rows = foldReviews(events);
|
|
67
70
|
if (report.buildSha256 !== current.evidenceSha256) bad("stale buildSha256; use the evidenceSha256 from the current ares paper build output");
|
|
68
71
|
if (rows.filter(row => row.report.buildSha256 === report.buildSha256).length >= 100) bad("100 reviews already recorded for this build; resolve existing findings or build a revised manuscript");
|
|
69
72
|
const id = sha256(JSON.stringify([report, events.length])).slice(0, 16);
|
|
@@ -73,7 +76,9 @@ export function addReview(dirArg: string, raw: unknown, actor: Actor) {
|
|
|
73
76
|
commit(dir, [], [{ actor, type: "review.added", data: { id, review: report } }]); return { id, ...report };
|
|
74
77
|
}
|
|
75
78
|
export function resolveReview(dirArg: string, target: string, reason: string, actor: Actor) {
|
|
76
|
-
const { dir } = openStudy(dirArg)
|
|
79
|
+
const { dir } = openStudy(dirArg);
|
|
80
|
+
requireCopilotAccept(dir, "paper");
|
|
81
|
+
const parts = target.split("/");
|
|
77
82
|
if (parts.length !== 2) bad("resolution target must be <review-id>/<finding-id>; use ids from ares review show");
|
|
78
83
|
const reviewId = identifier(parts[0], "review id"), findingId = identifier(parts[1], "finding id"), why = text(reason, "resolution reason"), row = foldReviews(readLedger(dir)).find(row => row.id === reviewId);
|
|
79
84
|
if (!row) bad(`unknown review ${reviewId}`);
|
|
@@ -98,9 +103,14 @@ export function reviewBinding(dirArg: string): string | undefined {
|
|
|
98
103
|
}
|
|
99
104
|
export function reviewReadiness(dirArg: string) {
|
|
100
105
|
const { dir } = openStudy(dirArg), events = readLedger(dir), unmet: { code: string; message: string }[] = [];
|
|
106
|
+
for (const artifact of ["paper", "reviews"] as const) {
|
|
107
|
+
try { requireCopilotAccept(dir, artifact); }
|
|
108
|
+
catch (error) { unmet.push({ code: `accept:${artifact}`, message: error instanceof Error ? error.message : `copilot ${artifact} acceptance unavailable; inspect ares gate list` }); }
|
|
109
|
+
}
|
|
101
110
|
let current: ReturnType<typeof readCurrentBuild> | undefined, check: ReturnType<typeof checkPaper> | undefined;
|
|
102
111
|
try { current = readCurrentBuild(dir); check = current.check; } catch (error) { unmet.push({ code: "build", message: error instanceof Error ? error.message : "current build unavailable; run ares paper build" }); }
|
|
103
112
|
if (!check) { try { check = checkPaper(dir); } catch (error) { unmet.push({ code: "paper-check", message: error instanceof Error ? error.message : "paper check unavailable; restore paper/manuscript.md" }); } }
|
|
113
|
+
for (const finding of check?.findings ?? []) if (finding.code === "unaccepted-draft" && !unmet.some(item => item.code === "accept-source" && item.message === finding.message)) unmet.unshift({ code: "accept-source", message: finding.message });
|
|
104
114
|
if (check && !check.ok) unmet.push({ code: "paper-check", message: `paper check has ${check.findings.length} finding(s); run ares paper check --json and fix every finding` });
|
|
105
115
|
let rows: ReviewRecord[] = [];
|
|
106
116
|
try { rows = foldReviews(events); } catch (error) { unmet.push({ code: "review-journal", message: error instanceof Error ? error.message : "review journal invalid; restore its valid events" }); }
|
|
@@ -111,5 +121,7 @@ export function reviewReadiness(dirArg: string) {
|
|
|
111
121
|
return { ready: unmet.length === 0, buildSha256: buildSha256 ?? null, ...(current ? { manuscriptSha256: current.manuscriptSha256 } : {}), check: check ?? null, reviews, families, openBlocking, checklists: reviews.map(row => ({ reviewId: row.id, ...row.report.checklist })), claims: "unavailable (STAIxBWLB/agent-hub#931)", unmet };
|
|
112
122
|
}
|
|
113
123
|
export function requireReviewReady(dir: string): void {
|
|
114
|
-
const report = reviewReadiness(dir)
|
|
124
|
+
const report = reviewReadiness(dir), accept = report.unmet.find(item => item.code.startsWith("accept:") || item.code === "accept-source");
|
|
125
|
+
if (accept) throw new CopilotAcceptError(accept.message);
|
|
126
|
+
if (!report.ready) throw new Error(`gate review: ${report.unmet.map(item => `${item.code}: ${item.message}`).join("; ")}; inspect ares review show before approval`);
|
|
115
127
|
}
|
package/src/plan/controller.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { CopilotAcceptError } from "../copilot.ts";
|
|
1
2
|
import { boundHubTask, type HubClient, type HubObservation } from "./hub.ts";
|
|
2
|
-
import { assertPlanOwner, commit, openStudy, readLedger, requireGate, withLock, withPlanLock, StudyLockBusyError, type Actor, type PendingEvent, type PlanOwner } from "../study.ts";
|
|
3
|
-
import { canonical, nextDispatchId, planGateBinding, readPlanState, type NodeState, type PlanState } from "./state.ts";
|
|
4
|
-
import type
|
|
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";
|
|
5
7
|
|
|
6
8
|
export type StepNode = Extract<PlanNode, { kind: "step" }>;
|
|
7
9
|
export type HubNode = Extract<PlanNode, { kind: "hub" }>;
|
|
@@ -36,7 +38,7 @@ async function transaction<T>(dir: string, fn: () => T): Promise<T> {
|
|
|
36
38
|
}
|
|
37
39
|
/** One controller, short transactional study locks, and no replay of unobserved actions. */
|
|
38
40
|
export async function runPlan(dirArg: string, options: ControllerOptions, runtime: ControllerRuntime): Promise<PlanState> {
|
|
39
|
-
const { dir
|
|
41
|
+
const { dir } = openStudy(dirArg), parallel = options.parallel ?? 1;
|
|
40
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");
|
|
41
43
|
return withPlanLock(dir, async owner => {
|
|
42
44
|
const state = () => readPlanState(dir, readLedger(dir));
|
|
@@ -56,10 +58,20 @@ export async function runPlan(dirArg: string, options: ControllerOptions, runtim
|
|
|
56
58
|
if (!target || target.dispatchId !== node.dispatchId || !["dispatched", "uncertain"].includes(target.status)) return;
|
|
57
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;
|
|
58
60
|
return locked(() => {
|
|
59
|
-
const current =
|
|
61
|
+
const ledger = readLedger(dir), current = readPlanState(dir, ledger), target = current.nodes.find(n => n.id === node.id);
|
|
60
62
|
if (!target || target.dispatchId !== node.dispatchId || !["dispatched", "uncertain"].includes(target.status)) return;
|
|
61
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;
|
|
62
|
-
|
|
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);
|
|
63
75
|
});
|
|
64
76
|
};
|
|
65
77
|
const reconcile = async (node: NodeState): Promise<void> => {
|
|
@@ -93,6 +105,7 @@ export async function runPlan(dirArg: string, options: ControllerOptions, runtim
|
|
|
93
105
|
try { observation = await runtime.execute(definition, node); break; }
|
|
94
106
|
catch (error) {
|
|
95
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; }
|
|
96
109
|
if (definition.command === "attempt.run") { await reconcile(node); return; }
|
|
97
110
|
observation = { status: "uncertain", reason: "step execution interrupted or failed without an observable result; inspect inputs, then retry or skip this node" }; break;
|
|
98
111
|
}
|
|
@@ -107,15 +120,20 @@ export async function runPlan(dirArg: string, options: ControllerOptions, runtim
|
|
|
107
120
|
try {
|
|
108
121
|
while (!runtime.signal?.aborted) {
|
|
109
122
|
let current = state();
|
|
110
|
-
if (current.state === "done") break;
|
|
123
|
+
if (current.state === "done" || current.state === "failed") break;
|
|
111
124
|
if (current.state === "running") {
|
|
112
125
|
// Pending auto gates are approved only inside a successful controller transaction.
|
|
113
|
-
|
|
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, () => {
|
|
114
130
|
assertPlanOwner(dir, owner);
|
|
115
131
|
const events = readLedger(dir), value = readPlanState(dir, events), approvals: PendingEvent[] = [];
|
|
132
|
+
const { study } = openStudy(dir);
|
|
116
133
|
if (value.state !== "running") return;
|
|
117
134
|
for (const gate of value.nodes.filter(n => n.kind === "gate" && n.status === "pending" && n.gate?.state === "pending")) {
|
|
118
|
-
|
|
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));
|
|
119
137
|
}
|
|
120
138
|
if (approvals.length) commit(dir, [], [...approvals, { actor: runtime.actor, type: "plan.gates", data: { revision: value.revision } }]);
|
|
121
139
|
});
|
|
@@ -124,6 +142,7 @@ export async function runPlan(dirArg: string, options: ControllerOptions, runtim
|
|
|
124
142
|
if (runtime.signal?.aborted || current.state !== "running") break;
|
|
125
143
|
const definition = current.definition.nodes.find(n => n.id === node.id)!;
|
|
126
144
|
if (node.status !== "ready" || active.has(node.id)) continue;
|
|
145
|
+
if (!modeAllowsAdmission(current, openStudy(dir).study.autonomy, definition)) continue;
|
|
127
146
|
if (definition.kind === "hub" && runtime.hub) {
|
|
128
147
|
const binding = boundHubTask(readLedger(dir), current.revision, node.id);
|
|
129
148
|
let result: HubObservation;
|
|
@@ -140,10 +159,10 @@ export async function runPlan(dirArg: string, options: ControllerOptions, runtim
|
|
|
140
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;
|
|
141
160
|
let prepared: { attemptId?: string };
|
|
142
161
|
try { prepared = await runtime.prepare(definition, node); }
|
|
143
|
-
catch { // Preparation is read-only; still record a correlated failed dispatch for visibility.
|
|
162
|
+
catch (error) { // Preparation is read-only; still record a correlated failed dispatch for visibility.
|
|
144
163
|
prepared = {};
|
|
145
164
|
const intent = await admit(dir, owner, runtime.actor, current, node, definition, prepared, parallel);
|
|
146
|
-
if (intent) await observe(intent, { status: "failed", reason: "step preparation failed; check command inputs and retry this node" });
|
|
165
|
+
if (intent) await observe(intent, { status: "failed", reason: error instanceof CopilotAcceptError ? error.message : "step preparation failed; check command inputs and retry this node" });
|
|
147
166
|
current = state(); continue;
|
|
148
167
|
}
|
|
149
168
|
const intent = await admit(dir, owner, runtime.actor, current, node, definition, prepared, parallel);
|
|
@@ -155,7 +174,7 @@ export async function runPlan(dirArg: string, options: ControllerOptions, runtim
|
|
|
155
174
|
for (const node of state().nodes.filter(n => n.status === "dispatched" && !active.has(n.id))) {
|
|
156
175
|
if (clock() >= (polls.get(node.dispatchId!)?.at ?? 0)) await reconcile(node);
|
|
157
176
|
}
|
|
158
|
-
if (state().state
|
|
177
|
+
if (["done", "failed"].includes(state().state)) break;
|
|
159
178
|
const next = canonical(state());
|
|
160
179
|
wait = next === fingerprint ? Math.min(wait * 2, 30_000) : 1000; fingerprint = next;
|
|
161
180
|
const live = new Set(state().nodes.filter(n => n.status === "dispatched").map(n => n.dispatchId));
|
|
@@ -180,6 +199,7 @@ async function admit(dir: string, owner: PlanOwner, actor: Actor, snapshot: Plan
|
|
|
180
199
|
assertPlanOwner(dir, owner);
|
|
181
200
|
const current = readPlanState(dir, readLedger(dir)), target = current.nodes.find(n => n.id === node.id);
|
|
182
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;
|
|
183
203
|
const dispatchId = nextDispatchId(current, node.id);
|
|
184
204
|
commit(dir, [], [{ actor, type: "node.intent", data: { revision: current.revision, node: node.id, dispatchId, ...prepared } }]);
|
|
185
205
|
return readPlanState(dir, readLedger(dir)).nodes.find(n => n.id === node.id)!;
|
|
@@ -190,3 +210,13 @@ async function admit(dir: string, owner: PlanOwner, actor: Actor, snapshot: Plan
|
|
|
190
210
|
function stepSlots(state: PlanState): number {
|
|
191
211
|
return state.nodes.filter(n => n.kind === "step" && n.status === "dispatched").length;
|
|
192
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
|
+
}
|
package/src/plan/definition.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { validatePhase, type Phase } from "../autonomy.ts";
|
|
3
4
|
import { COMMANDS } from "../commands-table.ts";
|
|
4
5
|
import { boundedText, commit, onlyKeys, plain, readJsonFile, sha256, type Actor } from "../study.ts";
|
|
5
6
|
|
|
@@ -7,7 +8,7 @@ export const PLAN_BYTES = 65_536, PLAN_ID_BYTES = 128;
|
|
|
7
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;
|
|
8
9
|
export type StepCommand = typeof STEP_COMMANDS[number];
|
|
9
10
|
export type StepArgs = Record<string, string | number | boolean | string[]>;
|
|
10
|
-
type BaseNode = { id: string; needs: string[] };
|
|
11
|
+
type BaseNode = { id: string; needs: string[]; phase?: Phase };
|
|
11
12
|
export type PlanNode = BaseNode & (
|
|
12
13
|
{ kind: "step"; command: StepCommand; args: StepArgs } |
|
|
13
14
|
{ kind: "hub"; campaign: string; unit: string } |
|
|
@@ -74,20 +75,20 @@ function validateDefinition(raw: unknown): PlanDefinition {
|
|
|
74
75
|
const nodes: PlanNode[] = raw.nodes.map((n: unknown, index: number) => {
|
|
75
76
|
if (!plain(n)) throw new Error(`plan node[${index}]: object required`);
|
|
76
77
|
const id = nodeId(n.id, `plan node[${index}].id`), needs = n.needs === undefined ? [] : references(n.needs, `plan node ${String(n.id)}.needs`);
|
|
77
|
-
const base = { id, needs };
|
|
78
|
+
const base = { id, needs, ...(n.phase === undefined ? {} : { phase: validatePhase(n.phase) }) };
|
|
78
79
|
switch (n.kind) {
|
|
79
80
|
case "step": {
|
|
80
|
-
onlyKeys(n, ["id", "kind", "needs", "command", "args"], `plan step ${id}`);
|
|
81
|
+
onlyKeys(n, ["id", "kind", "needs", "phase", "command", "args"], `plan step ${id}`);
|
|
81
82
|
if (!STEP_COMMANDS.includes(n.command as StepCommand)) throw new Error(`plan step ${id}: unknown command ${String(n.command)} (allowed: ${STEP_COMMANDS.join(", ")})`);
|
|
82
83
|
const command = n.command as StepCommand;
|
|
83
84
|
return { ...base, kind: "step", command, args: stepArgs(n.args ?? {}, command, id) };
|
|
84
85
|
}
|
|
85
86
|
case "hub": {
|
|
86
|
-
onlyKeys(n, ["id", "kind", "needs", "campaign", "unit"], `plan hub ${id}`);
|
|
87
|
+
onlyKeys(n, ["id", "kind", "needs", "phase", "campaign", "unit"], `plan hub ${id}`);
|
|
87
88
|
return { ...base, kind: "hub", campaign: nodeId(n.campaign, `plan hub ${id}.campaign`), unit: nodeId(n.unit, `plan hub ${id}.unit`) };
|
|
88
89
|
}
|
|
89
90
|
case "gate":
|
|
90
|
-
onlyKeys(n, ["id", "kind", "needs", "binds"], `plan gate ${id}`);
|
|
91
|
+
onlyKeys(n, ["id", "kind", "needs", "phase", "binds"], `plan gate ${id}`);
|
|
91
92
|
return { ...base, kind: "gate", binds: references(n.binds, `plan gate ${id}.binds`) };
|
|
92
93
|
default: throw new Error(`plan node ${id}: unknown node kind ${String(n.kind)}; ${compatible}`);
|
|
93
94
|
}
|
|
@@ -137,6 +138,17 @@ function validateDefinition(raw: unknown): PlanDefinition {
|
|
|
137
138
|
if (Buffer.byteLength(JSON.stringify(result, null, 2) + "\n") > PLAN_BYTES) throw new Error(`plan: exceeds ${PLAN_BYTES} bytes`);
|
|
138
139
|
return result;
|
|
139
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
|
+
|
|
140
152
|
export function readPlan(dir: string): PlanDefinition | undefined {
|
|
141
153
|
const file = join(dir, "plan.json");
|
|
142
154
|
if (!existsSync(file)) return undefined;
|
package/src/plan/state.ts
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
|
+
import { resolvePhaseAutonomy, type Autonomy } from "../autonomy.ts";
|
|
1
2
|
import { gateState, plain, sha256, boundedText, onlyKeys, type Actor, type GateName, type GateState, type LedgerEvent, type PendingEvent } from "../study.ts";
|
|
2
3
|
import { PLAN_ID_BYTES, readPlan, validatePlan, type PlanDefinition, type PlanNode } from "./definition.ts";
|
|
3
4
|
|
|
4
5
|
export const PLAN_GATE_BYTES = "plan:".length + PLAN_ID_BYTES;
|
|
5
6
|
|
|
6
7
|
export type NodeStatus = "pending" | "ready" | "dispatched" | "done" | "failed" | "cancelled" | "uncertain" | "stale" | "skipped";
|
|
7
|
-
export interface NodeState { 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 } }
|
|
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 } }
|
|
8
9
|
export interface ControlRequest { action: "pause" | "resume" | "retry" | "cancel" | "skip"; node?: string; commandId: string; expectedRevision: number }
|
|
9
|
-
export interface ControlResult { commandId: string; revision: number; action: ControlRequest["action"]; node?: string; state: "paused" | "running" | "done"; status?: NodeStatus }
|
|
10
|
-
export interface PlanState { definition: PlanDefinition; revision: number; state: "paused" | "running" | "done"; nodes: NodeState[]; uncertain: string[] }
|
|
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
|
+
}
|
|
11
25
|
const statuses: readonly NodeStatus[] = ["pending", "ready", "dispatched", "done", "failed", "cancelled", "uncertain", "stale", "skipped"];
|
|
12
26
|
const recovery = "; run ares plan show and retry with its revision and a new --command-id";
|
|
13
27
|
function bad(message: string): never { throw new Error(`plan: ${message}${recovery}`); }
|
|
@@ -35,7 +49,7 @@ function descendants(definition: PlanDefinition, roots: Set<string>): Set<string
|
|
|
35
49
|
}
|
|
36
50
|
function reset(node: NodeState, status: NodeStatus): void {
|
|
37
51
|
node.status = status; delete node.output; delete node.reason; delete node.gate;
|
|
38
|
-
delete node.dispatchId;
|
|
52
|
+
delete node.dispatchId; delete node.stepCancellation;
|
|
39
53
|
// A retry must create a child attempt, while hub task references stay read-only.
|
|
40
54
|
if (status === "pending" && node.attemptId) { node.retryOf = node.attemptId; delete node.attemptId; }
|
|
41
55
|
}
|
|
@@ -90,15 +104,17 @@ function refresh(state: PlanState, decisions: Map<string, LedgerEvent>): void {
|
|
|
90
104
|
}
|
|
91
105
|
for (const node of state.definition.nodes) visit(node.id);
|
|
92
106
|
state.uncertain = state.nodes.filter(n => n.status === "uncertain").map(n => n.id);
|
|
93
|
-
if (state.nodes.length && state.nodes.every(n => n.status === "done" || n.status === "skipped")) state.state = "done";
|
|
94
|
-
|
|
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";
|
|
95
111
|
}
|
|
96
112
|
/** Sole state authority: replay ledger events; no cache or snapshot is written. */
|
|
97
113
|
/** Optional observer sees each effective, refreshed event; it must not mutate the folded state. */
|
|
98
114
|
export function foldPlan(events: LedgerEvent[], onEvent?: (state: PlanState, event: LedgerEvent) => void): PlanState | undefined {
|
|
99
115
|
let state: PlanState | undefined;
|
|
100
116
|
const decisions = new Map<string, LedgerEvent>();
|
|
101
|
-
const dispatches = new Set<string>();
|
|
117
|
+
const dispatches = new Set<string>(), stepPauses = new Set<string>();
|
|
102
118
|
for (const event of events) {
|
|
103
119
|
const d = event.data;
|
|
104
120
|
if (event.type === "gate.approve" || event.type === "gate.reject") {
|
|
@@ -127,19 +143,34 @@ export function foldPlan(events: LedgerEvent[], onEvent?: (state: PlanState, eve
|
|
|
127
143
|
}
|
|
128
144
|
if (!state) continue;
|
|
129
145
|
if (!["plan.control", "plan.paused", "node.intent", "node.observed", "node.stale"].includes(event.type)) continue;
|
|
130
|
-
|
|
131
|
-
if (event.type
|
|
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
|
+
}
|
|
132
156
|
else if (event.type === "plan.control") {
|
|
133
157
|
const request = validateControl(d.request);
|
|
134
158
|
if (request.expectedRevision !== state.revision) bad("plan.control revision mismatch; restore the last valid ledger");
|
|
135
|
-
if (request.action === "pause") state.state = "paused";
|
|
136
|
-
else if (request.action === "resume") state.state = "running";
|
|
159
|
+
if (request.action === "pause") { state.state = "paused"; delete state.pauseReason; }
|
|
160
|
+
else if (request.action === "resume") { state.state = "running"; delete state.pauseReason; }
|
|
137
161
|
else {
|
|
138
162
|
const target = state.nodes.find(n => n.id === request.node);
|
|
139
163
|
if (!target) bad(`control references missing node ${request.node}`);
|
|
140
164
|
const affected = descendants(state.definition, new Set([target.id]));
|
|
141
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
|
+
}
|
|
142
172
|
reset(target, request.action === "retry" ? "pending" : request.action === "skip" ? "skipped" : "cancelled");
|
|
173
|
+
if (cancelled) target.stepCancellation = cancelled;
|
|
143
174
|
}
|
|
144
175
|
} else {
|
|
145
176
|
const node = state.nodes.find(n => n.id === d.node);
|
|
@@ -168,7 +199,7 @@ export function foldPlan(events: LedgerEvent[], onEvent?: (state: PlanState, eve
|
|
|
168
199
|
if (node.kind !== "hub" || !Number.isSafeInteger(d.taskId) || (d.taskId as number) < 1 || (node.taskId !== undefined && node.taskId !== d.taskId)) {
|
|
169
200
|
node.status = "failed"; delete node.output;
|
|
170
201
|
node.reason = "hub child task binding changed or invalid; inspect ahub study show and retry explicitly";
|
|
171
|
-
node.lastObserved = { revision: d.revision as number, dispatchId, status: "failed", at: event.at, reason: node.reason };
|
|
202
|
+
node.lastObserved = { revision: d.revision as number, dispatchId, status: "failed", at: event.at, reason: node.reason, ...(d.step === true ? { step: true as const } : {}) };
|
|
172
203
|
refresh(state, decisions); onEvent?.(state, event); continue;
|
|
173
204
|
}
|
|
174
205
|
node.taskId = d.taskId as number;
|
|
@@ -178,7 +209,7 @@ export function foldPlan(events: LedgerEvent[], onEvent?: (state: PlanState, eve
|
|
|
178
209
|
if (node.status === "done" && d.output !== undefined) node.output = d.output;
|
|
179
210
|
delete node.reason;
|
|
180
211
|
if (typeof d.reason === "string") node.reason = boundedText(d.reason, `node ${node.id} reason`, 500);
|
|
181
|
-
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 } : {}) };
|
|
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 } : {}) };
|
|
182
213
|
}
|
|
183
214
|
}
|
|
184
215
|
refresh(state, decisions); onEvent?.(state, event);
|
|
@@ -211,7 +242,7 @@ function controlRequest(raw: unknown): ControlRequest {
|
|
|
211
242
|
if ((action === "pause" || action === "resume") ? node !== undefined : node === undefined) bad(`control ${action} ${node === undefined ? "requires a node" : "takes no node"}`);
|
|
212
243
|
return { action, commandId, expectedRevision: raw.expectedRevision as number, ...(node !== undefined ? { node } : {}) };
|
|
213
244
|
}
|
|
214
|
-
export function prepareControl(state: PlanState, events: LedgerEvent[], raw: unknown, actor: Actor): { result: ControlResult; event?: PendingEvent } {
|
|
245
|
+
export function prepareControl(state: PlanState, events: LedgerEvent[], raw: unknown, actor: Actor, policy?: Autonomy): { result: ControlResult; event?: PendingEvent; pause?: PendingEvent } {
|
|
215
246
|
const request = validateControl(raw);
|
|
216
247
|
for (const e of events) if (e.type === "plan.control" && plain(e.data.request) && e.data.request.commandId === request.commandId) {
|
|
217
248
|
if (canonical(e.data.request) !== canonical(request)) bad(`command-id ${request.commandId} already has a different payload`);
|
|
@@ -226,9 +257,14 @@ export function prepareControl(state: PlanState, events: LedgerEvent[], raw: unk
|
|
|
226
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`);
|
|
227
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`);
|
|
228
259
|
}
|
|
229
|
-
const
|
|
230
|
-
const
|
|
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;
|
|
231
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 };
|
|
232
268
|
pending.data.result = result;
|
|
233
|
-
return { result, event: pending };
|
|
269
|
+
return { result, event: pending, ...(pause ? { pause } : {}) };
|
|
234
270
|
}
|
package/src/playbook.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
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)
|
|
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
3
|
|
|
4
|
-
Before starting a phase, create its study once: ares init <dir> --question "..." [--mode experiment] [--autonomy gates]. Use --mode experiment for experiment phases; reuse the initialized study thereafter.
|
|
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
5
|
|
|
6
6
|
Literature workflow
|
|
7
7
|
|
|
@@ -118,6 +118,18 @@ ares lessons <dir> --for "derive reusable skills from study evidence"
|
|
|
118
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
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
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
|
+
|
|
121
133
|
Archive workflow
|
|
122
134
|
|
|
123
135
|
ares lessons <dir> --for "archive the completed study"
|