@supersuit/hyperspec 0.5.0 → 0.6.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,80 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 (2026-09-29)
4
+
5
+ A spec can now check a draft. Until this release hyperspec could tell you whether a writing spec
6
+ was ready; it could not tell you whether the piece written from it met the spec. `hyperspec
7
+ check` runs seven deterministic stations against a draft: the length and required parts, the
8
+ terms the reader needs defined, the claims ledger, quotations, private material, the writer's
9
+ measured style, and links. None of them calls a model or touches the network, so the same draft
10
+ and spec always give the same answer. Each run leaves one line in the spec's runs ledger saying
11
+ whether the draft passed first time, improved, or did not, and why.
12
+
13
+ **No behavior change for lint.** Every spec without `writing.audience.terms` passes and fails
14
+ exactly as it did in 0.5.0, and no lint finding id changed. That field is the one schema
15
+ addition, and it is optional.
16
+
17
+ - `hyperspec check <spec> --draft <file> [--json] [--only a,b]` needs a writing spec
18
+ (`profile: writing`) and lints it first: a spec that fails lint, or is blocked on an open
19
+ decision, runs no station and exits with lint's code. Then it runs every station in a fixed
20
+ order, `form, terms, claims, quotes, private, dna, links`, and prints each one's `pass`, `fail`
21
+ with findings, or `skip` with the reason. Warnings print under their station and never fail it.
22
+ Exit 0 when every station that ran passed, 1 when one failed, 2 on usage (no spec, no
23
+ `--draft`, an unreadable draft, a spec without the writing profile, an `--only` that names no
24
+ known station); under `--json` a usage error is one document, `{ spec, draft, error }`.
25
+ `--only` runs a subset, still in the fixed order. A finding names the draft line it points at
26
+ where there is one, quotes at most 80 characters of the draft, and never prints an absolute
27
+ path. A byte order mark at the start of the draft is ignored. A station that throws becomes one
28
+ failing finding, `station-<name>-crashed`, and the rest still run.
29
+ - `form`: word count against `writing.form.length` (only `unit: words` is measured; another unit
30
+ skips the station), and every `required_parts` entry present as an ATX heading of that text
31
+ (indented up to three spaces, closing `#`s allowed), or as a line starting `part:`, outside
32
+ code blocks.
33
+ - `terms`: every term in the new optional `writing.audience.terms`, other than those in `knows`,
34
+ is defined at its first appearance: in that sentence or the next, the term followed within six
35
+ words by `is`, `means`, `refers to` or a colon, or by a parenthesis. A mechanical proxy for a
36
+ definition, and documented as one. No `terms` list: skip.
37
+ - `claims`: every claim in the JSONL ledger at `writing.sources.ledger` (`text`, `source`,
38
+ optional `span`) still appears in the draft word for word, and has a real source; unsourced
39
+ claims warn instead under `unsourced_claim: warn`, and point at the draft line where the claim
40
+ appears. A missing ledger fails. The ledger is the list of claims: the station does not decide
41
+ what counts as one.
42
+ - `quotes`: every double-quoted span of four words or more appears word for word in a `quote` or
43
+ `story` segment of a marked material, never a private one; when the sentence names a quote
44
+ segment's speaker, by the full name or by its first word (when that word has two or more
45
+ letters and is not a common function word such as "the"), the span must come from that
46
+ speaker. A spec with `fiction: true` skips the station, since a character's dialogue is
47
+ invented rather than quoted.
48
+ - `private`: no run of eight words from a `private` segment appears in the draft. Segments of four
49
+ to seven words are checked whole; shorter ones are counted in one warning and never quoted.
50
+ - `dna`: with a current `writing.dna.scope_dir`, the draft is measured the way goldens are and
51
+ each feature compared with the scope's, from v ÷ 1.5 to the larger of v × 1.5 and v + 5; drift,
52
+ and an em dash where the scope has none (pointing at the first one), are warnings. No scope
53
+ folder: skip.
54
+ - `links`: inline, reference and bare links are well-formed http, https or mailto (a bare
55
+ `https://` with no host fails); relative links resolve to a file beside the draft; a `/` link
56
+ warns, since there is no site root to resolve it against; a full or collapsed reference needs
57
+ its definition. A bare `[label]` is a link only when its label is defined, so `[sic]`, `[x]` and
58
+ `[1]` are text. No network access.
59
+ - The runs ledger: each check appends `{ at, kind: "check", draft, draft_sha256, spec_sha256,
60
+ stations, verdict }` to `improvement.ledger`, with `draft` relative to the spec's folder. A run
61
+ with `--only` adds `partial: true`, is `not-improved` with the reason `partial run: <stations>`,
62
+ and is ignored by later verdicts. A full run is compared with the last full line for the same
63
+ draft: `one-shot` when there is none and every station passes; `improved` when that line failed
64
+ and every station passes now, with `change` naming exactly the stations that failed then and
65
+ pass now; otherwise `not-improved`, with a `reason` that says which case it is, such as "no
66
+ change since the last passing check" or "spec changed; failing stations: terms". The lines keep
67
+ lint's test 9 passing.
68
+ - New optional field `writing.audience.terms`: a list of real strings when present (test 1,
69
+ `writing-audience-terms`, naming a scalar that is not a list, an empty list, and each entry that
70
+ is not a string, is empty or is a placeholder). Absent, nothing changes.
71
+ - The worked examples each ship a draft that passes every station, with its claims ledger:
72
+ `examples/writing/essay/draft.md` and `examples/writing/story/draft.md`. Both specs now list
73
+ `audience.terms`, and their `required_parts` are the drafts' headings. The essay's interview
74
+ quotes now name their speaker `dana, an engineering manager`, and the essay links the survey
75
+ summary it cites. A test runs `check` on both. WRITING.md gains "Checking a draft": each station, what it cannot check, a findings table
76
+ per station held by a test to the ids the stations raise, and the ledger line.
77
+
3
78
  ## 0.5.0 (2026-09-29)
4
79
 
5
80
  Scoped writer DNA. A writer does not have one voice: the same person writes differently for a
package/README.md CHANGED
@@ -28,6 +28,7 @@ improvement ledger. Every test is defined in [SPEC.md](SPEC.md).
28
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. |
29
29
  | `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
30
  | `hyperspec dna measure <scope-dir>` | Check every golden in a scope and write its measured features. |
31
+ | `hyperspec check <spec> --draft <file> [--only a,b]` | Run a writing spec's deterministic stations against a draft. |
31
32
  | `hyperspec recipe check <output-or-recipe>` | Check that a recipe records everything the standard asks for. |
32
33
  | `hyperspec recipe approve <recipe> --by <slug>` | Record who approved the output. |
33
34
  | `hyperspec reproduce <recipe> [--restore]` | Re-check every hash the recipe recorded. Never runs a model. |
@@ -42,6 +43,10 @@ Every command except `init`, `segments init` and `dna init` takes `--json`. `hyp
42
43
  test fails, 3 when every test passes but a decision is still open (blocked), and 2 on a usage
43
44
  error or a file that cannot be read, has broken frontmatter, or is not a hyperspec.
44
45
 
46
+ `hyperspec check` exits 0 when every station it ran passed, 1 when one failed, and 2 on a usage
47
+ error, a draft that cannot be read, or a spec without `profile: writing`. A spec that is not ready to check against exits with lint's
48
+ own code, 1 or 3, and no station runs.
49
+
45
50
  The recipe commands use the same numbers: 0 ok, 1 a check failed or the child regressed, 2
46
51
  usage or unreadable input, 3 pending, when `regenerate` has stages waiting for a runner.
47
52
  `regenerate` also exits 1 when a stage it ran reported a failing verdict, or none. A stage it
@@ -132,7 +137,7 @@ The skeleton fails until every placeholder is real and its four open questions (
132
137
  who speaks, who reads, what changes) are answered. The blocks, every field, and which test
133
138
  each rule reports under are in [WRITING.md](WRITING.md). Two complete specs that pass with
134
139
  nothing to warn ship in `examples/writing/`: an essay for new managers, and a short story with
135
- two characters whose voices a judge can tell apart.
140
+ two characters whose voices a judge can tell apart. Each comes with a draft written to it.
136
141
 
137
142
  ### Marking materials
138
143
 
@@ -176,6 +181,25 @@ it did in 0.4. The folder shape, every feature, and every finding are in
176
181
  [WRITING.md](WRITING.md#scoped-dna); `readScope` and `measureFeatures` are exported from
177
182
  `@supersuit/hyperspec/writing`.
178
183
 
184
+ ### Checking a draft
185
+
186
+ Once a draft exists, `check` holds it to its spec with seven stations, none of which calls a
187
+ model or touches the network: `form` (length and required parts), `terms` (every word in the new
188
+ optional `writing.audience.terms` is defined where it first appears), `claims` (the claims
189
+ ledger still matches the draft, and every claim has a source), `quotes` (in nonfiction, every quotation of four
190
+ words or more is word for word in a marked quote), `private` (no run of eight words from a
191
+ private segment), `dna` (the draft's measured style beside its scope's, as warnings) and `links`
192
+ (well-formed, and relative links resolve).
193
+
194
+ ```bash
195
+ npx @supersuit/hyperspec check essay.hyperspec.md --draft essay/draft.md
196
+ ```
197
+
198
+ It lints the spec first, prints each station's pass, fail or skip, and appends one line to the
199
+ spec's runs ledger with a verdict. Both examples ship a draft that passes. What each station
200
+ checks and cannot check, and every finding, are in
201
+ [WRITING.md](WRITING.md#checking-a-draft).
202
+
179
203
  ## The format
180
204
 
181
205
  A hyperspec is a markdown file with a YAML frontmatter block: `decisions`, `requirements`,
package/SPEC.md CHANGED
@@ -97,7 +97,7 @@ examples:
97
97
  - path: examples/minimal.hyperspec.md
98
98
  why: the smallest spec that passes all nine tests
99
99
  resume:
100
- next_action: collect adopter issues on 0.5, scoped DNA included, and cut 0.6 from them
100
+ next_action: collect adopter issues on 0.6, the check command included, and cut 0.7 from them
101
101
  feedback:
102
102
  issues: https://github.com/SupersuitUp/hyperspec/issues
103
103
  fork: MIT; fork it for your own purposes and say so in your SPEC
@@ -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.5.0** (2026-09-29)
112
+ **Version 0.6.0** (2026-09-29)
113
113
 
114
114
  ## What makes a spec a hyperspec
115
115
 
@@ -220,7 +220,7 @@ Each row lists every condition under which `hyperspec lint` fails that test. A w
220
220
  | 8 its adopters can push back on it | `feedback.issues` or `feedback.fork` missing |
221
221
  | 9 it improves itself | `improvement.ledger` missing; a ledger path that exists and is not a readable file; if the ledger file exists, a line that is not a JSON object, a `verdict` outside one-shot, improved or not-improved, `improved` without `change`, `not-improved` without `reason`. A declared ledger that does not exist yet is a warning |
222
222
 
223
- A profile adds its own conditions to these rows. The writing profile's are in [WRITING.md](WRITING.md#the-test-mapping), including the checks on each material's segments file: every material marked, every segment labeled from the closed set and matching its material word for word, the marking current (tests 1 and 4), and no spine claim citing a private or question segment (test 5). A writing spec that names a writer-DNA scope folder with `writing.dna.scope_dir` is also checked against it: the scope matches the spec (test 1), every golden has a person's approval and a source (test 4), no golden comes from outside the scope (test 5), and every golden has its why and the scope's measured features are current (test 6). `scope_dir` is optional, and without it nothing changes.
223
+ A profile adds its own conditions to these rows. The writing profile's are in [WRITING.md](WRITING.md#the-test-mapping), including the checks on each material's segments file: every material marked, every segment labeled from the closed set and matching its material word for word, the marking current (tests 1 and 4), and no spine claim citing a private or question segment (test 5). A writing spec that names a writer-DNA scope folder with `writing.dna.scope_dir` is also checked against it: the scope matches the spec (test 1), every golden has a person's approval and a source (test 4), no golden comes from outside the scope (test 5), and every golden has its why and the scope's measured features are current (test 6). `scope_dir` is optional, and without it nothing changes. The writing profile also has an optional `writing.audience.terms`, the words a piece uses that its reader may not know: when present it must list real terms (test 1), and `hyperspec check` reads it to require each term's definition where the draft first uses it. Without it nothing changes.
224
224
 
225
225
  ## Exit codes
226
226
 
@@ -236,7 +236,7 @@ A profile adds its own conditions to these rows. The writing profile's are in [W
236
236
  Every run of a skill that works from a hyperspec writes one line to the ledger named in `improvement.ledger`, one JSON object per line, with a `verdict`:
237
237
 
238
238
  - **one-shot**: no intervention, nothing to learn.
239
- - **improved**: the skill, the spec template, or a component library changed, and the line carries `change`, naming what changed.
239
+ - **improved**: the skill, the spec template, or a component library changed, and the line carries `change`, naming what changed. On a `kind: "check"` line, which `hyperspec check` writes, it means the draft now passes every station after the last full check of it failed, and `change` names those stations.
240
240
  - **not-improved**: nothing changed, and the line carries `reason`, a reason a later session can argue with, such as "the correction was about this piece only" or "the fix belongs to a shipped skill and was filed as an issue".
241
241
 
242
242
  Silence is not a verdict. A run that learned nothing has to say so and why, and a ledger line with none of the three verdicts fails the ninth test.
package/WRITING.md CHANGED
@@ -209,6 +209,8 @@ writing:
209
209
  wants: a plan for the first meeting
210
210
  reads_on: a phone, in the ten minutes before the meeting
211
211
  reader: person # person | agent
212
+ terms: # optional: terms the piece uses that the reader may not know
213
+ - skip-level
212
214
  check:
213
215
  station: term check against knows
214
216
  rubric: simulated reader reports where it got lost
@@ -317,6 +319,17 @@ cannot pass a presence check. A placeholder is a whole value, trimmed and in any
317
319
  marks, or an ellipsis, optionally followed by a trailing `.`, `:` or `!`. Real text that starts
318
320
  with one of those, such as `TODO: write the opening`, counts as present, and so does `none`.
319
321
 
322
+ `audience.terms` is checked by the `terms` station in `hyperspec check`, and that check is a
323
+ **mechanical proxy, not an understanding of meaning**: it looks for a definition-SHAPED phrase
324
+ near the term's first appearance (the word `is`, `means`, `refers to`, a colon within a few words,
325
+ or an immediate parenthetical), not for whether that phrase defines the term. A sentence
326
+ like "A hyperspec is mentioned here" reads as a definition of "hyperspec" by this rule, because
327
+ `is` immediately follows the word, even though nothing about the term is explained. This is
328
+ deliberate and known, not a bug to fix later in this station: reading for meaning is a judgment
329
+ call, and hyperspec's deterministic stations do not make judgment calls. A later release adds a
330
+ simulated-reader station that reads for meaning instead of shape; `terms` stays the fast,
331
+ mechanical first pass.
332
+
320
333
  ## The test mapping
321
334
 
322
335
  Each row lists what the writing profile adds to that test. The core conditions in
@@ -324,7 +337,7 @@ Each row lists what the writing profile adds to that test. The core conditions i
324
337
 
325
338
  | Test | A writing spec fails it when |
326
339
  |---|---|
327
- | 1 every decision is accounted for | a required block is missing and not deferred; a required field is missing; a closed-set value is outside its set (`trust`, `reader`, `change.kind`, the shape of `identity`, `unsourced_claim`); `identity: character:<id>` names a character that is not in `writing.characters`; `fiction` is present and is anything other than `true` or `false`; two materials, two spine claims or two characters share an id; `form.length.min` or `max` is not a whole number of at least 1, or `min` is greater than `max`; `spine.claims` has fewer than three or more than seven distinct claims; a character has no knowledge entry, or an entry lacks `by` or `knows`; a material has no text, or no `segments` field, or its segments file is missing, malformed, labels a segment outside the seven (`unlabeled` included), repeats a segment id, or has segments that overlap or leave text uncovered; `dna.scope_dir` is present and is a placeholder; with `dna.scope_dir`, its `scope.md` is missing, unreadable or lacks a field, its writer, form, audience or purpose differs from the spec's, or its `goldens/` folder is missing or empty, or holds a golden that cannot be read, whose frontmatter never closes, or that has no passage. A `stance` outside the four is a warning, and so is `unsourced_claim: warn` |
340
+ | 1 every decision is accounted for | a required block is missing and not deferred; a required field is missing; a closed-set value is outside its set (`trust`, `reader`, `change.kind`, the shape of `identity`, `unsourced_claim`); `identity: character:<id>` names a character that is not in `writing.characters`; `fiction` is present and is anything other than `true` or `false`; two materials, two spine claims or two characters share an id; `form.length.min` or `max` is not a whole number of at least 1, or `min` is greater than `max`; `spine.claims` has fewer than three or more than seven distinct claims; a character has no knowledge entry, or an entry lacks `by` or `knows`; a material has no text, or no `segments` field, or its segments file is missing, malformed, labels a segment outside the seven (`unlabeled` included), repeats a segment id, or has segments that overlap or leave text uncovered; `dna.scope_dir` is present and is a placeholder; with `dna.scope_dir`, its `scope.md` is missing, unreadable or lacks a field, its writer, form, audience or purpose differs from the spec's, or its `goldens/` folder is missing or empty, or holds a golden that cannot be read, whose frontmatter never closes, or that has no passage; `audience.terms`, when present, holds a non-string entry or has no real entries at all. A `stance` outside the four is a warning, and so is `unsourced_claim: warn` |
328
341
  | 2 every requirement can fail | `goal.conditions` lists fewer than five or more than ten distinct ids, lists an id twice, or names an id that is not a top-level requirement |
329
342
  | 3 every requirement names its check | a block or a character has no `check` with a `station` or a `rubric` |
330
343
  | 4 every field says where it came from and who wrote it | a block or a character has no `source` or no `author`; a spine claim names no materials, or names a material id that is not in `materials.items`, or a segment that is not in that material's segments file; a segment's text does not match its material word for word; a material changed after it was marked; a claim segment has no `source` and no `own`, a story no `teller`, a quote no `speaker`; with `dna.scope_dir`, a golden in the scope has no `approved_by`, an approver that starts `agent:`, or no `source` |
@@ -786,6 +799,216 @@ source, and a `features.json` that `dna measure` wrote. A test measures the fold
786
799
  release and requires the same bytes, so the example cannot drift from the tool. The short story
787
800
  beside it lists its goldens in the spec with no scope folder, the 0.4 shape, which still passes.
788
801
 
802
+ ## Checking a draft
803
+
804
+ Once a spec lints clean and a draft exists, `check` runs the spec's deterministic stations
805
+ against the draft:
806
+
807
+ ```bash
808
+ npx @supersuit/hyperspec check essay.hyperspec.md --draft essay/draft.md
809
+ ```
810
+
811
+ It lints the spec first. A spec that fails lint, or is blocked on an open decision, runs no
812
+ station and exits with lint's own code, because a draft cannot be checked against a spec that is
813
+ not ready. Then it runs seven stations in a fixed order and prints one line for each: `pass`,
814
+ `fail` with its findings, or `skip` with the reason. A warning prints under its station and never
815
+ fails it. This is the essay example's draft:
816
+
817
+ ```
818
+ form: pass
819
+ terms: pass
820
+ claims: pass
821
+ quotes: pass
822
+ private: pass
823
+ dna: pass
824
+ warn [station-dna-drift] first_person_singular_rate is 22.892 in the draft; the scope's goldens measure 0, band 0 to 5
825
+ fix: Bring first_person_singular_rate back inside the band, or, if the scope no longer describes this writer, re-measure it with better goldens.
826
+ links: pass
827
+ verdict: one-shot
828
+ ```
829
+
830
+ `--only form,terms` runs just those stations, still in the fixed order, and its ledger line is
831
+ marked partial (see [The runs ledger](#the-runs-ledger)). `--json` prints the whole result, every
832
+ finding included; a spec that is not ready prints lint's result with `lintBlocked: true` instead,
833
+ and a usage error prints `{ "spec", "draft", "error" }`. A finding names the draft line it points
834
+ at where there is one, quotes at most 80 characters of the draft, and never prints an absolute
835
+ path. A UTF-8 byte order mark at the start of the draft is ignored.
836
+
837
+ Exit codes: **0** every station that ran passed (a skip or a warning does not fail it); **1** a
838
+ station failed; **2** usage: no spec path, no `--draft`, a draft that cannot be read, a spec
839
+ without `profile: writing`, or an `--only` that names no known station; and lint's own **1** or
840
+ **3** when the spec is not ready.
841
+
842
+ Every station is a plain function of the spec and the draft. None of them calls a model, and none
843
+ of them touches the network. What each one checks, and what it cannot:
844
+
845
+ ### form
846
+
847
+ Counts the draft's words, by the same word definition `dna measure` uses, against
848
+ `form.length`. Only `unit: words` is measured; any other unit skips the whole station rather than
849
+ checking half of it. Every `required_parts` entry must appear as an ATX heading (`#` to
850
+ `######`, indented at most three spaces, closing `#`s allowed) whose text equals the part,
851
+ ignoring case, or as a line that starts with the part and a colon, for the fields a form fills in
852
+ place (`To:`, `Subject:`). An underlined (Setext) heading does not count, and neither does
853
+ anything inside a code block. It cannot tell whether the section under a heading does what the
854
+ part is for, so write `required_parts` as the headings the piece will carry,
855
+ as both examples do.
856
+
857
+ | Id | Severity | Meaning |
858
+ |---|---|---|
859
+ | `station-form-length` | fail | the word count is outside `form.length`; the message gives the count and the range |
860
+ | `station-form-required-part-<part>` | fail | a required part appears as neither a heading nor a `part:` line |
861
+
862
+ ### terms
863
+
864
+ Reads the optional `audience.terms`: the words the piece uses that its reader may not know. For
865
+ each term not also in `audience.knows`, it finds the term's first appearance (whole word, ignoring
866
+ case) and looks for a definition in that sentence or the next: the term followed within six words
867
+ by `is`, `means` or `refers to`, a colon among those words, or a parenthesis right after the term.
868
+ This is a mechanical proxy for a definition, not a reading of one: "A hyperspec is mentioned here"
869
+ passes. A term the draft never uses is not flagged, code blocks and inline code are ignored, and
870
+ with no `terms` list the station skips.
871
+
872
+ | Id | Severity | Meaning |
873
+ |---|---|---|
874
+ | `station-terms-undefined-<term>` | fail | the term's first appearance has no definition in that sentence or the next |
875
+
876
+ ### claims
877
+
878
+ Reads the claims ledger at `sources.ledger`: JSONL, one claim per line, each with the claim's
879
+ `text` exactly as the draft says it, a `source`, and optionally a `span`, the words in the source
880
+ that support it. The examples cite a segment as the source, the same `material#segment` form the
881
+ spine uses:
882
+
883
+ ```jsonl
884
+ {"text":"11 of 41 said at least one of their one-on-ones in the last quarter was mostly project status.","source":"survey#s4","span":"11 of 41 said at least one of their one-on-ones in the last quarter was mostly project status."}
885
+ ```
886
+
887
+ Every claim's text must still appear in the draft word for word, with whitespace and quote
888
+ characters normalized and case kept; otherwise the ledger is stale. Every claim needs a real
889
+ source; one without fails, or warns under `unsourced_claim: warn`. A missing ledger fails. The
890
+ station does not decide what counts as a factual claim: the ledger is the list of claims, so a
891
+ factual sentence left out of it passes unseen. Nor does it read the source to see whether it says
892
+ what the claim says.
893
+
894
+ | Id | Severity | Meaning |
895
+ |---|---|---|
896
+ | `station-claims-ledger-missing` | fail | the ledger file does not exist or cannot be read |
897
+ | `station-claims-json-line-<n>` | fail | ledger line n is not JSON, not an object, or has no `text` |
898
+ | `station-claims-stale` | fail | the claim on a ledger line no longer appears in the draft |
899
+ | `station-claims-unsourced` | fail, or warn under `unsourced_claim: warn` | the claim on a ledger line has no source, or only a placeholder; points at the draft line where the claim appears |
900
+
901
+ ### quotes
902
+
903
+ Every span in double quotation marks, straight or curly, of four words or more must appear word
904
+ for word in a `quote` or `story` segment of a marked material. Quote characters and whitespace
905
+ are normalized, case is kept, and a comma or period just inside the closing mark is dropped,
906
+ because that punctuation is the writer's; a `?` or `!` is kept, because adding one changes what
907
+ was said. Shorter spans are not checked, since two or three quoted words are as often a title as
908
+ a quotation. A private segment is never a source for a quote. When the sentence around a quote
909
+ names a speaker, the quote must come from a quote segment with that `speaker`. A speaker is named
910
+ by the full `speaker` value, hyphens read as spaces, or by its first word, so `maria-lopez` is
911
+ named by "Maria Lopez" and by "Maria". The first word alone counts only when it has two or more
912
+ letters and is not a common function word such as "the", so a speaker recorded as "the manager
913
+ interviewed" is named only by all three words. Attribution needs a declared speaker: a name that
914
+ is no segment's `speaker` attributes nothing, so start a `speaker` with the person's name, as
915
+ the essay example does with `dana, an engineering manager`. A spec with `fiction: true` skips
916
+ the station: a character's dialogue is invented rather than quoted from a material, and a later
917
+ release checks it against each character's own lines.
918
+
919
+ | Id | Severity | Meaning |
920
+ |---|---|---|
921
+ | `station-quotes-unmatched` | fail | a quoted span is in no quote or story segment |
922
+ | `station-quotes-misattributed` | fail | the sentence names a speaker, and the span is in no quote segment by that speaker |
923
+
924
+ ### private
925
+
926
+ No run of eight or more consecutive words from any `private` segment may appear in the draft,
927
+ compared by words with case and punctuation ignored. A private segment of four to seven words is
928
+ checked whole. One under four words is not checked, because two or three words match ordinary
929
+ prose; the station reports how many it skipped, as one warning that never quotes them. It cannot
930
+ catch a paraphrase, or a leak shorter than the run.
931
+
932
+ | Id | Severity | Meaning |
933
+ |---|---|---|
934
+ | `station-private-leak` | fail | the draft repeats a run from a private segment; names the material, the segment and the run |
935
+ | `station-private-short-skipped` | warn | private segments under four words were not checked; gives the count |
936
+
937
+ ### dna
938
+
939
+ Runs when `dna.scope_dir` is set and its `features.json` is current. It measures the draft the
940
+ way `dna measure` measures goldens and compares the sentence length mean, both paragraph length
941
+ means, every per-1000-word punctuation rate, and the contraction and person rates with the
942
+ scope's. For a scope value v, a draft value outside v ÷ 1.5 to the larger of v × 1.5 and v + 5 is
943
+ reported with both values. An em dash in a draft whose scope has none is its own finding,
944
+ pointing at the first one. Both
945
+ are warnings and the station never fails: it measures, and whether a draft sounds like its writer
946
+ is a judgment. The essay's warning is an example of what to read: its goldens are instructions in
947
+ the second person, and the essay tells the author's own story in the first. With no `scope_dir`,
948
+ or a `features.json` that is missing or stale, the station skips and says which.
949
+
950
+ | Id | Severity | Meaning |
951
+ |---|---|---|
952
+ | `station-dna-drift` | warn | a feature is outside its band; gives the draft's value, the scope's and the band |
953
+ | `station-dna-em-dash` | warn | the draft uses em dashes and the scope's goldens use none |
954
+
955
+ ### links
956
+
957
+ Every Markdown link (inline, reference, collapsed and shortcut) and every bare URL. An `http` or
958
+ `https` URL must parse and name a host, a `mailto:` link must carry an address, and any other
959
+ scheme fails. A relative link must resolve to a file, relative to the draft's own folder; the
960
+ part after `#` is not checked. A link that starts with `/` is relative to a site root the station
961
+ cannot see, so it warns. A full or collapsed reference, `[text][label]` or `[label][]`, needs a
962
+ definition for its label. A bare `[label]` is a link only when that label has a definition;
963
+ otherwise it is ordinary text, as Markdown renders it, so an editorial `[sic]`, a task list's
964
+ `[x]` and a numbered note `[1]` pass. Code blocks and inline code are ignored. It never touches
965
+ the network, so it cannot tell you a URL is live.
966
+
967
+ | Id | Severity | Meaning |
968
+ |---|---|---|
969
+ | `station-links-malformed` | fail | an http or https URL with no host (a bare `https://` included), or a `mailto:` with no address |
970
+ | `station-links-bad-scheme` | fail | a scheme other than http, https or mailto |
971
+ | `station-links-broken-relative` | fail | a relative link names no file beside the draft |
972
+ | `station-links-root-relative` | warn | a link starting with `/`, which cannot be resolved without the site |
973
+ | `station-links-undefined-reference` | fail | a full or collapsed reference link whose label has no definition |
974
+
975
+ ### Any station
976
+
977
+ | Id | Severity | Meaning |
978
+ |---|---|---|
979
+ | `station-<name>-crashed` | fail | the station threw; the message is the error's, with any absolute path shortened; the other stations and the ledger line still run |
980
+
981
+ ### The runs ledger
982
+
983
+ Each `check` appends one line to the spec's `improvement.ledger`, the same file lint's test 9
984
+ reads:
985
+
986
+ ```json
987
+ {"at":"2026-09-29T13:21:37.330Z","kind":"check","draft":"essay/draft.md","draft_sha256":"<sha256 of the draft>","spec_sha256":"<sha256 of the spec>","stations":{"form":"pass","terms":"pass","claims":"pass","quotes":"pass","private":"pass","dna":"pass","links":"pass"},"verdict":"one-shot"}
988
+ ```
989
+
990
+ `draft` is the draft's path relative to the spec's folder, however you spelled it, so one draft
991
+ has one history. `draft_sha256` and `spec_sha256` hash the two files' bytes; the files the spec
992
+ names (materials, the claims ledger, a scope folder) are not hashed, so "changed" below means the
993
+ draft or the spec. `stations` holds each station's status.
994
+
995
+ A run with `--only` is partial: its line carries `partial: true`, its verdict is `not-improved`
996
+ with the reason `partial run: <stations>`, and later verdicts ignore it, so a subset never claims
997
+ the verdict for the whole draft. A full run is compared with the most recent earlier full line for
998
+ the same draft:
999
+
1000
+ - **one-shot**: there is none, and every station passes.
1001
+ - **improved**: that line failed and every station passes now; `change` names exactly the
1002
+ stations that failed then and pass now.
1003
+ - **not-improved** otherwise, with a `reason` that says which case it is: `failing stations: ...`
1004
+ on a first check that fails; `no change since the last passing check`; `draft changed; every
1005
+ station still passes` (or `spec changed`, or `spec and draft changed`); `still failing: ...`,
1006
+ after `no change since the last check;` or after what changed, when every failing station failed
1007
+ last time too; `failing stations: ...` after what changed when a station fails that passed last
1008
+ time; and `stations that failed last time now skip: ...` when a spec change stopped them running.
1009
+
1010
+ A ledger path that leads outside the spec's folder is not written, and `check` prints a warning.
1011
+
789
1012
  ## Deferring a block
790
1013
 
791
1014
  A block can be deferred, never silently missing. A required block that is absent fails test 1
@@ -849,13 +1072,19 @@ the field it needs, and every spine claim cites the segments that support it. Ea
849
1072
  keeps the boundaries `segments init` wrote, in paragraph mode for prose and sentence mode for
850
1073
  bulleted notes, so you can re-run it and compare.
851
1074
 
852
- Both lint `pass (9/9)` with `writing: 9/9 blocks complete` and no findings. A test runs them on
853
- every release, so they cannot drift from the linter.
1075
+ Both lint `pass (9/9)` with `writing: 9/9 blocks complete` and no findings. Each also ships a
1076
+ draft written to it, `essay/draft.md` and `story/draft.md`, with its claims ledger beside it, and
1077
+ both drafts pass every station of `check`: the essay with one dna warning, described under
1078
+ [dna](#dna), and the story with dna skipped, since it names no scope folder, and quotes skipped,
1079
+ since it is fiction. A test lints both
1080
+ specs and checks both drafts on every release, so they cannot drift from the tool.
854
1081
 
855
1082
  ## What later versions add
856
1083
 
857
- This release is the schema, its lint, marked materials, and scoped DNA with measured features.
858
- Later versions build on it in order. The first compares a draft against its scope: its features
859
- beside the scope's features, and a blind lineup in which a judge sees a generated passage among
860
- the scope's goldens and tries to pick it out. Then the stations themselves, running the checks
861
- each block names and grading drafts against the goal.
1084
+ This release is the schema, its lint, marked materials, scoped DNA, and `check` with seven
1085
+ deterministic stations. Next come the judgment stations: the simulated reader, the blind lineup,
1086
+ the persona judge and the doctor. hyperspec calls no model, so `check` will write each one as a
1087
+ packet, the draft and the rubric and the materials the judge needs, for an outside judge to fill
1088
+ in, and read the filled packet back as a station result. After that, a learn step that reads the
1089
+ runs ledger for the stations that keep failing and the changes that made them pass, so a fix
1090
+ lands in the spec or the skill that wrote the draft rather than in one draft.
package/bin/hyperspec.mjs CHANGED
@@ -16,11 +16,25 @@ import { splitSegments } 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";
19
+ import { runCheck } from "../src/check.mjs";
19
20
 
20
21
  const HELP = `hyperspec <command> [options]
21
22
 
22
23
  lint <file...> [--json] score each hyperspec against the nine tests
23
24
  exit 0 pass, 1 a test fails, 3 blocked on an open decision, 2 usage
25
+ check <spec> --draft <file> [--json] [--only a,b]
26
+ needs a writing spec (profile: writing); lints it first (a spec
27
+ that does not pass lint, or is blocked, exits with lint's own code
28
+ and runs no station: a draft cannot be checked against a spec that
29
+ is not ready); then runs every deterministic station (or the
30
+ --only subset, by name) against the draft, printing pass, fail
31
+ (with findings) or skip (with a reason) per station; appends one
32
+ line to the spec's improvement.ledger with a verdict: one-shot,
33
+ improved, or not-improved with a reason (an --only run is partial:
34
+ not-improved, and ignored by later verdicts)
35
+ exit 0 every run station passed, 1 a station failed, 2 usage
36
+ (including a missing draft file, a spec without the writing
37
+ profile, or an --only that names no known station)
24
38
  init <file> [--title T] [--kind K] write a new hyperspec skeleton (refuses to overwrite)
25
39
  init <file> --profile writing [--title T] [--form F] [--fiction]
26
40
  write a writing-profile skeleton: every required block (materials,
@@ -282,6 +296,61 @@ if (cmd === "lint") {
282
296
  process.exit(worst);
283
297
  }
284
298
 
299
+ if (cmd === "check") {
300
+ const parsed = parseArgs(argv.slice(1), { valueFlags: ["--draft", "--only"], boolFlags: ["--json"] });
301
+ if (parsed.error) { console.error(parsed.error); process.exit(2); }
302
+ const [specPath] = parsed.positionals;
303
+ const json = parsed.values["--json"];
304
+ // A usage error exits 2: a plain message on stderr, or under --json one document on stdout,
305
+ // { spec, draft, error }, the way lint --json reports a file it could not read.
306
+ const usage = (error) => {
307
+ if (json) console.log(JSON.stringify({ spec: specPath ?? null, draft: parsed.values["--draft"] ?? null, error }, null, 2));
308
+ else console.error(error);
309
+ process.exit(2);
310
+ };
311
+ if (!specPath) usage("check needs a spec path");
312
+ if (!parsed.values["--draft"]) usage("check needs --draft <file>");
313
+ let only;
314
+ if (parsed.values["--only"] !== undefined) {
315
+ only = parsed.values["--only"].split(",").map((s) => s.trim()).filter(Boolean);
316
+ if (!only.length) usage("--only names no station");
317
+ }
318
+
319
+ const result = runCheck(specPath, parsed.values["--draft"], { only });
320
+
321
+ // Usage errors (an unreadable spec, a spec without the writing profile, an unknown --only name,
322
+ // a missing draft file): exit 2, as above.
323
+ if (result.usage) usage(result.error);
324
+
325
+ if (result.lintBlocked) {
326
+ if (json) console.log(JSON.stringify(result, null, 2));
327
+ else {
328
+ const r = result.lintScore;
329
+ console.log(`${result.specPath}: ${r.status} (${r.passed}/9)${r.open.length ? `, open: ${r.open.join(", ")}` : ""}`);
330
+ for (const t of r.tests) if (!t.pass) console.log(` ✗ ${t.n}. ${t.name}`);
331
+ for (const f of result.lintFindings) console.log(` ${f.severity === "fail" ? "fail" : "warn"} [${f.test}] ${f.message}\n fix: ${f.fix}`);
332
+ console.log("no stations run: the spec is not ready (run `hyperspec lint` on it for details)");
333
+ }
334
+ process.exit(result.code);
335
+ }
336
+
337
+ if (json) {
338
+ console.log(JSON.stringify(result, null, 2));
339
+ } else {
340
+ for (const s of result.stations) {
341
+ if (s.status === "skip") { console.log(`${s.station}: skip (${s.reason})`); continue; }
342
+ console.log(`${s.station}: ${s.status}`);
343
+ for (const finding of s.findings) console.log(` ${finding.severity === "fail" ? "fail" : "warn"} [${finding.id}] ${finding.message}${typeof finding.line === "number" ? ` (line ${finding.line})` : ""}\n fix: ${finding.fix}`);
344
+ }
345
+ if (result.verdict) {
346
+ const detail = result.verdictDetail.change ?? result.verdictDetail.reason;
347
+ console.log(`verdict: ${result.verdict}${detail ? ` (${detail})` : ""}`);
348
+ }
349
+ if (result.ledgerWarning) console.log(`warn: ${result.ledgerWarning}`);
350
+ }
351
+ process.exit(result.code);
352
+ }
353
+
285
354
  // Generic flag/positional parser for the recipe verbs below. A value-taking flag (valueFlags,
286
355
  // repeatableFlags) never swallows a following --flag as its value (missing value is an error, not
287
356
  // a silent grab); a bool flag never eats the next token as a positional; any --flag not declared
@@ -0,0 +1,9 @@
1
+ {"text":"29 of 41 people said their most useful one-on-one in the last quarter was one where they brought the first topic.","source":"survey#s3","span":"29 of 41 said their most useful one-on-one in the last quarter was one where they brought the first topic."}
2
+ {"text":"That is how an engineering manager I interviewed, eight years into the job, runs hers.","source":"interview#s4","span":"She keeps a shared document per person; they add items before the meeting, and she adds hers last, at the bottom."}
3
+ {"text":"Her rule is short: the report owns the agenda.","source":"interview#s3","span":"Her rule: the report owns the agenda."}
4
+ {"text":"My first one-on-one as a manager was a disaster, and the reason was simple: I ran it.","source":"voice-memo#s2","span":"My first one-on-one as a manager was a disaster, and the reason was simple: I ran it."}
5
+ {"text":"11 of 41 said at least one of their one-on-ones in the last quarter was mostly project status.","source":"survey#s4","span":"11 of 41 said at least one of their one-on-ones in the last quarter was mostly project status."}
6
+ {"text":"What is taking more of your energy than it should?","source":"voice-memo#s4","span":"What is taking more of your energy than it should?"}
7
+ {"text":"What do you want to be doing more of in six months?","source":"voice-memo#s4","span":"What do you want to be doing more of in six months?"}
8
+ {"text":"What should I stop doing, or start doing, that would make your week easier?","source":"voice-memo#s4","span":"What should I stop doing, or start doing, that would make your week easier?"}
9
+ {"text":"The most common free-text request in the survey, in 9 responses, was to be asked what they want to work on next.","source":"survey#s5","span":"The most common free-text request, in 9 responses: \"ask me what I want to work on next.\""}
@@ -0,0 +1,82 @@
1
+ # Hand your first one-on-one to the person you manage
2
+
3
+ ## Who sets the agenda
4
+
5
+ Your first one-on-one with a new report is the only meeting on your calendar where they should
6
+ set the agenda. Everything else you run. This one you hand over.
7
+
8
+ If you walk in with a list, you have told your report what the meeting is for, and it is for
9
+ you. They will answer your list politely and leave. You will know nothing you did not know when
10
+ you sat down, and so will they.
11
+
12
+ My team ran a [survey](materials/team-survey.md) this spring. 29 of 41 people said their most useful one-on-one in the last
13
+ quarter was one where they brought the first topic. The meeting that worked for them was the one
14
+ they started.
15
+
16
+ So give them the start. The simplest way to do it is a running agenda: one shared document per
17
+ person, kept for as long as you manage them. Your report adds items before each meeting, and you
18
+ add yours last, at the bottom. That is how an engineering manager I interviewed, eight years into
19
+ the job, runs hers. Her rule is short: the report owns the agenda.
20
+
21
+ ## My first one-on-one
22
+
23
+ My first one-on-one as a manager was a disaster, and the reason was simple: I ran it. I had a
24
+ list, and I went down the list. Project status, blockers, the thing from Tuesday. Thirty minutes
25
+ later my report said thanks and left, and I had learned nothing I could not have read in the
26
+ tracker.
27
+
28
+ What I ran was a status meeting, which is a meeting spent reading out loud what the tracker
29
+ already says. Your report wrote those updates. Asking them to recite the updates to you teaches
30
+ you nothing new, and it spends the one half hour a week that belongs to them.
31
+
32
+ The survey shows the cost from their side. 11 of 41 said at least one of their one-on-ones in the
33
+ last quarter was mostly project status. Read the tracker before you walk in, and leave status
34
+ there.
35
+
36
+ ## The three questions
37
+
38
+ Here is what I ask now, in this order, and then I let the report take over:
39
+
40
+ 1. What is taking more of your energy than it should?
41
+ 2. What do you want to be doing more of in six months?
42
+ 3. What should I stop doing, or start doing, that would make your week easier?
43
+
44
+ Three is enough to hand the meeting over. The first asks about this week. The second asks about
45
+ the next six months. The third asks about you, and it is the one your report will not raise
46
+ without being asked. A fourth question starts to look like your list again, and the list is what
47
+ you came to give up.
48
+
49
+ Ask them in the same words every time. Your report will learn them, and after a few weeks they
50
+ will walk in with answers already half formed. That is the point of fixing the words: the meeting
51
+ starts on their topic before you have said anything at all.
52
+
53
+ The second question is the one my own team asked for. The most common free-text request in the
54
+ survey, in 9 responses, was to be asked what they want to work on next.
55
+
56
+ After the third question, stop talking.
57
+
58
+ ## What to do with the answers
59
+
60
+ Wait after each question, longer than you want to. Dana, the engineering manager I interviewed,
61
+ puts it in three short sentences: "Wait. Count to five. The real answer is the second one." The first
62
+ answer your report gives is the tidy one, the version they could give anyone. The second is what
63
+ they came in with, and you only hear it if you let the silence run.
64
+
65
+ Write down what they say, in their words, at the top of the running agenda. That list is where
66
+ your next one-on-one starts, so the meeting stays theirs the week after too.
67
+
68
+ Do not try to fix everything in the room. Pick one thing you can act on this week, say what you
69
+ will do, and do it before you meet again. The rest stays on the running agenda until it is done
70
+ or your report takes it off.
71
+
72
+ And keep your own urgent items out of it. She told me: "If I have something urgent, it is not a
73
+ one-on-one topic. I send it the day it happens." Your report should never have to wait a week to
74
+ hear something you needed them to know on Monday.
75
+
76
+ ## Before the meeting
77
+
78
+ Open the invite and delete your list from it. Ask your report to add the first item to the
79
+ running agenda instead.
80
+
81
+ So write the three questions on a card. Ask the first one. Then wait, longer than feels polite,
82
+ because the first answer is the one they rehearsed and the second one is the one you came for.