@supersuit/hyperspec 0.6.0 → 0.7.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 +91 -0
  2. package/README.md +37 -0
  3. package/SPEC.md +2 -2
  4. package/WRITING.md +542 -10
  5. package/bin/hyperspec.mjs +183 -0
  6. package/examples/writing/essay/judge/doctor.packet.json +108 -0
  7. package/examples/writing/essay/judge/lineup.packet.json +64 -0
  8. package/examples/writing/essay/judge/persona.packet.json +73 -0
  9. package/examples/writing/essay/judge/reader.packet.json +93 -0
  10. package/examples/writing/essay/learn/first-draft.md +84 -0
  11. package/examples/writing/essay/learn/learn.packet.json +106 -0
  12. package/examples/writing/essay/sample-verdicts/doctor.verdict.json +43 -0
  13. package/examples/writing/essay/sample-verdicts/learn.verdict.json +30 -0
  14. package/examples/writing/essay/sample-verdicts/lineup.verdict.json +6 -0
  15. package/examples/writing/essay/sample-verdicts/persona.verdict.json +4 -0
  16. package/examples/writing/essay/sample-verdicts/reader.verdict.json +7 -0
  17. package/examples/writing/essay.hyperspec.md +6 -1
  18. package/examples/writing/story/judge/attribution.packet.json +194 -0
  19. package/examples/writing/story/judge/doctor.packet.json +108 -0
  20. package/examples/writing/story/judge/knowledge.packet.json +77 -0
  21. package/examples/writing/story/judge/persona.packet.json +73 -0
  22. package/examples/writing/story/judge/reader.packet.json +94 -0
  23. package/examples/writing/story/sample-verdicts/attribution.verdict.json +81 -0
  24. package/examples/writing/story/sample-verdicts/doctor.verdict.json +43 -0
  25. package/examples/writing/story/sample-verdicts/knowledge.verdict.json +4 -0
  26. package/examples/writing/story/sample-verdicts/persona.verdict.json +20 -0
  27. package/examples/writing/story/sample-verdicts/reader.verdict.json +16 -0
  28. package/examples/writing/story.hyperspec.md +7 -3
  29. package/package.json +1 -1
  30. package/src/check.mjs +75 -129
  31. package/src/draft.mjs +26 -0
  32. package/src/judge.mjs +386 -0
  33. package/src/judges/attribution.mjs +360 -0
  34. package/src/judges/doctor.mjs +126 -0
  35. package/src/judges/index.mjs +31 -0
  36. package/src/judges/knowledge.mjs +111 -0
  37. package/src/judges/lineup.mjs +272 -0
  38. package/src/judges/persona.mjs +137 -0
  39. package/src/judges/reader.mjs +111 -0
  40. package/src/learn.mjs +422 -0
  41. package/src/ledger.mjs +108 -0
  42. package/src/sentences.mjs +81 -0
  43. package/src/stations/claims.mjs +44 -39
  44. package/src/stations/quotes.mjs +6 -4
  45. package/src/writing.mjs +1 -1
@@ -44,6 +44,44 @@ function firstOccurrence(text, claimText) {
44
44
  return m ? m.index : -1;
45
45
  }
46
46
 
47
+ // The claims ledger as the claims station reads it, shared with the persona judge
48
+ // (src/judges/persona.mjs), so both see the same claims. { path, missing, lines }: path is
49
+ // writing.sources.ledger as written (null when unset); missing is true when it is set but cannot be
50
+ // read; lines holds every non-blank line, 1-based as n, each either { n, text } (a claim with
51
+ // non-empty text; claim is the parsed object) or { n, problem } naming why it is not one.
52
+ export function readClaimsLedger(spec) {
53
+ const ledgerPath = str(spec?.data?.writing?.sources?.ledger);
54
+ if (!ledgerPath) return { path: null, missing: false, lines: [] };
55
+ let raw;
56
+ try {
57
+ // A leading UTF-8 BOM (written by default by several Windows/Excel-adjacent editors) is not
58
+ // valid JSON leading whitespace, so it must come off before line 1 is parsed, or a genuinely
59
+ // well-formed first line reports as broken JSON for a reason that has nothing to do with its
60
+ // content.
61
+ raw = readFileSync(resolve(spec?.dir || ".", ledgerPath), "utf8").replace(/^\uFEFF/, "");
62
+ } catch {
63
+ return { path: ledgerPath, missing: true, lines: [] };
64
+ }
65
+ const lines = [];
66
+ raw.split("\n").forEach((text, i) => {
67
+ if (text.trim() === "") return;
68
+ const n = i + 1;
69
+ let obj;
70
+ try { obj = JSON.parse(text); } catch { lines.push({ n, problem: "is not valid JSON" }); return; }
71
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) { lines.push({ n, problem: "is not a JSON object" }); return; }
72
+ const claimText = typeof obj.text === "string" ? obj.text : "";
73
+ if (!claimText.trim()) { lines.push({ n, problem: "has no text" }); return; }
74
+ lines.push({ n, text: claimText, claim: obj });
75
+ });
76
+ return { path: ledgerPath, missing: false, lines };
77
+ }
78
+
79
+ const PROBLEM_FIX = {
80
+ "is not valid JSON": "Fix the JSON on that line.",
81
+ "is not a JSON object": 'Each ledger line must be a JSON object: {"text": "...", "source": "..."}.',
82
+ "has no text": "Add text: the claim exactly as it appears in the draft.",
83
+ };
84
+
47
85
  export function run(spec, draft) {
48
86
  const sources = spec?.data?.writing?.sources ?? {};
49
87
  const ledgerPath = str(sources.ledger);
@@ -57,15 +95,8 @@ export function run(spec, draft) {
57
95
  return { station: name, status: "skip", findings: [], reason: "writing.sources.ledger is not set" };
58
96
  }
59
97
 
60
- const ledgerAbs = resolve(spec?.dir || ".", ledgerPath);
61
- let raw;
62
- try {
63
- // A leading UTF-8 BOM (written by default by several Windows/Excel-adjacent editors) is not
64
- // valid JSON leading whitespace, so it must come off before line 1 is parsed, or a genuinely
65
- // well-formed first line reports as broken JSON for a reason that has nothing to do with its
66
- // content.
67
- raw = readFileSync(ledgerAbs, "utf8").replace(/^/, "");
68
- } catch {
98
+ const ledger = readClaimsLedger(spec);
99
+ if (ledger.missing) {
69
100
  return {
70
101
  station: name,
71
102
  status: "fail",
@@ -81,41 +112,15 @@ export function run(spec, draft) {
81
112
 
82
113
  const findings = [];
83
114
  const draftNorm = normalize(draft.text);
84
- const ledgerLines = raw.split("\n").map((text, i) => ({ n: i + 1, text })).filter((l) => l.text.trim() !== "");
85
-
86
- for (const { n, text } of ledgerLines) {
87
- let obj;
88
- try {
89
- obj = JSON.parse(text);
90
- } catch {
91
- findings.push({
92
- station: name,
93
- id: `station-claims-json-line-${n}`,
94
- severity: "fail",
95
- message: `writing.sources.ledger "${ledgerPath}" line ${n} is not valid JSON`,
96
- fix: "Fix the JSON on that line.",
97
- });
98
- continue;
99
- }
100
- if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
101
- findings.push({
102
- station: name,
103
- id: `station-claims-json-line-${n}`,
104
- severity: "fail",
105
- message: `writing.sources.ledger "${ledgerPath}" line ${n} is not a JSON object`,
106
- fix: 'Each ledger line must be a JSON object: {"text": "...", "source": "..."}.',
107
- });
108
- continue;
109
- }
110
115
 
111
- const claimText = typeof obj.text === "string" ? obj.text : "";
112
- if (!claimText.trim()) {
116
+ for (const { n, problem, text: claimText, claim: obj } of ledger.lines) {
117
+ if (problem) {
113
118
  findings.push({
114
119
  station: name,
115
120
  id: `station-claims-json-line-${n}`,
116
121
  severity: "fail",
117
- message: `writing.sources.ledger "${ledgerPath}" line ${n} has no text`,
118
- fix: "Add text: the claim exactly as it appears in the draft.",
122
+ message: `writing.sources.ledger "${ledgerPath}" line ${n} ${problem}`,
123
+ fix: PROBLEM_FIX[problem],
119
124
  });
120
125
  continue;
121
126
  }
@@ -59,15 +59,17 @@ function normalize(text) {
59
59
  // what was said.
60
60
  const matchKey = (inner) => normalize(inner).replace(/[.,]+$/, "").trim();
61
61
 
62
- // Every quoted span in `text`, as { start, end, inner }: start/end bound the whole span including
63
- // its quote marks, inner is the text between them. Paired per paragraph (see the header).
64
- function quotedSpans(text) {
62
+ // Every quoted span in `text`, as { start, end, inner, para }: start/end bound the whole span
63
+ // including its quote marks, inner is the text between them, para is { start, end } of the
64
+ // paragraph holding it. Paired per paragraph (see the header). Also how the attribution judge
65
+ // (src/judges/attribution.mjs) finds a draft's dialogue lines.
66
+ export function quotedSpans(text) {
65
67
  const spans = [];
66
68
  for (const para of splitSegments(text, { by: "paragraph" })) {
67
69
  QUOTE_RE.lastIndex = 0;
68
70
  let m;
69
71
  while ((m = QUOTE_RE.exec(para.text))) {
70
- spans.push({ start: para.start + m.index, end: para.start + m.index + m[0].length, inner: m[1] ?? m[2] ?? "" });
72
+ spans.push({ start: para.start + m.index, end: para.start + m.index + m[0].length, inner: m[1] ?? m[2] ?? "", para: { start: para.start, end: para.end } });
71
73
  }
72
74
  }
73
75
  return spans;
package/src/writing.mjs CHANGED
@@ -36,7 +36,7 @@ const required = (block, fiction) => block !== "characters" || fiction;
36
36
  // least one field. This is deliberately shallow: it is the bar for "something was written here",
37
37
  // not the bar for "this block is correct", which is what checkOwner and the field rules in
38
38
  // writing-fields.mjs are for.
39
- function blockPresent(block, raw) {
39
+ export function blockPresent(block, raw) {
40
40
  if (block === "characters") return Array.isArray(raw) && raw.length > 0;
41
41
  if (block === "materials") return isObj(raw) && Array.isArray(raw.items) && raw.items.length > 0;
42
42
  return isObj(raw) && Object.keys(raw).length > 0;