@supersuit/hyperspec 0.6.0 → 0.8.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 (59) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +71 -8
  3. package/SPEC.md +2 -2
  4. package/WRITING.md +680 -21
  5. package/bin/hyperspec.mjs +188 -4
  6. package/examples/writing/course/claims.jsonl +0 -0
  7. package/examples/writing/course/goldens/lesson.md +1 -0
  8. package/examples/writing/course/materials/brief.md +9 -0
  9. package/examples/writing/course/materials/brief.md.segments.jsonl +6 -0
  10. package/examples/writing/course/outline.md +11 -0
  11. package/examples/writing/course/part-1.md +47 -0
  12. package/examples/writing/course/part-2.md +40 -0
  13. package/examples/writing/course/runs.jsonl +0 -0
  14. package/examples/writing/course.hyperspec.md +205 -0
  15. package/examples/writing/essay/judge/doctor.packet.json +108 -0
  16. package/examples/writing/essay/judge/lineup.packet.json +64 -0
  17. package/examples/writing/essay/judge/persona.packet.json +73 -0
  18. package/examples/writing/essay/judge/reader.packet.json +93 -0
  19. package/examples/writing/essay/learn/first-draft.md +84 -0
  20. package/examples/writing/essay/learn/learn.packet.json +106 -0
  21. package/examples/writing/essay/sample-verdicts/doctor.verdict.json +43 -0
  22. package/examples/writing/essay/sample-verdicts/learn.verdict.json +30 -0
  23. package/examples/writing/essay/sample-verdicts/lineup.verdict.json +6 -0
  24. package/examples/writing/essay/sample-verdicts/persona.verdict.json +4 -0
  25. package/examples/writing/essay/sample-verdicts/reader.verdict.json +7 -0
  26. package/examples/writing/essay.hyperspec.md +6 -1
  27. package/examples/writing/story/judge/attribution.packet.json +194 -0
  28. package/examples/writing/story/judge/doctor.packet.json +108 -0
  29. package/examples/writing/story/judge/knowledge.packet.json +77 -0
  30. package/examples/writing/story/judge/persona.packet.json +73 -0
  31. package/examples/writing/story/judge/reader.packet.json +94 -0
  32. package/examples/writing/story/sample-verdicts/attribution.verdict.json +81 -0
  33. package/examples/writing/story/sample-verdicts/doctor.verdict.json +43 -0
  34. package/examples/writing/story/sample-verdicts/knowledge.verdict.json +4 -0
  35. package/examples/writing/story/sample-verdicts/persona.verdict.json +20 -0
  36. package/examples/writing/story/sample-verdicts/reader.verdict.json +16 -0
  37. package/examples/writing/story.hyperspec.md +7 -3
  38. package/package.json +1 -1
  39. package/src/check.mjs +96 -132
  40. package/src/draft.mjs +26 -0
  41. package/src/judge.mjs +386 -0
  42. package/src/judges/attribution.mjs +360 -0
  43. package/src/judges/doctor.mjs +126 -0
  44. package/src/judges/index.mjs +31 -0
  45. package/src/judges/knowledge.mjs +111 -0
  46. package/src/judges/lineup.mjs +272 -0
  47. package/src/judges/persona.mjs +137 -0
  48. package/src/judges/reader.mjs +111 -0
  49. package/src/learn.mjs +422 -0
  50. package/src/ledger.mjs +108 -0
  51. package/src/sentences.mjs +81 -0
  52. package/src/sequence-draft.mjs +75 -0
  53. package/src/stations/claims.mjs +44 -39
  54. package/src/stations/index.mjs +3 -1
  55. package/src/stations/links.mjs +11 -3
  56. package/src/stations/quotes.mjs +6 -4
  57. package/src/stations/sequence.mjs +275 -0
  58. package/src/writing-fields.mjs +34 -0
  59. package/src/writing.mjs +1 -1
package/src/judge.mjs ADDED
@@ -0,0 +1,386 @@
1
+ // `hyperspec judge prepare` and `hyperspec judge record`: judgment stations as packets. hyperspec
2
+ // never calls a model. For each judgment station, `prepare` writes a PACKET: everything a judge
3
+ // needs (the rubric from the spec, fixed instructions, the inputs, the exact verdict shape). An
4
+ // outside judge, a person or a model, fills a VERDICT file. `record` validates the verdict,
5
+ // including that every evidence span it cites really appears in the draft, derives the station's
6
+ // status deterministically, and appends one line to the spec's runs ledger, under the same truth
7
+ // rules `check` follows (src/ledger.mjs).
8
+ //
9
+ // This module is the commands' logic; bin/hyperspec.mjs owns argv parsing and printing, the split
10
+ // src/check.mjs keeps too. Each station lives in src/judges/ and is registered in its index.
11
+
12
+ import { appendFileSync, existsSync, readFileSync, statSync } from "node:fs";
13
+ import { join, resolve } from "node:path";
14
+ import { sha256 } from "./hash.mjs";
15
+ import { writeFileAtomic } from "./fsutil.mjs";
16
+ import { readDraft } from "./draft.mjs";
17
+ import { loadWritingSpec, lintBlock, withoutAbsolutePaths } from "./check.mjs";
18
+ import { openLedger, priorLines, ledgerDraftKey, ledgerVerdict } from "./ledger.mjs";
19
+ import { lineAt, truncate } from "./stations/util.mjs";
20
+ import { JUDGES, JUDGE_NAMES } from "./judges/index.mjs";
21
+
22
+ export const PACKET_VERSION = "0.1";
23
+
24
+ const present = (v) => typeof v === "string" && v.trim().length > 0;
25
+
26
+ // ---- the evidence rule -------------------------------------------------------------------------
27
+ // A span of evidence counts as quoted from the draft when, after both are normalized, the draft
28
+ // contains it. Normalization collapses every run of whitespace (spaces, tabs, line breaks, CRLF) to
29
+ // one space and turns curly, low and angle quotation marks and apostrophes into their straight
30
+ // forms (primes are not quotation marks and are left alone), so a judge that reflows a quotation or
31
+ // types typographic quotes is still quoting; changing a single word is not.
32
+ //
33
+ // A span must also carry at least MIN_EVIDENCE_WORDS word tokens (runs of letters and digits), and
34
+ // match on word boundaries: a match may not start or end in the middle of a word. A one-letter or
35
+ // one-word "quotation" is found almost anywhere and so checks nothing.
36
+
37
+ export const MIN_EVIDENCE_WORDS = 3;
38
+ const WORD_CHAR = /[\p{L}\p{N}]/u;
39
+ const WORD_TOKENS = /[\p{L}\p{N}]+/gu;
40
+
41
+ const QUOTE_CHARS = new Map([
42
+ ["\u2018", "'"], ["\u2019", "'"], ["\u201A", "'"], ["\u201B", "'"], ["\u2039", "'"], ["\u203A", "'"],
43
+ ["\u201C", '"'], ["\u201D", '"'], ["\u201E", '"'], ["\u201F", '"'], ["\u00AB", '"'], ["\u00BB", '"'],
44
+ ]);
45
+
46
+ // { norm, map }: the normalized text, and for each of its characters the offset in `text` it came
47
+ // from, so a match in the normalized text can be traced back to a line of the original.
48
+ export function normalizeForEvidence(text) {
49
+ let norm = "";
50
+ const map = [];
51
+ let pendingSpace = -1;
52
+ for (let i = 0; i < text.length; i++) {
53
+ const ch = text[i];
54
+ if (/\s/.test(ch)) { if (norm && pendingSpace < 0) pendingSpace = i; continue; }
55
+ if (pendingSpace >= 0) { norm += " "; map.push(pendingSpace); pendingSpace = -1; }
56
+ norm += QUOTE_CHARS.get(ch) ?? ch;
57
+ map.push(i);
58
+ }
59
+ return { norm, map };
60
+ }
61
+
62
+ // The helpers a station's validate() and derive() receive, bound to one packet and one draft: every
63
+ // finding they build has the same shape as a `check` finding ({ station, id, severity, message,
64
+ // fix, line? }).
65
+ function toolsFor(stationName, draft) {
66
+ const { norm, map } = normalizeForEvidence(draft.text);
67
+ // The 1-based line of the first whole-word match of `span`, or -1.
68
+ const locate = (span) => {
69
+ const needle = normalizeForEvidence(span).norm;
70
+ if (!needle) return -1;
71
+ const startsWord = WORD_CHAR.test(needle[0]);
72
+ const endsWord = WORD_CHAR.test(needle[needle.length - 1]);
73
+ for (let at = norm.indexOf(needle); at >= 0; at = norm.indexOf(needle, at + 1)) {
74
+ const before = at > 0 ? norm[at - 1] : "";
75
+ const after = norm[at + needle.length] ?? "";
76
+ if (startsWord && before && WORD_CHAR.test(before)) continue;
77
+ if (endsWord && after && WORD_CHAR.test(after)) continue;
78
+ return lineAt(draft.text, map[at]);
79
+ }
80
+ return -1;
81
+ };
82
+ const finding = (id, message, fix, line) => ({ station: stationName, id, severity: "fail", message, fix, ...(typeof line === "number" && line > 0 ? { line } : {}) });
83
+ return {
84
+ finding,
85
+ shape: (message, fix) => finding("judge-verdict-shape", message, fix),
86
+ // [] when `value` is a non-empty span found in the draft, else the one finding saying why.
87
+ evidence(value, where) {
88
+ if (typeof value !== "string" || !value.trim()) {
89
+ return [finding("judge-evidence-missing", `${where} is empty or not a string`, "Quote the span of the draft this judgment rests on, verbatim.")];
90
+ }
91
+ const words = (normalizeForEvidence(value).norm.match(WORD_TOKENS) ?? []).length;
92
+ if (words < MIN_EVIDENCE_WORDS) {
93
+ return [finding("judge-evidence-too-short", `${where} has ${words} word${words === 1 ? "" : "s"}, fewer than ${MIN_EVIDENCE_WORDS}: "${truncate(value, 80)}"`, `Quote the sentence or clause the judgment rests on, at least ${MIN_EVIDENCE_WORDS} words, verbatim.`)];
94
+ }
95
+ if (locate(value) < 0) {
96
+ return [finding("judge-evidence-not-found", `${where} is not in the draft: "${truncate(value, 80)}"`, "Copy the evidence verbatim from the draft, whole words only; only whitespace and quote characters may differ.")];
97
+ }
98
+ return [];
99
+ },
100
+ // The 1-based draft line where a (valid) evidence span starts, or undefined.
101
+ lineOf(span) {
102
+ const line = typeof span === "string" ? locate(span) : -1;
103
+ return line > 0 ? line : undefined;
104
+ },
105
+ };
106
+ }
107
+
108
+ // The message for files whose bytes no longer match the hashes a packet recorded. Nothing on disk
109
+ // can tell a file changed since prepare from a packet whose hash (or path) was edited, so it claims
110
+ // neither. `names` lists the files, e.g. ["the spec", "the draft"]. Shared with
111
+ // `learn record`.
112
+ export function hashMismatchMessage(names) {
113
+ const one = names.length === 1;
114
+ const what = one ? names[0] : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
115
+ return `${what} ${one ? "does" : "do"} not match the ${one ? "hash" : "hashes"} the packet recorded: ${one ? "it" : "they"} changed since prepare, or the packet was edited`;
116
+ }
117
+
118
+ const packetJson = (packet) => `${JSON.stringify(packet, null, 2)}\n`;
119
+
120
+ // The packet one judge gets for this spec and draft, built the one way both commands build it:
121
+ // prepare writes it, and record rebuilds it to check the file it was handed. { packet, packetBytes,
122
+ // key }: key is the station's hidden answer key (null for a station with none), written by prepare
123
+ // to <station>.key.json for a person to read, and never read back as truth: record rebuilds it.
124
+ // specPathArg and the draft's path are the strings as given, so the bytes are deterministic.
125
+ export function buildPacket(judge, specPathArg, spec, draft, specSha) {
126
+ const parts = judge.packet(spec, draft);
127
+ const packet = {
128
+ hyperspec_judge: PACKET_VERSION,
129
+ station: judge.name,
130
+ spec: specPathArg,
131
+ spec_sha256: specSha,
132
+ draft: draft.path,
133
+ draft_sha256: draft.sha256,
134
+ rubric: parts.rubric,
135
+ instructions: judge.instructions,
136
+ inputs: parts.inputs,
137
+ verdict_schema: parts.verdict_schema,
138
+ };
139
+ return { packet, packetBytes: packetJson(packet), key: parts.key ?? null };
140
+ }
141
+
142
+ // ---- prepare -----------------------------------------------------------------------------------
143
+
144
+ // prepareJudges(specPathArg, draftPathArg, outDirArg, { only, force }): writes one
145
+ // <station>.packet.json (plus <station>.key.json where a station has a hidden answer key) per
146
+ // applicable station into outDirArg. Paths are kept as given, so the same spec and draft produce
147
+ // byte-identical packets. Refuses (usage) to overwrite an existing file without force, naming every
148
+ // one, and writes nothing in that case.
149
+ export function prepareJudges(specPathArg, draftPathArg, outDirArg, { only, force = false } = {}) {
150
+ if (!present(specPathArg)) return { usage: true, error: "judge prepare needs a spec path" };
151
+ if (!present(draftPathArg)) return { usage: true, error: "judge prepare needs --draft <file>" };
152
+ if (!present(outDirArg)) return { usage: true, error: "judge prepare needs --out <dir>" };
153
+
154
+ const loaded = loadWritingSpec(specPathArg, "judge prepare");
155
+ if (loaded.usage) return loaded;
156
+ const { spec } = loaded;
157
+
158
+ let judges = JUDGES;
159
+ if (only && only.length) {
160
+ const unknown = only.filter((n) => !JUDGE_NAMES.includes(n));
161
+ if (unknown.length) {
162
+ return { usage: true, error: `unknown judge${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}; known judges: ${JUDGE_NAMES.join(", ")}` };
163
+ }
164
+ judges = JUDGES.filter((j) => only.includes(j.name));
165
+ }
166
+
167
+ let outStat = null;
168
+ try { outStat = statSync(resolve(outDirArg)); } catch { /* reported below */ }
169
+ if (!outStat) return { usage: true, error: `--out folder does not exist: ${outDirArg}; create it first` };
170
+ if (!outStat.isDirectory()) return { usage: true, error: `--out is not a folder: ${outDirArg}` };
171
+
172
+ const blocked = lintBlock(spec, specPathArg);
173
+ if (blocked) return blocked;
174
+
175
+ const draft = readDraft(draftPathArg);
176
+ if (!draft) return { usage: true, error: `cannot read draft: ${draftPathArg}` };
177
+ const specSha = sha256(readFileSync(resolve(specPathArg)));
178
+
179
+ const files = [];
180
+ const skipped = [];
181
+ const crashed = [];
182
+ for (const judge of judges) {
183
+ const reason = judge.skipReason(spec, draft);
184
+ if (reason) { skipped.push({ station: judge.name, reason }); continue; }
185
+ let built;
186
+ try { built = buildPacket(judge, specPathArg, spec, draft, specSha); }
187
+ catch (e) {
188
+ crashed.push({ station: judge.name, id: `judge-${judge.name}-crashed`, severity: "fail", message: withoutAbsolutePaths(e instanceof Error ? e.message : String(e)), fix: "Fix the station or file an issue; it should never throw." });
189
+ continue;
190
+ }
191
+ files.push({ station: judge.name, path: join(outDirArg, `${judge.name}.packet.json`), bytes: built.packetBytes });
192
+ if (built.key) files.push({ station: judge.name, path: join(outDirArg, `${judge.name}.key.json`), bytes: packetJson(built.key) });
193
+ }
194
+
195
+ const existing = files.filter((f) => existsSync(resolve(f.path))).map((f) => f.path);
196
+ if (existing.length && !force) {
197
+ return { usage: true, error: `refusing to overwrite ${existing.join(", ")}; pass --force to replace ${existing.length > 1 ? "them" : "it"}` };
198
+ }
199
+ for (const f of files) writeFileAtomic(resolve(f.path), f.bytes);
200
+
201
+ return {
202
+ ok: true,
203
+ specPath: specPathArg,
204
+ draftPath: draftPathArg,
205
+ draftSha256: draft.sha256,
206
+ written: files.map((f) => ({ station: f.station, path: f.path })),
207
+ skipped,
208
+ crashed,
209
+ code: crashed.length ? 1 : 0,
210
+ };
211
+ }
212
+
213
+ // ---- record ------------------------------------------------------------------------------------
214
+
215
+ // recordJudgment(packetPathArg, verdictPathArg): validates the verdict against its packet and, when
216
+ // it is valid, derives the station's status and appends one ledger line. Returns a usage result
217
+ // (exit 2), { invalid } (exit 1, nothing appended; a stale packet is { invalid, stale }, the shape
218
+ // `learn record` returns too), or the recorded result (exit 0 when the station passes, 1 when it
219
+ // fails).
220
+ export function recordJudgment(packetPathArg, verdictPathArg) {
221
+ if (!present(packetPathArg)) return { usage: true, error: "judge record needs a packet path" };
222
+ if (!present(verdictPathArg)) return { usage: true, error: "judge record needs --verdict <file>" };
223
+
224
+ let packetText;
225
+ try { packetText = readFileSync(resolve(packetPathArg), "utf8"); }
226
+ catch { return { usage: true, error: `cannot read packet: ${packetPathArg}` }; }
227
+ let packet;
228
+ try { packet = JSON.parse(packetText); } catch { packet = null; }
229
+ if (!packet || typeof packet !== "object" || packet.hyperspec_judge !== PACKET_VERSION || !present(packet.station) || !present(packet.spec) || !present(packet.draft)) {
230
+ return { usage: true, error: `not a hyperspec judge packet: ${packetPathArg}` };
231
+ }
232
+ const judge = JUDGES.find((j) => j.name === packet.station);
233
+ if (!judge) return { usage: true, error: `the packet names an unknown judge: ${packet.station}; known judges: ${JUDGE_NAMES.join(", ")}` };
234
+
235
+ let verdictBuf;
236
+ try { verdictBuf = readFileSync(resolve(verdictPathArg)); }
237
+ catch { return { usage: true, error: `cannot read verdict: ${verdictPathArg}` }; }
238
+
239
+ const loaded = loadWritingSpec(packet.spec, "judge record");
240
+ if (loaded.usage) return { usage: true, error: `the packet's spec: ${loaded.error}` };
241
+ const { spec } = loaded;
242
+ const draft = readDraft(packet.draft);
243
+ if (!draft) return { usage: true, error: `cannot read the packet's draft: ${packet.draft}` };
244
+
245
+ const base = { packetPath: packetPathArg, verdictPath: verdictPathArg, station: judge.name };
246
+ const t = toolsFor(judge.name, draft);
247
+
248
+ // A verdict on bytes other than the ones the packet was prepared from judges nothing that exists.
249
+ const specSha = sha256(readFileSync(resolve(packet.spec)));
250
+ const draftChanged = draft.sha256 !== packet.draft_sha256;
251
+ const specChanged = specSha !== packet.spec_sha256;
252
+ if (draftChanged || specChanged) {
253
+ const names = [specChanged && "the spec", draftChanged && "the draft"].filter(Boolean);
254
+ return { ...base, ok: false, invalid: true, stale: true, findings: [t.finding("judge-stale", hashMismatchMessage(names), "Run judge prepare again (with --force) and judge the new packet.")], code: 1 };
255
+ }
256
+
257
+ // record never trusts the packet file: it rebuilds the packet (and any answer key) from the spec
258
+ // and draft on disk, requires the file to be those exact bytes, and validates the verdict against
259
+ // the rebuilt copy only. A packet edited after prepare (its conditions, its inputs, a hash made to
260
+ // match a changed draft) is refused here.
261
+ const skip = judge.skipReason(spec, draft);
262
+ let rebuilt = null;
263
+ if (!skip) {
264
+ try { rebuilt = buildPacket(judge, packet.spec, spec, draft, specSha); }
265
+ catch (e) {
266
+ return { ...base, ok: false, invalid: true, findings: [t.finding(`judge-${judge.name}-crashed`, withoutAbsolutePaths(e instanceof Error ? e.message : String(e)), "Fix the station or file an issue; it should never throw.")], code: 1 };
267
+ }
268
+ }
269
+ // A station that reads files besides the spec and the draft (lineup reads the DNA
270
+ // goldens) can go out of date with both hashes unchanged. When the file is a well-formed packet
271
+ // that differs from the rebuilt one only in its inputs, that is reported as stale, naming those
272
+ // files; the packet could also have been hand-edited there (its inputs, or a hash forged to match
273
+ // an edited draft), and the message says so without claiming either hash is honest,
274
+ // since nothing on disk can tell these apart. Anything else is an altered packet.
275
+ //
276
+ // A station can also stop applying because of those files (persona's claims ledger deleted,
277
+ // lineup's goldens removed): its sourceSkip names that reason, and with the spec and draft hashes
278
+ // matching the packet is stale for the same reason, the message saying the station no longer
279
+ // applies and why. Any other skip (the spec changed under a forged hash, say) is an altered
280
+ // packet. Neither message tells the user to judge a packet prepare will no longer write.
281
+ const sources = judge.inputSources?.(spec) ?? null;
282
+ const staleSources = (message, fix) => ({ ...base, ok: false, invalid: true, stale: true, findings: [t.finding("judge-stale", `${message}: ${sources} changed since the packet was prepared, or the packet was edited`, fix)], code: 1 });
283
+ const sourceSkip = !rebuilt && sources ? judge.sourceSkip?.(spec, draft) ?? null : null;
284
+ if (sourceSkip) {
285
+ return staleSources(`${judge.name} no longer applies (${sourceSkip})`, `Restore ${sources} and record this verdict again; as they are now, judge prepare skips ${judge.name} for this spec and draft.`);
286
+ }
287
+ if (rebuilt && rebuilt.packetBytes !== packetText && sources && packetJson(packet) === packetText
288
+ && JSON.stringify({ ...packet, inputs: null }) === JSON.stringify({ ...rebuilt.packet, inputs: null })) {
289
+ return staleSources(`the packet's inputs no longer match what the spec, the draft and ${sources} produce now`, `Run judge prepare again (with --force) so the packet is built from the spec, the draft and ${sources} as they are now, and judge the new packet.`);
290
+ }
291
+ if (!rebuilt) {
292
+ return { ...base, ok: false, invalid: true, findings: [t.finding("judge-packet-altered", `${packetPathArg} is for a station that does not apply to this spec and draft (${skip})`, `judge prepare writes no ${judge.name} packet for this spec and draft; record verdicts only on packets prepare writes, and never edit one.`)], code: 1 };
293
+ }
294
+ if (rebuilt.packetBytes !== packetText) {
295
+ return { ...base, ok: false, invalid: true, findings: [t.finding("judge-packet-altered", `${packetPathArg} is not the packet judge prepare builds from the spec and draft on disk`, "Run judge prepare again (with --force) and judge the new packet; never edit a packet.")], code: 1 };
296
+ }
297
+
298
+ let verdictJson;
299
+ try { verdictJson = JSON.parse(verdictBuf.toString("utf8").replace(/^/, "")); }
300
+ catch (e) {
301
+ return { ...base, ok: false, invalid: true, findings: [t.finding("judge-verdict-not-json", `the verdict is not valid JSON (${e instanceof Error ? e.message : String(e)})`, "Write the verdict as one JSON object in the packet's verdict_schema shape.")], code: 1 };
302
+ }
303
+
304
+ let problems;
305
+ let derived;
306
+ try {
307
+ problems = judge.validate(verdictJson, rebuilt.packet, t, rebuilt.key);
308
+ if (!problems.length) derived = judge.derive(verdictJson, rebuilt.packet, t, rebuilt.key);
309
+ } catch (e) {
310
+ const crash = t.finding(`judge-${judge.name}-crashed`, withoutAbsolutePaths(e instanceof Error ? e.message : String(e)), "Fix the station or file an issue; it should never throw.");
311
+ return { ...base, ok: false, invalid: true, findings: [crash], code: 1 };
312
+ }
313
+ if (problems.length) return { ...base, ok: false, invalid: true, findings: problems, code: 1 };
314
+
315
+ // summary: an optional one-line result a station reports beside its status (attribution's
316
+ // accuracy); printed after the status and carried in --json, never in the ledger line.
317
+ const { status, findings, summary } = derived;
318
+
319
+ // ---- ledger: the same truth rules as check: compared with the most recent earlier
320
+ // judge line for the same station and draft path, through ledgerVerdict; two judge-only rules on
321
+ // top are marked below.
322
+ let ledgerPath = null;
323
+ let ledgerWarning = null;
324
+ let verdict = null;
325
+ let verdictDetail = {};
326
+ const ledger = openLedger(spec);
327
+ if (ledger?.warning) ledgerWarning = ledger.warning;
328
+ else if (ledger) {
329
+ const draftKey = ledgerDraftKey(spec.dir, packet.draft);
330
+ // packet_sha256: the hash of what the judge was shown, taken over the packet with its two paths
331
+ // written as the ledger writes them (relative to the spec's folder), so the same packet
332
+ // prepared from another folder, or as ./draft.md, hashes the same and a path's spelling can
333
+ // never pass for a change.
334
+ const packetSha = sha256(packetJson({ ...rebuilt.packet, spec: ledgerDraftKey(spec.dir, packet.spec), draft: draftKey }));
335
+ // inputs_sha256: the hash of the packet's inputs alone, so a packet that changed can be told
336
+ // apart as changed inputs (the files the station reads) or changed fixed text (a release that
337
+ // rewords the instructions or the schema).
338
+ const inputsSha = sha256(JSON.stringify(rebuilt.packet.inputs));
339
+ const judgeLines = priorLines(ledger.priorText, "judge");
340
+ const prior = judgeLines.filter((l) => l.station === judge.name && l.draft === draftKey).at(-1);
341
+ const last = prior ? { stations: { [prior.station]: prior.status }, draft_sha256: prior.draft_sha256, spec_sha256: prior.spec_sha256 } : undefined;
342
+ // What changed since that line is judged by what the judge was shown: the packet. The draft
343
+ // and the spec are named when their bytes changed. A packet that changed with both unchanged
344
+ // was changed either in its inputs, by the files the station reads besides them (the DNA
345
+ // scope, the claims ledger), which are named, or in its fixed text (hyperspec's instructions
346
+ // or format), which is said. A line with no packet_sha256 (none is written without one) is
347
+ // compared by the two hashes alone.
348
+ let what = null;
349
+ if (prior) {
350
+ const draftChanged = prior.draft_sha256 !== draft.sha256;
351
+ const specChanged = prior.spec_sha256 !== specSha;
352
+ if (draftChanged || specChanged) what = draftChanged && specChanged ? "spec and draft" : specChanged ? "spec" : "draft";
353
+ else if (typeof prior.packet_sha256 === "string" && prior.packet_sha256 !== packetSha) {
354
+ what = prior.inputs_sha256 === inputsSha ? "the packet's fixed text (hyperspec's instructions or format)" : sources ?? "the packet's inputs";
355
+ }
356
+ }
357
+ ({ verdict, detail: verdictDetail } = ledgerVerdict({
358
+ last, statusNow: { [judge.name]: status }, draftSha: draft.sha256, specSha, noun: "judgment", what,
359
+ // A judge can answer differently about an identical packet; that is not the work improving.
360
+ unchangedImprovedReason: "the verdict changed; nothing the judge was shown changed",
361
+ }));
362
+ // one-shot means these bytes passed the first time they were judged, whatever the file was
363
+ // called: bytes already judged under another path (a copy, a rename) never earn it.
364
+ if (verdict === "one-shot") {
365
+ const sameBytes = judgeLines.filter((l) => l.station === judge.name && l.draft_sha256 === draft.sha256).at(-1);
366
+ if (sameBytes) { verdict = "not-improved"; verdictDetail = { reason: `these draft bytes were judged before as ${sameBytes.draft}: ${sameBytes.status}` }; }
367
+ }
368
+ const line = {
369
+ at: new Date().toISOString(),
370
+ kind: "judge",
371
+ station: judge.name,
372
+ draft: draftKey,
373
+ draft_sha256: draft.sha256,
374
+ spec_sha256: specSha,
375
+ packet_sha256: packetSha,
376
+ inputs_sha256: inputsSha,
377
+ status,
378
+ verdict,
379
+ ...verdictDetail,
380
+ };
381
+ appendFileSync(ledger.abs, `${JSON.stringify(line)}\n`);
382
+ ledgerPath = ledger.decl;
383
+ }
384
+
385
+ return { ...base, ok: true, status, ...(summary ? { summary } : {}), findings, verdict, verdictDetail, ledgerPath, ledgerWarning, code: status === "pass" ? 0 : 1 };
386
+ }