@supersuit/hyperspec 0.8.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 (36) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +45 -4
  3. package/SPEC.md +2 -2
  4. package/WRITING.md +334 -16
  5. package/bin/hyperspec.mjs +132 -2
  6. package/examples/writing/course/part-1.md +17 -0
  7. package/examples/writing/course/part-2.md +17 -0
  8. package/examples/writing/course.hyperspec.md +1 -0
  9. package/examples/writing/essay/judge/panel-buyer.packet.json +118 -0
  10. package/examples/writing/essay/judge/panel-expert.packet.json +114 -0
  11. package/examples/writing/essay/judge/panel-novice.packet.json +114 -0
  12. package/examples/writing/essay/judge/panel-skeptic.packet.json +114 -0
  13. package/examples/writing/essay/sample-verdicts/panel-buyer.verdict.json +11 -0
  14. package/examples/writing/essay/sample-verdicts/panel-expert.verdict.json +13 -0
  15. package/examples/writing/essay/sample-verdicts/panel-novice.verdict.json +11 -0
  16. package/examples/writing/essay/sample-verdicts/panel-skeptic.verdict.json +13 -0
  17. package/examples/writing/story/judge/panel-buyer.packet.json +118 -0
  18. package/examples/writing/story/judge/panel-expert.packet.json +114 -0
  19. package/examples/writing/story/judge/panel-novice.packet.json +114 -0
  20. package/examples/writing/story/judge/panel-skeptic.packet.json +114 -0
  21. package/examples/writing/story/sample-verdicts/panel-buyer.verdict.json +13 -0
  22. package/examples/writing/story/sample-verdicts/panel-expert.verdict.json +11 -0
  23. package/examples/writing/story/sample-verdicts/panel-novice.verdict.json +11 -0
  24. package/examples/writing/story/sample-verdicts/panel-skeptic.verdict.json +11 -0
  25. package/package.json +1 -1
  26. package/src/evidence.mjs +74 -0
  27. package/src/judge.mjs +50 -66
  28. package/src/judges/index.mjs +7 -1
  29. package/src/judges/panel.mjs +135 -0
  30. package/src/stations/index.mjs +3 -1
  31. package/src/stations/quotes.mjs +26 -2
  32. package/src/stations/sequence.mjs +113 -0
  33. package/src/stations/triage.mjs +20 -0
  34. package/src/triage.mjs +401 -0
  35. package/src/writing-fields.mjs +48 -1
  36. package/src/writing.mjs +5 -1
@@ -0,0 +1,114 @@
1
+ {
2
+ "hyperspec_judge": "0.1",
3
+ "station": "panel",
4
+ "spec": "story.hyperspec.md",
5
+ "spec_sha256": "8f417ebce7777cb4130afc8f0e2a1e4a483f1d4c7e7bcbadca202fda2208c6a9",
6
+ "draft": "story/draft.md",
7
+ "draft_sha256": "fdf8dd1ee311a376e45cca31b50f0b01f1157a889cb332620cbd178e9c510306",
8
+ "rubric": "simulated reader reports where it got lost and where it stopped reading",
9
+ "instructions": "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. 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. 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. A list may be empty. Report what this reader would say, not what another reader would. Answer only in the verdict shape given in verdict_schema.",
10
+ "inputs": {
11
+ "reader": {
12
+ "id": "skeptic",
13
+ "who": "a skeptic who doubts the piece's central claim and wants it earned",
14
+ "knows": [],
15
+ "lens": "what is asserted without support, overstated, or does not follow"
16
+ },
17
+ "draft": "# The Rye\n\n## 3:40\n\nThe alarm at the bakery went at 3:20, but Ines was always there first, so in three years I never\nonce heard it. What I heard, coming in the back door at twenty to four with my hands in my\npockets, was the mixer. It had a knock in it, a slow one, like it was trying to remember\nsomething.\n\nShe was standing over the bowl with her sleeves pushed past the elbow and her glasses on top of\nher head, where they stayed until the till opened. She did not look round. She never did. The\nback door sticks in winter and you have to put your shoulder to it, so she always knew it was me\nbefore she could have seen me.\n\n\"You're late,\" she said.\n\nI wasn't. She knew I wasn't. The clock over the proving cabinet said 3:41, and it runs a minute\nfast, and she was the one who set it that way.\n\n\"The bus,\" I said anyway, because that is how we started.\n\n\"Flour first. Then you can talk.\"\n\nSo I did the flour. Flour is weighed at Ines's, never scooped. There is a scoop in the bin, a\nsteel one with a dent in the lip, and in three years I have only ever seen it used to push the\nflour level before the lid goes back on. I set the bowl on the scale and zeroed it and poured\nuntil the number stopped where she wanted it, and then I took out a handful and put back half\nof that, because the last few grams go in by hand, and she was watching the scale even though\nshe was not watching me.\n\nBehind us the deck oven was coming up to heat. The deck oven is three stone shelves stacked one\nover another inside a steel wall, each with its own door, and it takes the better part of an\nhour to get hot enough. Somewhere in that hour it starts to make the noise. It is not loud. It\nis a kind of tick, then a longer sound like somebody dragging a chair in the flat upstairs, then\nnothing for a while, then the tick again.\n\nIt made the noise while I was weighing the second batch. I looked at the oven and then at Ines,\nand she did not look at either of us.\n\n\"Okay but like,\" I said, \"if the oven's made that noise since March, is it a noise, or is that just\nhow the oven talks now?\"\n\n\"Water,\" she said.\n\nShe put the thermometer in the water herself, the way she did for every batch, and read it and\nsaid nothing, which meant it was right. The water temperature is checked every batch at Ines's,\neven when the water comes out of the same tap at the same time on the same morning as the day\nbefore. I have never asked her why. I check it too.\n\nWe worked. There is a radio on the shelf above the sink and nobody has turned it on since I have\nworked there. The mixer knocked. The oven ticked and dragged its chair. Outside it was still\ncompletely dark, and the street lamp at the corner made the frost on the window look like a\nthumbprint.\n\nThe letter from the school was in the inside pocket of my coat, which was on the hook by the back\ndoor. I had moved it there from my bag on the bus, and then back to my bag, and then back to the\ncoat, because the coat was closer. It was two pages. The first page said I had a place, and the\nsecond page said when term started, which was the autumn, in a city four hours away by train. I\nhad read both pages enough times that the fold had gone soft.\n\nI did not look at the coat. I looked at the dough.\n\nOn the calendar by the till, which I could see from the bench if I leaned, somebody had drawn a\nring round Friday in red pen. Ines does not use red pen. She uses a pencil she keeps behind her\near and sharpens with a knife. I leaned, and looked, and leaned back.\n\nThe starter was on the shelf above the mixer, where it always was. The starter is a wide glass\njar with a cloth over the top held on by a rubber band, and everything we make that is sour\ncomes out of it. It is the only thing in the bakery she has never let me touch. I have fed the\nmixer and cleaned the oven floor and scraped the bins and carried the flour sacks up from the\ncellar two at a time, and I have never once taken the cloth off that jar.\n\n\"Is the rye going in the big bowl?\" I asked, although it always went in the big bowl.\n\n\"Big bowl,\" she said.\n\n## 4:30\n\nInes does not talk while she shapes. Talking happens at the mixer and at the till, and the bench\nis for your hands. I learned that in my first week and I have never had to be told it twice. The\nsilence at the bench is the kind that makes you hear how loud your own questions are.\n\nSo at half past four we stood at the bench with the dough between us and did not talk.\n\nShe cut the white dough into pieces with the bench knife, and weighed each piece, and pushed each\none across to me, and I rounded them and set them seam up on the floured cloth in rows. Her\nknife was faster than my hands. It always was. She would cut the last piece and wipe the blade\nand wait, and I would still have six to go, and she would not help, and she would not watch me\neither. She would look at the window, where there was nothing yet to look at.\n\nWhen the white was done she went to the big bowl and turned the rye out onto the bench. Rye does\nnot behave like the white. It is heavy and it is wet and it sticks to everything, and it does not\nstretch so much as slump, and you have to shape it quickly with wet hands before it decides to\nbe the shape of the bench instead of the shape of the basket.\n\nShe cut it into four and weighed the four. Then she wiped the knife and put it down on my side of\nthe bench, and she went to the sink, and she wet her hands, and she dried them.\n\nI stood there. The rye sat there. The oven ticked.\n\n\"I can do the rye,\" I said, and then I remembered where we were and said the rest quietly. \"I\nmean, I think I can do the rye. I did it Tuesday, kind of.\"\n\nOn Tuesday I had held the basket while she shaped. That was what I meant by kind of.\n\nShe did not answer. That was allowed, at the bench. She took the cloth off the white rounds and\nchecked them with one finger, and put the cloth back, and then she went through to the front and\nstarted taking the chairs down off the tables, which was the job she always gave me.\n\nSo I did the rye.\n\nI wet my hands the way she did, up to the wrist. I took the first piece and folded it in on itself\nand turned it and folded it, and it stuck to my palm and I wet my hand again and it stuck again,\nand I could hear the chairs going down in the front, one leg and then the other three, and I did\nnot look up. The second piece was better. The third piece tore and I had to fold the torn side\nunder and hope. The fourth piece was the best one I have ever done, and there was nobody at the\nbench to see it, which I think was the point, or I think now was the point, and I did not think\neither of those things at the time. At the time I only thought about my hands.\n\nI put the four of them seam up in the baskets and dusted them and covered them. The baskets are\nold, and the rye has worn a pattern into the cane that is there even when they are empty.\n\nThe proof is the long wait after shaping, when the dough sits and rises before it goes in the\noven, and with rye you cannot hurry it. In winter the rye proofs about three hours at room\ntemperature. Ines will not put it in the proving cabinet. She says the cabinet is for the white.\nSo the four baskets went on the shelf by the window, and would sit there until well after the\ndoors opened, and nothing either of us did in the meantime would make them go any faster.\n\nShe came back from the front with flour on the knees of her trousers from the chairs. She looked\nat the four baskets on the shelf for about as long as it takes to read a price, and then she\nlooked at the bench, which I had scraped clean, and then she went to the mixer.\n\n\"Scrape the bowl,\" she said.\n\nI scraped the bowl.\n\n## 5:50\n\nBy ten to six the oven was full and the kitchen smelled the way it smells only for about an hour\na day, which is the hour I would pick if somebody made me pick one.\n\nThe back left of the deck oven runs hot, so every tray is turned halfway through the bake. Ines\nhas a timer on a string round her neck for it. She does not trust the ones on the oven. When it\ngoes she opens each door in turn and pulls each tray out with the peel, and spins it, and pushes\nit back, so that the side that was at the back is at the front, and nothing comes out darker on\none end than the other.\n\n\"Left side runs hot,\" she said, as the first tray went in, as if I had not been turning those\ntrays with her since I was sixteen. \"Turn them at eight minutes.\"\n\nI set my own timer, which I did not need to do, because hers would go at the same time.\n\nWe stood in front of the oven. There is nothing to do in that eight minutes except stand in front\nof the oven and not open it. The noise came and went. The street outside was starting to go from\nblack to the color of dishwater. A van went past without stopping.\n\nThe timer went. She opened the top door, and the heat came out, and she slid the peel under the\nfirst tray and drew it out onto the lip of the door and started to turn it.\n\n\"It's sold,\" she said.\n\nShe said it to the tray. She turned the tray, and pushed it back in, and pulled out the second.\n\nI did not say anything, because I did not know yet that she had said anything. It went past me\nthe way the van had gone past. Then it came back.\n\n\"What's sold?\" I said. I knew what was sold.\n\n\"The bakery. Friday.\" She turned the second tray and pushed it back and shut the top door and\nopened the middle one.\n\n\"Sold like,\" I said, \"sold? Like someone else is going to be here? Like I come in on Monday and\nsomebody else is at the mixer, is that what, is that the sold you mean?\"\n\n\"Not a bakery,\" she said. \"They want the room. Not the ovens.\"\n\nShe pulled out the third tray and turned it. She had not burnt herself on a tray in all the time\nI had known her, and she did not burn herself then. Her hands did exactly what they always did.\nI watched them because I did not know what else to watch.\n\n\"Then what happens to the ovens?\" I said. \"Then what happens to the mixer. Is somebody going to buy\nthe mixer? It knocks. Does the person who buys it know it knocks?\"\n\nShe shut the middle door and opened the bottom one.\n\n\"Is that why there's a ring round Friday?\" I said. \"Is that what the red pen is?\"\n\n\"Sold means sold,\" she said. \"Get the next tray.\"\n\nI got the next tray. It was the seeded rolls, which I had shaped at five, and I put them on the\npeel and she put them in, and I stood with the empty peel in my hands while she shut the door and\nthe heat stopped coming out.\n\nI wanted to ask how long she had known. I wanted to ask whether the new people had been in, and\nwhen, and whether they had stood in this kitchen while I was at home asleep, and whether they had\nlooked at the baskets on the shelf and thought they were just baskets. I did not ask any of it.\nI stood there with the peel and I thought about the letter in my coat, and I thought, she has\nknown this for days, maybe weeks, and every one of those mornings she came in before me and set\nthe clock a minute fast and told me I was late.\n\nThat was the only time all morning I nearly told her. The words got as far as the back of my\nteeth.\n\nThe timer on the string went. She turned the next tray.\n\n\"Rolls come out at sixteen,\" she said. \"Get the racks.\"\n\nI got the racks.\n\n## 6:55\n\nAt five to seven the front was ready. The chairs were down, the counter was wiped, the white was\nin the baskets behind the till and the rolls were on the racks, and the four rye loaves were still\non the shelf by the window under their cloths, still rising, a long way from done.\n\nThe doors open to customers at 7:00. The first person in is usually a regular. Through the glass\nI could see Walter already standing on the step with his collar up and his dog sitting on his\nfoot, which was what the dog did in winter. Ines keeps a biscuit in her apron for the dog and has\nnever once said so.\n\nShe was counting the float into the till. I stood behind the counter with my hands flat on it.\nThe letter was still in my coat. The coat was still on the hook by the back door, which was as\nfar from the counter as you can get in the bakery and still be inside it.\n\n\"I got into a school,\" I said.\n\nShe went on counting. She got to the end of the coins and closed the drawer.\n\n\"A baking school,\" I said. \"In the city. It's a proper one, it's the one with the, they do the whole\nyear on bread, and then pastry, and I applied in the spring and I didn't think, I mean I didn't\nthink I'd get in, and then I did. It starts in the autumn. I was going to tell you. I was going to\ntell you the day the letter came, and then I got here and you were at the mixer and it felt like,\nI don't know. It felt like the wrong time to say anything to anybody.\"\n\nShe looked at the four baskets on the shelf by the window.\n\nThen she walked past me into the back, and I heard her feet on the flour-dusty floor, and I heard\nher stop by the mixer, and I stood there with my hands on the counter and looked at Walter's dog.\n\nWhen she came back she had the starter.\n\nShe had the jar in both hands, the way you carry something you have filled too full. The cloth\nwas still on, and the rubber band, and the glass had a rim of dried flour at the top where it had\nrisen in the night and fallen back. She put it on the counter between us and took her hands away.\n\n\"Feed it at noon,\" she said. \"Equal weight flour and water. Weigh it.\"\n\nI looked at the jar. I did not pick it up. I have scraped the bins and carried the sacks and\ncleaned the oven floor, and I had never once taken the cloth off that jar, and now it was on the\ncounter in front of me with nobody's hands on it.\n\n\"Every day,\" she said. \"Not most days. Keep it out of the sun.\"\n\n\"Okay,\" I said. \"Okay. So is that a yes, or is that the face you make when it's a yes?\"\n\nShe took the glasses off the top of her head and put them on, which she only does for the till.\n\n\"Doors,\" she said.\n\nI picked up the jar. It was heavier than it looked, and it was warm on the side that had faced\nthe oven. I held it against my chest with one arm while she walked to the front and turned the\nsign and let Walter in, and Walter said good morning to her by name, and she said good morning to\nhim by his, and she bent down to the dog with her hand already in her apron.\n\nBehind me, on the shelf by the window, under their cloths, the four rye loaves I had shaped were\nstill rising, and the dough had lifted the cloth off the edge of the first basket by the width of\na finger.\n"
18
+ },
19
+ "verdict_schema": {
20
+ "type": "object",
21
+ "required": [
22
+ "good",
23
+ "improve",
24
+ "missing",
25
+ "remove"
26
+ ],
27
+ "properties": {
28
+ "good": {
29
+ "type": "array",
30
+ "description": "what works for this reader; empty when nothing does",
31
+ "items": {
32
+ "type": "object",
33
+ "required": [
34
+ "evidence",
35
+ "note"
36
+ ],
37
+ "properties": {
38
+ "evidence": {
39
+ "type": "string",
40
+ "description": "a passage copied verbatim from the draft, at least three words; for a missing item, the passage nearest where it belongs"
41
+ },
42
+ "note": {
43
+ "type": "string",
44
+ "description": "what and why, in a sentence"
45
+ }
46
+ }
47
+ }
48
+ },
49
+ "improve": {
50
+ "type": "array",
51
+ "description": "what should change",
52
+ "items": {
53
+ "type": "object",
54
+ "required": [
55
+ "evidence",
56
+ "note"
57
+ ],
58
+ "properties": {
59
+ "evidence": {
60
+ "type": "string",
61
+ "description": "a passage copied verbatim from the draft, at least three words; for a missing item, the passage nearest where it belongs"
62
+ },
63
+ "note": {
64
+ "type": "string",
65
+ "description": "what and why, in a sentence"
66
+ }
67
+ }
68
+ }
69
+ },
70
+ "missing": {
71
+ "type": "array",
72
+ "description": "what this reader needs that the draft does not give",
73
+ "items": {
74
+ "type": "object",
75
+ "required": [
76
+ "evidence",
77
+ "note"
78
+ ],
79
+ "properties": {
80
+ "evidence": {
81
+ "type": "string",
82
+ "description": "a passage copied verbatim from the draft, at least three words; for a missing item, the passage nearest where it belongs"
83
+ },
84
+ "note": {
85
+ "type": "string",
86
+ "description": "what and why, in a sentence"
87
+ }
88
+ }
89
+ }
90
+ },
91
+ "remove": {
92
+ "type": "array",
93
+ "description": "what should go",
94
+ "items": {
95
+ "type": "object",
96
+ "required": [
97
+ "evidence",
98
+ "note"
99
+ ],
100
+ "properties": {
101
+ "evidence": {
102
+ "type": "string",
103
+ "description": "a passage copied verbatim from the draft, at least three words; for a missing item, the passage nearest where it belongs"
104
+ },
105
+ "note": {
106
+ "type": "string",
107
+ "description": "what and why, in a sentence"
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "sample": "A sample judgment for the worked example, filled in by hand to show what a verdict looks like and what judge record does with it. It is one reading of the packet, not a model run, and another judge may answer differently. hyperspec ignores this field.",
3
+ "good": [
4
+ { "evidence": "\"Flour first. Then you can talk.\"", "note": "One line tells me who she is and how the two of them work, so I keep reading." }
5
+ ],
6
+ "improve": [
7
+ { "evidence": "somebody had drawn a ring round Friday in red pen", "note": "I guessed Friday before the reveal, which took some of the weight out of the oven scene." }
8
+ ],
9
+ "missing": [],
10
+ "remove": [
11
+ { "evidence": "There is a radio on the shelf above the sink and nobody has turned it on since I have worked there.", "note": "A detail that goes nowhere; in a story I read in one sitting, every object should earn its place." }
12
+ ]
13
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "sample": "A sample judgment for the worked example, filled in by hand to show what a verdict looks like and what judge record does with it. It is one reading of the packet, not a model run, and another judge may answer differently. hyperspec ignores this field.",
3
+ "good": [
4
+ { "evidence": "Flour is weighed at Ines's, never scooped.", "note": "True to how a working bakery runs; the detail earns a baker's trust early." }
5
+ ],
6
+ "improve": [],
7
+ "missing": [
8
+ { "evidence": "The starter is a wide glass jar with a cloth over the top", "note": "A baker would expect to see the starter fed at least once; the story leans on it and never shows how it is kept alive." }
9
+ ],
10
+ "remove": []
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "sample": "A sample judgment for the worked example, filled in by hand to show what a verdict looks like and what judge record does with it. It is one reading of the packet, not a model run, and another judge may answer differently. hyperspec ignores this field.",
3
+ "good": [
4
+ { "evidence": "The deck oven is three stone shelves stacked one over another inside a steel wall", "note": "The oven is shown in the sentence that names it, so a reader who has never seen one can picture it." }
5
+ ],
6
+ "improve": [
7
+ { "evidence": "The clock over the proving cabinet said 3:41", "note": "A proving cabinet is never explained, and a reader outside baking cannot tell what it is for until the proof is described much later." }
8
+ ],
9
+ "missing": [],
10
+ "remove": []
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "sample": "A sample judgment for the worked example, filled in by hand to show what a verdict looks like and what judge record does with it. It is one reading of the packet, not a model run, and another judge may answer differently. hyperspec ignores this field.",
3
+ "good": [
4
+ { "evidence": "I did not look at the coat. I looked at the dough.", "note": "The letter is carried by what he does not do, so nothing about his leaving has to be told." }
5
+ ],
6
+ "improve": [
7
+ { "evidence": "somebody had drawn a ring round Friday in red pen", "note": "The red ring is planted hard; a doubting reader sees the sale coming a scene before Ines says it." }
8
+ ],
9
+ "missing": [],
10
+ "remove": []
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/hyperspec",
3
- "version": "0.8.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": {
@@ -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));