@staix/agent-research 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/LICENSE +21 -0
- package/README.md +666 -2
- package/bin/ares.js +11 -0
- package/package.json +37 -4
- package/src/autonomy.ts +50 -0
- package/src/cli.ts +308 -0
- package/src/commands-table.ts +75 -0
- package/src/commands.ts +561 -0
- package/src/console/registry.ts +97 -0
- package/src/copilot.ts +91 -0
- package/src/exp/ablate.ts +140 -0
- package/src/exp/artifacts.ts +229 -0
- package/src/exp/attempts.ts +483 -0
- package/src/exp/executor.ts +207 -0
- package/src/exp/final.ts +197 -0
- package/src/exp/ideas.ts +117 -0
- package/src/exp/investigate.ts +187 -0
- package/src/exp/logs.ts +63 -0
- package/src/exp/metrics.ts +132 -0
- package/src/exp/protocol.ts +148 -0
- package/src/exp/selection.ts +53 -0
- package/src/exp/snapshot.ts +147 -0
- package/src/http.ts +106 -0
- package/src/index.ts +4 -0
- package/src/lit/bibtex.ts +61 -0
- package/src/lit/candidates.ts +148 -0
- package/src/lit/cards.ts +21 -0
- package/src/lit/cite.ts +26 -0
- package/src/lit/sources.ts +122 -0
- package/src/lit/verify.ts +110 -0
- package/src/lit/watch.ts +122 -0
- package/src/manifest.ts +39 -0
- package/src/mcp-tools.ts +210 -0
- package/src/mcp.ts +113 -0
- package/src/memory/amem.ts +97 -0
- package/src/memory/archive.ts +66 -0
- package/src/memory/lessons.ts +88 -0
- package/src/memory/skills.ts +99 -0
- package/src/paper/build.ts +73 -0
- package/src/paper/check.ts +181 -0
- package/src/paper/placeholders.ts +118 -0
- package/src/paper/review.ts +127 -0
- package/src/plan/controller.ts +222 -0
- package/src/plan/definition.ts +168 -0
- package/src/plan/hub.ts +56 -0
- package/src/plan/state.ts +270 -0
- package/src/playbook.ts +141 -0
- package/src/scope.ts +53 -0
- package/src/setup.ts +296 -0
- package/src/study-export.ts +254 -0
- package/src/study.ts +324 -0
- package/src/ui/panel.css +11 -0
- package/src/ui/panel.js +161 -0
- package/src/ui/plugin.ts +375 -0
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { once } from "node:events";
|
|
2
|
+
import type { Readable, Writable } from "node:stream";
|
|
3
|
+
|
|
4
|
+
/** Newline-delimited JSON-RPC stdio transport (MCP basic + tools). Sequential, bounded, no timers. */
|
|
5
|
+
export const FRAME_BYTES = 128 * 1024, RESULT_BYTES = 2 * 1024 * 1024;
|
|
6
|
+
export const DEFAULT_STRING_LENGTH = 65_536;
|
|
7
|
+
export interface McpTool { name: string; description: string; inputSchema: Record<string, unknown>; annotations: Record<string, boolean> }
|
|
8
|
+
export type McpHandler = (args: Record<string, unknown>) => unknown | Promise<unknown>;
|
|
9
|
+
export interface McpServerOptions { tools: readonly McpTool[]; handlers: Record<string, McpHandler>; input?: Readable; output?: Writable; name?: string; version?: string }
|
|
10
|
+
const protocols = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
11
|
+
const plain = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
|
|
12
|
+
|
|
13
|
+
/** Runtime validation for the small schema vocabulary the tools use. Unknown keywords never widen acceptance. */
|
|
14
|
+
export function validArguments(value: unknown, raw: unknown): boolean {
|
|
15
|
+
// An empty JSON schema delegates a frame-bounded field (watch version) to the shared validator.
|
|
16
|
+
if (plain(raw) && Object.keys(raw).length === 0) return true;
|
|
17
|
+
const s = raw as Record<string, any>, type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
|
18
|
+
const types: unknown[] = Array.isArray(s.type) ? s.type : [s.type];
|
|
19
|
+
if (!types.includes(type) && !(type === "number" && Number.isSafeInteger(value) && types.includes("integer"))) return false;
|
|
20
|
+
if (type === "number" && !types.includes("number") && !Number.isSafeInteger(value)) return false;
|
|
21
|
+
if (s.enum && !s.enum.includes(value)) return false;
|
|
22
|
+
if (typeof value === "string" && (value.length < (s.minLength ?? 0) || value.length > (s.maxLength ?? DEFAULT_STRING_LENGTH) || value.includes("\0"))) return false;
|
|
23
|
+
if (typeof value === "number" && (!Number.isFinite(value) || value < (s.minimum ?? -Infinity) || value > (s.maximum ?? Infinity))) return false;
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
if (value.length < (s.minItems ?? 0) || value.length > (s.maxItems ?? 64) || !plain(s.items)) return false;
|
|
26
|
+
return value.every(item => validArguments(item, s.items));
|
|
27
|
+
}
|
|
28
|
+
if (plain(value)) {
|
|
29
|
+
const keys = Object.keys(value);
|
|
30
|
+
if (keys.length < (s.minProperties ?? 0) || keys.length > (s.maxProperties ?? 64)) return false;
|
|
31
|
+
if ((s.required ?? []).some((key: string) => !Object.hasOwn(value, key))) return false;
|
|
32
|
+
for (const key of keys) {
|
|
33
|
+
if (["__proto__", "prototype", "constructor"].includes(key) || key.length > 200) return false;
|
|
34
|
+
if (Object.hasOwn(s.properties ?? {}, key)) { if (!validArguments(value[key], s.properties[key])) return false; }
|
|
35
|
+
else if (s.additionalProperties === false || !plain(s.additionalProperties) || !validArguments(value[key], s.additionalProperties)) return false;
|
|
36
|
+
}
|
|
37
|
+
} else if (type === "object") return false;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Serve until input ends. stdout carries protocol messages only; nothing else is written to `output`. */
|
|
42
|
+
export async function serveMcp(options: McpServerOptions): Promise<void> {
|
|
43
|
+
const input = options.input ?? process.stdin, output = options.output ?? process.stdout;
|
|
44
|
+
let phase: "new" | "initializing" | "ready" = "new";
|
|
45
|
+
const send = async (response: unknown) => { if (!output.write(JSON.stringify(response) + "\n")) await once(output, "drain"); };
|
|
46
|
+
const error = (id: unknown, code: number, message: string) => send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
47
|
+
const handle = async (line: string) => {
|
|
48
|
+
let msg: unknown;
|
|
49
|
+
try { msg = JSON.parse(line); } catch { await error(null, -32700, "Parse error"); return; }
|
|
50
|
+
if (!plain(msg) || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") { await error(null, -32600, "Invalid request"); return; }
|
|
51
|
+
if (!Object.hasOwn(msg, "id")) {
|
|
52
|
+
if (msg.method === "notifications/initialized" && phase === "initializing") phase = "ready";
|
|
53
|
+
return; // Notifications never invoke a handler.
|
|
54
|
+
}
|
|
55
|
+
const id = msg.id;
|
|
56
|
+
if (!(typeof id === "string" && id.length <= 200 || typeof id === "number" && Number.isSafeInteger(id))) { await error(null, -32600, "Invalid request id"); return; }
|
|
57
|
+
if (msg.method === "ping") { await send({ jsonrpc: "2.0", id, result: {} }); return; }
|
|
58
|
+
if (msg.method === "initialize") {
|
|
59
|
+
if (phase !== "new" || !plain(msg.params) || typeof msg.params.protocolVersion !== "string" || !plain(msg.params.capabilities) || !plain(msg.params.clientInfo)) { await error(id, -32602, "Invalid initialization"); return; }
|
|
60
|
+
phase = "initializing";
|
|
61
|
+
await send({ jsonrpc: "2.0", id, result: { protocolVersion: protocols.includes(msg.params.protocolVersion) ? msg.params.protocolVersion : protocols[0],
|
|
62
|
+
capabilities: { tools: { listChanged: false } }, serverInfo: { name: options.name ?? "ares", version: options.version ?? "unreleased" } } });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (phase !== "ready") { await error(id, -32002, "Initialize the session first"); return; }
|
|
66
|
+
if (msg.method === "tools/list") {
|
|
67
|
+
const params = msg.params;
|
|
68
|
+
if (params !== undefined && params !== null && (!plain(params) || Object.keys(params).some(key => key !== "cursor" && key !== "_meta") || params.cursor !== undefined && params.cursor !== null)) { await error(id, -32602, "Invalid tool listing arguments"); return; }
|
|
69
|
+
await send({ jsonrpc: "2.0", id, result: { tools: options.tools } }); return;
|
|
70
|
+
}
|
|
71
|
+
if (msg.method !== "tools/call") { await error(id, -32601, "Method not found"); return; }
|
|
72
|
+
const params = msg.params;
|
|
73
|
+
if (!plain(params) || Object.keys(params).some(k => !["name", "arguments", "_meta"].includes(k)) || typeof params.name !== "string") { await error(id, -32602, "Invalid tool call"); return; }
|
|
74
|
+
const definition = options.tools.find(t => t.name === params.name), args = params.arguments ?? {};
|
|
75
|
+
const handler = definition && options.handlers[definition.name];
|
|
76
|
+
if (!definition || !handler || !validArguments(args, definition.inputSchema)) { await error(id, -32602, "Invalid tool name or arguments"); return; }
|
|
77
|
+
let result: unknown;
|
|
78
|
+
try {
|
|
79
|
+
const text = JSON.stringify(await handler(args as Record<string, unknown>));
|
|
80
|
+
if (text === undefined) throw new Error("tool returned no result");
|
|
81
|
+
result = Buffer.byteLength(text) > RESULT_BYTES
|
|
82
|
+
? { isError: true, content: [{ type: "text", text: JSON.stringify({ refused: true, issues: [{ code: "LIMIT", severity: "error", message: "result exceeds 2 MiB; narrow filters, lower the list limit or select fewer log streams" }] }) }] }
|
|
83
|
+
: { content: [{ type: "text", text }] };
|
|
84
|
+
} catch (failure) {
|
|
85
|
+
// Handlers return structured refusals; anything thrown is reported without stack or content.
|
|
86
|
+
const message = failure instanceof Error ? failure.message.slice(0, 500) : "operation failed";
|
|
87
|
+
result = { isError: true, content: [{ type: "text", text: JSON.stringify({ refused: true, issues: [{ code: "ERROR", severity: "error", message }] }) }] };
|
|
88
|
+
}
|
|
89
|
+
await send({ jsonrpc: "2.0", id, result });
|
|
90
|
+
};
|
|
91
|
+
let pending = "", overflow = false;
|
|
92
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
93
|
+
const consume = async (chunk: string) => {
|
|
94
|
+
let start = 0;
|
|
95
|
+
for (;;) {
|
|
96
|
+
const newline = chunk.indexOf("\n", start), part = chunk.slice(start, newline < 0 ? undefined : newline);
|
|
97
|
+
if (!overflow) {
|
|
98
|
+
if (Buffer.byteLength(pending) + Buffer.byteLength(part) > FRAME_BYTES) { overflow = true; pending = ""; }
|
|
99
|
+
else pending += part;
|
|
100
|
+
}
|
|
101
|
+
if (newline < 0) return;
|
|
102
|
+
if (overflow) await error(null, -32600, "Request exceeds the 128 KiB frame bound; shorten the request and retry");
|
|
103
|
+
else if (pending.trim()) await handle(pending);
|
|
104
|
+
pending = ""; overflow = false; start = newline + 1;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
try {
|
|
108
|
+
for await (const chunk of input) await consume(typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true }));
|
|
109
|
+
} catch { await error(null, -32700, "Invalid UTF-8 input"); return; }
|
|
110
|
+
await consume(decoder.decode());
|
|
111
|
+
// A truncated frame at EOF is never executed.
|
|
112
|
+
if (pending.trim() || overflow) await error(null, -32700, "Incomplete request at EOF");
|
|
113
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { PROCESS_CONTEXT_ENV_KEYS, spawnRunner, type ProcessRunner } from "../exp/executor.ts";
|
|
2
|
+
import { plain } from "../study.ts";
|
|
3
|
+
|
|
4
|
+
export const AMEM_OUTPUT_BYTES = 262_144;
|
|
5
|
+
export const AMEM_BRIEF_BYTES = 8192;
|
|
6
|
+
export type AmemKind = "fail" | "finding" | "decision" | "contract";
|
|
7
|
+
export interface AmemNote {
|
|
8
|
+
text: string; kind: AmemKind; title?: string;
|
|
9
|
+
approach?: string; symptom?: string; cause?: string; evidence?: string[]; retryWhen?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface AmemHit { id: string; kind: AmemKind; title: string | null; at: string; relevance: number; about: string[] }
|
|
12
|
+
export interface AmemBrief { brief: string; bytes: number; truncated: boolean; state: string }
|
|
13
|
+
export interface AmemClient {
|
|
14
|
+
add(note: AmemNote): Promise<string>;
|
|
15
|
+
search(words: string, kind?: AmemKind, limit?: number): Promise<AmemHit[]>;
|
|
16
|
+
brief(text: string, bytes: number): Promise<AmemBrief>;
|
|
17
|
+
}
|
|
18
|
+
const kindOf = (value: unknown): value is AmemKind => ["fail", "finding", "decision", "contract"].includes(value as string);
|
|
19
|
+
const identifier = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{32}$/.test(value);
|
|
20
|
+
function invalid(): never { throw new Error("amem bad JSON: invalid response shape; check amem --version and inspect --json output in the study directory"); }
|
|
21
|
+
function input(text: string): void {
|
|
22
|
+
if (!text.trim() || text.includes("\0")) throw new Error("amem input: provide non-empty text without NUL; correct the input and retry");
|
|
23
|
+
}
|
|
24
|
+
function optionValue(value: string): string {
|
|
25
|
+
input(value);
|
|
26
|
+
// ponytail: amem 0.20.3 cannot escape -- values; one leading space works until its parser supports literal option values.
|
|
27
|
+
return value.startsWith("--") ? ` ${value}` : value;
|
|
28
|
+
}
|
|
29
|
+
function hit(raw: unknown): AmemHit {
|
|
30
|
+
if (!plain(raw) || !identifier(raw.id) || !kindOf(raw.kind) || (raw.title !== null && typeof raw.title !== "string")
|
|
31
|
+
|| typeof raw.at !== "string" || !Number.isFinite(Date.parse(raw.at))
|
|
32
|
+
|| !Array.isArray(raw.about) || !raw.about.every(node => typeof node === "string")) invalid();
|
|
33
|
+
const details = raw.scoreDetails;
|
|
34
|
+
if (!plain(details) || typeof details.finalScore !== "number" || !Number.isFinite(details.finalScore) || details.finalScore <= 0
|
|
35
|
+
|| typeof details.recencyFactor !== "number" || !Number.isFinite(details.recencyFactor) || details.recencyFactor <= 0) scoreUnavailable();
|
|
36
|
+
const relevance = details.finalScore / details.recencyFactor;
|
|
37
|
+
if (!Number.isFinite(relevance) || relevance <= 0) scoreUnavailable();
|
|
38
|
+
return { id: raw.id, kind: raw.kind, title: raw.title, at: raw.at, relevance, about: [...raw.about] as string[] };
|
|
39
|
+
}
|
|
40
|
+
function scoreUnavailable(): never {
|
|
41
|
+
throw new Error("amem score unavailable: finite positive finalScore and recencyFactor required; inspect amem search <words> --json --explain in the study directory");
|
|
42
|
+
}
|
|
43
|
+
async function readAmem(args: string[], cwd: string, runner: ProcessRunner, context: NodeJS.ProcessEnv, stdin?: Uint8Array): Promise<unknown> {
|
|
44
|
+
let result;
|
|
45
|
+
try { result = await runner({ command: "amem", args, cwd, env: context, ...(stdin === undefined ? {} : { stdin }), maxOutputBytes: AMEM_OUTPUT_BYTES, timeoutMs: 30_000 }); }
|
|
46
|
+
catch { throw new Error("amem spawn failure; check amem is on PATH (amem --version)"); }
|
|
47
|
+
if (result.truncated || Buffer.byteLength(result.stdout) > AMEM_OUTPUT_BYTES) throw new Error("amem over-bound output; reduce the search limit or brief bytes and retry");
|
|
48
|
+
if (result.timedOut) throw new Error("amem timeout; check amem --version and retry in the study directory");
|
|
49
|
+
if (result.exitCode !== 0) throw new Error("amem nonzero exit; run amem search <words> --json in the study directory to see the cause");
|
|
50
|
+
try { return JSON.parse(result.stdout); } catch { throw new Error("amem bad JSON; check amem --version and inspect --json output in the study directory"); }
|
|
51
|
+
}
|
|
52
|
+
function searchRows(raw: unknown): unknown[] {
|
|
53
|
+
if (Array.isArray(raw)) return raw;
|
|
54
|
+
if (!plain(raw) || !Array.isArray(raw.hits)) invalid();
|
|
55
|
+
return raw.hits.flatMap(item => {
|
|
56
|
+
if (!plain(item)) invalid();
|
|
57
|
+
if (item.source !== "curated" || item.view === undefined) return [];
|
|
58
|
+
if (!plain(item.view)) invalid();
|
|
59
|
+
return [{ ...item.view, scoreDetails: item.scoreDetails }];
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** The process boundary returns projections only; raw diagnostics never escape in errors. */
|
|
63
|
+
export function createAmemClient(cwd: string, runner: ProcessRunner = spawnRunner, env: NodeJS.ProcessEnv = process.env): AmemClient {
|
|
64
|
+
const context = Object.fromEntries([...PROCESS_CONTEXT_ENV_KEYS, "HOME"].flatMap(key => env[key] === undefined ? [] : [[key, env[key]!]]));
|
|
65
|
+
const read = (args: string[], stdin?: Uint8Array) => readAmem(args, cwd, runner, context, stdin);
|
|
66
|
+
return {
|
|
67
|
+
async add(note) {
|
|
68
|
+
input(note.text);
|
|
69
|
+
if (!kindOf(note.kind)) throw new Error("amem input: invalid kind; use fail, finding, decision or contract");
|
|
70
|
+
if (note.evidence && note.evidence.length > 16) throw new Error("amem input: at most 16 evidence refs; reduce evidence refs and retry");
|
|
71
|
+
const args = ["note", "add", "-", "--kind", note.kind];
|
|
72
|
+
for (const [flag, value] of [["--approach", note.approach], ["--symptom", note.symptom], ["--cause", note.cause],
|
|
73
|
+
... (note.evidence ?? []).map(ref => ["--evidence", ref]), ["--retry-when", note.retryWhen], ["--title", note.title]]) {
|
|
74
|
+
if (value !== undefined) args.push(flag!, optionValue(value));
|
|
75
|
+
}
|
|
76
|
+
const raw = await read([...args, "--json"], Buffer.from(note.text, "utf8"));
|
|
77
|
+
if (!plain(raw) || !identifier(raw.id)) invalid();
|
|
78
|
+
return raw.id;
|
|
79
|
+
},
|
|
80
|
+
async search(words, kind, limit = 100) {
|
|
81
|
+
input(words);
|
|
82
|
+
if (kind !== undefined && !kindOf(kind)) throw new Error("amem input: invalid kind; use fail, finding, decision or contract");
|
|
83
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error("amem input: search limit must be 1..100; choose an integer in this range");
|
|
84
|
+
const raw = await read(["search", optionValue(words), "--json", "--explain", "--limit", String(limit)]);
|
|
85
|
+
const rows = searchRows(raw);
|
|
86
|
+
return rows.filter(row => kind === undefined || plain(row) && row.kind === kind).map(hit);
|
|
87
|
+
},
|
|
88
|
+
async brief(text, bytes) {
|
|
89
|
+
input(text);
|
|
90
|
+
if (!Number.isSafeInteger(bytes) || bytes < 1 || bytes > AMEM_BRIEF_BYTES) throw new Error("amem input: brief bytes must be 1..8192; choose an integer in this range");
|
|
91
|
+
const raw = await read(["brief", "--for", optionValue(text), "--json", "--bytes", String(bytes)]);
|
|
92
|
+
if (!plain(raw) || typeof raw.brief !== "string" || !Number.isSafeInteger(raw.bytes) || (raw.bytes as number) < 0
|
|
93
|
+
|| (raw.bytes as number) > bytes || Buffer.byteLength(raw.brief) > bytes || typeof raw.truncated !== "boolean" || typeof raw.state !== "string") invalid();
|
|
94
|
+
return { brief: raw.brief, bytes: raw.bytes as number, truncated: raw.truncated, state: raw.state };
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ACCEPT_ARTIFACTS } from "../autonomy.ts";
|
|
2
|
+
import { requireCopilotAccept } from "../copilot.ts";
|
|
3
|
+
import { commit, prepareCommit, openStudy, plain, sha256, type Actor, type LedgerEvent } from "../study.ts";
|
|
4
|
+
import { canonical } from "../plan/state.ts";
|
|
5
|
+
import { createAmemClient, type AmemNote } from "./amem.ts";
|
|
6
|
+
import { deriveLessons, readLessonSources } from "./lessons.ts";
|
|
7
|
+
import type { ProcessRunner } from "../exp/executor.ts";
|
|
8
|
+
|
|
9
|
+
export const ARCHIVE_NOTES = 200;
|
|
10
|
+
export interface ArchiveReceipt { sha256: string; id: string }
|
|
11
|
+
export interface ArchivePreview { fingerprint: string; notes: { sha256: string; note: AmemNote }[] }
|
|
12
|
+
export interface ArchiveResult extends ArchivePreview { written: ArchiveReceipt[]; remaining: number }
|
|
13
|
+
/** Carries receipt progress for adapters; CLI/MCP retain the existing retry instruction verbatim. */
|
|
14
|
+
export class ArchivePartialError extends Error {
|
|
15
|
+
readonly failureMessage: string;
|
|
16
|
+
constructor(readonly exportedCount: number, failure: unknown) {
|
|
17
|
+
const detail = failure instanceof Error ? failure.message : "amem failure; check amem --version before retrying";
|
|
18
|
+
super(`archive: ${exportedCount} note(s) written and receipted; rerun with the same --confirm to continue; ${detail}`);
|
|
19
|
+
this.failureMessage = detail;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function hex(value: unknown, length: 32 | 64): value is string {
|
|
23
|
+
return typeof value === "string" && value.length === length && /^[a-f0-9]+$/.test(value);
|
|
24
|
+
}
|
|
25
|
+
function badReceipt(): never { throw new Error("archive receipt: malformed or conflicting exported notes; restore the last valid archive.exported ledger event before retrying ares archive"); }
|
|
26
|
+
function exported(events: LedgerEvent[]): Set<string> {
|
|
27
|
+
const known = new Map<string, string>();
|
|
28
|
+
for (const event of events) if (event.type === "archive.exported") {
|
|
29
|
+
if (!hex(event.data.fingerprint, 64) || !Array.isArray(event.data.notes) || event.data.notes.length > ARCHIVE_NOTES) badReceipt();
|
|
30
|
+
for (const row of event.data.notes) {
|
|
31
|
+
if (!plain(row) || !hex(row.sha256, 64) || !hex(row.id, 32)) badReceipt();
|
|
32
|
+
if (known.has(row.sha256) && known.get(row.sha256) !== row.id) badReceipt();
|
|
33
|
+
known.set(row.sha256, row.id);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return new Set(known.keys());
|
|
37
|
+
}
|
|
38
|
+
/** No lock, subprocess or write: confirmation binds the full list, receipts only narrow pending writes. */
|
|
39
|
+
export function previewArchive(dir: string): ArchivePreview {
|
|
40
|
+
for (const artifact of ACCEPT_ARTIFACTS) requireCopilotAccept(dir, artifact);
|
|
41
|
+
const sources = readLessonSources(dir), notes = deriveLessons(sources), known = exported(sources.events);
|
|
42
|
+
const unique = new Map(notes.map(note => [sha256(canonical(note)), note]));
|
|
43
|
+
const pending = [...unique].filter(([hash]) => !known.has(hash)).map(([sha256, note]) => ({ sha256, note }));
|
|
44
|
+
return { fingerprint: sha256(canonical(notes)), notes: pending };
|
|
45
|
+
}
|
|
46
|
+
/** Caller holds the study lock across the confirmed write and its single receipt commit. */
|
|
47
|
+
export async function exportArchive(dirArg: string, fingerprint: string, actor: Actor, runner?: ProcessRunner, env: NodeJS.ProcessEnv = process.env): Promise<ArchiveResult> {
|
|
48
|
+
const { dir } = openStudy(dirArg), preview = previewArchive(dir);
|
|
49
|
+
if (!hex(fingerprint, 64) || fingerprint !== preview.fingerprint) throw new Error("archive: stale fingerprint; run ares archive <dir> again and confirm its current fingerprint");
|
|
50
|
+
const batch = preview.notes.slice(0, ARCHIVE_NOTES);
|
|
51
|
+
if (batch.length) {
|
|
52
|
+
try { prepareCommit(dir, [], [{ actor, type: "archive.exported", data: {
|
|
53
|
+
fingerprint, notes: batch.map(note => ({ sha256: note.sha256, id: "f".repeat(32) })),
|
|
54
|
+
} }]); }
|
|
55
|
+
catch { throw new Error("archive receipt: capacity check failed; restore a valid bounded ledger or start a new study before exporting"); }
|
|
56
|
+
}
|
|
57
|
+
const client = createAmemClient(dir, runner, env), written: ArchiveReceipt[] = [];
|
|
58
|
+
let failure: unknown;
|
|
59
|
+
for (const item of batch) {
|
|
60
|
+
try { written.push({ sha256: item.sha256, id: await client.add(item.note) }); }
|
|
61
|
+
catch (error) { failure = error; break; }
|
|
62
|
+
}
|
|
63
|
+
if (written.length) commit(dir, [], [{ actor, type: "archive.exported", data: { fingerprint, notes: written } }]);
|
|
64
|
+
if (failure) throw new ArchivePartialError(written.length, failure);
|
|
65
|
+
return { ...preview, written, remaining: preview.notes.length - written.length };
|
|
66
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { openStudy, readLedger, type LedgerEvent, type Study } from "../study.ts";
|
|
2
|
+
import { foldIdeas } from "../exp/ideas.ts";
|
|
3
|
+
import { readProtocol } from "../exp/protocol.ts";
|
|
4
|
+
import { readSelection, selectionHash, type Selection } from "../exp/selection.ts";
|
|
5
|
+
import { readClosedInvestigation, type InvestigateResult } from "../exp/investigate.ts";
|
|
6
|
+
import { readClosedFinal, readFinalSeeds, finalSeedsHash, type FinalResult, type FinalSeeds } from "../exp/final.ts";
|
|
7
|
+
import { foldReviews } from "../paper/review.ts";
|
|
8
|
+
import type { AmemNote, AmemHit } from "./amem.ts";
|
|
9
|
+
|
|
10
|
+
export interface LessonSources {
|
|
11
|
+
study: Pick<Study, "id" | "question">; events: LedgerEvent[];
|
|
12
|
+
investigation?: InvestigateResult; final?: FinalResult; selection?: Selection; finalSeeds?: FinalSeeds;
|
|
13
|
+
}
|
|
14
|
+
export const LESSON_EVIDENCE_REFS = 16;
|
|
15
|
+
/** Read current committed sources only. An absent close yields no lesson; an invalid close refuses. */
|
|
16
|
+
export function readLessonSources(dirArg: string): LessonSources {
|
|
17
|
+
const { dir, study } = openStudy(dirArg), events = readLedger(dir), protocol = readProtocol(dir);
|
|
18
|
+
const sources: LessonSources = { study, events };
|
|
19
|
+
if (protocol) {
|
|
20
|
+
sources.selection = readSelection(dir, protocol);
|
|
21
|
+
sources.finalSeeds = readFinalSeeds(dir, protocol);
|
|
22
|
+
for (const [type, value, hash] of [["selection.set", sources.selection, selectionHash(dir, protocol)], ["final.seeds.set", sources.finalSeeds, finalSeedsHash(dir, protocol)]]) {
|
|
23
|
+
if (value && events.findLast(event => event.type === type)?.data.sha256 !== hash) throw new Error("lessons: design file lacks a matching receipt; restore the committed selection/final seeds with ares selection or ares final seeds");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (sources.selection && events.some(event => event.type === "investigate.closed" && event.data.run === sources.selection!.run)) sources.investigation = readClosedInvestigation(dir).result;
|
|
27
|
+
if (events.some(event => event.type === "final.closed")) sources.final = readClosedFinal(dir).result;
|
|
28
|
+
return sources;
|
|
29
|
+
}
|
|
30
|
+
/** Pure derivation. Ledger order, fixed source order and evidence chunks keep export fingerprints stable. */
|
|
31
|
+
export function deriveLessons(sources: LessonSources): AmemNote[] {
|
|
32
|
+
const { study, events } = sources, notes: AmemNote[] = [], prefix = `${study.id}: ${study.question}\n`;
|
|
33
|
+
const attemptRef = (id: string) => `ares:${study.id}/attempt:${id}`;
|
|
34
|
+
const runRef = (run: string) => `ares:${study.id}/run:${run}`;
|
|
35
|
+
const add = (note: AmemNote) => {
|
|
36
|
+
const refs = [...new Set(note.evidence ?? [])];
|
|
37
|
+
// Split long evidence lists rather than silently dropping cited attempts.
|
|
38
|
+
for (let index = 0; index < Math.max(1, refs.length); index += LESSON_EVIDENCE_REFS) {
|
|
39
|
+
notes.push({ ...note, text: prefix + note.text, evidence: refs.slice(index, index + LESSON_EVIDENCE_REFS) });
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
for (const idea of foldIdeas(events)) {
|
|
43
|
+
for (const decision of idea.decisions) if (decision.decision === "pivot") add({ kind: "fail", title: idea.title,
|
|
44
|
+
text: `Pivot ${idea.id}: ${decision.reason}`, approach: `${idea.title}\n${idea.hypothesis}`, symptom: decision.reason,
|
|
45
|
+
cause: decision.cause ?? "not recorded", retryWhen: decision.retryWhen ?? "not recorded", evidence: decision.attempts.map(attemptRef) });
|
|
46
|
+
}
|
|
47
|
+
const investigation = sources.investigation;
|
|
48
|
+
if (investigation) {
|
|
49
|
+
const winner = investigation.winner, candidate = investigation.candidates.find(row => row.commit === winner?.commit);
|
|
50
|
+
add({ kind: "finding", title: `Investigation ${investigation.run}`,
|
|
51
|
+
text: winner ? `Investigation ${investigation.run}: winner ${winner.commit}; paired delta ${winner.delta}; seeds ${JSON.stringify(candidate?.pairedSeeds ?? [])}`
|
|
52
|
+
: `Investigation ${investigation.run}: no winner; reason ${investigation.reason}`,
|
|
53
|
+
evidence: [runRef(investigation.run), ...(winner?.attempts ?? []).map(attemptRef)] });
|
|
54
|
+
}
|
|
55
|
+
const final = sources.final;
|
|
56
|
+
if (final) add({ kind: "finding", title: `Final ${final.status}`,
|
|
57
|
+
text: `Final ${final.status}: winner ${final.winner}; baseline ${final.baseline}; reason ${final.reason ?? "qualified"}; paired delta ${final.delta}; seeds ${JSON.stringify(final.seeds)}`,
|
|
58
|
+
evidence: [runRef("final"), ...final.decisive.map(row => attemptRef(row.id))] });
|
|
59
|
+
if (sources.selection) add({ kind: "decision", title: "Selection rule", text: `Selection rule: ${JSON.stringify(sources.selection)}`, evidence: [runRef(sources.selection.run)] });
|
|
60
|
+
if (sources.finalSeeds) add({ kind: "decision", title: "Final seeds", text: `Final seeds: ${JSON.stringify(sources.finalSeeds.seeds)}`, evidence: [runRef("final")] });
|
|
61
|
+
for (const review of foldReviews(events)) {
|
|
62
|
+
for (const finding of review.report.findings) {
|
|
63
|
+
const resolution = review.resolutions[finding.id];
|
|
64
|
+
if (finding.kind === "contradiction" && resolution) add({ kind: "finding", title: `Review ${review.id}/${finding.id}`,
|
|
65
|
+
text: `Review contradiction ${review.id}/${finding.id}: ${finding.text}; resolution: ${resolution.reason}`, evidence: [] });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return notes;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface LessonRankOptions { halfLife?: number; limit?: number }
|
|
72
|
+
export interface RankedLesson { id: string; kind: AmemHit["kind"]; title: string | null; age: number; relevance: number; score: number; rank: number }
|
|
73
|
+
export const LESSON_HALF_LIFE = 180, LESSON_LIMIT = 50;
|
|
74
|
+
const DAY_MS = 86_400_000;
|
|
75
|
+
/** Pure ranking: age is days, relevance excludes amem recency, score is decayed and rank is 1-based. */
|
|
76
|
+
export function rankLessons(hits: readonly AmemHit[], now: number, options: LessonRankOptions = {}): RankedLesson[] {
|
|
77
|
+
const halfLife = options.halfLife ?? LESSON_HALF_LIFE, limit = options.limit ?? LESSON_LIMIT;
|
|
78
|
+
if (!Number.isFinite(now)) throw new Error("lessons clock: finite timestamp required; supply a valid current time and retry");
|
|
79
|
+
if (!Number.isFinite(halfLife) || halfLife < 1 || halfLife > 3650) throw new Error("lessons half-life: outside 1..3650 days; pass --half-life DAYS within that range");
|
|
80
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > LESSON_LIMIT) throw new Error("lessons limit: outside 1..50; pass --limit with an integer in that range");
|
|
81
|
+
const ranked = hits.map(hit => {
|
|
82
|
+
const at = Date.parse(hit.at);
|
|
83
|
+
if (!Number.isFinite(at) || !Number.isFinite(hit.relevance) || hit.relevance <= 0) throw new Error("lessons score unavailable: invalid timestamp or relevance; inspect amem search --json --explain in the study directory before retrying");
|
|
84
|
+
const age = Math.max(0, (now - at) / DAY_MS), score = hit.relevance * 0.5 ** (age / halfLife);
|
|
85
|
+
return { id: hit.id, kind: hit.kind, title: hit.title, age, relevance: hit.relevance, score };
|
|
86
|
+
});
|
|
87
|
+
return ranked.sort((a, b) => b.score - a.score || Number(b.kind === "fail") - Number(a.kind === "fail") || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).slice(0, limit).map((row, index) => ({ ...row, rank: index + 1 }));
|
|
88
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { assertWritable, boundedText, commit, onlyKeys, openStudy, plain, readLedger, readTextBounded, sha256, type Actor, type LedgerEvent } from "../study.ts";
|
|
4
|
+
|
|
5
|
+
export const SKILL_PROPOSAL_VERSION = 1, SKILL_PROPOSALS = 100, SKILL_BYTES = 16 * 1024, SKILL_EVIDENCE = 64;
|
|
6
|
+
export interface SkillProposal { name: string; summary: string; steps: string[]; evidence: string[] }
|
|
7
|
+
export type SkillDecision = "accept" | "reject" | "withdraw";
|
|
8
|
+
export interface SkillRecord extends SkillProposal {
|
|
9
|
+
skillProposal: 1; state: "proposed" | "accepted" | "rejected" | "withdrawn"; sha256: string; actor: Actor; at: string;
|
|
10
|
+
decision?: { action: SkillDecision; reason: string; pr?: string; actor: Actor; at: string };
|
|
11
|
+
}
|
|
12
|
+
function bad(message: string): never { throw new Error(`skill: ${message}; inspect ares skill list, correct the input or restore the last valid proposal and retry`); }
|
|
13
|
+
function nameOf(value: unknown): string {
|
|
14
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(value)) bad("name must follow the run-name rule (1..64 letters, digits, underscores or hyphens)");
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function text(value: unknown, field: string): string {
|
|
18
|
+
let result: string;
|
|
19
|
+
try { result = boundedText(value, `skill ${field}`, SKILL_BYTES); } catch { bad(`${field} must be nonblank text within 16 KiB`); }
|
|
20
|
+
if (result.includes("\0") || Buffer.byteLength(result) > SKILL_BYTES) bad(`${field} contains NUL or exceeds 16 KiB`);
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
function markdown(proposal: SkillProposal): string {
|
|
24
|
+
const list = (values: string[]) => values.map(value => `- ${value.replaceAll("\n", "\n ")}`).join("\n");
|
|
25
|
+
return `---\nskillProposal: 1\nname: ${proposal.name}\n---\n\n# ${proposal.name}\n\n${proposal.summary}\n\n## Steps\n\n${list(proposal.steps)}\n\n## Evidence\n\n${list(proposal.evidence)}\n`;
|
|
26
|
+
}
|
|
27
|
+
/** Strict for new input, additive-field tolerant when replaying existing v1 records. */
|
|
28
|
+
export function validateSkillProposal(raw: unknown, strict = true): SkillProposal {
|
|
29
|
+
if (!plain(raw)) bad("proposal object required");
|
|
30
|
+
if (strict) { try { onlyKeys(raw, ["name", "summary", "steps", "evidence"], "skill proposal"); } catch { bad("unknown proposal field; use only name, summary, steps and evidence"); } }
|
|
31
|
+
if (!Array.isArray(raw.steps) || !raw.steps.length || !Array.isArray(raw.evidence)) bad("steps must be a nonempty array and evidence an array");
|
|
32
|
+
if (raw.evidence.length < 1 || raw.evidence.length > SKILL_EVIDENCE) bad("evidence requires 1..64 refs; supply at least one recorded amem id or ares: reference");
|
|
33
|
+
if (raw.steps.length > SKILL_BYTES) bad("proposal arrays exceed the 16 KiB format ceiling; shorten steps or evidence");
|
|
34
|
+
const proposal: SkillProposal = { name: nameOf(raw.name), summary: text(raw.summary, "summary"), steps: raw.steps.map(value => text(value, "step")), evidence: raw.evidence.map(value => text(value, "evidence")) };
|
|
35
|
+
if (proposal.evidence.some(value => !/^[a-f0-9]{32}$/.test(value) && !/^ares:[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(value))) bad("evidence must contain 32-hex amem ids or ares:<study-id>/... refs");
|
|
36
|
+
try { assertWritable(markdown(proposal), SKILL_BYTES, "skill proposal"); } catch { bad("proposal Markdown exceeds 16 KiB; shorten summary, steps or evidence"); }
|
|
37
|
+
return proposal;
|
|
38
|
+
}
|
|
39
|
+
function decisionOf(value: unknown): SkillDecision {
|
|
40
|
+
if (value !== "accept" && value !== "reject" && value !== "withdraw") bad("decision must be accept, reject or withdraw");
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
function prOf(value: unknown, action: SkillDecision): string | undefined {
|
|
44
|
+
if (value === undefined) { if (action === "accept") bad("accept requires --pr URL for the reviewed PR"); return undefined; }
|
|
45
|
+
const result = text(value, "PR URL");
|
|
46
|
+
try { const url = new URL(result); if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) bad("PR must be an HTTP(S) URL without credentials"); }
|
|
47
|
+
catch { bad("PR must be an HTTP(S) URL without credentials"); }
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
function version(event: LedgerEvent): void {
|
|
51
|
+
if (event.data.skillProposal !== 1) throw new Error("skill proposal: unsupported version; use an ares version supporting this journal and keep its files unchanged");
|
|
52
|
+
}
|
|
53
|
+
export function foldSkills(events: LedgerEvent[]): SkillRecord[] {
|
|
54
|
+
const rows = new Map<string, SkillRecord>();
|
|
55
|
+
for (const event of events) {
|
|
56
|
+
if (event.type === "skill.proposed") {
|
|
57
|
+
version(event); const proposal = validateSkillProposal(event.data.proposal, false), hash = sha256(markdown(proposal));
|
|
58
|
+
if (event.data.sha256 !== hash || rows.has(proposal.name) || rows.size >= SKILL_PROPOSALS) bad("journal has a mismatched hash, duplicate name or more than 100 proposals");
|
|
59
|
+
rows.set(proposal.name, { ...proposal, skillProposal: 1, state: "proposed", sha256: hash, actor: event.actor, at: event.at });
|
|
60
|
+
} else if (event.type === "skill.decided") {
|
|
61
|
+
version(event); const name = nameOf(event.data.name), row = rows.get(name), action = decisionOf(event.data.decision);
|
|
62
|
+
if (!row || row.state !== "proposed") bad("decision references a missing or already decided proposal");
|
|
63
|
+
const reason = text(event.data.reason, "reason"), pr = prOf(event.data.pr, action);
|
|
64
|
+
row.state = action === "accept" ? "accepted" : action === "reject" ? "rejected" : "withdrawn";
|
|
65
|
+
row.decision = { action, reason, ...(pr === undefined ? {} : { pr }), actor: event.actor, at: event.at };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return [...rows.values()];
|
|
69
|
+
}
|
|
70
|
+
function file(dir: string, name: string): string { return join(dir, "proposals", "skills", `${name}.md`); }
|
|
71
|
+
/** Read only tracked proposals; every file is regular/bounded and bound to its original event. */
|
|
72
|
+
function readSkills(dirArg: string) {
|
|
73
|
+
const { dir } = openStudy(dirArg), events = readLedger(dir), rows = foldSkills(events);
|
|
74
|
+
for (const row of rows) {
|
|
75
|
+
let content: string;
|
|
76
|
+
try { content = readTextBounded(file(dir, row.name), SKILL_BYTES); } catch { bad("proposal file missing, unreadable or larger than 16 KiB; restore its committed Markdown"); }
|
|
77
|
+
if (!content.startsWith("---\nskillProposal: 1\n")) throw new Error("skill proposal file: unsupported version; use an ares version supporting this file and keep it unchanged");
|
|
78
|
+
if (sha256(content) !== row.sha256) bad("proposal file differs from its receipt; restore the original Markdown");
|
|
79
|
+
}
|
|
80
|
+
return { dir, events, rows };
|
|
81
|
+
}
|
|
82
|
+
export function listSkills(dir: string): SkillRecord[] { return readSkills(dir).rows; }
|
|
83
|
+
/** Caller holds the study lock. One bounded file and one event share the existing commit preflight. */
|
|
84
|
+
export function proposeSkill(dirArg: string, raw: unknown, actor: Actor): SkillRecord {
|
|
85
|
+
const proposal = validateSkillProposal(raw), { dir, events, rows } = readSkills(dirArg);
|
|
86
|
+
if (rows.length >= SKILL_PROPOSALS) bad("100-proposal limit reached; start a new study for further proposals");
|
|
87
|
+
if (rows.some(row => row.name === proposal.name) || existsSync(file(dir, proposal.name))) bad("name or proposal file already exists; choose a new name without replacing prior work");
|
|
88
|
+
const content = markdown(proposal), hash = sha256(content);
|
|
89
|
+
const added = commit(dir, [[file(dir, proposal.name), content, SKILL_BYTES]], [{ actor, type: "skill.proposed", data: { skillProposal: 1, proposal, sha256: hash } }]);
|
|
90
|
+
return foldSkills([...events, ...added]).find(row => row.name === proposal.name)!;
|
|
91
|
+
}
|
|
92
|
+
/** Final actor-recorded decisions never open, inspect or merge a PR. */
|
|
93
|
+
export function decideSkill(dirArg: string, name: string, decision: string, reason: string, pr: string | undefined, actor: Actor): SkillRecord {
|
|
94
|
+
const target = nameOf(name), action = decisionOf(decision), why = text(reason, "reason"), reference = prOf(pr, action), { dir, events, rows } = readSkills(dirArg);
|
|
95
|
+
const row = rows.find(row => row.name === target);
|
|
96
|
+
if (!row || row.state !== "proposed") bad("proposal is missing or already decided; decisions including withdrawal are final");
|
|
97
|
+
const added = commit(dir, [], [{ actor, type: "skill.decided", data: { skillProposal: 1, name: target, decision: action, reason: why, ...(reference === undefined ? {} : { pr: reference }) } }]);
|
|
98
|
+
return foldSkills([...events, ...added]).find(row => row.name === target)!;
|
|
99
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { requireCopilotAccept } from "../copilot.ts";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { assertWritable, commit, openStudy, plain, readLedger, readTextBounded, sha256, type Actor } from "../study.ts";
|
|
5
|
+
import { checkPaper, manuscriptPath } from "./check.ts";
|
|
6
|
+
import { MANUSCRIPT_BYTES } from "./placeholders.ts";
|
|
7
|
+
|
|
8
|
+
export const PAPER_EVIDENCE_BYTES = 8 * 1024 * 1024;
|
|
9
|
+
export interface PaperEvidence {
|
|
10
|
+
v: 1;
|
|
11
|
+
manuscriptSha256: string;
|
|
12
|
+
placeholders: {
|
|
13
|
+
kind: "number" | "literal"; raw: string; start: number; end: number; line: number; column: number;
|
|
14
|
+
ref?: string; format?: number; value: string | number; rendered: string;
|
|
15
|
+
source: { file: string; sha256: string };
|
|
16
|
+
}[];
|
|
17
|
+
}
|
|
18
|
+
export function paperBuildPaths(dir: string) { return { manuscript: join(dir, "paper", "build", "manuscript.md"), evidence: join(dir, "paper", "build", "evidence.json") }; }
|
|
19
|
+
function previous(file: string, bound: number): string | undefined {
|
|
20
|
+
if (!existsSync(file)) return undefined;
|
|
21
|
+
try { return readTextBounded(file, bound); }
|
|
22
|
+
catch { throw new Error(`paper build: previous output is unreadable, non-regular, invalid UTF-8 or oversized; restore the last valid ${file.endsWith("evidence.json") ? "paper/build/evidence.json" : "paper/build/manuscript.md"} before rebuilding`); }
|
|
23
|
+
}
|
|
24
|
+
function evidenceBytes(dir: string): string | undefined {
|
|
25
|
+
const oldEvidence = previous(paperBuildPaths(dir).evidence, PAPER_EVIDENCE_BYTES);
|
|
26
|
+
if (oldEvidence !== undefined) {
|
|
27
|
+
let raw: unknown;
|
|
28
|
+
try { raw = JSON.parse(oldEvidence); } catch { throw new Error("paper evidence: invalid JSON; restore the last valid paper/build/evidence.json before rebuilding"); }
|
|
29
|
+
if (!plain(raw) || raw.v !== 1) throw new Error("paper evidence: unsupported version; use an ares version that supports it, keeping paper/build/evidence.json unchanged");
|
|
30
|
+
}
|
|
31
|
+
return oldEvidence;
|
|
32
|
+
}
|
|
33
|
+
function calculatePaper(dir: string) {
|
|
34
|
+
const report = checkPaper(dir);
|
|
35
|
+
if (!report.ok) {
|
|
36
|
+
const reasons = report.findings.slice(0, 10).map(f => `${f.code} at ${f.line}:${f.column}`).join(", ");
|
|
37
|
+
throw new Error(`paper build: paper check found ${report.findings.length} finding(s): ${reasons}; run ares paper check <dir> --json and fix every finding before rebuilding`);
|
|
38
|
+
}
|
|
39
|
+
const evidence: PaperEvidence = { v: 1, manuscriptSha256: report.manuscriptSha256, placeholders: report.placeholders.map(entry => {
|
|
40
|
+
const token = entry.placeholder;
|
|
41
|
+
return { kind: token.kind, raw: token.raw, start: token.start, end: token.end, line: token.line, column: token.column,
|
|
42
|
+
...(token.kind === "number" ? { ref: token.ref, ...(token.format === undefined ? {} : { format: token.format }) } : {}),
|
|
43
|
+
value: entry.value, rendered: entry.rendered,
|
|
44
|
+
source: entry.source ?? { file: "paper/manuscript.md", sha256: report.manuscriptSha256 },
|
|
45
|
+
};
|
|
46
|
+
}) };
|
|
47
|
+
const text = JSON.stringify(evidence, null, 2) + "\n";
|
|
48
|
+
assertWritable(report.rendered, MANUSCRIPT_BYTES, "rendered manuscript; shorten paper/manuscript.md before rebuilding");
|
|
49
|
+
assertWritable(text, PAPER_EVIDENCE_BYTES, "paper evidence; reduce paper/manuscript.md or its placeholders before rebuilding");
|
|
50
|
+
return { report, evidence, text, manuscriptSha256: sha256(report.rendered), evidenceSha256: sha256(text) };
|
|
51
|
+
}
|
|
52
|
+
/** Caller holds the study lock. Refusals precede the single output-and-receipt commit. */
|
|
53
|
+
export function buildPaper(dirArg: string, actor: Actor) {
|
|
54
|
+
const { dir } = openStudy(dirArg);
|
|
55
|
+
requireCopilotAccept(dir, "hypotheses");
|
|
56
|
+
const paths = paperBuildPaths(dir), oldEvidence = evidenceBytes(dir);
|
|
57
|
+
const { report, text, manuscriptSha256, evidenceSha256 } = calculatePaper(dir), oldManuscript = previous(paths.manuscript, MANUSCRIPT_BYTES);
|
|
58
|
+
const receipt = readLedger(dir).findLast(event => event.type === "paper.built");
|
|
59
|
+
const changed = oldManuscript !== report.rendered || oldEvidence !== text || receipt?.data.manuscriptSha256 !== manuscriptSha256 || receipt.data.evidenceSha256 !== evidenceSha256;
|
|
60
|
+
if (sha256(readTextBounded(manuscriptPath(dir), MANUSCRIPT_BYTES)) !== report.manuscriptSha256) throw new Error("paper build: source manuscript changed during checking; rerun ares paper check and paper build on the current manuscript");
|
|
61
|
+
if (changed) commit(dir, [[paths.manuscript, report.rendered, MANUSCRIPT_BYTES], [paths.evidence, text, PAPER_EVIDENCE_BYTES]], [{ actor, type: "paper.built", data: { manuscriptSha256, evidenceSha256, sourceManuscriptSha256: report.manuscriptSha256 } }]);
|
|
62
|
+
return { manuscriptSha256, evidenceSha256, changed };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
/** The exact fresh build and both output receipts must agree; never repairs or writes files. */
|
|
67
|
+
export function readCurrentBuild(dirArg: string) {
|
|
68
|
+
const { dir } = openStudy(dirArg), paths = paperBuildPaths(dir), evidence = evidenceBytes(dir), manuscript = previous(paths.manuscript, MANUSCRIPT_BYTES);
|
|
69
|
+
if (evidence === undefined || manuscript === undefined) throw new Error("paper build: missing output; run ares paper build before reviewing");
|
|
70
|
+
const fresh = calculatePaper(dir), receipt = readLedger(dir).findLast(event => event.type === "paper.built");
|
|
71
|
+
if (evidence !== fresh.text || manuscript !== fresh.report.rendered || receipt?.data.manuscriptSha256 !== fresh.manuscriptSha256 || receipt.data.evidenceSha256 !== fresh.evidenceSha256 || receipt.data.sourceManuscriptSha256 !== fresh.report.manuscriptSha256) throw new Error("paper build: stale outputs or missing/mismatched receipt; run ares paper check and ares paper build before reviewing");
|
|
72
|
+
return { manuscriptSha256: fresh.manuscriptSha256, evidenceSha256: fresh.evidenceSha256, check: fresh.report };
|
|
73
|
+
}
|