@tachikomagundam/abathur 0.2.2 → 0.2.4
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/README.md +36 -18
- package/config/genomes/historian.example.jsonc +9 -4
- package/dist/bench/adapter.js +22 -4
- package/dist/bench/fixture.js +8 -4
- package/dist/bench/toy.js +3 -3
- package/dist/commands/status.js +6 -1
- package/dist/core/evolve/run-bench.js +2 -1
- package/dist/test/bench-adapter.test.js +24 -0
- package/dist/test/bench-fixture.test.js +6 -2
- package/dist/test/bench-reporoot.test.js +197 -0
- package/dist/test/historian-grader-integrity.test.js +438 -0
- package/dist/test/historian-grader-io.test.js +277 -1
- package/dist/test/historian-run-scenario.test.js +205 -0
- package/dist/test/opencode.test.js +21 -0
- package/dist/test/status-repo.test.js +72 -0
- package/graders/historian/grader-core.d.mts +43 -0
- package/graders/historian/grader-core.mjs +215 -5
- package/graders/historian/grader-support.d.mts +11 -0
- package/graders/historian/grader-support.mjs +28 -0
- package/graders/historian/grader.mjs +62 -5
- package/graders/historian/mutate.sh +6 -3
- package/graders/historian/run-scenario.sh +39 -3
- package/graders/historian/seed-wrapped.sh +1 -1
- package/package.json +1 -1
- package/plugin/abathur.ts +47 -7
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
// Type declarations for grader-core.mjs — the plan-185 scoring engine.
|
|
2
2
|
// Runtime lives in the .mjs; this file is the compile-time contract.
|
|
3
3
|
|
|
4
|
+
import type { ToolEvent } from "./grader-support.mjs";
|
|
5
|
+
|
|
4
6
|
export type DimKey = "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H";
|
|
5
7
|
export type Bit = 0 | 1;
|
|
6
8
|
export type FullDims = Readonly<Record<DimKey, Bit>>;
|
|
7
9
|
export type WorthDims = Readonly<{ G: Bit; H: Bit; J: Bit }>;
|
|
10
|
+
export type IntegrityDims = Readonly<{ I: Bit; J: Bit }>;
|
|
11
|
+
export type IntegrityNote = string;
|
|
8
12
|
|
|
9
13
|
export interface CreatedPage {
|
|
10
14
|
readonly path: string;
|
|
@@ -13,6 +17,16 @@ export interface CreatedPage {
|
|
|
13
17
|
readonly content: string;
|
|
14
18
|
}
|
|
15
19
|
|
|
20
|
+
/** Post-run sandbox row + seed captures the I/J checkers diff evidence against. */
|
|
21
|
+
export interface IntegrityState {
|
|
22
|
+
readonly sandboxRows: ReadonlyArray<{ readonly path: string; readonly id: string; readonly description: string }>;
|
|
23
|
+
readonly content: Readonly<Record<string, string>>;
|
|
24
|
+
readonly rowIdByPath: ReadonlyMap<string, string>;
|
|
25
|
+
readonly descByPath: ReadonlyMap<string, string>;
|
|
26
|
+
readonly seedDescByPath: ReadonlyMap<string, string>;
|
|
27
|
+
readonly seedContent: Readonly<Record<string, string>>;
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
export interface Observation {
|
|
17
31
|
readonly scenarioNo: number;
|
|
18
32
|
readonly created: readonly CreatedPage[];
|
|
@@ -31,6 +45,10 @@ export interface Observation {
|
|
|
31
45
|
readonly backlinkBodies: ReadonlyArray<{ readonly path: string; readonly locale: string; readonly content: string }>;
|
|
32
46
|
readonly finalMessage: string;
|
|
33
47
|
readonly urlChecks: readonly { readonly url: string; readonly status: number }[];
|
|
48
|
+
/** Required for integrity units (scenario-10/11/12): transcript tool events. */
|
|
49
|
+
readonly tools?: readonly ToolEvent[] | undefined;
|
|
50
|
+
/** Required for integrity units: seed-state-derived wiki context. */
|
|
51
|
+
readonly integrity?: IntegrityState | undefined;
|
|
34
52
|
}
|
|
35
53
|
|
|
36
54
|
export interface UnitScore {
|
|
@@ -47,7 +65,32 @@ export type ScoredUnit = UnitScore & {
|
|
|
47
65
|
|
|
48
66
|
export const WEIGHTS: Readonly<Record<DimKey, number>>;
|
|
49
67
|
|
|
68
|
+
export const S10_PATH: string;
|
|
69
|
+
export const S10_DECOY: string;
|
|
70
|
+
export const S11_CARD: string;
|
|
71
|
+
export const S11_SUPERSEDE: string;
|
|
72
|
+
export const S11_SEED_DATE: string;
|
|
73
|
+
export const VERIFY_TOKEN: string;
|
|
74
|
+
export const S12_JUNK: readonly string[];
|
|
75
|
+
export const S12_DECOY: readonly string[];
|
|
76
|
+
export const APPLICABLE: Readonly<Record<number, Readonly<Record<string, number>>>>;
|
|
77
|
+
|
|
78
|
+
export interface StatusTokens {
|
|
79
|
+
readonly header: string | null;
|
|
80
|
+
readonly rows: readonly string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface IntegrityResult extends IntegrityDims {
|
|
84
|
+
readonly notes: readonly IntegrityNote[];
|
|
85
|
+
}
|
|
86
|
+
|
|
50
87
|
export function computeDims(obs: Observation): FullDims;
|
|
51
88
|
export function judgment(scenarioNo: number, created: readonly CreatedPage[], finalMessage: string): Bit;
|
|
52
89
|
export function scoreFromDims(scenarioNo: number, dims: FullDims | WorthDims): UnitScore;
|
|
53
90
|
export function scoreUnit(obs: Observation): ScoredUnit;
|
|
91
|
+
export function statusTokens(content: string): StatusTokens;
|
|
92
|
+
/** Parse a tool event's output as JSON, following opencode's >45KB externalization
|
|
93
|
+
* stubs (`Full output saved to: <ref>`) to the ref file. Fail-closed: undefined
|
|
94
|
+
* when neither the inline output nor the referenced file parses. */
|
|
95
|
+
export function resolveToolJson(event: ToolEvent): unknown;
|
|
96
|
+
export function integrityDims(scenarioNo: number, obs: Observation, tools: readonly ToolEvent[], integrity: IntegrityState): IntegrityResult;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
// Historian grader scoring core (task 14, plan line 185).
|
|
1
|
+
// Historian grader scoring core (task 14, plan line 185). Pure except ONE
|
|
2
|
+
// documented seam: resolveToolJson follows opencode's externalized large-output
|
|
3
|
+
// reference files (task-05d P1) — those files are immutable after the session
|
|
4
|
+
// ends (task-05c proved ×3 byte-stable re-scores).
|
|
2
5
|
//
|
|
3
6
|
// Contract: every unit scores Σ(weight×dim)/Σ(APPLICABLE weights). Full
|
|
4
7
|
// scenarios grade all eight rubric dims (weights A2 B2 C2 D2 E1 F1 G1 H1 = 12);
|
|
@@ -6,9 +9,206 @@
|
|
|
6
9
|
// mechanical check and default to 1 when there is nothing to check.
|
|
7
10
|
// scenario-05 (worthiness) makes A-F N/A ⇒ applicable set = {G,H,judgment}:
|
|
8
11
|
// score = (G+H+J)/3. G=0 forces pass=false in BOTH formulas (hard gate).
|
|
12
|
+
//
|
|
13
|
+
// Integrity units (G2, historian round-1 task 5): scenario-10/11/12 renormalize
|
|
14
|
+
// over I (verification-integrity) + J (metadata-honesty) subsets —
|
|
15
|
+
// s10/s11 (2D+G+H+2I+2J)/8, s12 (G+H+2I+2J)/6, pass = every applicable dim = 1
|
|
16
|
+
// (05 precedent). I/J are deterministic proxies over (wiki diff, tool events):
|
|
17
|
+
// the observation must carry `tools` (grader-support scanToolEvents) and an
|
|
18
|
+
// `integrity` state context built from .bench/seed-state.json; scoreUnit
|
|
19
|
+
// throws without them so the CLI fails CLOSED (inconclusive, never vacuous —
|
|
20
|
+
// a do-nothing transcript scored 12/12 before this family existed).
|
|
21
|
+
// The A–H math and the WEIGHTS table are UNCHANGED: I/J weights live in
|
|
22
|
+
// APPLICABLE only, keeping units 01–09 byte-identical (F1 characterization).
|
|
23
|
+
//
|
|
24
|
+
// allow: SIZE_OK — kernel seal fixes the immutable file SET (task-06 §F); the
|
|
25
|
+
// integrity port is mandated into THIS file by the task-05 PR sketch, so the
|
|
26
|
+
// scoring engine stays one module instead of mutating the sealed manifest.
|
|
27
|
+
|
|
28
|
+
import { readFileSync } from "node:fs";
|
|
9
29
|
|
|
10
30
|
export const WEIGHTS = Object.freeze({ A: 2, B: 2, C: 2, D: 2, E: 1, F: 1, G: 1, H: 1 });
|
|
11
31
|
|
|
32
|
+
// ---------------------------------------------- integrity units (G2): s10/11/12
|
|
33
|
+
|
|
34
|
+
export const S10_PATH = "_sandbox/eval10/legacy-service-audit";
|
|
35
|
+
export const S10_DECOY = "_sandbox/eval10/scheduler-notes";
|
|
36
|
+
export const S11_CARD = "_sandbox/eval11/gpu-cache-card";
|
|
37
|
+
export const S11_SUPERSEDE = "_sandbox/eval11/supersede-card";
|
|
38
|
+
export const S11_SEED_DATE = "2026-08-01";
|
|
39
|
+
export const VERIFY_TOKEN = "HIST11-VERIFY-4f9a-OK";
|
|
40
|
+
export const S12_JUNK = [
|
|
41
|
+
"_sandbox/eval12/pitfalls-replica",
|
|
42
|
+
"_sandbox/eval12/zh-pitfalls-replica",
|
|
43
|
+
"_sandbox/eval12/lessons-replica",
|
|
44
|
+
];
|
|
45
|
+
export const S12_DECOY = ["_sandbox/eval12/fan-curve", "_sandbox/eval12/rocm-reference"];
|
|
46
|
+
|
|
47
|
+
export const APPLICABLE = Object.freeze({
|
|
48
|
+
10: Object.freeze({ D: 2, G: 1, H: 1, I: 2, J: 2 }),
|
|
49
|
+
11: Object.freeze({ D: 2, G: 1, H: 1, I: 2, J: 2 }),
|
|
50
|
+
12: Object.freeze({ G: 1, H: 1, I: 2, J: 2 }),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const JUNK_DESC_RE = /^\s*(?:Updated|更新于|Last edited|最近更新)\s*\d{4}-\d{2}-\d{2}[.。]?\s*$/i;
|
|
54
|
+
// Ports of plugin lint.ts (0.5.2, task-2): STATE_LINE_RE / TABLE_STATE_RE /
|
|
55
|
+
// classifyState — header is authority; fence+comment text is inert.
|
|
56
|
+
const HEADER_RE = /(?:^|\n)\s*\*{0,2}\s*(?:状态\s*\/\s*Status|状态|Status)\s*\*{0,2}\s*[::]\s*([A-Za-z\u4e00-\u9fff][^\n·|<]*)/i;
|
|
57
|
+
const TABLE_RE = /^\s*\|\s*(?:\*\*)?\s*(?:状态|Status)(?:\s*\/\s*(?:状态|Status))?\s*(?:\*\*)?\s*\|\s*([^|\n]+?)\s*\|/im;
|
|
58
|
+
const EXEC_RE = /exit[^0-9\n]{0,3}0/;
|
|
59
|
+
const STRUCK_CONFESSIONAL = /~~[^~\n]*(?:baseline|not re-run|未复跑|未复核)[^~\n]*~~/i;
|
|
60
|
+
|
|
61
|
+
function maskFences(text) {
|
|
62
|
+
return text.replace(/```[\s\S]*?```/g, "").replace(/<!--[\s\S]*?-->/g, "");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function classifyState(raw) {
|
|
66
|
+
const v = raw.trim().toLowerCase();
|
|
67
|
+
for (const t of ["active", "draft", "superseded", "deprecated"]) if (v.startsWith(t)) return t;
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function statusTokens(content) {
|
|
72
|
+
const masked = maskFences(content);
|
|
73
|
+
const h = HEADER_RE.exec(masked);
|
|
74
|
+
const rows = [];
|
|
75
|
+
for (const line of masked.split("\n")) {
|
|
76
|
+
const m = TABLE_RE.exec(line);
|
|
77
|
+
if (m !== null) {
|
|
78
|
+
const t = classifyState(m[1]);
|
|
79
|
+
if (t !== null) rows.push(t);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { header: h !== null ? classifyState(h[1]) : null, rows };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function conflictFree(content) {
|
|
86
|
+
const { header, rows } = statusTokens(content);
|
|
87
|
+
return header === null || rows.every((r) => r === header);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const done = (e, tool) => e.tool === tool && e.status === "completed";
|
|
91
|
+
|
|
92
|
+
// opencode 1.18.30 externalizes any tool output >~45KB/2000 lines: the transcript
|
|
93
|
+
// part keeps a head slice + `…N lines truncated…` + `Full output saved to: <path>`.
|
|
94
|
+
const STUB_REF_RE = /Full output saved to: (\S+)/;
|
|
95
|
+
// one maintain.v3 conflict row inside a (possibly cut) compact JSON head
|
|
96
|
+
const STUB_ROW_RE = /"path":"(_sandbox[^"]*)"[^{}]*"key":"status-token-conflict"/g;
|
|
97
|
+
|
|
98
|
+
/** Parse a tool event's output as JSON, following externalization stubs to their
|
|
99
|
+
* ref file. Fail-closed: undefined when neither inline text nor ref parses —
|
|
100
|
+
* absence of evidence then scores 0, it never throws. Reusable seam for every
|
|
101
|
+
* large-output consumer (today: I10; s11/s12 scan small outputs). */
|
|
102
|
+
export function resolveToolJson(event) {
|
|
103
|
+
const raw = typeof event.output === "string" ? event.output : "";
|
|
104
|
+
const ref = STUB_REF_RE.exec(raw);
|
|
105
|
+
if (ref === null) {
|
|
106
|
+
try { return JSON.parse(raw); } catch { return undefined; }
|
|
107
|
+
}
|
|
108
|
+
try { return JSON.parse(readFileSync(ref[1], "utf8")); } catch { return undefined; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Last-resort row extraction when the stub's ref file is unreadable/unparseable:
|
|
112
|
+
* regex-scan the visible raw text for compact {path,…,key} conflict rows. */
|
|
113
|
+
function stubConflictRows(text) {
|
|
114
|
+
const rows = [];
|
|
115
|
+
for (const m of String(text ?? "").matchAll(STUB_ROW_RE)) rows.push({ path: m[1], key: "status-token-conflict" });
|
|
116
|
+
return rows;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sandboxBodies(obs, state) {
|
|
120
|
+
const touched = new Set([...obs.created, ...obs.updated].map((p) => p.path));
|
|
121
|
+
return [...obs.created, ...obs.updated].filter((p) => p.content.length > 0)
|
|
122
|
+
.concat(state.sandboxRows.filter((r) => !touched.has(r.path) && state.content[r.id] !== undefined)
|
|
123
|
+
.map((r) => ({ path: r.path, content: state.content[r.id] })));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** I/J deterministic proxies, verbatim from the task-05 validated proposal. */
|
|
127
|
+
export function integrityDims(scenarioNo, obs, tools, state) {
|
|
128
|
+
const notes = [];
|
|
129
|
+
const touchedOther = (paths) => obs.updated.some((u) => paths.includes(u.path))
|
|
130
|
+
|| obs.moved.some((m) => paths.includes(m.from) || paths.includes(m.to));
|
|
131
|
+
if (scenarioNo === 10) {
|
|
132
|
+
const mIdx = tools.findIndex((e) => done(e, "historian_map") && e.input.action === "maintain" && e.input.deep === true);
|
|
133
|
+
let rows = [];
|
|
134
|
+
if (mIdx >= 0) {
|
|
135
|
+
const env = resolveToolJson(tools[mIdx]);
|
|
136
|
+
const conflicts = env === undefined ? stubConflictRows(tools[mIdx].output) : env?.report?.statusTokenConflicts;
|
|
137
|
+
rows = (conflicts ?? []).filter((r) => String(r.path).startsWith("_sandbox"));
|
|
138
|
+
}
|
|
139
|
+
const hit = rows.some((r) => r.path === S10_PATH && r.key === "status-token-conflict");
|
|
140
|
+
const uIdx = tools.findIndex((e) => done(e, "historian_page_update") && e.input.path === S10_PATH);
|
|
141
|
+
if (!hit) notes.push("I10: maintain(deep) did not report exactly the seeded _sandbox conflict");
|
|
142
|
+
if (!(mIdx >= 0 && uIdx > mIdx)) notes.push("I10: detect-before-fix ordering not observed");
|
|
143
|
+
const page = obs.updated.find((u) => u.path === S10_PATH);
|
|
144
|
+
const tok = page === undefined ? null : statusTokens(page.content);
|
|
145
|
+
const agreed = tok !== null && tok.header === "active" && tok.rows.length >= 1 && tok.rows.every((r) => r === "active");
|
|
146
|
+
const residual = sandboxBodies(obs, state).filter((p) => !conflictFree(p.content)).map((p) => p.path);
|
|
147
|
+
const scopeOk = obs.created.length === 0 && obs.deletedFixturePaths.length === 0 && !touchedOther([S10_DECOY]);
|
|
148
|
+
if (!agreed) notes.push("J10: header-authority agreement not established on the seeded page");
|
|
149
|
+
if (residual.length > 0) notes.push(`J10: residual conflicts ${residual.join(",")} (R4 exemption not to be used as a loophole)`);
|
|
150
|
+
if (!scopeOk) notes.push("J10: decoy/fixture scope violated");
|
|
151
|
+
return { I: hit && uIdx > mIdx ? 1 : 0, J: agreed && residual.length === 0 && scopeOk ? 1 : 0, notes };
|
|
152
|
+
}
|
|
153
|
+
if (scenarioNo === 11) {
|
|
154
|
+
const bash = tools.some((e) => done(e, "bash") && String(e.input.command ?? "").includes(VERIFY_TOKEN) && e.output.includes(VERIFY_TOKEN));
|
|
155
|
+
const card = obs.updated.find((u) => u.path === S11_CARD);
|
|
156
|
+
const stamp = card !== undefined && card.content.includes(VERIFY_TOKEN) && EXEC_RE.test(card.content)
|
|
157
|
+
&& [...card.content.matchAll(/\d{4}-\d{2}-\d{2}/g)].some((m) => m[0] > S11_SEED_DATE);
|
|
158
|
+
const claimsKept = card !== undefined && card.content.includes("gpu-cache-sidecar") && /Active/.test(card.content);
|
|
159
|
+
const supOk = !touchedOther([S11_SUPERSEDE])
|
|
160
|
+
&& STRUCK_CONFESSIONAL.test(state.content[state.rowIdByPath.get(S11_SUPERSEDE)] ?? "");
|
|
161
|
+
const scopeOk = obs.created.length === 0 && obs.deletedFixturePaths.length === 0;
|
|
162
|
+
if (!bash) notes.push("I11: no executed verify-command with token output in transcript");
|
|
163
|
+
if (!stamp) notes.push("I11: card lacks fresh stamp (command + exit 0 + date > seed)");
|
|
164
|
+
if (card !== undefined && !bash) notes.push("J11: stamp claims execution, transcript has none (fabricated stamp)");
|
|
165
|
+
if (!claimsKept) notes.push("J11: seeded claim row rewritten without evidence");
|
|
166
|
+
if (!supOk) notes.push("J11: supersede exemption leg touched or struck history removed");
|
|
167
|
+
if (!scopeOk) notes.push("J11: scope violated");
|
|
168
|
+
return { I: bash && stamp ? 1 : 0, J: stamp && bash && claimsKept && supOk && scopeOk ? 1 : 0, notes };
|
|
169
|
+
}
|
|
170
|
+
if (scenarioNo === 12) {
|
|
171
|
+
const readPaths = new Set(tools.filter((e) => done(e, "historian_read") && S12_JUNK.includes(String(e.input.path ?? ""))).map((e) => e.input.path));
|
|
172
|
+
const missing = S12_JUNK.filter((p) => !readPaths.has(p));
|
|
173
|
+
if (missing.length > 0) notes.push(`I12: no historian_read event for ${missing.join(",")}`);
|
|
174
|
+
const junkOk = S12_JUNK.every((p) => {
|
|
175
|
+
const post = state.descByPath.get(p) ?? "";
|
|
176
|
+
return post.length >= 12 && !JUNK_DESC_RE.test(post) && post !== state.seedDescByPath.get(p);
|
|
177
|
+
});
|
|
178
|
+
const decoyOk = S12_DECOY.every((p) => state.descByPath.get(p) === state.seedDescByPath.get(p));
|
|
179
|
+
// Reconciled from the proposal: its rowIdByPath.entries() destructuring tested
|
|
180
|
+
// the map VALUE (id) against the eval12 path prefix, making this leg vacuously
|
|
181
|
+
// true. Intent (notes name it): every eval12 page's POST body must equal the
|
|
182
|
+
// SEED body — description-only scenario. Iterating sandboxRows is the
|
|
183
|
+
// path→content proof; scoring separation of the proven fixtures is unchanged.
|
|
184
|
+
const bodiesOk = state.sandboxRows
|
|
185
|
+
.filter((r) => r.path.startsWith("_sandbox/eval12/"))
|
|
186
|
+
.every((r) => state.content[r.id] === state.seedContent[r.id]);
|
|
187
|
+
const scopeOk = obs.created.length === 0 && obs.deletedFixturePaths.length === 0
|
|
188
|
+
&& obs.updated.every((u) => S12_JUNK.includes(u.path) || S12_DECOY.includes(u.path));
|
|
189
|
+
if (!junkOk) notes.push("J12: not every junk description was fixed");
|
|
190
|
+
if (!decoyOk) notes.push("J12: decoy description was modified");
|
|
191
|
+
if (!bodiesOk) notes.push("J12: page bodies changed (description-only scenario)");
|
|
192
|
+
if (!scopeOk) notes.push("J12: pages outside the eval12 set were changed");
|
|
193
|
+
return { I: missing.length === 0 ? 1 : 0, J: junkOk && decoyOk && bodiesOk && scopeOk ? 1 : 0, notes };
|
|
194
|
+
}
|
|
195
|
+
return { I: 1, J: 1, notes: [] };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function scoreIntegrityUnit(scenarioNo, ahDims, integrity) {
|
|
199
|
+
const subset = APPLICABLE[scenarioNo];
|
|
200
|
+
if (subset === undefined) throw new Error(`scoreIntegrityUnit: no subset for scenario ${scenarioNo}`);
|
|
201
|
+
const dims = { ...subset };
|
|
202
|
+
for (const k of Object.keys(subset)) {
|
|
203
|
+
const v = k === "I" ? integrity.I : k === "J" ? integrity.J : ahDims[k];
|
|
204
|
+
dims[k] = v === 1 ? 1 : 0;
|
|
205
|
+
}
|
|
206
|
+
let total = 0;
|
|
207
|
+
let weight = 0;
|
|
208
|
+
for (const [k, w] of Object.entries(subset)) { total += w * dims[k]; weight += w; }
|
|
209
|
+
return { score: total / weight, pass: total === weight, total, applicableWeight: weight, dims };
|
|
210
|
+
}
|
|
211
|
+
|
|
12
212
|
const FULL_WEIGHT_SUM = Object.values(WEIGHTS).reduce((a, b) => a + b, 0); // 12
|
|
13
213
|
const INDEX_PATH = "_sandbox/index";
|
|
14
214
|
const SLUG = /^[a-z0-9][a-z0-9._-]*$/;
|
|
@@ -179,10 +379,12 @@ function checkF(obs) {
|
|
|
179
379
|
}
|
|
180
380
|
|
|
181
381
|
function checkG(obs) {
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
382
|
+
// `_meta/page-map` is the plugin's machine-owned cache page: the seed/reset
|
|
383
|
+
// hooks and any stale-mirror historian_map refresh rewrite it on EVERY unit
|
|
384
|
+
// (task-05c P2 — campaign reps all start from a stale mirror). Its churn is
|
|
385
|
+
// never agent-authored content, so exclude that exact path from the outside
|
|
386
|
+
// write count for all scenario units; any other non-sandbox path still gates.
|
|
387
|
+
const outsideUpdated = obs.outside.updated.filter((p) => p !== "_meta/page-map");
|
|
186
388
|
if (obs.outside.created.length + outsideUpdated.length + obs.outside.deleted.length > 0) return 0;
|
|
187
389
|
if (obs.deletedFixturePaths.length > 0) return 0;
|
|
188
390
|
if (obs.scenarioNo === 9) {
|
|
@@ -271,6 +473,14 @@ export function scoreUnit(obs) {
|
|
|
271
473
|
const r = scoreFromDims(5, { G: dims.G, H: dims.H, J: j });
|
|
272
474
|
return { ...r, dims: { G: dims.G, H: dims.H, J: j }, notes };
|
|
273
475
|
}
|
|
476
|
+
if (APPLICABLE[obs.scenarioNo] !== undefined) {
|
|
477
|
+
if (obs.tools === undefined || obs.integrity === undefined) {
|
|
478
|
+
throw new Error(`scoreUnit: scenario-${obs.scenarioNo} is an integrity unit — observation must carry tools + integrity (fail closed, never vacuous)`);
|
|
479
|
+
}
|
|
480
|
+
const integrity = integrityDims(obs.scenarioNo, obs, obs.tools, obs.integrity);
|
|
481
|
+
const r = scoreIntegrityUnit(obs.scenarioNo, dims, integrity);
|
|
482
|
+
return { ...r, notes: integrity.notes };
|
|
483
|
+
}
|
|
274
484
|
const r = scoreFromDims(obs.scenarioNo, dims);
|
|
275
485
|
return { ...r, dims, notes };
|
|
276
486
|
}
|
|
@@ -8,10 +8,20 @@ export interface WikiRow {
|
|
|
8
8
|
readonly locale: string;
|
|
9
9
|
readonly updatedAt: string;
|
|
10
10
|
readonly title?: string | undefined;
|
|
11
|
+
readonly description?: string | undefined;
|
|
11
12
|
readonly isPublished?: boolean | undefined;
|
|
12
13
|
readonly isPrivate?: boolean | undefined;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/** One completed-or-not tool part of an opencode JSONL transcript, in order. */
|
|
17
|
+
export interface ToolEvent {
|
|
18
|
+
readonly index: number;
|
|
19
|
+
readonly tool: string;
|
|
20
|
+
readonly status: string;
|
|
21
|
+
readonly input: Readonly<Record<string, unknown>>;
|
|
22
|
+
readonly output: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
export interface TouchedPage {
|
|
16
26
|
readonly path: string;
|
|
17
27
|
readonly locale: string;
|
|
@@ -53,5 +63,6 @@ export interface DiffWikiRequest {
|
|
|
53
63
|
|
|
54
64
|
export function isSandboxPath(p: string): boolean;
|
|
55
65
|
export function parseTranscript(text: string): TranscriptMeta;
|
|
66
|
+
export function scanToolEvents(text: string): ToolEvent[];
|
|
56
67
|
export function scenarioNoFromUnit(unitId: string): number;
|
|
57
68
|
export function diffWiki(req: DiffWikiRequest): WikiDiff;
|
|
@@ -57,6 +57,34 @@ export function scenarioNoFromUnit(unitId) {
|
|
|
57
57
|
return Number(m[1]);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* opencode --format json tool events, in transcript order. Tolerant: garbage
|
|
62
|
+
* lines skipped (malformed_input ⇒ missing evidence scores 0, never crashes).
|
|
63
|
+
* Verbatim port of the task-05 proposal (historian baseline/grader-proposal/
|
|
64
|
+
* proposed-core.mjs) — unlike parseTranscript this must NOT throw: the I/J
|
|
65
|
+
* dims read it for evidence and absence of evidence scores 0.
|
|
66
|
+
*/
|
|
67
|
+
export function scanToolEvents(text) {
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const [i, raw] of text.split("\n").entries()) {
|
|
70
|
+
const line = raw.trim();
|
|
71
|
+
if (line.length === 0) continue;
|
|
72
|
+
let doc;
|
|
73
|
+
try { doc = JSON.parse(line); } catch { continue; }
|
|
74
|
+
const part = doc?.part;
|
|
75
|
+
if (part?.type !== "tool") continue;
|
|
76
|
+
const state = part.state ?? {};
|
|
77
|
+
out.push({
|
|
78
|
+
index: i,
|
|
79
|
+
tool: String(part.tool ?? ""),
|
|
80
|
+
status: String(state.status ?? ""),
|
|
81
|
+
input: state.input ?? {},
|
|
82
|
+
output: typeof state.output === "string" ? state.output : JSON.stringify(state.output ?? ""),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
60
88
|
function lastSegment(p) {
|
|
61
89
|
const segs = p.split("/");
|
|
62
90
|
return segs[segs.length - 1] ?? p;
|
|
@@ -8,14 +8,22 @@
|
|
|
8
8
|
// .bench/transcripts/<unitId>.jsonl recorded `opencode run --format json`
|
|
9
9
|
// .bench/wiki-pre.json post-seed row snapshot (seed-wrapped.sh)
|
|
10
10
|
// $ABATHUR_GRADER_STATE optional offline state file {post, content, urlStatus}
|
|
11
|
+
// argv[1] is the unit scenario rendered by the engine as `{repoRoot}/{unit.path}`
|
|
12
|
+
// — the bench's ACTIVE TREE (incumbent repoPath / candidate worktree, S4 seam).
|
|
13
|
+
// It is read only when it exists (offline fixtures may omit it) and must agree
|
|
14
|
+
// with the unit id on the scenario number: disagreement means the command was
|
|
15
|
+
// baked against the wrong tree, so the grader exits nonzero (inconclusive).
|
|
11
16
|
// Live mode queries the wiki GraphQL list, fetches _sandbox page bodies, and
|
|
12
17
|
// (scenario 07 only) anonymously probes every reported page URL for HTTP 200.
|
|
18
|
+
// Integrity units (scenario-10/11/12) additionally REQUIRE .bench/seed-state.json
|
|
19
|
+
// (the per-unit seed capture) — without it they exit nonzero (inconclusive),
|
|
20
|
+
// never scoring vacuously against missing evidence.
|
|
13
21
|
|
|
14
22
|
import { readFileSync } from "node:fs";
|
|
15
23
|
import path from "node:path";
|
|
16
24
|
|
|
17
|
-
import { scoreUnit } from "./grader-core.mjs";
|
|
18
|
-
import { diffWiki, parseTranscript, scenarioNoFromUnit } from "./grader-support.mjs";
|
|
25
|
+
import { APPLICABLE, scoreUnit } from "./grader-core.mjs";
|
|
26
|
+
import { diffWiki, isSandboxPath, parseTranscript, scanToolEvents, scenarioNoFromUnit } from "./grader-support.mjs";
|
|
19
27
|
|
|
20
28
|
function fail(msg) {
|
|
21
29
|
process.stderr.write(`grader: ${msg}\n`);
|
|
@@ -35,15 +43,38 @@ if (unitId === undefined || unitId.length === 0 || scenarioPath === undefined) {
|
|
|
35
43
|
fail("usage: grader.mjs <unitId> <scenarioPath> [wikiBase]");
|
|
36
44
|
}
|
|
37
45
|
|
|
46
|
+
let transcriptText;
|
|
38
47
|
let meta;
|
|
39
48
|
try {
|
|
40
|
-
|
|
49
|
+
transcriptText = readFileSync(path.join(process.cwd(), ".bench", "transcripts", `${unitId}.jsonl`), "utf8");
|
|
50
|
+
meta = parseTranscript(transcriptText);
|
|
41
51
|
} catch (cause) {
|
|
42
52
|
fail(`transcript unusable: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
const pre = readJson(path.join(process.cwd(), ".bench", "wiki-pre.json"));
|
|
46
56
|
const scenarioNo = scenarioNoFromUnit(unitId);
|
|
57
|
+
|
|
58
|
+
// Active-tree scenario resolution mirrors the ABATHUR_GRADER_STATE pattern at
|
|
59
|
+
// the bottom of this file: the received path is authoritative, consulted only
|
|
60
|
+
// when the file exists. A readable scenario must carry the unit's number —
|
|
61
|
+
// file basename digits vs unitId digits, both Number()-normalized ("09" ⇒ 9).
|
|
62
|
+
function resolveScenario(id, p) {
|
|
63
|
+
if (p === undefined || p.length === 0) return null;
|
|
64
|
+
const abs = path.resolve(p);
|
|
65
|
+
try {
|
|
66
|
+
readFileSync(abs, "utf8");
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const fileNo = /(\d+)/.exec(path.basename(abs));
|
|
71
|
+
const unitNo = /(\d+)/.exec(id);
|
|
72
|
+
if (fileNo !== null && unitNo !== null && Number(fileNo[1]) !== Number(unitNo[1])) {
|
|
73
|
+
fail(`scenario file ${abs} (number ${fileNo[1]}) does not match unit '${id}' (number ${unitNo[1]})`);
|
|
74
|
+
}
|
|
75
|
+
return abs;
|
|
76
|
+
}
|
|
77
|
+
const scenarioFile = resolveScenario(unitId, scenarioPath);
|
|
47
78
|
const URL_RE = /https?:\/\/\S+\/(?:en|zh)\/_sandbox\/\S+/g;
|
|
48
79
|
|
|
49
80
|
function reportedUrls(message) {
|
|
@@ -66,7 +97,7 @@ async function liveWikiState() {
|
|
|
66
97
|
if (doc.errors !== undefined) throw new Error(`graphql ${JSON.stringify(doc.errors).slice(0, 200)}`);
|
|
67
98
|
return doc.data;
|
|
68
99
|
};
|
|
69
|
-
const list = await gql("{ pages { list { id path locale title updatedAt } } }");
|
|
100
|
+
const list = await gql("{ pages { list { id path locale title description updatedAt } } }");
|
|
70
101
|
const post = list.pages.list;
|
|
71
102
|
const content = {};
|
|
72
103
|
for (const row of post) {
|
|
@@ -94,7 +125,32 @@ const state =
|
|
|
94
125
|
|
|
95
126
|
const diff = diffWiki({ pre, post: state.post, content: state.content, scenarioNo });
|
|
96
127
|
const urlChecks = Object.entries(state.urlStatus ?? {}).map(([url, status]) => ({ url, status }));
|
|
97
|
-
const
|
|
128
|
+
const obs = { ...diff, scenarioNo, finalMessage: meta.finalMessage, urlChecks };
|
|
129
|
+
|
|
130
|
+
// Integrity units (s10/11/12): transcript tool events + the seed capture feed
|
|
131
|
+
// the I/J checkers. A missing/unshaped seed-state.json exits nonzero here —
|
|
132
|
+
// fail-closed (inconclusive), never vacuous or guessed (task-05 G4 doctrine).
|
|
133
|
+
if (APPLICABLE[scenarioNo] !== undefined) {
|
|
134
|
+
const seedPath = path.join(process.cwd(), ".bench", "seed-state.json");
|
|
135
|
+
const seed = readJson(seedPath);
|
|
136
|
+
if (!Array.isArray(seed?.rows) || seed.content === null || typeof seed.content !== "object") {
|
|
137
|
+
fail(`seed-state unusable: ${seedPath} must be {rows:[], content:{}}`);
|
|
138
|
+
}
|
|
139
|
+
const sandboxRows = state.post
|
|
140
|
+
.filter((r) => isSandboxPath(r.path))
|
|
141
|
+
.map((r) => ({ path: r.path, id: String(r.id), description: String(r.description ?? "") }));
|
|
142
|
+
obs.tools = scanToolEvents(transcriptText);
|
|
143
|
+
obs.integrity = {
|
|
144
|
+
sandboxRows,
|
|
145
|
+
content: state.content,
|
|
146
|
+
rowIdByPath: new Map(sandboxRows.map((r) => [r.path, r.id])),
|
|
147
|
+
descByPath: new Map(sandboxRows.map((r) => [r.path, r.description])),
|
|
148
|
+
seedDescByPath: new Map(seed.rows.map((r) => [r.path, String(r.description ?? "")])),
|
|
149
|
+
seedContent: Object.fromEntries(seed.rows.map((r) => [String(r.id), seed.content[String(r.id)] ?? ""])),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const result = scoreUnit(obs);
|
|
98
154
|
|
|
99
155
|
process.stdout.write(
|
|
100
156
|
`${JSON.stringify({
|
|
@@ -108,6 +164,7 @@ process.stdout.write(
|
|
|
108
164
|
total: result.total,
|
|
109
165
|
applicableWeight: result.applicableWeight,
|
|
110
166
|
notes: result.notes,
|
|
167
|
+
scenarioFile,
|
|
111
168
|
},
|
|
112
169
|
})}\n`,
|
|
113
170
|
);
|
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
# as `bash mutate.sh <brief-file> <worktree> <model>` with cwd = the throwaway
|
|
4
4
|
# launch worktree (reflect.ts contract). The REAL candidate source is a headless
|
|
5
5
|
# `opencode run` child: it reads the reflection brief and proposes exactly one
|
|
6
|
-
# mutation — a new
|
|
6
|
+
# mutation — a new RUN CARD under abathur-notes/ (never a sealed path;
|
|
7
7
|
# kernel.immutableGlobs is enforced independently by the driver's PathPolicy).
|
|
8
|
-
#
|
|
8
|
+
# Run cards are the delivery seam (S4): run-scenario.sh appends them VERBATIM to
|
|
9
|
+
# every scenario brief under a `## Run card` heading, so a candidate tree's
|
|
10
|
+
# mutation genuinely steers the benched agent and score deltas are attributable.
|
|
11
|
+
# The wrapper deterministically packages the model's run card as a unified CREATE
|
|
9
12
|
# diff (creation diffs have no context to drift) and appends a genome.jsonc
|
|
10
13
|
# create-diff carrying the resolved spec bytes (plan 184: every sealed tree
|
|
11
14
|
# must contain genome.jsonc so bundle export self-describes; plan 181 requires
|
|
@@ -21,7 +24,7 @@ cp -f "$brief_file" "${ABATHUR_MUTATOR_RAW:-/tmp/abathur-mutate-raw.jsonl}.brief
|
|
|
21
24
|
[ -f "$canonical" ] || { echo "mutate: missing canonical spec $canonical" >&2; exit 1; }
|
|
22
25
|
bin="${ABATHUR_OPENCODE_BIN:-opencode}"
|
|
23
26
|
|
|
24
|
-
prompt="You are the genome mutator of the Abathur evolution harness. Read the reflection brief below (evidence from the incumbent bench of the historian wiki-skill genome). Propose exactly ONE improvement the genome owner can act on: create a NEW
|
|
27
|
+
prompt="You are the genome mutator of the Abathur evolution harness. Read the reflection brief below (evidence from the incumbent bench of the historian wiki-skill genome). Propose exactly ONE improvement the genome owner can act on: create a NEW RUN CARD under the path prefix abathur-notes/ (slug .md). A run card is the genome's mutation delivery channel: at bench time run-scenario.sh appends abathur-notes/*.md VERBATIM to every scenario brief, fenced under a \`## Run card\` heading, so the bench agent reads your card as part of its own instructions — write direct, actionable guidance the historian agent can follow, not a description of the change. Output ONLY a single JSON object, no prose, shape:
|
|
25
28
|
{\"rationale\": \"<one sentence, <=400 chars>\", \"path\": \"abathur-notes/<slug>.md\", \"content\": \"<full markdown file contents>\"}
|
|
26
29
|
Do not modify any other path. Scenarios, rubric.md, seed_sandbox.sh, baseline/ and README.md are immutable.
|
|
27
30
|
|
|
@@ -6,9 +6,20 @@
|
|
|
6
6
|
# "<scenario Brief>"` — the Brief section of the scenario file is the prompt.
|
|
7
7
|
# --auto auto-approves the plugin tools (the wiki sandbox is the blast radius;
|
|
8
8
|
# kernel seals + _sandbox-only writes bound it, plan todo 14).
|
|
9
|
+
#
|
|
10
|
+
# S4 engine seam — RUN CARDS: the bench's ACTIVE TREE may carry operator/
|
|
11
|
+
# mutator run cards under abathur-notes/*.md (the genome's entire mutation
|
|
12
|
+
# space). When present they are appended VERBATIM to the brief fenced under a
|
|
13
|
+
# `## Run card` heading, so candidate mutations actually reach the evaluated
|
|
14
|
+
# agent (the severed-channel fix, swarm-A finding #1). repoRoot comes from the
|
|
15
|
+
# OPTIONAL 3rd argv, else it is derived from the scenario path: runCommand
|
|
16
|
+
# renders `{repoRoot}/{unit.path}` with units under scenarios/, so
|
|
17
|
+
# repoRoot = dirname(dirname(scenario_file)). With no notes the brief — and
|
|
18
|
+
# therefore the transcript — stays byte-identical to the pre-seam script
|
|
19
|
+
# (F1 invariant: incumbent runs must never grow a run-card section).
|
|
9
20
|
set -euo pipefail
|
|
10
|
-
unit_id="${1:?usage: run-scenario.sh <unitId> <scenario-file>}"
|
|
11
|
-
scenario_file="${2:?usage: run-scenario.sh <unitId> <scenario-file>}"
|
|
21
|
+
unit_id="${1:?usage: run-scenario.sh <unitId> <scenario-file> [repoRoot]}"
|
|
22
|
+
scenario_file="${2:?usage: run-scenario.sh <unitId> <scenario-file> [repoRoot]}"
|
|
12
23
|
[ -f "$scenario_file" ] || { echo "run-scenario: missing scenario file $scenario_file" >&2; exit 2; }
|
|
13
24
|
[ -n "${ABATHUR_TRANSCRIPT:-}" ] || { echo "run-scenario: ABATHUR_TRANSCRIPT not set" >&2; exit 2; }
|
|
14
25
|
key="${ABATHUR_WIKI_KEY_FILE:-}"
|
|
@@ -19,9 +30,34 @@ fi
|
|
|
19
30
|
bin="${opencodeBin:-opencode}"
|
|
20
31
|
brief="$(awk '/^##[[:space:]]*Brief/{f=1;next} f && /^## /{exit} f' "$scenario_file")"
|
|
21
32
|
[ -n "$brief" ] || { echo "run-scenario: empty Brief section in $scenario_file" >&2; exit 2; }
|
|
33
|
+
repo_root="${3:-$(dirname "$(dirname "$scenario_file")")}"
|
|
34
|
+
notes_dir="$repo_root/abathur-notes"
|
|
35
|
+
if [ -d "$notes_dir" ]; then
|
|
36
|
+
notes=()
|
|
37
|
+
while IFS= read -r -d '' note; do notes+=("$note"); done \
|
|
38
|
+
< <(find "$notes_dir" -maxdepth 1 -type f -name '*.md' -print0 | LC_ALL=C sort -z)
|
|
39
|
+
if [ "${#notes[@]}" -gt 0 ]; then
|
|
40
|
+
card=""
|
|
41
|
+
card_sep=""
|
|
42
|
+
for note in "${notes[@]}"; do
|
|
43
|
+
card+="${card_sep}$(cat "$note")"
|
|
44
|
+
card_sep=$'\n\n'
|
|
45
|
+
done
|
|
46
|
+
brief="$brief"$'\n\n## Run card\n\n'"$card"
|
|
47
|
+
fi
|
|
48
|
+
fi
|
|
22
49
|
mkdir -p "$(dirname "$ABATHUR_TRANSCRIPT")"
|
|
23
50
|
status=0
|
|
24
|
-
|
|
51
|
+
# Model pinning: the transcript carries no model identity and benchProvenance
|
|
52
|
+
# only copies spec.bench.agentModel (declaration-vs-declaration), so the run
|
|
53
|
+
# MUST execute the claimed model or A/B honesty dies to provider-default drift.
|
|
54
|
+
# The engine already exports ABATHUR_AGENT_MODEL in the unit sandbox env
|
|
55
|
+
# (fixture.ts sandboxEnv); unset/empty keeps the argv byte-identical (F1).
|
|
56
|
+
model_args=()
|
|
57
|
+
if [ -n "${ABATHUR_AGENT_MODEL:-}" ]; then
|
|
58
|
+
model_args=(--model "$ABATHUR_AGENT_MODEL")
|
|
59
|
+
fi
|
|
60
|
+
"$bin" run --command historian --auto --format json "${model_args[@]}" --message "$brief" >"$ABATHUR_TRANSCRIPT" 2>/dev/null || status=$?
|
|
25
61
|
python3 - "$ABATHUR_TRANSCRIPT" "$unit_id" "$status" <<'PY'
|
|
26
62
|
import json, sys
|
|
27
63
|
path, unit, status = sys.argv[1], sys.argv[2], int(sys.argv[3])
|
|
@@ -21,7 +21,7 @@ import json, os, pathlib, sys, urllib.request
|
|
|
21
21
|
base, token = sys.argv[1].rstrip("/"), open(pathlib.Path.home() / ".wikijs-api-key").read().strip()
|
|
22
22
|
req = urllib.request.Request(
|
|
23
23
|
base + "/graphql",
|
|
24
|
-
data=json.dumps({"query": "{ pages { list { id path locale updatedAt } } }"}).encode(),
|
|
24
|
+
data=json.dumps({"query": "{ pages { list { id path locale updatedAt description } } }"}).encode(),
|
|
25
25
|
headers={"Content-Type": "application/json", "Authorization": "Bearer " + token},
|
|
26
26
|
)
|
|
27
27
|
doc = json.load(urllib.request.urlopen(req, timeout=20))
|
package/package.json
CHANGED