@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 +30 -0
- package/README.md +3 -1
- package/SPEC.md +1 -1
- package/WRITING.md +29 -1
- package/bin/hyperspec.mjs +61 -1
- package/package.json +1 -1
- package/src/draft.mjs +15 -1
- package/src/ready.mjs +78 -0
- package/src/segments.mjs +40 -0
- package/src/sequence-draft.mjs +4 -4
- package/src/spec-text.mjs +85 -0
- package/src/writing-exports.mjs +3 -0
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.
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/sequence-draft.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/src/writing-exports.mjs
CHANGED
|
@@ -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";
|