@supersuit/hyperspec 0.7.0 → 0.9.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 (45) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +76 -9
  3. package/SPEC.md +2 -2
  4. package/WRITING.md +463 -18
  5. package/bin/hyperspec.mjs +137 -6
  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 +64 -0
  12. package/examples/writing/course/part-2.md +57 -0
  13. package/examples/writing/course/runs.jsonl +0 -0
  14. package/examples/writing/course.hyperspec.md +206 -0
  15. package/examples/writing/essay/judge/panel-buyer.packet.json +118 -0
  16. package/examples/writing/essay/judge/panel-expert.packet.json +114 -0
  17. package/examples/writing/essay/judge/panel-novice.packet.json +114 -0
  18. package/examples/writing/essay/judge/panel-skeptic.packet.json +114 -0
  19. package/examples/writing/essay/sample-verdicts/panel-buyer.verdict.json +11 -0
  20. package/examples/writing/essay/sample-verdicts/panel-expert.verdict.json +13 -0
  21. package/examples/writing/essay/sample-verdicts/panel-novice.verdict.json +11 -0
  22. package/examples/writing/essay/sample-verdicts/panel-skeptic.verdict.json +13 -0
  23. package/examples/writing/story/judge/panel-buyer.packet.json +118 -0
  24. package/examples/writing/story/judge/panel-expert.packet.json +114 -0
  25. package/examples/writing/story/judge/panel-novice.packet.json +114 -0
  26. package/examples/writing/story/judge/panel-skeptic.packet.json +114 -0
  27. package/examples/writing/story/sample-verdicts/panel-buyer.verdict.json +13 -0
  28. package/examples/writing/story/sample-verdicts/panel-expert.verdict.json +11 -0
  29. package/examples/writing/story/sample-verdicts/panel-novice.verdict.json +11 -0
  30. package/examples/writing/story/sample-verdicts/panel-skeptic.verdict.json +11 -0
  31. package/package.json +1 -1
  32. package/src/check.mjs +25 -7
  33. package/src/evidence.mjs +74 -0
  34. package/src/judge.mjs +50 -66
  35. package/src/judges/index.mjs +7 -1
  36. package/src/judges/panel.mjs +135 -0
  37. package/src/sequence-draft.mjs +75 -0
  38. package/src/stations/index.mjs +5 -1
  39. package/src/stations/links.mjs +11 -3
  40. package/src/stations/quotes.mjs +26 -2
  41. package/src/stations/sequence.mjs +388 -0
  42. package/src/stations/triage.mjs +20 -0
  43. package/src/triage.mjs +401 -0
  44. package/src/writing-fields.mjs +81 -0
  45. package/src/writing.mjs +5 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/hyperspec",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "A hyperspec is a spec written for an agent: every decision accounted for, every requirement failable and checked, every field traced. The standard and its linter.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/check.mjs CHANGED
@@ -21,6 +21,7 @@ import { readDraft } from "./draft.mjs";
21
21
  import { openLedger, priorLines, ledgerDraftKey, ledgerVerdict } from "./ledger.mjs";
22
22
  import { STATIONS, STATION_NAMES } from "./stations/index.mjs";
23
23
  import { str } from "./placeholder.mjs";
24
+ import { readSequenceDraft, sequenceFilesDecl, sourceAt } from "./sequence-draft.mjs";
24
25
 
25
26
  const present = (v) => typeof v === "string" && v.trim().length > 0;
26
27
 
@@ -95,17 +96,29 @@ export function lintBlock(spec, specPathArg) {
95
96
  };
96
97
  }
97
98
 
99
+ // A finding from a draft assembled out of a sequence's files names the file and its own line in
100
+ // it, rather than a line of the joined text nobody can open.
101
+ function locate(finding, draft) {
102
+ const src = typeof finding.line === "number" ? sourceAt(draft, finding.line) : null;
103
+ return src ? { ...finding, file: src.file, line: finding.line - src.startLine + 1 } : finding;
104
+ }
105
+
98
106
  // runCheck(specPathArg, draftPathArg, { only }): specPathArg and draftPathArg are exactly what
99
107
  // the CLI (or a caller) was given, never resolved, so every path this returns or writes to the
100
108
  // ledger is displayed and recorded the way the operator typed it, not as an absolute path on this
101
109
  // machine. only, when given, is an array of station names to run instead of every registered one.
110
+ //
111
+ // With no draftPathArg, a spec that lists writing.form.sequence.files is checked against those
112
+ // files, joined in reading order (src/sequence-draft.mjs); any other spec still needs --draft.
102
113
  export function runCheck(specPathArg, draftPathArg, { only } = {}) {
103
114
  if (!present(specPathArg)) return { usage: true, error: "check needs a spec path" };
104
- if (!present(draftPathArg)) return { usage: true, error: "check needs --draft <file>" };
115
+ const needsDraft = { usage: true, error: "check needs --draft <file>" };
105
116
 
106
117
  const loaded = loadWritingSpec(specPathArg, "check");
118
+ if (!present(draftPathArg) && (loaded.usage || !sequenceFilesDecl(loaded.spec).length)) return needsDraft;
107
119
  if (loaded.usage) return loaded;
108
120
  const { spec } = loaded;
121
+ const fromSequence = !present(draftPathArg);
109
122
 
110
123
  // --only: every name must be one this build's registry knows; unknown names are a usage error
111
124
  // (exit 2) rather than a silent no-op, and the run order always follows the registry, never the
@@ -123,12 +136,16 @@ export function runCheck(specPathArg, draftPathArg, { only } = {}) {
123
136
  const blocked = lintBlock(spec, specPathArg);
124
137
  if (blocked) return blocked;
125
138
 
126
- // BOM stripped, CRLF-clean lines, sha256 over the raw bytes: see src/draft.mjs.
127
- const draft = readDraft(draftPathArg);
128
- if (!draft) return { usage: true, error: `cannot read draft: ${draftPathArg}` };
139
+ // BOM stripped, CRLF-clean lines, sha256 over the raw bytes: see src/draft.mjs. A sequence's
140
+ // draft is its files joined; its ledger key is the files entry as the spec writes it, so the
141
+ // history of the work stays one history as parts are added.
142
+ const draft = fromSequence ? readSequenceDraft(spec, specPathArg) : readDraft(draftPathArg);
143
+ const draftLabel = fromSequence ? sequenceFilesDecl(spec).join(", ") : draftPathArg;
144
+ if (!draft) return { usage: true, error: fromSequence ? `writing.form.sequence.files matches no file: ${draftLabel}` : `cannot read draft: ${draftPathArg}` };
129
145
 
130
146
  const ctx = {};
131
- const results = stationsToRun.map((s) => runStation(s, spec, draft, ctx));
147
+ const results = stationsToRun.map((s) => runStation(s, spec, draft, ctx))
148
+ .map((r) => (draft.sources ? { ...r, findings: r.findings.map((f) => locate(f, draft)) } : r));
132
149
  const failing = results.filter((r) => r.status === "fail").map((r) => r.station);
133
150
  const code = failing.length ? 1 : 0;
134
151
 
@@ -146,7 +163,7 @@ export function runCheck(specPathArg, draftPathArg, { only } = {}) {
146
163
  const ledger = openLedger(spec);
147
164
  if (ledger?.warning) ledgerWarning = ledger.warning;
148
165
  else if (ledger) {
149
- const draftKey = ledgerDraftKey(spec.dir, draftPathArg);
166
+ const draftKey = fromSequence ? draftLabel : ledgerDraftKey(spec.dir, draftPathArg);
150
167
  const specSha = sha256(readFileSync(resolve(specPathArg)));
151
168
  const statusNow = Object.fromEntries(results.map((r) => [r.station, r.status]));
152
169
 
@@ -177,7 +194,8 @@ export function runCheck(specPathArg, draftPathArg, { only } = {}) {
177
194
  return {
178
195
  ok: true,
179
196
  specPath: specPathArg,
180
- draftPath: draftPathArg,
197
+ draftPath: draftLabel,
198
+ ...(fromSequence ? { files: draft.sources.map((x) => x.file) } : {}),
181
199
  draftSha256: draft.sha256,
182
200
  stations: results,
183
201
  failing,
@@ -0,0 +1,74 @@
1
+ // The evidence rule, shared by every command that holds a quoted span to a draft: `judge record`
2
+ // (every passage a judge cites), and `triage` (every answer that says where the draft now does
3
+ // what a finding asked, and every finding an outside review quotes). One rule, so a span that
4
+ // counts for a judge counts for a triage answer, and the reverse.
5
+ //
6
+ // A span of evidence counts as quoted from the draft when, after both are normalized, the draft
7
+ // contains it. Normalization collapses every run of whitespace (spaces, tabs, line breaks, CRLF) to
8
+ // one space and turns curly, low and angle quotation marks and apostrophes into their straight
9
+ // forms (primes are not quotation marks and are left alone), so a judge that reflows a quotation or
10
+ // types typographic quotes is still quoting; changing a single word is not.
11
+ //
12
+ // A span must also carry at least MIN_EVIDENCE_WORDS word tokens (runs of letters and digits), and
13
+ // match on word boundaries: a match may not start or end in the middle of a word. A one-letter or
14
+ // one-word "quotation" is found almost anywhere and so checks nothing.
15
+
16
+ import { lineAt } from "./stations/util.mjs";
17
+
18
+ export const MIN_EVIDENCE_WORDS = 3;
19
+ const WORD_CHAR = /[\p{L}\p{N}]/u;
20
+ export const WORD_TOKENS = /[\p{L}\p{N}]+/gu;
21
+
22
+ const QUOTE_CHARS = new Map([
23
+ ["‘", "'"], ["’", "'"], ["‚", "'"], ["‛", "'"], ["‹", "'"], ["›", "'"],
24
+ ["“", '"'], ["”", '"'], ["„", '"'], ["‟", '"'], ["«", '"'], ["»", '"'],
25
+ ]);
26
+
27
+ // { norm, map }: the normalized text, and for each of its characters the offset in `text` it came
28
+ // from, so a match in the normalized text can be traced back to a line of the original.
29
+ export function normalizeForEvidence(text) {
30
+ let norm = "";
31
+ const map = [];
32
+ let pendingSpace = -1;
33
+ for (let i = 0; i < text.length; i++) {
34
+ const ch = text[i];
35
+ if (/\s/.test(ch)) { if (norm && pendingSpace < 0) pendingSpace = i; continue; }
36
+ if (pendingSpace >= 0) { norm += " "; map.push(pendingSpace); pendingSpace = -1; }
37
+ norm += QUOTE_CHARS.get(ch) ?? ch;
38
+ map.push(i);
39
+ }
40
+ return { norm, map };
41
+ }
42
+
43
+ // The number of word tokens in `span`, as the rule counts them.
44
+ export const evidenceWords = (span) => (normalizeForEvidence(String(span)).norm.match(WORD_TOKENS) ?? []).length;
45
+
46
+ // A locator bound to one text: locate(span) is the first whole-word match of `span`, as
47
+ // { line, start, end } (1-based line; start and end are offsets into the original text, end
48
+ // exclusive), or null. Build it once per text: normalizing is the cost.
49
+ export function evidenceLocator(text) {
50
+ const { norm, map } = normalizeForEvidence(text);
51
+ return (span) => {
52
+ const needle = normalizeForEvidence(String(span ?? "")).norm;
53
+ if (!needle) return null;
54
+ const startsWord = WORD_CHAR.test(needle[0]);
55
+ const endsWord = WORD_CHAR.test(needle[needle.length - 1]);
56
+ for (let at = norm.indexOf(needle); at >= 0; at = norm.indexOf(needle, at + 1)) {
57
+ const before = at > 0 ? norm[at - 1] : "";
58
+ const after = norm[at + needle.length] ?? "";
59
+ if (startsWord && before && WORD_CHAR.test(before)) continue;
60
+ if (endsWord && after && WORD_CHAR.test(after)) continue;
61
+ const start = map[at];
62
+ return { line: lineAt(text, start), start, end: map[at + needle.length - 1] + 1 };
63
+ }
64
+ return null;
65
+ };
66
+ }
67
+
68
+ // Why `span` is not evidence of `locate`'s text: "missing" (empty or not a string), "too-short"
69
+ // (fewer than MIN_EVIDENCE_WORDS words) or "not-found"; null when it is evidence.
70
+ export function evidenceProblem(locate, span) {
71
+ if (typeof span !== "string" || !span.trim()) return "missing";
72
+ if (evidenceWords(span) < MIN_EVIDENCE_WORDS) return "too-short";
73
+ return locate(span) ? null : "not-found";
74
+ }
package/src/judge.mjs CHANGED
@@ -16,69 +16,27 @@ import { writeFileAtomic } from "./fsutil.mjs";
16
16
  import { readDraft } from "./draft.mjs";
17
17
  import { loadWritingSpec, lintBlock, withoutAbsolutePaths } from "./check.mjs";
18
18
  import { openLedger, priorLines, ledgerDraftKey, ledgerVerdict } from "./ledger.mjs";
19
- import { lineAt, truncate } from "./stations/util.mjs";
19
+ import { truncate } from "./stations/util.mjs";
20
20
  import { JUDGES, JUDGE_NAMES } from "./judges/index.mjs";
21
+ import { MIN_EVIDENCE_WORDS, normalizeForEvidence, evidenceLocator, evidenceWords } from "./evidence.mjs";
22
+ import { addFindings } from "./triage.mjs";
21
23
 
22
24
  export const PACKET_VERSION = "0.1";
23
25
 
24
26
  const present = (v) => typeof v === "string" && v.trim().length > 0;
25
27
 
26
28
  // ---- 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
- }
29
+ // Lives in src/evidence.mjs, shared with `triage`; re-exported here for the callers that import it
30
+ // from this module.
31
+ export { MIN_EVIDENCE_WORDS, normalizeForEvidence };
61
32
 
62
33
  // The helpers a station's validate() and derive() receive, bound to one packet and one draft: every
63
34
  // finding they build has the same shape as a `check` finding ({ station, id, severity, message,
64
35
  // fix, line? }).
65
36
  function toolsFor(stationName, draft) {
66
- const { norm, map } = normalizeForEvidence(draft.text);
37
+ const locator = evidenceLocator(draft.text);
67
38
  // 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
- };
39
+ const locate = (span) => locator(span)?.line ?? -1;
82
40
  const finding = (id, message, fix, line) => ({ station: stationName, id, severity: "fail", message, fix, ...(typeof line === "number" && line > 0 ? { line } : {}) });
83
41
  return {
84
42
  finding,
@@ -88,7 +46,7 @@ function toolsFor(stationName, draft) {
88
46
  if (typeof value !== "string" || !value.trim()) {
89
47
  return [finding("judge-evidence-missing", `${where} is empty or not a string`, "Quote the span of the draft this judgment rests on, verbatim.")];
90
48
  }
91
- const words = (normalizeForEvidence(value).norm.match(WORD_TOKENS) ?? []).length;
49
+ const words = evidenceWords(value);
92
50
  if (words < MIN_EVIDENCE_WORDS) {
93
51
  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
52
  }
@@ -122,8 +80,10 @@ const packetJson = (packet) => `${JSON.stringify(packet, null, 2)}\n`;
122
80
  // key }: key is the station's hidden answer key (null for a station with none), written by prepare
123
81
  // to <station>.key.json for a person to read, and never read back as truth: record rebuilds it.
124
82
  // 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);
83
+ // variant: for a station that writes one packet per variant (the panel, one per reader), the
84
+ // variant this packet is for; undefined for every other station.
85
+ export function buildPacket(judge, specPathArg, spec, draft, specSha, variant) {
86
+ const parts = judge.packet(spec, draft, variant);
127
87
  const packet = {
128
88
  hyperspec_judge: PACKET_VERSION,
129
89
  station: judge.name,
@@ -182,14 +142,18 @@ export function prepareJudges(specPathArg, draftPathArg, outDirArg, { only, forc
182
142
  for (const judge of judges) {
183
143
  const reason = judge.skipReason(spec, draft);
184
144
  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;
145
+ // A station with variants (the panel) writes <station>-<variant>.packet.json for each.
146
+ for (const variant of judge.variants ? judge.variants(spec) : [undefined]) {
147
+ const base = variant ? `${judge.name}-${variant.id}` : judge.name;
148
+ let built;
149
+ try { built = buildPacket(judge, specPathArg, spec, draft, specSha, variant); }
150
+ catch (e) {
151
+ 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." });
152
+ continue;
153
+ }
154
+ files.push({ station: judge.name, ...(variant ? { reader: variant.id } : {}), path: join(outDirArg, `${base}.packet.json`), bytes: built.packetBytes });
155
+ if (built.key) files.push({ station: judge.name, path: join(outDirArg, `${base}.key.json`), bytes: packetJson(built.key) });
190
156
  }
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
157
  }
194
158
 
195
159
  const existing = files.filter((f) => existsSync(resolve(f.path))).map((f) => f.path);
@@ -203,7 +167,7 @@ export function prepareJudges(specPathArg, draftPathArg, outDirArg, { only, forc
203
167
  specPath: specPathArg,
204
168
  draftPath: draftPathArg,
205
169
  draftSha256: draft.sha256,
206
- written: files.map((f) => ({ station: f.station, path: f.path })),
170
+ written: files.map((f) => ({ station: f.station, ...(f.reader ? { reader: f.reader } : {}), path: f.path })),
207
171
  skipped,
208
172
  crashed,
209
173
  code: crashed.length ? 1 : 0,
@@ -242,7 +206,11 @@ export function recordJudgment(packetPathArg, verdictPathArg) {
242
206
  const draft = readDraft(packet.draft);
243
207
  if (!draft) return { usage: true, error: `cannot read the packet's draft: ${packet.draft}` };
244
208
 
245
- const base = { packetPath: packetPathArg, verdictPath: verdictPathArg, station: judge.name };
209
+ // A station with variants (the panel) names its variant in the packet; record rebuilds the packet
210
+ // for that variant, so the name is checked like every other byte of it.
211
+ const variantId = judge.variants ? judge.variantOf(packet) : undefined;
212
+ const variant = judge.variants ? judge.variants(spec).find((v) => v.id === variantId) : undefined;
213
+ const base = { packetPath: packetPathArg, verdictPath: verdictPathArg, station: judge.name, ...(judge.variants ? { reader: String(variantId ?? "") } : {}) };
246
214
  const t = toolsFor(judge.name, draft);
247
215
 
248
216
  // A verdict on bytes other than the ones the packet was prepared from judges nothing that exists.
@@ -259,9 +227,12 @@ export function recordJudgment(packetPathArg, verdictPathArg) {
259
227
  // the rebuilt copy only. A packet edited after prepare (its conditions, its inputs, a hash made to
260
228
  // match a changed draft) is refused here.
261
229
  const skip = judge.skipReason(spec, draft);
230
+ if (!skip && judge.variants && !variant) {
231
+ return { ...base, ok: false, invalid: true, findings: [t.finding("judge-packet-altered", `${packetPathArg} is for a ${judge.name} reader this spec does not name (${String(variantId ?? "none")})`, `judge prepare writes a ${judge.name} packet only for each reader the spec names; record verdicts only on packets prepare writes, and never edit one.`)], code: 1 };
232
+ }
262
233
  let rebuilt = null;
263
234
  if (!skip) {
264
- try { rebuilt = buildPacket(judge, packet.spec, spec, draft, specSha); }
235
+ try { rebuilt = buildPacket(judge, packet.spec, spec, draft, specSha, variant); }
265
236
  catch (e) {
266
237
  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
238
  }
@@ -337,7 +308,9 @@ export function recordJudgment(packetPathArg, verdictPathArg) {
337
308
  // rewords the instructions or the schema).
338
309
  const inputsSha = sha256(JSON.stringify(rebuilt.packet.inputs));
339
310
  const judgeLines = priorLines(ledger.priorText, "judge");
340
- const prior = judgeLines.filter((l) => l.station === judge.name && l.draft === draftKey).at(-1);
311
+ // A panel reader's history is its own: the skeptic's line is compared with the skeptic's.
312
+ const sameReader = (l) => !judge.variants || l.reader === variant.id;
313
+ const prior = judgeLines.filter((l) => l.station === judge.name && sameReader(l) && l.draft === draftKey).at(-1);
341
314
  const last = prior ? { stations: { [prior.station]: prior.status }, draft_sha256: prior.draft_sha256, spec_sha256: prior.spec_sha256 } : undefined;
342
315
  // What changed since that line is judged by what the judge was shown: the packet. The draft
343
316
  // and the spec are named when their bytes changed. A packet that changed with both unchanged
@@ -362,13 +335,14 @@ export function recordJudgment(packetPathArg, verdictPathArg) {
362
335
  // one-shot means these bytes passed the first time they were judged, whatever the file was
363
336
  // called: bytes already judged under another path (a copy, a rename) never earn it.
364
337
  if (verdict === "one-shot") {
365
- const sameBytes = judgeLines.filter((l) => l.station === judge.name && l.draft_sha256 === draft.sha256).at(-1);
338
+ const sameBytes = judgeLines.filter((l) => l.station === judge.name && sameReader(l) && l.draft_sha256 === draft.sha256).at(-1);
366
339
  if (sameBytes) { verdict = "not-improved"; verdictDetail = { reason: `these draft bytes were judged before as ${sameBytes.draft}: ${sameBytes.status}` }; }
367
340
  }
368
341
  const line = {
369
342
  at: new Date().toISOString(),
370
343
  kind: "judge",
371
344
  station: judge.name,
345
+ ...(judge.variants ? { reader: variant.id } : {}),
372
346
  draft: draftKey,
373
347
  draft_sha256: draft.sha256,
374
348
  spec_sha256: specSha,
@@ -382,5 +356,15 @@ export function recordJudgment(packetPathArg, verdictPathArg) {
382
356
  ledgerPath = ledger.decl;
383
357
  }
384
358
 
385
- return { ...base, ok: true, status, ...(summary ? { summary } : {}), findings, verdict, verdictDetail, ledgerPath, ledgerWarning, code: status === "pass" ? 0 : 1 };
359
+ // A station whose verdict yields findings to answer (the panel) hands them to the triage file,
360
+ // beside the runs ledger, where `hyperspec triage` answers them and `check` holds them.
361
+ let triage = null;
362
+ let line = summary;
363
+ if (derived.triage) {
364
+ triage = addFindings(spec, derived.triage, { source: judge.name, reader: variant?.id ?? null, draftSha: draft.sha256, idPrefix: variant ? `${judge.name}-${variant.id}` : judge.name });
365
+ if (triage.warning) ledgerWarning = ledgerWarning ?? triage.warning;
366
+ else line = `${summary ? `${summary}; ` : ""}${triage.added} added to triage (${triage.decl})${triage.already ? `, ${triage.already} already there` : ""}`;
367
+ }
368
+
369
+ return { ...base, ok: true, status, ...(line ? { summary: line } : {}), findings, ...(triage && !triage.warning ? { triage: { path: triage.decl, added: triage.added, already: triage.already } } : {}), verdict, verdictDetail, ledgerPath, ledgerWarning, code: status === "pass" ? 0 : 1 };
386
370
  }
@@ -9,7 +9,11 @@
9
9
  // packet whose inputs went out of date stale rather than edited; such a station also carries
10
10
  // sourceSkip(spec, draft), the skip reason when it skips because of those files (else null), so a
11
11
  // packet whose station stopped applying for that reason is stale too, and any other skip is not. The packet and key a station receives are
12
- // always the ones record rebuilt from the spec and draft on disk, never read from a file; see
12
+ // always the ones record rebuilt from the spec and draft on disk, never read from a file. A
13
+ // station that writes one packet per variant (the panel: one per reader) also carries variants(spec),
14
+ // the list of { id, ... } it writes packets for, and variantOf(packet), the id a packet names;
15
+ // packet() then receives the variant as a third argument, the file is <station>-<id>.packet.json,
16
+ // and derive may return triage, the findings `hyperspec triage` answers. See
13
17
  // src/judge.mjs for the framework that calls them. Adding a judge is one file plus one line here.
14
18
 
15
19
  import * as doctor from "./doctor.mjs";
@@ -18,6 +22,7 @@ import * as reader from "./reader.mjs";
18
22
  import * as persona from "./persona.mjs";
19
23
  import * as attribution from "./attribution.mjs";
20
24
  import * as knowledge from "./knowledge.mjs";
25
+ import * as panel from "./panel.mjs";
21
26
 
22
27
  export const JUDGES = Object.freeze([
23
28
  { name: doctor.name, instructions: doctor.DOCTOR_INSTRUCTIONS, skipReason: doctor.skipReason, packet: doctor.packet, validate: doctor.validate, derive: doctor.derive },
@@ -26,6 +31,7 @@ export const JUDGES = Object.freeze([
26
31
  { name: persona.name, instructions: persona.PERSONA_INSTRUCTIONS, skipReason: persona.skipReason, packet: persona.packet, validate: persona.validate, derive: persona.derive, inputSources: persona.inputSources, sourceSkip: persona.sourceSkip },
27
32
  { name: attribution.name, instructions: attribution.ATTRIBUTION_INSTRUCTIONS, skipReason: attribution.skipReason, packet: attribution.packet, validate: attribution.validate, derive: attribution.derive },
28
33
  { name: knowledge.name, instructions: knowledge.KNOWLEDGE_INSTRUCTIONS, skipReason: knowledge.skipReason, packet: knowledge.packet, validate: knowledge.validate, derive: knowledge.derive },
34
+ { name: panel.name, instructions: panel.PANEL_INSTRUCTIONS, skipReason: panel.skipReason, packet: panel.packet, validate: panel.validate, derive: panel.derive, variants: panel.variants, variantOf: panel.variantOf },
29
35
  ]);
30
36
 
31
37
  export const JUDGE_NAMES = Object.freeze(JUDGES.map((j) => j.name));
@@ -0,0 +1,135 @@
1
+ // The panel (hyperspec 0.9): the draft read by several readers at once, each through their own
2
+ // lens, the way a draft is pressure-tested by hand before it ships. Every other judgment station
3
+ // asks one question with a right answer; the panel asks each reader what works, what to improve,
4
+ // what is missing and what to remove, and every answer is a finding somebody has to answer.
5
+ //
6
+ // One packet per reader (panel-<id>.packet.json): the readers writing.panel declares, or with none
7
+ // declared the default three (a skeptic, a newcomer and an expert), and always the spec's own
8
+ // audience reader, added last as "buyer", because a panel that never includes the person the piece
9
+ // is for tests everything except whether it works. Every item a reader lists carries evidence
10
+ // copied from the draft (src/evidence.mjs's rule; a missing item anchors to the passage nearest
11
+ // where it belongs), so a review can never be of a stale or truncated copy.
12
+ //
13
+ // The panel never fails a draft: its findings are warnings, and each improve, missing and remove
14
+ // item goes to the triage file (src/triage.mjs), where the operator answers it and `check` holds
15
+ // every answer to the draft. What works is counted, never triaged: there is nothing to answer.
16
+
17
+ import { str } from "../placeholder.mjs";
18
+
19
+ export const name = "panel";
20
+
21
+ export const PANEL_INSTRUCTIONS = [
22
+ "Read inputs.draft as inputs.reader: the person inputs.reader.who describes, knowing only what inputs.reader.knows lists and what anyone would, reading for inputs.reader.lens.",
23
+ "List what works for this reader under good, what should change under improve, what this reader needs that the draft does not give under missing, and what should go under remove.",
24
+ "Every item is { evidence, note }. evidence is a passage copied verbatim from inputs.draft, at least three whole words; for a missing item, quote the passage nearest to where the missing thing belongs. note says what and why, in a sentence, in this reader's terms.",
25
+ "A list may be empty. Report what this reader would say, not what another reader would.",
26
+ "Answer only in the verdict shape given in verdict_schema.",
27
+ ].join(" ");
28
+
29
+ // The default panel, used when writing.panel is not declared. The buyer is added to it, as to any.
30
+ export const DEFAULT_PANEL = Object.freeze([
31
+ Object.freeze({ id: "skeptic", who: "a skeptic who doubts the piece's central claim and wants it earned", knows: Object.freeze([]), lens: "what is asserted without support, overstated, or does not follow" }),
32
+ Object.freeze({ id: "novice", who: "a newcomer to the subject, meeting its ideas for the first time", knows: Object.freeze([]), lens: "every term, step or assumption the piece does not explain" }),
33
+ Object.freeze({ id: "expert", who: "an expert in the piece's subject", knows: Object.freeze([]), lens: "what is wrong, out of date, oversimplified or missing for someone who knows the field" }),
34
+ ]);
35
+
36
+ export const KINDS = Object.freeze(["good", "improve", "missing", "remove"]);
37
+
38
+ const isObject = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
39
+ const texts = (v) => (Array.isArray(v) ? v.map(str).filter(Boolean) : []);
40
+ const nonEmpty = (v) => typeof v === "string" && v.trim().length > 0;
41
+
42
+ // null when the panel applies: its buyer is the audience's reader, so it needs a written audience
43
+ // block whose check names a rubric, the reader station's own condition.
44
+ export function skipReason(spec) {
45
+ const audience = spec.data?.writing?.audience;
46
+ if (!isObject(audience)) return "writing.audience is not written (deferred); the panel's buyer is the audience's reader";
47
+ if (!str(audience.check?.rubric)) return "writing.audience.check has no rubric";
48
+ return null;
49
+ }
50
+
51
+ // Every reader on this spec's panel, in order: the declared readers (or the default three), then
52
+ // the buyer. Each is { id, who, knows, lens }.
53
+ export function variants(spec) {
54
+ const declared = spec.data?.writing?.panel;
55
+ const readers = Array.isArray(declared) && declared.length
56
+ ? declared.filter(isObject).map((r) => ({ id: str(r.id), who: str(r.who), knows: texts(r.knows), lens: str(r.lens) })).filter((r) => r.id && r.id !== "buyer")
57
+ : DEFAULT_PANEL.map((r) => ({ id: r.id, who: r.who, knows: [...r.knows], lens: r.lens }));
58
+ const a = spec.data.writing.audience;
59
+ const buyer = { id: "buyer", who: str(a.who), knows: texts(a.knows), lens: `what they want from this piece: ${str(a.wants)}` };
60
+ return [...readers, buyer];
61
+ }
62
+
63
+ // Which reader a packet is for, as it says; record rebuilds the packet for that reader and
64
+ // compares every byte, so a changed name is caught with the rest.
65
+ export const variantOf = (packet) => packet?.inputs?.reader?.id;
66
+
67
+ const itemSchema = {
68
+ type: "object",
69
+ required: ["evidence", "note"],
70
+ properties: {
71
+ evidence: { type: "string", description: "a passage copied verbatim from the draft, at least three words; for a missing item, the passage nearest where it belongs" },
72
+ note: { type: "string", description: "what and why, in a sentence" },
73
+ },
74
+ };
75
+
76
+ // The packet's rubric (audience.check.rubric, verbatim: the buyer's test is the piece's), inputs
77
+ // (the reader and the draft) and verdict schema. No answer key.
78
+ export function packet(spec, draft, reader) {
79
+ return {
80
+ rubric: spec.data.writing.audience.check.rubric,
81
+ inputs: {
82
+ reader: { id: reader.id, who: reader.who, knows: reader.knows, lens: reader.lens },
83
+ draft: draft.text,
84
+ },
85
+ verdict_schema: {
86
+ type: "object",
87
+ required: [...KINDS],
88
+ properties: {
89
+ good: { type: "array", description: "what works for this reader; empty when nothing does", items: itemSchema },
90
+ improve: { type: "array", description: "what should change", items: itemSchema },
91
+ missing: { type: "array", description: "what this reader needs that the draft does not give", items: itemSchema },
92
+ remove: { type: "array", description: "what should go", items: itemSchema },
93
+ },
94
+ },
95
+ key: null,
96
+ };
97
+ }
98
+
99
+ // Every problem with the verdict, as findings; every evidence span must be in the draft.
100
+ export function validate(verdict, pkt, t) {
101
+ if (!isObject(verdict)) return [t.shape("the verdict is not a JSON object", "Write one object: { good, improve, missing, remove }.")];
102
+ const out = [];
103
+ for (const kind of KINDS) {
104
+ if (!Array.isArray(verdict[kind])) { out.push(t.shape(`${kind} is missing or not a list`, `Give ${kind}: one { evidence, note } per item, or [] when there is none.`)); continue; }
105
+ verdict[kind].forEach((item, i) => {
106
+ const at = `${kind}[${i}]`;
107
+ if (!isObject(item)) { out.push(t.shape(`${at} is not an object`, "Write it as { evidence, note }.")); return; }
108
+ if (!nonEmpty(item.note)) out.push(t.shape(`${at}.note is missing or empty`, "Say what and why, in a sentence."));
109
+ out.push(...t.evidence(item.evidence, `${at}.evidence`));
110
+ });
111
+ }
112
+ return out;
113
+ }
114
+
115
+ const WORD = { improve: "would improve", missing: "misses", remove: "would remove" };
116
+
117
+ // Always passes: every improve, missing and remove item is a warning at its evidence's line, and a
118
+ // finding for the triage file. The summary counts all four lists.
119
+ export function derive(verdict, pkt, t) {
120
+ const reader = pkt.inputs.reader.id;
121
+ const findings = [];
122
+ const triage = [];
123
+ for (const kind of ["improve", "missing", "remove"]) {
124
+ for (const item of verdict[kind]) {
125
+ const note = item.note.trim();
126
+ if (kind === "improve") findings.push({ ...t.finding("judge-panel-improve", `${reader} ${WORD[kind]} this: ${note}`, "Answer it in the triage file: take it, keep the passage with a reason, show it is already true, or leave it open for a decision.", t.lineOf(item.evidence)), severity: "warn" });
127
+ if (kind === "missing") findings.push({ ...t.finding("judge-panel-missing", `${reader} ${WORD[kind]} something here: ${note}`, "Answer it in the triage file: take it, keep the passage with a reason, show it is already true, or leave it open for a decision.", t.lineOf(item.evidence)), severity: "warn" });
128
+ if (kind === "remove") findings.push({ ...t.finding("judge-panel-remove", `${reader} ${WORD[kind]} this: ${note}`, "Answer it in the triage file: take it, keep the passage with a reason, show it is already true, or leave it open for a decision.", t.lineOf(item.evidence)), severity: "warn" });
129
+ triage.push({ kind, text: note, evidence: item.evidence });
130
+ }
131
+ }
132
+ const n = (k) => verdict[k].length;
133
+ const summary = `${reader}: ${n("good")} good, ${n("improve")} to improve, ${n("missing")} missing, ${n("remove")} to remove`;
134
+ return { status: "pass", findings, summary, triage };
135
+ }
@@ -0,0 +1,75 @@
1
+ // A work read in order often lives in several files (one per part, one per lesson). When a writing
2
+ // spec lists them as writing.form.sequence.files, `hyperspec check <spec>` needs no --draft: the
3
+ // draft is those files, joined in order. This module owns which files that is and how they join, so
4
+ // lint (every entry must match a file) and check (the draft) agree on one reading.
5
+
6
+ import { readdirSync, readFileSync, statSync } from "node:fs";
7
+ import { dirname, join, posix, resolve } from "node:path";
8
+ import { sha256 } from "./hash.mjs";
9
+ import { str } from "./placeholder.mjs";
10
+ import { splitLines } from "./draft.mjs";
11
+
12
+ const list = (v) => (Array.isArray(v) ? v : []);
13
+ const byNumber = (a, b) => a.localeCompare(b, "en", { numeric: true });
14
+
15
+ // The declared entries, as written: writing.form.sequence.files, strings only.
16
+ export function sequenceFilesDecl(spec) {
17
+ return list(spec?.data?.writing?.form?.sequence?.files).map(str).filter(Boolean);
18
+ }
19
+
20
+ // The files one entry names, relative to the spec's folder and written with "/": the entry itself
21
+ // when it has no "*", or every file in its folder whose name matches, sorted so part-2 comes before
22
+ // part-10. A "*" matches within a file name only; the folder part is taken literally. [] when
23
+ // nothing matches.
24
+ export function expandEntry(specDir, entry) {
25
+ const clean = entry.replace(/\\/g, "/");
26
+ const dir = posix.dirname(clean);
27
+ const base = posix.basename(clean);
28
+ const isFile = (rel) => { try { return statSync(resolve(specDir, rel)).isFile(); } catch { return false; } };
29
+ if (!base.includes("*")) return isFile(clean) ? [clean] : [];
30
+ const re = new RegExp(`^${base.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*")}$`);
31
+ let names = [];
32
+ try { names = readdirSync(resolve(specDir, dir)); } catch { return []; }
33
+ return names.filter((n) => re.test(n)).sort(byNumber).map((n) => (dir === "." ? n : `${dir}/${n}`)).filter(isFile);
34
+ }
35
+
36
+ // Every file of the sequence in reading order, each once (at its first position).
37
+ export function sequenceFiles(specDir, entries) {
38
+ const out = [];
39
+ for (const e of entries) for (const f of expandEntry(specDir, e)) if (!out.includes(f)) out.push(f);
40
+ return out;
41
+ }
42
+
43
+ // The draft `check` grades when the spec lists sequence files and no --draft is given:
44
+ // { path, text, lines, sha256, sources }, the same shape src/draft.mjs's readDraft returns plus
45
+ // sources, one per file: { file, at, startLine, lineCount }. file is the path relative to the spec
46
+ // (what a finding names); at resolves from the working directory (what a station opens, such as
47
+ // links resolving a relative link beside the file that holds it). Each file's YAML frontmatter is
48
+ // blanked line for line, so its metadata is not prose and its line numbers stay its own. sha256
49
+ // covers every file's name and bytes. path is the first file's. null when no file matches.
50
+ export function readSequenceDraft(spec, specPathArg) {
51
+ const files = sequenceFiles(spec.dir, sequenceFilesDecl(spec));
52
+ if (!files.length) return null;
53
+ const parts = [];
54
+ const hashed = [];
55
+ const sources = [];
56
+ let startLine = 1;
57
+ for (const file of files) {
58
+ const buf = readFileSync(resolve(spec.dir, file));
59
+ hashed.push(Buffer.from(`${file}\n`), buf);
60
+ let text = buf.toString("utf8").replace(/^\uFEFF/, "");
61
+ text = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, (fm) => fm.replace(/[^\n]/g, ""));
62
+ if (!text.endsWith("\n")) text += "\n";
63
+ const lineCount = text.split("\n").length - 1;
64
+ sources.push({ file, at: join(dirname(specPathArg), file), startLine, lineCount });
65
+ parts.push(text);
66
+ startLine += lineCount;
67
+ }
68
+ const text = parts.join("");
69
+ return { path: sources[0].at, text, lines: splitLines(text), sha256: sha256(Buffer.concat(hashed)), sources };
70
+ }
71
+
72
+ // The source holding 1-based draft line `line`, or null.
73
+ export function sourceAt(draft, line) {
74
+ return list(draft?.sources).find((s) => line >= s.startLine && line < s.startLine + s.lineCount) ?? null;
75
+ }
@@ -3,7 +3,7 @@
3
3
  // src/check.mjs calls; adding a station is adding one file plus one line here, which is the whole
4
4
  // point of the registry existing rather than check.mjs importing each station by name itself.
5
5
  //
6
- // The order: form, terms, claims, quotes, private, dna, links. quotes and private share ctx (util.mjs's
6
+ // The order: form, terms, claims, quotes, private, dna, links, sequence, triage. quotes and private share ctx (util.mjs's
7
7
  // markedSegments caches the spec's marked materials there), so a check run reads them once.
8
8
 
9
9
  import * as form from "./form.mjs";
@@ -13,6 +13,8 @@ import * as quotes from "./quotes.mjs";
13
13
  import * as privateStation from "./private.mjs";
14
14
  import * as dna from "./dna.mjs";
15
15
  import * as links from "./links.mjs";
16
+ import * as sequence from "./sequence.mjs";
17
+ import * as triage from "./triage.mjs";
16
18
 
17
19
  export const STATIONS = Object.freeze([
18
20
  { name: form.name, run: form.run },
@@ -22,6 +24,8 @@ export const STATIONS = Object.freeze([
22
24
  { name: privateStation.name, run: privateStation.run },
23
25
  { name: dna.name, run: dna.run },
24
26
  { name: links.name, run: links.run },
27
+ { name: sequence.name, run: sequence.run },
28
+ { name: triage.name, run: triage.run },
25
29
  ]);
26
30
 
27
31
  export const STATION_NAMES = Object.freeze(STATIONS.map((s) => s.name));