@supersuit/hyperspec 0.3.0 → 0.4.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 +59 -0
- package/README.md +22 -1
- package/SPEC.md +5 -3
- package/WRITING.md +203 -32
- package/bin/hyperspec.mjs +50 -2
- package/examples/writing/essay/materials/interview-notes.md.segments.jsonl +10 -0
- package/examples/writing/essay/materials/team-survey.md.segments.jsonl +6 -0
- package/examples/writing/essay/materials/voice-memo.md.segments.jsonl +7 -0
- package/examples/writing/essay.hyperspec.md +9 -6
- package/examples/writing/story/materials/bakery-visit.md +1 -0
- package/examples/writing/story/materials/bakery-visit.md.segments.jsonl +13 -0
- package/examples/writing/story/materials/notes.md +2 -0
- package/examples/writing/story/materials/notes.md.segments.jsonl +7 -0
- package/examples/writing/story/materials/scene-list.md.segments.jsonl +14 -0
- package/examples/writing/story.hyperspec.md +8 -5
- package/package.json +2 -1
- package/src/blobs.mjs +1 -1
- package/src/compare.mjs +6 -6
- package/src/fsutil.mjs +1 -1
- package/src/labels.mjs +6 -0
- package/src/reproduce.mjs +5 -5
- package/src/segments.mjs +407 -0
- package/src/writing-exports.mjs +6 -0
- package/src/writing-fields.mjs +106 -5
- package/src/writing-template.mjs +8 -1
- package/src/writing.mjs +4 -4
|
@@ -74,7 +74,7 @@ examples:
|
|
|
74
74
|
- path: essay/goldens/opening.md
|
|
75
75
|
why: the claim lands in the first sentence, and the second sentence turns it into an instruction
|
|
76
76
|
resume:
|
|
77
|
-
next_action:
|
|
77
|
+
next_action: outline the four spine claims against the form's required parts, citing the segments each claim points at
|
|
78
78
|
feedback:
|
|
79
79
|
issues: https://github.com/SupersuitUp/hyperspec/issues
|
|
80
80
|
fork: MIT; fork it for your own purposes
|
|
@@ -85,24 +85,27 @@ writing:
|
|
|
85
85
|
items:
|
|
86
86
|
- id: voice-memo
|
|
87
87
|
path: essay/materials/voice-memo.md
|
|
88
|
+
segments: essay/materials/voice-memo.md.segments.jsonl
|
|
88
89
|
produced_by: example-author
|
|
89
90
|
captured: "2026-09-12"
|
|
90
91
|
how: voice memo, transcribed
|
|
91
92
|
trust: raw
|
|
92
93
|
- id: interview
|
|
93
94
|
path: essay/materials/interview-notes.md
|
|
95
|
+
segments: essay/materials/interview-notes.md.segments.jsonl
|
|
94
96
|
produced_by: example-author
|
|
95
97
|
captured: "2026-09-15"
|
|
96
98
|
how: notes taken during a call, reviewed by the person interviewed
|
|
97
99
|
trust: considered
|
|
98
100
|
- id: survey
|
|
99
101
|
path: essay/materials/team-survey.md
|
|
102
|
+
segments: essay/materials/team-survey.md.segments.jsonl
|
|
100
103
|
produced_by: example-author
|
|
101
104
|
captured: "2026-05-30"
|
|
102
105
|
how: survey summary, figures checked against the raw export by a second person
|
|
103
106
|
trust: verified
|
|
104
107
|
check:
|
|
105
|
-
station: every segment of every material carries a label from the closed set
|
|
108
|
+
station: every segment of every material carries a label from the closed set, matches its source verbatim, and the markings are current
|
|
106
109
|
source: capture step
|
|
107
110
|
author: agent:claude
|
|
108
111
|
dna:
|
|
@@ -186,16 +189,16 @@ writing:
|
|
|
186
189
|
claims:
|
|
187
190
|
- id: c1
|
|
188
191
|
text: the first one-on-one is the one meeting where the report should set the agenda
|
|
189
|
-
materials: [voice-memo, interview]
|
|
192
|
+
materials: [voice-memo#s3, interview#s3]
|
|
190
193
|
- id: c2
|
|
191
194
|
text: status belongs in the tracker, and a one-on-one spent on it teaches the manager nothing new
|
|
192
|
-
materials: [voice-memo, survey]
|
|
195
|
+
materials: [voice-memo#s2, voice-memo#s5, survey#s4]
|
|
193
196
|
- id: c3
|
|
194
197
|
text: three questions are enough to hand the meeting over
|
|
195
|
-
materials: [voice-memo]
|
|
198
|
+
materials: [voice-memo#s4]
|
|
196
199
|
- id: c4
|
|
197
200
|
text: the answer worth having comes after a silence the manager does not fill
|
|
198
|
-
materials: [interview]
|
|
201
|
+
materials: [interview#s7, interview#s8]
|
|
199
202
|
check:
|
|
200
203
|
rubric: each claim lands, in order, and the draft argues nothing outside the chain
|
|
201
204
|
source: spine interview
|
|
@@ -6,3 +6,4 @@ Considered: the owner read these notes and corrected the proofing times.
|
|
|
6
6
|
- The doors open to customers at 7:00. The first person in is usually a regular.
|
|
7
7
|
- The owner does not talk while shaping. Talking happens at the mixer and at the till.
|
|
8
8
|
- Flour is weighed, never scooped. Water temperature is checked every batch.
|
|
9
|
+
- Said in confidence, not for the story: the lease ends next spring, and she has not told her staff.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{"material":"bakery-visit","path":"story/materials/bakery-visit.md","sha256":"5eb09f7c0ad8836378c549392d939f0b070669e972c9e4d94e1f81f27465a19b"}
|
|
2
|
+
{"id":"s1","start":0,"end":90,"label":"aside","text":"Notes from a morning spent at a working bakery, 3:30 to 7:30, with the owner's permission."}
|
|
3
|
+
{"id":"s2","start":91,"end":163,"label":"aside","text":"Considered: the owner read these notes and corrected the proofing times."}
|
|
4
|
+
{"id":"s3","start":165,"end":185,"label":"claim","source":"a morning observed at a working bakery, notes corrected by the owner","text":"- First mix at 3:45."}
|
|
5
|
+
{"id":"s4","start":186,"end":255,"label":"claim","source":"a morning observed at a working bakery, notes corrected by the owner","text":"Rye sourdough proofs about three hours at room temperature in winter."}
|
|
6
|
+
{"id":"s5","start":256,"end":346,"label":"claim","source":"a morning observed at a working bakery, notes corrected by the owner","text":"- Trays are turned halfway through the bake because the back left of a deck oven runs hot."}
|
|
7
|
+
{"id":"s6","start":347,"end":385,"label":"claim","source":"a morning observed at a working bakery, notes corrected by the owner","text":"- The doors open to customers at 7:00."}
|
|
8
|
+
{"id":"s7","start":386,"end":427,"label":"claim","source":"a morning observed at a working bakery, notes corrected by the owner","text":"The first person in is usually a regular."}
|
|
9
|
+
{"id":"s8","start":428,"end":468,"label":"claim","source":"a morning observed at a working bakery, notes corrected by the owner","text":"- The owner does not talk while shaping."}
|
|
10
|
+
{"id":"s9","start":469,"end":514,"label":"claim","source":"a morning observed at a working bakery, notes corrected by the owner","text":"Talking happens at the mixer and at the till."}
|
|
11
|
+
{"id":"s10","start":515,"end":549,"label":"claim","source":"a morning observed at a working bakery, notes corrected by the owner","text":"- Flour is weighed, never scooped."}
|
|
12
|
+
{"id":"s11","start":550,"end":591,"label":"claim","source":"a morning observed at a working bakery, notes corrected by the owner","text":"Water temperature is checked every batch."}
|
|
13
|
+
{"id":"s12","start":592,"end":692,"label":"private","text":"- Said in confidence, not for the story: the lease ends next spring, and she has not told her staff."}
|
|
@@ -8,6 +8,8 @@ autumn. Each thinks they are protecting the other.
|
|
|
8
8
|
The story is four scenes, one morning, 3:40 to 7:00. The oven has a noise. The rye is the thing
|
|
9
9
|
she has never let him do alone.
|
|
10
10
|
|
|
11
|
+
Open question: does she tell him about the sale before she lets him shape the rye, or after?
|
|
12
|
+
|
|
11
13
|
What it is about, I think: a craft outlives the room it was practiced in. And: people who love
|
|
12
14
|
each other in a work setting say it through the work.
|
|
13
15
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{"material":"notes","path":"story/materials/notes.md","sha256":"d2af0977ca30dc1f666d219e3c5d2d0f063e91d9d07f14cf703f848ca260ada8"}
|
|
2
|
+
{"id":"s1","start":0,"end":68,"label":"aside","text":"Author's notes for the story, typed over two evenings. Raw thinking."}
|
|
3
|
+
{"id":"s2","start":70,"end":405,"label":"claim","own":true,"text":"A bakery on its last morning before the sale closes. Two people: the owner, who has run it for\nthirty-one years, and the apprentice she took on at sixteen, now nineteen. She has not told him\nit is sold. He has not told her he got into a baking school in another city and leaves in the\nautumn. Each thinks they are protecting the other."}
|
|
4
|
+
{"id":"s3","start":407,"end":534,"label":"claim","own":true,"text":"The story is four scenes, one morning, 3:40 to 7:00. The oven has a noise. The rye is the thing\nshe has never let him do alone."}
|
|
5
|
+
{"id":"s4","start":536,"end":628,"label":"question","text":"Open question: does she tell him about the sale before she lets him shape the rye, or after?"}
|
|
6
|
+
{"id":"s5","start":630,"end":778,"label":"stance","text":"What it is about, I think: a craft outlives the room it was practiced in. And: people who love\neach other in a work setting say it through the work."}
|
|
7
|
+
{"id":"s6","start":780,"end":838,"label":"stance","text":"The last line should be about bread, never about feelings."}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{"material":"scene-list","path":"story/materials/scene-list.md","sha256":"8181cdb35cd1afad01809367de15b3233f24fc7bede5b7b89c022f05e3d655e0"}
|
|
2
|
+
{"id":"s1","start":0,"end":51,"label":"aside","text":"Scene list, agreed with the editor before drafting."}
|
|
3
|
+
{"id":"s2","start":52,"end":63,"label":"aside","text":"Considered."}
|
|
4
|
+
{"id":"s3","start":65,"end":95,"label":"claim","own":true,"text":"- scene-1, 3:40: Theo arrives."}
|
|
5
|
+
{"id":"s4","start":96,"end":119,"label":"claim","own":true,"text":"Ines is already mixing."}
|
|
6
|
+
{"id":"s5","start":120,"end":135,"label":"claim","own":true,"text":"The oven noise."}
|
|
7
|
+
{"id":"s6","start":136,"end":162,"label":"claim","own":true,"text":"No one says anything real."}
|
|
8
|
+
{"id":"s7","start":163,"end":188,"label":"claim","own":true,"text":"- scene-2, 4:30: shaping."}
|
|
9
|
+
{"id":"s8","start":189,"end":259,"label":"claim","own":true,"text":"Ines lets Theo shape the rye for the first time, and does not say why."}
|
|
10
|
+
{"id":"s9","start":260,"end":286,"label":"claim","own":true,"text":"- scene-3, 5:50: the bake."}
|
|
11
|
+
{"id":"s10","start":287,"end":344,"label":"claim","own":true,"text":"Ines tells Theo the bakery is sold, while turning a tray."}
|
|
12
|
+
{"id":"s11","start":345,"end":384,"label":"claim","own":true,"text":"- scene-4, 6:55: before the doors open."}
|
|
13
|
+
{"id":"s12","start":385,"end":418,"label":"claim","own":true,"text":"Theo tells Ines about the school."}
|
|
14
|
+
{"id":"s13","start":419,"end":451,"label":"claim","own":true,"text":"She hands him the\n starter jar."}
|
|
@@ -74,7 +74,7 @@ examples:
|
|
|
74
74
|
- path: story/goldens/dialogue.md
|
|
75
75
|
why: three short lines carry a ritual both characters know, without either of them naming it
|
|
76
76
|
resume:
|
|
77
|
-
next_action:
|
|
77
|
+
next_action: draft scene-1 from the scene list with Theo's knowledge as of scene-1
|
|
78
78
|
feedback:
|
|
79
79
|
issues: https://github.com/SupersuitUp/hyperspec/issues
|
|
80
80
|
fork: MIT; fork it for your own purposes
|
|
@@ -85,24 +85,27 @@ writing:
|
|
|
85
85
|
items:
|
|
86
86
|
- id: notes
|
|
87
87
|
path: story/materials/notes.md
|
|
88
|
+
segments: story/materials/notes.md.segments.jsonl
|
|
88
89
|
produced_by: example-author
|
|
89
90
|
captured: "2026-08-20"
|
|
90
91
|
how: typed notes
|
|
91
92
|
trust: raw
|
|
92
93
|
- id: bakery-visit
|
|
93
94
|
path: story/materials/bakery-visit.md
|
|
95
|
+
segments: story/materials/bakery-visit.md.segments.jsonl
|
|
94
96
|
produced_by: example-author
|
|
95
97
|
captured: "2026-08-28"
|
|
96
98
|
how: notes taken on site, corrected by the bakery owner afterwards
|
|
97
99
|
trust: considered
|
|
98
100
|
- id: scene-list
|
|
99
101
|
path: story/materials/scene-list.md
|
|
102
|
+
segments: story/materials/scene-list.md.segments.jsonl
|
|
100
103
|
produced_by: example-author
|
|
101
104
|
captured: "2026-09-02"
|
|
102
105
|
how: scene list agreed with the editor
|
|
103
106
|
trust: considered
|
|
104
107
|
check:
|
|
105
|
-
station: every segment of every material carries a label from the closed set
|
|
108
|
+
station: every segment of every material carries a label from the closed set, matches its source verbatim, and the markings are current
|
|
106
109
|
source: capture step
|
|
107
110
|
author: agent:claude
|
|
108
111
|
dna:
|
|
@@ -184,13 +187,13 @@ writing:
|
|
|
184
187
|
claims:
|
|
185
188
|
- id: c1
|
|
186
189
|
text: each of them hides their news to protect the other, and the hiding is the thing they share
|
|
187
|
-
materials: [notes, scene-list]
|
|
190
|
+
materials: [notes#s2, scene-list#s10, scene-list#s12]
|
|
188
191
|
- id: c2
|
|
189
192
|
text: people who love each other at work say it through the work
|
|
190
|
-
materials: [notes, bakery-visit]
|
|
193
|
+
materials: [notes#s5, bakery-visit#s8, scene-list#s8]
|
|
191
194
|
- id: c3
|
|
192
195
|
text: a craft outlives the room it was practiced in
|
|
193
|
-
materials: [notes, scene-list]
|
|
196
|
+
materials: [notes#s5, scene-list#s13]
|
|
194
197
|
check:
|
|
195
198
|
rubric: each claim lands, in order, through what the characters do, and the story argues nothing outside the chain
|
|
196
199
|
source: spine interview
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/hyperspec",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A hyperspec is a spec written for an agent: every decision accounted for, every requirement failable and checked, every field traced. The standard and its linter.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"exports": {
|
|
10
10
|
"./recipe": "./src/writer.mjs",
|
|
11
|
+
"./writing": "./src/writing-exports.mjs",
|
|
11
12
|
"./package.json": "./package.json"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
package/src/blobs.mjs
CHANGED
|
@@ -41,7 +41,7 @@ export function blobPath(root, hex) {
|
|
|
41
41
|
// Atomic: bytes land in a temp file in the same directory first, then a single renameSync
|
|
42
42
|
// (atomic on one filesystem) puts them at the final path. A process killed mid-write leaves
|
|
43
43
|
// only the orphaned temp file, never a partially-written file sitting at the content-addressed
|
|
44
|
-
// path
|
|
44
|
+
// path. That is the failure mode a plain writeFileSync(path, bytes) would otherwise leave behind, and
|
|
45
45
|
// which nothing short of an explicit verifyBlob would ever catch afterward.
|
|
46
46
|
export function putBlob(root, bytes) {
|
|
47
47
|
const hex = sha256(bytes);
|
package/src/compare.mjs
CHANGED
|
@@ -66,7 +66,7 @@ export function compare(childRecipePath, { doctor, parent: parentOption, spec: s
|
|
|
66
66
|
const specHash = sha256(readFileSync(specAbs));
|
|
67
67
|
// specChanged is about the PARENT's record, not the child's: it tells the caller the spec being
|
|
68
68
|
// graded against now differs from what the parent was made under. Both outputs still get graded
|
|
69
|
-
// against this one spec file either way
|
|
69
|
+
// against this one spec file either way, never each against its own.
|
|
70
70
|
const specChanged = parentRecipe.spec?.sha256 !== specHash;
|
|
71
71
|
if (specChanged) warnings.push("spec changed since the parent was made; both outputs graded against the current file");
|
|
72
72
|
|
|
@@ -114,14 +114,14 @@ export function compare(childRecipePath, { doctor, parent: parentOption, spec: s
|
|
|
114
114
|
const ledgerAbs = resolve(specDir, ledgerDecl);
|
|
115
115
|
const ledgerDir = dirname(ledgerAbs);
|
|
116
116
|
// The child's own `change` is null whenever it has no genealogical parent of its own (a root
|
|
117
|
-
// recipe compared against an explicit, unrelated --parent
|
|
117
|
+
// recipe compared against an explicit, unrelated --parent; compare's usage check allows
|
|
118
118
|
// this: it only requires *either* the child's recorded parent.path *or* an explicit
|
|
119
119
|
// override). Fall back to naming what was actually compared against, so `change` and the
|
|
120
|
-
// not-improved `reason` below are never "after null"
|
|
120
|
+
// not-improved `reason` below are never "after null", which would be both a broken persisted record and,
|
|
121
121
|
// for the "improved" verdict, a lint failure (rules.mjs's verdict-change: `!str(v.change)`).
|
|
122
122
|
const childChange = child.change ?? `compared against ${relative(ledgerDir, parentRecipeAbs)}`;
|
|
123
123
|
// A compare line must satisfy test 9 ("it improves itself"), which only knows the
|
|
124
|
-
// verdict vocabulary one-shot/improved/not-improved
|
|
124
|
+
// verdict vocabulary one-shot/improved/not-improved, never a new "compare" verdict. A
|
|
125
125
|
// strictly higher child score is improved (and already carries change, which doubles as
|
|
126
126
|
// that verdict's required field). Equal or lower is not-improved, with a reason a later
|
|
127
127
|
// session can argue with; when it's a genuine regression (strictly lower, not merely tied)
|
|
@@ -162,8 +162,8 @@ export function compare(childRecipePath, { doctor, parent: parentOption, spec: s
|
|
|
162
162
|
|
|
163
163
|
// Runs the doctor command once, via /bin/sh -c, over one output against the one spec. stdin is
|
|
164
164
|
// { output, spec } (absolute paths); the last non-empty stdout line must be JSON with a numeric
|
|
165
|
-
// score. Nothing recipe-derived is ever interpolated into the command string
|
|
166
|
-
// stdin
|
|
165
|
+
// score. Nothing recipe-derived is ever interpolated into the command string; it is only passed on
|
|
166
|
+
// stdin, so the same command string is reused verbatim for the parent and the child.
|
|
167
167
|
function runDoctor(doctorCmd, outputAbs, specAbs) {
|
|
168
168
|
const res = spawnSync("/bin/sh", ["-c", doctorCmd], {
|
|
169
169
|
input: JSON.stringify({ output: outputAbs, spec: specAbs }),
|
package/src/fsutil.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { relative, resolve, sep } from "node:path";
|
|
4
4
|
|
|
5
|
-
// True when `target`, resolved against `dir`, stays inside `dir
|
|
5
|
+
// True when `target`, resolved against `dir`, stays inside `dir`. It refuses a `..`-escape and an
|
|
6
6
|
// absolute path pointing elsewhere. Both a relative `target` (including one that climbs out via
|
|
7
7
|
// `../`) and an already-absolute `target` are handled the same way, since `path.resolve(dir,
|
|
8
8
|
// target)` already treats an absolute second argument as overriding the first: either way, the
|
package/src/labels.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The closed vocabulary every segment of a marked material is labeled from (hyperspec 0.4). It lives in
|
|
2
|
+
// this leaf module, which imports nothing, so src/segments.mjs can read it without importing
|
|
3
|
+
// src/writing.mjs: writing.mjs imports writing-fields.mjs, which imports segments.mjs, and reading
|
|
4
|
+
// the labels through writing.mjs made that a cycle. writing.mjs re-exports it for callers that
|
|
5
|
+
// already import it from there.
|
|
6
|
+
export const MATERIAL_LABELS = Object.freeze(["claim", "story", "quote", "stance", "question", "aside", "private"]);
|
package/src/reproduce.mjs
CHANGED
|
@@ -11,7 +11,7 @@ const MALFORMED = "recorded hash is not a SHA-256 hash (64 lowercase hex charact
|
|
|
11
11
|
const blobWhy = (hex, why) => (present(hex) && !isSha256(hex) ? MALFORMED : why);
|
|
12
12
|
|
|
13
13
|
// Spec requirement `reproduce`: replay the record and hash-check it. Never invokes a model,
|
|
14
|
-
// never runs a command, never regenerates a byte of content
|
|
14
|
+
// never runs a command, never regenerates a byte of content. Every blob this looks at already
|
|
15
15
|
// exists in the store, and this only confirms the recipe's own claims about it still hold.
|
|
16
16
|
//
|
|
17
17
|
// Checks, in order (and every one is reported, nothing stops the walk early): every input blob
|
|
@@ -98,8 +98,8 @@ export function reproduce(recipePath, { store, restore = false } = {}) {
|
|
|
98
98
|
// 5. The output file on disk, if present, hashes to output.sha256. Absent is vacuously fine:
|
|
99
99
|
// reproduce doesn't require the file to already exist, only that it agrees when it does.
|
|
100
100
|
//
|
|
101
|
-
// The recipe is a plain JSON file on disk
|
|
102
|
-
// distrust
|
|
101
|
+
// The recipe is a plain JSON file on disk, exactly the kind of claim reproduce exists to
|
|
102
|
+
// distrust, so output.path is never trusted blind. A hand-edited or corrupted recipe could
|
|
103
103
|
// name a path outside the recipe's own directory (a `../` climb, or an absolute path); refuse
|
|
104
104
|
// before touching disk at all, rather than reading from or (worse, under --restore) writing to
|
|
105
105
|
// wherever it points.
|
|
@@ -120,12 +120,12 @@ export function reproduce(recipePath, { store, restore = false } = {}) {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
// restore: true writes the output file from its blob, but only when the output blob
|
|
123
|
-
// itself verified (step 4)
|
|
123
|
+
// itself verified (step 4); restoring from an unverified blob would just write
|
|
124
124
|
// different wrong bytes. Covers both a lost file (never existed / deleted) and an
|
|
125
125
|
// edited one. The write is atomic (temp file + rename, same pattern as putBlob), so a
|
|
126
126
|
// crash mid-write never leaves the file in a state that is neither the old nor the new
|
|
127
127
|
// content, and a write failure is caught and reported as a failing step rather than
|
|
128
|
-
// thrown out of reproduce()
|
|
128
|
+
// thrown out of reproduce(): this function always returns a structured result.
|
|
129
129
|
const needsRestore = !fileExists || !matches;
|
|
130
130
|
if (restore && needsRestore && outputBlobOk) {
|
|
131
131
|
const blob = getBlob(root, outputSha);
|