@supersuit/hyperspec 0.9.1 → 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,35 @@
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
+
3
33
  ## 0.9.1 (2026-09-30)
4
34
 
5
35
  Two fixes, each found by the first book written as a sequence.
package/README.md CHANGED
@@ -26,9 +26,11 @@ improvement ledger. Every test is defined in [SPEC.md](SPEC.md).
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
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.1** (2026-09-30)
112
+ **Version 0.10.0** (2026-10-02)
113
113
 
114
114
  ## What makes a spec a hyperspec
115
115
 
package/WRITING.md CHANGED
@@ -408,7 +408,10 @@ exist or a `--by` it does not know, a material with nothing in it, and an `--out
408
408
  not exist.
409
409
 
410
410
  Then name the file on the material item, as `segments:` beside `path:`, and label every segment.
411
- 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
412
415
  the rules under [Coverage](#coverage) still hold.
413
416
 
414
417
  ### The segments file
@@ -844,6 +847,10 @@ against the draft:
844
847
  npx @supersuit/hyperspec check essay.hyperspec.md --draft essay/draft.md
845
848
  ```
846
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
+
847
854
  It lints the spec first. A spec that fails lint, or is blocked on an open decision, runs no
848
855
  station and exits with lint's own code, because a draft cannot be checked against a spec that is
849
856
  not ready. Then it runs nine stations in a fixed order and prints one line for each: `pass`,
@@ -1867,6 +1874,27 @@ cannot be read. `--json` prints the whole result.
1867
1874
  | `triage-import-quote-not-found` | warn | an imported finding quotes text, and none of it is in the draft |
1868
1875
  | `triage-import-empty` | invalid | the review holds no finding to answer |
1869
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
+
1870
1898
  ## Learning from edits
1871
1899
 
1872
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
@@ -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, carrySegments } 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
@@ -141,6 +150,13 @@ const HELP = `hyperspec <command> [options]
141
150
  cannot be read, has a line that is not a JSON object, or marks
142
151
  another material
143
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
159
+
144
160
  dna init <scope-dir> --writer W --form F --audience A --purpose P
145
161
  write a new writer-DNA scope: scope.md (writer, form, audience,
146
162
  purpose) and an empty goldens/ folder holding a README on the
@@ -294,6 +310,24 @@ if (cmd === "segments") {
294
310
  process.exit(0);
295
311
  }
296
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."}`);
328
+ process.exit(0);
329
+ }
330
+
297
331
  console.error(`unknown segments subcommand: ${sub}\n\n${HELP}`);
298
332
  process.exit(2);
299
333
  }
@@ -415,6 +449,32 @@ if (cmd === "lint") {
415
449
  process.exit(worst);
416
450
  }
417
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
+
418
478
  if (cmd === "check") {
419
479
  const parsed = parseArgs(argv.slice(1), { valueFlags: ["--draft", "--only"], boolFlags: ["--json"] });
420
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.1",
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
@@ -449,3 +449,43 @@ export function readSegments(segmentsPath, { materialPath, materialId, displayPa
449
449
 
450
450
  return { header, segments, findings };
451
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
+ }
@@ -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";