@supersuit/hyperspec 0.2.0 → 0.4.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 (43) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +53 -3
  3. package/SPEC.md +24 -10
  4. package/WRITING.md +597 -0
  5. package/bin/hyperspec.mjs +94 -3
  6. package/examples/minimal.hyperspec.md +2 -2
  7. package/examples/writing/essay/goldens/close.md +2 -0
  8. package/examples/writing/essay/goldens/opening.md +2 -0
  9. package/examples/writing/essay/materials/interview-notes.md +12 -0
  10. package/examples/writing/essay/materials/interview-notes.md.segments.jsonl +10 -0
  11. package/examples/writing/essay/materials/team-survey.md +7 -0
  12. package/examples/writing/essay/materials/team-survey.md.segments.jsonl +6 -0
  13. package/examples/writing/essay/materials/voice-memo.md +18 -0
  14. package/examples/writing/essay/materials/voice-memo.md.segments.jsonl +7 -0
  15. package/examples/writing/essay/runs.jsonl +0 -0
  16. package/examples/writing/essay.hyperspec.md +220 -0
  17. package/examples/writing/story/goldens/dialogue.md +3 -0
  18. package/examples/writing/story/goldens/opening.md +3 -0
  19. package/examples/writing/story/materials/bakery-visit.md +9 -0
  20. package/examples/writing/story/materials/bakery-visit.md.segments.jsonl +13 -0
  21. package/examples/writing/story/materials/notes.md +16 -0
  22. package/examples/writing/story/materials/notes.md.segments.jsonl +7 -0
  23. package/examples/writing/story/materials/scene-list.md +7 -0
  24. package/examples/writing/story/materials/scene-list.md.segments.jsonl +14 -0
  25. package/examples/writing/story/runs.jsonl +0 -0
  26. package/examples/writing/story.hyperspec.md +288 -0
  27. package/examples/writing/style-rules.md +19 -0
  28. package/package.json +4 -2
  29. package/src/blobs.mjs +1 -1
  30. package/src/compare.mjs +6 -6
  31. package/src/fsutil.mjs +1 -1
  32. package/src/labels.mjs +6 -0
  33. package/src/placeholder.mjs +20 -0
  34. package/src/profiles.mjs +50 -0
  35. package/src/reproduce.mjs +5 -5
  36. package/src/rules.mjs +25 -13
  37. package/src/score.mjs +7 -1
  38. package/src/segments.mjs +407 -0
  39. package/src/template.mjs +4 -1
  40. package/src/writing-exports.mjs +6 -0
  41. package/src/writing-fields.mjs +418 -0
  42. package/src/writing-template.mjs +199 -0
  43. package/src/writing.mjs +181 -0
@@ -0,0 +1,288 @@
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: 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
+ segments: story/materials/notes.md.segments.jsonl
89
+ produced_by: example-author
90
+ captured: "2026-08-20"
91
+ how: typed notes
92
+ trust: raw
93
+ - id: bakery-visit
94
+ path: story/materials/bakery-visit.md
95
+ segments: story/materials/bakery-visit.md.segments.jsonl
96
+ produced_by: example-author
97
+ captured: "2026-08-28"
98
+ how: notes taken on site, corrected by the bakery owner afterwards
99
+ trust: considered
100
+ - id: scene-list
101
+ path: story/materials/scene-list.md
102
+ segments: story/materials/scene-list.md.segments.jsonl
103
+ produced_by: example-author
104
+ captured: "2026-09-02"
105
+ how: scene list agreed with the editor
106
+ trust: considered
107
+ check:
108
+ station: every segment of every material carries a label from the closed set, matches its source verbatim, and the markings are current
109
+ source: capture step
110
+ author: agent:claude
111
+ dna:
112
+ writer: example-author
113
+ scope:
114
+ form: short story
115
+ audience: literary magazine readers
116
+ purpose: move
117
+ rules: style-rules.md
118
+ goldens:
119
+ - path: story/goldens/opening.md
120
+ why: the narrator's voice arrives through one concrete sound, and the sentence about the mixer tells you how he sees the world
121
+ - path: story/goldens/dialogue.md
122
+ why: the narration between lines says only what Theo notices, never what Ines feels
123
+ check:
124
+ rubric: blind lineup within this scope; a judge shown a generated passage beside the two goldens cannot pick it out
125
+ source: goldens marked on the review page
126
+ author: example-author
127
+ persona:
128
+ identity: character:theo
129
+ stance: witness
130
+ may_assert:
131
+ - what Theo sees, hears and does in the bakery that morning
132
+ - what Theo knows as of the current scene, per his knowledge timeline
133
+ will_not_say:
134
+ - what Ines is thinking or feeling
135
+ - anything Theo does not know yet at that point in the morning
136
+ facts_from: sources
137
+ check:
138
+ rubric: persona-consistency judge; Theo's narration stays a witness's, and no process detail appears that is not in the claims ledger
139
+ source: persona interview
140
+ author: example-author
141
+ audience:
142
+ who: readers of a quarterly literary magazine who read short fiction in print
143
+ funnel_now: has turned to the story in the magazine without knowing the author
144
+ knows:
145
+ - bakery
146
+ - apprentice
147
+ - sourdough
148
+ believes_now: nothing yet about this author or these two people
149
+ wants: a story they can finish in one sitting and keep thinking about
150
+ reads_on: print, in one sitting of about fifteen minutes
151
+ reader: person
152
+ check:
153
+ station: term check against knows; proof, starter and deck oven are made plain by context on first use
154
+ rubric: simulated reader reports where it got lost and where it stopped reading
155
+ source: editor's brief
156
+ author: example-author
157
+ goal:
158
+ from: has never read the author
159
+ to: finishes the story and remembers the starter jar
160
+ next_if_worked: looks for the author's other stories
161
+ change:
162
+ kind: feeling
163
+ text: the reader feels the handover of the starter jar as the moment the two say what neither says aloud
164
+ conditions: [r1, r2, r3, r4, r5, r6]
165
+ check:
166
+ 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
167
+ source: goal interview
168
+ author: example-author
169
+ form:
170
+ name: short story
171
+ length:
172
+ min: 2500
173
+ max: 4000
174
+ unit: words
175
+ required_parts:
176
+ - four scenes, in the scene list's order
177
+ - a last line about bread
178
+ stations:
179
+ - continuity against the scene list
180
+ - knowledge-leak check, per character, per scene
181
+ check:
182
+ station: structure and length check against required_parts and length
183
+ source: editor's brief
184
+ author: example-author
185
+ spine:
186
+ kind: story
187
+ claims:
188
+ - id: c1
189
+ text: each of them hides their news to protect the other, and the hiding is the thing they share
190
+ materials: [notes#s2, scene-list#s10, scene-list#s12]
191
+ - id: c2
192
+ text: people who love each other at work say it through the work
193
+ materials: [notes#s5, bakery-visit#s8, scene-list#s8]
194
+ - id: c3
195
+ text: a craft outlives the room it was practiced in
196
+ materials: [notes#s5, scene-list#s13]
197
+ check:
198
+ rubric: each claim lands, in order, through what the characters do, and the story argues nothing outside the chain
199
+ source: spine interview
200
+ author: example-author
201
+ sources:
202
+ ledger: story/claims.jsonl
203
+ unsourced_claim: fail
204
+ check:
205
+ station: every process detail in the ledger points at a source span; every quote matches its source verbatim
206
+ source: sourcing pass
207
+ author: agent:claude
208
+ characters:
209
+ - id: ines
210
+ speech:
211
+ uses:
212
+ - instructions in the imperative
213
+ - numbers, weights and times
214
+ - first names, for customers
215
+ never:
216
+ - an apology in words
217
+ - a sentence about her own feelings
218
+ - a question she does not need answered
219
+ rhythm: short, flat sentences, often without a subject; silence where another person would reassure
220
+ wants: to leave the bakery in hands she trusts, without having to say that she is leaving it
221
+ fears: that Theo will stay in a trade with no bakery to do it in, because of her
222
+ hides: that she sold the bakery two weeks ago
223
+ knowledge:
224
+ - by: scene-1
225
+ knows: the sale closes on Friday, and the new owners will not keep it a bakery
226
+ - by: scene-4
227
+ knows: Theo has a place at a baking school in another city and leaves in the autumn
228
+ relationships:
229
+ - to: theo
230
+ how: gives him instructions where another person would give praise
231
+ - to: customers
232
+ how: warm and unhurried, asks after their families by name
233
+ arc_state: has let go of the bakery, and has not yet let go of Theo
234
+ golden_lines:
235
+ - Flour first. Then you can talk.
236
+ - Left side runs hot. Turn them at eight minutes.
237
+ - Sold means sold. Shape the rye.
238
+ rejected_lines:
239
+ - I'm so sorry I didn't tell you sooner, Theo.
240
+ - This place has been my whole life, you know?
241
+ - Would you like to try the rye today?
242
+ check:
243
+ rubric: blind attribution test, knowledge-leak check against the timeline, consistency against golden and rejected lines
244
+ source: story/materials/notes.md
245
+ author: example-author
246
+ - id: theo
247
+ speech:
248
+ uses:
249
+ - questions he already knows the answer to
250
+ - hedges such as "I mean" and "kind of"
251
+ - a joke when he is nervous
252
+ never:
253
+ - a flat instruction
254
+ - a word of baking jargon he has not heard Ines use first
255
+ rhythm: long sentences that double back on themselves and end in a question
256
+ wants: Ines to tell him he is ready, in words, once
257
+ fears: that leaving for school is a betrayal of the person who taught him
258
+ hides: that he has a place at a baking school in another city and leaves in the autumn
259
+ knowledge:
260
+ - by: scene-1
261
+ knows: the oven has made the noise since March, and he leaves for school in the autumn
262
+ - by: scene-3
263
+ knows: the bakery is sold, and the new owners will not keep it a bakery
264
+ relationships:
265
+ - to: ines
266
+ how: asks questions to keep her talking, and never interrupts her while she shapes
267
+ arc_state: still acting the apprentice while privately already leaving
268
+ golden_lines:
269
+ - 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?
270
+ - I can do the rye. I mean, I think I can do the rye. I did it Tuesday, kind of.
271
+ - So is that a yes, or is that the face you make when it's a yes?
272
+ rejected_lines:
273
+ - Shape the rye.
274
+ - I have been accepted to a culinary program and will be leaving in the autumn.
275
+ - Left side runs hot, turn them early.
276
+ check:
277
+ rubric: blind attribution test, knowledge-leak check against the timeline, consistency against golden and rejected lines
278
+ source: story/materials/notes.md
279
+ author: example-author
280
+ fiction: true
281
+ ---
282
+
283
+ # The Rye
284
+
285
+ A worked example of the writing profile for fiction: a short story with two characters, each
286
+ 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.
@@ -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.2.0",
3
+ "version": "0.4.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": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "exports": {
10
10
  "./recipe": "./src/writer.mjs",
11
+ "./writing": "./src/writing-exports.mjs",
11
12
  "./package.json": "./package.json"
12
13
  },
13
14
  "files": [
@@ -15,6 +16,7 @@
15
16
  "src/",
16
17
  "examples/",
17
18
  "SPEC.md",
19
+ "WRITING.md",
18
20
  "runs.jsonl",
19
21
  "README.md",
20
22
  "CHANGELOG.md",
@@ -39,6 +41,6 @@
39
41
  "outcome-factory"
40
42
  ],
41
43
  "dependencies": {
42
- "@supersuit/superskill": "^0.2.1"
44
+ "@supersuit/superskill": "^0.2.2"
43
45
  }
44
46
  }
package/src/blobs.mjs CHANGED
@@ -41,7 +41,7 @@ export function blobPath(root, hex) {
41
41
  // Atomic: bytes land in a temp file in the same directory first, then a single renameSync
42
42
  // (atomic on one filesystem) puts them at the final path. A process killed mid-write leaves
43
43
  // only the orphaned temp file, never a partially-written file sitting at the content-addressed
44
- // path — the failure mode a plain writeFileSync(path, bytes) would otherwise leave behind, and
44
+ // path. That is the failure mode a plain writeFileSync(path, bytes) would otherwise leave behind, and
45
45
  // which nothing short of an explicit verifyBlob would ever catch afterward.
46
46
  export function putBlob(root, bytes) {
47
47
  const hex = sha256(bytes);
package/src/compare.mjs CHANGED
@@ -66,7 +66,7 @@ export function compare(childRecipePath, { doctor, parent: parentOption, spec: s
66
66
  const specHash = sha256(readFileSync(specAbs));
67
67
  // specChanged is about the PARENT's record, not the child's: it tells the caller the spec being
68
68
  // graded against now differs from what the parent was made under. Both outputs still get graded
69
- // against this one spec file either way — never each against its own.
69
+ // against this one spec file either way, never each against its own.
70
70
  const specChanged = parentRecipe.spec?.sha256 !== specHash;
71
71
  if (specChanged) warnings.push("spec changed since the parent was made; both outputs graded against the current file");
72
72
 
@@ -114,14 +114,14 @@ export function compare(childRecipePath, { doctor, parent: parentOption, spec: s
114
114
  const ledgerAbs = resolve(specDir, ledgerDecl);
115
115
  const ledgerDir = dirname(ledgerAbs);
116
116
  // The child's own `change` is null whenever it has no genealogical parent of its own (a root
117
- // recipe compared against an explicit, unrelated --parent — compare's usage check allows
117
+ // recipe compared against an explicit, unrelated --parent; compare's usage check allows
118
118
  // this: it only requires *either* the child's recorded parent.path *or* an explicit
119
119
  // override). Fall back to naming what was actually compared against, so `change` and the
120
- // not-improved `reason` below are never "after null" — both a broken persisted record and,
120
+ // not-improved `reason` below are never "after null", which would be both a broken persisted record and,
121
121
  // for the "improved" verdict, a lint failure (rules.mjs's verdict-change: `!str(v.change)`).
122
122
  const childChange = child.change ?? `compared against ${relative(ledgerDir, parentRecipeAbs)}`;
123
123
  // A compare line must satisfy test 9 ("it improves itself"), which only knows the
124
- // verdict vocabulary one-shot/improved/not-improved — never a new "compare" verdict. A
124
+ // verdict vocabulary one-shot/improved/not-improved, never a new "compare" verdict. A
125
125
  // strictly higher child score is improved (and already carries change, which doubles as
126
126
  // that verdict's required field). Equal or lower is not-improved, with a reason a later
127
127
  // session can argue with; when it's a genuine regression (strictly lower, not merely tied)
@@ -162,8 +162,8 @@ export function compare(childRecipePath, { doctor, parent: parentOption, spec: s
162
162
 
163
163
  // Runs the doctor command once, via /bin/sh -c, over one output against the one spec. stdin is
164
164
  // { output, spec } (absolute paths); the last non-empty stdout line must be JSON with a numeric
165
- // score. Nothing recipe-derived is ever interpolated into the command string — only passed on
166
- // stdin — so the same command string is reused verbatim for the parent and the child.
165
+ // score. Nothing recipe-derived is ever interpolated into the command string; it is only passed on
166
+ // stdin, so the same command string is reused verbatim for the parent and the child.
167
167
  function runDoctor(doctorCmd, outputAbs, specAbs) {
168
168
  const res = spawnSync("/bin/sh", ["-c", doctorCmd], {
169
169
  input: JSON.stringify({ output: outputAbs, spec: specAbs }),
package/src/fsutil.mjs CHANGED
@@ -2,7 +2,7 @@ import { renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { relative, resolve, sep } from "node:path";
4
4
 
5
- // True when `target`, resolved against `dir`, stays inside `dir` — refuses a `..`-escape and an
5
+ // True when `target`, resolved against `dir`, stays inside `dir`. It refuses a `..`-escape and an
6
6
  // absolute path pointing elsewhere. Both a relative `target` (including one that climbs out via
7
7
  // `../`) and an already-absolute `target` are handled the same way, since `path.resolve(dir,
8
8
  // target)` already treats an absolute second argument as overriding the first: either way, the
package/src/labels.mjs ADDED
@@ -0,0 +1,6 @@
1
+ // The closed vocabulary every segment of a marked material is labeled from (hyperspec 0.4). It lives in
2
+ // this leaf module, which imports nothing, so src/segments.mjs can read it without importing
3
+ // src/writing.mjs: writing.mjs imports writing-fields.mjs, which imports segments.mjs, and reading
4
+ // the labels through writing.mjs made that a cycle. writing.mjs re-exports it for callers that
5
+ // already import it from there.
6
+ export const MATERIAL_LABELS = Object.freeze(["claim", "story", "quote", "stance", "question", "aside", "private"]);
@@ -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; };
@@ -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/reproduce.mjs CHANGED
@@ -11,7 +11,7 @@ const MALFORMED = "recorded hash is not a SHA-256 hash (64 lowercase hex charact
11
11
  const blobWhy = (hex, why) => (present(hex) && !isSha256(hex) ? MALFORMED : why);
12
12
 
13
13
  // Spec requirement `reproduce`: replay the record and hash-check it. Never invokes a model,
14
- // never runs a command, never regenerates a byte of content — every blob this looks at already
14
+ // never runs a command, never regenerates a byte of content. Every blob this looks at already
15
15
  // exists in the store, and this only confirms the recipe's own claims about it still hold.
16
16
  //
17
17
  // Checks, in order (and every one is reported, nothing stops the walk early): every input blob
@@ -98,8 +98,8 @@ export function reproduce(recipePath, { store, restore = false } = {}) {
98
98
  // 5. The output file on disk, if present, hashes to output.sha256. Absent is vacuously fine:
99
99
  // reproduce doesn't require the file to already exist, only that it agrees when it does.
100
100
  //
101
- // The recipe is a plain JSON file on disk — exactly the kind of claim reproduce exists to
102
- // distrust — so output.path is never trusted blind. A hand-edited or corrupted recipe could
101
+ // The recipe is a plain JSON file on disk, exactly the kind of claim reproduce exists to
102
+ // distrust, so output.path is never trusted blind. A hand-edited or corrupted recipe could
103
103
  // name a path outside the recipe's own directory (a `../` climb, or an absolute path); refuse
104
104
  // before touching disk at all, rather than reading from or (worse, under --restore) writing to
105
105
  // wherever it points.
@@ -120,12 +120,12 @@ export function reproduce(recipePath, { store, restore = false } = {}) {
120
120
  }
121
121
 
122
122
  // restore: true writes the output file from its blob, but only when the output blob
123
- // itself verified (step 4) — restoring from an unverified blob would just write
123
+ // itself verified (step 4); restoring from an unverified blob would just write
124
124
  // different wrong bytes. Covers both a lost file (never existed / deleted) and an
125
125
  // edited one. The write is atomic (temp file + rename, same pattern as putBlob), so a
126
126
  // crash mid-write never leaves the file in a state that is neither the old nor the new
127
127
  // content, and a write failure is caught and reported as a failing step rather than
128
- // thrown out of reproduce() — this function always returns a structured result.
128
+ // thrown out of reproduce(): this function always returns a structured result.
129
129
  const needsRestore = !fileExists || !matches;
130
130
  if (restore && needsRestore && outputBlobOk) {
131
131
  const blob = getBlob(root, outputSha);
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
- // A value that is only null or ~ is a placeholder: the reader (@supersuit/superskill/yaml) keeps
29
- // these as the literal strings "null" and "~" rather than resolving them to YAML's own null, so
30
- // they must never count as present. A value that is only a YAML comment (source: # TODO) is
31
- // handled upstream since superskill 0.2.1: the reader returns "" for it, same as any other blank
32
- // scalar, so it already fails str()'s own emptiness check and needs no rule here. A QUOTED value
33
- // that happens to start with "#" (source: "# literal") is real text and must count as present.
34
- // Every presence check goes through str().
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
- const next = str(d.resume?.next_action);
123
- if (!next) out.push(f(7, "next-action", "fail", "no resume.next_action", "Write the single concrete step that starts the next session."));
124
- else if (NO_ACTION.test(next)) out.push(f(7, "next-action-vague", "fail", `next action "${next}" names no action`, "Name the concrete step."));
125
- 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."));
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
- return { tests, passed: tests.filter((t) => t.pass).length, open, status };
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;