@supersuit/hyperspec 0.9.0 → 0.10.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 CHANGED
@@ -1,5 +1,61 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 (2026-10-02)
4
+
5
+ What a drafting loop and a per-audience renderer need to run on the engine without hand work
6
+ (build 7 wiring, from the letter's BUILD-NOTES).
7
+
8
+ - **A draft's frontmatter and HTML comments are not prose.** `check`, `judge` and `learn` read every
9
+ draft through `readDraft`, which now blanks a leading YAML frontmatter block and every
10
+ `<!-- ... -->` comment line for line (their characters go, their line breaks stay, so line
11
+ numbers are the file's own). A kept document (a title, a status, a note to self about what it
12
+ was meant to be) grades exactly as its prose does. Sequence drafts already blanked frontmatter;
13
+ they now blank comments too. `hideUnseen` in `src/draft.mjs` is the rule.
14
+ - **`hyperspec segments label <segments-file> <ids>=<label>[:key=value]...`** labels segments in
15
+ place: `s1,s4=aside`, `s3=quote:speaker=gary-sheng`, `s2=claim:own=true` (`own=true` is written as
16
+ the boolean). Offsets and text never change. An unknown id, a label outside the closed set or a
17
+ malformed assignment exits 2 with nothing written. It prints what is still unlabeled.
18
+ `labelSegments` and `parseLabelAssignment` in `src/segments.mjs`.
19
+ - **`hyperspec ready <spec> [--draft <file>] [--judges a,b] [--json]`** answers whether this draft
20
+ has been through the engine under this spec, from the runs ledger alone: the latest full check
21
+ of these exact draft and spec bytes passed; every required judge (the list given, or every judge
22
+ that applies) passed on the same bytes, each panel reader including the buyer; and that check
23
+ came after the last of those judge lines, so the panel's findings were triaged and held. Exit 0
24
+ ready, 1 not ready with each missing step listed, 2 usage.
25
+ - **`specText(data, body)` and `parseSpecText(text)`**, exported from `@supersuit/hyperspec/writing`, writes a spec's text
26
+ from data and reads it back with the linter's own reader, throwing (and naming the field) when
27
+ any value would not come back unchanged. For tools that build specs rather than people typing
28
+ them.
29
+
30
+ **Behavior change:** a draft whose frontmatter or comments held words now counts fewer words, and a
31
+ term that first appeared in a comment now first appears in the prose. No finding id changed.
32
+
33
+ ## 0.9.1 (2026-09-30)
34
+
35
+ Two fixes, each found by the first book written as a sequence.
36
+
37
+ - The sequence station no longer reads a word inside a longer defined term as a use of the shorter
38
+ one. A book that defines "Thinking level" in Lesson 2 and "Level" in Lesson 22 failed
39
+ `station-sequence-used-before-defined` and `station-sequence-quiz-used-before-defined` at every
40
+ "thinking level" before Lesson 22. Before it looks for a term, the station now blanks every other
41
+ defined term that contains it as a whole-word phrase, a trailing plural "s" included, in the order
42
+ guard, the quiz order guard and quiz coverage. The shorter term on its own still counts, and the
43
+ longer term no longer tests the shorter one in a quiz. `longerTerms` and `maskLonger` are exported
44
+ from the station.
45
+ - `hyperspec segments init <material> --id <mid> --keep <old>` re-marks an edited material without
46
+ relabeling what did not change (issue #2). Every segment whose text, trimmed, a segment in `<old>`
47
+ has keeps that segment's id, label and every other key (`own`, `source`, `teller`, `speaker`, and
48
+ anything added by hand), with new offsets; the rest start `unlabeled` with the next unused
49
+ `s<n>` id, and the command lists them. The id is carried so the spine's citations keep pointing
50
+ at the same words. `--keep` may name the file being written, so a material is re-marked in place;
51
+ otherwise an existing file is still never overwritten. It exits 2 for a `--keep` file that cannot
52
+ be read, has a line that is not a JSON object, or marks another material. `carrySegments` in
53
+ `src/segments.mjs` is the logic.
54
+
55
+ **Behavior change:** a sequential work that defines a term inside a longer one may now pass where
56
+ 0.9.0 failed it, and a quiz whose only question on a shorter term used it inside the longer term now
57
+ fails `station-sequence-quiz-untested` for that term. No finding id changed.
58
+
3
59
  ## 0.9.0 (2026-09-29)
4
60
 
5
61
  The pressure test a draft gets before it ships is now part of the run, and so is answering it.
package/README.md CHANGED
@@ -25,10 +25,12 @@ improvement ledger. Every test is defined in [SPEC.md](SPEC.md).
25
25
  | `hyperspec lint <file...> [--json]` | Score each hyperspec against the nine tests. |
26
26
  | `hyperspec init <file> [--title T] [--kind K]` | Write a new hyperspec skeleton. Refuses to overwrite an existing file. |
27
27
  | `hyperspec init <file> --profile writing [--title T] [--form F] [--fiction]` | Write a writing-spec skeleton, every block shown with placeholders. |
28
- | `hyperspec segments init <material> --id <mid> [--out F] [--by paragraph\|sentence]` | Split a material into segments to label. Refuses to overwrite an existing file. |
28
+ | `hyperspec segments init <material> --id <mid> [--out F] [--by paragraph\|sentence] [--keep OLD]` | Split a material into segments to label. With `--keep`, re-mark an edited material: every segment whose text is unchanged keeps its id and labels, and only the rest are listed to label. Refuses to overwrite an existing file unless `--keep` names it. |
29
+ | `hyperspec segments label <segments-file> <ids>=<label>[:key=value]...` | Label segments in place (`s1,s4=aside`, `s3=quote:speaker=gary-sheng`, `s2=claim:own=true`); offsets and text never change. Refuses an unknown id or label with nothing written. |
29
30
  | `hyperspec dna init <scope-dir> --writer W --form F --audience A --purpose P` | Start a writer-DNA scope folder. Refuses to overwrite an existing `scope.md`. |
30
31
  | `hyperspec dna measure <scope-dir>` | Check every golden in a scope and write its measured features. |
31
32
  | `hyperspec check <spec> [--draft <file>] [--only a,b]` | Run a writing spec's deterministic stations against a draft, or, for a sequential work, against its files in reading order. |
33
+ | `hyperspec ready <spec> --draft <file> [--judges a,b]` | Has this draft been through the engine? From the runs ledger only: a passing full check of these exact bytes, every required judge passing on them (each panel reader, the buyer included), and the check after the last judge so triage was held. Exit 0 ready, 1 not ready with each missing step listed. |
32
34
  | `hyperspec judge prepare <spec> --draft <file> --out <dir> [--only a,b] [--force]` | Write one packet per judgment station, for an outside judge to fill. |
33
35
  | `hyperspec judge record <packet> --verdict <file>` | Check a judge's verdict against its packet, derive the station's status, and record it. |
34
36
  | `hyperspec triage status <spec> [--draft <file>]` | Count every finding's answer, list the passages two or more readers share, and hold every answer to the draft. |
@@ -43,7 +45,7 @@ improvement ledger. Every test is defined in [SPEC.md](SPEC.md).
43
45
  | `hyperspec regenerate <recipe> --out <path> --clicker <slug> <one change> [--run cmd]` | Make a child recipe from a parent and one named change, rerunning only the stages it reaches. |
44
46
  | `hyperspec compare <child-recipe> --doctor cmd` | Grade a child and its parent through one doctor against one spec. |
45
47
 
46
- Every command except `init`, `segments init` and `dna init` takes `--json`. `hyperspec --help` prints every flag.
48
+ Every command except `init`, `segments init`, `segments label` and `dna init` takes `--json`. `hyperspec --help` prints every flag.
47
49
 
48
50
  ## Exit codes
49
51
 
package/SPEC.md CHANGED
@@ -109,7 +109,7 @@ improvement:
109
109
 
110
110
  A person writing for another person leaves most of the specification unsaid, because the other person fills the gaps from shared context. An agent has none of that context, so it fills every gap with the average, and the average is what reads as middling. Hyperspecification is writing down the gaps. It is a level of detail that would feel like overkill between two people and is exactly enough for an agent: every decision the agent would otherwise guess is either decided, delegated with the rule for deciding it, or marked open, so the work stops instead of guessing.
111
111
 
112
- **Version 0.9.0** (2026-09-29)
112
+ **Version 0.10.0** (2026-10-02)
113
113
 
114
114
  ## What makes a spec a hyperspec
115
115
 
package/WRITING.md CHANGED
@@ -397,17 +397,21 @@ writing spec cannot pass until every material it draws on is marked.
397
397
  npx @supersuit/hyperspec segments init materials/voice-memo.md --id voice-memo
398
398
  ```
399
399
 
400
- `hyperspec segments init <material> --id <mid> [--out <file>] [--by paragraph|sentence]` writes
401
- `<material>.segments.jsonl`, or the path `--out` names. `--by paragraph`, the default, makes one
400
+ `hyperspec segments init <material> --id <mid> [--out <file>] [--by paragraph|sentence] [--keep <old>]`
401
+ writes `<material>.segments.jsonl`, or the path `--out` names. `--by paragraph`, the default, makes one
402
402
  segment per paragraph. `--by sentence` makes one per sentence, and a new line that opens on a list
403
403
  marker (`-`, `*`, `+`, `1.` or `1)`, then a space) also starts a segment, so each bullet in a set
404
404
  of notes stands on its own. Every segment starts as `unlabeled`, which lint never accepts. `init`
405
- refuses to overwrite a file that exists, and exits 2 on a material that does not exist or a
406
- `--by` it does not know, a material with nothing in it, and an `--out` folder that does not
407
- exist.
405
+ refuses to overwrite a file that exists, unless `--keep` names that file (see
406
+ [When a material changes](#when-a-material-changes)), and exits 2 on a material that does not
407
+ exist or a `--by` it does not know, a material with nothing in it, and an `--out` folder that does
408
+ not exist.
408
409
 
409
410
  Then name the file on the material item, as `segments:` beside `path:`, and label every segment.
410
- You may also move a boundary by hand, splitting one segment in two or joining two, as long as
411
+ `hyperspec segments label <segments-file> <ids>=<label>[:key=value]...` does it without editing
412
+ JSON by hand: `s1,s4=aside`, `s3=quote:speaker=gary-sheng`, `s2=claim:own=true`. Offsets and text
413
+ never change, an unknown id or a label outside the set is refused with nothing written, and it
414
+ prints what is still unlabeled. You may also move a boundary by hand, splitting one segment in two or joining two, as long as
411
415
  the rules under [Coverage](#coverage) still hold.
412
416
 
413
417
  ### The segments file
@@ -482,9 +486,31 @@ fails test 1.
482
486
 
483
487
  The header's `sha256` pins the material as it was when it was marked. If the material changes,
484
488
  lint fails the segments file as stale (test 4), because its offsets and labels describe text that
485
- is no longer there. Mark it again: run `segments init` with `--out` to a new file, point the
486
- material item at it, and label every segment, carrying labels over from the old file wherever the
487
- text did not change.
489
+ is no longer there. Mark it again, keeping the labels of every stretch of text that did not change:
490
+
491
+ ```bash
492
+ npx @supersuit/hyperspec segments init materials/voice-memo.md --id voice-memo --keep materials/voice-memo.md.segments.jsonl
493
+ ```
494
+
495
+ `--keep <old>` splits the material as it reads now, and every segment whose text, trimmed, a
496
+ segment in `<old>` has keeps that segment's `id`, its `label` and every other key it carries
497
+ (`own`, `source`, `teller`, `speaker`, anything added by hand), with its `start` and `end` taken
498
+ from the new split. The `id` is kept because the spine cites segments by id, so a citation keeps
499
+ pointing at the words it pointed at. Old segments with the same text are used in order, each once.
500
+ A segment no old one matches is new or changed text: it starts `unlabeled`, gets the next `s<n>` id
501
+ no old segment used, and is listed with the start of its text, so only those need a label.
502
+ `--keep` may name the file being written, which is how a material is re-marked in place; with no
503
+ `--keep`, or one naming another file, an existing file is still never overwritten. It exits 2 when
504
+ the `--keep` file cannot be read, has a line that is not a JSON object, or marks a material other
505
+ than `--id`.
506
+
507
+ After the author's framing line, s4, is edited to "and I kept the note in my wallet.", that prints:
508
+
509
+ ```text
510
+ 5 segments written to materials/voice-memo.md.segments.jsonl, 4 labels carried from materials/voice-memo.md.segments.jsonl, 1 to label:
511
+ s6 "My first manager said this to me in my second week, and I ke..."
512
+ Label each one (claim, story, quote, stance, question, aside, private), then run hyperspec lint on the spec.
513
+ ```
488
514
 
489
515
  The hash is over the file's bytes, so a change nobody would call an edit still counts. Converting
490
516
  line endings is the common one: a material marked with LF endings reads as stale once an editor
@@ -821,6 +847,10 @@ against the draft:
821
847
  npx @supersuit/hyperspec check essay.hyperspec.md --draft essay/draft.md
822
848
  ```
823
849
 
850
+ A draft is read as a reader sees it: a leading YAML frontmatter block and every HTML comment
851
+ are blanked line for line, so a kept document (a title, a status, a note to self) grades exactly
852
+ as its prose does, and every line number is still the file's own.
853
+
824
854
  It lints the spec first. A spec that fails lint, or is blocked on an open decision, runs no
825
855
  station and exits with lint's own code, because a draft cannot be checked against a spec that is
826
856
  not ready. Then it runs nine stations in a fixed order and prints one line for each: `pass`,
@@ -1146,7 +1176,10 @@ marks and any parenthetical dropped. An item that says `(from Lesson 3)` reminds
1146
1176
  earlier term and defines nothing.
1147
1177
 
1148
1178
  A use is a whole-word match, ignoring case, where a hyphen joins a word: "context-aware" does not
1149
- use "context", and "skills" does not use "skill". Code is not prose: fenced blocks and inline code
1179
+ use "context", and "skills" does not use "skill". A word inside a longer defined term is not a
1180
+ use of the shorter one: with "level" and "thinking level" both defined, "thinking level" and
1181
+ "thinking levels" use only "thinking level", while "level" on its own still uses "level". The quiz
1182
+ rules read a question the same way. Code is not prose: fenced blocks and inline code
1150
1183
  never count as a use, so `@supersuit/superskill` does not use "supersuit". A unit's own terms
1151
1184
  section is not a use either. Listing a word in `knows` or `audience.knows` is a decision that the
1152
1185
  reader already has it, and it is the only way a unit may use a word before the unit that defines it.
@@ -1841,6 +1874,27 @@ cannot be read. `--json` prints the whole result.
1841
1874
  | `triage-import-quote-not-found` | warn | an imported finding quotes text, and none of it is in the draft |
1842
1875
  | `triage-import-empty` | invalid | the review holds no finding to answer |
1843
1876
 
1877
+ ## Is it ready
1878
+
1879
+ ```bash
1880
+ npx @supersuit/hyperspec ready essay.hyperspec.md --draft essay/draft.md
1881
+ ```
1882
+
1883
+ `hyperspec ready <spec> [--draft <file>] [--judges a,b] [--json]` answers one question for a
1884
+ tool that hands a draft on (to a person, to a send step): has THIS draft been through the engine
1885
+ under THIS spec? It reads the runs ledger and runs nothing. Ready means all three:
1886
+
1887
+ - the latest full check of these exact draft and spec bytes passed (an `--only` run proves
1888
+ nothing about the stations it skipped);
1889
+ - every required judge has a line for the same bytes and the latest passed; required is the
1890
+ `--judges` list, or every judgment station that applies to the spec and draft, and the panel
1891
+ needs a line for every reader, the buyer included;
1892
+ - that check comes after the last of those judge lines, so the panel's findings went to triage
1893
+ and a check held every answer.
1894
+
1895
+ It exits 0 ready, 1 not ready with each missing step listed, 2 usage. Edit the draft or the spec
1896
+ and it is not ready until it is graded again, because every rule is about the bytes.
1897
+
1844
1898
  ## Learning from edits
1845
1899
 
1846
1900
  A factory writes a first draft, and a person edits it into the draft they approve. Every edit is
package/bin/hyperspec.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, statSync, writeFileSync, readFileSync, mkdirSync } from "node:fs";
2
+ import { existsSync, statSync, writeFileSync, readFileSync, mkdirSync, realpathSync } from "node:fs";
3
3
  import { dirname, resolve, join } from "node:path";
4
4
  import { loadSpec } from "../src/load.mjs";
5
5
  import { lintSpec } from "../src/rules.mjs";
@@ -12,7 +12,7 @@ import { approve } from "../src/writer.mjs";
12
12
  import { reproduce } from "../src/reproduce.mjs";
13
13
  import { regenerate } from "../src/regenerate.mjs";
14
14
  import { compare } from "../src/compare.mjs";
15
- import { splitSegments } from "../src/segments.mjs";
15
+ import { splitSegments, carrySegments, labelSegments } from "../src/segments.mjs";
16
16
  import { sha256 } from "../src/hash.mjs";
17
17
  import { readScope, measureFeatures, writeFeatures, scopeTemplate, GOLDENS_README } from "../src/dna.mjs";
18
18
  import { str } from "../src/placeholder.mjs";
@@ -21,6 +21,7 @@ import { prepareJudges, recordJudgment } from "../src/judge.mjs";
21
21
  import { prepareLearn, recordLearn, tallyLine } from "../src/learn.mjs";
22
22
  import { triageContext, triageState, answerFinding, importReview, replyText } from "../src/triage.mjs";
23
23
  import { sourceAt } from "../src/sequence-draft.mjs";
24
+ import { ready } from "../src/ready.mjs";
24
25
 
25
26
  const HELP = `hyperspec <command> [options]
26
27
 
@@ -41,6 +42,14 @@ const HELP = `hyperspec <command> [options]
41
42
  exit 0 every run station passed, 1 a station failed, 2 usage
42
43
  (including a missing draft file, a spec without the writing
43
44
  profile, or an --only that names no known station)
45
+ ready <spec> [--draft <file>] [--judges a,b] [--json]
46
+ has this draft been through the engine under this spec? read from
47
+ the runs ledger only: the latest full check of these exact draft
48
+ and spec bytes passed, every required judge (the --judges list, or
49
+ every judge that applies; each panel reader, the buyer included)
50
+ passed on the same bytes, and that check came after the last of
51
+ those judge lines, so panel findings were triaged and held
52
+ exit 0 ready, 1 not ready (each missing step listed), 2 usage
44
53
  judge prepare <spec> --draft <file> --out <dir> [--only a,b] [--force] [--json]
45
54
  needs a writing spec that passes lint (exits with lint's own code
46
55
  otherwise); writes one <station>.packet.json per applicable
@@ -125,16 +134,28 @@ const HELP = `hyperspec <command> [options]
125
134
  writing, --kind with it (use --form), or a folder that does not
126
135
  exist
127
136
 
128
- segments init <material> --id <mid> [--out <file>] [--by paragraph|sentence]
137
+ segments init <material> --id <mid> [--out <file>] [--by paragraph|sentence] [--keep <old>]
129
138
  split a material into candidate segments, written as JSONL to
130
139
  <material>.segments.jsonl by default; every segment starts label:
131
140
  unlabeled, never valid in lint; label each one by hand (claim,
132
141
  story, quote, stance, question, aside, private), then run hyperspec
133
142
  lint on the spec; --by sentence also starts a segment at each list
134
- item (-, *, +, 1. or 1) then a space); refuses to overwrite an
135
- existing file (exit 2); exit 2 for a missing material, a material
136
- with nothing in it, an --out folder that does not exist, or a --by
137
- outside paragraph/sentence
143
+ item (-, *, +, 1. or 1) then a space); --keep re-marks an edited
144
+ material: every segment whose text, trimmed, an old segment in
145
+ <old> has keeps that segment's id, label and every other key, and
146
+ the rest are listed to label; refuses to overwrite an existing
147
+ file unless --keep names it (exit 2); exit 2 for a missing
148
+ material, a material with nothing in it, an --out folder that does
149
+ not exist, a --by outside paragraph/sentence, or a --keep file that
150
+ cannot be read, has a line that is not a JSON object, or marks
151
+ another material
152
+
153
+ segments label <segments-file> <ids>=<label>[:key=value]...
154
+ label segments in place: ids a comma list (s1,s4), the label from
155
+ the closed set, then per-label fields (speaker=, teller=, source=,
156
+ own=true); offsets and text are never touched; refuses an unknown
157
+ id, a label outside the set or a malformed assignment with nothing
158
+ written (exit 2); prints what is still unlabeled
138
159
 
139
160
  dna init <scope-dir> --writer W --form F --audience A --purpose P
140
161
  write a new writer-DNA scope: scope.md (writer, form, audience,
@@ -225,7 +246,7 @@ if (cmd === "segments") {
225
246
  const sub = argv[1];
226
247
 
227
248
  if (sub === "init") {
228
- const parsed = parseArgs(argv.slice(2), { valueFlags: ["--id", "--out", "--by"] });
249
+ const parsed = parseArgs(argv.slice(2), { valueFlags: ["--id", "--out", "--by", "--keep"] });
229
250
  if (parsed.error) { console.error(parsed.error); process.exit(2); }
230
251
  const [material] = parsed.positionals;
231
252
  if (!material) { console.error("segments init needs a material path"); process.exit(2); }
@@ -237,18 +258,73 @@ if (cmd === "segments") {
237
258
  try { materialStat = statSync(material); } catch { materialStat = null; }
238
259
  if (!materialStat || !materialStat.isFile()) { console.error(`material not found: ${material}`); process.exit(2); }
239
260
  const out = parsed.values["--out"] ?? `${material}.segments.jsonl`;
240
- if (existsSync(out)) { console.error(`refusing to overwrite ${out}`); process.exit(2); }
261
+ const keep = parsed.values["--keep"];
262
+ // --keep may name the file being written: re-marking in place is the point. Nothing else is
263
+ // ever overwritten.
264
+ const samePath = (a, b) => { try { return realpathSync(a) === realpathSync(b); } catch { return resolve(a) === resolve(b); } };
265
+ if (existsSync(out) && !(keep && samePath(keep, out))) { console.error(`refusing to overwrite ${out}${keep ? " (--keep names a different file)" : ""}`); process.exit(2); }
241
266
  const outFolder = dirname(resolve(out));
242
267
  if (!existsSync(outFolder) || !statSync(outFolder).isDirectory()) { console.error(`folder does not exist: ${dirname(out)}; create it first`); process.exit(2); }
243
268
 
269
+ // The old segments to carry labels from: every line after the header, each a JSON object.
270
+ let old = null;
271
+ if (keep) {
272
+ let raw;
273
+ try { raw = readFileSync(keep, "utf8"); } catch { console.error(`--keep file not found: ${keep}`); process.exit(2); }
274
+ const rows = raw.split("\n").map((l, i) => ({ n: i + 1, l })).filter((r) => r.l.trim());
275
+ old = [];
276
+ for (const { n, l } of rows) {
277
+ let o;
278
+ try { o = JSON.parse(l); } catch { o = null; }
279
+ if (!o || typeof o !== "object" || Array.isArray(o)) { console.error(`--keep file ${keep} line ${n} is not a JSON object; fix it, or mark the material from scratch`); process.exit(2); }
280
+ if (n === 1) {
281
+ if (str(o.material) && str(o.material) !== id) { console.error(`--keep file ${keep} marks material "${o.material}", not "${id}"`); process.exit(2); }
282
+ continue;
283
+ }
284
+ old.push(o);
285
+ }
286
+ }
287
+
244
288
  const buf = readFileSync(material);
245
289
  const text = buf.toString("utf8");
246
290
  if (!/\S/.test(text)) { console.error(`nothing to mark: ${material} has no text`); process.exit(2); }
247
- const segments = splitSegments(text, { by });
291
+ const fresh = splitSegments(text, { by });
292
+ const kept = old ? carrySegments(fresh, old) : null;
293
+ const segments = kept ? kept.segments : fresh;
248
294
  const header = { material: id, path: material, sha256: sha256(buf) };
249
295
  const lines = [JSON.stringify(header), ...segments.map((s) => JSON.stringify(s))];
250
296
  writeFileSync(out, `${lines.join("\n")}\n`);
251
- console.log(`${segments.length} segments written to ${out}. Label every segment (claim, story, quote, stance, question, aside, private), then run hyperspec lint on the spec.`);
297
+ if (!kept) {
298
+ console.log(`${segments.length} segments written to ${out}. Label every segment (claim, story, quote, stance, question, aside, private), then run hyperspec lint on the spec.`);
299
+ process.exit(0);
300
+ }
301
+ const todo = kept.unlabeled;
302
+ console.log(`${segments.length} segments written to ${out}, ${kept.carried} labels carried from ${keep}, ${todo.length} to label${todo.length ? ":" : "."}`);
303
+ for (const s of todo) {
304
+ const t = s.text.replace(/\s+/g, " ").trim();
305
+ console.log(` ${s.id} ${JSON.stringify(t.length > 60 ? `${t.slice(0, 60)}...` : t)}`);
306
+ }
307
+ console.log(todo.length
308
+ ? "Label each one (claim, story, quote, stance, question, aside, private), then run hyperspec lint on the spec."
309
+ : "Run hyperspec lint on the spec.");
310
+ process.exit(0);
311
+ }
312
+
313
+ if (sub === "label") {
314
+ const [file, ...assignments] = argv.slice(2);
315
+ if (!file || !assignments.length) { console.error("segments label needs a segments file and at least one <ids>=<label>[:key=value] assignment"); process.exit(2); }
316
+ let raw;
317
+ try { raw = readFileSync(file, "utf8"); } catch { console.error(`segments file not found: ${file}`); process.exit(2); }
318
+ const lines = raw.split("\n").filter((l) => l.trim());
319
+ let parsed;
320
+ try { parsed = lines.map((l) => JSON.parse(l)); } catch { console.error(`${file} has a line that is not JSON; fix it before labeling`); process.exit(2); }
321
+ const [header, ...segments] = parsed;
322
+ const done = labelSegments(segments, assignments);
323
+ if (done.error) { console.error(`segments label: ${done.error}; nothing written`); process.exit(2); }
324
+ writeFileSync(file, `${[header, ...done.segments].map((o) => JSON.stringify(o)).join("\n")}\n`);
325
+ const touched = new Set(assignments.flatMap((a) => a.slice(0, a.indexOf("=")).split(",").map((s) => s.trim())));
326
+ const todo = done.segments.filter((s) => s.label === "unlabeled").map((s) => s.id);
327
+ console.log(`${touched.size} segments labeled in ${file}${todo.length ? `; ${todo.length} still unlabeled: ${todo.join(", ")}` : "; every segment is labeled. Run hyperspec lint on the spec."}`);
252
328
  process.exit(0);
253
329
  }
254
330
 
@@ -373,6 +449,32 @@ if (cmd === "lint") {
373
449
  process.exit(worst);
374
450
  }
375
451
 
452
+ if (cmd === "ready") {
453
+ const parsed = parseArgs(argv.slice(1), { valueFlags: ["--draft", "--judges"], boolFlags: ["--json"] });
454
+ if (parsed.error) { console.error(parsed.error); process.exit(2); }
455
+ const [specPath] = parsed.positionals;
456
+ const json = parsed.values["--json"];
457
+ const usage = (error) => {
458
+ if (json) console.log(JSON.stringify({ spec: specPath ?? null, draft: parsed.values["--draft"] ?? null, error }, null, 2));
459
+ else console.error(error);
460
+ process.exit(2);
461
+ };
462
+ if (!specPath) usage("ready needs a spec path");
463
+ let judges;
464
+ if (parsed.values["--judges"] !== undefined) {
465
+ judges = parsed.values["--judges"].split(",").map((s) => s.trim()).filter(Boolean);
466
+ if (!judges.length) usage("--judges names no judge");
467
+ }
468
+ const r = ready(specPath, parsed.values["--draft"], { judges });
469
+ if (r.usage) usage(r.error);
470
+ if (json) console.log(JSON.stringify({ spec: specPath, draft: r.draft, ready: r.ready, judges: r.judges, missing: r.missing }, null, 2));
471
+ else {
472
+ console.log(r.ready ? `ready: checked and judged (${r.judges.join(", ")}) on these exact bytes` : "not ready:");
473
+ for (const m of r.missing) console.log(` - ${m}`);
474
+ }
475
+ process.exit(r.code);
476
+ }
477
+
376
478
  if (cmd === "check") {
377
479
  const parsed = parseArgs(argv.slice(1), { valueFlags: ["--draft", "--only"], boolFlags: ["--json"] });
378
480
  if (parsed.error) { console.error(parsed.error); process.exit(2); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/hyperspec",
3
- "version": "0.9.0",
3
+ "version": "0.10.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/draft.mjs CHANGED
@@ -21,6 +21,20 @@ export function splitLines(text) {
21
21
  export function readDraft(draftPathArg) {
22
22
  let buf;
23
23
  try { buf = readFileSync(resolve(draftPathArg)); } catch { return null; }
24
- const text = buf.toString("utf8").replace(/^/, "");
24
+ const text = hideUnseen(buf.toString("utf8").replace(/^\uFEFF/, ""));
25
25
  return { path: draftPathArg, text, lines: splitLines(text), sha256: sha256(buf) };
26
26
  }
27
+
28
+ // WHAT A READER NEVER SEES IS NOT PROSE (0.10). A leading YAML frontmatter block and every HTML
29
+ // comment are blanked line for line: their characters go and their line breaks stay, so every
30
+ // line number a finding names is still the file's own. A draft that is a kept document (a title,
31
+ // a status, a note to self about what it was meant to be) then grades exactly as its prose does:
32
+ // no hidden word counts toward length, is a term's first use, or needs a claim. Sequence drafts
33
+ // already blanked frontmatter per file (src/sequence-draft.mjs); this is the same rule for every
34
+ // draft. Offsets inside a line can move; evidence is matched by text, never by column.
35
+ export function hideUnseen(text) {
36
+ const blank = (s) => s.replace(/[^\n]/g, "");
37
+ return text
38
+ .replace(/^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/, blank)
39
+ .replace(/<!--[\s\S]*?-->/g, blank);
40
+ }
package/src/ready.mjs ADDED
@@ -0,0 +1,78 @@
1
+ // `hyperspec ready` (0.10): has this draft, under this spec, been through the engine?
2
+ //
3
+ // Answered from the runs ledger alone (improvement.ledger), never by running a station or a judge,
4
+ // so a caller can gate a handoff on it cheaply and deterministically: a drafting loop before it
5
+ // shows a person the draft, a per-audience renderer before a version may be sent. Every rule
6
+ // below is about the BYTES, the same hashes check and judge record write, so a draft or spec
7
+ // edited after it was graded is never ready on the strength of grades it no longer has.
8
+ //
9
+ // 1. the latest FULL check of these draft and spec bytes passed (a partial --only run proves
10
+ // nothing about the stations it skipped);
11
+ // 2. every required judge has a line for these bytes and its latest says pass; the panel needs
12
+ // one per reader, its buyer (the audience's own reader) included;
13
+ // 3. that check is later in the ledger than every one of those judge lines, so the panel's
14
+ // findings went to triage and a check held every answer, instead of being recorded and left.
15
+ //
16
+ // Required judges: the caller's list, or every judgment station that applies to this spec and
17
+ // draft (src/judges/index.mjs skipReason is null).
18
+
19
+ import { readFileSync } from "node:fs";
20
+ import { resolve } from "node:path";
21
+ import { loadWritingSpec } from "./check.mjs";
22
+ import { readDraft } from "./draft.mjs";
23
+ import { readSequenceDraft, sequenceFilesDecl } from "./sequence-draft.mjs";
24
+ import { openLedger } from "./ledger.mjs";
25
+ import { sha256 } from "./hash.mjs";
26
+ import { JUDGES, JUDGE_NAMES } from "./judges/index.mjs";
27
+
28
+ const present = (v) => typeof v === "string" && v.trim().length > 0;
29
+
30
+ export function ready(specPathArg, draftPathArg, { judges } = {}) {
31
+ const loaded = loadWritingSpec(specPathArg, "ready");
32
+ if (loaded.usage) return { usage: true, error: loaded.error };
33
+ const { spec } = loaded;
34
+ const fromSequence = !present(draftPathArg);
35
+ if (fromSequence && !sequenceFilesDecl(spec).length) return { usage: true, error: "ready needs --draft <file>" };
36
+ if (judges) {
37
+ const unknown = judges.filter((j) => !JUDGE_NAMES.includes(j));
38
+ if (unknown.length) return { usage: true, error: `unknown judge${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}; known: ${JUDGE_NAMES.join(", ")}` };
39
+ }
40
+ const draft = fromSequence ? readSequenceDraft(spec, specPathArg) : readDraft(draftPathArg);
41
+ if (!draft) return { usage: true, error: `cannot read draft: ${draftPathArg ?? "the sequence files"}` };
42
+ const specSha = sha256(readFileSync(resolve(specPathArg)));
43
+
44
+ const required = judges ?? JUDGES.filter((j) => j.skipReason(spec, draft) === null).map((j) => j.name);
45
+ const ledger = openLedger(spec);
46
+ const missing = [];
47
+ if (!ledger || ledger.warning) {
48
+ missing.push(ledger?.warning ?? "the spec declares no improvement.ledger, so nothing it was graded on is recorded");
49
+ return { ok: true, ready: false, judges: required, missing, code: 1 };
50
+ }
51
+ const lines = ledger.priorText.split("\n").filter((l) => l.trim())
52
+ .map((l, i) => { try { return { ...JSON.parse(l), _n: i }; } catch { return null; } })
53
+ .filter((l) => l && l.draft_sha256 === draft.sha256 && l.spec_sha256 === specSha);
54
+
55
+ const check = lines.filter((l) => l.kind === "check" && l.partial !== true).at(-1);
56
+ if (!check) missing.push("no full check of this draft under this spec; run hyperspec check");
57
+ else {
58
+ const failing = Object.entries(check.stations ?? {}).filter(([, s]) => s === "fail").map(([n]) => n);
59
+ if (failing.length) missing.push(`the last check failed: ${failing.join(", ")}`);
60
+ }
61
+
62
+ let lastJudge = -1;
63
+ for (const name of required) {
64
+ const judge = JUDGES.find((j) => j.name === name);
65
+ const of = lines.filter((l) => l.kind === "judge" && l.station === name);
66
+ const variants = judge.variants ? judge.variants(spec).map((v) => v.id) : [null];
67
+ for (const v of variants) {
68
+ const label = v ? `${name} (${v})` : name;
69
+ const last = of.filter((l) => (v ? l.reader === v : true)).at(-1);
70
+ if (!last) { missing.push(`${label} has not judged this draft under this spec`); continue; }
71
+ lastJudge = Math.max(lastJudge, last._n);
72
+ if (last.status !== "pass") missing.push(`${label} did not pass: ${last.status}`);
73
+ }
74
+ }
75
+ if (check && lastJudge > check._n) missing.push("a judge recorded after the last check; run hyperspec check again so its findings are held");
76
+
77
+ return { ok: true, ready: missing.length === 0, judges: required, missing, draft: draft.path, code: missing.length ? 1 : 0 };
78
+ }
package/src/segments.mjs CHANGED
@@ -182,6 +182,50 @@ export function splitSegments(text, { by = "paragraph" } = {}) {
182
182
  return spans.map((s, i) => ({ id: `s${i + 1}`, start: s.start, end: s.end, label: "unlabeled", text: text.slice(s.start, s.end) }));
183
183
  }
184
184
 
185
+ // -------------------------------------------------------------------------------------------
186
+ // carrySegments: re-marking an edited material without relabeling what did not change (0.9.1).
187
+ // A label is a fact about a stretch of text, so while the same text (compared trimmed) is still in
188
+ // the material, its label is still true. `fresh` is what splitSegments returned for the material as
189
+ // it reads now; `old` is the segment objects of the file it was marked in before. Each fresh
190
+ // segment whose trimmed text an old segment has takes that old segment's id, label and every other
191
+ // key it carries (own, source, teller, speaker, anything a person added), with start, end and text
192
+ // from the new split. The id is carried too, because the spine cites segments by id: "m1#s3" keeps
193
+ // pointing at the words it pointed at. Old segments with the same text are used in document order,
194
+ // each once. A fresh segment no old one matches stays "unlabeled" and gets the next id no old
195
+ // segment used, "s<n>" counting up past the highest. Returns { segments, carried, unlabeled }:
196
+ // carried counts the segments that took a label other than "unlabeled"; unlabeled lists every
197
+ // segment still to label, in document order.
198
+ export function carrySegments(fresh, old) {
199
+ const byText = new Map();
200
+ for (const o of old) {
201
+ if (!o || typeof o !== "object" || typeof o.text !== "string") continue;
202
+ const k = o.text.trim();
203
+ if (!byText.has(k)) byText.set(k, []);
204
+ byText.get(k).push(o);
205
+ }
206
+ const taken = new Set(old.map((o) => str(o?.id)).filter(Boolean));
207
+ let n = Math.max(0, ...[...taken].map((id) => Number(/^s(\d+)$/.exec(id)?.[1] ?? 0)));
208
+ const nextId = () => {
209
+ do n += 1; while (taken.has(`s${n}`));
210
+ taken.add(`s${n}`);
211
+ return `s${n}`;
212
+ };
213
+ const segments = [];
214
+ let carried = 0;
215
+ for (const seg of fresh) {
216
+ const match = byText.get(seg.text.trim())?.shift();
217
+ if (!match) {
218
+ segments.push({ id: nextId(), start: seg.start, end: seg.end, label: "unlabeled", text: seg.text });
219
+ continue;
220
+ }
221
+ const { id, start, end, label, text, ...extra } = match;
222
+ const kept = { id: str(id) || nextId(), start: seg.start, end: seg.end, label: typeof label === "string" && label ? label : "unlabeled", ...extra, text: seg.text };
223
+ if (kept.label !== "unlabeled") carried += 1;
224
+ segments.push(kept);
225
+ }
226
+ return { segments, carried, unlabeled: segments.filter((s) => s.label === "unlabeled") };
227
+ }
228
+
185
229
  // -------------------------------------------------------------------------------------------
186
230
  // readSegments: parse + validate a marked-up segments file. Never throws on a bad or missing
187
231
  // file; every failure mode becomes a finding in the lint shape (test, id, severity, message, fix),
@@ -405,3 +449,43 @@ export function readSegments(segmentsPath, { materialPath, materialId, displayPa
405
449
 
406
450
  return { header, segments, findings };
407
451
  }
452
+
453
+ // ── labeling (0.10) ──────────────────────────────────────────────────────────────────────────
454
+ // One assignment is "<ids>=<label>[:key=value]...": ids a comma list ("s1,s4"), the label from the
455
+ // closed set, then any per-label fields (speaker=, teller=, source=, own=true). own's "true" is
456
+ // written as the boolean the format reads. Parsed and applied as pure data so a caller (the CLI,
457
+ // a capture stage) can label without hand-editing JSONL, and so a bad assignment is refused
458
+ // before anything is written: { segments } on success, { error } on the first problem.
459
+ export function parseLabelAssignment(raw) {
460
+ const eq = String(raw).indexOf("=");
461
+ if (eq < 1) return { error: `"${raw}" is not <ids>=<label>[:key=value]...` };
462
+ const ids = raw.slice(0, eq).split(",").map((s) => s.trim()).filter(Boolean);
463
+ const [label, ...pairs] = raw.slice(eq + 1).split(":");
464
+ if (!MATERIAL_LABELS.includes(label)) return { error: `"${label}" is not a label; use one of ${MATERIAL_LABELS.join(", ")}` };
465
+ const fields = {};
466
+ for (const p of pairs) {
467
+ const k = p.indexOf("=");
468
+ if (k < 1) return { error: `"${p}" in "${raw}" is not key=value` };
469
+ const key = p.slice(0, k).trim();
470
+ if (["id", "start", "end", "text", "label"].includes(key)) return { error: `"${key}" is set by marking, not by a label assignment` };
471
+ const value = p.slice(k + 1);
472
+ fields[key] = key === "own" && value === "true" ? true : value;
473
+ }
474
+ return { ids, label, fields };
475
+ }
476
+
477
+ export function labelSegments(segments, assignments) {
478
+ const out = segments.map((s) => ({ ...s }));
479
+ const byId = new Map(out.map((s) => [s.id, s]));
480
+ for (const raw of assignments) {
481
+ const a = parseLabelAssignment(raw);
482
+ if (a.error) return { error: a.error };
483
+ for (const id of a.ids) {
484
+ const s = byId.get(id);
485
+ if (!s) return { error: `no segment "${id}" in this file` };
486
+ s.label = a.label;
487
+ Object.assign(s, a.fields);
488
+ }
489
+ }
490
+ return { segments: out };
491
+ }
@@ -7,7 +7,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
7
7
  import { dirname, join, posix, resolve } from "node:path";
8
8
  import { sha256 } from "./hash.mjs";
9
9
  import { str } from "./placeholder.mjs";
10
- import { splitLines } from "./draft.mjs";
10
+ import { splitLines, hideUnseen } from "./draft.mjs";
11
11
 
12
12
  const list = (v) => (Array.isArray(v) ? v : []);
13
13
  const byNumber = (a, b) => a.localeCompare(b, "en", { numeric: true });
@@ -44,8 +44,8 @@ export function sequenceFiles(specDir, entries) {
44
44
  // { path, text, lines, sha256, sources }, the same shape src/draft.mjs's readDraft returns plus
45
45
  // sources, one per file: { file, at, startLine, lineCount }. file is the path relative to the spec
46
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
47
+ // links resolving a relative link beside the file that holds it). Each file's YAML frontmatter and HTML comments are
48
+ // blanked line for line (src/draft.mjs's hideUnseen), so its metadata is not prose and its line numbers stay its own. sha256
49
49
  // covers every file's name and bytes. path is the first file's. null when no file matches.
50
50
  export function readSequenceDraft(spec, specPathArg) {
51
51
  const files = sequenceFiles(spec.dir, sequenceFilesDecl(spec));
@@ -58,7 +58,7 @@ export function readSequenceDraft(spec, specPathArg) {
58
58
  const buf = readFileSync(resolve(spec.dir, file));
59
59
  hashed.push(Buffer.from(`${file}\n`), buf);
60
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, ""));
61
+ text = hideUnseen(text);
62
62
  if (!text.endsWith("\n")) text += "\n";
63
63
  const lineCount = text.split("\n").length - 1;
64
64
  sources.push({ file, at: join(dirname(specPathArg), file), startLine, lineCount });
@@ -0,0 +1,85 @@
1
+ // specText(data, body) (0.10): a hyperspec file's text from data, for a tool that BUILDS specs
2
+ // rather than a person typing one: a drafting loop filling a skeleton's mechanical blocks, a
3
+ // renderer writing one spec per audience. Writes the block-style YAML subset hyperspec reads
4
+ // (maps, lists of scalars, lists of maps, scalars), quoting every scalar the way init does
5
+ // (template.mjs's scalar), then READS IT BACK with the same reader lint uses and throws, naming
6
+ // the field, if any value came back different. A lossy spec is never handed over: hand-written
7
+ // YAML once lost everything after " #" and after a leading quoted phrase while lint passed
8
+ // (letter BUILD-NOTES item 1).
9
+ //
10
+ // Every scalar reads back as a string (the reader's own rule), so numbers and booleans are
11
+ // written plain and compared as text. null and nested lists have no form here and are refused.
12
+ import { parseSkillFile } from "@supersuit/superskill/yaml";
13
+ import { scalar } from "./template.mjs";
14
+
15
+ const isMap = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
16
+
17
+ function emitScalar(v, at) {
18
+ if (typeof v === "string") return scalar(v);
19
+ if (typeof v === "number" && Number.isFinite(v)) return String(v);
20
+ if (typeof v === "boolean") return String(v);
21
+ throw new Error(`specText: ${at} is ${v === null ? "null" : typeof v}, which a hyperspec cannot hold; write a string or leave the key out`);
22
+ }
23
+
24
+ function emitMap(obj, indent, at) {
25
+ const pad = " ".repeat(indent);
26
+ const out = [];
27
+ for (const [k, v] of Object.entries(obj)) {
28
+ if (v === undefined) continue;
29
+ const here = at ? `${at}.${k}` : k;
30
+ if (Array.isArray(v)) {
31
+ if (!v.length) { out.push(`${pad}${k}: []`); continue; }
32
+ out.push(`${pad}${k}:`);
33
+ v.forEach((item, i) => out.push(...emitItem(item, indent + 2, `${here}[${i}]`)));
34
+ } else if (isMap(v)) {
35
+ if (!Object.keys(v).length) throw new Error(`specText: ${here} is an empty map, which reads back as nothing; leave the key out`);
36
+ out.push(`${pad}${k}:`, ...emitMap(v, indent + 2, here));
37
+ } else out.push(`${pad}${k}: ${emitScalar(v, here)}`);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ function emitItem(item, indent, at) {
43
+ const pad = " ".repeat(indent);
44
+ if (Array.isArray(item)) throw new Error(`specText: ${at} is a list inside a list, which a hyperspec cannot hold`);
45
+ if (!isMap(item)) return [`${pad}- ${emitScalar(item, at)}`];
46
+ const lines = emitMap(item, indent + 2, at);
47
+ if (!lines.length) throw new Error(`specText: ${at} is an empty map`);
48
+ return [`${pad}- ${lines[0].slice(indent + 2)}`, ...lines.slice(1)];
49
+ }
50
+
51
+ // Scalars compared as the reader returns them: text.
52
+ const norm = (v) => (Array.isArray(v) ? v.map(norm) : isMap(v)
53
+ ? Object.fromEntries(Object.entries(v).filter(([, x]) => x !== undefined).map(([k, x]) => [k, norm(x)]))
54
+ : String(v));
55
+
56
+ function firstDiff(a, b, at = "") {
57
+ if (Array.isArray(a) || Array.isArray(b)) {
58
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return at || "(root)";
59
+ for (let i = 0; i < a.length; i++) { const d = firstDiff(a[i], b[i], `${at}[${i}]`); if (d) return d; }
60
+ return null;
61
+ }
62
+ if (isMap(a) || isMap(b)) {
63
+ if (!isMap(a) || !isMap(b)) return at || "(root)";
64
+ for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) { const d = firstDiff(a[k], b[k], at ? `${at}.${k}` : k); if (d) return d; }
65
+ return null;
66
+ }
67
+ return a === b ? null : at;
68
+ }
69
+
70
+ export function specText(data, body = "") {
71
+ if (!isMap(data)) throw new Error("specText: data must be an object");
72
+ const text = `---\n${emitMap(data, 0, "").join("\n")}\n---\n${body}`;
73
+ const { data: readBack, error } = parseSkillFile(text);
74
+ if (error) throw new Error(`specText: the reader refused the result: ${error}`);
75
+ const diff = firstDiff(norm(data), readBack);
76
+ if (diff) throw new Error(`specText: ${diff} does not read back as written; nothing returned`);
77
+ return text;
78
+ }
79
+
80
+ // The reading half: a spec's text as lint reads it, { data, body, error }. With specText, a tool
81
+ // can read a skeleton (`hyperspec init --profile writing`), fill the blocks it knows, and write
82
+ // it back without a second YAML reader that disagrees with the linter.
83
+ export function parseSpecText(text) {
84
+ return parseSkillFile(String(text ?? ""));
85
+ }
@@ -27,7 +27,11 @@
27
27
  // way used, so the rules and the question shape are that checker's.
28
28
  //
29
29
  // A use is a whole-word, case-insensitive match, where a hyphen is part of a word: "context-aware"
30
- // does not use "context", and "skills" does not use "skill". The station reads the outline from
30
+ // does not use "context", and "skills" does not use "skill". A word inside a longer defined term is
31
+ // not a use of the shorter one (hyperspec 0.9.1): with "level" and "thinking level" both defined,
32
+ // "thinking level" and "thinking levels" use only "thinking level", and "level" on its own still
33
+ // uses "level". Every guard that looks for a use (order, quiz order, quiz coverage) masks the
34
+ // longer terms first. The station reads the outline from
31
35
  // disk, resolved against the spec's folder, the way claims reads its ledger. It never reads a
32
36
  // part's text for meaning: a term used in a sense other than its definition still counts as a use.
33
37
 
@@ -202,6 +206,25 @@ function firstUse(unit, prose, term) {
202
206
  // Whether `text` (code already masked) uses `term`, by the same whole-word rule as firstUse.
203
207
  const usesTerm = (text, term) => new RegExp(`(^|[^a-z0-9-])${escapeRe(term)}([^a-z0-9-]|$)`, "i").test(text);
204
208
 
209
+ // For each defined term, the other defined terms that contain it as a whole-word phrase, longest
210
+ // first: { level: ["thinking level"] }. A term no other term contains maps to [].
211
+ export function longerTerms(terms) {
212
+ const all = [...terms];
213
+ return new Map(all.map((t) => [t, all.filter((u) => u !== t && usesTerm(u, t)).sort((a, b) => b.length - a.length)]));
214
+ }
215
+
216
+ // `text` with every whole-word occurrence of each `longer` term, a trailing plural "s" included,
217
+ // blanked to spaces. Line breaks survive, so a line number still maps to the same line, and a term
218
+ // broken across two lines is blanked on both.
219
+ export function maskLonger(text, longer) {
220
+ let out = text;
221
+ for (const t of longer) {
222
+ const re = new RegExp(`(?<![a-z0-9-])${escapeRe(t).replace(/ /g, "\\s+")}s?(?![a-z0-9-])`, "gi");
223
+ out = out.replace(re, (m) => m.replace(/[^\n]/g, " "));
224
+ }
225
+ return out;
226
+ }
227
+
205
228
  // Every quiz in the draft, read the way the first book written to this shape wrote it:
206
229
  //
207
230
  // ## Check yourself: Part 1
@@ -308,12 +331,17 @@ export function run(spec, draft) {
308
331
  }
309
332
 
310
333
  // order: no unit before the defining one uses the term.
334
+ // A longer defined term that contains the term is masked first: "thinking level" is not a use of
335
+ // "level".
311
336
  const prose = new Map(units.map((u) => [u, proseOf(u, seq, { withTerms: false })]));
337
+ const longer = longerTerms(definedAt.keys());
312
338
  for (const [t, at] of definedAt) {
313
339
  if (seq.knows.has(t)) continue;
340
+ const sups = longer.get(t);
314
341
  for (const u of units) {
315
342
  if (u === at) break;
316
- const line = firstUse(u, prose.get(u), t);
343
+ const lines = sups.length ? maskLonger(prose.get(u).join("\n"), sups).split("\n") : prose.get(u);
344
+ const line = firstUse(u, lines, t);
317
345
  if (line) findings.push(finding({ id: "station-sequence-used-before-defined", severity: "fail", message: `${U} ${u.n} uses "${t}" before ${U} ${at.n} defines it`, fix: `Rewrite ${U} ${u.n} without "${t}", define it earlier, or add it to writing.form.sequence.knows if the reader already has the word.`, line: line }));
318
346
  }
319
347
  }
@@ -362,6 +390,12 @@ function quizFindings(draft, seq, definedAt) {
362
390
  const U = seq.unit;
363
391
  const out = [];
364
392
  const { quizzes, questions, untagged } = parseQuizzes(draft, seq);
393
+ // Every question's text with each term's longer defined terms masked, as the order guard reads
394
+ // prose: "thinking level" in a question neither uses nor tests "level".
395
+ const longer = longerTerms(definedAt.keys());
396
+ const masked = (q, t) => (longer.get(t).length ? maskLonger(q.text, longer.get(t)) : q.text);
397
+ const uses = (q, t) => usesTerm(masked(q, t), t);
398
+ const tests = (q, t) => { const text = masked(q, t); return usesTerm(text, t) || usesTerm(text, `${t}s`); };
365
399
  if (!quizzes) {
366
400
  return [finding({ id: "station-sequence-quiz-missing", severity: "fail", message: `no "${seq.quiz}" heading in the draft, and writing.form.sequence.quiz names one`, fix: `Add a "## ${seq.quiz}" section of questions, or delete quiz: from the spec.` })];
367
401
  }
@@ -375,12 +409,12 @@ function quizFindings(draft, seq, definedAt) {
375
409
  }
376
410
  for (const [t, at] of definedAt) {
377
411
  if (seq.knows.has(t) || at.n <= q.unit) continue;
378
- if (usesTerm(q.text, t)) out.push(finding({ id: "station-sequence-quiz-used-before-defined", severity: "fail", message: `question ${q.n}, tagged ${U} ${q.unit}, uses "${t}", which ${U} ${at.n} defines later`, fix: `Rewrite the question without "${t}", or tag it with ${U} ${at.n} or later.`, line: q.line }));
412
+ if (uses(q, t)) out.push(finding({ id: "station-sequence-quiz-used-before-defined", severity: "fail", message: `question ${q.n}, tagged ${U} ${q.unit}, uses "${t}", which ${U} ${at.n} defines later`, fix: `Rewrite the question without "${t}", or tag it with ${U} ${at.n} or later.`, line: q.line }));
379
413
  }
380
414
  }
381
415
  // coverage: a simple plural counts, so "skills" tests "skill".
382
416
  for (const [t, at] of definedAt) {
383
- if (!questions.some((q) => usesTerm(q.text, t) || usesTerm(q.text, `${t}s`))) {
417
+ if (!questions.some((q) => tests(q, t))) {
384
418
  out.push(finding({ id: "station-sequence-quiz-untested", severity: "fail", message: `no quiz question tests "${t}", which ${U} ${at.n} defines`, fix: `Add a question that uses "${t}", tagged ${U} ${at.n} or later.`, line: at.line }));
385
419
  }
386
420
  }
@@ -9,3 +9,6 @@
9
9
  export { MATERIAL_LABELS } from "./labels.mjs";
10
10
  export { readSegments } from "./segments.mjs";
11
11
  export { readScope, measureFeatures } from "./dna.mjs";
12
+ // specText writes a spec's text from data and refuses anything the reader would not return
13
+ // unchanged, for a tool that builds specs (0.10).
14
+ export { specText, parseSpecText } from "./spec-text.mjs";