@supersuit/hyperspec 0.2.0 → 0.3.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.
- package/CHANGELOG.md +60 -0
- package/README.md +31 -2
- package/SPEC.md +22 -10
- package/WRITING.md +426 -0
- package/bin/hyperspec.mjs +45 -2
- package/examples/minimal.hyperspec.md +2 -2
- package/examples/writing/essay/goldens/close.md +2 -0
- package/examples/writing/essay/goldens/opening.md +2 -0
- package/examples/writing/essay/materials/interview-notes.md +12 -0
- package/examples/writing/essay/materials/team-survey.md +7 -0
- package/examples/writing/essay/materials/voice-memo.md +18 -0
- package/examples/writing/essay/runs.jsonl +0 -0
- package/examples/writing/essay.hyperspec.md +217 -0
- package/examples/writing/story/goldens/dialogue.md +3 -0
- package/examples/writing/story/goldens/opening.md +3 -0
- package/examples/writing/story/materials/bakery-visit.md +8 -0
- package/examples/writing/story/materials/notes.md +14 -0
- package/examples/writing/story/materials/scene-list.md +7 -0
- package/examples/writing/story/runs.jsonl +0 -0
- package/examples/writing/story.hyperspec.md +285 -0
- package/examples/writing/style-rules.md +19 -0
- package/package.json +3 -2
- package/src/placeholder.mjs +20 -0
- package/src/profiles.mjs +50 -0
- package/src/rules.mjs +25 -13
- package/src/score.mjs +7 -1
- package/src/template.mjs +4 -1
- package/src/writing-fields.mjs +317 -0
- package/src/writing-template.mjs +192 -0
- package/src/writing.mjs +181 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
---
|
|
2
|
+
hyperspec: "0.1"
|
|
3
|
+
title: The Rye
|
|
4
|
+
kind: short story
|
|
5
|
+
profile: writing
|
|
6
|
+
decisions:
|
|
7
|
+
- id: point-of-view
|
|
8
|
+
state: decided
|
|
9
|
+
value: first person, told by Theo, in the past tense
|
|
10
|
+
source: story/materials/notes.md
|
|
11
|
+
author: example-author
|
|
12
|
+
chosen_by: human
|
|
13
|
+
- id: time-span
|
|
14
|
+
state: decided
|
|
15
|
+
value: one morning, 3:40 to 7:00, in the four scenes of the scene list and in that order
|
|
16
|
+
source: story/materials/scene-list.md
|
|
17
|
+
author: example-author
|
|
18
|
+
chosen_by: human
|
|
19
|
+
- id: bakery-name
|
|
20
|
+
state: delegated
|
|
21
|
+
rule: the bakery is never named; it is always "the bakery" or "Ines's"
|
|
22
|
+
source: editor's note on the scene list
|
|
23
|
+
author: agent:claude
|
|
24
|
+
chosen_by: agent
|
|
25
|
+
requirements:
|
|
26
|
+
- id: r1
|
|
27
|
+
text: the two voices cannot be confused
|
|
28
|
+
fails_when: a judge shown ten lines of dialogue with the speaker hidden names the wrong speaker for more than one of them
|
|
29
|
+
check:
|
|
30
|
+
rubric: blind attribution test across both characters, ten lines drawn at random from the draft
|
|
31
|
+
source: story/materials/notes.md
|
|
32
|
+
author: example-author
|
|
33
|
+
- id: r2
|
|
34
|
+
text: no one says what they could not know yet
|
|
35
|
+
fails_when: Theo mentions the sale before scene-3, or Ines mentions the school before scene-4
|
|
36
|
+
check:
|
|
37
|
+
station: knowledge-leak check of every line against each character's knowledge timeline
|
|
38
|
+
source: story/materials/scene-list.md
|
|
39
|
+
author: example-author
|
|
40
|
+
- id: r3
|
|
41
|
+
text: the bakery's process matches a real working morning
|
|
42
|
+
fails_when: a proofing time, the tray turn, or the opening time differs from story/materials/bakery-visit.md
|
|
43
|
+
check:
|
|
44
|
+
station: every process detail in the claims ledger points at a span of the bakery visit notes
|
|
45
|
+
source: story/materials/bakery-visit.md
|
|
46
|
+
author: agent:claude
|
|
47
|
+
- id: r4
|
|
48
|
+
text: the story is the four scenes of the scene list, in order
|
|
49
|
+
fails_when: the draft has more or fewer than four scenes, or they run out of the scene list's order
|
|
50
|
+
check:
|
|
51
|
+
station: structure check against story/materials/scene-list.md
|
|
52
|
+
source: story/materials/scene-list.md
|
|
53
|
+
author: example-author
|
|
54
|
+
- id: r5
|
|
55
|
+
text: the last line is about bread
|
|
56
|
+
fails_when: the final sentence names a feeling, or does not mention bread, dough, flour or the starter
|
|
57
|
+
check:
|
|
58
|
+
rubric: read the final sentence; fail if it names an emotion or mentions none of bread, dough, flour or the starter
|
|
59
|
+
source: story/materials/notes.md
|
|
60
|
+
author: example-author
|
|
61
|
+
- id: r6
|
|
62
|
+
text: the story stays inside its length envelope
|
|
63
|
+
fails_when: the word count is under 2,500 or over 4,000
|
|
64
|
+
check:
|
|
65
|
+
station: word count against form.length
|
|
66
|
+
source: editor's brief
|
|
67
|
+
author: example-author
|
|
68
|
+
rejects:
|
|
69
|
+
- either character saying out loud that they love or will miss the other
|
|
70
|
+
- a flashback outside the one morning
|
|
71
|
+
- a scene where the oven noise is explained
|
|
72
|
+
- an ending that tells the reader what the starter jar means
|
|
73
|
+
examples:
|
|
74
|
+
- path: story/goldens/dialogue.md
|
|
75
|
+
why: three short lines carry a ritual both characters know, without either of them naming it
|
|
76
|
+
resume:
|
|
77
|
+
next_action: mark the three materials into labeled segments, then draft scene-1 from the scene list with Theo's knowledge as of scene-1
|
|
78
|
+
feedback:
|
|
79
|
+
issues: https://github.com/SupersuitUp/hyperspec/issues
|
|
80
|
+
fork: MIT; fork it for your own purposes
|
|
81
|
+
improvement:
|
|
82
|
+
ledger: story/runs.jsonl
|
|
83
|
+
writing:
|
|
84
|
+
materials:
|
|
85
|
+
items:
|
|
86
|
+
- id: notes
|
|
87
|
+
path: story/materials/notes.md
|
|
88
|
+
produced_by: example-author
|
|
89
|
+
captured: "2026-08-20"
|
|
90
|
+
how: typed notes
|
|
91
|
+
trust: raw
|
|
92
|
+
- id: bakery-visit
|
|
93
|
+
path: story/materials/bakery-visit.md
|
|
94
|
+
produced_by: example-author
|
|
95
|
+
captured: "2026-08-28"
|
|
96
|
+
how: notes taken on site, corrected by the bakery owner afterwards
|
|
97
|
+
trust: considered
|
|
98
|
+
- id: scene-list
|
|
99
|
+
path: story/materials/scene-list.md
|
|
100
|
+
produced_by: example-author
|
|
101
|
+
captured: "2026-09-02"
|
|
102
|
+
how: scene list agreed with the editor
|
|
103
|
+
trust: considered
|
|
104
|
+
check:
|
|
105
|
+
station: every segment of every material carries a label from the closed set
|
|
106
|
+
source: capture step
|
|
107
|
+
author: agent:claude
|
|
108
|
+
dna:
|
|
109
|
+
writer: example-author
|
|
110
|
+
scope:
|
|
111
|
+
form: short story
|
|
112
|
+
audience: literary magazine readers
|
|
113
|
+
purpose: move
|
|
114
|
+
rules: style-rules.md
|
|
115
|
+
goldens:
|
|
116
|
+
- path: story/goldens/opening.md
|
|
117
|
+
why: the narrator's voice arrives through one concrete sound, and the sentence about the mixer tells you how he sees the world
|
|
118
|
+
- path: story/goldens/dialogue.md
|
|
119
|
+
why: the narration between lines says only what Theo notices, never what Ines feels
|
|
120
|
+
check:
|
|
121
|
+
rubric: blind lineup within this scope; a judge shown a generated passage beside the two goldens cannot pick it out
|
|
122
|
+
source: goldens marked on the review page
|
|
123
|
+
author: example-author
|
|
124
|
+
persona:
|
|
125
|
+
identity: character:theo
|
|
126
|
+
stance: witness
|
|
127
|
+
may_assert:
|
|
128
|
+
- what Theo sees, hears and does in the bakery that morning
|
|
129
|
+
- what Theo knows as of the current scene, per his knowledge timeline
|
|
130
|
+
will_not_say:
|
|
131
|
+
- what Ines is thinking or feeling
|
|
132
|
+
- anything Theo does not know yet at that point in the morning
|
|
133
|
+
facts_from: sources
|
|
134
|
+
check:
|
|
135
|
+
rubric: persona-consistency judge; Theo's narration stays a witness's, and no process detail appears that is not in the claims ledger
|
|
136
|
+
source: persona interview
|
|
137
|
+
author: example-author
|
|
138
|
+
audience:
|
|
139
|
+
who: readers of a quarterly literary magazine who read short fiction in print
|
|
140
|
+
funnel_now: has turned to the story in the magazine without knowing the author
|
|
141
|
+
knows:
|
|
142
|
+
- bakery
|
|
143
|
+
- apprentice
|
|
144
|
+
- sourdough
|
|
145
|
+
believes_now: nothing yet about this author or these two people
|
|
146
|
+
wants: a story they can finish in one sitting and keep thinking about
|
|
147
|
+
reads_on: print, in one sitting of about fifteen minutes
|
|
148
|
+
reader: person
|
|
149
|
+
check:
|
|
150
|
+
station: term check against knows; proof, starter and deck oven are made plain by context on first use
|
|
151
|
+
rubric: simulated reader reports where it got lost and where it stopped reading
|
|
152
|
+
source: editor's brief
|
|
153
|
+
author: example-author
|
|
154
|
+
goal:
|
|
155
|
+
from: has never read the author
|
|
156
|
+
to: finishes the story and remembers the starter jar
|
|
157
|
+
next_if_worked: looks for the author's other stories
|
|
158
|
+
change:
|
|
159
|
+
kind: feeling
|
|
160
|
+
text: the reader feels the handover of the starter jar as the moment the two say what neither says aloud
|
|
161
|
+
conditions: [r1, r2, r3, r4, r5, r6]
|
|
162
|
+
check:
|
|
163
|
+
rubric: the doctor grades the draft against every condition; the simulated reader is asked what the starter jar meant and whether it would look for the author's next story
|
|
164
|
+
source: goal interview
|
|
165
|
+
author: example-author
|
|
166
|
+
form:
|
|
167
|
+
name: short story
|
|
168
|
+
length:
|
|
169
|
+
min: 2500
|
|
170
|
+
max: 4000
|
|
171
|
+
unit: words
|
|
172
|
+
required_parts:
|
|
173
|
+
- four scenes, in the scene list's order
|
|
174
|
+
- a last line about bread
|
|
175
|
+
stations:
|
|
176
|
+
- continuity against the scene list
|
|
177
|
+
- knowledge-leak check, per character, per scene
|
|
178
|
+
check:
|
|
179
|
+
station: structure and length check against required_parts and length
|
|
180
|
+
source: editor's brief
|
|
181
|
+
author: example-author
|
|
182
|
+
spine:
|
|
183
|
+
kind: story
|
|
184
|
+
claims:
|
|
185
|
+
- id: c1
|
|
186
|
+
text: each of them hides their news to protect the other, and the hiding is the thing they share
|
|
187
|
+
materials: [notes, scene-list]
|
|
188
|
+
- id: c2
|
|
189
|
+
text: people who love each other at work say it through the work
|
|
190
|
+
materials: [notes, bakery-visit]
|
|
191
|
+
- id: c3
|
|
192
|
+
text: a craft outlives the room it was practiced in
|
|
193
|
+
materials: [notes, scene-list]
|
|
194
|
+
check:
|
|
195
|
+
rubric: each claim lands, in order, through what the characters do, and the story argues nothing outside the chain
|
|
196
|
+
source: spine interview
|
|
197
|
+
author: example-author
|
|
198
|
+
sources:
|
|
199
|
+
ledger: story/claims.jsonl
|
|
200
|
+
unsourced_claim: fail
|
|
201
|
+
check:
|
|
202
|
+
station: every process detail in the ledger points at a source span; every quote matches its source verbatim
|
|
203
|
+
source: sourcing pass
|
|
204
|
+
author: agent:claude
|
|
205
|
+
characters:
|
|
206
|
+
- id: ines
|
|
207
|
+
speech:
|
|
208
|
+
uses:
|
|
209
|
+
- instructions in the imperative
|
|
210
|
+
- numbers, weights and times
|
|
211
|
+
- first names, for customers
|
|
212
|
+
never:
|
|
213
|
+
- an apology in words
|
|
214
|
+
- a sentence about her own feelings
|
|
215
|
+
- a question she does not need answered
|
|
216
|
+
rhythm: short, flat sentences, often without a subject; silence where another person would reassure
|
|
217
|
+
wants: to leave the bakery in hands she trusts, without having to say that she is leaving it
|
|
218
|
+
fears: that Theo will stay in a trade with no bakery to do it in, because of her
|
|
219
|
+
hides: that she sold the bakery two weeks ago
|
|
220
|
+
knowledge:
|
|
221
|
+
- by: scene-1
|
|
222
|
+
knows: the sale closes on Friday, and the new owners will not keep it a bakery
|
|
223
|
+
- by: scene-4
|
|
224
|
+
knows: Theo has a place at a baking school in another city and leaves in the autumn
|
|
225
|
+
relationships:
|
|
226
|
+
- to: theo
|
|
227
|
+
how: gives him instructions where another person would give praise
|
|
228
|
+
- to: customers
|
|
229
|
+
how: warm and unhurried, asks after their families by name
|
|
230
|
+
arc_state: has let go of the bakery, and has not yet let go of Theo
|
|
231
|
+
golden_lines:
|
|
232
|
+
- Flour first. Then you can talk.
|
|
233
|
+
- Left side runs hot. Turn them at eight minutes.
|
|
234
|
+
- Sold means sold. Shape the rye.
|
|
235
|
+
rejected_lines:
|
|
236
|
+
- I'm so sorry I didn't tell you sooner, Theo.
|
|
237
|
+
- This place has been my whole life, you know?
|
|
238
|
+
- Would you like to try the rye today?
|
|
239
|
+
check:
|
|
240
|
+
rubric: blind attribution test, knowledge-leak check against the timeline, consistency against golden and rejected lines
|
|
241
|
+
source: story/materials/notes.md
|
|
242
|
+
author: example-author
|
|
243
|
+
- id: theo
|
|
244
|
+
speech:
|
|
245
|
+
uses:
|
|
246
|
+
- questions he already knows the answer to
|
|
247
|
+
- hedges such as "I mean" and "kind of"
|
|
248
|
+
- a joke when he is nervous
|
|
249
|
+
never:
|
|
250
|
+
- a flat instruction
|
|
251
|
+
- a word of baking jargon he has not heard Ines use first
|
|
252
|
+
rhythm: long sentences that double back on themselves and end in a question
|
|
253
|
+
wants: Ines to tell him he is ready, in words, once
|
|
254
|
+
fears: that leaving for school is a betrayal of the person who taught him
|
|
255
|
+
hides: that he has a place at a baking school in another city and leaves in the autumn
|
|
256
|
+
knowledge:
|
|
257
|
+
- by: scene-1
|
|
258
|
+
knows: the oven has made the noise since March, and he leaves for school in the autumn
|
|
259
|
+
- by: scene-3
|
|
260
|
+
knows: the bakery is sold, and the new owners will not keep it a bakery
|
|
261
|
+
relationships:
|
|
262
|
+
- to: ines
|
|
263
|
+
how: asks questions to keep her talking, and never interrupts her while she shapes
|
|
264
|
+
arc_state: still acting the apprentice while privately already leaving
|
|
265
|
+
golden_lines:
|
|
266
|
+
- Okay but like, if the oven's made that noise since March, is it a noise, or is that just how the oven talks now?
|
|
267
|
+
- I can do the rye. I mean, I think I can do the rye. I did it Tuesday, kind of.
|
|
268
|
+
- So is that a yes, or is that the face you make when it's a yes?
|
|
269
|
+
rejected_lines:
|
|
270
|
+
- Shape the rye.
|
|
271
|
+
- I have been accepted to a culinary program and will be leaving in the autumn.
|
|
272
|
+
- Left side runs hot, turn them early.
|
|
273
|
+
check:
|
|
274
|
+
rubric: blind attribution test, knowledge-leak check against the timeline, consistency against golden and rejected lines
|
|
275
|
+
source: story/materials/notes.md
|
|
276
|
+
author: example-author
|
|
277
|
+
fiction: true
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
# The Rye
|
|
281
|
+
|
|
282
|
+
A worked example of the writing profile for fiction: a short story with two characters, each
|
|
283
|
+
specified well enough that an agent can write their dialogue and a judge can tell them apart.
|
|
284
|
+
Every file this spec names ships beside it. `story/claims.jsonl` does not exist yet, because the
|
|
285
|
+
claims ledger is written during drafting.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Style rules
|
|
2
|
+
|
|
3
|
+
The always-on layer. These apply to everything this writer publishes, whatever the form, the
|
|
4
|
+
audience or the purpose. Goldens change with the scope; these do not.
|
|
5
|
+
|
|
6
|
+
## Never
|
|
7
|
+
|
|
8
|
+
- No em dashes. Use a comma, a colon, or a new sentence.
|
|
9
|
+
- No sentence that sets up a claim only to knock down a weaker one first. State the claim.
|
|
10
|
+
- No adjective standing in for evidence. If a sentence says a thing is important, it shows why.
|
|
11
|
+
- No filler openers ("In today's world", "It goes without saying").
|
|
12
|
+
- No exclamation marks outside quoted dialogue.
|
|
13
|
+
|
|
14
|
+
## Always
|
|
15
|
+
|
|
16
|
+
- The first line of a piece states what it is about.
|
|
17
|
+
- A number carries its source, or it does not go in.
|
|
18
|
+
- A quotation is word for word, or it is not in quotation marks.
|
|
19
|
+
- One idea per paragraph. A paragraph that needs a second idea is two paragraphs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/hyperspec",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"src/",
|
|
16
16
|
"examples/",
|
|
17
17
|
"SPEC.md",
|
|
18
|
+
"WRITING.md",
|
|
18
19
|
"runs.jsonl",
|
|
19
20
|
"README.md",
|
|
20
21
|
"CHANGELOG.md",
|
|
@@ -39,6 +40,6 @@
|
|
|
39
40
|
"outcome-factory"
|
|
40
41
|
],
|
|
41
42
|
"dependencies": {
|
|
42
|
-
"@supersuit/superskill": "^0.2.
|
|
43
|
+
"@supersuit/superskill": "^0.2.2"
|
|
43
44
|
}
|
|
44
45
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// A value only a human or an agent would recognize as "not actually written yet" never counts as
|
|
2
|
+
// present, wherever a presence check reads it:
|
|
3
|
+
//
|
|
4
|
+
// - null and ~ (the YAML reader hands these back as the literal strings "null" and "~" rather
|
|
5
|
+
// than resolving them to YAML's own null);
|
|
6
|
+
// - the placeholder words a scaffold or a hurried author leaves behind: todo, tbd, fixme, xxx,
|
|
7
|
+
// placeholder, <placeholder>, n/a;
|
|
8
|
+
// - a run of dashes, a run of question marks, or an ellipsis ("...", or the single character);
|
|
9
|
+
// - any of those followed by trailing ".", ":" or "!" (TODO., tbd:, FIXME!).
|
|
10
|
+
//
|
|
11
|
+
// Matched only against the WHOLE trimmed value, case-insensitive: "TODO: write the opening" is
|
|
12
|
+
// real text that happens to start with the word, and still counts as present. "none" is not on
|
|
13
|
+
// the list, because it is a legitimate decided value ("rejects: none of the above").
|
|
14
|
+
//
|
|
15
|
+
// This is the one place this pattern is defined. src/rules.mjs (the core tests), src/writing.mjs
|
|
16
|
+
// and src/writing-fields.mjs (the writing profile) all import str() from here rather than keeping
|
|
17
|
+
// their own copy, so a word added here closes every presence check in the linter at once. The
|
|
18
|
+
// rule exists because a scaffold that fills every field with "TODO" would otherwise lint clean.
|
|
19
|
+
export const PLACEHOLDER = /^(?:null|~|(?:todo|tbd|fixme|xxx|placeholder|<placeholder>|n\/a|-+|\?+|\.\.\.|…)[.:!]*)$/is;
|
|
20
|
+
export const str = (v) => { const t = typeof v === "string" ? v.trim() : ""; return PLACEHOLDER.test(t) ? "" : t; };
|
package/src/profiles.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// A profile is an opt-in: a spec sets profile: <name> and gains a typed map of extra content
|
|
2
|
+
// (writing: for profile: writing) that this file's rules check, on top of everything the core
|
|
3
|
+
// format already requires. Every profile finding still reports under one of the nine tests; a
|
|
4
|
+
// profile adds no tenth test and no separate score.
|
|
5
|
+
//
|
|
6
|
+
// This is the one place that knows which profile names exist. Adding a profile means adding one
|
|
7
|
+
// entry here; rules.mjs and score.mjs both go through this registry rather than naming "writing"
|
|
8
|
+
// themselves, so a second profile needs no change to either.
|
|
9
|
+
import { lintWriting, blockStatus } from "./writing.mjs";
|
|
10
|
+
|
|
11
|
+
const str = (v) => (typeof v === "string" ? v.trim() : "");
|
|
12
|
+
const f = (test, id, severity, message, fix) => ({ test, id, severity, message, fix });
|
|
13
|
+
|
|
14
|
+
export const PROFILES = Object.freeze({
|
|
15
|
+
writing: Object.freeze({
|
|
16
|
+
lint: lintWriting,
|
|
17
|
+
// Block completeness for the CLI's "<name>: k/n blocks complete" line and its --json twin.
|
|
18
|
+
// Reads the same findings lint just produced, so the count and the findings can never
|
|
19
|
+
// disagree with each other.
|
|
20
|
+
status: (data, findings) => blockStatus(data, findings),
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Own-property lookup only: a profile named after something every object inherits
|
|
25
|
+
// (constructor, toString, __proto__) is an unknown profile, never a function to call.
|
|
26
|
+
export const knownProfile = (name) => (Object.hasOwn(PROFILES, name) ? PROFILES[name] : undefined);
|
|
27
|
+
|
|
28
|
+
// Runs the declared profile's rules against a loaded spec. A spec with no profile: at all runs no
|
|
29
|
+
// profile rules, so an unprofiled spec lints exactly as it always has. A profile: this linter does
|
|
30
|
+
// not know is a warning under test 7 (a stranger resuming the spec still needs to know its rules
|
|
31
|
+
// were not checked), not a failure: an unknown profile is not necessarily a wrong one, only one
|
|
32
|
+
// this version cannot yet check.
|
|
33
|
+
export function lintProfile(spec) {
|
|
34
|
+
const name = str(spec?.data?.profile);
|
|
35
|
+
if (!name) return [];
|
|
36
|
+
const profile = knownProfile(name);
|
|
37
|
+
if (!profile) {
|
|
38
|
+
return [f(7, "unknown-profile", "warn", `this linter does not know profile "${name}"; its rules were not checked`, "Set profile: to one this linter knows (writing), or remove it.")];
|
|
39
|
+
}
|
|
40
|
+
return profile.lint(spec);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The { name, complete, total } score.mjs merges into a passing profile's score, or undefined
|
|
44
|
+
// when no profile ran or the declared one is unknown (nothing to count).
|
|
45
|
+
export function profileStatus(data, findings) {
|
|
46
|
+
const name = str(data?.profile);
|
|
47
|
+
const profile = knownProfile(name);
|
|
48
|
+
if (!profile) return undefined;
|
|
49
|
+
return { name, ...profile.status(data, findings) };
|
|
50
|
+
}
|
package/src/rules.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { lintProfile } from "./profiles.mjs";
|
|
4
|
+
import { str } from "./placeholder.mjs";
|
|
3
5
|
|
|
4
6
|
export const TESTS = Object.freeze([
|
|
5
7
|
{ n: 1, name: "every decision is accounted for" },
|
|
@@ -25,15 +27,13 @@ const FENCE = /^ {0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:^ {0,3}\1[`~]*[ \t]*$|(?![
|
|
|
25
27
|
const INLINE_CODE = /(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)/g;
|
|
26
28
|
const prose = (body) => String(body || "").replace(FENCE, "").replace(INLINE_CODE, "");
|
|
27
29
|
const list = (v) => (Array.isArray(v) ? v : []);
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// handled upstream since superskill 0.2.1: the reader returns ""
|
|
32
|
-
// scalar, so it already fails str()'s own emptiness check and
|
|
33
|
-
// that happens to start with "#" (source: "# literal") is real
|
|
34
|
-
//
|
|
35
|
-
const PLACEHOLDER = /^(null|~)$/is;
|
|
36
|
-
const str = (v) => { const t = typeof v === "string" ? v.trim() : ""; return PLACEHOLDER.test(t) ? "" : t; };
|
|
30
|
+
// str() (a value that is null/~/todo/tbd/fixme/xxx/placeholder never counts as present) is
|
|
31
|
+
// shared, from src/placeholder.mjs: writing.mjs and writing-fields.mjs import the same function,
|
|
32
|
+
// so a placeholder word closes every presence check in the linter at once. A value that is only a
|
|
33
|
+
// YAML comment (source: # TODO) is handled upstream since superskill 0.2.1: the reader returns ""
|
|
34
|
+
// for it, same as any other blank scalar, so it already fails str()'s own emptiness check and
|
|
35
|
+
// needs no rule here. A QUOTED value that happens to start with "#" (source: "# literal") is real
|
|
36
|
+
// text and must count as present.
|
|
37
37
|
const f = (test, id, severity, message, fix) => ({ test, id, severity, message, fix });
|
|
38
38
|
// The versions of the format this linter knows. A spec naming any other may follow rules it
|
|
39
39
|
// cannot check, so it is warned about rather than failed.
|
|
@@ -52,6 +52,11 @@ export function lintSpec(spec) {
|
|
|
52
52
|
const version = str(d.hyperspec);
|
|
53
53
|
if (!KNOWN_VERSIONS.includes(version)) out.push(f(7, "hyperspec-version", "warn", `hyperspec version "${version}" is not one this linter knows (${KNOWN_VERSIONS.join(", ")})`, "Set hyperspec: to a version this linter knows, or upgrade @supersuit/hyperspec."));
|
|
54
54
|
|
|
55
|
+
// A spec with no profile: runs no profile rules at all, so it lints exactly as it always has.
|
|
56
|
+
// One declared runs that profile's own rules (writing.mjs for profile: writing), every finding
|
|
57
|
+
// still reported under one of the nine tests below; an unknown profile name is a warning here.
|
|
58
|
+
out.push(...lintProfile(spec));
|
|
59
|
+
|
|
55
60
|
// 1 and 4, decisions
|
|
56
61
|
const decisions = list(d.decisions);
|
|
57
62
|
if (!decisions.length) out.push(f(1, "decisions", "fail", "no decisions are listed", "List every decision this kind of work has under decisions:, each decided, delegated or open."));
|
|
@@ -119,10 +124,17 @@ export function lintSpec(spec) {
|
|
|
119
124
|
});
|
|
120
125
|
|
|
121
126
|
// 7
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
127
|
+
// NO_ACTION is checked against the RAW trimmed value, ahead of str()'s placeholder-blanking:
|
|
128
|
+
// two of its own words (tbd, todo) are now also placeholder words str() treats as blank, and
|
|
129
|
+
// "next action names no action" is the more specific, more correct message for those than "no
|
|
130
|
+
// resume.next_action" would be (something WAS written; it just names no action).
|
|
131
|
+
const rawNext = typeof d.resume?.next_action === "string" ? d.resume.next_action.trim() : "";
|
|
132
|
+
if (rawNext && NO_ACTION.test(rawNext)) out.push(f(7, "next-action-vague", "fail", `next action "${rawNext}" names no action`, "Name the concrete step."));
|
|
133
|
+
else {
|
|
134
|
+
const next = str(d.resume?.next_action);
|
|
135
|
+
if (!next) out.push(f(7, "next-action", "fail", "no resume.next_action", "Write the single concrete step that starts the next session."));
|
|
136
|
+
else if (CONVERSATION.test(next)) out.push(f(7, "next-action-vague", "fail", `next action "${next}" points into a conversation the next reader cannot see`, "State the step itself."));
|
|
137
|
+
}
|
|
126
138
|
if (CONVERSATION.test(prose(spec.body))) out.push(f(7, "conversation-pointer", "warn", "the body points into a conversation the next reader cannot see", "State the thing itself."));
|
|
127
139
|
|
|
128
140
|
// 8
|
package/src/score.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TESTS } from "./rules.mjs";
|
|
2
|
+
import { profileStatus } from "./profiles.mjs";
|
|
2
3
|
|
|
3
4
|
export function score(findings, data = {}) {
|
|
4
5
|
const failed = new Set(findings.filter((x) => x.severity === "fail").map((x) => x.test));
|
|
@@ -6,7 +7,12 @@ export function score(findings, data = {}) {
|
|
|
6
7
|
const open = (Array.isArray(data.decisions) ? data.decisions : [])
|
|
7
8
|
.filter((x) => String(x?.state || "").trim() === "open").map((x) => String(x.id || "").trim());
|
|
8
9
|
const status = failed.size ? "fail" : open.length ? "blocked" : "pass";
|
|
9
|
-
|
|
10
|
+
const out = { tests, passed: tests.filter((t) => t.pass).length, open, status };
|
|
11
|
+
// Only when a known profile ran: profileStatus returns undefined for no profile: at all, and
|
|
12
|
+
// for one this linter does not know (nothing to count when its rules were never checked).
|
|
13
|
+
const profile = profileStatus(data, findings);
|
|
14
|
+
if (profile) out.profile = profile;
|
|
15
|
+
return out;
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
export const exitCode = (status) => ({ pass: 0, fail: 1, blocked: 3 })[status] ?? 2;
|
package/src/template.mjs
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
// which is also a valid YAML double-quoted scalar.
|
|
5
5
|
const PLAIN = /^[A-Za-z0-9][A-Za-z0-9 _.,'()/-]*$/;
|
|
6
6
|
const RESOLVES = /^(null|~|true|false|yes|no|on|off|y|n|[-+]?(\d[\d_]*)?\.?\d+([eE][-+]?\d+)?|0x[0-9a-f]+|0o[0-7]+|\.inf|\.nan)$/i;
|
|
7
|
-
|
|
7
|
+
// Exported so any other init-time template (writing-template.mjs's writingTemplate, and whatever
|
|
8
|
+
// profile templates come after it) quotes titles, kinds and other free-text scalars the same way,
|
|
9
|
+
// rather than re-deriving this regex pair.
|
|
10
|
+
export const scalar = (v) => (PLAIN.test(v) && !/\s$/.test(v) && !RESOLVES.test(v) ? v : JSON.stringify(v));
|
|
8
11
|
|
|
9
12
|
export function template({ title = "Untitled", kind = "document" } = {}) {
|
|
10
13
|
const heading = String(title).replace(/\s+/g, " ").trim();
|