@tachikomagundam/abathur 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.
Files changed (119) hide show
  1. package/.github/workflows/ci.yml +29 -0
  2. package/.github/workflows/publish.yml +74 -0
  3. package/LICENSE +21 -0
  4. package/README.md +461 -0
  5. package/config/abathur.jsonc +17 -0
  6. package/config/genomes/historian.example.jsonc +124 -0
  7. package/dist/bench/adapter.js +201 -0
  8. package/dist/bench/fixture-probe.js +92 -0
  9. package/dist/bench/fixture-support.js +173 -0
  10. package/dist/bench/fixture.js +236 -0
  11. package/dist/bench/toy.js +152 -0
  12. package/dist/cli.js +110 -0
  13. package/dist/commands/bundle.js +79 -0
  14. package/dist/commands/genome.js +94 -0
  15. package/dist/commands/graft.js +71 -0
  16. package/dist/commands/kernel.js +47 -0
  17. package/dist/commands/promote.js +25 -0
  18. package/dist/commands/run.js +145 -0
  19. package/dist/commands/self-eval.js +240 -0
  20. package/dist/commands/status.js +186 -0
  21. package/dist/commands/tombstone.js +72 -0
  22. package/dist/config.js +161 -0
  23. package/dist/core/bundle-common.js +119 -0
  24. package/dist/core/bundle-export.js +212 -0
  25. package/dist/core/bundle-inspect.js +143 -0
  26. package/dist/core/bundle-manifest.js +105 -0
  27. package/dist/core/bundle-mask.js +75 -0
  28. package/dist/core/bundle-tar.js +240 -0
  29. package/dist/core/bundle.js +9 -0
  30. package/dist/core/evolve/brief.js +45 -0
  31. package/dist/core/evolve/candidate.js +140 -0
  32. package/dist/core/evolve/child-track.js +197 -0
  33. package/dist/core/evolve/friction.js +150 -0
  34. package/dist/core/evolve/reflect.js +191 -0
  35. package/dist/core/evolve/run-bench.js +170 -0
  36. package/dist/core/evolve/run-friction.js +63 -0
  37. package/dist/core/evolve/run-loop.js +282 -0
  38. package/dist/core/evolve/run-plan.js +39 -0
  39. package/dist/core/evolve/run-rows.js +145 -0
  40. package/dist/core/evolve/self-overlay.js +213 -0
  41. package/dist/core/evolve/self-snapshot.js +170 -0
  42. package/dist/core/evolve/stub-mutators.mjs +105 -0
  43. package/dist/core/evolve/udiff.js +189 -0
  44. package/dist/core/genome-paths.js +76 -0
  45. package/dist/core/genome.js +176 -0
  46. package/dist/core/glob.js +106 -0
  47. package/dist/core/graft-gates.js +184 -0
  48. package/dist/core/graft-rebench.js +187 -0
  49. package/dist/core/graft-support.js +181 -0
  50. package/dist/core/graft.js +218 -0
  51. package/dist/core/ids.js +154 -0
  52. package/dist/core/incumbent.js +46 -0
  53. package/dist/core/kernel.js +112 -0
  54. package/dist/core/ledger.js +198 -0
  55. package/dist/core/locks.js +172 -0
  56. package/dist/core/promote.js +119 -0
  57. package/dist/core/snapshot.js +61 -0
  58. package/dist/core/spec.js +178 -0
  59. package/dist/core/stats-math.js +102 -0
  60. package/dist/core/stats-pareto.js +57 -0
  61. package/dist/core/stats.js +184 -0
  62. package/dist/core/worktree.js +190 -0
  63. package/dist/exit.js +32 -0
  64. package/dist/genomes/toy-smoke/genome.jsonc +30 -0
  65. package/dist/genomes/toy-smoke/grader.mjs +61 -0
  66. package/dist/genomes/toy-smoke/init.mjs +63 -0
  67. package/dist/genomes/toy-smoke/units/add.mjs +17 -0
  68. package/dist/genomes/toy-smoke/units/explode.mjs +4 -0
  69. package/dist/genomes/toy-smoke/units/hang.mjs +16 -0
  70. package/dist/genomes/toy-smoke/units/mul.mjs +16 -0
  71. package/dist/genomes/toy-smoke/units/mutate.mjs +18 -0
  72. package/dist/genomes/toy-smoke/units/sub.mjs +16 -0
  73. package/dist/jsonc.js +77 -0
  74. package/dist/out.js +5 -0
  75. package/dist/test/bench-adapter.test.js +33 -0
  76. package/dist/test/bench-fixture.test.js +407 -0
  77. package/dist/test/bench-toy.test.js +251 -0
  78. package/dist/test/bundle.test.js +659 -0
  79. package/dist/test/config.test.js +185 -0
  80. package/dist/test/d7-gate.test.js +56 -0
  81. package/dist/test/fixture-loop.test.js +267 -0
  82. package/dist/test/fixtures/friction-writer.js +16 -0
  83. package/dist/test/fixtures-historian.js +82 -0
  84. package/dist/test/fixtures-self.js +143 -0
  85. package/dist/test/fixtures-wt.js +64 -0
  86. package/dist/test/friction.test.js +398 -0
  87. package/dist/test/genome.test.js +453 -0
  88. package/dist/test/git.test.js +69 -0
  89. package/dist/test/graft.test.js +567 -0
  90. package/dist/test/historian-genome.test.js +134 -0
  91. package/dist/test/historian-grader-io.test.js +148 -0
  92. package/dist/test/historian-grader.test.js +209 -0
  93. package/dist/test/ids.test.js +116 -0
  94. package/dist/test/include-val.test.js +120 -0
  95. package/dist/test/ledger-lock.test.js +99 -0
  96. package/dist/test/ledger.test.js +102 -0
  97. package/dist/test/promote.test.js +394 -0
  98. package/dist/test/reflect.test.js +410 -0
  99. package/dist/test/run-loop.test.js +433 -0
  100. package/dist/test/self-snapshot.test.js +328 -0
  101. package/dist/test/snapshot.test.js +86 -0
  102. package/dist/test/stats.test.js +423 -0
  103. package/dist/test/stub-mutators.test.js +17 -0
  104. package/dist/test/testutil.js +30 -0
  105. package/dist/test/worktree.test.js +198 -0
  106. package/dist/util/freeze.js +30 -0
  107. package/dist/util/git.js +85 -0
  108. package/docs/federation.md +184 -0
  109. package/docs/immutable-kernel.md +87 -0
  110. package/graders/historian/grader-core.d.mts +53 -0
  111. package/graders/historian/grader-core.mjs +276 -0
  112. package/graders/historian/grader-support.d.mts +57 -0
  113. package/graders/historian/grader-support.mjs +137 -0
  114. package/graders/historian/grader.mjs +113 -0
  115. package/graders/historian/mutate.sh +114 -0
  116. package/graders/historian/reset-sandbox.sh +60 -0
  117. package/graders/historian/run-scenario.sh +49 -0
  118. package/graders/historian/seed-wrapped.sh +32 -0
  119. package/package.json +42 -0
@@ -0,0 +1,276 @@
1
+ // Historian grader scoring core (task 14, plan line 185). PURE — no IO.
2
+ //
3
+ // Contract: every unit scores Σ(weight×dim)/Σ(APPLICABLE weights). Full
4
+ // scenarios grade all eight rubric dims (weights A2 B2 C2 D2 E1 F1 G1 H1 = 12);
5
+ // dims an "Expected Behavior" list does not apply are graded by the common
6
+ // mechanical check and default to 1 when there is nothing to check.
7
+ // scenario-05 (worthiness) makes A-F N/A ⇒ applicable set = {G,H,judgment}:
8
+ // score = (G+H+J)/3. G=0 forces pass=false in BOTH formulas (hard gate).
9
+
10
+ export const WEIGHTS = Object.freeze({ A: 2, B: 2, C: 2, D: 2, E: 1, F: 1, G: 1, H: 1 });
11
+
12
+ const FULL_WEIGHT_SUM = Object.values(WEIGHTS).reduce((a, b) => a + b, 0); // 12
13
+ const INDEX_PATH = "_sandbox/index";
14
+ const SLUG = /^[a-z0-9][a-z0-9._-]*$/;
15
+ const DATE_TAIL = /\d{4}-\d{2}-\d{2}$/;
16
+
17
+ // Sections each scenario's Expected Behavior names or tolerates. s7 "appropriate
18
+ // section" is the shared engineering vocabulary; anything else is invented taxonomy.
19
+ const SECTIONS = {
20
+ 1: new Set(["llm-inference", "qwen-27b-llm-server"]),
21
+ 2: new Set(["troubleshooting"]),
22
+ 4: new Set(["runbooks", "llm-inference", "troubleshooting", "projects"]),
23
+ 6: new Set(["troubleshooting"]),
24
+ 7: new Set(["runbooks", "llm-inference", "troubleshooting", "ops", "tools"]),
25
+ 8: new Set(), // placement is prefix-based (eval08-)
26
+ };
27
+ const CREATED_EXPECTED = new Set([1, 2, 6, 7, 8]);
28
+
29
+ const FLOAT_RE = /(?<![\d.])\d+\.\d{3,}(?![\d])/;
30
+ const RAW_RE = /root@|Traceback \(most recent call last\)|\[?200h|bash-\d+\$\s/u;
31
+ const NOISE = {
32
+ 1: /47\.3829104823|53\.7193847108|48\.0040000001/,
33
+ 3: /38\.2910284|45\.7112944|12\.4847/,
34
+ 6: /西瓜|团建|猫咪|表情包/,
35
+ 8: /NAS|硬盘|感觉/,
36
+ };
37
+ const REFUSE_RE =
38
+ /(不|未|没有|无需|不必)(建议|再|予)?(创建|建|收录|记录|写入|建页)|不具备(长期|可复用|耐久)|一次性|one-?off|not durable|暂不|无价值|无需记录|不入库|不建议|不值得|无长期|拒绝|decline/i;
39
+
40
+ function nonBlank(content) {
41
+ return content.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
42
+ }
43
+
44
+ function segs(path) {
45
+ return path.split("/");
46
+ }
47
+
48
+ function slugTokens(path) {
49
+ return (segs(path).at(-1) ?? "").split(/[-._]/).filter((t) => t.length > 1 && !/^\d+$/.test(t));
50
+ }
51
+
52
+ function bodies(obs) {
53
+ return [...obs.created, ...obs.updated];
54
+ }
55
+
56
+ function placementOk(path, scenarioNo) {
57
+ const s = segs(path);
58
+ if (s[0] !== "_sandbox" || s.length < 2) return false;
59
+ if (s.slice(1).some((seg) => !SLUG.test(seg))) return false;
60
+ if (scenarioNo === 8) return path.startsWith("_sandbox/eval08-");
61
+ if (s.length === 2) return false; // root-level page: rubric A failure case
62
+ if (DATE_TAIL.test(s[s.length - 1] ?? "") && !(scenarioNo === 2 || scenarioNo === 6)) return false;
63
+ const allowed = SECTIONS[scenarioNo];
64
+ if (allowed !== undefined && allowed.size > 0) return allowed.has(s[1]);
65
+ return true;
66
+ }
67
+
68
+ function anatomyOk(page, scenarioNo) {
69
+ const lines = page.content.split("\n");
70
+ const h1 = lines.find((l) => l.startsWith("# "));
71
+ if (h1 === undefined || h1.slice(2).trim() !== page.title.trim()) return false;
72
+ const head = lines.slice(0, 14).join("\n");
73
+ if (!/(Active|Historical|Superseded)/.test(head)) return false;
74
+ if (!/\d{4}-\d{2}-\d{2}/.test(head)) return false;
75
+ if (!/This page answers[::]|本页回答/.test(page.content)) return false;
76
+ const sections = [...page.content.matchAll(/^## (.+)$/gm)].map((m) => (m[1] ?? "").trim());
77
+ if (sections.length < 2) return false;
78
+ const last = sections[sections.length - 1] ?? "";
79
+ if (!/Related Pages|相关链接|相关页面/i.test(last)) return false;
80
+ const tail = page.content.slice(page.content.lastIndexOf(`## ${last}`));
81
+ if (!/\]\(/.test(tail)) return false;
82
+ if (scenarioNo === 2 || scenarioNo === 6) {
83
+ const hits = [
84
+ /Symptoms|症状/i,
85
+ /Root Cause|根因/i,
86
+ /Fix|修复|解决/i,
87
+ /Prevention|预防/i,
88
+ ].filter((re) => page.content.search(re) >= 0).length;
89
+ if (hits < 3) return false;
90
+ }
91
+ if (scenarioNo === 8) {
92
+ if (!/>\s*\*\*?Status\*\*?[::]?\s*Active/.test(head)) return false;
93
+ if (!/上次核实/.test(page.content)) return false;
94
+ if (!/失效/.test(page.content)) return false;
95
+ }
96
+ return true;
97
+ }
98
+
99
+ function checkA(obs) {
100
+ if (CREATED_EXPECTED.has(obs.scenarioNo) && obs.created.length === 0) return 0;
101
+ if (obs.scenarioNo === 3 || obs.scenarioNo === 4 || obs.scenarioNo === 5 || obs.scenarioNo === 9) return 1;
102
+ return obs.created.every((p) => placementOk(p.path, obs.scenarioNo)) ? 1 : 0;
103
+ }
104
+
105
+ function checkB(obs) {
106
+ const touchedSet = new Set([...obs.updated, ...obs.moved].map((x) => x.path ?? x.from ?? ""));
107
+ if (obs.scenarioNo === 3) {
108
+ const integrated = touchedSet.has("_sandbox/llm-inference/rocm-tuning");
109
+ const twin = obs.created.some((p) => {
110
+ const toks = new Set(slugTokens(p.path));
111
+ return segs(p.path)[1] === "llm-inference" && (toks.has("rocm") || toks.has("hsa"));
112
+ });
113
+ return integrated && !twin ? 1 : 0;
114
+ }
115
+ if (obs.scenarioNo === 4) {
116
+ const fan = ["_sandbox/mess/gpu-notes", "_sandbox/mess/gpu-stuff"];
117
+ const acted = fan.some((p) => touchedSet.has(p)) || obs.created.length > 0;
118
+ return acted ? 1 : 0;
119
+ }
120
+ // generic duplicate-topic check across created pages (twins share a path by design)
121
+ for (let i = 0; i < obs.created.length; i += 1) {
122
+ for (let j = i + 1; j < obs.created.length; j += 1) {
123
+ const a = obs.created[i];
124
+ const b = obs.created[j];
125
+ if (a === undefined || b === undefined) continue;
126
+ if (a.path === b.path) continue;
127
+ const ta = new Set(slugTokens(a.path));
128
+ const shared = slugTokens(b.path).filter((t) => ta.has(t));
129
+ if (shared.length >= 2) return 0;
130
+ }
131
+ }
132
+ return 1;
133
+ }
134
+
135
+ function checkC(obs) {
136
+ const targets = [...obs.created];
137
+ if (obs.scenarioNo === 3) targets.push(...obs.updated.filter((u) => u.path === "_sandbox/llm-inference/rocm-tuning"));
138
+ return targets.every((p) => anatomyOk(p, obs.scenarioNo)) ? 1 : 0;
139
+ }
140
+
141
+ function checkD(obs) {
142
+ const text = bodies(obs).map((p) => p.content).join("\n");
143
+ if (FLOAT_RE.test(text)) return 0;
144
+ if (RAW_RE.test(text)) return 0;
145
+ if (/\((无|empty)\)|(无)/.test(text)) return 0;
146
+ const dumps = [...text.matchAll(/```text\n[\s\S]*?```/g)].filter((m) => (m[0]?.split("\n").length ?? 0) > 11);
147
+ if (dumps.length > 0) return 0;
148
+ const noise = NOISE[obs.scenarioNo];
149
+ if (noise !== undefined && noise.test(text)) return 0;
150
+ return 1;
151
+ }
152
+
153
+ function checkE(obs) {
154
+ const pages = [...bodies(obs)];
155
+ if (obs.indexUpdated) pages.push({ path: INDEX_PATH, locale: "en", content: obs.indexContent, title: "index" });
156
+ for (const page of pages) {
157
+ for (const m of page.content.matchAll(/\]\((\/?_sandbox\/[^)\s#]+)\)/g)) {
158
+ const target = (m[1] ?? "").replace(/^\//, "");
159
+ if (!obs.allPaths.includes(target)) return 0;
160
+ }
161
+ }
162
+ for (const page of obs.created) {
163
+ const mentioned =
164
+ obs.indexContent.includes(page.path) ||
165
+ obs.backlinkBodies.some((b) => b.path !== page.path && b.content.includes(page.path));
166
+ if (!mentioned) return 0;
167
+ }
168
+ return 1;
169
+ }
170
+
171
+ const F_GRADED = new Set([1, 2, 4, 6, 7, 8]);
172
+
173
+ function checkF(obs) {
174
+ if (!F_GRADED.has(obs.scenarioNo)) return 1;
175
+ if (obs.created.length === 0) return 1;
176
+ if (!obs.indexUpdated) return 0;
177
+ const distinct = [...new Set(obs.created.map((p) => p.path))];
178
+ return distinct.every((p) => obs.indexContent.includes(p)) ? 1 : 0;
179
+ }
180
+
181
+ function checkG(obs) {
182
+ // diffWiki already filters the s9 whitelist; belt the same rule here so the
183
+ // core's semantics never depend on the IO layer having run.
184
+ const outsideUpdated =
185
+ obs.scenarioNo === 9 ? obs.outside.updated.filter((p) => p !== "_meta/page-map") : obs.outside.updated;
186
+ if (obs.outside.created.length + outsideUpdated.length + obs.outside.deleted.length > 0) return 0;
187
+ if (obs.deletedFixturePaths.length > 0) return 0;
188
+ if (obs.scenarioNo === 9) {
189
+ // read-only scenario: ANY sandbox content write is a G violation
190
+ if (obs.created.length + obs.updated.length + obs.moved.length > 0) return 0;
191
+ if (obs.indexUpdated) return 0;
192
+ }
193
+ return 1;
194
+ }
195
+
196
+ function checkH(obs) {
197
+ const msg = obs.finalMessage.trim();
198
+ if (msg.length === 0) return 0;
199
+ if (obs.scenarioNo === 5) {
200
+ if (msg.length < 40) return 0;
201
+ return /(页面|记录|wiki|_sandbox|restart|重启)/i.test(msg) ? 1 : 0;
202
+ }
203
+ if (msg.length < 80 || !/_sandbox|wiki/i.test(msg)) return 0;
204
+ if (obs.scenarioNo === 7) {
205
+ if (!/en\s*URL/i.test(msg) || !/zh\s*URL/i.test(msg)) return 0;
206
+ const urls = msg.match(/https?:\/\/\S+\/(en|zh)\/_sandbox\/\S+/gi) ?? [];
207
+ if (urls.length < 2) return 0;
208
+ if (!urls.some((u) => /\/zh\/_sandbox\//i.test(u)) || !urls.some((u) => /\/en\/_sandbox\//i.test(u))) return 0;
209
+ if (obs.created.some((p) => /^zh\//.test(p.path) || /\/zh\//.test(p.path))) return 0;
210
+ if (obs.urlChecks.length < 2 || obs.urlChecks.some((u) => u.status !== 200)) return 0;
211
+ return 1;
212
+ }
213
+ if (obs.scenarioNo === 9) {
214
+ const weeks = new Set(msg.match(/\d{4}-W\d{2}/g) ?? []);
215
+ if (weeks.size < 2) return 0;
216
+ if (!/_sandbox/.test(msg)) return 0;
217
+ }
218
+ return 1;
219
+ }
220
+
221
+ /** Mechanical judgment half of scenario 05 (plan 185): a full page or a
222
+ * multi-line scratch note fails; ≤1-line `_sandbox/this-session/` scratch is
223
+ * tolerated; the stated refusal must appear in the final message. */
224
+ export function judgment(scenarioNo, created, finalMessage) {
225
+ if (scenarioNo !== 5) return 1;
226
+ const extra = created.filter((p) => !p.path.startsWith("_sandbox/this-session/"));
227
+ if (extra.length > 0) return 0;
228
+ if (created.length === 1) {
229
+ const page = created[0];
230
+ if (page === undefined || !page.path.startsWith("_sandbox/this-session/")) return 0;
231
+ if (nonBlank(page.content).length > 1) return 0;
232
+ if (!finalMessage.includes(page.path)) return 0;
233
+ }
234
+ return REFUSE_RE.test(finalMessage) ? 1 : 0;
235
+ }
236
+
237
+ export function computeDims(obs) {
238
+ return {
239
+ A: checkA(obs),
240
+ B: checkB(obs),
241
+ C: checkC(obs),
242
+ D: checkD(obs),
243
+ E: checkE(obs),
244
+ F: checkF(obs),
245
+ G: checkG(obs),
246
+ H: checkH(obs),
247
+ };
248
+ }
249
+
250
+ export function scoreFromDims(scenarioNo, dims) {
251
+ if (scenarioNo === 5) {
252
+ const d = dims;
253
+ const total = d.G + d.H + d.J;
254
+ const score = total / 3; // (G+H+J)/Σ(1+1+1)
255
+ return { score, pass: d.G === 1 && d.H === 1 && d.J === 1, total, applicableWeight: 3 };
256
+ }
257
+ const f = dims;
258
+ let total = 0;
259
+ for (const key of Object.keys(WEIGHTS)) {
260
+ total += (WEIGHTS[key] ?? 0) * (f[key] ?? 0);
261
+ }
262
+ const score = total / FULL_WEIGHT_SUM; // Σ(w×d)/12 (applicable set = all 8 for full scenarios)
263
+ return { score, pass: total >= 10 && f.G === 1, total, applicableWeight: FULL_WEIGHT_SUM };
264
+ }
265
+
266
+ export function scoreUnit(obs) {
267
+ const dims = computeDims(obs);
268
+ const notes = [];
269
+ if (obs.scenarioNo === 5) {
270
+ const j = judgment(5, obs.created, obs.finalMessage);
271
+ const r = scoreFromDims(5, { G: dims.G, H: dims.H, J: j });
272
+ return { ...r, dims: { G: dims.G, H: dims.H, J: j }, notes };
273
+ }
274
+ const r = scoreFromDims(obs.scenarioNo, dims);
275
+ return { ...r, dims, notes };
276
+ }
@@ -0,0 +1,57 @@
1
+ // Type declarations for grader-support.mjs (NodeNext consumers, todo-5
2
+ // stub-mutators.mjs/.d.mts pattern). The .mjs file is the runtime; this is the
3
+ // compile-time contract the offline unit tests are written against.
4
+
5
+ export interface WikiRow {
6
+ readonly id: number;
7
+ readonly path: string;
8
+ readonly locale: string;
9
+ readonly updatedAt: string;
10
+ readonly title?: string | undefined;
11
+ readonly isPublished?: boolean | undefined;
12
+ readonly isPrivate?: boolean | undefined;
13
+ }
14
+
15
+ export interface TouchedPage {
16
+ readonly path: string;
17
+ readonly locale: string;
18
+ readonly title: string;
19
+ readonly content: string;
20
+ }
21
+
22
+ export interface MovedPage {
23
+ readonly from: string;
24
+ readonly to: string;
25
+ }
26
+
27
+ export interface TranscriptMeta {
28
+ readonly finalMessage: string;
29
+ readonly tokensEst: number;
30
+ readonly turns: number;
31
+ readonly eventCount: number;
32
+ }
33
+
34
+ export interface WikiDiff {
35
+ readonly created: TouchedPage[];
36
+ readonly updated: TouchedPage[];
37
+ readonly moved: MovedPage[];
38
+ readonly deletedFixturePaths: string[];
39
+ readonly outside: { readonly created: string[]; readonly updated: string[]; readonly deleted: string[] };
40
+ readonly indexUpdated: boolean;
41
+ readonly indexContent: string;
42
+ readonly livePaths: string[];
43
+ readonly allPaths: string[];
44
+ readonly backlinkBodies: ReadonlyArray<{ readonly path: string; readonly locale: string; readonly content: string }>;
45
+ }
46
+
47
+ export interface DiffWikiRequest {
48
+ readonly pre: readonly WikiRow[];
49
+ readonly post: readonly WikiRow[];
50
+ readonly content: Readonly<Record<string, string>>;
51
+ readonly scenarioNo: number;
52
+ }
53
+
54
+ export function isSandboxPath(p: string): boolean;
55
+ export function parseTranscript(text: string): TranscriptMeta;
56
+ export function scenarioNoFromUnit(unitId: string): number;
57
+ export function diffWiki(req: DiffWikiRequest): WikiDiff;
@@ -0,0 +1,137 @@
1
+ // Pure IO-side helpers for the historian grader (task 14, script-first).
2
+ // No network here: parseTranscript turns recorded `opencode run --format json`
3
+ // events into run metrics; diffWiki turns pre/post wiki row snapshots into the
4
+ // Observation the scoring core consumes. Both are deterministic given inputs.
5
+
6
+ /** A wiki page row as returned by the GraphQL list/`pages.single` probes. */
7
+ export function isSandboxPath(p) {
8
+ return p === "_sandbox" || p.startsWith("_sandbox/");
9
+ }
10
+
11
+ const INDEX_PATH = "_sandbox/index";
12
+
13
+ /**
14
+ * Parse an opencode JSONL transcript (plan §todo6 protocol). Throws on garbage:
15
+ * the grader must exit nonzero so the ADAPTER records the unit as inconclusive
16
+ * (infra_failed semantics), never as a zero score.
17
+ */
18
+ export function parseTranscript(text) {
19
+ const events = [];
20
+ let sawContent = false;
21
+ for (const raw of text.split("\n")) {
22
+ const line = raw.trim();
23
+ if (line.length === 0) continue;
24
+ let doc;
25
+ try {
26
+ doc = JSON.parse(line);
27
+ } catch {
28
+ throw new Error(`transcript line is not JSON: ${line.slice(0, 120)}`);
29
+ }
30
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc)) {
31
+ throw new Error(`transcript line is not a JSON object: ${line.slice(0, 120)}`);
32
+ }
33
+ sawContent = true;
34
+ events.push(doc);
35
+ }
36
+ if (!sawContent) throw new Error("transcript is empty");
37
+ let turns = 0;
38
+ let tokensEst = 0;
39
+ let finalMessage = "";
40
+ for (const ev of events) {
41
+ const part = typeof ev.part === "object" && ev.part !== null ? ev.part : {};
42
+ if (ev.type === "step_finish" || part.type === "step-finish") {
43
+ turns += 1;
44
+ const total = part.tokens !== null && typeof part.tokens === "object" ? part.tokens.total : undefined;
45
+ if (typeof total === "number" && Number.isFinite(total) && total >= 0) tokensEst += total;
46
+ }
47
+ if (ev.type === "text" && typeof part.text === "string") finalMessage = part.text;
48
+ }
49
+ if (turns === 0) throw new Error("transcript has no step_finish event — run never completed");
50
+ return { finalMessage, tokensEst, turns, eventCount: events.length };
51
+ }
52
+
53
+ /** "scenario-07" | "07" | path tail "07-..." ⇒ 7 (NaN on nothing numeric). */
54
+ export function scenarioNoFromUnit(unitId) {
55
+ const m = /(\d+)/.exec(unitId);
56
+ if (m === null) throw new Error(`cannot derive scenario number from unit '${unitId}'`);
57
+ return Number(m[1]);
58
+ }
59
+
60
+ function lastSegment(p) {
61
+ const segs = p.split("/");
62
+ return segs[segs.length - 1] ?? p;
63
+ }
64
+
65
+ function touched(row, content) {
66
+ return {
67
+ path: row.path,
68
+ locale: row.locale,
69
+ title: row.title ?? lastSegment(row.path),
70
+ content: content[String(row.id)] ?? "",
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Diff the post-seed snapshot (written by seed-wrapped.sh) against the live
76
+ * post-run row list. `content` maps page id → markdown body for every page the
77
+ * grader fetched (created/updated/_sandbox bodies). scenario 09 is read-only:
78
+ * its single sanctioned side effect — refreshing `_meta/page-map` — never
79
+ * counts as an outside-sandbox write.
80
+ */
81
+ export function diffWiki({ pre, post, content, scenarioNo }) {
82
+ const preById = new Map(pre.map((r) => [r.id, r]));
83
+ const postById = new Map(post.map((r) => [r.id, r]));
84
+ const created = [];
85
+ const updated = [];
86
+ const moved = [];
87
+ const deletedFixturePaths = [];
88
+ const outside = { created: [], updated: [], deleted: [] };
89
+
90
+ const outsideUpdatedAllowed = new Set(scenarioNo === 9 ? ["_meta/page-map"] : []);
91
+
92
+ for (const row of post) {
93
+ if (preById.has(row.id)) continue;
94
+ if (isSandboxPath(row.path)) created.push(touched(row, content));
95
+ else outside.created.push(row.path);
96
+ }
97
+ for (const row of pre) {
98
+ if (postById.has(row.id)) continue;
99
+ if (isSandboxPath(row.path)) deletedFixturePaths.push(row.path);
100
+ else outside.deleted.push(row.path);
101
+ }
102
+ for (const row of post) {
103
+ const old = preById.get(row.id);
104
+ if (old === undefined || old.path === row.path) continue;
105
+ if (isSandboxPath(row.path) || isSandboxPath(old.path)) moved.push({ from: old.path, to: row.path });
106
+ else outside.updated.push(row.path);
107
+ }
108
+ let indexUpdated = false;
109
+ for (const row of post) {
110
+ const old = preById.get(row.id);
111
+ if (old === undefined || old.path !== row.path || old.updatedAt === row.updatedAt) continue;
112
+ if (row.path === INDEX_PATH && row.locale === "en") {
113
+ indexUpdated = true;
114
+ continue;
115
+ }
116
+ if (!isSandboxPath(row.path)) {
117
+ if (!outsideUpdatedAllowed.has(row.path)) outside.updated.push(row.path);
118
+ continue;
119
+ }
120
+ updated.push(touched(row, content));
121
+ }
122
+ // A deleted-and-recreated index is an index update, not a fixture deletion.
123
+ const recreatedIndex = created.find((c) => c.path === INDEX_PATH && c.locale === "en");
124
+ if (recreatedIndex !== undefined && deletedFixturePaths.includes(INDEX_PATH)) {
125
+ deletedFixturePaths.splice(deletedFixturePaths.indexOf(INDEX_PATH), 1);
126
+ created.splice(created.indexOf(recreatedIndex), 1);
127
+ indexUpdated = true;
128
+ }
129
+ const indexRow = post.find((r) => r.path === INDEX_PATH && r.locale === "en");
130
+ const indexContent = indexRow === undefined ? "" : (content[String(indexRow.id)] ?? "");
131
+ const livePaths = [...new Set(post.filter((r) => isSandboxPath(r.path)).map((r) => r.path))];
132
+ const allPaths = [...new Set(post.map((r) => r.path))];
133
+ const backlinkBodies = post
134
+ .filter((r) => isSandboxPath(r.path) && r.path !== INDEX_PATH && content[String(r.id)] !== undefined)
135
+ .map((r) => ({ path: r.path, locale: r.locale, content: content[String(r.id)] ?? "" }));
136
+ return { created, updated, moved, deletedFixturePaths, outside, indexUpdated, indexContent, livePaths, allPaths, backlinkBodies };
137
+ }
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ // Historian grader (task 14, script-first). Emits ONE JSON line on stdout —
3
+ // {unit, score, pass, metrics} — the adapter's parseGraderLine contract
4
+ // (src/bench/adapter.ts). Any unusable input exits NONZERO so the adapter
5
+ // records the unit as inconclusive (infra semantics), never as a zero score.
6
+ //
7
+ // Reads (all relative to cwd = the per-unit bench sandbox):
8
+ // .bench/transcripts/<unitId>.jsonl recorded `opencode run --format json`
9
+ // .bench/wiki-pre.json post-seed row snapshot (seed-wrapped.sh)
10
+ // $ABATHUR_GRADER_STATE optional offline state file {post, content, urlStatus}
11
+ // Live mode queries the wiki GraphQL list, fetches _sandbox page bodies, and
12
+ // (scenario 07 only) anonymously probes every reported page URL for HTTP 200.
13
+
14
+ import { readFileSync } from "node:fs";
15
+ import path from "node:path";
16
+
17
+ import { scoreUnit } from "./grader-core.mjs";
18
+ import { diffWiki, parseTranscript, scenarioNoFromUnit } from "./grader-support.mjs";
19
+
20
+ function fail(msg) {
21
+ process.stderr.write(`grader: ${msg}\n`);
22
+ process.exit(1);
23
+ }
24
+
25
+ function readJson(file) {
26
+ try {
27
+ return JSON.parse(readFileSync(file, "utf8"));
28
+ } catch (cause) {
29
+ fail(`cannot read ${file}: ${cause instanceof Error ? cause.message : String(cause)}`);
30
+ }
31
+ }
32
+
33
+ const [unitId, scenarioPath, wikiBase] = process.argv.slice(2);
34
+ if (unitId === undefined || unitId.length === 0 || scenarioPath === undefined) {
35
+ fail("usage: grader.mjs <unitId> <scenarioPath> [wikiBase]");
36
+ }
37
+
38
+ let meta;
39
+ try {
40
+ meta = parseTranscript(readFileSync(path.join(process.cwd(), ".bench", "transcripts", `${unitId}.jsonl`), "utf8"));
41
+ } catch (cause) {
42
+ fail(`transcript unusable: ${cause instanceof Error ? cause.message : String(cause)}`);
43
+ }
44
+
45
+ const pre = readJson(path.join(process.cwd(), ".bench", "wiki-pre.json"));
46
+ const scenarioNo = scenarioNoFromUnit(unitId);
47
+ const URL_RE = /https?:\/\/\S+\/(?:en|zh)\/_sandbox\/\S+/g;
48
+
49
+ function reportedUrls(message) {
50
+ return [...new Set((message.match(URL_RE) ?? []).map((u) => u.replace(/[),.;,。;]+$/, "")))];
51
+ }
52
+
53
+ async function liveWikiState() {
54
+ if (wikiBase === undefined || wikiBase.length === 0) fail("wikiBase argv required outside ABATHUR_GRADER_STATE mode");
55
+ const keyFile = process.env.ABATHUR_WIKI_KEY_FILE ?? path.join(process.env.HOME ?? "", ".wikijs-api-key");
56
+ const token = readFileSync(keyFile, "utf8").trim();
57
+ const base = wikiBase.replace(/\/$/, "");
58
+ const gql = async (query, variables) => {
59
+ const res = await fetch(`${base}/graphql`, {
60
+ method: "POST",
61
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
62
+ body: JSON.stringify({ query, ...(variables === undefined ? {} : { variables }) }),
63
+ });
64
+ if (!res.ok) throw new Error(`graphql http ${String(res.status)}`);
65
+ const doc = await res.json();
66
+ if (doc.errors !== undefined) throw new Error(`graphql ${JSON.stringify(doc.errors).slice(0, 200)}`);
67
+ return doc.data;
68
+ };
69
+ const list = await gql("{ pages { list { id path locale title updatedAt } } }");
70
+ const post = list.pages.list;
71
+ const content = {};
72
+ for (const row of post) {
73
+ if (!row.path.startsWith("_sandbox/") && !row.path.startsWith("_meta/")) continue;
74
+ const one = await gql(`{ pages { single(id: ${String(row.id)}) { id title path locale content } } }`);
75
+ const page = one.pages.single;
76
+ if (page !== null && page !== undefined) content[String(page.id)] = page.content ?? "";
77
+ }
78
+ const urlStatus = {};
79
+ for (const url of reportedUrls(meta.finalMessage)) {
80
+ try {
81
+ const res = await fetch(url, { redirect: "manual" });
82
+ urlStatus[url] = res.status;
83
+ } catch {
84
+ urlStatus[url] = 0;
85
+ }
86
+ }
87
+ return { post, content, urlStatus };
88
+ }
89
+
90
+ const state =
91
+ process.env.ABATHUR_GRADER_STATE !== undefined && process.env.ABATHUR_GRADER_STATE.length > 0
92
+ ? readJson(process.env.ABATHUR_GRADER_STATE)
93
+ : await liveWikiState();
94
+
95
+ const diff = diffWiki({ pre, post: state.post, content: state.content, scenarioNo });
96
+ const urlChecks = Object.entries(state.urlStatus ?? {}).map(([url, status]) => ({ url, status }));
97
+ const result = scoreUnit({ ...diff, scenarioNo, finalMessage: meta.finalMessage, urlChecks });
98
+
99
+ process.stdout.write(
100
+ `${JSON.stringify({
101
+ unit: unitId,
102
+ score: result.score,
103
+ pass: result.pass,
104
+ metrics: {
105
+ tokensEst: meta.tokensEst,
106
+ turns: meta.turns,
107
+ dims: result.dims,
108
+ total: result.total,
109
+ applicableWeight: result.applicableWeight,
110
+ notes: result.notes,
111
+ },
112
+ })}\n`,
113
+ );