@supersuit/hyperspec 0.5.0 → 0.6.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.
@@ -3,8 +3,8 @@
3
3
  {"id":"s2","start":119,"end":199,"label":"aside","text":"Considered: the manager reviewed these notes afterwards and corrected two\nlines."}
4
4
  {"id":"s3","start":201,"end":240,"label":"claim","source":"interview with an engineering manager of eight years","text":"- Her rule: the report owns the agenda."}
5
5
  {"id":"s4","start":241,"end":356,"label":"claim","source":"interview with an engineering manager of eight years","text":"She keeps a shared document per person; they add items\n before the meeting, and she adds hers last, at the bottom."}
6
- {"id":"s5","start":357,"end":448,"label":"quote","speaker":"the engineering manager interviewed","text":"- \"If I have something urgent, it is not a one-on-one topic. I send it the day it happens.\""}
6
+ {"id":"s5","start":357,"end":448,"label":"quote","speaker":"dana, an engineering manager","text":"- \"If I have something urgent, it is not a one-on-one topic. I send it the day it happens.\""}
7
7
  {"id":"s6","start":449,"end":617,"label":"claim","source":"interview with an engineering manager of eight years","text":"- The first one-on-one with a new report is always the same: she asks how they like to receive\n feedback, in writing or out loud, right away or at the end of the week."}
8
8
  {"id":"s7","start":618,"end":673,"label":"claim","source":"interview with an engineering manager of eight years","text":"- Her warning: new managers treat silence as a problem."}
9
- {"id":"s8","start":674,"end":733,"label":"quote","speaker":"the engineering manager interviewed","text":"\"Wait. Count to five. The real answer is\n the second one.\""}
9
+ {"id":"s8","start":674,"end":733,"label":"quote","speaker":"dana, an engineering manager","text":"\"Wait. Count to five. The real answer is\n the second one.\""}
10
10
  {"id":"s9","start":734,"end":812,"label":"claim","source":"interview with an engineering manager of eight years","text":"- She cancels a one-on-one only when the report asks her to, never on her own."}
@@ -74,7 +74,7 @@ examples:
74
74
  - path: dna/essay-new-managers-teach/goldens/opening.md
75
75
  why: the claim lands in the first sentence, and the two short sentences after it turn it into an instruction
76
76
  resume:
77
- next_action: outline the four spine claims against the form's required parts, citing the segments each claim points at
77
+ next_action: hand essay/draft.md to the doctor for the rubric checks in r1, r4 and r5, now that hyperspec check passes it
78
78
  feedback:
79
79
  issues: https://github.com/SupersuitUp/hyperspec/issues
80
80
  fork: MIT; fork it for your own purposes
@@ -148,6 +148,9 @@ writing:
148
148
  - one-on-one
149
149
  - report
150
150
  - tracker
151
+ terms:
152
+ - running agenda
153
+ - status meeting
151
154
  believes_now: a one-on-one is where a manager catches up on how the work is going
152
155
  wants: a plan for the first meeting that will not waste either person's half hour
153
156
  reads_on: a phone, in the ten minutes before the meeting
@@ -176,11 +179,11 @@ writing:
176
179
  max: 1100
177
180
  unit: words
178
181
  required_parts:
179
- - an opening that states the claim
180
- - the story of the author's first one-on-one
182
+ - who sets the agenda
183
+ - my first one-on-one
181
184
  - the three questions
182
185
  - what to do with the answers
183
- - a close the reader can act on
186
+ - before the meeting
184
187
  stations:
185
188
  - the three questions render as a numbered list
186
189
  check:
@@ -219,8 +222,16 @@ fiction: false
219
222
  # Hand your first one-on-one to the person you manage
220
223
 
221
224
  A worked example of the writing profile: an essay for new managers, specified before a word of
222
- it is drafted. Every file this spec names ships beside it. `essay/claims.jsonl` does not exist
223
- yet, because the claims ledger is written during drafting.
225
+ it is drafted. Every file this spec names ships beside it. The draft is `essay/draft.md`, and
226
+ the claims ledger written while drafting it is `essay/claims.jsonl`. The draft passes every
227
+ deterministic station:
228
+
229
+ ```bash
230
+ npx @supersuit/hyperspec check essay.hyperspec.md --draft essay/draft.md
231
+ ```
232
+
233
+ The required parts are the draft's section headings, because the form station finds a required
234
+ part by its heading.
224
235
 
225
236
  The writer's voice for this piece comes from a scope folder, `dna/essay-new-managers-teach/`:
226
237
  the goldens this writer approved for essays that teach new managers, and the features measured
@@ -0,0 +1,9 @@
1
+ {"text":"Flour is weighed at Ines's, never scooped.","source":"bakery-visit#s10","span":"Flour is weighed, never scooped."}
2
+ {"text":"The water temperature is checked every batch at Ines's","source":"bakery-visit#s11","span":"Water temperature is checked every batch."}
3
+ {"text":"Ines does not talk while she shapes.","source":"bakery-visit#s8","span":"The owner does not talk while shaping."}
4
+ {"text":"Talking happens at the mixer and at the till","source":"bakery-visit#s9","span":"Talking happens at the mixer and at the till."}
5
+ {"text":"In winter the rye proofs about three hours at room temperature.","source":"bakery-visit#s4","span":"Rye sourdough proofs about three hours at room temperature in winter."}
6
+ {"text":"The back left of the deck oven runs hot, so every tray is turned halfway through the bake.","source":"bakery-visit#s5","span":"Trays are turned halfway through the bake because the back left of a deck oven runs hot."}
7
+ {"text":"The doors open to customers at 7:00.","source":"bakery-visit#s6","span":"The doors open to customers at 7:00."}
8
+ {"text":"The first person in is usually a regular.","source":"bakery-visit#s7","span":"The first person in is usually a regular."}
9
+ {"text":"turning those trays with her since I was sixteen","source":"notes#s2","span":"the apprentice she took on at sixteen, now nineteen"}
@@ -0,0 +1,267 @@
1
+ # The Rye
2
+
3
+ ## 3:40
4
+
5
+ The alarm at the bakery went at 3:20, but Ines was always there first, so in three years I never
6
+ once heard it. What I heard, coming in the back door at twenty to four with my hands in my
7
+ pockets, was the mixer. It had a knock in it, a slow one, like it was trying to remember
8
+ something.
9
+
10
+ She was standing over the bowl with her sleeves pushed past the elbow and her glasses on top of
11
+ her head, where they stayed until the till opened. She did not look round. She never did. The
12
+ back door sticks in winter and you have to put your shoulder to it, so she always knew it was me
13
+ before she could have seen me.
14
+
15
+ "You're late," she said.
16
+
17
+ I wasn't. She knew I wasn't. The clock over the proving cabinet said 3:41, and it runs a minute
18
+ fast, and she was the one who set it that way.
19
+
20
+ "The bus," I said anyway, because that is how we started.
21
+
22
+ "Flour first. Then you can talk."
23
+
24
+ So I did the flour. Flour is weighed at Ines's, never scooped. There is a scoop in the bin, a
25
+ steel one with a dent in the lip, and in three years I have only ever seen it used to push the
26
+ flour level before the lid goes back on. I set the bowl on the scale and zeroed it and poured
27
+ until the number stopped where she wanted it, and then I took out a handful and put back half
28
+ of that, because the last few grams go in by hand, and she was watching the scale even though
29
+ she was not watching me.
30
+
31
+ Behind us the deck oven was coming up to heat. The deck oven is three stone shelves stacked one
32
+ over another inside a steel wall, each with its own door, and it takes the better part of an
33
+ hour to get hot enough. Somewhere in that hour it starts to make the noise. It is not loud. It
34
+ is a kind of tick, then a longer sound like somebody dragging a chair in the flat upstairs, then
35
+ nothing for a while, then the tick again.
36
+
37
+ It made the noise while I was weighing the second batch. I looked at the oven and then at Ines,
38
+ and she did not look at either of us.
39
+
40
+ "Okay but like," I said, "if the oven's made that noise since March, is it a noise, or is that just
41
+ how the oven talks now?"
42
+
43
+ "Water," she said.
44
+
45
+ She put the thermometer in the water herself, the way she did for every batch, and read it and
46
+ said nothing, which meant it was right. The water temperature is checked every batch at Ines's,
47
+ even when the water comes out of the same tap at the same time on the same morning as the day
48
+ before. I have never asked her why. I check it too.
49
+
50
+ We worked. There is a radio on the shelf above the sink and nobody has turned it on since I have
51
+ worked there. The mixer knocked. The oven ticked and dragged its chair. Outside it was still
52
+ completely dark, and the street lamp at the corner made the frost on the window look like a
53
+ thumbprint.
54
+
55
+ The letter from the school was in the inside pocket of my coat, which was on the hook by the back
56
+ door. I had moved it there from my bag on the bus, and then back to my bag, and then back to the
57
+ coat, because the coat was closer. It was two pages. The first page said I had a place, and the
58
+ second page said when term started, which was the autumn, in a city four hours away by train. I
59
+ had read both pages enough times that the fold had gone soft.
60
+
61
+ I did not look at the coat. I looked at the dough.
62
+
63
+ On the calendar by the till, which I could see from the bench if I leaned, somebody had drawn a
64
+ ring round Friday in red pen. Ines does not use red pen. She uses a pencil she keeps behind her
65
+ ear and sharpens with a knife. I leaned, and looked, and leaned back.
66
+
67
+ The starter was on the shelf above the mixer, where it always was. The starter is a wide glass
68
+ jar with a cloth over the top held on by a rubber band, and everything we make that is sour
69
+ comes out of it. It is the only thing in the bakery she has never let me touch. I have fed the
70
+ mixer and cleaned the oven floor and scraped the bins and carried the flour sacks up from the
71
+ cellar two at a time, and I have never once taken the cloth off that jar.
72
+
73
+ "Is the rye going in the big bowl?" I asked, although it always went in the big bowl.
74
+
75
+ "Big bowl," she said.
76
+
77
+ ## 4:30
78
+
79
+ Ines does not talk while she shapes. Talking happens at the mixer and at the till, and the bench
80
+ is for your hands. I learned that in my first week and I have never had to be told it twice. The
81
+ silence at the bench is the kind that makes you hear how loud your own questions are.
82
+
83
+ So at half past four we stood at the bench with the dough between us and did not talk.
84
+
85
+ She cut the white dough into pieces with the bench knife, and weighed each piece, and pushed each
86
+ one across to me, and I rounded them and set them seam up on the floured cloth in rows. Her
87
+ knife was faster than my hands. It always was. She would cut the last piece and wipe the blade
88
+ and wait, and I would still have six to go, and she would not help, and she would not watch me
89
+ either. She would look at the window, where there was nothing yet to look at.
90
+
91
+ When the white was done she went to the big bowl and turned the rye out onto the bench. Rye does
92
+ not behave like the white. It is heavy and it is wet and it sticks to everything, and it does not
93
+ stretch so much as slump, and you have to shape it quickly with wet hands before it decides to
94
+ be the shape of the bench instead of the shape of the basket.
95
+
96
+ She cut it into four and weighed the four. Then she wiped the knife and put it down on my side of
97
+ the bench, and she went to the sink, and she wet her hands, and she dried them.
98
+
99
+ I stood there. The rye sat there. The oven ticked.
100
+
101
+ "I can do the rye," I said, and then I remembered where we were and said the rest quietly. "I
102
+ mean, I think I can do the rye. I did it Tuesday, kind of."
103
+
104
+ On Tuesday I had held the basket while she shaped. That was what I meant by kind of.
105
+
106
+ She did not answer. That was allowed, at the bench. She took the cloth off the white rounds and
107
+ checked them with one finger, and put the cloth back, and then she went through to the front and
108
+ started taking the chairs down off the tables, which was the job she always gave me.
109
+
110
+ So I did the rye.
111
+
112
+ I wet my hands the way she did, up to the wrist. I took the first piece and folded it in on itself
113
+ and turned it and folded it, and it stuck to my palm and I wet my hand again and it stuck again,
114
+ and I could hear the chairs going down in the front, one leg and then the other three, and I did
115
+ not look up. The second piece was better. The third piece tore and I had to fold the torn side
116
+ under and hope. The fourth piece was the best one I have ever done, and there was nobody at the
117
+ bench to see it, which I think was the point, or I think now was the point, and I did not think
118
+ either of those things at the time. At the time I only thought about my hands.
119
+
120
+ I put the four of them seam up in the baskets and dusted them and covered them. The baskets are
121
+ old, and the rye has worn a pattern into the cane that is there even when they are empty.
122
+
123
+ The proof is the long wait after shaping, when the dough sits and rises before it goes in the
124
+ oven, and with rye you cannot hurry it. In winter the rye proofs about three hours at room
125
+ temperature. Ines will not put it in the proving cabinet. She says the cabinet is for the white.
126
+ So the four baskets went on the shelf by the window, and would sit there until well after the
127
+ doors opened, and nothing either of us did in the meantime would make them go any faster.
128
+
129
+ She came back from the front with flour on the knees of her trousers from the chairs. She looked
130
+ at the four baskets on the shelf for about as long as it takes to read a price, and then she
131
+ looked at the bench, which I had scraped clean, and then she went to the mixer.
132
+
133
+ "Scrape the bowl," she said.
134
+
135
+ I scraped the bowl.
136
+
137
+ ## 5:50
138
+
139
+ By ten to six the oven was full and the kitchen smelled the way it smells only for about an hour
140
+ a day, which is the hour I would pick if somebody made me pick one.
141
+
142
+ The back left of the deck oven runs hot, so every tray is turned halfway through the bake. Ines
143
+ has a timer on a string round her neck for it. She does not trust the ones on the oven. When it
144
+ goes she opens each door in turn and pulls each tray out with the peel, and spins it, and pushes
145
+ it back, so that the side that was at the back is at the front, and nothing comes out darker on
146
+ one end than the other.
147
+
148
+ "Left side runs hot," she said, as the first tray went in, as if I had not been turning those
149
+ trays with her since I was sixteen. "Turn them at eight minutes."
150
+
151
+ I set my own timer, which I did not need to do, because hers would go at the same time.
152
+
153
+ We stood in front of the oven. There is nothing to do in that eight minutes except stand in front
154
+ of the oven and not open it. The noise came and went. The street outside was starting to go from
155
+ black to the color of dishwater. A van went past without stopping.
156
+
157
+ The timer went. She opened the top door, and the heat came out, and she slid the peel under the
158
+ first tray and drew it out onto the lip of the door and started to turn it.
159
+
160
+ "It's sold," she said.
161
+
162
+ She said it to the tray. She turned the tray, and pushed it back in, and pulled out the second.
163
+
164
+ I did not say anything, because I did not know yet that she had said anything. It went past me
165
+ the way the van had gone past. Then it came back.
166
+
167
+ "What's sold?" I said. I knew what was sold.
168
+
169
+ "The bakery. Friday." She turned the second tray and pushed it back and shut the top door and
170
+ opened the middle one.
171
+
172
+ "Sold like," I said, "sold? Like someone else is going to be here? Like I come in on Monday and
173
+ somebody else is at the mixer, is that what, is that the sold you mean?"
174
+
175
+ "Not a bakery," she said. "They want the room. Not the ovens."
176
+
177
+ She pulled out the third tray and turned it. She had not burnt herself on a tray in all the time
178
+ I had known her, and she did not burn herself then. Her hands did exactly what they always did.
179
+ I watched them because I did not know what else to watch.
180
+
181
+ "Then what happens to the ovens?" I said. "Then what happens to the mixer. Is somebody going to buy
182
+ the mixer? It knocks. Does the person who buys it know it knocks?"
183
+
184
+ She shut the middle door and opened the bottom one.
185
+
186
+ "Is that why there's a ring round Friday?" I said. "Is that what the red pen is?"
187
+
188
+ "Sold means sold," she said. "Get the next tray."
189
+
190
+ I got the next tray. It was the seeded rolls, which I had shaped at five, and I put them on the
191
+ peel and she put them in, and I stood with the empty peel in my hands while she shut the door and
192
+ the heat stopped coming out.
193
+
194
+ I wanted to ask how long she had known. I wanted to ask whether the new people had been in, and
195
+ when, and whether they had stood in this kitchen while I was at home asleep, and whether they had
196
+ looked at the baskets on the shelf and thought they were just baskets. I did not ask any of it.
197
+ I stood there with the peel and I thought about the letter in my coat, and I thought, she has
198
+ known this for days, maybe weeks, and every one of those mornings she came in before me and set
199
+ the clock a minute fast and told me I was late.
200
+
201
+ That was the only time all morning I nearly told her. The words got as far as the back of my
202
+ teeth.
203
+
204
+ The timer on the string went. She turned the next tray.
205
+
206
+ "Rolls come out at sixteen," she said. "Get the racks."
207
+
208
+ I got the racks.
209
+
210
+ ## 6:55
211
+
212
+ At five to seven the front was ready. The chairs were down, the counter was wiped, the white was
213
+ in the baskets behind the till and the rolls were on the racks, and the four rye loaves were still
214
+ on the shelf by the window under their cloths, still rising, a long way from done.
215
+
216
+ The doors open to customers at 7:00. The first person in is usually a regular. Through the glass
217
+ I could see Walter already standing on the step with his collar up and his dog sitting on his
218
+ foot, which was what the dog did in winter. Ines keeps a biscuit in her apron for the dog and has
219
+ never once said so.
220
+
221
+ She was counting the float into the till. I stood behind the counter with my hands flat on it.
222
+ The letter was still in my coat. The coat was still on the hook by the back door, which was as
223
+ far from the counter as you can get in the bakery and still be inside it.
224
+
225
+ "I got into a school," I said.
226
+
227
+ She went on counting. She got to the end of the coins and closed the drawer.
228
+
229
+ "A baking school," I said. "In the city. It's a proper one, it's the one with the, they do the whole
230
+ year on bread, and then pastry, and I applied in the spring and I didn't think, I mean I didn't
231
+ think I'd get in, and then I did. It starts in the autumn. I was going to tell you. I was going to
232
+ tell you the day the letter came, and then I got here and you were at the mixer and it felt like,
233
+ I don't know. It felt like the wrong time to say anything to anybody."
234
+
235
+ She looked at the four baskets on the shelf by the window.
236
+
237
+ Then she walked past me into the back, and I heard her feet on the flour-dusty floor, and I heard
238
+ her stop by the mixer, and I stood there with my hands on the counter and looked at Walter's dog.
239
+
240
+ When she came back she had the starter.
241
+
242
+ She had the jar in both hands, the way you carry something you have filled too full. The cloth
243
+ was still on, and the rubber band, and the glass had a rim of dried flour at the top where it had
244
+ risen in the night and fallen back. She put it on the counter between us and took her hands away.
245
+
246
+ "Feed it at noon," she said. "Equal weight flour and water. Weigh it."
247
+
248
+ I looked at the jar. I did not pick it up. I have scraped the bins and carried the sacks and
249
+ cleaned the oven floor, and I had never once taken the cloth off that jar, and now it was on the
250
+ counter in front of me with nobody's hands on it.
251
+
252
+ "Every day," she said. "Not most days. Keep it out of the sun."
253
+
254
+ "Okay," I said. "Okay. So is that a yes, or is that the face you make when it's a yes?"
255
+
256
+ She took the glasses off the top of her head and put them on, which she only does for the till.
257
+
258
+ "Doors," she said.
259
+
260
+ I picked up the jar. It was heavier than it looked, and it was warm on the side that had faced
261
+ the oven. I held it against my chest with one arm while she walked to the front and turned the
262
+ sign and let Walter in, and Walter said good morning to her by name, and she said good morning to
263
+ him by his, and she bent down to the dog with her hand already in her apron.
264
+
265
+ Behind me, on the shelf by the window, under their cloths, the four rye loaves I had shaped were
266
+ still rising, and the dough had lifted the cloth off the edge of the first basket by the width of
267
+ a finger.
@@ -74,7 +74,7 @@ examples:
74
74
  - path: story/goldens/dialogue.md
75
75
  why: three short lines carry a ritual both characters know, without either of them naming it
76
76
  resume:
77
- next_action: draft scene-1 from the scene list with Theo's knowledge as of scene-1
77
+ next_action: hand story/draft.md to the doctor for the rubric checks in r1 and r5, now that hyperspec check passes it
78
78
  feedback:
79
79
  issues: https://github.com/SupersuitUp/hyperspec/issues
80
80
  fork: MIT; fork it for your own purposes
@@ -145,6 +145,10 @@ writing:
145
145
  - bakery
146
146
  - apprentice
147
147
  - sourdough
148
+ terms:
149
+ - proof
150
+ - starter
151
+ - deck oven
148
152
  believes_now: nothing yet about this author or these two people
149
153
  wants: a story they can finish in one sitting and keep thinking about
150
154
  reads_on: print, in one sitting of about fifteen minutes
@@ -173,8 +177,10 @@ writing:
173
177
  max: 4000
174
178
  unit: words
175
179
  required_parts:
176
- - four scenes, in the scene list's order
177
- - a last line about bread
180
+ - "3:40"
181
+ - "4:30"
182
+ - "5:50"
183
+ - "6:55"
178
184
  stations:
179
185
  - continuity against the scene list
180
186
  - knowledge-leak check, per character, per scene
@@ -284,5 +290,14 @@ fiction: true
284
290
 
285
291
  A worked example of the writing profile for fiction: a short story with two characters, each
286
292
  specified well enough that an agent can write their dialogue and a judge can tell them apart.
287
- Every file this spec names ships beside it. `story/claims.jsonl` does not exist yet, because the
288
- claims ledger is written during drafting.
293
+ Every file this spec names ships beside it. The draft is `story/draft.md`, and the claims ledger
294
+ written while drafting it is `story/claims.jsonl`: every process detail in the draft, pointed at
295
+ the bakery visit notes. The draft passes every deterministic station:
296
+
297
+ ```bash
298
+ npx @supersuit/hyperspec check story.hyperspec.md --draft story/draft.md
299
+ ```
300
+
301
+ The four required parts are the scene headings, one per scene of the scene list, in its order.
302
+ The story is fiction, so the quotes station skips it: its dialogue is invented, not quoted from
303
+ a material, and is checked against each character's lines by a later release's character stations.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/hyperspec",
3
- "version": "0.5.0",
3
+ "version": "0.6.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 ADDED
@@ -0,0 +1,245 @@
1
+ // `hyperspec check`: run every deterministic station against a draft, once the spec that
2
+ // declares them is itself lint-clean. This module is the command's logic, independent of the
3
+ // CLI's argv parsing and printing (bin/hyperspec.mjs owns those), the same split rules.mjs and
4
+ // score.mjs already keep for `lint`.
5
+ //
6
+ // A draft is never checked against a spec that is not ready to check anything against: the spec
7
+ // is linted first, and a spec that fails or is blocked runs no station at all, exiting with lint's
8
+ // own code (1 fail, 3 blocked) rather than a check-specific one. Passing lint's test 9 requires
9
+ // improvement.ledger to be a non-empty path (see
10
+ // src/rules.mjs), so by the time any station runs, the spec is guaranteed to declare one; the
11
+ // presence check and escape check below exist anyway, for the same reason compare.mjs (the other
12
+ // ledger writer) keeps its own copy: defense in depth costs one branch and this file should never
13
+ // silently assume another file's invariant holds.
14
+
15
+ import { appendFileSync, readFileSync } from "node:fs";
16
+ import { basename, isAbsolute, relative, resolve, sep } from "node:path";
17
+ import { loadSpec } from "./load.mjs";
18
+ import { lintSpec } from "./rules.mjs";
19
+ import { score, exitCode } from "./score.mjs";
20
+ import { sha256 } from "./hash.mjs";
21
+ import { insideDir } from "./fsutil.mjs";
22
+ import { STATIONS, STATION_NAMES } from "./stations/index.mjs";
23
+ import { str } from "./placeholder.mjs";
24
+
25
+ const present = (v) => typeof v === "string" && v.trim().length > 0;
26
+
27
+ // 1-based line array: text.split("\n"), so array index i holds line i + 1. A trailing "\r" (a
28
+ // CRLF file) is stripped from every entry here, at the source, so every station that reads
29
+ // draft.lines sees a clean line ("# Claim", never "# Claim\r") without needing to know CRLF
30
+ // exists; the line COUNT and every 1-based line number are unaffected, since stripping a
31
+ // trailing byte from an entry never changes how many entries there are.
32
+ function splitLines(text) {
33
+ return text.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
34
+ }
35
+
36
+ // Every well-formed kind: "check" line already in the ledger, in file order (oldest first). A
37
+ // line that is not valid JSON, or not a kind: "check" object, is silently skipped here: this is a
38
+ // read for verdict history, not a lint pass. A malformed ledger line is rules.mjs's test 9's
39
+ // finding to report, not this function's to crash on.
40
+ function priorCheckLines(text) {
41
+ return (text ?? "")
42
+ .split("\n")
43
+ .filter((l) => l.trim())
44
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
45
+ .filter((v) => v && typeof v === "object" && !Array.isArray(v) && v.kind === "check");
46
+ }
47
+
48
+ // A crash message with every absolute path in it made relative to the working directory, or cut to
49
+ // "<path>/<file name>" when it lies outside it, so a station that hits a filesystem error never
50
+ // prints this machine's layout.
51
+ function withoutAbsolutePaths(message) {
52
+ return message.replace(/(?:[A-Za-z]:\\|\/)[^\s'"`,)]+/g, (p) => {
53
+ if (!isAbsolute(p)) return p;
54
+ const rel = relative(process.cwd(), p);
55
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : `<path>/${basename(p)}`;
56
+ });
57
+ }
58
+
59
+ // runStation(station, spec, draft, ctx): runs one station's run(spec, draft, ctx), converting a
60
+ // throw into a single failing finding rather than letting it crash the whole command. A station
61
+ // is pure and deterministic BY CONTRACT, but that contract is not enforced by the type system,
62
+ // and later stations (terms, claims, quotes, private, dna) read JSONL ledgers, segments files
63
+ // and regexes over untrusted draft text, which is a lot more surface for a bug to throw from
64
+ // than form's own narrow reading. A throw here must never crash the whole command (no raw stack
65
+ // trace, no half-finished --json, no skipped ledger line): every OTHER station and the ledger
66
+ // write still run normally, the same way lintSpec's own crash in bin/hyperspec.mjs's `lint`
67
+ // handler becomes a reported error rather than an uncaught exception. Exported (and taking the
68
+ // station object rather than reading STATIONS itself) so this exact wrapping is testable against
69
+ // a station built to throw, without needing one registered in the shared registry
70
+ // (src/stations/index.mjs), which this file does not own.
71
+ export function runStation(station, spec, draft, ctx) {
72
+ try {
73
+ return station.run(spec, draft, ctx);
74
+ } catch (e) {
75
+ const raw = e instanceof Error ? e.message : String(e);
76
+ return {
77
+ station: station.name,
78
+ status: "fail",
79
+ findings: [{
80
+ station: station.name,
81
+ id: `station-${station.name}-crashed`,
82
+ severity: "fail",
83
+ message: withoutAbsolutePaths(raw),
84
+ fix: "Fix the station or file an issue; it should never throw.",
85
+ }],
86
+ };
87
+ }
88
+ }
89
+
90
+ // runCheck(specPathArg, draftPathArg, { only }): specPathArg and draftPathArg are exactly what
91
+ // the CLI (or a caller) was given, never resolved, so every path this returns or writes to the
92
+ // ledger is displayed and recorded the way the operator typed it, not as an absolute path on this
93
+ // machine. only, when given, is an array of station names to run instead of every registered one.
94
+ export function runCheck(specPathArg, draftPathArg, { only } = {}) {
95
+ if (!present(specPathArg)) return { usage: true, error: "check needs a spec path" };
96
+ if (!present(draftPathArg)) return { usage: true, error: "check needs --draft <file>" };
97
+
98
+ const spec = loadSpec(specPathArg);
99
+ if (spec.error) return { usage: true, error: spec.error };
100
+ // Every station reads the writing profile's blocks; a spec without it has nothing for them to
101
+ // read, and a run against it would fail quotations it has no materials for.
102
+ if (str(spec.data?.profile) !== "writing") return { usage: true, error: "check needs a writing spec (profile: writing)" };
103
+
104
+ // --only: every name must be one this build's registry knows; unknown names are a usage error
105
+ // (exit 2) rather than a silent no-op, and the run order always follows the registry, never the
106
+ // order --only happened to name them in, so two operators running the same --only string in a
107
+ // different order still see stations printed and ledgered identically.
108
+ let stationsToRun = STATIONS;
109
+ if (only && only.length) {
110
+ const unknown = only.filter((n) => !STATION_NAMES.includes(n));
111
+ if (unknown.length) {
112
+ return { usage: true, error: `unknown station${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}; known stations: ${STATION_NAMES.join(", ") || "(none)"}` };
113
+ }
114
+ stationsToRun = STATIONS.filter((s) => only.includes(s.name));
115
+ }
116
+
117
+ const lintFindings = lintSpec(spec);
118
+ const lintScore = score(lintFindings, spec.data);
119
+ if (lintScore.status !== "pass") {
120
+ return {
121
+ ok: false,
122
+ lintBlocked: true,
123
+ specPath: specPathArg,
124
+ lintStatus: lintScore.status,
125
+ lintScore,
126
+ lintFindings,
127
+ code: exitCode(lintScore.status),
128
+ };
129
+ }
130
+
131
+ let draftBuf;
132
+ try { draftBuf = readFileSync(resolve(draftPathArg)); }
133
+ catch { return { usage: true, error: `cannot read draft: ${draftPathArg}` }; }
134
+ // One leading UTF-8 BOM is not part of the draft's text: stripped here, so a heading on line 1
135
+ // is found and every offset and line number counts from the first real character. sha256 stays
136
+ // over the raw bytes.
137
+ const text = draftBuf.toString("utf8").replace(/^\uFEFF/, "");
138
+ const draft = { path: draftPathArg, text, lines: splitLines(text), sha256: sha256(draftBuf) };
139
+
140
+ const ctx = {};
141
+ const results = stationsToRun.map((s) => runStation(s, spec, draft, ctx));
142
+ const failing = results.filter((r) => r.status === "fail").map((r) => r.station);
143
+ const code = failing.length ? 1 : 0;
144
+
145
+ // ---- ledger: one line of evidence per check, only when the spec declares one -----------------
146
+ // What a line says, and why each reason is true:
147
+ // - A run with --only is partial: verdict not-improved, reason "partial run: <stations>",
148
+ // partial: true. Later verdicts ignore partial lines, so a subset never claims (or uses up)
149
+ // the verdict for the whole draft.
150
+ // - A full run compares with the most recent earlier FULL line for the same draft path (the
151
+ // path relative to the spec's folder). "Changed" means the draft's bytes (draft_sha256) or
152
+ // the spec's bytes (spec_sha256); files the spec names are not hashed.
153
+ // none, and every station passes -> one-shot
154
+ // none, and a station fails -> not-improved "failing stations: X"
155
+ // it failed, every station passes now -> improved "stations now pass: X", exactly
156
+ // the stations that failed then and pass now
157
+ // it passed, nothing changed, passing -> not-improved "no change since the last passing check"
158
+ // it passed, something changed, passing -> not-improved "<what> changed; every station still passes"
159
+ // failing now -> not-improved "[<what> changed; |no change since
160
+ // the last check; ]still failing: X" when every
161
+ // failing station also failed then, else
162
+ // "[<what> changed; ]failing stations: X"
163
+ let ledgerPath = null;
164
+ let ledgerWarning = null;
165
+ let verdict = null;
166
+ let verdictDetail = {};
167
+ const partial = Boolean(only && only.length);
168
+ const ledgerDecl = spec.data?.improvement?.ledger;
169
+ if (present(ledgerDecl)) {
170
+ if (!insideDir(spec.dir, ledgerDecl)) {
171
+ ledgerWarning = "improvement.ledger escapes the spec's directory; not appended";
172
+ } else {
173
+ const ledgerAbs = resolve(spec.dir, ledgerDecl);
174
+ let priorText = "";
175
+ try { priorText = readFileSync(ledgerAbs, "utf8"); } catch { /* not written yet; a first check creates it */ }
176
+
177
+ // The draft as the ledger records it: relative to the spec's folder, with forward slashes, so
178
+ // "./draft.md", "draft.md" and an absolute path are one history, and no absolute path lands
179
+ // in a ledger that is usually committed.
180
+ const draftKey = relative(resolve(spec.dir), resolve(draftPathArg)).split(sep).join("/");
181
+ const specSha = sha256(readFileSync(resolve(specPathArg)));
182
+ const statusNow = Object.fromEntries(results.map((r) => [r.station, r.status]));
183
+ const list = (names) => names.join(", ");
184
+
185
+ if (partial) {
186
+ verdict = "not-improved";
187
+ verdictDetail.reason = `partial run: ${list(results.map((r) => r.station))}`;
188
+ } else {
189
+ const last = priorCheckLines(priorText).filter((l) => l.draft === draftKey && l.partial !== true).at(-1);
190
+ const failedThen = last ? Object.entries(last.stations ?? {}).filter(([, st]) => st === "fail").map(([n]) => n) : [];
191
+ const draftChanged = Boolean(last) && last.draft_sha256 !== draft.sha256;
192
+ const specChanged = Boolean(last) && last.spec_sha256 !== specSha;
193
+ const what = draftChanged && specChanged ? "spec and draft" : specChanged ? "spec" : draftChanged ? "draft" : null;
194
+ const passedNow = failing.length === 0;
195
+
196
+ if (!last) {
197
+ if (passedNow) verdict = "one-shot";
198
+ else { verdict = "not-improved"; verdictDetail.reason = `failing stations: ${list(failing)}`; }
199
+ } else if (passedNow && failedThen.length) {
200
+ const nowPass = failedThen.filter((n) => statusNow[n] === "pass");
201
+ if (nowPass.length) { verdict = "improved"; verdictDetail.change = `stations now pass: ${list(nowPass)}`; }
202
+ else { verdict = "not-improved"; verdictDetail.reason = `${what ? `${what} changed; ` : ""}stations that failed last time now skip: ${list(failedThen)}`; }
203
+ } else if (passedNow) {
204
+ verdict = "not-improved";
205
+ verdictDetail.reason = what ? `${what} changed; every station still passes` : "no change since the last passing check";
206
+ } else {
207
+ verdict = "not-improved";
208
+ const still = failing.every((n) => failedThen.includes(n));
209
+ const prefix = what ? `${what} changed; ` : still ? "no change since the last check; " : "";
210
+ verdictDetail.reason = `${prefix}${still ? "still failing" : "failing stations"}: ${list(failing)}`;
211
+ }
212
+ }
213
+
214
+ const line = {
215
+ at: new Date().toISOString(),
216
+ kind: "check",
217
+ draft: draftKey,
218
+ draft_sha256: draft.sha256,
219
+ spec_sha256: specSha,
220
+ stations: statusNow,
221
+ ...(partial ? { partial: true } : {}),
222
+ verdict,
223
+ ...verdictDetail,
224
+ };
225
+ appendFileSync(ledgerAbs, `${JSON.stringify(line)}\n`);
226
+ // Reported exactly as the spec wrote it (improvement.ledger's own string), never resolved.
227
+ ledgerPath = ledgerDecl;
228
+ }
229
+ }
230
+
231
+ return {
232
+ ok: true,
233
+ specPath: specPathArg,
234
+ draftPath: draftPathArg,
235
+ draftSha256: draft.sha256,
236
+ stations: results,
237
+ failing,
238
+ partial,
239
+ verdict,
240
+ verdictDetail,
241
+ ledgerPath,
242
+ ledgerWarning,
243
+ code,
244
+ };
245
+ }