@supersuit/hyperspec 0.1.0 → 0.2.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/README.md +74 -2
- package/SPEC.md +190 -10
- package/bin/hyperspec.mjs +277 -0
- package/examples/recipe/doctor.mjs +11 -0
- package/examples/recipe/essay.hyperspec.md +50 -0
- package/examples/recipe/factory.mjs +29 -0
- package/examples/recipe/materials/call-2.md +2 -0
- package/examples/recipe/materials/call.md +3 -0
- package/examples/recipe/materials/notes.md +3 -0
- package/examples/recipe/runner.mjs +20 -0
- package/examples/recipe/runs.jsonl +0 -0
- package/examples/recipe/stages.mjs +31 -0
- package/package.json +6 -1
- package/runs.jsonl +0 -0
- package/src/blobs.mjs +77 -0
- package/src/compare.mjs +189 -0
- package/src/fsutil.mjs +33 -0
- package/src/hash.mjs +26 -0
- package/src/recipe.mjs +164 -0
- package/src/regenerate.mjs +318 -0
- package/src/reproduce.mjs +156 -0
- package/src/rules.mjs +33 -6
- package/src/template.mjs +12 -3
- package/src/writer.mjs +125 -0
package/bin/hyperspec.mjs
CHANGED
|
@@ -4,6 +4,11 @@ import { loadSpec } from "../src/load.mjs";
|
|
|
4
4
|
import { lintSpec } from "../src/rules.mjs";
|
|
5
5
|
import { score, exitCode } from "../src/score.mjs";
|
|
6
6
|
import { template } from "../src/template.mjs";
|
|
7
|
+
import { readRecipe, checkRecipe } from "../src/recipe.mjs";
|
|
8
|
+
import { approve } from "../src/writer.mjs";
|
|
9
|
+
import { reproduce } from "../src/reproduce.mjs";
|
|
10
|
+
import { regenerate } from "../src/regenerate.mjs";
|
|
11
|
+
import { compare } from "../src/compare.mjs";
|
|
7
12
|
|
|
8
13
|
const HELP = `hyperspec <command> [options]
|
|
9
14
|
|
|
@@ -11,6 +16,29 @@ const HELP = `hyperspec <command> [options]
|
|
|
11
16
|
exit 0 pass, 1 a test fails, 3 blocked on an open decision, 2 usage
|
|
12
17
|
init <file> [--title T] [--kind K] write a new hyperspec skeleton (refuses to overwrite)
|
|
13
18
|
|
|
19
|
+
recipe check <output-or-recipe> [--json]
|
|
20
|
+
check a recipe's completeness (a path not ending .recipe.json
|
|
21
|
+
means <path>.recipe.json)
|
|
22
|
+
exit 0 ok (warns only), 1 no recipe found or a check failed,
|
|
23
|
+
2 unreadable/invalid recipe JSON
|
|
24
|
+
recipe approve <recipe> --by <slug> [--json]
|
|
25
|
+
set the recipe's approver, print remaining findings
|
|
26
|
+
exit 0 once --by and the recipe are valid, 2 missing --by or
|
|
27
|
+
unreadable recipe
|
|
28
|
+
reproduce <recipe> [--restore] [--store d] [--json]
|
|
29
|
+
replay a recipe's hash checks against the blob store; never
|
|
30
|
+
runs a model
|
|
31
|
+
exit 0 reproduces cleanly, 1 a check failed, 2 unreadable recipe
|
|
32
|
+
regenerate <recipe> --out <path> --clicker <slug>
|
|
33
|
+
(--add-input n=p [--reads id]... | --swap-input n=p | --factory-version v)
|
|
34
|
+
[--run cmd] [--change text] [--store d] [--json]
|
|
35
|
+
make a child recipe from a parent plus exactly one change
|
|
36
|
+
exit 0 ok, 1 a stage failed or the child has failing verdicts,
|
|
37
|
+
2 usage, 3 pending a runner
|
|
38
|
+
compare <child-recipe> --doctor cmd [--parent r] [--spec s] [--json]
|
|
39
|
+
grade a child and its parent through one doctor against one spec
|
|
40
|
+
exit 0 not regressed, 1 regressed, 2 usage or unreadable input
|
|
41
|
+
|
|
14
42
|
Spec: SPEC.md`;
|
|
15
43
|
|
|
16
44
|
const argv = process.argv.slice(2);
|
|
@@ -57,5 +85,254 @@ if (cmd === "lint") {
|
|
|
57
85
|
process.exit(worst);
|
|
58
86
|
}
|
|
59
87
|
|
|
88
|
+
// Generic flag/positional parser for the recipe verbs below. A value-taking flag (valueFlags,
|
|
89
|
+
// repeatableFlags) never swallows a following --flag as its value (missing value is an error, not
|
|
90
|
+
// a silent grab); a bool flag never eats the next token as a positional; any --flag not declared
|
|
91
|
+
// for this verb is an error. This is the same discipline lint's own hand-rolled parser follows
|
|
92
|
+
// above (a stray flag never swallows a file), generalized once repeated-flag verbs (regenerate's
|
|
93
|
+
// --reads) and value flags (--out, --by, --doctor, ...) showed up.
|
|
94
|
+
function parseArgs(args, { valueFlags = [], boolFlags = [], repeatableFlags = [] } = {}) {
|
|
95
|
+
const positionals = [];
|
|
96
|
+
const values = {};
|
|
97
|
+
for (const f of boolFlags) values[f] = false;
|
|
98
|
+
for (const f of repeatableFlags) values[f] = [];
|
|
99
|
+
let i = 0;
|
|
100
|
+
while (i < args.length) {
|
|
101
|
+
const a = args[i];
|
|
102
|
+
if (a.startsWith("--")) {
|
|
103
|
+
if (repeatableFlags.includes(a)) {
|
|
104
|
+
const v = args[i + 1];
|
|
105
|
+
if (v === undefined || v.startsWith("--")) return { error: `${a} needs a value` };
|
|
106
|
+
values[a].push(v);
|
|
107
|
+
i += 2;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (valueFlags.includes(a)) {
|
|
111
|
+
const v = args[i + 1];
|
|
112
|
+
if (v === undefined || v.startsWith("--")) return { error: `${a} needs a value` };
|
|
113
|
+
values[a] = v;
|
|
114
|
+
i += 2;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (boolFlags.includes(a)) { values[a] = true; i += 1; continue; }
|
|
118
|
+
return { error: `unknown flag: ${a}` };
|
|
119
|
+
}
|
|
120
|
+
positionals.push(a);
|
|
121
|
+
i += 1;
|
|
122
|
+
}
|
|
123
|
+
return { positionals, values };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// name=path, split on the FIRST =, so a path that itself contains = survives intact. A value with
|
|
127
|
+
// no = is a usage error (exit 2).
|
|
128
|
+
function namePath(flagName, raw) {
|
|
129
|
+
const eq = raw.indexOf("=");
|
|
130
|
+
if (eq === -1) { console.error(`${flagName} needs name=path`); process.exit(2); }
|
|
131
|
+
return { name: raw.slice(0, eq), path: raw.slice(eq + 1) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function printFindings(findings) {
|
|
135
|
+
if (!findings.length) { console.log("ok"); return; }
|
|
136
|
+
for (const f of findings) console.log(`${f.severity === "fail" ? "fail" : "warn"} [${f.field}] ${f.message}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function printPlan(plan) {
|
|
140
|
+
for (const p of plan ?? []) console.log(` ${p.id}: ${p.action}${p.reason ? ` (${p.reason})` : ""}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (cmd === "recipe") {
|
|
144
|
+
const sub = argv[1];
|
|
145
|
+
|
|
146
|
+
if (sub === "check") {
|
|
147
|
+
const parsed = parseArgs(argv.slice(2), { boolFlags: ["--json"] });
|
|
148
|
+
if (parsed.error) { console.error(parsed.error); process.exit(2); }
|
|
149
|
+
const [target] = parsed.positionals;
|
|
150
|
+
const json = parsed.values["--json"];
|
|
151
|
+
if (!target) { console.error("recipe check needs a recipe or output path"); process.exit(2); }
|
|
152
|
+
const recipePath = target.endsWith(".recipe.json") ? target : `${target}.recipe.json`;
|
|
153
|
+
|
|
154
|
+
if (!existsSync(recipePath)) {
|
|
155
|
+
const msg = `no recipe beside ${target}`;
|
|
156
|
+
if (json) console.log(JSON.stringify({ recipe: recipePath, error: msg }, null, 2));
|
|
157
|
+
else console.error(msg);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const loaded = readRecipe(recipePath);
|
|
162
|
+
if (loaded.error) {
|
|
163
|
+
if (json) console.log(JSON.stringify({ recipe: recipePath, error: loaded.error }, null, 2));
|
|
164
|
+
else console.error(loaded.error);
|
|
165
|
+
process.exit(2);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const findings = checkRecipe(loaded.data);
|
|
169
|
+
const hasFail = findings.some((f) => f.severity === "fail");
|
|
170
|
+
if (json) console.log(JSON.stringify({ recipe: recipePath, findings }, null, 2));
|
|
171
|
+
else { console.log(`${recipePath}:`); printFindings(findings); }
|
|
172
|
+
process.exit(hasFail ? 1 : 0);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (sub === "approve") {
|
|
176
|
+
const parsed = parseArgs(argv.slice(2), { valueFlags: ["--by"], boolFlags: ["--json"] });
|
|
177
|
+
if (parsed.error) { console.error(parsed.error); process.exit(2); }
|
|
178
|
+
const [recipePath] = parsed.positionals;
|
|
179
|
+
const json = parsed.values["--json"];
|
|
180
|
+
if (!recipePath) { console.error("recipe approve needs a recipe path"); process.exit(2); }
|
|
181
|
+
if (!parsed.values["--by"]) { console.error("recipe approve needs --by <slug>"); process.exit(2); }
|
|
182
|
+
|
|
183
|
+
let findings;
|
|
184
|
+
try {
|
|
185
|
+
findings = approve(recipePath, parsed.values["--by"]);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
if (json) console.log(JSON.stringify({ recipe: recipePath, error: e.message }, null, 2));
|
|
188
|
+
else console.error(e.message);
|
|
189
|
+
process.exit(2);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (json) console.log(JSON.stringify({ recipe: recipePath, approver: parsed.values["--by"], findings }, null, 2));
|
|
193
|
+
else { console.log(`${recipePath}: approver set to ${parsed.values["--by"]}`); printFindings(findings); }
|
|
194
|
+
process.exit(0);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
console.error(`unknown recipe subcommand: ${sub}\n\n${HELP}`);
|
|
198
|
+
process.exit(2);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (cmd === "reproduce") {
|
|
202
|
+
const parsed = parseArgs(argv.slice(1), { valueFlags: ["--store"], boolFlags: ["--restore", "--json"] });
|
|
203
|
+
if (parsed.error) { console.error(parsed.error); process.exit(2); }
|
|
204
|
+
const [recipePath] = parsed.positionals;
|
|
205
|
+
const json = parsed.values["--json"];
|
|
206
|
+
if (!recipePath) { console.error("reproduce needs a recipe path"); process.exit(2); }
|
|
207
|
+
|
|
208
|
+
const result = reproduce(recipePath, { store: parsed.values["--store"], restore: parsed.values["--restore"] });
|
|
209
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
210
|
+
|
|
211
|
+
if (result.error) {
|
|
212
|
+
if (!json) console.error(result.error);
|
|
213
|
+
process.exit(2);
|
|
214
|
+
}
|
|
215
|
+
if (!result.ok) {
|
|
216
|
+
if (!json) {
|
|
217
|
+
console.error(`first mismatch: ${result.firstMismatch}`);
|
|
218
|
+
for (const step of result.steps) console.log(` ${step.ok ? "ok " : "FAIL"} ${step.ref}${step.why ? `: ${step.why}` : ""}`);
|
|
219
|
+
if (result.restored) console.log("restored the output file from its blob");
|
|
220
|
+
}
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
if (!json) {
|
|
224
|
+
for (const step of result.steps) console.log(` ok ${step.ref}`);
|
|
225
|
+
if (result.restored) console.log("restored the output file from its blob");
|
|
226
|
+
console.log("reproduces cleanly");
|
|
227
|
+
}
|
|
228
|
+
process.exit(0);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (cmd === "regenerate") {
|
|
232
|
+
const parsed = parseArgs(argv.slice(1), {
|
|
233
|
+
valueFlags: ["--out", "--clicker", "--add-input", "--swap-input", "--factory-version", "--run", "--change", "--store"],
|
|
234
|
+
boolFlags: ["--json"],
|
|
235
|
+
repeatableFlags: ["--reads"],
|
|
236
|
+
});
|
|
237
|
+
if (parsed.error) { console.error(parsed.error); process.exit(2); }
|
|
238
|
+
const [recipePath] = parsed.positionals;
|
|
239
|
+
const json = parsed.values["--json"];
|
|
240
|
+
if (!recipePath) { console.error("regenerate needs a parent recipe path"); process.exit(2); }
|
|
241
|
+
if (parsed.values["--reads"].length && parsed.values["--add-input"] === undefined) {
|
|
242
|
+
console.error("--reads is only valid with --add-input");
|
|
243
|
+
process.exit(2);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const change = {};
|
|
247
|
+
if (parsed.values["--add-input"] !== undefined) {
|
|
248
|
+
const pair = namePath("--add-input", parsed.values["--add-input"]);
|
|
249
|
+
change.addInput = { ...pair, reads: parsed.values["--reads"] };
|
|
250
|
+
}
|
|
251
|
+
if (parsed.values["--swap-input"] !== undefined) {
|
|
252
|
+
change.swapInput = namePath("--swap-input", parsed.values["--swap-input"]);
|
|
253
|
+
}
|
|
254
|
+
if (parsed.values["--factory-version"] !== undefined) change.factoryVersion = parsed.values["--factory-version"];
|
|
255
|
+
|
|
256
|
+
const result = regenerate(recipePath, {
|
|
257
|
+
out: parsed.values["--out"],
|
|
258
|
+
clicker: parsed.values["--clicker"],
|
|
259
|
+
change,
|
|
260
|
+
run: parsed.values["--run"],
|
|
261
|
+
changeText: parsed.values["--change"],
|
|
262
|
+
store: parsed.values["--store"],
|
|
263
|
+
});
|
|
264
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
265
|
+
|
|
266
|
+
if (result.usage) {
|
|
267
|
+
if (!json) console.error(result.error);
|
|
268
|
+
process.exit(2);
|
|
269
|
+
}
|
|
270
|
+
if (!result.ok) {
|
|
271
|
+
if (!json) {
|
|
272
|
+
console.error(result.failedStage ? `stage ${result.failedStage} failed: ${result.error}` : result.error);
|
|
273
|
+
if (result.plan?.length) printPlan(result.plan);
|
|
274
|
+
}
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
if (result.pending) {
|
|
278
|
+
if (!json) {
|
|
279
|
+
const waiting = result.plan.filter((p) => p.action === "rerun").map((p) => p.id);
|
|
280
|
+
console.log(`pending; stages waiting for a runner: ${waiting.join(", ") || "(none)"}`);
|
|
281
|
+
printPlan(result.plan);
|
|
282
|
+
console.log(`child recipe: ${result.childRecipe}`);
|
|
283
|
+
console.log("a pending child cannot be finished in place yet; to produce the output, rerun regenerate on the parent with --run <command> and a new --out");
|
|
284
|
+
}
|
|
285
|
+
process.exit(3);
|
|
286
|
+
}
|
|
287
|
+
if (result.failedVerdicts?.length) {
|
|
288
|
+
if (!json) {
|
|
289
|
+
console.log(`child written despite failing verdict(s): ${result.failedVerdicts.join(", ")}`);
|
|
290
|
+
printPlan(result.plan);
|
|
291
|
+
console.log(`child recipe: ${result.childRecipe}`);
|
|
292
|
+
console.log(`output: ${result.output}`);
|
|
293
|
+
}
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
if (!json) {
|
|
297
|
+
printPlan(result.plan);
|
|
298
|
+
console.log(`child recipe: ${result.childRecipe}`);
|
|
299
|
+
console.log(`output: ${result.output}`);
|
|
300
|
+
}
|
|
301
|
+
process.exit(0);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (cmd === "compare") {
|
|
305
|
+
const parsed = parseArgs(argv.slice(1), { valueFlags: ["--doctor", "--parent", "--spec"], boolFlags: ["--json"] });
|
|
306
|
+
if (parsed.error) { console.error(parsed.error); process.exit(2); }
|
|
307
|
+
const [childRecipePath] = parsed.positionals;
|
|
308
|
+
const json = parsed.values["--json"];
|
|
309
|
+
if (!childRecipePath) { console.error("compare needs a child recipe path"); process.exit(2); }
|
|
310
|
+
|
|
311
|
+
const result = compare(childRecipePath, {
|
|
312
|
+
doctor: parsed.values["--doctor"],
|
|
313
|
+
parent: parsed.values["--parent"],
|
|
314
|
+
spec: parsed.values["--spec"],
|
|
315
|
+
});
|
|
316
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
317
|
+
|
|
318
|
+
// Both a usage error and a check-failed error (missing output, escaping path, ...) map to
|
|
319
|
+
// 2 here — compare's own ok:false without a usage flag is still "could not grade", i.e. an
|
|
320
|
+
// unreadable-input class failure, not a graded-but-worse-1 class one.
|
|
321
|
+
if (!result.ok) {
|
|
322
|
+
if (!json) console.error(result.error);
|
|
323
|
+
process.exit(2);
|
|
324
|
+
}
|
|
325
|
+
if (!json) {
|
|
326
|
+
for (const w of result.warnings) console.log(`warn: ${w}`);
|
|
327
|
+
if (result.ledger) console.log(`ledger: ${result.ledger}`);
|
|
328
|
+
}
|
|
329
|
+
if (result.regressed) {
|
|
330
|
+
if (!json) console.log(`regression: child scored ${result.child.score} vs parent ${result.parent.score}; suspect: ${result.suspect ?? "unknown"}`);
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
if (!json) console.log(`child scored ${result.child.score} vs parent ${result.parent.score} (delta ${result.delta})`);
|
|
334
|
+
process.exit(0);
|
|
335
|
+
}
|
|
336
|
+
|
|
60
337
|
console.error(`unknown command: ${cmd}\n\n${HELP}`);
|
|
61
338
|
process.exit(2);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The doctor `hyperspec compare --doctor "node doctor.mjs"` runs once on the parent's output and
|
|
2
|
+
// once on the child's, with the same spec. stdin: { output, spec }, both absolute paths. The last
|
|
3
|
+
// stdout line is { "score": <number>, "notes": "..." }. This one scores an essay by how many
|
|
4
|
+
// claims it carries.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
|
|
7
|
+
const { output } = JSON.parse(readFileSync(0, "utf8"));
|
|
8
|
+
const essay = readFileSync(output, "utf8");
|
|
9
|
+
const section = essay.split("## Claims")[1]?.split("## ")[0] ?? "";
|
|
10
|
+
const score = section.split("\n").filter((line) => line.startsWith("- ")).length;
|
|
11
|
+
console.log(JSON.stringify({ score, notes: `${score} claims` }));
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
hyperspec: "0.1"
|
|
3
|
+
title: What a recipe is for
|
|
4
|
+
kind: essay
|
|
5
|
+
decisions:
|
|
6
|
+
- id: audience
|
|
7
|
+
state: decided
|
|
8
|
+
value: someone who has never rerun a piece of work from its inputs
|
|
9
|
+
source: materials/notes.md
|
|
10
|
+
author: example-author
|
|
11
|
+
chosen_by: human
|
|
12
|
+
- id: shape
|
|
13
|
+
state: decided
|
|
14
|
+
value: the claims from the calls first, then the terms they lean on
|
|
15
|
+
source: stages.mjs
|
|
16
|
+
author: example-author
|
|
17
|
+
chosen_by: human
|
|
18
|
+
requirements:
|
|
19
|
+
- id: every-claim-spoken
|
|
20
|
+
text: every claim is a line someone said on a call
|
|
21
|
+
fails_when: the essay carries a claim that no call transcript contains
|
|
22
|
+
check:
|
|
23
|
+
station: stages.mjs claims() copies lines from the calls and never writes one
|
|
24
|
+
source: materials/call.md
|
|
25
|
+
author: example-author
|
|
26
|
+
- id: more-claims-score-higher
|
|
27
|
+
text: an essay built from more calls carries more claims
|
|
28
|
+
fails_when: compare scores an essay with an added call no higher than its parent
|
|
29
|
+
check:
|
|
30
|
+
rubric: doctor.mjs counts the bullets under the Claims heading
|
|
31
|
+
source: doctor.mjs
|
|
32
|
+
author: example-author
|
|
33
|
+
rejects:
|
|
34
|
+
- a claim nobody said on a call
|
|
35
|
+
examples:
|
|
36
|
+
- path: materials/call.md
|
|
37
|
+
why: the raw call every claim is copied from, word for word
|
|
38
|
+
resume:
|
|
39
|
+
next_action: add the next call with hyperspec regenerate --add-input, then compare the two essays
|
|
40
|
+
feedback:
|
|
41
|
+
issues: https://github.com/SupersuitUp/hyperspec/issues
|
|
42
|
+
fork: MIT; fork it for your own purposes
|
|
43
|
+
improvement:
|
|
44
|
+
ledger: runs.jsonl
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
# What a recipe is for
|
|
48
|
+
|
|
49
|
+
The example spec for `examples/recipe/`. The factory builds a short essay from call transcripts
|
|
50
|
+
and a set of notes, and writes a recipe beside it.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The example factory: two inputs, three stages, and a recipe written beside the output.
|
|
2
|
+
// Run it with `node factory.mjs`. It writes essay.md and essay.md.recipe.json next to itself.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { startRecipe } from "@supersuit/hyperspec/recipe";
|
|
6
|
+
import { claims, draft, terms, verdict } from "./stages.mjs";
|
|
7
|
+
|
|
8
|
+
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
|
|
9
|
+
const read = (p) => readFileSync(here(p), "utf8");
|
|
10
|
+
|
|
11
|
+
const recipe = startRecipe({
|
|
12
|
+
output: here("essay.md"),
|
|
13
|
+
factory: { name: "example-essay", version: "1.0.0" },
|
|
14
|
+
spec: here("essay.hyperspec.md"),
|
|
15
|
+
clicker: "you",
|
|
16
|
+
});
|
|
17
|
+
recipe.input("call", here("materials/call.md"));
|
|
18
|
+
recipe.input("notes", here("materials/notes.md"));
|
|
19
|
+
|
|
20
|
+
const c = claims([read("materials/call.md")]);
|
|
21
|
+
recipe.stage({ id: "claims", reads: ["input:call"], output: c, verdict: verdict("claims", c) });
|
|
22
|
+
const t = terms([read("materials/notes.md")]);
|
|
23
|
+
recipe.stage({ id: "terms", reads: ["input:notes"], output: t, verdict: verdict("terms", t) });
|
|
24
|
+
const d = draft(c, t);
|
|
25
|
+
recipe.stage({ id: "draft", reads: ["stage:claims", "stage:terms"], output: d, verdict: verdict("draft", d) });
|
|
26
|
+
|
|
27
|
+
const { findings } = recipe.finish();
|
|
28
|
+
console.log("wrote essay.md and essay.md.recipe.json");
|
|
29
|
+
for (const f of findings) console.log(`${f.severity} [${f.field}] ${f.message}`);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The runner `hyperspec regenerate --run "node runner.mjs"` calls once per stage it must rerun.
|
|
2
|
+
// stdin: { stage, reads: [{ ref, sha256, path }], model }. Each read is a temp file holding the
|
|
3
|
+
// exact bytes that ref resolved to. stdout: the stage's output, byte for byte. The last stderr
|
|
4
|
+
// line starting "VERDICT " is the stage's verdict. A runner that prints none fails the stage:
|
|
5
|
+
// silence is not a verdict.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { claims, draft, terms, verdict } from "./stages.mjs";
|
|
8
|
+
|
|
9
|
+
const job = JSON.parse(readFileSync(0, "utf8"));
|
|
10
|
+
const text = (ref) => readFileSync(job.reads.find((r) => r.ref === ref).path, "utf8");
|
|
11
|
+
const inputs = () => job.reads.filter((r) => r.ref.startsWith("input:")).map((r) => readFileSync(r.path, "utf8"));
|
|
12
|
+
|
|
13
|
+
let output;
|
|
14
|
+
if (job.stage === "claims") output = claims(inputs());
|
|
15
|
+
else if (job.stage === "terms") output = terms(inputs());
|
|
16
|
+
else if (job.stage === "draft") output = draft(text("stage:claims"), text("stage:terms"));
|
|
17
|
+
else { console.error(`runner.mjs does not know stage ${job.stage}`); process.exit(2); }
|
|
18
|
+
|
|
19
|
+
process.stdout.write(output);
|
|
20
|
+
console.error(`VERDICT ${JSON.stringify(verdict(job.stage, output))}`);
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The three stages of the example factory. Each is a plain text transform with no model in it,
|
|
2
|
+
// so the same inputs always give the same bytes. factory.mjs runs them the first time;
|
|
3
|
+
// runner.mjs runs them again when `hyperspec regenerate --run` asks for a stage.
|
|
4
|
+
|
|
5
|
+
// claims: every line spoken on a call, without the speaker's name, as a bullet.
|
|
6
|
+
export function claims(calls) {
|
|
7
|
+
const lines = calls.flatMap((text) => text.split("\n"))
|
|
8
|
+
.map((line) => line.replace(/^[^:]+:\s*/, "").trim())
|
|
9
|
+
.filter(Boolean);
|
|
10
|
+
return lines.map((line) => `- ${line}`).join("\n") + "\n";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// terms: every "term: definition" line in the notes, sorted by term.
|
|
14
|
+
export function terms(notes) {
|
|
15
|
+
const lines = notes.flatMap((text) => text.split("\n")).filter((line) => line.includes(":"));
|
|
16
|
+
return lines
|
|
17
|
+
.map((line) => { const i = line.indexOf(":"); return [line.slice(0, i).trim(), line.slice(i + 1).trim()]; })
|
|
18
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
19
|
+
.map(([term, meaning]) => `- **${term}**: ${meaning}`)
|
|
20
|
+
.join("\n") + "\n";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// draft: the essay, built from the two earlier stages.
|
|
24
|
+
export function draft(claimsText, termsText) {
|
|
25
|
+
return `# What a recipe is for\n\n## Claims\n\n${claimsText}\n## Terms\n\n${termsText}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The verdict each stage reports: a stage that produced nothing fails.
|
|
29
|
+
export function verdict(stage, output) {
|
|
30
|
+
return { station: `${stage}-not-empty`, pass: output.trim().length > 0, note: "" };
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/hyperspec",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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": {
|
|
7
7
|
"hyperspec": "bin/hyperspec.mjs"
|
|
8
8
|
},
|
|
9
|
+
"exports": {
|
|
10
|
+
"./recipe": "./src/writer.mjs",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
9
13
|
"files": [
|
|
10
14
|
"bin/",
|
|
11
15
|
"src/",
|
|
12
16
|
"examples/",
|
|
13
17
|
"SPEC.md",
|
|
18
|
+
"runs.jsonl",
|
|
14
19
|
"README.md",
|
|
15
20
|
"CHANGELOG.md",
|
|
16
21
|
"LICENSE"
|
package/runs.jsonl
ADDED
|
File without changes
|
package/src/blobs.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { sha256 } from "./hash.mjs";
|
|
5
|
+
|
|
6
|
+
// Resolve the store root: the --store flag, then HYPERSPEC_STORE, then the
|
|
7
|
+
// nearest ancestor of `from` holding .hyperspec/ or .git, else from's own
|
|
8
|
+
// directory. `from` is normally the recipe's directory, but a file path
|
|
9
|
+
// works too (we walk up from its dirname).
|
|
10
|
+
export function storeRoot({ from, store } = {}) {
|
|
11
|
+
if (store) return resolve(store);
|
|
12
|
+
if (process.env.HYPERSPEC_STORE) return resolve(process.env.HYPERSPEC_STORE);
|
|
13
|
+
let dir = resolve(from);
|
|
14
|
+
try { if (statSync(dir).isFile()) dir = dirname(dir); } catch { /* from may not exist yet; treat it as a directory */ }
|
|
15
|
+
let cur = dir;
|
|
16
|
+
while (true) {
|
|
17
|
+
if (existsSync(join(cur, ".hyperspec")) || existsSync(join(cur, ".git"))) return cur;
|
|
18
|
+
const parent = dirname(cur);
|
|
19
|
+
if (parent === cur) return dir; // hit the filesystem root: fall back to the starting directory
|
|
20
|
+
cur = parent;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A hash read from a recipe is a claim, and it becomes part of a filesystem path here. Anything
|
|
25
|
+
// but exactly 64 lowercase hex characters is refused before it touches the disk, so a crafted
|
|
26
|
+
// recipe cannot point a read at `../../somewhere`, a device, or a pipe that never closes.
|
|
27
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
28
|
+
export function isSha256(hex) {
|
|
29
|
+
return typeof hex === "string" && SHA256_HEX.test(hex);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function blobPath(root, hex) {
|
|
33
|
+
if (!isSha256(hex)) {
|
|
34
|
+
const shown = typeof hex === "string" ? JSON.stringify(hex.length > 80 ? `${hex.slice(0, 80)}...` : hex) : String(hex);
|
|
35
|
+
throw new Error(`not a SHA-256 hash (64 lowercase hex characters): ${shown}`);
|
|
36
|
+
}
|
|
37
|
+
return join(root, ".hyperspec", "blobs", hex.slice(0, 2), hex);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Writes only if the blob is absent; an existing blob is left untouched (never rewritten).
|
|
41
|
+
// Atomic: bytes land in a temp file in the same directory first, then a single renameSync
|
|
42
|
+
// (atomic on one filesystem) puts them at the final path. A process killed mid-write leaves
|
|
43
|
+
// only the orphaned temp file, never a partially-written file sitting at the content-addressed
|
|
44
|
+
// path — the failure mode a plain writeFileSync(path, bytes) would otherwise leave behind, and
|
|
45
|
+
// which nothing short of an explicit verifyBlob would ever catch afterward.
|
|
46
|
+
export function putBlob(root, bytes) {
|
|
47
|
+
const hex = sha256(bytes);
|
|
48
|
+
const path = blobPath(root, hex);
|
|
49
|
+
if (existsSync(path)) return hex;
|
|
50
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
51
|
+
const tmp = `${path}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
52
|
+
try {
|
|
53
|
+
writeFileSync(tmp, bytes);
|
|
54
|
+
if (existsSync(path)) { unlinkSync(tmp); return hex; } // another writer won the race; keep theirs
|
|
55
|
+
renameSync(tmp, path);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
try { unlinkSync(tmp); } catch { /* nothing to clean up */ }
|
|
58
|
+
throw e;
|
|
59
|
+
}
|
|
60
|
+
return hex;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A malformed hash names no blob, so hasBlob, getBlob and verifyBlob treat it as missing.
|
|
64
|
+
export function hasBlob(root, hex) {
|
|
65
|
+
return isSha256(hex) && existsSync(blobPath(root, hex));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getBlob(root, hex) {
|
|
69
|
+
if (!isSha256(hex)) return null;
|
|
70
|
+
try { return readFileSync(blobPath(root, hex)); } catch { return null; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// True when the blob exists and its bytes re-hash to hex (catches tampering).
|
|
74
|
+
export function verifyBlob(root, hex) {
|
|
75
|
+
const bytes = getBlob(root, hex);
|
|
76
|
+
return bytes !== null && sha256(bytes) === hex;
|
|
77
|
+
}
|