@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
|
@@ -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,63 @@
|
|
|
1
|
+
import { commit, prepareCommit, openStudy, plain, sha256, type Actor, type LedgerEvent } from "../study.ts";
|
|
2
|
+
import { canonical } from "../plan/state.ts";
|
|
3
|
+
import { createAmemClient, type AmemNote } from "./amem.ts";
|
|
4
|
+
import { deriveLessons, readLessonSources } from "./lessons.ts";
|
|
5
|
+
import type { ProcessRunner } from "../exp/executor.ts";
|
|
6
|
+
|
|
7
|
+
export const ARCHIVE_NOTES = 200;
|
|
8
|
+
export interface ArchiveReceipt { sha256: string; id: string }
|
|
9
|
+
export interface ArchivePreview { fingerprint: string; notes: { sha256: string; note: AmemNote }[] }
|
|
10
|
+
export interface ArchiveResult extends ArchivePreview { written: ArchiveReceipt[]; remaining: number }
|
|
11
|
+
/** Carries receipt progress for adapters; CLI/MCP retain the existing retry instruction verbatim. */
|
|
12
|
+
export class ArchivePartialError extends Error {
|
|
13
|
+
readonly failureMessage: string;
|
|
14
|
+
constructor(readonly exportedCount: number, failure: unknown) {
|
|
15
|
+
const detail = failure instanceof Error ? failure.message : "amem failure; check amem --version before retrying";
|
|
16
|
+
super(`archive: ${exportedCount} note(s) written and receipted; rerun with the same --confirm to continue; ${detail}`);
|
|
17
|
+
this.failureMessage = detail;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function hex(value: unknown, length: 32 | 64): value is string {
|
|
21
|
+
return typeof value === "string" && value.length === length && /^[a-f0-9]+$/.test(value);
|
|
22
|
+
}
|
|
23
|
+
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"); }
|
|
24
|
+
function exported(events: LedgerEvent[]): Set<string> {
|
|
25
|
+
const known = new Map<string, string>();
|
|
26
|
+
for (const event of events) if (event.type === "archive.exported") {
|
|
27
|
+
if (!hex(event.data.fingerprint, 64) || !Array.isArray(event.data.notes) || event.data.notes.length > ARCHIVE_NOTES) badReceipt();
|
|
28
|
+
for (const row of event.data.notes) {
|
|
29
|
+
if (!plain(row) || !hex(row.sha256, 64) || !hex(row.id, 32)) badReceipt();
|
|
30
|
+
if (known.has(row.sha256) && known.get(row.sha256) !== row.id) badReceipt();
|
|
31
|
+
known.set(row.sha256, row.id);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return new Set(known.keys());
|
|
35
|
+
}
|
|
36
|
+
/** No lock, subprocess or write: confirmation binds the full list, receipts only narrow pending writes. */
|
|
37
|
+
export function previewArchive(dir: string): ArchivePreview {
|
|
38
|
+
const sources = readLessonSources(dir), notes = deriveLessons(sources), known = exported(sources.events);
|
|
39
|
+
const unique = new Map(notes.map(note => [sha256(canonical(note)), note]));
|
|
40
|
+
const pending = [...unique].filter(([hash]) => !known.has(hash)).map(([sha256, note]) => ({ sha256, note }));
|
|
41
|
+
return { fingerprint: sha256(canonical(notes)), notes: pending };
|
|
42
|
+
}
|
|
43
|
+
/** Caller holds the study lock across the confirmed write and its single receipt commit. */
|
|
44
|
+
export async function exportArchive(dirArg: string, fingerprint: string, actor: Actor, runner?: ProcessRunner, env: NodeJS.ProcessEnv = process.env): Promise<ArchiveResult> {
|
|
45
|
+
const { dir } = openStudy(dirArg), preview = previewArchive(dir);
|
|
46
|
+
if (!hex(fingerprint, 64) || fingerprint !== preview.fingerprint) throw new Error("archive: stale fingerprint; run ares archive <dir> again and confirm its current fingerprint");
|
|
47
|
+
const batch = preview.notes.slice(0, ARCHIVE_NOTES);
|
|
48
|
+
if (batch.length) {
|
|
49
|
+
try { prepareCommit(dir, [], [{ actor, type: "archive.exported", data: {
|
|
50
|
+
fingerprint, notes: batch.map(note => ({ sha256: note.sha256, id: "f".repeat(32) })),
|
|
51
|
+
} }]); }
|
|
52
|
+
catch { throw new Error("archive receipt: capacity check failed; restore a valid bounded ledger or start a new study before exporting"); }
|
|
53
|
+
}
|
|
54
|
+
const client = createAmemClient(dir, runner, env), written: ArchiveReceipt[] = [];
|
|
55
|
+
let failure: unknown;
|
|
56
|
+
for (const item of batch) {
|
|
57
|
+
try { written.push({ sha256: item.sha256, id: await client.add(item.note) }); }
|
|
58
|
+
catch (error) { failure = error; break; }
|
|
59
|
+
}
|
|
60
|
+
if (written.length) commit(dir, [], [{ actor, type: "archive.exported", data: { fingerprint, notes: written } }]);
|
|
61
|
+
if (failure) throw new ArchivePartialError(written.length, failure);
|
|
62
|
+
return { ...preview, written, remaining: preview.notes.length - written.length };
|
|
63
|
+
}
|
|
@@ -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,70 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { assertWritable, commit, openStudy, plain, readLedger, readTextBounded, sha256, type Actor } from "../study.ts";
|
|
4
|
+
import { checkPaper, manuscriptPath } from "./check.ts";
|
|
5
|
+
import { MANUSCRIPT_BYTES } from "./placeholders.ts";
|
|
6
|
+
|
|
7
|
+
export const PAPER_EVIDENCE_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
export interface PaperEvidence {
|
|
9
|
+
v: 1;
|
|
10
|
+
manuscriptSha256: string;
|
|
11
|
+
placeholders: {
|
|
12
|
+
kind: "number" | "literal"; raw: string; start: number; end: number; line: number; column: number;
|
|
13
|
+
ref?: string; format?: number; value: string | number; rendered: string;
|
|
14
|
+
source: { file: string; sha256: string };
|
|
15
|
+
}[];
|
|
16
|
+
}
|
|
17
|
+
export function paperBuildPaths(dir: string) { return { manuscript: join(dir, "paper", "build", "manuscript.md"), evidence: join(dir, "paper", "build", "evidence.json") }; }
|
|
18
|
+
function previous(file: string, bound: number): string | undefined {
|
|
19
|
+
if (!existsSync(file)) return undefined;
|
|
20
|
+
try { return readTextBounded(file, bound); }
|
|
21
|
+
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`); }
|
|
22
|
+
}
|
|
23
|
+
function evidenceBytes(dir: string): string | undefined {
|
|
24
|
+
const oldEvidence = previous(paperBuildPaths(dir).evidence, PAPER_EVIDENCE_BYTES);
|
|
25
|
+
if (oldEvidence !== undefined) {
|
|
26
|
+
let raw: unknown;
|
|
27
|
+
try { raw = JSON.parse(oldEvidence); } catch { throw new Error("paper evidence: invalid JSON; restore the last valid paper/build/evidence.json before rebuilding"); }
|
|
28
|
+
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");
|
|
29
|
+
}
|
|
30
|
+
return oldEvidence;
|
|
31
|
+
}
|
|
32
|
+
function calculatePaper(dir: string) {
|
|
33
|
+
const report = checkPaper(dir);
|
|
34
|
+
if (!report.ok) {
|
|
35
|
+
const reasons = report.findings.slice(0, 10).map(f => `${f.code} at ${f.line}:${f.column}`).join(", ");
|
|
36
|
+
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`);
|
|
37
|
+
}
|
|
38
|
+
const evidence: PaperEvidence = { v: 1, manuscriptSha256: report.manuscriptSha256, placeholders: report.placeholders.map(entry => {
|
|
39
|
+
const token = entry.placeholder;
|
|
40
|
+
return { kind: token.kind, raw: token.raw, start: token.start, end: token.end, line: token.line, column: token.column,
|
|
41
|
+
...(token.kind === "number" ? { ref: token.ref, ...(token.format === undefined ? {} : { format: token.format }) } : {}),
|
|
42
|
+
value: entry.value, rendered: entry.rendered,
|
|
43
|
+
source: entry.source ?? { file: "paper/manuscript.md", sha256: report.manuscriptSha256 },
|
|
44
|
+
};
|
|
45
|
+
}) };
|
|
46
|
+
const text = JSON.stringify(evidence, null, 2) + "\n";
|
|
47
|
+
assertWritable(report.rendered, MANUSCRIPT_BYTES, "rendered manuscript; shorten paper/manuscript.md before rebuilding");
|
|
48
|
+
assertWritable(text, PAPER_EVIDENCE_BYTES, "paper evidence; reduce paper/manuscript.md or its placeholders before rebuilding");
|
|
49
|
+
return { report, evidence, text, manuscriptSha256: sha256(report.rendered), evidenceSha256: sha256(text) };
|
|
50
|
+
}
|
|
51
|
+
/** Caller holds the study lock. Refusals precede the single output-and-receipt commit. */
|
|
52
|
+
export function buildPaper(dirArg: string, actor: Actor) {
|
|
53
|
+
const { dir } = openStudy(dirArg), paths = paperBuildPaths(dir), oldEvidence = evidenceBytes(dir);
|
|
54
|
+
const { report, text, manuscriptSha256, evidenceSha256 } = calculatePaper(dir), oldManuscript = previous(paths.manuscript, MANUSCRIPT_BYTES);
|
|
55
|
+
const receipt = readLedger(dir).findLast(event => event.type === "paper.built");
|
|
56
|
+
const changed = oldManuscript !== report.rendered || oldEvidence !== text || receipt?.data.manuscriptSha256 !== manuscriptSha256 || receipt.data.evidenceSha256 !== evidenceSha256;
|
|
57
|
+
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");
|
|
58
|
+
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 } }]);
|
|
59
|
+
return { manuscriptSha256, evidenceSha256, changed };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
/** The exact fresh build and both output receipts must agree; never repairs or writes files. */
|
|
64
|
+
export function readCurrentBuild(dirArg: string) {
|
|
65
|
+
const { dir } = openStudy(dirArg), paths = paperBuildPaths(dir), evidence = evidenceBytes(dir), manuscript = previous(paths.manuscript, MANUSCRIPT_BYTES);
|
|
66
|
+
if (evidence === undefined || manuscript === undefined) throw new Error("paper build: missing output; run ares paper build before reviewing");
|
|
67
|
+
const fresh = calculatePaper(dir), receipt = readLedger(dir).findLast(event => event.type === "paper.built");
|
|
68
|
+
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");
|
|
69
|
+
return { manuscriptSha256: fresh.manuscriptSha256, evidenceSha256: fresh.evidenceSha256, check: fresh.report };
|
|
70
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { openStudy, readTextBounded, sha256 } from "../study.ts";
|
|
4
|
+
import { loadCandidates } from "../lit/candidates.ts";
|
|
5
|
+
import { checkCitations, citationOccurrences } from "../lit/cite.ts";
|
|
6
|
+
import { readProtocol } from "../exp/protocol.ts";
|
|
7
|
+
import { readSelection } from "../exp/selection.ts";
|
|
8
|
+
import { investigateResultPath, readClosedInvestigation } from "../exp/investigate.ts";
|
|
9
|
+
import { finalResultPath } from "../exp/final.ts";
|
|
10
|
+
import { MANUSCRIPT_BYTES, parsePlaceholders, resolvePlaceholders } from "./placeholders.ts";
|
|
11
|
+
|
|
12
|
+
export const MAX_CODE_NUMBERS = 500;
|
|
13
|
+
export interface PaperFinding { code: "bare-number" | "unresolved-ref" | "bad-format" | "unknown-citation" | "unverified-citation" | "missing-negative-result"; start: number; end: number; line: number; column: number; message: string }
|
|
14
|
+
function blank(text: string, mask: Uint8Array): string { return text.split("").map((char, index) => mask[index] && char !== "\n" && char !== "\r" ? " " : char).join(""); }
|
|
15
|
+
function mark(mask: Uint8Array, start: number, end: number) { mask.fill(1, start, end); }
|
|
16
|
+
function escaped(text: string, index: number): boolean { let slashes = 0; while (index > 0 && text[--index] === "\\") slashes++; return slashes % 2 === 1; }
|
|
17
|
+
/** Masks syntax-only destinations and code while retaining original offsets. Visible link labels remain prose. */
|
|
18
|
+
function markdownMask(text: string, knownHidden?: Uint8Array): { mask: Uint8Array; code: Uint8Array; hidden: Uint8Array } {
|
|
19
|
+
const mask = new Uint8Array(text.length), protectedText = new Uint8Array(text.length), tokens = parsePlaceholders(text);
|
|
20
|
+
for (const token of [...tokens.placeholders, ...tokens.findings]) mark(protectedText, token.start, token.end);
|
|
21
|
+
let fence: { char: string; length: number } | undefined, paragraph = false, indented = false;
|
|
22
|
+
const listIndents: number[] = [];
|
|
23
|
+
for (const match of text.matchAll(/[^\n]*(?:\n|$)/g)) {
|
|
24
|
+
const line = match[0], start = match.index;
|
|
25
|
+
if (!line) continue;
|
|
26
|
+
if (!line.trim()) { paragraph = false; continue; }
|
|
27
|
+
if (!fence && knownHidden?.[start + /^[ \t]*/.exec(line)![0].length]) { paragraph = false; continue; }
|
|
28
|
+
if (fence) {
|
|
29
|
+
mark(mask, start, start + line.length);
|
|
30
|
+
const close = /^ {0,3}(`+|~+)[ \t]*\r?\n?$/.exec(line);
|
|
31
|
+
if (close && close[1]![0] === fence.char && close[1]!.length >= fence.length) fence = undefined;
|
|
32
|
+
paragraph = false; indented = false;
|
|
33
|
+
} else if (!protectedText[start]) {
|
|
34
|
+
const open = /^ {0,3}(`{3,}|~{3,})([^\r\n]*)/.exec(line);
|
|
35
|
+
if (open && !(open[1]![0] === "`" && open[2]!.includes("`"))) { fence = { char: open[1]![0]!, length: open[1]!.length }; mark(mask, start, start + line.length); paragraph = false; indented = false; }
|
|
36
|
+
else {
|
|
37
|
+
const prefix = /^[ \t]*/.exec(line)![0];
|
|
38
|
+
let indent = 0; for (const char of prefix) indent += char === "\t" ? 4 - indent % 4 : 1;
|
|
39
|
+
const item = /^[ \t]*(?:[-+*]|[0-9]+[.)])[ \t]+/.exec(line);
|
|
40
|
+
let parent = listIndents.length; while (parent && listIndents[parent - 1]! > indent) parent--;
|
|
41
|
+
if (item && indent < (listIndents[parent - 1] ?? 0) + 4) {
|
|
42
|
+
listIndents.length = parent; listIndents.push(item[0].length); paragraph = true; indented = false;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
if (!paragraph) listIndents.length = parent;
|
|
46
|
+
if (indent >= (listIndents.at(-1) ?? 0) + 4 && (indented || !paragraph)) { mark(mask, start, start + line.length); indented = true; }
|
|
47
|
+
else { indented = false; paragraph = !/^ {0,3}(?:#{1,6}[ \t]|(?:---+|\*\*\*+|___+)[ \t]*$)/.test(line.trimEnd()); }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Backtick runs must have an exactly matching closer; unmatched delimiters remain prose.
|
|
53
|
+
const runs = [...text.matchAll(/`+/g)].filter(hit => !mask[hit.index] && !protectedText[hit.index] && !knownHidden?.[hit.index]);
|
|
54
|
+
const regions: number[] = []; let region = 0, previous = 0;
|
|
55
|
+
for (const hit of runs) {
|
|
56
|
+
if (/\n[ \t]*\r?\n/.test(text.slice(previous, hit.index)) || mask.subarray(previous, hit.index).includes(1)) region++;
|
|
57
|
+
regions.push(region); previous = hit.index + hit[0].length;
|
|
58
|
+
}
|
|
59
|
+
const next = new Map<number, number>(), closers = new Map<string, number>();
|
|
60
|
+
for (let i = runs.length - 1; i >= 0; i--) {
|
|
61
|
+
const hit = runs[i]!, key = `${regions[i]}:${hit[0].length}`;
|
|
62
|
+
if (closers.has(key)) next.set(i, closers.get(key)!); closers.set(key, i);
|
|
63
|
+
}
|
|
64
|
+
for (let i = 0; i < runs.length; i++) {
|
|
65
|
+
if (escaped(text, runs[i]!.index)) continue;
|
|
66
|
+
const close = next.get(i);
|
|
67
|
+
if (close !== undefined) { mark(mask, runs[i]!.index, runs[close]!.index + runs[close]![0].length); i = close; }
|
|
68
|
+
}
|
|
69
|
+
const code = mask.slice();
|
|
70
|
+
const labels: number[] = [];
|
|
71
|
+
for (let i = 0; i < text.length; i++) {
|
|
72
|
+
if (mask[i] || protectedText[i] || knownHidden?.[i] || (text[i] !== "<" && text[i] !== "]" && text[i] !== "[") || escaped(text, i)) continue;
|
|
73
|
+
if (text[i] === "[") { labels.push(i); continue; }
|
|
74
|
+
if (text[i] === "<") {
|
|
75
|
+
const end = text.indexOf(">", i + 1), target = text.slice(i + 1, end < 0 ? i + 1 : end);
|
|
76
|
+
if (end >= 0 && /^(?:[A-Za-z][A-Za-z0-9+.-]{1,31}:[^\s<>]*|[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+)$/.test(target)) { mark(mask, i, end + 1); i = end; }
|
|
77
|
+
}
|
|
78
|
+
if (text[i] !== "]") continue;
|
|
79
|
+
const label = labels.pop();
|
|
80
|
+
if (label === undefined || text[i + 1] !== "(") continue;
|
|
81
|
+
let start = i + 2; while (/\s/.test(text[start] ?? "") && start < text.length) start++;
|
|
82
|
+
let end = start, depth = 0;
|
|
83
|
+
if (text[start] === "<") { end = text.indexOf(">", start + 1); if (end < 0) continue; end++; }
|
|
84
|
+
else {
|
|
85
|
+
while (end < text.length) {
|
|
86
|
+
const char = text[end]!;
|
|
87
|
+
if (char === "\\") { end += 2; continue; }
|
|
88
|
+
if (/\s/.test(char) || (char === ")" && depth === 0)) break;
|
|
89
|
+
if (char === "(") depth++;
|
|
90
|
+
if (char === ")") depth--;
|
|
91
|
+
end++;
|
|
92
|
+
}
|
|
93
|
+
if (depth) continue;
|
|
94
|
+
}
|
|
95
|
+
let tail = end; while (/\s/.test(text[tail] ?? "") && tail < text.length) tail++;
|
|
96
|
+
if (["\"", "'"].includes(text[tail] ?? "")) {
|
|
97
|
+
const quote = text[tail++]!; while (tail < text.length && (text[tail] !== quote || escaped(text, tail))) tail++;
|
|
98
|
+
if (tail >= text.length) continue; tail++; while (/\s/.test(text[tail] ?? "") && tail < text.length) tail++;
|
|
99
|
+
}
|
|
100
|
+
if (text[tail] === ")") mark(mask, start, end);
|
|
101
|
+
}
|
|
102
|
+
const hidden = new Uint8Array(text.length); let hasComment = false;
|
|
103
|
+
for (const comment of text.matchAll(/<!--[\s\S]*?(?:-->|$)/g)) {
|
|
104
|
+
if (!code[comment.index] && !protectedText[comment.index]) { mark(mask, comment.index, comment.index + comment[0].length); mark(hidden, comment.index, comment.index + comment[0].length); hasComment = true; }
|
|
105
|
+
}
|
|
106
|
+
// Reference definitions do not count as visible negative-result prose; labels/titles still get numeric checks.
|
|
107
|
+
for (const match of text.matchAll(/^ {0,3}\[[^\]\n]+\]:[ \t]*(<[^>\n]*>|[^\s]+)(?:[ \t]|$)/gm)) {
|
|
108
|
+
const target = match[1]!, start = match.index + match[0].lastIndexOf(target);
|
|
109
|
+
if (!mask[start] && !protectedText[start]) {
|
|
110
|
+
mark(mask, start, start + target.length);
|
|
111
|
+
const lineEnd = text.indexOf("\n", match.index); mark(hidden, match.index, lineEnd < 0 ? text.length : lineEnd);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Reclassify once with non-code comments excluded, so a fence inside a comment cannot hide later prose.
|
|
115
|
+
if (!knownHidden && hasComment) return markdownMask(text, hidden);
|
|
116
|
+
return { mask, code, hidden };
|
|
117
|
+
}
|
|
118
|
+
export function manuscriptPath(dir: string): string { return join(dir, "paper", "manuscript.md"); }
|
|
119
|
+
export function checkPaper(dirArg: string) {
|
|
120
|
+
const { dir, study } = openStudy(dirArg), file = manuscriptPath(dir);
|
|
121
|
+
let manuscript: string;
|
|
122
|
+
try { manuscript = readTextBounded(file, MANUSCRIPT_BYTES); }
|
|
123
|
+
catch { throw new Error("paper manuscript: missing, unreadable, invalid UTF-8, non-regular or larger than 2 MiB; restore paper/manuscript.md as a bounded UTF-8 regular file before paper check"); }
|
|
124
|
+
const masks = markdownMask(manuscript), prose = blank(manuscript, masks.mask), parsed = parsePlaceholders(prose), resolution = resolvePlaceholders(dir, prose);
|
|
125
|
+
const lines = [0]; for (let i = 0; i < manuscript.length; i++) if (manuscript[i] === "\n") lines.push(i + 1);
|
|
126
|
+
const location = (start: number) => {
|
|
127
|
+
let lo = 0, hi = lines.length; while (lo + 1 < hi) { const mid = (lo + hi) >>> 1; if (lines[mid]! <= start) lo = mid; else hi = mid; }
|
|
128
|
+
return { line: lo + 1, column: start - lines[lo]! + 1 };
|
|
129
|
+
};
|
|
130
|
+
const finding = (code: PaperFinding["code"], start: number, end: number, message: string): PaperFinding => ({ code, start, end, ...location(start), message });
|
|
131
|
+
const findings: PaperFinding[] = resolution.findings.map(f => finding(f.code, f.start, f.end, f.message));
|
|
132
|
+
const codeNumbers: { value: string; line: number; column: number }[] = [];
|
|
133
|
+
let codeNumberCount = 0;
|
|
134
|
+
const codeText = manuscript.split("").map((char, index) => masks.code[index] || char === "\n" || char === "\r" ? char : " ").join("");
|
|
135
|
+
for (const hit of codeText.matchAll(/\p{Nd}+(?:[.,]\p{Nd}+)*/gu)) {
|
|
136
|
+
codeNumberCount++;
|
|
137
|
+
if (codeNumbers.length < MAX_CODE_NUMBERS) codeNumbers.push({ value: hit[0], ...location(hit.index) });
|
|
138
|
+
}
|
|
139
|
+
const numberMask = masks.mask.slice(), citationMask = masks.mask.slice();
|
|
140
|
+
for (const placeholder of parsed.placeholders) {
|
|
141
|
+
mark(numberMask, placeholder.start, placeholder.end);
|
|
142
|
+
if (placeholder.kind === "number") mark(citationMask, placeholder.start, placeholder.end);
|
|
143
|
+
}
|
|
144
|
+
for (const problem of parsed.findings) { mark(numberMask, problem.start, problem.end); mark(citationMask, problem.start, problem.end); }
|
|
145
|
+
const citationText = blank(manuscript, citationMask), citations = checkCitations(citationText, loadCandidates(dir));
|
|
146
|
+
const groups = new Map<number, number>();
|
|
147
|
+
for (const hit of citationOccurrences(citationText)) {
|
|
148
|
+
mark(numberMask, hit.start, hit.end); groups.set(hit.groupStart, hit.groupEnd);
|
|
149
|
+
if (citations.unknown.includes(hit.key)) findings.push(finding("unknown-citation", hit.start, hit.end, `citation ${hit.key}: unknown key; add and verify the reference, then regenerate the bibliography`));
|
|
150
|
+
else if (citations.notVerified.includes(hit.key)) findings.push(finding("unverified-citation", hit.start, hit.end, `citation ${hit.key}: not included and identifier-verified; inspect ares lit list, verify the reference or exclude this citation`));
|
|
151
|
+
}
|
|
152
|
+
// Only locator-number syntax is exempt inside citation brackets; arbitrary prose numbers remain checked.
|
|
153
|
+
const locators = /(?:^|[^\p{L}\p{N}_])(p\.|pp\.|sec\.|§|ch\.|chap\.|fig\.|tbl\.|table|eq\.|l\.|ll\.|para\.)[ \t\r\n]*(\p{Nd}+(?:[ \t]*(?:--?|–|—)[ \t]*\p{Nd}+)?(?:[ \t]*,[ \t]*\p{Nd}+(?:[ \t]*(?:--?|–|—)[ \t]*\p{Nd}+)?)*)(?=$|[\s,;:\]\)]|\.(?!\p{Nd}))/giu;
|
|
154
|
+
for (const [start, end] of groups) {
|
|
155
|
+
const group = citationText.slice(start, end);
|
|
156
|
+
for (const hit of group.matchAll(locators)) {
|
|
157
|
+
const numbers = hit[2]!, offset = start + hit.index + hit[0].length - numbers.length;
|
|
158
|
+
mark(numberMask, offset, offset + numbers.length);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (study.mode === "experiment") {
|
|
162
|
+
for (const match of manuscript.matchAll(/^ {0,3}[0-9]+[.)](?=[ \t])/gm)) mark(numberMask, match.index, match.index + match[0].length);
|
|
163
|
+
for (const match of blank(manuscript, numberMask).matchAll(/\p{Nd}+(?:[.,]\p{Nd}+)*/gu)) findings.push(finding("bare-number", match.index, match.index + match[0].length, "prose number has no evidence ref; use {{n:REF}} for a measured value or {{lit:TEXT}} for a reviewer-visible literal"));
|
|
164
|
+
const requireRef = (ref: string) => {
|
|
165
|
+
if (!parsed.placeholders.some(p => p.kind === "number" && p.ref === ref && p.format === undefined && !masks.hidden[p.start])) findings.push(finding("missing-negative-result", 0, 0, `paper omits ${ref}; include {{n:${ref}}} in prose so the result cannot be hidden`));
|
|
166
|
+
};
|
|
167
|
+
if (existsSync(finalResultPath(dir))) requireRef("final.status");
|
|
168
|
+
try {
|
|
169
|
+
const protocol = readProtocol(dir), rule = protocol ? readSelection(dir, protocol) : undefined;
|
|
170
|
+
if (rule && existsSync(investigateResultPath(dir, rule.run))) {
|
|
171
|
+
try { if (readClosedInvestigation(dir).result.winner === null) requireRef("investigate.reason"); }
|
|
172
|
+
catch { findings.push(finding("unresolved-ref", 0, 0, "investigate result is not current and closed; run ares investigate close before checking the paper")); }
|
|
173
|
+
}
|
|
174
|
+
} catch { findings.push(finding("unresolved-ref", 0, 0, "experiment design or selection is invalid; restore the current validated inputs before paper check")); }
|
|
175
|
+
}
|
|
176
|
+
let rendered = "", cursor = 0;
|
|
177
|
+
for (const entry of resolution.placeholders) { rendered += manuscript.slice(cursor, entry.placeholder.start) + entry.rendered; cursor = entry.placeholder.end; }
|
|
178
|
+
rendered += manuscript.slice(cursor);
|
|
179
|
+
return { ok: findings.length === 0, manuscriptSha256: sha256(manuscript), rendered, placeholders: resolution.placeholders, findings: findings.sort((a, b) => a.start - b.start || a.code.localeCompare(b.code)), literals: resolution.literals, codeNumbers, codeNumberCount, citations };
|
|
180
|
+
}
|