@supersuit/hyperspec 0.1.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 ADDED
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-09-28)
4
+
5
+ - The hyperspecification standard: a markdown file with a YAML frontmatter block, versioned
6
+ in git, living beside the work it specifies.
7
+ - Nine tests: decisions, failable requirements, checked requirements, provenance, rejects,
8
+ examples, resumability, a place to push back, and an improvement ledger.
9
+ - `hyperspec lint <file...> [--json]`, exiting 0 pass, 1 a test fails, 3 blocked on an open
10
+ decision, 2 usage or IO error.
11
+ - `hyperspec init <file> [--title T] [--kind K]`, writing a new hyperspec skeleton and
12
+ refusing to overwrite an existing file.
13
+ - Every finding names its test, a severity, a message and a fix.
14
+ - YAML read only through `parseSkillFile` from `@supersuit/superskill/yaml`.
15
+ - SPEC.md is itself a hyperspec and passes its own lint.
16
+ - A placeholder value never counts as present: a value that is only a YAML comment
17
+ (`source: # TODO`), or `null`, or `~`, fails the test its field belongs to.
18
+ - `hyperspec lint` never skips a file that follows a stray flag such as `--kind`.
19
+ - The package ships `examples/minimal.hyperspec.md`, the smallest spec that passes all nine
20
+ tests, and SPEC.md points at it, so the SPEC.md inside the package passes its own lint.
21
+ - Test 7 fails a `next_action` that is only a no-action word (`continue`, `tbd`, `n/a`, and
22
+ the rest), so "continue drafting section two from the outline" passes; it also fails a
23
+ `next_action` that says "as discussed" or "as mentioned earlier" or "above".
24
+ - Test 5 names a `rejects` item that is not a plain string, instead of reporting that
25
+ nothing is rejected.
26
+ - SPEC.md describes exactly what `lint` enforces: decision completeness is the author's job,
27
+ a vague `fails_when` is a warning, where `chosen_by` is checked, how example paths
28
+ resolve, and that exit 2 covers a file that is not a hyperspec.
29
+ - A ledger can never crash the linter: a ledger path that is a directory or a device, or a
30
+ file that cannot be read, fails test 9, and a ledger line that is valid JSON but not an
31
+ object (such as `null`) is a bad line. A crash on one file is reported as that file's
32
+ error and never stops the others or empties `--json`.
33
+ - The body scan for "as discussed" skips fenced code blocks and inline code, so a spec can
34
+ quote the phrases it bans. SPEC.md lints with zero findings, warnings included.
35
+ - SPEC.md cites only sources a public reader can open, and its next action is to collect
36
+ adopter issues on 0.1 and cut 0.2 from them.
37
+ - The README's sample output is exactly what `hyperspec lint` prints.
38
+ - Reads YAML through `@supersuit/superskill/yaml` 0.2.1, which now returns a comment-only value
39
+ (`source: # TODO`) as an empty string itself. The placeholder rule no longer treats a leading
40
+ `#` as empty; it only handles `null` and `~`, which the reader still keeps as those literal
41
+ strings. A quoted value that starts with `#` (`source: "# literal"`) is real text and counts
42
+ as present.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SupersuitUp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # hyperspec
2
+
3
+ A hyperspec is a spec written for an agent: every decision is recorded with who made it and
4
+ where it came from, every requirement can fail in a named way, and every field is traced back
5
+ to its source. This package is the standard and its linter.
6
+
7
+ ## 30 seconds
8
+
9
+ ```bash
10
+ npx @supersuit/hyperspec lint spec.md
11
+ ```
12
+
13
+ ```
14
+ spec.md: pass (9/9)
15
+ ```
16
+
17
+ Nine tests run against the frontmatter: decisions, failable requirements, checked
18
+ requirements, provenance, rejects, examples, resumability, a place to push back, and an
19
+ improvement ledger. Every test is defined in [SPEC.md](SPEC.md).
20
+
21
+ ## Commands
22
+
23
+ | Command | What it does |
24
+ |---|---|
25
+ | `hyperspec lint <file...> [--json]` | Score each hyperspec against the nine tests. |
26
+ | `hyperspec init <file> [--title T] [--kind K]` | Write a new hyperspec skeleton. Refuses to overwrite an existing file. |
27
+
28
+ ## Exit codes
29
+
30
+ `hyperspec lint` exits 0 when every test passes and nothing is open, 1 when at least one
31
+ test fails, 2 on a usage or IO error, and 3 when every test passes but a decision is still
32
+ open (blocked).
33
+
34
+ ## The format
35
+
36
+ A hyperspec is a markdown file with a YAML frontmatter block: `decisions`, `requirements`,
37
+ `rejects`, `examples`, `resume`, `feedback`, and `improvement`. The full field-by-field
38
+ standard, including what makes each of the nine tests fail, is in [SPEC.md](SPEC.md).
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ npm install @supersuit/hyperspec
44
+ ```
45
+
46
+ Node 20 or later. One dependency, `@supersuit/superskill`, for the YAML reader.
package/SPEC.md ADDED
@@ -0,0 +1,190 @@
1
+ ---
2
+ hyperspec: "0.1"
3
+ title: The hyperspecification standard
4
+ kind: standard
5
+ decisions:
6
+ - id: format
7
+ state: decided
8
+ value: a hyperspec is a markdown file with a YAML frontmatter block, versioned in git, living beside the work it specifies
9
+ source: SPEC.md, section "The format (what `lint` reads)"
10
+ author: gary-sheng
11
+ chosen_by: human
12
+ - id: yaml-reader
13
+ state: decided
14
+ value: hyperspec reads YAML only through parseSkillFile from @supersuit/superskill/yaml; it never carries a second parser
15
+ source: "superskill 0.2.0 CHANGELOG: the reader gained nesting so standards in this family import it instead of writing a second parser"
16
+ author: gary-sheng
17
+ chosen_by: human
18
+ - id: exit-codes
19
+ state: decided
20
+ value: "0 every test passes and nothing is open; 1 at least one test fails; 3 every test passes but a decision is open; 2 usage or IO error"
21
+ source: SPEC.md, section "Exit codes", and README.md, section "Exit codes"
22
+ author: agent:claude
23
+ chosen_by: agent
24
+ requirements:
25
+ - id: r1
26
+ text: lint exits 0 only when all nine tests pass and nothing is open
27
+ fails_when: a spec missing fails_when on any requirement exits 0
28
+ check:
29
+ station: test/rules.test.mjs and test/score.test.mjs
30
+ source: SPEC.md, section "What makes a spec a hyperspec"
31
+ author: gary-sheng
32
+ - id: r2
33
+ text: every finding names a fix
34
+ fails_when: a finding with an empty fix
35
+ check:
36
+ station: "test/rules.test.mjs, \"every finding names its test\""
37
+ source: CHANGELOG.md, 0.1.0, "every finding names its test, a severity, a message and a fix"
38
+ author: agent:claude
39
+ rejects:
40
+ - prose advice where a field could be checked
41
+ - a second YAML parser
42
+ examples:
43
+ - path: examples/minimal.hyperspec.md
44
+ why: the smallest spec that passes all nine tests
45
+ resume:
46
+ next_action: collect adopter issues on 0.1 and cut 0.2 from them
47
+ feedback:
48
+ issues: https://github.com/SupersuitUp/hyperspec/issues
49
+ fork: MIT; fork it for your own purposes and say so in your SPEC
50
+ improvement:
51
+ ledger: runs.jsonl
52
+ ---
53
+
54
+ # The hyperspecification standard
55
+
56
+ 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.
57
+
58
+ **Version 0.1.0** (2026-09-28)
59
+
60
+ ## What makes a spec a hyperspec
61
+
62
+ A spec is a hyperspec when it passes these nine tests. Each one is checkable, which is itself the first test.
63
+
64
+ ### 1. every decision is accounted for
65
+
66
+ The form of the work has a known list of decision points: for an essay, who it is for, what it argues, how it opens, how long it runs, what it refuses to say, and so on. Each one is decided, delegated with the rule the agent uses to decide it, or open. An open decision stops the work. Nothing is left to the average.
67
+
68
+ `lint` checks that at least one decision is listed and that each listed decision is well-formed. Whether the list covers every decision the form of the work has is the author's job, because the linter does not know the form's full list.
69
+
70
+ ### 2. every requirement can fail
71
+
72
+ Each line is written so a specific observation could show it was not met, under `fails_when`. The observation is what makes a requirement failable. "A reader who has never heard the term can say what it means after the first section" is one. A vague word such as "engaging" in `fails_when` is reported as a warning, because it leans on an adjective where an observation should be.
73
+
74
+ ### 3. every requirement names its check
75
+
76
+ Either a deterministic station (a lint, a schema, a source match) or a judgment station (a rubric a grader applies), and the check is written beside the requirement it belongs to.
77
+
78
+ ### 4. every field says where it came from and who wrote it
79
+
80
+ Where: a brain dump line, an interview answer, a transcript, a prior piece. Who: a person or an agent, and which one, and whether a human explicitly chose it or an agent proposed it and nobody objected. `lint` checks `source`, `author` and `chosen_by` on every decision, and `source` and `author` on every requirement. Neither answer is bad on its own. Knowing which is what lets you debug: an agent that invented a detail, or a person who put in something wrong, both show up as a field with an author you can ask.
81
+
82
+ ### 5. negative space is specified
83
+
84
+ What the work must not do is usually more specific than what it must do. A hyperspec lists its rejected poles under `rejects`.
85
+
86
+ ### 6. examples outrank adjectives
87
+
88
+ Where a quality matters, the spec points at a real example of it instead of describing it. A model copies surface style from a description and misses the style an example carries.
89
+
90
+ ### 7. a stranger can resume it
91
+
92
+ A different agent, with only the spec and the state beside it, can pick the work up mid-flight and know what to do next. The spec is the primary artifact, and the output is regenerable from it.
93
+
94
+ ### 8. its adopters can push back on it
95
+
96
+ A spec receives issues and pull requests, like code. Anyone adopting it can say they are not happy with it, propose a change, and fork it for their own purposes. A spec nobody can argue with stops improving the day it ships.
97
+
98
+ ### 9. it improves itself
99
+
100
+ Every run leaves a verdict: it went through clean, or it did not and the spec or the skill changed, or it did not and here is why nothing changed. A framework with no improvement loop is as good on its first run as it will ever be.
101
+
102
+ ## The format (what `lint` reads)
103
+
104
+ A hyperspec is a markdown file with a YAML frontmatter block. This is the shape:
105
+
106
+ ```yaml
107
+ ---
108
+ hyperspec: "0.1"
109
+ title: What this specifies
110
+ kind: essay
111
+ decisions:
112
+ - id: audience
113
+ state: decided # decided | delegated | open
114
+ value: the operator, reading on a phone
115
+ source: interview A2 # where it came from
116
+ author: gary-sheng # a person slug, or agent:<model>
117
+ chosen_by: human # human | agent
118
+ - id: length
119
+ state: delegated
120
+ rule: as short as the claim chain allows, never over 1,200 words
121
+ source: design doc, Part 2
122
+ author: agent:claude
123
+ chosen_by: agent
124
+ - id: title
125
+ state: open
126
+ question: which of the three candidate titles?
127
+ source: draft 2
128
+ author: agent:claude
129
+ chosen_by: agent
130
+ requirements:
131
+ - id: r1
132
+ text: a reader new to the term can say what it means after section one
133
+ fails_when: the simulated reader cannot define the term after section one
134
+ check:
135
+ rubric: ask the simulated reader to define the term; pass only on a correct definition
136
+ source: design doc, audience block
137
+ author: gary-sheng
138
+ rejects:
139
+ - hype words about AI
140
+ examples:
141
+ - path: goldens/opening.md
142
+ why: the claim lands in the first line and the second line earns it
143
+ resume:
144
+ next_action: write the outline from the claim chain
145
+ feedback:
146
+ issues: https://github.com/SupersuitUp/hyperspec/issues
147
+ fork: MIT; fork it for your own purposes
148
+ improvement:
149
+ ledger: runs.jsonl
150
+ ---
151
+ ```
152
+
153
+ ## The test-to-field map
154
+
155
+ Each row lists every condition under which `hyperspec lint` fails that test. A warning never fails a test. A value that is only a YAML comment (`source: # TODO`), or `null`, or `~`, counts as missing. A quoted value that happens to start with `#` (`source: "# literal"`) is real text and counts as present.
156
+
157
+ | Test | Fails when |
158
+ |---|---|
159
+ | 1 every decision is accounted for | no `decisions`; a decision with no or duplicate `id`; `state` not decided, delegated or open; decided without `value`; delegated without `rule`; open without `question` |
160
+ | 2 every requirement can fail | no `requirements`; a requirement without `text` or without `fails_when`. A vague word in `fails_when` is a warning |
161
+ | 3 every requirement names its check | a requirement whose `check` has neither `station` nor `rubric` |
162
+ | 4 every field says where it came from and who wrote it | a decision or requirement without `source` or `author`; a decision whose `chosen_by` is not human or agent |
163
+ | 5 negative space is specified | `rejects` missing or empty; a `rejects` item that is not a plain string |
164
+ | 6 examples outrank adjectives | `examples` missing or empty; an example without `path` or `why`; a `path` that is not an http(s) URL and does not exist, read relative to the spec or as an absolute path |
165
+ | 7 a stranger can resume it | `resume.next_action` missing; a `next_action` that is only a no-action word (`continue`, `follow up`, `tbd`, `todo`, `keep going`, `pick it back up`, `n/a`, `none`); a `next_action` that says `as discussed` or `as mentioned earlier` or `above`. Those pointers in the body are a warning |
166
+ | 8 its adopters can push back on it | `feedback.issues` or `feedback.fork` missing |
167
+ | 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` |
168
+
169
+ ## Exit codes
170
+
171
+ `hyperspec lint` reports the worst result across every file it is given:
172
+
173
+ - **0** every test passes and nothing is open.
174
+ - **1** at least one test fails.
175
+ - **3** every test passes, but a decision is left open, so the work waits on that decision.
176
+ - **2** usage error; a file that could not be read; a file whose frontmatter is missing or broken; or a file that is not a hyperspec. A file is a hyperspec when its frontmatter has a `hyperspec` key.
177
+
178
+ ## The improvement ledger
179
+
180
+ 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`:
181
+
182
+ - **one-shot**: no intervention, nothing to learn.
183
+ - **improved**: the skill, the spec template, or a component library changed, and the line carries `change`, naming what changed.
184
+ - **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".
185
+
186
+ 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.
187
+
188
+ ## Why now
189
+
190
+ A human reader treats a thousand-line spec as a burden, so specs were written short and the gaps were filled from shared context. A model reads all of it at almost no cost and uses every line. Detail that would have been waste between two people is now the cheapest input there is. That is the whole reason hyperspecification exists now and could not have before: the reader changed, so the economics of writing everything down changed with it.
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, writeFileSync } from "node:fs";
3
+ import { loadSpec } from "../src/load.mjs";
4
+ import { lintSpec } from "../src/rules.mjs";
5
+ import { score, exitCode } from "../src/score.mjs";
6
+ import { template } from "../src/template.mjs";
7
+
8
+ const HELP = `hyperspec <command> [options]
9
+
10
+ lint <file...> [--json] score each hyperspec against the nine tests
11
+ exit 0 pass, 1 a test fails, 3 blocked on an open decision, 2 usage
12
+ init <file> [--title T] [--kind K] write a new hyperspec skeleton (refuses to overwrite)
13
+
14
+ Spec: SPEC.md`;
15
+
16
+ const argv = process.argv.slice(2);
17
+ const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
18
+ const cmd = argv[0];
19
+
20
+ if (!cmd || cmd === "--help" || cmd === "-h") { console.log(HELP); process.exit(cmd ? 0 : 2); }
21
+
22
+ if (cmd === "init") {
23
+ const file = argv[1];
24
+ if (!file || file.startsWith("--")) { console.error("init needs a file path"); process.exit(2); }
25
+ if (existsSync(file)) { console.error(`refusing to overwrite ${file}`); process.exit(2); }
26
+ writeFileSync(file, template({ title: flag("--title"), kind: flag("--kind") }));
27
+ console.log(`wrote ${file}; run: hyperspec lint ${file}`);
28
+ process.exit(0);
29
+ }
30
+
31
+ if (cmd === "lint") {
32
+ const json = argv.includes("--json");
33
+ // Lint takes one flag, --json, and no flag takes a value, so a flag is dropped on its own and
34
+ // never takes the argument after it (a stray --kind before the files must not swallow one).
35
+ const files = argv.slice(1).filter((a) => !a.startsWith("--"));
36
+ if (!files.length) { console.error("lint needs at least one file"); process.exit(2); }
37
+ const rank = { 2: 4, 1: 3, 3: 2, 0: 1 };
38
+ let worst = 0;
39
+ const reports = files.map((file) => {
40
+ const spec = loadSpec(file);
41
+ if (spec.error) { worst = 2; return { file, status: "error", error: spec.error }; }
42
+ // A crash on one file is reported as that file's error; it never aborts the others or empties --json.
43
+ let findings, s;
44
+ try { findings = lintSpec(spec); s = score(findings, spec.data); }
45
+ catch (e) { worst = 2; return { file, status: "error", error: `lint crashed: ${e.message}` }; }
46
+ const code = exitCode(s.status);
47
+ if (rank[code] > rank[worst]) worst = code;
48
+ return { file, ...s, findings };
49
+ });
50
+ if (json) console.log(JSON.stringify({ files: reports }, null, 2));
51
+ else for (const r of reports) {
52
+ if (r.error) { console.log(`${r.file}: ${r.error}`); continue; }
53
+ console.log(`${r.file}: ${r.status} (${r.passed}/9)${r.open.length ? `, open: ${r.open.join(", ")}` : ""}`);
54
+ for (const t of r.tests) if (!t.pass) console.log(` ✗ ${t.n}. ${t.name}`);
55
+ for (const f of r.findings) console.log(` ${f.severity === "fail" ? "fail" : "warn"} [${f.test}] ${f.message}\n fix: ${f.fix}`);
56
+ }
57
+ process.exit(worst);
58
+ }
59
+
60
+ console.error(`unknown command: ${cmd}\n\n${HELP}`);
61
+ process.exit(2);
@@ -0,0 +1 @@
1
+ The claim first.
@@ -0,0 +1,39 @@
1
+ ---
2
+ hyperspec: "0.1"
3
+ title: What this specifies
4
+ kind: essay
5
+ decisions:
6
+ - id: audience
7
+ state: decided
8
+ value: the operator, reading on a phone
9
+ source: interview A2
10
+ author: gary-sheng
11
+ chosen_by: human
12
+ - id: length
13
+ state: delegated
14
+ rule: as short as the claim chain allows, never over 1,200 words
15
+ source: design doc, Part 2
16
+ author: agent:claude
17
+ chosen_by: agent
18
+ requirements:
19
+ - id: r1
20
+ text: a reader new to the term can say what it means after section one
21
+ fails_when: the simulated reader cannot define the term after section one
22
+ check:
23
+ rubric: ask the simulated reader to define the term; pass only on a correct definition
24
+ source: design doc, audience block
25
+ author: gary-sheng
26
+ rejects:
27
+ - hype words about AI
28
+ examples:
29
+ - path: goldens/opening.md
30
+ why: the claim lands in the first line and the second line earns it
31
+ resume:
32
+ next_action: write the outline from the claim chain
33
+ feedback:
34
+ issues: https://github.com/SupersuitUp/hyperspec/issues
35
+ fork: MIT; fork it for your own purposes
36
+ improvement:
37
+ ledger: runs.jsonl
38
+ ---
39
+ # Body
@@ -0,0 +1,3 @@
1
+ {"verdict":"one-shot"}
2
+ {"verdict":"improved","change":"added a reject for hype words"}
3
+ {"verdict":"not-improved","reason":"the correction was about this piece only"}
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@supersuit/hyperspec",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "hyperspec": "bin/hyperspec.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "examples/",
13
+ "SPEC.md",
14
+ "README.md",
15
+ "CHANGELOG.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "test": "node --test test/*.test.mjs"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/SupersuitUp/hyperspec.git"
28
+ },
29
+ "keywords": [
30
+ "hyperspec",
31
+ "specification",
32
+ "agents",
33
+ "spec-driven",
34
+ "outcome-factory"
35
+ ],
36
+ "dependencies": {
37
+ "@supersuit/superskill": "^0.2.1"
38
+ }
39
+ }
package/src/load.mjs ADDED
@@ -0,0 +1,13 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { parseSkillFile } from "@supersuit/superskill/yaml";
4
+
5
+ export function loadSpec(path) {
6
+ const abs = resolve(path);
7
+ let text;
8
+ try { text = readFileSync(abs, "utf8"); } catch { return { path: abs, dir: dirname(abs), data: {}, body: "", error: `cannot read ${path}` }; }
9
+ const { data, body, error } = parseSkillFile(text);
10
+ if (error) return { path: abs, dir: dirname(abs), data: {}, body, error };
11
+ if (!("hyperspec" in data)) return { path: abs, dir: dirname(abs), data, body, error: "not a hyperspec" };
12
+ return { path: abs, dir: dirname(abs), data, body };
13
+ }
package/src/rules.mjs ADDED
@@ -0,0 +1,128 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ export const TESTS = Object.freeze([
5
+ { n: 1, name: "every decision is accounted for" },
6
+ { n: 2, name: "every requirement can fail" },
7
+ { n: 3, name: "every requirement names its check" },
8
+ { n: 4, name: "every field says where it came from and who wrote it" },
9
+ { n: 5, name: "negative space is specified" },
10
+ { n: 6, name: "examples outrank adjectives" },
11
+ { n: 7, name: "a stranger can resume it" },
12
+ { n: 8, name: "its adopters can push back on it" },
13
+ { n: 9, name: "it improves itself" },
14
+ ].map(Object.freeze));
15
+
16
+ const VAGUE = /\b(engaging|compelling|high[- ]quality|good|great|clear|clean|professional|polished|nice|strong|effective|appropriate|better|amazing|excellent|best|world[- ]class|seamless|intuitive|robust)\b/i;
17
+ // A no-action word fails only when it is the WHOLE next action: "continue drafting section two" names one.
18
+ const NO_ACTION = /^(continue|follow[- ]?up|tbd|todo|keep going|pick (this|it) (back )?up|n\/?a|none)\W*$/i;
19
+ // A pointer into a conversation the next reader cannot see, anywhere in the text.
20
+ const CONVERSATION = /\bas (we )?discussed\b|\bas mentioned (earlier|above)\b/i;
21
+ // The body scan reads prose only: fenced code blocks (``` or ~~~, closed by the same fence or the end
22
+ // of the file) and inline code spans (a run of N backticks closed by a run of N) are removed first, so a
23
+ // spec can quote the phrases it bans. Tables are prose and are still scanned.
24
+ const FENCE = /^ {0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:^ {0,3}\1[`~]*[ \t]*$|(?![\s\S]))/gm;
25
+ const INLINE_CODE = /(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)/g;
26
+ const prose = (body) => String(body || "").replace(FENCE, "").replace(INLINE_CODE, "");
27
+ const list = (v) => (Array.isArray(v) ? v : []);
28
+ // A value that is only null or ~ is a placeholder: the reader (@supersuit/superskill/yaml) keeps
29
+ // these as the literal strings "null" and "~" rather than resolving them to YAML's own null, so
30
+ // they must never count as present. A value that is only a YAML comment (source: # TODO) is
31
+ // handled upstream since superskill 0.2.1: the reader returns "" for it, same as any other blank
32
+ // scalar, so it already fails str()'s own emptiness check and needs no rule here. A QUOTED value
33
+ // that happens to start with "#" (source: "# literal") is real text and must count as present.
34
+ // Every presence check goes through str().
35
+ const PLACEHOLDER = /^(null|~)$/is;
36
+ const str = (v) => { const t = typeof v === "string" ? v.trim() : ""; return PLACEHOLDER.test(t) ? "" : t; };
37
+ const f = (test, id, severity, message, fix) => ({ test, id, severity, message, fix });
38
+
39
+ export function lintSpec(spec, { exists = existsSync } = {}) {
40
+ const d = spec.data || {};
41
+ const out = [];
42
+ const here = (p) => resolve(spec.dir || ".", p);
43
+
44
+ // 1 and 4, decisions
45
+ const decisions = list(d.decisions);
46
+ if (!decisions.length) out.push(f(1, "decisions", "fail", "no decisions are listed", "List every decision this kind of work has under decisions:, each decided, delegated or open."));
47
+ const seen = new Set();
48
+ decisions.forEach((x, i) => {
49
+ const id = str(x?.id) || `#${i + 1}`;
50
+ if (!str(x?.id)) out.push(f(1, "decision-id", "fail", `decision ${id} has no id`, "Give it a short id."));
51
+ else if (seen.has(id)) out.push(f(1, "decision-id", "fail", `decision id "${id}" is used twice`, "Make every id unique."));
52
+ seen.add(id);
53
+ const st = str(x?.state);
54
+ if (!["decided", "delegated", "open"].includes(st)) out.push(f(1, "decision-state", "fail", `decision "${id}" has state "${st || "(none)"}"`, "Set state to decided, delegated or open."));
55
+ if (st === "decided" && !str(x.value)) out.push(f(1, "decided-value", "fail", `decision "${id}" is decided with no value`, "Write the value that was decided."));
56
+ if (st === "delegated" && !str(x.rule)) out.push(f(1, "delegated-rule", "fail", `decision "${id}" is delegated with no rule`, "Write the rule the agent uses to decide it."));
57
+ if (st === "open" && !str(x.question)) out.push(f(1, "open-question", "fail", `decision "${id}" is open with no question`, "Write the question that has to be answered."));
58
+ if (!str(x?.source)) out.push(f(4, "decision-source", "fail", `decision "${id}" does not say where it came from`, "Add source: the brain dump line, interview answer, transcript or document."));
59
+ if (!str(x?.author)) out.push(f(4, "decision-author", "fail", `decision "${id}" does not say who wrote it`, "Add author: a person slug, or agent:<model>."));
60
+ if (!["human", "agent"].includes(str(x?.chosen_by))) out.push(f(4, "decision-chosen-by", "fail", `decision "${id}" does not say whether a human chose it`, "Add chosen_by: human or agent."));
61
+ });
62
+
63
+ // 2, 3 and 4, requirements
64
+ const reqs = list(d.requirements);
65
+ if (!reqs.length) out.push(f(2, "requirements", "fail", "no requirements are listed", "List what the finished work must meet under requirements:."));
66
+ reqs.forEach((r, i) => {
67
+ const id = str(r?.id) || `#${i + 1}`;
68
+ if (!str(r?.text)) out.push(f(2, "requirement-text", "fail", `requirement "${id}" has no text`, "Write the requirement."));
69
+ const fw = str(r?.fails_when);
70
+ if (!fw) out.push(f(2, "fails-when", "fail", `requirement "${id}" does not say what would show it failed`, "Add fails_when: something a person or a check could observe."));
71
+ else if (VAGUE.test(fw)) out.push(f(2, "fails-when-vague", "warn", `requirement "${id}": fails_when leans on "${fw.match(VAGUE)[0]}"`, "Replace the adjective with something observable."));
72
+ const c = r?.check && typeof r.check === "object" ? r.check : {};
73
+ if (!str(c.station) && !str(c.rubric)) out.push(f(3, "check", "fail", `requirement "${id}" names no check`, "Add check: with station: <a deterministic check> or rubric: <what a grader applies>."));
74
+ if (!str(r?.source)) out.push(f(4, "requirement-source", "fail", `requirement "${id}" does not say where it came from`, "Add source:."));
75
+ if (!str(r?.author)) out.push(f(4, "requirement-author", "fail", `requirement "${id}" does not say who wrote it`, "Add author:."));
76
+ });
77
+
78
+ // 5
79
+ // rejects items are plain strings; a non-string item is named on its own rather than hidden
80
+ // behind "nothing is rejected".
81
+ const rejects = list(d.rejects);
82
+ const notStrings = rejects.map((x, i) => (x != null && typeof x !== "string" ? i + 1 : 0)).filter(Boolean);
83
+ notStrings.forEach((n) => out.push(f(5, "rejects-item", "fail", `rejects item ${n} is not a plain string`, "Write each rejected pole as one plain line of text.")));
84
+ if (!notStrings.length && !rejects.some((x) => str(x))) out.push(f(5, "rejects", "fail", "nothing is rejected", "List what the work must not do under rejects:."));
85
+
86
+ // 6
87
+ const ex = list(d.examples);
88
+ if (!ex.length) out.push(f(6, "examples", "fail", "no examples are given", "Point at a real example under examples:, with path and why."));
89
+ ex.forEach((e, i) => {
90
+ const p = str(e?.path);
91
+ if (!p) out.push(f(6, "example-path", "fail", `example ${i + 1} has no path`, "Add path: to the example."));
92
+ else if (!/^https?:\/\//.test(p) && !exists(here(p))) out.push(f(6, "example-missing", "fail", `example "${p}" does not exist`, "Fix the path, or add the example file."));
93
+ if (!str(e?.why)) out.push(f(6, "example-why", "fail", `example ${p || i + 1} does not say why it is an example`, "Add why: what it shows that an adjective could not."));
94
+ });
95
+
96
+ // 7
97
+ const next = str(d.resume?.next_action);
98
+ if (!next) out.push(f(7, "next-action", "fail", "no resume.next_action", "Write the single concrete step that starts the next session."));
99
+ else if (NO_ACTION.test(next)) out.push(f(7, "next-action-vague", "fail", `next action "${next}" names no action`, "Name the concrete step."));
100
+ else if (CONVERSATION.test(next)) out.push(f(7, "next-action-vague", "fail", `next action "${next}" points into a conversation the next reader cannot see`, "State the step itself."));
101
+ if (CONVERSATION.test(prose(spec.body))) out.push(f(7, "conversation-pointer", "warn", "the body points into a conversation the next reader cannot see", "State the thing itself."));
102
+
103
+ // 8
104
+ if (!str(d.feedback?.issues)) out.push(f(8, "feedback-issues", "fail", "no feedback.issues", "Say where adopters file issues and pull requests."));
105
+ if (!str(d.feedback?.fork)) out.push(f(8, "feedback-fork", "fail", "no feedback.fork", "Say whether and how it may be forked."));
106
+
107
+ // 9
108
+ const led = str(d.improvement?.ledger);
109
+ if (!led) out.push(f(9, "ledger", "fail", "no improvement.ledger", "Name the file each run writes its verdict to."));
110
+ else {
111
+ // A declared ledger not yet written is fine. One that exists must be a readable file.
112
+ let st = null;
113
+ try { st = statSync(here(led)); } catch { /* not written yet */ }
114
+ let text = null;
115
+ if (st && !st.isFile()) out.push(f(9, "ledger-not-file", "fail", `ledger path ${led} is not a file`, "Point improvement.ledger at a file, one JSON object per line."));
116
+ else if (st) {
117
+ try { text = readFileSync(here(led), "utf8"); } catch (e) { out.push(f(9, "ledger-unreadable", "fail", `ledger ${led} cannot be read (${e.code || e.message})`, "Make the ledger file readable.")); }
118
+ }
119
+ (text ?? "").split("\n").filter((l) => l.trim()).forEach((line, i) => {
120
+ let v; try { v = JSON.parse(line); } catch { v = undefined; }
121
+ if (!v || typeof v !== "object" || Array.isArray(v)) { out.push(f(9, "ledger-line", "fail", `${led} line ${i + 1} is not a JSON object`, "One JSON object per line.")); return; }
122
+ if (!["one-shot", "improved", "not-improved"].includes(v.verdict)) out.push(f(9, "verdict", "fail", `${led} line ${i + 1}: verdict "${v.verdict}"`, "Use one-shot, improved or not-improved."));
123
+ if (v.verdict === "improved" && !str(v.change)) out.push(f(9, "verdict-change", "fail", `${led} line ${i + 1}: improved, but no change named`, "Say what changed."));
124
+ if (v.verdict === "not-improved" && !str(v.reason)) out.push(f(9, "verdict-reason", "fail", `${led} line ${i + 1}: not improved, and no reason`, "Say why nothing changed."));
125
+ });
126
+ }
127
+ return out;
128
+ }
package/src/score.mjs ADDED
@@ -0,0 +1,12 @@
1
+ import { TESTS } from "./rules.mjs";
2
+
3
+ export function score(findings, data = {}) {
4
+ const failed = new Set(findings.filter((x) => x.severity === "fail").map((x) => x.test));
5
+ const tests = TESTS.map((t) => ({ n: t.n, name: t.name, pass: !failed.has(t.n) }));
6
+ const open = (Array.isArray(data.decisions) ? data.decisions : [])
7
+ .filter((x) => String(x?.state || "").trim() === "open").map((x) => String(x.id || "").trim());
8
+ const status = failed.size ? "fail" : open.length ? "blocked" : "pass";
9
+ return { tests, passed: tests.filter((t) => t.pass).length, open, status };
10
+ }
11
+
12
+ export const exitCode = (status) => ({ pass: 0, fail: 1, blocked: 3 })[status] ?? 2;
@@ -0,0 +1,27 @@
1
+ export function template({ title = "Untitled", kind = "document" } = {}) {
2
+ return `---
3
+ hyperspec: "0.1"
4
+ title: ${title}
5
+ kind: ${kind}
6
+ decisions:
7
+ - id: audience
8
+ state: open
9
+ question: who reads this, and where are they on their path before they read it?
10
+ source: hyperspec init
11
+ author: agent:hyperspec-init
12
+ chosen_by: agent
13
+ requirements: []
14
+ rejects: []
15
+ examples: []
16
+ resume:
17
+ next_action: answer the open decisions, then list the requirements with fails_when and a check for each
18
+ feedback:
19
+ issues: ""
20
+ fork: ""
21
+ improvement:
22
+ ledger: runs.jsonl
23
+ ---
24
+
25
+ # ${title}
26
+ `;
27
+ }