@staix/agent-research 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +572 -2
- package/bin/ares.js +11 -0
- package/package.json +35 -4
- package/src/cli.ts +295 -0
- package/src/commands-table.ts +72 -0
- package/src/commands.ts +520 -0
- package/src/console/registry.ts +97 -0
- package/src/exp/ablate.ts +140 -0
- package/src/exp/artifacts.ts +229 -0
- package/src/exp/attempts.ts +475 -0
- package/src/exp/executor.ts +207 -0
- package/src/exp/final.ts +197 -0
- package/src/exp/ideas.ts +117 -0
- package/src/exp/investigate.ts +182 -0
- package/src/exp/logs.ts +63 -0
- package/src/exp/metrics.ts +132 -0
- package/src/exp/protocol.ts +145 -0
- package/src/exp/selection.ts +53 -0
- package/src/exp/snapshot.ts +147 -0
- package/src/http.ts +62 -0
- package/src/index.ts +4 -0
- package/src/lit/bibtex.ts +61 -0
- package/src/lit/candidates.ts +148 -0
- package/src/lit/cards.ts +21 -0
- package/src/lit/cite.ts +26 -0
- package/src/lit/sources.ts +122 -0
- package/src/lit/verify.ts +110 -0
- package/src/lit/watch.ts +109 -0
- package/src/manifest.ts +38 -0
- package/src/mcp-tools.ts +200 -0
- package/src/mcp.ts +111 -0
- package/src/memory/amem.ts +97 -0
- package/src/memory/archive.ts +63 -0
- package/src/memory/lessons.ts +88 -0
- package/src/memory/skills.ts +99 -0
- package/src/paper/build.ts +70 -0
- package/src/paper/check.ts +180 -0
- package/src/paper/placeholders.ts +116 -0
- package/src/paper/review.ts +115 -0
- package/src/plan/controller.ts +192 -0
- package/src/plan/definition.ts +156 -0
- package/src/plan/hub.ts +56 -0
- package/src/plan/state.ts +234 -0
- package/src/playbook.ts +129 -0
- package/src/scope.ts +53 -0
- package/src/setup.ts +296 -0
- package/src/study.ts +296 -0
- package/src/ui/panel.css +11 -0
- package/src/ui/panel.js +156 -0
- package/src/ui/plugin.ts +361 -0
package/src/setup.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, realpathSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import { assertWritable, plain, readBytesBounded, readTextBounded, sha256, writeAtomic } from "./study.ts";
|
|
7
|
+
|
|
8
|
+
export const SETUP_TARGETS = ["claude", "codex", "kimi", "pi", "qwen", "jcode", "agy"] as const;
|
|
9
|
+
type AgentSetupTarget = typeof SETUP_TARGETS[number];
|
|
10
|
+
export type SetupTarget = AgentSetupTarget | "console";
|
|
11
|
+
export type SetupMode = "dry-run" | "apply" | "undo";
|
|
12
|
+
export interface McpEntry { command: string; args: string[] }
|
|
13
|
+
export interface ConsoleManifest { api: 1; tool: "ares"; module: string; sha256: string }
|
|
14
|
+
type SetupEntry = McpEntry | ConsoleManifest;
|
|
15
|
+
interface Host { target: SetupTarget; file: string; key: string; format: "json" | "toml" | "manifest" }
|
|
16
|
+
export interface SetupAction { target: SetupTarget; file: string; entry: string; receipt: string; lock: string; change: "add" | "replace" | "unchanged" | "restore" | "remove" | "none" | "refused"; receiptChange: "add" | "replace" | "remove" | "none"; reason?: string }
|
|
17
|
+
export interface SetupReport { mode: SetupMode; applied: boolean; root: string; entry: McpEntry; actions: SetupAction[]; notes: string[]; console?: ConsoleManifest }
|
|
18
|
+
interface Receipt { v: 1; target: SetupTarget; file: string; existed: boolean; keyExisted: boolean; priorPresent: boolean; prior?: unknown; priorToml?: string; installed: SetupEntry }
|
|
19
|
+
interface Plan { host: Host; action: SetupAction; before: string | null; after: string | null; receiptBefore: string | null; receiptAfter: string | null }
|
|
20
|
+
export const SETUP_CONFIG_BYTES = 4 * 1024 * 1024, SETUP_RECEIPT_BYTES = 8 * 1024 * 1024;
|
|
21
|
+
const OWN_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
22
|
+
class SetupError extends Error {}
|
|
23
|
+
function fail(message: string, nextStep = "correct the config or restore its setup receipt, then rerun ares setup"): never { throw new SetupError(`setup: ${message}; ${nextStep}`); }
|
|
24
|
+
function failureReason(error: unknown): string {
|
|
25
|
+
return error instanceof SetupError ? error.message : `setup: ${error instanceof Error ? error.message : "file operation failed"}; check file permissions and the setup receipt, then rerun ares setup`;
|
|
26
|
+
}
|
|
27
|
+
function setupLockRefusal(file: string): never {
|
|
28
|
+
fail(`target is locked by another setup: ${file}`, `if no ares setup is running, remove ${file} and rerun ares setup`);
|
|
29
|
+
}
|
|
30
|
+
function absolute(value: string, label: string): string {
|
|
31
|
+
if (!isAbsolute(value) || value.includes("\0")) fail(`${label} must be an absolute path`, label === "--root" ? "pass --root ABS for an existing agent-research release" : `set ${label} to an absolute path and rerun ares setup`);
|
|
32
|
+
return resolve(value);
|
|
33
|
+
}
|
|
34
|
+
/** Links are always refused. Ownership checks protect mutable config/receipts; release reads may be administrator-owned. */
|
|
35
|
+
function guard(file: string, owned = true): void {
|
|
36
|
+
const parts: string[] = []; for (let current = resolve(file); ; current = dirname(current)) { parts.unshift(current); if (current === dirname(current)) break; }
|
|
37
|
+
for (const part of parts) {
|
|
38
|
+
let info;
|
|
39
|
+
try { info = lstatSync(part); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
|
|
40
|
+
if (info.isSymbolicLink()) fail("links are refused in config and receipt paths");
|
|
41
|
+
if (part === resolve(file)) {
|
|
42
|
+
if (!info.isFile()) fail("config or receipt is not a regular file");
|
|
43
|
+
if (owned && info.uid !== process.getuid!()) fail("config or receipt is owned by another user");
|
|
44
|
+
} else {
|
|
45
|
+
if (!info.isDirectory()) fail("config or receipt parent is not a directory");
|
|
46
|
+
if (owned && info.uid !== 0 && info.uid !== process.getuid!()) fail("config or receipt parent is owned by another user");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function read(file: string, max: number, owned = true): string | null {
|
|
51
|
+
guard(file, owned);
|
|
52
|
+
try { return readTextBounded(file, max, { noFollow: true, ...(owned ? { uid: process.getuid!() } : {}) }); }
|
|
53
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; }
|
|
54
|
+
}
|
|
55
|
+
function write(file: string, value: string | null, max: number, privateFile = false): void {
|
|
56
|
+
guard(file);
|
|
57
|
+
if (value === null) { unlinkSync(file); return; }
|
|
58
|
+
assertWritable(value, max, "setup output");
|
|
59
|
+
let mode = 0o600;
|
|
60
|
+
try { if (!privateFile) mode = lstatSync(file).mode & 0o777; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
61
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
62
|
+
if (privateFile) privateDirectory(dirname(file), true);
|
|
63
|
+
guard(file); writeAtomic(file, value, max, mode);
|
|
64
|
+
}
|
|
65
|
+
function record(value: unknown): Record<string, unknown> { if (!plain(value)) fail("config must contain an object"); return value; }
|
|
66
|
+
function document(host: Host, source: string | null): Record<string, unknown> {
|
|
67
|
+
if (source === null) return {};
|
|
68
|
+
try { return record(host.format === "toml" ? Bun.TOML.parse(source) : JSON.parse(source)); }
|
|
69
|
+
catch { fail(`invalid ${host.format.toUpperCase()}; fix ${host.file} before applying setup`); }
|
|
70
|
+
}
|
|
71
|
+
function entry(host: Host, source: string | null): unknown { if (host.format === "manifest") return source === null ? undefined : document(host, source); const doc = document(host, source); return doc[host.key] === undefined ? undefined : record(doc[host.key]).ares; }
|
|
72
|
+
function hosts(env: NodeJS.ProcessEnv, cwd: string): Record<AgentSetupTarget, Host> {
|
|
73
|
+
const home = absolute(env.HOME?.trim() || homedir(), "HOME");
|
|
74
|
+
const dir = (key: string, fallback: string) => absolute(env[key]?.trim() || join(home, fallback), key);
|
|
75
|
+
const json = (target: AgentSetupTarget, file: string, key = "mcpServers"): Host => ({ target, file, format: "json", key });
|
|
76
|
+
return {
|
|
77
|
+
claude: json("claude", env.CLAUDE_CONFIG_DIR?.trim() ? join(dir("CLAUDE_CONFIG_DIR", ".claude"), ".claude.json") : join(home, ".claude.json")),
|
|
78
|
+
codex: { target: "codex", file: join(dir("CODEX_HOME", ".codex"), "config.toml"), format: "toml", key: "mcp_servers" },
|
|
79
|
+
kimi: json("kimi", join(dir("KIMI_CODE_HOME", ".kimi-code"), "mcp.json")),
|
|
80
|
+
pi: json("pi", join(dir("PI_CODING_AGENT_DIR", ".pi/agent"), "mcp.json")),
|
|
81
|
+
qwen: json("qwen", join(dir("QWEN_HOME", ".qwen"), "settings.json")),
|
|
82
|
+
jcode: json("jcode", join(dir("JCODE_HOME", ".jcode"), "mcp.json"), "servers"),
|
|
83
|
+
agy: json("agy", join(absolute(cwd, "cwd"), ".agents", "mcp_config.json")),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Header/key handling follows vmem setup; Bun decodes quoted keys instead of a second TOML decoder. */
|
|
87
|
+
function tomlKey(raw: string): string[] | undefined {
|
|
88
|
+
const pattern = /\s*([A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')\s*(?:\.|$)/y, parts: string[] = [];
|
|
89
|
+
while (pattern.lastIndex < raw.length) {
|
|
90
|
+
const match = pattern.exec(raw); if (!match) return undefined;
|
|
91
|
+
const token = match[1]!;
|
|
92
|
+
try { parts.push(token.startsWith('"') || token.startsWith("'") ? record(Bun.TOML.parse(`key = ${token}`)).key as string : token); } catch { return undefined; }
|
|
93
|
+
}
|
|
94
|
+
return parts;
|
|
95
|
+
}
|
|
96
|
+
function header(line: string): string | undefined {
|
|
97
|
+
line = line.replace(/\r?\n$/, "");
|
|
98
|
+
const match = /^\s*(\[\[?)/.exec(line); if (!match) return undefined;
|
|
99
|
+
let quote = "", index = match[0].length;
|
|
100
|
+
for (; index < line.length; index++) {
|
|
101
|
+
const char = line[index]!;
|
|
102
|
+
if (quote) { if (char === "\\" && quote === '"') index++; else if (char === quote) quote = ""; }
|
|
103
|
+
else if (char === '"' || char === "'") quote = char;
|
|
104
|
+
else if (char === "]") break;
|
|
105
|
+
}
|
|
106
|
+
const close = match[1] === "[[" ? "]]" : "]";
|
|
107
|
+
return !quote && line.startsWith(close, index) && /^\s*(?:#.*)?$/.test(line.slice(index + close.length)) ? line.slice(match[0].length, index).trim() : undefined;
|
|
108
|
+
}
|
|
109
|
+
/** Multiline strings may contain apparent table headers; never treat those lines as config entries. */
|
|
110
|
+
function spans(lines: string[]): [number, number][] {
|
|
111
|
+
const result: [number, number][] = [], comments: boolean[] = []; let start = -1, quote = "";
|
|
112
|
+
for (let i = 0; i <= lines.length; i++) {
|
|
113
|
+
const line = lines[i], name = line !== undefined && !quote ? header(line) : undefined;
|
|
114
|
+
comments[i] = !quote && line?.trim().startsWith("#") === true;
|
|
115
|
+
if (i === lines.length || name !== undefined) {
|
|
116
|
+
if (start >= 0) { let stop = i; while (stop > start && (!lines[stop - 1]!.trim() || comments[stop - 1])) stop--; result.push([start, stop]); start = -1; }
|
|
117
|
+
const key = name === undefined ? undefined : tomlKey(name); if (key?.[0] === "mcp_servers" && key[1] === "ares") start = i;
|
|
118
|
+
}
|
|
119
|
+
for (let pos = 0; line !== undefined && pos < line.length; pos++) {
|
|
120
|
+
const char = line[pos]!;
|
|
121
|
+
if (!quote && char === "#") break;
|
|
122
|
+
if (quote && char === "\\" && quote[0] === '"') { pos++; continue; }
|
|
123
|
+
if (quote.length === 3 && line.startsWith(quote, pos)) { const end = quote[0]!; while (line[pos + 1] === end) pos++; quote = ""; }
|
|
124
|
+
else if (quote.length === 1 && char === quote) quote = "";
|
|
125
|
+
else if (!quote && (char === '"' || char === "'")) { quote = line.startsWith(char.repeat(3), pos) ? char.repeat(3) : char; pos += quote.length - 1; }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
const linesOf = (source: string): string[] => source.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
|
131
|
+
function priorToml(source: string | null): string | undefined {
|
|
132
|
+
const lines = linesOf(source ?? ""), owned = spans(lines);
|
|
133
|
+
return owned.length ? owned.map(([start, stop]) => lines.slice(start, stop).join("")).join("") : undefined;
|
|
134
|
+
}
|
|
135
|
+
function put(host: Host, source: string | null, value: unknown, rawToml?: string, keepKey = false): string {
|
|
136
|
+
if (host.format === "manifest") return JSON.stringify(value ?? {}, null, 2) + "\n";
|
|
137
|
+
if (host.format === "json") {
|
|
138
|
+
const doc = document(host, source), servers = { ...record(doc[host.key] ?? {}) };
|
|
139
|
+
if (value === undefined) delete servers.ares; else servers.ares = value;
|
|
140
|
+
if (Object.keys(servers).length || keepKey) doc[host.key] = servers; else delete doc[host.key];
|
|
141
|
+
return JSON.stringify(doc, null, source && /\n(\t| +)"/.exec(source)?.[1] || " ") + "\n";
|
|
142
|
+
}
|
|
143
|
+
const newline = source?.includes("\r\n") ? "\r\n" : "\n", lines = linesOf(source ?? ""), owned = spans(lines);
|
|
144
|
+
let block = rawToml ?? "";
|
|
145
|
+
if (value !== undefined && rawToml === undefined) {
|
|
146
|
+
const target = record(value);
|
|
147
|
+
block = `[mcp_servers.ares]${newline}command = ${JSON.stringify(target.command)}${newline}args = ${JSON.stringify(target.args)}${newline}`;
|
|
148
|
+
}
|
|
149
|
+
if (owned.length) {
|
|
150
|
+
for (const [start, stop] of [...owned].reverse()) lines.splice(start, stop - start);
|
|
151
|
+
lines.splice(owned[0]![0], 0, block);
|
|
152
|
+
} else if (block) {
|
|
153
|
+
const prefix = (source ?? "").endsWith("\n") || !source ? "" : newline;
|
|
154
|
+
lines.push(prefix + (source ? newline : "") + block);
|
|
155
|
+
}
|
|
156
|
+
const output = lines.join("");
|
|
157
|
+
try { const parsed = document(host, output); if (!isDeepStrictEqual(parsed.mcp_servers === undefined ? undefined : record(parsed.mcp_servers).ares, rawToml === undefined ? value : entry(host, rawToml))) throw new Error(); }
|
|
158
|
+
catch { fail("TOML entry cannot be edited safely; convert inline/dotted ares settings to [mcp_servers.ares] tables first"); }
|
|
159
|
+
return output;
|
|
160
|
+
}
|
|
161
|
+
function install(root: string): string {
|
|
162
|
+
const requested = absolute(root, "--root");
|
|
163
|
+
let location: string;
|
|
164
|
+
try { location = realpathSync(requested); } catch { fail("install root is unreadable or missing", "pass --root ABS for an existing agent-research release"); }
|
|
165
|
+
const source = read(join(location, "package.json"), 65_536, false);
|
|
166
|
+
let pkg: unknown; try { pkg = source === null ? null : JSON.parse(source); } catch { fail("install root package.json is not valid JSON", "select or reinstall an intact agent-research release root"); }
|
|
167
|
+
if (!plain(pkg) || pkg.name !== "@staix/agent-research" || typeof pkg.version !== "string") fail("--root is not an agent-research installation", "select a release root containing package.json and bin/ares.js");
|
|
168
|
+
const bootstrap = join(location, "bin", "ares.js"); guard(bootstrap, false);
|
|
169
|
+
try { if (!lstatSync(bootstrap).isFile()) throw new Error(); } catch { fail("install root has no regular bin/ares.js", "select or reinstall an intact agent-research release root"); }
|
|
170
|
+
return location;
|
|
171
|
+
}
|
|
172
|
+
function validInstalled(host: Host, value: unknown): boolean {
|
|
173
|
+
if (!plain(value)) return false;
|
|
174
|
+
return host.format === "manifest"
|
|
175
|
+
? value.api === 1 && value.tool === "ares" && typeof value.module === "string" && isAbsolute(value.module) && typeof value.sha256 === "string" && /^[a-f0-9]{64}$/.test(value.sha256)
|
|
176
|
+
: typeof value.command === "string" && Array.isArray(value.args) && value.args.every(arg => typeof arg === "string");
|
|
177
|
+
}
|
|
178
|
+
function receipt(source: string | null, host: Host): Receipt | undefined {
|
|
179
|
+
if (source === null) return undefined;
|
|
180
|
+
let raw: unknown; try { raw = JSON.parse(source); } catch { fail("receipt must be valid JSON"); }
|
|
181
|
+
if (!plain(raw) || raw.v !== 1 || raw.target !== host.target || raw.file !== host.file || typeof raw.existed !== "boolean" || typeof raw.keyExisted !== "boolean" || typeof raw.priorPresent !== "boolean"
|
|
182
|
+
|| !validInstalled(host, raw.installed)
|
|
183
|
+
|| (raw.priorPresent && !Object.hasOwn(raw, "prior")) || (raw.priorToml !== undefined && typeof raw.priorToml !== "string")) fail("unsupported, malformed or foreign setup receipt");
|
|
184
|
+
return raw as unknown as Receipt;
|
|
185
|
+
}
|
|
186
|
+
function plan(host: Host, receiptPath: string, mode: SetupMode, desired: SetupEntry, ownsLock = false): Plan {
|
|
187
|
+
const action: SetupAction = { target: host.target, file: host.file, entry: host.format === "manifest" ? "console plugin manifest" : `${host.key}.ares`, receipt: receiptPath, lock: `${host.file}.ares-setup.lock`, change: "none", receiptChange: "none" };
|
|
188
|
+
if (host.format === "manifest") privateDirectory(dirname(host.file));
|
|
189
|
+
guard(action.lock);
|
|
190
|
+
if (!ownsLock && existsSync(action.lock)) setupLockRefusal(action.lock);
|
|
191
|
+
const before = read(host.file, SETUP_CONFIG_BYTES), receiptBefore = read(receiptPath, SETUP_RECEIPT_BYTES), prior = receipt(receiptBefore, host), current = entry(host, before);
|
|
192
|
+
const result: Plan = { host, action, before, after: before, receiptBefore, receiptAfter: receiptBefore };
|
|
193
|
+
if (mode === "undo") {
|
|
194
|
+
if (!prior) return result;
|
|
195
|
+
if (!isDeepStrictEqual(current, prior.installed)) fail("ares entry changed since apply; keep the user edit and reconcile the receipt manually before undo");
|
|
196
|
+
result.after = put(host, before, prior.priorPresent ? prior.prior : undefined, prior.priorToml, prior.keyExisted);
|
|
197
|
+
if (!prior.existed && isDeepStrictEqual(document(host, result.after), {})) result.after = null;
|
|
198
|
+
result.receiptAfter = null; action.change = prior.priorPresent ? "restore" : "remove"; action.receiptChange = "remove"; return result;
|
|
199
|
+
}
|
|
200
|
+
if (isDeepStrictEqual(current, desired) && (host.format !== "manifest" || !privateModesNeeded(host.file))) { action.change = "unchanged"; return result; }
|
|
201
|
+
const original = prior?.priorToml === undefined ? prior?.priorPresent ? prior.prior : undefined : entry(host, prior.priorToml);
|
|
202
|
+
if (prior && !isDeepStrictEqual(current, prior.installed) && !isDeepStrictEqual(current, original)) fail("ares entry changed since apply; reconcile it before replacing the installed root");
|
|
203
|
+
result.after = put(host, before, desired);
|
|
204
|
+
const saved: Receipt = prior ?? { v: 1, target: host.target, file: host.file, existed: before !== null, keyExisted: Object.hasOwn(document(host, before), host.key), priorPresent: current !== undefined,
|
|
205
|
+
...(current === undefined ? {} : { prior: current }), ...(host.format === "toml" && current !== undefined ? { priorToml: priorToml(before) } : {}), installed: desired };
|
|
206
|
+
result.receiptAfter = JSON.stringify({ ...saved, installed: desired }, null, 2) + "\n";
|
|
207
|
+
assertWritable(result.receiptAfter, SETUP_RECEIPT_BYTES, "setup receipt");
|
|
208
|
+
assertWritable(result.after, SETUP_CONFIG_BYTES, "client config");
|
|
209
|
+
action.change = current === undefined ? "add" : "replace"; action.receiptChange = receiptBefore === null ? "add" : "replace"; return result;
|
|
210
|
+
}
|
|
211
|
+
/** Per-config, not per-family, so two family homes cannot race on the same client file. */
|
|
212
|
+
function acquireSetupLock(file: string): () => void {
|
|
213
|
+
guard(file); mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); guard(file);
|
|
214
|
+
let fd: number;
|
|
215
|
+
try { fd = openSync(file, "wx", 0o600); }
|
|
216
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") setupLockRefusal(file); throw error; }
|
|
217
|
+
return () => { try { closeSync(fd); } finally { unlinkSync(file); } };
|
|
218
|
+
}
|
|
219
|
+
/** Agent setup only. Console dispatch is deliberately separate for the plugin integration. */
|
|
220
|
+
export function runSetup(target: string, mode: SetupMode, options: { env: NodeJS.ProcessEnv; cwd?: string; root?: string }): SetupReport {
|
|
221
|
+
if (target === "console") return consoleManifest(mode, options.env, options.root ?? OWN_ROOT, options.root !== undefined);
|
|
222
|
+
if (target !== "all" && !(SETUP_TARGETS as readonly string[]).includes(target)) fail("unknown target", `use ${SETUP_TARGETS.join("|")}|all|console`);
|
|
223
|
+
if (!["dry-run", "apply", "undo"].includes(mode)) fail("unsupported setup mode", "choose dry-run, apply or undo and rerun ares setup");
|
|
224
|
+
const env = options.env, root = install(options.root ?? OWN_ROOT), desired: McpEntry = { command: "bun", args: ["--smol", join(root, "bin", "ares.js"), "mcp"] };
|
|
225
|
+
const table = hosts(env, options.cwd ?? process.cwd()), family = absolute(env.AGENTHUB_HOME?.trim() || join(absolute(env.HOME?.trim() || homedir(), "HOME"), ".agenthub"), "AGENTHUB_HOME");
|
|
226
|
+
const targets = target === "all" ? SETUP_TARGETS.filter(name => name !== "agy") : [target as AgentSetupTarget], plans: Plan[] = [];
|
|
227
|
+
const report: SetupReport = { mode, applied: false, root, entry: desired, actions: [], notes: target === "all" ? ["all covers user-level clients; run ares setup agy from its workspace separately"] : [] };
|
|
228
|
+
for (const name of targets) {
|
|
229
|
+
const host = table[name], receiptPath = join(family, "research", "setup", `${name}-${sha256(host.file).slice(0, 16)}.json`);
|
|
230
|
+
try { const item = plan(host, receiptPath, mode, desired); plans.push(item); report.actions.push(item.action); }
|
|
231
|
+
catch (error) { report.actions.push({ target: name, file: host.file, entry: host.format === "manifest" ? "console plugin manifest" : `${host.key}.ares`, receipt: receiptPath, lock: `${host.file}.ares-setup.lock`, change: "refused", receiptChange: "none", reason: failureReason(error) }); }
|
|
232
|
+
}
|
|
233
|
+
return executePlans(plans, mode, desired, report);
|
|
234
|
+
}
|
|
235
|
+
function executePlans(plans: Plan[], mode: SetupMode, desired: SetupEntry, report: SetupReport): SetupReport {
|
|
236
|
+
if (mode === "dry-run" || report.actions.some(action => action.change === "refused")) return report;
|
|
237
|
+
for (let item of plans) {
|
|
238
|
+
if (["none", "unchanged"].includes(item.action.change)) continue;
|
|
239
|
+
let release: (() => void) | undefined;
|
|
240
|
+
try {
|
|
241
|
+
release = acquireSetupLock(item.action.lock);
|
|
242
|
+
const fresh = plan(item.host, item.action.receipt, mode, desired, true);
|
|
243
|
+
Object.assign(item.action, fresh.action); item = { ...fresh, action: item.action };
|
|
244
|
+
if (["none", "unchanged"].includes(item.action.change)) continue;
|
|
245
|
+
if (read(item.host.file, SETUP_CONFIG_BYTES) !== item.before || read(item.action.receipt, SETUP_RECEIPT_BYTES) !== item.receiptBefore) fail("config or receipt changed during setup; rerun preview before applying");
|
|
246
|
+
// Receipt is durable before apply changes client config. Undo keeps the receipt until restoration succeeds.
|
|
247
|
+
if (mode === "apply") write(item.action.receipt, item.receiptAfter, SETUP_RECEIPT_BYTES);
|
|
248
|
+
try {
|
|
249
|
+
if (read(item.host.file, SETUP_CONFIG_BYTES) !== item.before) fail("client config changed after receipt creation; rerun preview before applying");
|
|
250
|
+
write(item.host.file, item.after, SETUP_CONFIG_BYTES, item.host.format === "manifest");
|
|
251
|
+
}
|
|
252
|
+
catch (error) { if (mode === "apply" && read(item.action.receipt, SETUP_RECEIPT_BYTES) === item.receiptAfter) write(item.action.receipt, item.receiptBefore, SETUP_RECEIPT_BYTES); throw error; }
|
|
253
|
+
if (mode === "undo") write(item.action.receipt, null, SETUP_RECEIPT_BYTES);
|
|
254
|
+
report.applied = true;
|
|
255
|
+
} catch (error) { item.action.change = "refused"; item.action.reason = failureReason(error); break; }
|
|
256
|
+
finally { release?.(); }
|
|
257
|
+
}
|
|
258
|
+
return report;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The manifest directory must satisfy agent-console's private user-owned registration boundary. */
|
|
262
|
+
function privateDirectory(dir: string, apply = false): void {
|
|
263
|
+
let info;
|
|
264
|
+
try { info = lstatSync(dir); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; }
|
|
265
|
+
if (!info.isDirectory() || info.isSymbolicLink()) fail("console plugin directory must be a real directory");
|
|
266
|
+
if (info.uid !== process.getuid!()) fail("console plugin directory is owned by another user");
|
|
267
|
+
if (apply) chmodSync(dir, 0o700);
|
|
268
|
+
}
|
|
269
|
+
function privateModesNeeded(file: string): boolean {
|
|
270
|
+
try { if ((lstatSync(dirname(file)).mode & 0o777) !== 0o700) return true; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
271
|
+
try { return (lstatSync(file).mode & 0o777) !== 0o600; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; return false; }
|
|
272
|
+
}
|
|
273
|
+
/** Console registration is separate from agent-client `all`; no instruction blocks or other manifests are touched. */
|
|
274
|
+
export function consoleManifest(mode: SetupMode, env: NodeJS.ProcessEnv, requestedRoot = OWN_ROOT, versioned = false): SetupReport {
|
|
275
|
+
if (!["dry-run", "apply", "undo"].includes(mode)) fail("unsupported setup mode", "choose dry-run, apply or undo and rerun ares setup");
|
|
276
|
+
const root = install(requestedRoot), module = join(root, "src", "ui", "plugin.ts");
|
|
277
|
+
const mcp: McpEntry = { command: "bun", args: ["--smol", join(root, "bin", "ares.js"), "mcp"] };
|
|
278
|
+
let desired: SetupEntry = mcp;
|
|
279
|
+
if (mode !== "undo") {
|
|
280
|
+
guard(module, false);
|
|
281
|
+
let bytes: Buffer;
|
|
282
|
+
try { bytes = readBytesBounded(module, SETUP_CONFIG_BYTES, { noFollow: true }); }
|
|
283
|
+
catch { fail("console plugin module is missing, unreadable or over 4 MiB", "select an intact release containing src/ui/plugin.ts and rerun ares setup console"); }
|
|
284
|
+
desired = { api: 1, tool: "ares", module, sha256: sha256(bytes) };
|
|
285
|
+
}
|
|
286
|
+
const pkg = record(JSON.parse(read(join(root, "package.json"), 65_536, false)!)), version = pkg.version;
|
|
287
|
+
if (versioned && (typeof version !== "string" || version.length > 64 || !/^\d{1,9}\.\d{1,9}\.\d{1,9}(?:[-+][0-9A-Za-z.+-]{1,40})?$/.test(version))) fail("release version cannot name a console registration", "use an agent-research root with a semantic package version: three 1..9-digit parts, optional -/+ suffix of 1..40 characters from 0-9A-Za-z.+-, and at most 64 characters total");
|
|
288
|
+
const family = absolute(env.AGENTHUB_HOME?.trim() || join(absolute(env.HOME?.trim() || homedir(), "HOME"), ".agenthub"), "AGENTHUB_HOME");
|
|
289
|
+
const file = join(family, "console", "plugins", versioned ? `ares@${version}.json` : "ares.json");
|
|
290
|
+
const host: Host = { target: "console", file, key: "", format: "manifest" }, receiptPath = join(family, "research", "setup", `console-${sha256(file).slice(0, 16)}.json`);
|
|
291
|
+
const report: SetupReport = { mode, applied: false, root, entry: mcp, ...(mode === "undo" ? {} : { console: desired as ConsoleManifest }), actions: [], notes: ["Registers only the ares console plugin; restart the console to load changed code."] };
|
|
292
|
+
let item: Plan;
|
|
293
|
+
try { item = plan(host, receiptPath, mode, desired); }
|
|
294
|
+
catch (error) { report.actions.push({ target: "console", file, entry: "console plugin manifest", receipt: receiptPath, lock: `${file}.ares-setup.lock`, change: "refused", receiptChange: "none", reason: failureReason(error) }); return report; }
|
|
295
|
+
report.actions.push(item.action); return executePlans([item], mode, desired, report);
|
|
296
|
+
}
|
package/src/study.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { closeSync, constants, existsSync, fstatSync, fsyncSync, linkSync, mkdirSync, openSync, readSync, renameSync, statSync, unlinkSync, writeSync, appendFileSync, lstatSync } from "node:fs";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
export type Mode = "survey" | "experiment";
|
|
8
|
+
export type Autonomy = "auto" | "gates";
|
|
9
|
+
export type GateName = "scope" | "screen" | "design" | "final" | "review" | `plan:${string}`;
|
|
10
|
+
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 Actor { kind: "person" | "agent" | "auto"; id?: string }
|
|
13
|
+
export function requireExperiment(study: Study): void {
|
|
14
|
+
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");
|
|
15
|
+
}
|
|
16
|
+
export interface LedgerEvent { v: 1; at: string; actor: Actor; type: string; data: Record<string, unknown> }
|
|
17
|
+
|
|
18
|
+
const EVENT_BYTES = 65_536, LEDGER_BYTES = 64 * 1024 * 1024;
|
|
19
|
+
const LOCK_STALE_MS = 10 * 60_000;
|
|
20
|
+
const QUESTION_MAX = 2000;
|
|
21
|
+
export const STUDY_BYTES = 65_536;
|
|
22
|
+
|
|
23
|
+
/** Agent environment markers the family uses; the first match names the actor. */
|
|
24
|
+
const AGENT_MARKERS: readonly [string, string | undefined][] = [
|
|
25
|
+
["AGENTHUB_PEER_ID", undefined], ["CLAUDECODE", "claude"], ["CLAUDE_CODE_SESSION_ID", "claude"], ["CODEX_THREAD_ID", "codex"], ["PI_SESSION_ID", "pi"],
|
|
26
|
+
];
|
|
27
|
+
export function detectActor(env: NodeJS.ProcessEnv): Actor {
|
|
28
|
+
for (const [marker, name] of AGENT_MARKERS) {
|
|
29
|
+
const value = env[marker]?.trim();
|
|
30
|
+
if (value) return { kind: "agent", id: (name ?? value).slice(0, 64) };
|
|
31
|
+
}
|
|
32
|
+
return { kind: "person" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const sha256 = (data: string | Uint8Array) => createHash("sha256").update(data).digest("hex");
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Write a file by temp + fsync + rename so readers never see a partial file. `maxBytes` must be the bound the
|
|
39
|
+
* file's reader uses, so nothing is written that the tool cannot read back.
|
|
40
|
+
*/
|
|
41
|
+
export function writeAtomic(path: string, text: string, maxBytes: number, mode = 0o644): void {
|
|
42
|
+
assertWritable(text, maxBytes, path);
|
|
43
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
44
|
+
const fd = openSync(tmp, "wx", mode);
|
|
45
|
+
try { writeSync(fd, text); fsyncSync(fd); } finally { closeSync(fd); }
|
|
46
|
+
try { renameSync(tmp, path); } catch (error) { try { unlinkSync(tmp); } catch { /* already gone */ } throw error; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Read a regular file, stopping one byte past `maxBytes`: special files and oversize files are refused without buffering them. */
|
|
50
|
+
export interface ReadOptions { noFollow?: boolean; uid?: number }
|
|
51
|
+
export function readBytesBounded(path: string, maxBytes: number, options: ReadOptions = {}): Buffer {
|
|
52
|
+
// Non-blocking open: a FIFO without a writer would otherwise block before the type check.
|
|
53
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | (options.noFollow ? constants.O_NOFOLLOW : 0));
|
|
54
|
+
try {
|
|
55
|
+
const info = fstatSync(fd);
|
|
56
|
+
if (!info.isFile()) throw new Error(`${path}: not a regular file`);
|
|
57
|
+
if (options.uid !== undefined && info.uid !== options.uid) throw new Error(`${path}: owned by another user`);
|
|
58
|
+
const buffer = Buffer.alloc(Math.min(maxBytes + 1, 1 << 20)), parts: Buffer[] = [];
|
|
59
|
+
let total = 0;
|
|
60
|
+
for (;;) {
|
|
61
|
+
const n = readSync(fd, buffer, 0, buffer.length, null);
|
|
62
|
+
if (n === 0) break;
|
|
63
|
+
total += n;
|
|
64
|
+
if (total > maxBytes) throw new Error(`${path}: larger than ${maxBytes} bytes`);
|
|
65
|
+
parts.push(Buffer.from(buffer.subarray(0, n)));
|
|
66
|
+
}
|
|
67
|
+
return Buffer.concat(parts);
|
|
68
|
+
} finally { closeSync(fd); }
|
|
69
|
+
}
|
|
70
|
+
export function readTextBounded(path: string, maxBytes: number, options: ReadOptions = {}): string {
|
|
71
|
+
try { return new TextDecoder("utf-8", { fatal: true }).decode(readBytesBounded(path, maxBytes, options)); }
|
|
72
|
+
catch (error) { if (error instanceof TypeError) throw new Error(`${path}: not valid UTF-8`); throw error; }
|
|
73
|
+
}
|
|
74
|
+
export function readJsonFile(path: string, maxBytes: number): unknown {
|
|
75
|
+
const text = readTextBounded(path, maxBytes);
|
|
76
|
+
try { return JSON.parse(text); } catch { throw new Error(`${path}: not valid JSON`); }
|
|
77
|
+
}
|
|
78
|
+
/** Refuse a write that the matching bounded read could not load back. */
|
|
79
|
+
export function assertWritable(text: string, maxBytes: number, what: string): void {
|
|
80
|
+
if (Buffer.byteLength(text) > maxBytes) throw new Error(`${what} would exceed ${maxBytes} bytes`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const plain = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v) && Object.getPrototypeOf(v) === Object.prototype;
|
|
84
|
+
|
|
85
|
+
/** Refuse keys outside `allowed`, so a typo never silently drops a field. */
|
|
86
|
+
export function onlyKeys(value: Record<string, unknown>, allowed: readonly string[], what: string): void {
|
|
87
|
+
const extra = Object.keys(value).filter(key => !allowed.includes(key));
|
|
88
|
+
if (extra.length) throw new Error(`${what}: unknown field ${extra[0]}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function boundedText(value: unknown, what: string, max: number, min = 1): string {
|
|
92
|
+
if (typeof value !== "string" || value.includes("\0")) throw new Error(`${what}: text required`);
|
|
93
|
+
const text = value.trim();
|
|
94
|
+
if (text.length < min || text.length > max) throw new Error(`${what}: ${min}..${max} characters`);
|
|
95
|
+
return text;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function studyFile(dir: string, ...parts: string[]): string { return join(dir, ...parts); }
|
|
99
|
+
|
|
100
|
+
/** Resolve a study directory: it must hold a valid study.json. */
|
|
101
|
+
export function openStudy(dir: string): { dir: string; study: Study } {
|
|
102
|
+
const root = resolve(dir), file = join(root, "study.json");
|
|
103
|
+
if (!existsSync(file)) throw new Error(`${root}: no study.json (run ares init first)`);
|
|
104
|
+
return { dir: root, study: validStudy(readJsonFile(file, STUDY_BYTES)) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
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;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function initStudy(dir: string, options: { question: string; mode?: string; autonomy?: string }, actor: Actor): Study {
|
|
115
|
+
const root = resolve(dir);
|
|
116
|
+
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";
|
|
118
|
+
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() };
|
|
121
|
+
for (const sub of ["", "literature", "literature/cards", "refs"]) mkdirSync(join(root, sub), { recursive: true });
|
|
122
|
+
// link() refuses an existing target, so two concurrent inits cannot both create the study.
|
|
123
|
+
const text = JSON.stringify(study, null, 2) + "\n", tmp = join(root, `study.json.${randomUUID()}.tmp`);
|
|
124
|
+
assertWritable(text, STUDY_BYTES, "study.json");
|
|
125
|
+
const fd = openSync(tmp, "wx", 0o644);
|
|
126
|
+
try { writeSync(fd, text); fsyncSync(fd); } finally { closeSync(fd); }
|
|
127
|
+
try { linkSync(tmp, join(root, "study.json")); }
|
|
128
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`${root}: a study already exists here`); throw error; }
|
|
129
|
+
finally { unlinkSync(tmp); }
|
|
130
|
+
appendEvent(root, actor, "study.init", { id: study.id, mode, autonomy });
|
|
131
|
+
return study;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 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.
|
|
137
|
+
*/
|
|
138
|
+
export class StudyLockBusyError extends Error {}
|
|
139
|
+
export async function withLock<T>(dir: string, fn: () => Promise<T> | T): Promise<T> {
|
|
140
|
+
const path = join(dir, ".ares.lock");
|
|
141
|
+
let fd: number;
|
|
142
|
+
try { fd = openSync(path, "wx", 0o600); }
|
|
143
|
+
catch (error) {
|
|
144
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
145
|
+
let age = 0;
|
|
146
|
+
try { age = Date.now() - statSync(path).mtimeMs; } catch { /* removed meanwhile */ }
|
|
147
|
+
if (age < LOCK_STALE_MS) throw new StudyLockBusyError(`study is locked by another ares command (${path}); retry when it finishes`);
|
|
148
|
+
try { unlinkSync(path); } catch { /* raced with another cleaner */ }
|
|
149
|
+
fd = openSync(path, "wx", 0o600);
|
|
150
|
+
}
|
|
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 */ } }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface PendingEvent { actor: Actor; type: string; data: Record<string, unknown> }
|
|
156
|
+
export type FileWrite = [path: string, text: string, maxBytes: number];
|
|
157
|
+
|
|
158
|
+
/** Validate command capacity without writing; callers can reserve receipt space before outward effects. */
|
|
159
|
+
export function prepareCommit(dir: string, files: FileWrite[], pending: PendingEvent[]): { events: LedgerEvent[]; lines: string[] } {
|
|
160
|
+
const at = new Date().toISOString(), events: LedgerEvent[] = pending.map(e => ({ v: 1, at, ...e }));
|
|
161
|
+
const lines = events.map(e => JSON.stringify(e) + "\n");
|
|
162
|
+
lines.forEach((line, i) => { if (Buffer.byteLength(line) > EVENT_BYTES) throw new Error(`ledger event ${events[i]!.type} exceeds ${EVENT_BYTES} bytes`); });
|
|
163
|
+
const ledger = join(dir, "ledger.jsonl"), size = existsSync(ledger) ? statSync(ledger).size : 0;
|
|
164
|
+
if (size + Buffer.byteLength(lines.join("")) > LEDGER_BYTES) throw new Error(`ledger.jsonl would exceed ${LEDGER_BYTES} bytes; start a new study`);
|
|
165
|
+
for (const [path, text, max] of files) assertWritable(text, max, path);
|
|
166
|
+
return { events, lines };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Apply one command's changes: every file and ledger bound is checked first (file sizes, event sizes, the ledger's
|
|
170
|
+
* total against its read limit), then the files are written and all events appended in one write. A failed check
|
|
171
|
+
* changes nothing.
|
|
172
|
+
* ponytail: no write-ahead log; a crash between the file writes and the append leaves files without their event.
|
|
173
|
+
*/
|
|
174
|
+
export function commit(dir: string, files: FileWrite[], pending: PendingEvent[]): LedgerEvent[] {
|
|
175
|
+
const { events, lines } = prepareCommit(dir, files, pending), ledger = join(dir, "ledger.jsonl");
|
|
176
|
+
for (const [path, text, max] of files) { mkdirSync(dirname(path), { recursive: true }); writeAtomic(path, text, max); }
|
|
177
|
+
if (lines.length) appendFileSync(ledger, lines.join(""), { mode: 0o644 });
|
|
178
|
+
return events;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function appendEvent(dir: string, actor: Actor, type: string, data: Record<string, unknown>): LedgerEvent {
|
|
182
|
+
return commit(dir, [], [{ actor, type, data }])[0]!;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Read the whole ledger; a malformed line fails closed with its line number. */
|
|
186
|
+
export function readLedger(dir: string): LedgerEvent[] {
|
|
187
|
+
const path = join(dir, "ledger.jsonl");
|
|
188
|
+
if (!existsSync(path)) return [];
|
|
189
|
+
const lines = readTextBounded(path, LEDGER_BYTES).split("\n");
|
|
190
|
+
const events: LedgerEvent[] = [];
|
|
191
|
+
lines.forEach((line, index) => {
|
|
192
|
+
if (!line.trim()) return;
|
|
193
|
+
let event: unknown;
|
|
194
|
+
try { event = JSON.parse(line); } catch { throw new Error(`ledger.jsonl line ${index + 1}: not JSON`); }
|
|
195
|
+
if (plain(event) && event.v !== 1) throw new Error(`ledger.jsonl line ${index + 1}: unsupported schema version; use an ares version that supports this ledger, keeping all original lines unchanged`);
|
|
196
|
+
if (!plain(event) || event.v !== 1 || typeof event.type !== "string" || !plain(event.data) || !plain(event.actor)) throw new Error(`ledger.jsonl line ${index + 1}: malformed event`);
|
|
197
|
+
events.push(event as unknown as LedgerEvent);
|
|
198
|
+
});
|
|
199
|
+
return events;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface GateState { gate: GateName; state: "none" | "pending" | "approved" | "rejected"; current?: string; decided?: { decision: "approve" | "reject"; binding: string; actor: Actor; at: string; reason?: string } }
|
|
203
|
+
|
|
204
|
+
/** The content a gate approval binds to. `undefined` means there is nothing to decide yet. */
|
|
205
|
+
export type Binder = (gate: GateName) => string | undefined;
|
|
206
|
+
|
|
207
|
+
export function gateState(gate: GateName, events: LedgerEvent[], binder: Binder): GateState {
|
|
208
|
+
const current = binder(gate);
|
|
209
|
+
let decided: GateState["decided"];
|
|
210
|
+
for (const event of events) {
|
|
211
|
+
if ((event.type === "gate.approve" || event.type === "gate.reject") && event.data.gate === gate && typeof event.data.binding === "string")
|
|
212
|
+
decided = { decision: event.type === "gate.approve" ? "approve" : "reject", binding: event.data.binding, actor: event.actor, at: event.at, ...(typeof event.data.reason === "string" ? { reason: event.data.reason } : {}) };
|
|
213
|
+
}
|
|
214
|
+
if (current === undefined) return { gate, state: "none", ...(decided ? { decided } : {}) };
|
|
215
|
+
if (!decided || decided.binding !== current) return { gate, state: "pending", current, ...(decided ? { decided } : {}) };
|
|
216
|
+
return { gate, state: decided.decision === "approve" ? "approved" : "rejected", current, decided };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
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`);
|
|
221
|
+
const before = gateState(gate, readLedger(dir), binder);
|
|
222
|
+
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
|
+
if (decision === "reject" && !reason) throw new Error(`gate ${gate}: a rejection needs --reason`);
|
|
224
|
+
appendEvent(dir, actor, decision === "approve" ? "gate.approve" : "gate.reject", { gate, binding: before.current!, ...(reason ? { reason } : {}) });
|
|
225
|
+
return gateState(gate, readLedger(dir), binder);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Pass when the gate is approved for the current content. In auto mode a pending gate passes too, and the returned
|
|
230
|
+
* `auto` approval goes into the command's own commit only when the command succeeds, so a failed command never
|
|
231
|
+
* changes a gate.
|
|
232
|
+
*/
|
|
233
|
+
export function requireGate(dir: string, study: Study, gate: GateName, binder: Binder): PendingEvent[] {
|
|
234
|
+
if (gate === "design" || gate === "final") requireExperiment(study);
|
|
235
|
+
const state = gateState(gate, readLedger(dir), binder);
|
|
236
|
+
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! } }];
|
|
238
|
+
if (state.state === "none" && gate === "final") throw new Error("gate final: complete reserved seeds and close investigate/required ablate inputs before final approval");
|
|
239
|
+
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
|
+
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");
|
|
241
|
+
if (state.state === "rejected") throw new Error(`gate ${gate} is rejected${state.decided?.reason ? `: ${state.decided.reason}` : ""}; change the content and ask again`);
|
|
242
|
+
throw new Error(`gate ${gate} is pending: run ares gate approve <dir> ${gate}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
export interface PlanOwner { pid: number; host: string; startedAt: string; token: string }
|
|
247
|
+
/** Kernel process start identity, rather than lock age, detects a reused PID. */
|
|
248
|
+
export function processStart(pid: number): string | undefined {
|
|
249
|
+
const result = spawnSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8", timeout: 2000, maxBuffer: 4096, env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LC_ALL: "C" } });
|
|
250
|
+
if (result.status === 0 && result.stdout.trim()) return result.stdout.trim();
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
export function readPlanOwner(dir: string): PlanOwner | undefined {
|
|
254
|
+
const file = join(dir, ".ares.plan.lock");
|
|
255
|
+
let stat;
|
|
256
|
+
try { stat = lstatSync(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
|
|
257
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("plan controller lock: regular file required; restore the study lock file without links before running ares run");
|
|
258
|
+
const raw = readJsonFile(file, 4096);
|
|
259
|
+
if (!plain(raw) || !Number.isSafeInteger(raw.pid) || (raw.pid as number) < 1 || typeof raw.host !== "string" || !raw.host || typeof raw.startedAt !== "string" || !raw.startedAt || typeof raw.token !== "string" || !raw.token) throw new Error("plan controller lock: malformed owner; inspect the owner process and restore a valid lock before ares run");
|
|
260
|
+
return raw as unknown as PlanOwner;
|
|
261
|
+
}
|
|
262
|
+
export function assertPlanOwner(dir: string, owner: PlanOwner): void {
|
|
263
|
+
const current = readPlanOwner(dir);
|
|
264
|
+
if (!current || current.token !== owner.token || current.pid !== owner.pid || current.host !== owner.host || current.startedAt !== owner.startedAt) throw new Error("plan controller: ownership changed; stop this controller and inspect ares plan show before restarting");
|
|
265
|
+
}
|
|
266
|
+
export async function withPlanLock<T>(dir: string, fn: (owner: PlanOwner) => Promise<T>): Promise<T> {
|
|
267
|
+
const startedAt = processStart(process.pid);
|
|
268
|
+
if (!startedAt) throw new Error("plan controller: process start identity unavailable; check local ps access before running ares run");
|
|
269
|
+
const owner: PlanOwner = { pid: process.pid, host: hostname(), startedAt, token: randomUUID() };
|
|
270
|
+
await withLock(dir, () => {
|
|
271
|
+
const previous = readPlanOwner(dir);
|
|
272
|
+
if (previous) {
|
|
273
|
+
const busy = () => new Error(`plan controller: owned by pid ${previous.pid} on ${previous.host} (started ${previous.startedAt}); stop that controller or run on its host after it exits`);
|
|
274
|
+
if (previous.host !== owner.host) throw busy();
|
|
275
|
+
let alive = true;
|
|
276
|
+
try { process.kill(previous.pid, 0); }
|
|
277
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") alive = false; else throw busy(); }
|
|
278
|
+
if (alive) {
|
|
279
|
+
const observed = processStart(previous.pid);
|
|
280
|
+
if (!observed || observed === previous.startedAt) throw busy();
|
|
281
|
+
}
|
|
282
|
+
// Short study lock serializes contenders. Never recover by age.
|
|
283
|
+
unlinkSync(join(dir, ".ares.plan.lock"));
|
|
284
|
+
}
|
|
285
|
+
const fd = openSync(join(dir, ".ares.plan.lock"), "wx", 0o600);
|
|
286
|
+
try { writeSync(fd, JSON.stringify(owner) + "\n"); fsyncSync(fd); } finally { closeSync(fd); }
|
|
287
|
+
});
|
|
288
|
+
try { return await fn(owner); }
|
|
289
|
+
finally {
|
|
290
|
+
// A live matching owner cannot be replaced by any valid contender.
|
|
291
|
+
try {
|
|
292
|
+
const current = readPlanOwner(dir);
|
|
293
|
+
if (current?.token === owner.token) unlinkSync(join(dir, ".ares.plan.lock"));
|
|
294
|
+
} catch { /* Preserve the original failure; never remove a malformed or unverified owner. */ }
|
|
295
|
+
}
|
|
296
|
+
}
|
package/src/ui/panel.css
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
.ares { display: flex; flex-direction: column; gap: var(--acon-space-3); min-width: 0; }
|
|
2
|
+
.ares-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--acon-space-2); }
|
|
3
|
+
.ares-bar h2 { flex: 1; margin: 0; min-width: 0; font-size: var(--acon-text-lg); overflow-wrap: anywhere; }
|
|
4
|
+
.ares-card { display: flex; flex-direction: column; gap: var(--acon-space-2); min-width: 0; }
|
|
5
|
+
.ares-card h3 { margin: 0; font-size: var(--acon-text-base, 1rem); }
|
|
6
|
+
.ares-card > p, .ares-prose { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
7
|
+
.ares-sub { margin: 0; color: var(--acon-text-2); overflow-wrap: anywhere; }
|
|
8
|
+
.ares-pre { margin: 0; max-height: 40vh; overflow: auto; padding: var(--acon-space-3); white-space: pre-wrap; overflow-wrap: anywhere; background: var(--acon-surface-2); border: 1px solid var(--acon-line); border-radius: var(--acon-radius-2); font: var(--acon-text-sm)/1.5 var(--acon-font-mono); }
|
|
9
|
+
.ares-list { margin: 0; padding-left: 1.5em; overflow-wrap: anywhere; }
|
|
10
|
+
.ares-list li + li { margin-top: var(--acon-space-1); }
|
|
11
|
+
@media (max-width: 600px) { .ares-bar { align-items: flex-start; } .ares-bar h2 { flex-basis: 100%; } }
|