@supersuit/hyperspec 0.7.0 → 0.8.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.
@@ -0,0 +1,205 @@
1
+ ---
2
+ hyperspec: "0.1"
3
+ title: "Bread from zero: a four-lesson course"
4
+ kind: course
5
+ profile: writing
6
+ decisions:
7
+ - id: kind
8
+ state: decided
9
+ value: a course of four lessons in two parts, for someone who has never baked a loaf
10
+ source: course/materials/brief.md
11
+ author: example-author
12
+ chosen_by: human
13
+ - id: lesson-shape
14
+ state: decided
15
+ value: every lesson opens with what the reader can do after it and the terms it defines, and ends with one thing to do in a kitchen
16
+ source: course/materials/brief.md, the paragraph on ending each lesson
17
+ author: example-author
18
+ chosen_by: human
19
+ - id: part-files
20
+ state: delegated
21
+ rule: one file per part, named part-<n>.md, so a new part is picked up by the files pattern without editing the spec
22
+ source: course layout
23
+ author: agent:claude
24
+ chosen_by: agent
25
+ requirements:
26
+ - id: r1
27
+ text: no lesson uses a term before the lesson that defines it
28
+ fails_when: the sequence station reports a term used before it is defined
29
+ check:
30
+ station: sequence station, order guard
31
+ source: course/materials/brief.md
32
+ author: example-author
33
+ - id: r2
34
+ text: every term is defined in exactly one lesson
35
+ fails_when: the sequence station reports a term defined twice
36
+ check:
37
+ station: sequence station, defined-once guard
38
+ source: lesson-shape decision
39
+ author: example-author
40
+ - id: r3
41
+ text: every lesson carries its three sections
42
+ fails_when: a lesson has no "After this lesson you can", "New terms" or "Try this" section
43
+ check:
44
+ station: sequence station, sections guard
45
+ source: lesson-shape decision
46
+ author: example-author
47
+ - id: r4
48
+ text: each lesson defines the terms the outline promises for it
49
+ fails_when: the outline promises a term in a lesson that does not define it
50
+ check:
51
+ station: sequence station, outline guard
52
+ source: course/outline.md
53
+ author: example-author
54
+ - id: r5
55
+ text: every Try this can be done in a home kitchen in one day, apart from the starter, which takes a week
56
+ fails_when: a Try this needs equipment beyond a bowl, a scale, a jar, an oven and a heavy pot
57
+ check:
58
+ rubric: list what each Try this needs; fail on anything outside that list
59
+ source: course/materials/brief.md
60
+ author: example-author
61
+ rejects:
62
+ - a recipe before the reader has the words to follow it
63
+ - a term used before the lesson that defines it
64
+ examples:
65
+ - path: course/goldens/lesson.md
66
+ why: an instruction, then the reader's likely worry named plainly, then what to do next
67
+ resume:
68
+ next_action: bake the course's two loaves from Lesson 3 and photograph their crumb for Lesson 4
69
+ feedback:
70
+ issues: https://github.com/SupersuitUp/hyperspec/issues
71
+ fork: MIT; fork it for your own purposes
72
+ improvement:
73
+ ledger: course/runs.jsonl
74
+ writing:
75
+ materials:
76
+ items:
77
+ - id: brief
78
+ path: course/materials/brief.md
79
+ segments: course/materials/brief.md.segments.jsonl
80
+ produced_by: example-author
81
+ captured: "2026-09-20"
82
+ how: written after teaching the class twice
83
+ trust: considered
84
+ check:
85
+ station: every segment of every material carries a label from the closed set, matches its source verbatim, and the markings are current
86
+ source: capture step
87
+ author: agent:claude
88
+ dna:
89
+ writer: example-author
90
+ scope:
91
+ form: course
92
+ audience: first-time bakers
93
+ purpose: teach
94
+ rules: style-rules.md
95
+ goldens:
96
+ - path: course/goldens/lesson.md
97
+ why: an instruction, then the reader's likely worry named plainly, then what to do next
98
+ check:
99
+ rubric: blind lineup within this scope
100
+ source: goldens marked on the review page
101
+ author: example-author
102
+ persona:
103
+ identity: self
104
+ stance: guide
105
+ may_assert:
106
+ - what the author saw go wrong when teaching the class
107
+ will_not_say:
108
+ - a bake time or temperature for an oven the author has not used
109
+ facts_from: sources
110
+ check:
111
+ rubric: persona-consistency judge
112
+ source: persona interview
113
+ author: example-author
114
+ audience:
115
+ who: someone who has never baked a loaf of bread
116
+ funnel_now: has bought flour and has never used it for bread
117
+ knows:
118
+ - flour
119
+ - oven
120
+ believes_now: bread takes a recipe and a lot of skill
121
+ wants: to bake one good loaf
122
+ reads_on: a tablet propped up in the kitchen
123
+ reader: person
124
+ check:
125
+ station: the sequence station defines every term before it is used
126
+ rubric: simulated reader reports where it got lost
127
+ source: audience interview
128
+ author: example-author
129
+ goal:
130
+ from: has never baked bread
131
+ to: bakes a loaf and reads its crumb
132
+ next_if_worked: starts a starter the day they finish Lesson 2
133
+ change:
134
+ kind: action
135
+ text: the reader bakes their first loaf
136
+ conditions: [r1, r2, r3, r4, r5]
137
+ check:
138
+ rubric: the doctor grades the course against every condition
139
+ source: goal interview
140
+ author: example-author
141
+ form:
142
+ name: course
143
+ length:
144
+ min: 400
145
+ max: 1500
146
+ unit: words
147
+ required_parts:
148
+ - "Part 1: Dough"
149
+ - "Part 2: The bake"
150
+ stations:
151
+ - the sequence station
152
+ sequence:
153
+ unit: Lesson
154
+ files:
155
+ - course/part-*.md
156
+ sections:
157
+ - After this lesson you can
158
+ - New terms
159
+ - Try this
160
+ terms_section: New terms
161
+ outline: course/outline.md
162
+ teaser: Next,
163
+ check:
164
+ station: structure and length, then the sequence station
165
+ source: form decision
166
+ author: example-author
167
+ spine:
168
+ kind: primer
169
+ claims:
170
+ - id: c1
171
+ text: a first-time baker needs four words before any recipe
172
+ materials: [brief#s2]
173
+ - id: c2
174
+ text: the order is water and flour, then the rise, then shaping, then the oven
175
+ materials: [brief#s3]
176
+ - id: c3
177
+ text: each lesson ends with something to do in a real kitchen
178
+ materials: [brief#s4, brief#s5]
179
+ check:
180
+ rubric: each claim lands, in order
181
+ source: spine interview
182
+ author: example-author
183
+ sources:
184
+ ledger: course/claims.jsonl
185
+ unsourced_claim: fail
186
+ check:
187
+ station: every factual claim in the ledger points at a source span
188
+ source: sourcing pass
189
+ author: agent:claude
190
+ fiction: false
191
+ ---
192
+
193
+ # Bread from zero
194
+
195
+ A worked example of a sequential work: a course of four lessons in two parts, one file per part.
196
+ The spec lists the parts as `course/part-*.md`, so `check` needs no `--draft`: the parts, joined
197
+ in order, are the draft.
198
+
199
+ ```bash
200
+ npx @supersuit/hyperspec check course.hyperspec.md
201
+ ```
202
+
203
+ The `sequence` station holds the course to what a reader of Lesson 3 depends on: Lessons 1 and 2
204
+ defined every word it uses. The outline in `course/outline.md` promises the terms each lesson
205
+ defines, and the station checks the lessons keep that promise.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/hyperspec",
3
- "version": "0.7.0",
3
+ "version": "0.8.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 CHANGED
@@ -21,6 +21,7 @@ import { readDraft } from "./draft.mjs";
21
21
  import { openLedger, priorLines, ledgerDraftKey, ledgerVerdict } from "./ledger.mjs";
22
22
  import { STATIONS, STATION_NAMES } from "./stations/index.mjs";
23
23
  import { str } from "./placeholder.mjs";
24
+ import { readSequenceDraft, sequenceFilesDecl, sourceAt } from "./sequence-draft.mjs";
24
25
 
25
26
  const present = (v) => typeof v === "string" && v.trim().length > 0;
26
27
 
@@ -95,17 +96,29 @@ export function lintBlock(spec, specPathArg) {
95
96
  };
96
97
  }
97
98
 
99
+ // A finding from a draft assembled out of a sequence's files names the file and its own line in
100
+ // it, rather than a line of the joined text nobody can open.
101
+ function locate(finding, draft) {
102
+ const src = typeof finding.line === "number" ? sourceAt(draft, finding.line) : null;
103
+ return src ? { ...finding, file: src.file, line: finding.line - src.startLine + 1 } : finding;
104
+ }
105
+
98
106
  // runCheck(specPathArg, draftPathArg, { only }): specPathArg and draftPathArg are exactly what
99
107
  // the CLI (or a caller) was given, never resolved, so every path this returns or writes to the
100
108
  // ledger is displayed and recorded the way the operator typed it, not as an absolute path on this
101
109
  // machine. only, when given, is an array of station names to run instead of every registered one.
110
+ //
111
+ // With no draftPathArg, a spec that lists writing.form.sequence.files is checked against those
112
+ // files, joined in reading order (src/sequence-draft.mjs); any other spec still needs --draft.
102
113
  export function runCheck(specPathArg, draftPathArg, { only } = {}) {
103
114
  if (!present(specPathArg)) return { usage: true, error: "check needs a spec path" };
104
- if (!present(draftPathArg)) return { usage: true, error: "check needs --draft <file>" };
115
+ const needsDraft = { usage: true, error: "check needs --draft <file>" };
105
116
 
106
117
  const loaded = loadWritingSpec(specPathArg, "check");
118
+ if (!present(draftPathArg) && (loaded.usage || !sequenceFilesDecl(loaded.spec).length)) return needsDraft;
107
119
  if (loaded.usage) return loaded;
108
120
  const { spec } = loaded;
121
+ const fromSequence = !present(draftPathArg);
109
122
 
110
123
  // --only: every name must be one this build's registry knows; unknown names are a usage error
111
124
  // (exit 2) rather than a silent no-op, and the run order always follows the registry, never the
@@ -123,12 +136,16 @@ export function runCheck(specPathArg, draftPathArg, { only } = {}) {
123
136
  const blocked = lintBlock(spec, specPathArg);
124
137
  if (blocked) return blocked;
125
138
 
126
- // BOM stripped, CRLF-clean lines, sha256 over the raw bytes: see src/draft.mjs.
127
- const draft = readDraft(draftPathArg);
128
- if (!draft) return { usage: true, error: `cannot read draft: ${draftPathArg}` };
139
+ // BOM stripped, CRLF-clean lines, sha256 over the raw bytes: see src/draft.mjs. A sequence's
140
+ // draft is its files joined; its ledger key is the files entry as the spec writes it, so the
141
+ // history of the work stays one history as parts are added.
142
+ const draft = fromSequence ? readSequenceDraft(spec, specPathArg) : readDraft(draftPathArg);
143
+ const draftLabel = fromSequence ? sequenceFilesDecl(spec).join(", ") : draftPathArg;
144
+ if (!draft) return { usage: true, error: fromSequence ? `writing.form.sequence.files matches no file: ${draftLabel}` : `cannot read draft: ${draftPathArg}` };
129
145
 
130
146
  const ctx = {};
131
- const results = stationsToRun.map((s) => runStation(s, spec, draft, ctx));
147
+ const results = stationsToRun.map((s) => runStation(s, spec, draft, ctx))
148
+ .map((r) => (draft.sources ? { ...r, findings: r.findings.map((f) => locate(f, draft)) } : r));
132
149
  const failing = results.filter((r) => r.status === "fail").map((r) => r.station);
133
150
  const code = failing.length ? 1 : 0;
134
151
 
@@ -146,7 +163,7 @@ export function runCheck(specPathArg, draftPathArg, { only } = {}) {
146
163
  const ledger = openLedger(spec);
147
164
  if (ledger?.warning) ledgerWarning = ledger.warning;
148
165
  else if (ledger) {
149
- const draftKey = ledgerDraftKey(spec.dir, draftPathArg);
166
+ const draftKey = fromSequence ? draftLabel : ledgerDraftKey(spec.dir, draftPathArg);
150
167
  const specSha = sha256(readFileSync(resolve(specPathArg)));
151
168
  const statusNow = Object.fromEntries(results.map((r) => [r.station, r.status]));
152
169
 
@@ -177,7 +194,8 @@ export function runCheck(specPathArg, draftPathArg, { only } = {}) {
177
194
  return {
178
195
  ok: true,
179
196
  specPath: specPathArg,
180
- draftPath: draftPathArg,
197
+ draftPath: draftLabel,
198
+ ...(fromSequence ? { files: draft.sources.map((x) => x.file) } : {}),
181
199
  draftSha256: draft.sha256,
182
200
  stations: results,
183
201
  failing,
@@ -0,0 +1,75 @@
1
+ // A work read in order often lives in several files (one per part, one per lesson). When a writing
2
+ // spec lists them as writing.form.sequence.files, `hyperspec check <spec>` needs no --draft: the
3
+ // draft is those files, joined in order. This module owns which files that is and how they join, so
4
+ // lint (every entry must match a file) and check (the draft) agree on one reading.
5
+
6
+ import { readdirSync, readFileSync, statSync } from "node:fs";
7
+ import { dirname, join, posix, resolve } from "node:path";
8
+ import { sha256 } from "./hash.mjs";
9
+ import { str } from "./placeholder.mjs";
10
+ import { splitLines } from "./draft.mjs";
11
+
12
+ const list = (v) => (Array.isArray(v) ? v : []);
13
+ const byNumber = (a, b) => a.localeCompare(b, "en", { numeric: true });
14
+
15
+ // The declared entries, as written: writing.form.sequence.files, strings only.
16
+ export function sequenceFilesDecl(spec) {
17
+ return list(spec?.data?.writing?.form?.sequence?.files).map(str).filter(Boolean);
18
+ }
19
+
20
+ // The files one entry names, relative to the spec's folder and written with "/": the entry itself
21
+ // when it has no "*", or every file in its folder whose name matches, sorted so part-2 comes before
22
+ // part-10. A "*" matches within a file name only; the folder part is taken literally. [] when
23
+ // nothing matches.
24
+ export function expandEntry(specDir, entry) {
25
+ const clean = entry.replace(/\\/g, "/");
26
+ const dir = posix.dirname(clean);
27
+ const base = posix.basename(clean);
28
+ const isFile = (rel) => { try { return statSync(resolve(specDir, rel)).isFile(); } catch { return false; } };
29
+ if (!base.includes("*")) return isFile(clean) ? [clean] : [];
30
+ const re = new RegExp(`^${base.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*")}$`);
31
+ let names = [];
32
+ try { names = readdirSync(resolve(specDir, dir)); } catch { return []; }
33
+ return names.filter((n) => re.test(n)).sort(byNumber).map((n) => (dir === "." ? n : `${dir}/${n}`)).filter(isFile);
34
+ }
35
+
36
+ // Every file of the sequence in reading order, each once (at its first position).
37
+ export function sequenceFiles(specDir, entries) {
38
+ const out = [];
39
+ for (const e of entries) for (const f of expandEntry(specDir, e)) if (!out.includes(f)) out.push(f);
40
+ return out;
41
+ }
42
+
43
+ // The draft `check` grades when the spec lists sequence files and no --draft is given:
44
+ // { path, text, lines, sha256, sources }, the same shape src/draft.mjs's readDraft returns plus
45
+ // sources, one per file: { file, at, startLine, lineCount }. file is the path relative to the spec
46
+ // (what a finding names); at resolves from the working directory (what a station opens, such as
47
+ // links resolving a relative link beside the file that holds it). Each file's YAML frontmatter is
48
+ // blanked line for line, so its metadata is not prose and its line numbers stay its own. sha256
49
+ // covers every file's name and bytes. path is the first file's. null when no file matches.
50
+ export function readSequenceDraft(spec, specPathArg) {
51
+ const files = sequenceFiles(spec.dir, sequenceFilesDecl(spec));
52
+ if (!files.length) return null;
53
+ const parts = [];
54
+ const hashed = [];
55
+ const sources = [];
56
+ let startLine = 1;
57
+ for (const file of files) {
58
+ const buf = readFileSync(resolve(spec.dir, file));
59
+ hashed.push(Buffer.from(`${file}\n`), buf);
60
+ let text = buf.toString("utf8").replace(/^\uFEFF/, "");
61
+ text = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, (fm) => fm.replace(/[^\n]/g, ""));
62
+ if (!text.endsWith("\n")) text += "\n";
63
+ const lineCount = text.split("\n").length - 1;
64
+ sources.push({ file, at: join(dirname(specPathArg), file), startLine, lineCount });
65
+ parts.push(text);
66
+ startLine += lineCount;
67
+ }
68
+ const text = parts.join("");
69
+ return { path: sources[0].at, text, lines: splitLines(text), sha256: sha256(Buffer.concat(hashed)), sources };
70
+ }
71
+
72
+ // The source holding 1-based draft line `line`, or null.
73
+ export function sourceAt(draft, line) {
74
+ return list(draft?.sources).find((s) => line >= s.startLine && line < s.startLine + s.lineCount) ?? null;
75
+ }
@@ -3,7 +3,7 @@
3
3
  // src/check.mjs calls; adding a station is adding one file plus one line here, which is the whole
4
4
  // point of the registry existing rather than check.mjs importing each station by name itself.
5
5
  //
6
- // The order: form, terms, claims, quotes, private, dna, links. quotes and private share ctx (util.mjs's
6
+ // The order: form, terms, claims, quotes, private, dna, links, sequence. quotes and private share ctx (util.mjs's
7
7
  // markedSegments caches the spec's marked materials there), so a check run reads them once.
8
8
 
9
9
  import * as form from "./form.mjs";
@@ -13,6 +13,7 @@ import * as quotes from "./quotes.mjs";
13
13
  import * as privateStation from "./private.mjs";
14
14
  import * as dna from "./dna.mjs";
15
15
  import * as links from "./links.mjs";
16
+ import * as sequence from "./sequence.mjs";
16
17
 
17
18
  export const STATIONS = Object.freeze([
18
19
  { name: form.name, run: form.run },
@@ -22,6 +23,7 @@ export const STATIONS = Object.freeze([
22
23
  { name: privateStation.name, run: privateStation.run },
23
24
  { name: dna.name, run: dna.run },
24
25
  { name: links.name, run: links.run },
26
+ { name: sequence.name, run: sequence.run },
25
27
  ]);
26
28
 
27
29
  export const STATION_NAMES = Object.freeze(STATIONS.map((s) => s.name));
@@ -30,6 +30,7 @@
30
30
  import { existsSync } from "node:fs";
31
31
  import { dirname, resolve } from "node:path";
32
32
  import { lineAt, maskCode, maskRanges, truncate } from "./util.mjs";
33
+ import { sourceAt } from "../sequence-draft.mjs";
33
34
 
34
35
  export const name = "links";
35
36
 
@@ -226,6 +227,12 @@ function undefinedReferenceFinding(label, line) {
226
227
 
227
228
  export function run(spec, draft) {
228
229
  const draftDirAbs = dirname(resolve(draft.path));
230
+ // A draft assembled from a sequence's files (src/sequence-draft.mjs) resolves each relative link
231
+ // beside the file that holds it.
232
+ const dirAt = (line) => {
233
+ const src = sourceAt(draft, line);
234
+ return src ? dirname(resolve(src.at)) : draftDirAbs;
235
+ };
229
236
 
230
237
  // Masking pipeline: code first, then each link form in turn, each pass working on the text the
231
238
  // previous pass left behind, so nothing is ever matched twice by a later, looser pattern (a
@@ -253,8 +260,9 @@ export function run(spec, draft) {
253
260
  const entries = [];
254
261
 
255
262
  for (const { start, url } of [...mdLinks, ...bare]) {
256
- const reason = checkUrl(url, draftDirAbs);
257
- if (reason) entries.push({ start, finding: reasonFinding(reason, url, lineAt(draft.text, start)) });
263
+ const line = lineAt(draft.text, start);
264
+ const reason = checkUrl(url, dirAt(line));
265
+ if (reason) entries.push({ start, finding: reasonFinding(reason, url, line) });
258
266
  }
259
267
 
260
268
  for (const { start, label } of [...fullRefs, ...shortcutRefs]) {
@@ -264,7 +272,7 @@ export function run(spec, draft) {
264
272
  entries.push({ start, finding: undefinedReferenceFinding(label, line) });
265
273
  continue;
266
274
  }
267
- const reason = checkUrl(def.url, draftDirAbs);
275
+ const reason = checkUrl(def.url, dirAt(line));
268
276
  if (reason) entries.push({ start, finding: reasonFinding(reason, def.url, line) });
269
277
  }
270
278