@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.
@@ -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
  }
@@ -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), report = validateReview(raw), current = readCurrentBuild(dir), events = readLedger(dir), rows = foldReviews(events);
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), parts = target.split("/");
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); if (!report.ready) throw new Error(`gate review: ${report.unmet.map(item => `${item.code}: ${item.message}`).join("; ")}; inspect ares review show before approval`);
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
  }
@@ -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 { PlanNode } from "./definition.ts";
3
+ import { assertPlanOwner, commit, openStudy, readLedger, prepareCommit, requireGate, withLock, withPlanLock, StudyLockBusyError, type Actor, type PendingEvent, type PlanOwner } from "../study.ts";
4
+ import { canonical, foldPlan, hasRemainingWork, TERMINAL_NODE_STATUSES, nextDispatchId, planGateBinding, readPlanState, type NodeState, type PlanState } from "./state.ts";
5
+ import { guardedPlanPhase, type PlanNode } from "./definition.ts";
6
+ import { resolveAutonomy, resolvePhaseAutonomy, type Autonomy } from "../autonomy.ts";
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, study } = openStudy(dirArg), parallel = options.parallel ?? 1;
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 = state(), target = current.nodes.find(n => n.id === node.id);
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
- commit(dir, [], [{ 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 } : {}) } }]);
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
- if (study.autonomy === "auto" && current.nodes.some(n => n.kind === "gate" && n.status === "pending" && n.gate?.state === "pending")) await tryTransaction(dir, () => {
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
- if (study.autonomy === "auto") approvals.push(...requireGate(dir, study, `plan:${gate.id}`, name => planGateBinding(value, name)));
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 === "done") break;
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
+ }
@@ -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
- else if (state.state === "done") state.state = "paused";
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
- if (event.type !== "node.observed" && d.revision !== state.revision) continue;
131
- if (event.type === "plan.paused") state.state = "paused";
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 pending: PendingEvent = { actor, type: "plan.control", data: { revision: state.revision, request } };
230
- const next = foldPlan([...events, { v: 1, at: new Date().toISOString(), ...pending }])!;
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. Install amem 0.20.3 or a compatible CLI for phase lessons and archive; these keep the study cwd and agent attribution.
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"