@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 +42 -0
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/SPEC.md +190 -0
- package/bin/hyperspec.mjs +61 -0
- package/examples/goldens/opening.md +1 -0
- package/examples/minimal.hyperspec.md +39 -0
- package/examples/runs.jsonl +3 -0
- package/package.json +39 -0
- package/src/load.mjs +13 -0
- package/src/rules.mjs +128 -0
- package/src/score.mjs +12 -0
- package/src/template.mjs +27 -0
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
|
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;
|
package/src/template.mjs
ADDED
|
@@ -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
|
+
}
|