@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.
@@ -0,0 +1,254 @@
1
+ import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync } from "node:fs";
2
+ import { basename, dirname, join, relative, resolve, isAbsolute, sep } from "node:path";
3
+ import { readAttemptRecords, type Attempt, type AttemptStatus } from "./exp/attempts.ts";
4
+ import { ablateResultPath, readClosedAblate } from "./exp/ablate.ts";
5
+ import { finalResultPath, readClosedFinal } from "./exp/final.ts";
6
+ import { readClosedInvestigation } from "./exp/investigate.ts";
7
+ import { readSelection } from "./exp/selection.ts";
8
+ import { designBinding, protocolHash, readProtocol } from "./exp/protocol.ts";
9
+ import { FORMAT_VERSIONS } from "./manifest.ts";
10
+ import { PAPER_EVIDENCE_BYTES, paperBuildPaths } from "./paper/build.ts";
11
+ import { assertWritable, openStudy, plain, readLedger, readTextBounded, sha256, withLock, writeAtomic, type LedgerEvent } from "./study.ts";
12
+
13
+ /** Reader bound for every bundle file; writeAtomic refuses anything larger. */
14
+ export const BUNDLE_FILE_BYTES = 64 * 1024 * 1024;
15
+ export const BUNDLE_FILES = ["manifest.json", "ledger.jsonl", "attempts.jsonl", "results.json", "reviews.jsonl", "evidence.json"] as const;
16
+ const STATUSES: AttemptStatus[] = ["running", "succeeded", "failed", "cancelled", "unknown"];
17
+ const SHA256 = /^[a-f0-9]{64}$/;
18
+ const HEX = /^[a-f0-9]{16,64}$/;
19
+ const TOKEN = /^[a-z][a-z0-9_.:-]{0,47}$/;
20
+ const GIT_ID = /^[a-f0-9]{40}$/, ATTEMPT_ID = /^[a-f0-9]{16}$/;
21
+ const ACTOR_KINDS = new Set(["person", "agent", "auto"]), EVIDENCE_FAMILIES = new Set(["attempt", "posthoc", "investigate", "ablate", "final"]);
22
+ /** Exact-shape field or null: hand-edited free text in an identity field is never copied. */
23
+ const shaped = (value: unknown, pattern: RegExp): string | null => typeof value === "string" && pattern.test(value) ? value : null;
24
+ const finite = (value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? value : null;
25
+ const METRIC_KEYS = new Set(["name", "unit", "metric"]);
26
+ /** Closed data keys per ledger event type; unlisted types keep the envelope with empty data. Never host, containerId or hubProject. */
27
+ const LEDGER_KEYS: Record<string, readonly string[]> = {
28
+ "scope.set": ["sha256"], "autonomy.set": ["from", "to", "phase"], "gate.approve": ["gate", "binding"], "gate.reject": ["gate", "binding"],
29
+ "lit.search": ["sources", "added", "merged", "total"], "lit.screen": ["decision"], "lit.card": ["id", "sha256"], "lit.bib": ["entries", "sha256"], "lit.verify": ["verified", "failed"],
30
+ "archive.exported": ["fingerprint"], "skill.proposed": ["skillProposal", "sha256"], "skill.decided": ["skillProposal", "decision"], "watch.set": ["sha256"],
31
+ "plan.set": ["revision", "state", "sha256"], "plan.gates": ["revision"], "plan.paused": ["revision"], "plan.control": ["revision"], "node.observed": ["revision", "status"], "node.intent": ["revision"],
32
+ "executor.set": ["kind"], "protocol.set": ["sha256", "protocolHash", "treeHash"], "selection.set": ["sha256", "metric"], "final.seeds.set": ["sha256"],
33
+ "investigate.closed": ["run", "sha256", "ruleSha256", "winner"], "ablate.closed": ["sha256", "investigateSha256"], "final.closed": ["sha256", "status", "investigateSha256"],
34
+ "idea.decided": ["decision"], "paper.built": ["manuscriptSha256", "evidenceSha256", "sourceManuscriptSha256"], "review.added": [], "review.resolved": ["buildSha256"],
35
+ "attempt.started": ["id", "run", "seed", "commit", "treeId"],
36
+ };
37
+ for (const t of ["attempt.run", "attempt.snapshot", "attempt.stage.changed", "attempt.stage.evidence", "attempt.stage.recovered", "attempt.stage.started", "attempt.succeeded", "attempt.failed", "attempt.cancelled", "attempt.unknown"]) LEDGER_KEYS[t] = ["id", "run", "stage", "status"];
38
+
39
+ export interface BundleManifest {
40
+ bundle: 1; formats: typeof FORMAT_VERSIONS; studyIdSha256: string; mode: string; protocolHash: string | null; sealBinding: string | null;
41
+ window: { from: string | null; to: string | null }; counts: { attempts: number; byStatus: Record<AttemptStatus, number>; byRun: Record<string, Record<AttemptStatus, number>>; ledgerEvents: number; reviews: number };
42
+ files: Record<string, string>;
43
+ }
44
+ export interface BundleListing { bundle: string; files: string[]; wouldWrite: true }
45
+
46
+ /** A closed scalar: numbers, booleans, hashes and enum-like tokens survive; free text, paths and URLs do not. */
47
+ function closed(value: unknown): value is number | boolean | string | null {
48
+ if (value === null || typeof value === "boolean") return true;
49
+ if (typeof value === "number") return Number.isFinite(value);
50
+ return typeof value === "string" && (HEX.test(value) || TOKEN.test(value));
51
+ }
52
+ function reduceData(data: Record<string, unknown>, keys: readonly string[]): Record<string, unknown> {
53
+ const out: Record<string, unknown> = {};
54
+ for (const key of keys) { const value = Object.hasOwn(data, key) ? data[key] : undefined; if (closed(value) || (METRIC_KEYS.has(key) && typeof value === "string")) out[key] = value; else if (Array.isArray(value) && value.every(closed)) out[key] = value; }
55
+ return out;
56
+ }
57
+ /** Result keys that hold free text even when a value happens to be one token. */
58
+ const FREE_TEXT_KEYS = new Set(["removes", "description", "note", "notes", "label", "text", "message", "cause", "retryWhen", "method", "question"]);
59
+ /** Keep numbers, booleans, null, hashes and closed tokens recursively; drop free-text keys and every other string. */
60
+ function project(value: unknown): unknown {
61
+ if (Array.isArray(value)) return value.map(project).filter(v => v !== undefined);
62
+ if (plain(value)) { const out: Record<string, unknown> = {}; for (const [k, v] of Object.entries(value)) { if (FREE_TEXT_KEYS.has(k)) continue; const p = METRIC_KEYS.has(k) && typeof v === "string" ? v : project(v); if (p !== undefined) out[k] = p; } return out; }
63
+ return closed(value) ? value : undefined;
64
+ }
65
+ /** A parseable ISO timestamp or null. */
66
+ const isoAt = (value: unknown): string | null => typeof value === "string" && value.length <= 64 && /^\d{4}-\d{2}-\d{2}T[\d:.]+(?:Z|[+-]\d{2}:\d{2})$/.test(value) && Number.isFinite(Date.parse(value)) ? value : null;
67
+ function reduceEvent(event: LedgerEvent) {
68
+ const actor = { kind: ACTOR_KINDS.has(event.actor?.kind as string) ? event.actor.kind : "other", ...(event.actor.id !== undefined ? { idSha256: sha256(event.actor.id) } : {}) };
69
+ const at = isoAt(event.at);
70
+ return { v: event.v, at, actor, type: TOKEN.test(event.type) ? event.type : "other", data: reduceData(plain(event.data) ? event.data : {}, (Object.hasOwn(LEDGER_KEYS, event.type) ? LEDGER_KEYS[event.type]! : [])) };
71
+ }
72
+ // Closed codes emitted by attempts.ts, metrics.ts and artifacts.ts; never derive a class from user prose.
73
+ const ATTEMPT_REASON_CODES = new Set([
74
+ "executor-start", "executor-start-uncertain", "preparation", "lost", "wall-clock", "candidate-exit", "evaluator-exit",
75
+ "evidence-read", "invalid-metrics", "malformed-metrics", "undeclared-metric", "missing-metric", "invalid-metric-value",
76
+ "nonfinite-metric", "metrics-limit", "duplicate-metric", "missing-metrics", "missing-receipt", "changed-output",
77
+ "artifact-limit", "artifact-read", "invalid-artifact", "invalid-log-state", "missing-logs",
78
+ ]);
79
+ function reasonClass(reason: string | undefined, status: AttemptStatus): string | undefined {
80
+ if (reason === undefined) return undefined;
81
+ if (status === "cancelled") return "cancel-reason";
82
+ return ATTEMPT_REASON_CODES.has(reason) ? reason : "other";
83
+ }
84
+ function duration(start: unknown, end: unknown): number | null {
85
+ if (typeof start !== "string" || typeof end !== "string") return null;
86
+ const ms = Date.parse(end) - Date.parse(start);
87
+ return Number.isFinite(ms) ? ms : null;
88
+ }
89
+ function digest(image: unknown): string | null {
90
+ const match = typeof image === "string" ? /@(sha256:[a-f0-9]{64})$/.exec(image) : null;
91
+ return match?.[1] ?? null;
92
+ }
93
+ function attemptRow(a: Attempt) {
94
+ const stages: Record<string, unknown> = {};
95
+ for (const name of ["candidate", "evaluator"] as const) {
96
+ const stage = plain(a.stages) && Object.hasOwn(a.stages, name) ? a.stages[name] : undefined;
97
+ if (!plain(stage)) continue;
98
+ stages[name] = { exitCode: Number.isSafeInteger(stage.exitCode) ? stage.exitCode : null, durationMs: duration(stage.startedAt, stage.endedAt), network: stage.network === "none" ? "none" : "other", limits: plain(stage.limits) ? reduceData(stage.limits, ["cpus", "memoryMb", "pidsLimit", "wallSeconds", "gpus"]) : null, imageDigest: digest(stage.image) };
99
+ }
100
+ const reason = reasonClass(a.reason, a.status);
101
+ return {
102
+ id: a.id, run: a.run, studyIdSha256: sha256(String(a.studyId)), protocolHash: shaped(a.protocolHash, SHA256), sealBinding: shaped(a.sealBinding, SHA256), commit: shaped(a.commit, GIT_ID), treeId: shaped(a.treeId, GIT_ID),
103
+ snapshotSha256: shaped(a.snapshotSha256, SHA256), seed: Number.isSafeInteger(a.seed) ? a.seed : null, labelSha256: sha256(String(a.label ?? "")), retryOf: shaped(a.retryOf, ATTEMPT_ID), status: a.status, stage: a.stage, ...(reason ? { reasonClass: reason } : {}),
104
+ durationMs: duration(a.startedAt, a.endedAt), stages,
105
+ metrics: (Array.isArray(a.metrics) ? a.metrics : []).map(m => ({ name: m.name, unit: m.unit, value: finite(m.value), provenance: m.provenance === "ares-observed" ? "ares-observed" : "other" })),
106
+ outputs: (Array.isArray(a.outputs) ? a.outputs : []).map(o => ({ sha256: shaped(o.sha256, SHA256), bytes: finite(o.bytes) })),
107
+ sealing: plain(a.sealing) ? { separateContainers: a.sealing.separateContainers === true, evaluatorReadOnly: a.sealing.evaluatorReadOnly === true, candidateHasNoEvaluator: a.sealing.candidateHasNoEvaluator === true } : null,
108
+ };
109
+ }
110
+ function closedResult<T>(name: string, read: () => { result: T }): unknown {
111
+ try { return project(read().result); }
112
+ catch (error) { throw new Error(`study export: ${name} result is not a verified closed result (${(error as Error).message}); fix it as that message says, then export again`); }
113
+ }
114
+ function reviews(events: LedgerEvent[]) {
115
+ const resolved = new Map<string, number>();
116
+ for (const e of events) if (e.type === "review.resolved" && typeof e.data.reviewId === "string") resolved.set(e.data.reviewId, (resolved.get(e.data.reviewId) ?? 0) + 1);
117
+ return events.filter(e => e.type === "review.added" && plain(e.data.review)).map(e => {
118
+ const review = e.data.review as Record<string, unknown>, findings = Array.isArray(review.findings) ? review.findings as Record<string, unknown>[] : [];
119
+ const reviewer = plain(review.reviewer) ? review.reviewer : {}, kinds: Record<string, number> = {};
120
+ for (const f of findings) { const kind = typeof f.kind === "string" && TOKEN.test(f.kind) ? f.kind : "other"; kinds[kind] = (kinds[kind] ?? 0) + 1; }
121
+ const id = typeof e.data.id === "string" ? e.data.id : "";
122
+ const family = typeof reviewer.modelFamily === "string" ? reviewer.modelFamily.trim().toLowerCase() : "";
123
+ return { idSha256: sha256(id), at: isoAt(e.at), modelFamily: /^[a-z0-9][a-z0-9 ._-]{0,47}$/.test(family) ? family : "other", buildSha256: typeof review.buildSha256 === "string" && HEX.test(review.buildSha256) ? review.buildSha256 : null, findings: findings.length, blocking: findings.filter(f => f.blocking === true).length, kinds, resolved: resolved.get(id) ?? 0 };
124
+ });
125
+ }
126
+ function evidence(dir: string) {
127
+ const path = paperBuildPaths(dir).evidence;
128
+ if (!existsSync(path)) return null;
129
+ let raw: unknown;
130
+ try { raw = JSON.parse(readTextBounded(path, PAPER_EVIDENCE_BYTES)); } catch { throw new Error("study export: paper/build/evidence.json is unreadable, invalid JSON or oversized; rebuild the paper before exporting"); }
131
+ if (!plain(raw) || raw.v !== 1 || !Array.isArray(raw.placeholders)) throw new Error("study export: paper/build/evidence.json has an unsupported shape; rebuild the paper before exporting");
132
+ return {
133
+ v: 1, manuscriptSha256: typeof raw.manuscriptSha256 === "string" && SHA256.test(raw.manuscriptSha256) ? raw.manuscriptSha256 : null,
134
+ placeholders: (raw.placeholders as Record<string, unknown>[]).filter(p => p.kind === "number").map(p => {
135
+ const ref = typeof p.ref === "string" ? p.ref : "", attempt = /^(?:attempt|posthoc):([a-f0-9]{16})\./.exec(ref)?.[1];
136
+ const source = plain(p.source) ? p.source : {};
137
+ return { refSha256: sha256(ref), family: ((f: string | undefined) => f && EVIDENCE_FAMILIES.has(f) ? f : "other")(/^[a-z]+/.exec(ref)?.[0]), ...(attempt ? { attempt } : {}), sourceSha256: typeof source.sha256 === "string" && SHA256.test(source.sha256) ? source.sha256 : null };
138
+ }),
139
+ };
140
+ }
141
+
142
+ /** Path rules run on decoded keys and string values, so a boundary is a real character (a raw newline or tab included). */
143
+ const PATH_SCANS: RegExp[] = [
144
+ // POSIX: `/` or `~/` not preceded by a letter, digit or underscore; units such as tokens/s keep a word character there.
145
+ /(?<![\p{L}\p{N}_])(?:\/|~\/)/u,
146
+ /\b[A-Za-z]:\\/,
147
+ // Windows root-relative (\Users\x), bare \ and UNC (\\server): any backslash not preceded by a word character or backslash.
148
+ /(?<![\p{L}\p{N}_\\])\\/u,
149
+ ];
150
+ /** URL, key-like and env rules, also run on decoded strings. */
151
+ const SCANS: [string, RegExp][] = [
152
+ ["url", /\b[a-z][a-z0-9+.-]*:\/\/|\b(?:mailto|urn|file|data):/i],
153
+ ["key-like token", /\b(?:sk|pk|rk)[-_][A-Za-z0-9_-]{12,}|\b(?:ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{12,}|\bxox[abprs]-[A-Za-z0-9-]{8,}|\bAKIA[0-9A-Z]{12,}|\beyJ[A-Za-z0-9_-]{16,}/],
154
+ ["key-like token", /(?<![A-Za-z0-9_-])(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[a-z])(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{32,}(?![A-Za-z0-9_-])/],
155
+ ["env value", /(?<![A-Za-z0-9_])[A-Za-z_][A-Za-z0-9_]*=[^\s"]/],
156
+ ];
157
+ /** Every key and string value of a JSON document, or of each JSONL line; text that is not JSON is scanned as one string. */
158
+ function* decodedStrings(text: string): Generator<string> {
159
+ const walk = function* (value: unknown): Generator<string> {
160
+ if (typeof value === "string") yield value;
161
+ else if (Array.isArray(value)) for (const item of value) yield* walk(item);
162
+ else if (value && typeof value === "object") for (const [key, item] of Object.entries(value)) { yield key; yield* walk(item); }
163
+ };
164
+ let whole: unknown, parsed = false;
165
+ try { whole = JSON.parse(text); parsed = true; } catch { /* JSONL or plain text */ }
166
+ if (parsed) { yield* walk(whole); return; }
167
+ for (const line of text.split("\n")) {
168
+ if (!line) continue;
169
+ try { yield* walk(JSON.parse(line)); } catch { yield line; }
170
+ }
171
+ }
172
+ /** Refuse the whole bundle if any file carries a path, URL, env assignment or key-like token. */
173
+ export function scanBundle(files: Record<string, string>): void {
174
+ const refuse = (name: string, kind: string): never => { throw new Error(`study export: refused; ${name} would contain a ${kind}. Remove it from the study record (for example an attempt label or ledger field) and retry; nothing was written`); };
175
+ for (const [name, text] of Object.entries(files)) {
176
+ for (const value of decodedStrings(text)) {
177
+ if (PATH_SCANS.some(pattern => pattern.test(value))) refuse(name, "absolute path");
178
+ for (const [kind, pattern] of SCANS) if (pattern.test(value)) refuse(name, kind);
179
+ }
180
+ }
181
+ }
182
+
183
+ function emptyDir(path: string): void {
184
+ if (existsSync(path)) { if (!lstatSync(path).isDirectory()) throw new Error("study export: --bundle exists and is not a directory; choose a new or empty directory"); if (readdirSync(path).length) throw new Error("study export: --bundle directory is not empty; choose a new or empty directory"); }
185
+ else mkdirSync(path, { recursive: true });
186
+ }
187
+
188
+ /** Real path of the nearest existing ancestor with the missing tail re-appended, so symlinks cannot hide containment. */
189
+ function realTarget(path: string): string {
190
+ const tail: string[] = [];
191
+ let current = path;
192
+ while (!existsSync(current)) { const parent = dirname(current); if (parent === current) break; tail.unshift(basename(current)); current = parent; }
193
+ return join(realpathSync(current), ...tail);
194
+ }
195
+ /** Caller holds the study lock across every bounded source read. */
196
+ function readBundle(dirArg: string, maxFileBytes: number) {
197
+ const { dir, study } = openStudy(dirArg);
198
+ const events = readLedger(dir);
199
+ const zero = () => Object.fromEntries(STATUSES.map(s => [s, 0])) as Record<AttemptStatus, number>;
200
+ const byStatus = zero(), runCounts = new Map<string, Record<AttemptStatus, number>>(), attemptLines: string[] = [];
201
+ let attemptBytes = 0;
202
+ // One pass; only the projected row and counts outlive each raw record.
203
+ for (const a of study.mode === "experiment" ? readAttemptRecords(dir) : []) {
204
+ const line = JSON.stringify(attemptRow(a)) + "\n";
205
+ attemptBytes += Buffer.byteLength(line);
206
+ if (attemptBytes > maxFileBytes) throw new Error(`study export: attempts.jsonl would exceed ${maxFileBytes} bytes; nothing was written`);
207
+ attemptLines.push(line);
208
+ byStatus[a.status]++;
209
+ let row = runCounts.get(a.run); if (!row) runCounts.set(a.run, row = zero()); row[a.status]++;
210
+ }
211
+ const byRun: Record<string, Record<AttemptStatus, number>> = Object.create(null);
212
+ for (const [run, row] of [...runCounts].sort(([x], [y]) => x < y ? -1 : 1)) byRun[run] = row;
213
+ const experiment = study.mode === "experiment", current = experiment ? readProtocol(dir) : undefined, rule = current ? readSelection(dir, current) : undefined;
214
+ const investigate: Record<string, unknown> = {};
215
+ if (rule && existsSync(join(dir, "runs", rule.run, "investigate.json"))) investigate[rule.run] = closedResult("investigate", () => readClosedInvestigation(dir));
216
+ const reviewRows = reviews(events), protocol = study.mode === "experiment" ? readProtocol(dir) : undefined;
217
+ const lines = (rows: unknown[]) => rows.map(r => JSON.stringify(r)).join("\n") + (rows.length ? "\n" : "");
218
+ const files: Record<string, string> = {
219
+ "ledger.jsonl": lines(events.map(reduceEvent)),
220
+ "attempts.jsonl": attemptLines.join(""),
221
+ "results.json": JSON.stringify({
222
+ investigate,
223
+ ablate: experiment && existsSync(ablateResultPath(dir)) ? closedResult("ablate", () => readClosedAblate(dir)) : null,
224
+ final: experiment && existsSync(finalResultPath(dir)) ? closedResult("final", () => readClosedFinal(dir)) : null,
225
+ }, null, 2) + "\n",
226
+ "reviews.jsonl": lines(reviewRows),
227
+ "evidence.json": JSON.stringify(evidence(dir), null, 2) + "\n",
228
+ };
229
+ const manifest: BundleManifest = {
230
+ bundle: 1, formats: { ...FORMAT_VERSIONS }, studyIdSha256: sha256(study.id), mode: study.mode,
231
+ protocolHash: protocol ? protocolHash(protocol) : null, sealBinding: study.mode === "experiment" ? designBinding(dir) ?? null : null,
232
+ window: (() => { const ats = events.map(e => isoAt(e.at)).filter((v): v is string => v !== null); return { from: ats[0] ?? null, to: ats.at(-1) ?? null }; })(),
233
+ counts: { attempts: attemptLines.length, byStatus, byRun, ledgerEvents: events.length, reviews: reviewRows.length },
234
+ files: Object.fromEntries(Object.entries(files).map(([name, text]) => [name, sha256(text)])),
235
+ };
236
+ files["manifest.json"] = JSON.stringify(manifest, null, 2) + "\n";
237
+ return { files, manifest };
238
+ }
239
+
240
+ /** Read one locked study snapshot, then validate and publish without holding the study lock. */
241
+ export async function exportBundle(dirArg: string, options: { bundle: string; list?: boolean }, maxFileBytes = BUNDLE_FILE_BYTES): Promise<BundleManifest | BundleListing> {
242
+ const dir = resolve(dirArg), target = resolve(options.bundle), inside = relative(realpathSync(dir), realTarget(target));
243
+ if (inside === "" || (!(inside === ".." || inside.startsWith(".." + sep)) && !isAbsolute(inside))) throw new Error("study export: --bundle is inside the study directory; choose a directory outside the study");
244
+ if (options.list) {
245
+ await withLock(dir, () => openStudy(dir));
246
+ return { bundle: options.bundle, files: [...BUNDLE_FILES], wouldWrite: true };
247
+ }
248
+ const { files, manifest } = await withLock(dir, () => readBundle(dir, maxFileBytes));
249
+ scanBundle(files);
250
+ for (const name of BUNDLE_FILES) assertWritable(files[name]!, maxFileBytes, `study export: ${name}; nothing was written`);
251
+ emptyDir(target);
252
+ for (const name of BUNDLE_FILES) writeAtomic(join(target, name), files[name]!, maxFileBytes);
253
+ return manifest;
254
+ }
package/src/study.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { validateAutonomy, validateAutonomyMode, resolveAutonomy, ACCEPT_ARTIFACTS, type Autonomy, type Phase, type AcceptArtifact } from "./autonomy.ts";
2
+ export type { Autonomy } from "./autonomy.ts";
1
3
  import { hostname } from "node:os";
2
4
  import { spawnSync } from "node:child_process";
3
5
  import { closeSync, constants, existsSync, fstatSync, fsyncSync, linkSync, mkdirSync, openSync, readSync, renameSync, statSync, unlinkSync, writeSync, appendFileSync, lstatSync } from "node:fs";
@@ -5,10 +7,9 @@ import { createHash, randomUUID } from "node:crypto";
5
7
  import { dirname, join, resolve } from "node:path";
6
8
 
7
9
  export type Mode = "survey" | "experiment";
8
- export type Autonomy = "auto" | "gates";
9
- export type GateName = "scope" | "screen" | "design" | "final" | "review" | `plan:${string}`;
10
+ export type GateName = "scope" | "screen" | "design" | "final" | "review" | `plan:${string}` | `accept:${AcceptArtifact}`;
10
11
  export const GATES: readonly GateName[] = ["scope", "screen", "design", "final", "review"];
11
- export interface Study { v: 1; id: string; question: string; mode: Mode; autonomy: Autonomy; createdAt: string }
12
+ export interface Study { v: 1 | 2; id: string; question: string; mode: Mode; autonomy: Autonomy; createdAt: string }
12
13
  export interface Actor { kind: "person" | "agent" | "auto"; id?: string }
13
14
  export function requireExperiment(study: Study): void {
14
15
  if (study.mode !== "experiment") throw new Error("experiment command: study mode must be experiment; create a study with ares init <dir> --question TEXT --mode experiment");
@@ -105,19 +106,22 @@ export function openStudy(dir: string): { dir: string; study: Study } {
105
106
  }
106
107
 
107
108
  function validStudy(raw: unknown): Study {
108
- if (plain(raw) && raw.v !== 1) throw new Error("study.json: unsupported schema version; use an ares version that supports this study, keeping the original file unchanged");
109
- if (!plain(raw) || raw.v !== 1 || typeof raw.id !== "string" || typeof raw.question !== "string" || typeof raw.createdAt !== "string" ||
110
- !["survey", "experiment"].includes(raw.mode as string) || !["auto", "gates"].includes(raw.autonomy as string)) throw new Error("study.json: malformed");
111
- return raw as unknown as Study;
109
+ if (plain(raw) && raw.v !== 1 && raw.v !== 2) throw new Error("study.json: unsupported schema version; use an ares version that supports this study, keeping the original file unchanged");
110
+ if (!plain(raw) || (raw.v !== 1 && raw.v !== 2) || typeof raw.id !== "string" || typeof raw.question !== "string" || typeof raw.createdAt !== "string" ||
111
+ !["survey", "experiment"].includes(raw.mode as string)) throw new Error("study.json: malformed");
112
+ let autonomy: Autonomy;
113
+ try { autonomy = validateAutonomy(raw.autonomy); }
114
+ catch { throw new Error("study.json: unsupported or malformed autonomy policy; restore the last valid study.json or use a compatible ares version before retrying"); }
115
+ if (raw.v === 1 && (typeof autonomy !== "string" || autonomy === "copilot" || autonomy === "step")) throw new Error("study.json: this autonomy policy requires study v2; restore the valid v1 study and use ares autonomy set with a compatible ares version");
116
+ return { ...raw, autonomy } as unknown as Study;
112
117
  }
113
118
 
114
119
  export function initStudy(dir: string, options: { question: string; mode?: string; autonomy?: string }, actor: Actor): Study {
115
120
  const root = resolve(dir);
116
121
  if (existsSync(join(root, "study.json"))) throw new Error(`${root}: a study already exists here`);
117
- const mode = options.mode ?? "survey", autonomy = options.autonomy ?? "gates";
122
+ const mode = options.mode ?? "survey", autonomy = validateAutonomyMode(options.autonomy ?? "gates");
118
123
  if (mode !== "survey" && mode !== "experiment") throw new Error("--mode: survey or experiment");
119
- if (autonomy !== "auto" && autonomy !== "gates") throw new Error("--autonomy: auto or gates");
120
- const study: Study = { v: 1, id: randomUUID(), question: boundedText(options.question, "--question", QUESTION_MAX), mode, autonomy, createdAt: new Date().toISOString() };
124
+ const study: Study = { v: autonomy === "auto" || autonomy === "gates" ? 1 : 2, id: randomUUID(), question: boundedText(options.question, "--question", QUESTION_MAX), mode, autonomy, createdAt: new Date().toISOString() };
121
125
  for (const sub of ["", "literature", "literature/cards", "refs"]) mkdirSync(join(root, sub), { recursive: true });
122
126
  // link() refuses an existing target, so two concurrent inits cannot both create the study.
123
127
  const text = JSON.stringify(study, null, 2) + "\n", tmp = join(root, `study.json.${randomUUID()}.tmp`);
@@ -133,10 +137,15 @@ export function initStudy(dir: string, options: { question: string; mode?: strin
133
137
 
134
138
  /**
135
139
  * Exclusive write lock for one study. A lock older than 10 minutes is treated as abandoned and replaced.
136
- * ponytail: mtime-based staleness, no owner liveness check; add a pid probe if two hosts share a study.
140
+ * ponytail: mtime-based staleness, no owner liveness check. The inode check before unlink is best-effort:
141
+ * a replacement between lstat and unlink can still be removed, after stale takeover of a live owner has
142
+ * already broken exclusion. Upgrade path: an owner liveness probe, including shared-host ownership.
137
143
  */
144
+ const ownedStudyLocks = new Set<() => void>();
145
+ /** MCP termination only: call immediately before process exit, never while continuing async work. */
146
+ export function releaseStudyLocksForExit(): void { for (const release of [...ownedStudyLocks]) release(); }
138
147
  export class StudyLockBusyError extends Error {}
139
- export async function withLock<T>(dir: string, fn: () => Promise<T> | T): Promise<T> {
148
+ export async function withLock<T>(dir: string, fn: () => Promise<T> | T, inspectOwner: (fd: number) => ReturnType<typeof fstatSync> = fstatSync): Promise<T> {
140
149
  const path = join(dir, ".ares.lock");
141
150
  let fd: number;
142
151
  try { fd = openSync(path, "wx", 0o600); }
@@ -148,8 +157,25 @@ export async function withLock<T>(dir: string, fn: () => Promise<T> | T): Promis
148
157
  try { unlinkSync(path); } catch { /* raced with another cleaner */ }
149
158
  fd = openSync(path, "wx", 0o600);
150
159
  }
151
- try { writeSync(fd, `${process.pid} ${new Date().toISOString()}\n`); } finally { closeSync(fd); }
152
- try { return await fn(); } finally { try { unlinkSync(path); } catch { /* already removed */ } }
160
+ let owner: ReturnType<typeof fstatSync>;
161
+ try { owner = inspectOwner(fd); }
162
+ catch (error) {
163
+ try { unlinkSync(path); } catch { /* the just-created lock may already be absent */ }
164
+ finally { try { closeSync(fd); } catch { /* preserve the original probe failure */ } }
165
+ throw error;
166
+ }
167
+ let released = false;
168
+ const release = () => {
169
+ if (released) return; released = true;
170
+ try { const current = lstatSync(path); if (current.dev === owner.dev && current.ino === owner.ino) unlinkSync(path); }
171
+ catch { /* absent or replaced; never remove a different owner's lock */ }
172
+ finally { try { closeSync(fd); } catch { /* process exit also closes owned descriptors */ } ownedStudyLocks.delete(release); }
173
+ };
174
+ ownedStudyLocks.add(release);
175
+ try { writeSync(fd, `${process.pid} ${new Date().toISOString()}\n`); }
176
+ catch (error) { release(); throw error; }
177
+ // Keep the descriptor open until release so an unlinked inode cannot be reused by a replacement lock.
178
+ try { return await fn(); } finally { release(); }
153
179
  }
154
180
 
155
181
  export interface PendingEvent { actor: Actor; type: string; data: Record<string, unknown> }
@@ -217,8 +243,9 @@ export function gateState(gate: GateName, events: LedgerEvent[], binder: Binder)
217
243
  }
218
244
 
219
245
  export function decideGate(dir: string, gate: GateName, decision: "approve" | "reject", actor: Actor, binder: Binder, reason?: string): GateState {
220
- if (!GATES.includes(gate) && !gate.startsWith("plan:")) throw new Error(`unknown gate: ${gate}; run ares gate list to select a gate`);
246
+ if (!GATES.includes(gate) && !ACCEPT_ARTIFACTS.some(artifact => gate === `accept:${artifact}`) && !gate.startsWith("plan:")) throw new Error(`unknown gate: ${gate}; run ares gate list to select a gate`);
221
247
  const before = gateState(gate, readLedger(dir), binder);
248
+ if (before.state === "none" && gate.startsWith("accept:")) throw new Error(`gate ${gate}: no current copilot draft; inspect ares autonomy show, create the phase artifact, then use ares gate list and ares gate approve <dir> ${gate}`);
222
249
  if (before.state === "none") throw new Error(`gate ${gate}: nothing to decide yet${gate.startsWith("plan:") ? "; finish every upstream node with output references, then run ares gate list" : ""}`);
223
250
  if (decision === "reject" && !reason) throw new Error(`gate ${gate}: a rejection needs --reason`);
224
251
  appendEvent(dir, actor, decision === "approve" ? "gate.approve" : "gate.reject", { gate, binding: before.current!, ...(reason ? { reason } : {}) });
@@ -230,11 +257,12 @@ export function decideGate(dir: string, gate: GateName, decision: "approve" | "r
230
257
  * `auto` approval goes into the command's own commit only when the command succeeds, so a failed command never
231
258
  * changes a gate.
232
259
  */
233
- export function requireGate(dir: string, study: Study, gate: GateName, binder: Binder): PendingEvent[] {
260
+ export function requireGate(dir: string, study: Study, gate: GateName, binder: Binder, guardedPhase?: Phase): PendingEvent[] {
234
261
  if (gate === "design" || gate === "final") requireExperiment(study);
235
262
  const state = gateState(gate, readLedger(dir), binder);
236
263
  if (state.state === "approved") return [];
237
- if (state.state === "pending" && study.autonomy === "auto") return [{ actor: { kind: "auto" }, type: "gate.approve", data: { gate, binding: state.current! } }];
264
+ if (state.state === "pending" && !gate.startsWith("accept:") && resolveAutonomy(study.autonomy, gate, guardedPhase) === "auto") return [{ actor: { kind: "auto" }, type: "gate.approve", data: { gate, binding: state.current! } }];
265
+ if (state.state === "none" && gate.startsWith("accept:")) throw new Error(`gate ${gate}: no current copilot draft; create the phase artifact, then run ares gate list and ares gate approve <dir> ${gate}`);
238
266
  if (state.state === "none" && gate === "final") throw new Error("gate final: complete reserved seeds and close investigate/required ablate inputs before final approval");
239
267
  if (state.state === "none" && gate.startsWith("plan:")) throw new Error(`gate ${gate}: upstream outputs are not complete; finish every upstream node and run ares gate list again`);
240
268
  if (state.state === "none") throw new Error(gate === "scope" ? "gate scope: no scope yet (run ares scope)" : gate === "design" ? "gate design: no protocol yet (run ares protocol)" : "gate screen: no included candidates yet");
package/src/ui/panel.js CHANGED
@@ -10,6 +10,8 @@ export default (host) => {
10
10
  const number = (value) => typeof value === "number" && Number.isFinite(value) ? String(value) : "Unavailable";
11
11
  const rows = (value) => Array.isArray(value) ? value : [];
12
12
  const record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
13
+ const autonomyLabel = (value) => typeof value === "string" ? text(value)
14
+ : `${text(record(value).default)}${Object.keys(record(record(value).phases)).length ? " (phase overrides)" : ""}`;
13
15
  const line = (value, max = 180) => {
14
16
  const valueText = text(value, "").replace(/\s+/g, " ").trim();
15
17
  return valueText.length > max ? `${valueText.slice(0, max - 3)}...` : valueText;
@@ -84,7 +86,7 @@ export default (host) => {
84
86
  const attempts = study.attempts?.available === false ? `Attempts unavailable: ${line(study.attempts.reason) || "Attempt records could not be read."}` : `${number(study.attempts?.total)} attempts`;
85
87
  const pending = rows(study.gates).filter((gate) => gate.state === "pending").length;
86
88
  return ui.row({ title: line(study.question) || text(study.id, "Unavailable study"),
87
- sub: available ? [text(study.mode), text(study.autonomy), attempts, `${pending} pending gates`, finalState ? `Final ${finalState}` : "Final unavailable", reviewState, watchState].join(" | ") : text(study.reason, "Study cannot be read; its registration is retained."),
89
+ sub: available ? [text(study.mode), autonomyLabel(study.autonomy), attempts, `${pending} pending gates`, finalState ? `Final ${finalState}` : "Final unavailable", reviewState, watchState].join(" | ") : text(study.reason, "Study cannot be read; its registration is retained."),
88
90
  chip: { text: available ? text(study.mode, "study") : "unavailable", tone: available ? "neutral" : "err" },
89
91
  ...(typeof study.dir === "string" ? { onOpen: () => go("study", { study: study.dir }) } : {}) });
90
92
  }) ?? ui.empty("No registered studies", "Register one with ares console add <dir>. Ares does not scan the disk for studies.")];
@@ -106,8 +108,9 @@ export default (host) => {
106
108
  if (!study) return [toolbar("Study", true), ui.notice(text(snapshot.error, "Choose a registered study from the studies panel."), "warn")];
107
109
  const sections = [toolbar(text(study.question, "Study"), true), decisionNotice()];
108
110
  if (study.available === false) return [...sections, ui.notice(text(study.reason, "Study unavailable; its registration is retained."), "err"), h("p", { class: "ares-sub", text: text(study.dir, "") })];
109
- sections.push(h("div", { class: "ares-bar" }, ui.chip(text(study.mode), "neutral"), ui.chip(text(study.autonomy), "neutral")),
111
+ sections.push(h("div", { class: "ares-bar" }, ui.chip(text(study.mode), "neutral"), ui.chip(autonomyLabel(study.autonomy), "neutral")),
110
112
  h("p", { class: "ares-sub", text: text(study.dir, "") }), gateRows(study.gates, study.dir), attemptRows(study.attempts));
113
+ if (study.autonomy && typeof study.autonomy === "object") sections.push(block("Autonomy policy", study.autonomy));
111
114
  const final = record(study.final);
112
115
  sections.push(h("section", { class: "ares-card" }, h("h3", { text: "Final status" }),
113
116
  typeof final.status === "string" ? ui.chip(final.status, tone(final.status)) : ui.empty("No current closed final"),
@@ -119,11 +122,13 @@ export default (host) => {
119
122
  rows(review.openBlocking).length ? ui.notice(`${review.openBlocking.length} blocking findings remain open.`, "warn") : null));
120
123
  if (typeof study.manuscript?.id === "string") sections.push(h("section", { class: "ares-card" }, h("h3", { text: "Built manuscript" }),
121
124
  ui.link({ asset: study.manuscript.id }, "Open manuscript text")));
125
+ const investigationArtifact = record(study.investigationArtifact);
126
+ if (investigationArtifact.state === "missing") sections.push(ui.notice(text(investigationArtifact.message, "Investigation artifact missing; displayed result is recalculated. Restore the artifact or run ares investigate close."), "warn"));
122
127
  sections.push(block("Investigation", study.investigation), block("Posthoc exploration", study.posthoc));
123
128
  const watch = record(study.watch), watchConfig = record(watch.config);
124
129
  sections.push(h("section", { class: "ares-card" }, h("h3", { text: "Watch" }),
125
130
  typeof watchConfig.rubric === "string" ? h("div", {}, h("strong", { text: "Display-only rubric" }), h("p", { class: "ares-prose", text: watchConfig.rubric })) : null,
126
- ui.rows(rows(watch.runs), (run) => ui.row({ title: run.ok ? "Source answered" : "All sources failed", sub: `${at(run.at)} | ${number(run.added)} added | ${number(run.addedIdsTotal)} recorded ids`, chip: { text: run.ok ? "answered" : "failed", tone: run.ok ? "ok" : "err" } })) ?? ui.empty("No watch runs")));
131
+ ui.rows(rows(watch.runs), (run) => ui.row({ title: run.ok ? "Source answered" : run.cause === undefined ? "All sources failed" : ["terminated", "filesystem", "internal", "network", "rate", "server", "client", "format"].includes(run.cause) ? `Watch run failed (${run.cause})` : "Watch run failed (unknown cause)", sub: `${at(run.at)} | ${number(run.added)} added | ${number(run.addedIdsTotal)} recorded ids`, chip: { text: run.ok ? "answered" : "failed", tone: run.ok ? "ok" : "err" } })) ?? ui.empty("No watch runs")));
127
132
  const candidates = rows(record(watch.newCandidates).candidates).filter((candidate) => candidate.status === "new" && typeof candidate.id === "string");
128
133
  sections.push(h("section", { class: "ares-card" }, h("h3", { text: "New candidates" }),
129
134
  ui.rows(candidates, (candidate) => ui.row({ title: line(candidate.title) || candidate.id, sub: `${candidate.id}${typeof candidate.year === "number" ? ` | ${candidate.year}` : ""}`, verbs: typeof study.dir === "string" ? [
package/src/ui/plugin.ts CHANGED
@@ -12,6 +12,7 @@ import { previewArchive, ARCHIVE_NOTES, ArchivePartialError } from "../memory/ar
12
12
  import { spawnRunner, type ProcessRunner } from "../exp/executor.ts";
13
13
  import { readProtocol } from "../exp/protocol.ts";
14
14
  import { readSelection, selectionPath } from "../exp/selection.ts";
15
+ import { investigateResultPath } from "../exp/investigate.ts";
15
16
  import { readClosedFinal, finalResultPath } from "../exp/final.ts";
16
17
  import { readCurrentBuild, paperBuildPaths } from "../paper/build.ts";
17
18
  import { loadCandidates } from "../lit/candidates.ts";
@@ -39,12 +40,13 @@ const stamp = (value: unknown, fallback = EPOCH) => typeof value === "string" &&
39
40
  function readCall<T>(name: string, fn: () => T, errors: Record<string, string>): T | null {
40
41
  try { return fn(); } catch (cause) { errors[name] = error(cause); return null; }
41
42
  }
42
- interface GateInputs { selectionRun?: string; review?: { manuscriptSha256: string; evidenceSha256: string; ids: ReadonlySet<string> } }
43
+ export interface GateInputs { selectionRun?: string; review?: { manuscriptSha256: string; evidenceSha256: string; ids: ReadonlySet<string> } }
43
44
  /** Every gate advances only when an input consumed by its binder changes, never merely on an event kind. */
44
- function gateWaitingTimes(events: LedgerEvent[], inputs: GateInputs): Map<string, string> {
45
+ export function gateWaitingTimes(events: LedgerEvent[], inputs: GateInputs, observeSignature?: (gate: string, signature: string | undefined) => void): Map<string, string> {
45
46
  const waiting = new Map<string, string>(), previous = new Map<string, string | undefined>();
46
47
  const observe = (gate: string, input: unknown, at: string) => {
47
48
  const signature = input === undefined ? undefined : canonical(input);
49
+ observeSignature?.(gate, signature);
48
50
  if (previous.get(gate) === signature) return;
49
51
  previous.set(gate, signature);
50
52
  if (signature === undefined) waiting.delete(gate); else waiting.set(gate, at);
@@ -85,8 +87,8 @@ function gateWaitingTimes(events: LedgerEvent[], inputs: GateInputs): Map<string
85
87
  experiment(event.at); break;
86
88
  case "selection.set": if (typeof data.sha256 === "string") selection = data.sha256; experiment(event.at); break;
87
89
  case "final.seeds.set": if (typeof data.sha256 === "string") reserved = data.sha256; experiment(event.at); break;
88
- case "investigate.closed": if (data.run === inputs.selectionRun && typeof data.sha256 === "string") investigate = data.sha256; experiment(event.at); break;
89
- case "ablate.added": hasAblations = true; experiment(event.at); break;
90
+ case "investigate.closed": if (data.run === inputs.selectionRun && typeof data.sha256 === "string") investigate = typeof data.winner === "string" && /^[a-f0-9]{40}$/.test(data.winner) ? data.sha256 : undefined; experiment(event.at); break;
91
+ case "ablation.added": hasAblations = true; experiment(event.at); break;
90
92
  case "ablate.closed": if (typeof data.sha256 === "string") ablate = data.sha256; experiment(event.at); break;
91
93
  case "paper.built":
92
94
  if (typeof data.manuscriptSha256 === "string" && typeof data.evidenceSha256 === "string") paper = [data.manuscriptSha256, data.evidenceSha256];
@@ -124,9 +126,16 @@ function loadStudy(dir: string, features: readonly string[], input: { panel: str
124
126
  };
125
127
  const status = ops.status(dir), gates = ops.gateList(dir);
126
128
  const attempt = study.mode === "experiment" ? readCall("attempts", () => attempts(dir, inbox), errors) : null;
129
+ let investigationArtifact: { state: "missing"; recalculated: true; message: string } | undefined;
127
130
  const investigation = detailed && study.mode === "experiment" ? readCall("investigation", () => {
128
131
  const configured = recordPresent(selectionPath(dir)) || events.some(event => event.type === "selection.set" || event.type === "investigate.closed");
129
- return configured ? ops.investigateShow(dir) : null;
132
+ if (!configured) return null;
133
+ const result = ops.investigateShow(dir);
134
+ if (events.some(event => event.type === "investigate.closed" && event.data.run === result.run) && !recordPresent(investigateResultPath(dir, result.run))) {
135
+ investigationArtifact = { state: "missing", recalculated: true,
136
+ message: `Missing receipted artifact runs/${result.run}/investigate.json; this investigation result is recalculated from current inputs. Restore the artifact or run ares investigate close before using a closed result.` };
137
+ }
138
+ return result;
130
139
  }, errors) : null;
131
140
  const final = study.mode === "experiment" ? readCall("final", () => {
132
141
  const recorded = recordPresent(finalResultPath(dir)) || events.some(event => event.type === "final.closed");
@@ -169,7 +178,12 @@ function loadStudy(dir: string, features: readonly string[], input: { panel: str
169
178
  const event = attemptEvents.get(`${row.id}:${row.status}`);
170
179
  add("attempt", row.id, "alert", `${row.status} attempt ${row.id}`, event?.at ?? at, row.reason ?? "Inspect ares attempt show before retrying");
171
180
  }
172
- for (const [index, event] of events.entries()) if (event.type === "watch.ran" && event.data.ok === false) add("watch", event.at + index, "alert", "Every watch source failed", event.at, "Inspect ares watch show and retry after restoring source access");
181
+ for (const [index, event] of events.entries()) if (event.type === "watch.ran" && event.data.ok === false) {
182
+ const cause = event.data.cause, known = typeof cause === "string" && ["terminated", "filesystem", "internal", "network", "rate", "server", "client", "format"].includes(cause);
183
+ const title = cause === undefined ? "Every watch source failed" : known ? `Watch run failed (${cause})` : "Watch run failed (unknown cause)";
184
+ const detail = cause === undefined ? "Inspect ares watch show and retry after restoring source access" : "Inspect ares watch show and lit list; restore study storage or source access as needed, then rerun ares watch run";
185
+ add("watch", event.at + index, "alert", title, event.at, detail);
186
+ }
173
187
  }
174
188
  const archive = detailed ? readCall("archive", () => { const preview = previewArchive(dir); return { pendingCount: preview.notes.length, fingerprint: preview.fingerprint }; }, errors) : null;
175
189
  const manuscript = detailed && readCall("manuscript", () => {
@@ -186,7 +200,7 @@ function loadStudy(dir: string, features: readonly string[], input: { panel: str
186
200
  attempts: attempt ? { total: attempt.total, byStatus: attempt.byStatus } : study.mode === "experiment" ? { available: false, reason: errors.attempts ?? "Attempt records could not be read; inspect ares attempt list and restore readable records, then refresh" } : { total: 0, byStatus: {} },
187
201
  final: final ? { status: final.status, ...(final.reason ? { reason: final.reason } : {}) } : null,
188
202
  review: review ? { ready: review.ready, unmet: review.unmet } : null, lastWatch };
189
- return { summary, targets, detail: detailed ? { ...summary, investigation, final, posthoc, review, skills, watch, archive, manuscript, errors } : null, items, at };
203
+ return { summary, targets, detail: detailed ? { ...summary, investigation, investigationArtifact: investigationArtifact ?? null, final, posthoc, review, skills, watch, archive, manuscript, errors } : null, items, at };
190
204
  }
191
205
  interface Selection { wireAction: string; action: "gate" | "screen" | "skill.decide" | "archive.confirm"; study: string; gate?: string; ids?: string[]; name?: string; decision?: string; reason?: string; pr?: string }
192
206
  const ACTIONS: Record<string, { action: Selection["action"]; decision?: string; fields: readonly string[] }> = {