@supersuit/hyperspec 0.1.0 → 0.3.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 +102 -0
- package/README.md +105 -4
- package/SPEC.md +205 -13
- package/WRITING.md +426 -0
- package/bin/hyperspec.mjs +322 -2
- package/examples/minimal.hyperspec.md +2 -2
- 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/examples/writing/essay/goldens/close.md +2 -0
- package/examples/writing/essay/goldens/opening.md +2 -0
- package/examples/writing/essay/materials/interview-notes.md +12 -0
- package/examples/writing/essay/materials/team-survey.md +7 -0
- package/examples/writing/essay/materials/voice-memo.md +18 -0
- package/examples/writing/essay/runs.jsonl +0 -0
- package/examples/writing/essay.hyperspec.md +217 -0
- package/examples/writing/story/goldens/dialogue.md +3 -0
- package/examples/writing/story/goldens/opening.md +3 -0
- package/examples/writing/story/materials/bakery-visit.md +8 -0
- package/examples/writing/story/materials/notes.md +14 -0
- package/examples/writing/story/materials/scene-list.md +7 -0
- package/examples/writing/story/runs.jsonl +0 -0
- package/examples/writing/story.hyperspec.md +285 -0
- package/examples/writing/style-rules.md +19 -0
- package/package.json +8 -2
- 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/placeholder.mjs +20 -0
- package/src/profiles.mjs +50 -0
- package/src/recipe.mjs +164 -0
- package/src/regenerate.mjs +318 -0
- package/src/reproduce.mjs +156 -0
- package/src/rules.mjs +58 -19
- package/src/score.mjs +7 -1
- package/src/template.mjs +15 -3
- package/src/writer.mjs +125 -0
- package/src/writing-fields.mjs +317 -0
- package/src/writing-template.mjs +192 -0
- package/src/writing.mjs +181 -0
package/bin/hyperspec.mjs
CHANGED
|
@@ -1,15 +1,57 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
3
4
|
import { loadSpec } from "../src/load.mjs";
|
|
4
5
|
import { lintSpec } from "../src/rules.mjs";
|
|
5
6
|
import { score, exitCode } from "../src/score.mjs";
|
|
6
7
|
import { template } from "../src/template.mjs";
|
|
8
|
+
import { writingTemplate } from "../src/writing-template.mjs";
|
|
9
|
+
import { PROFILES, knownProfile } from "../src/profiles.mjs";
|
|
10
|
+
import { readRecipe, checkRecipe } from "../src/recipe.mjs";
|
|
11
|
+
import { approve } from "../src/writer.mjs";
|
|
12
|
+
import { reproduce } from "../src/reproduce.mjs";
|
|
13
|
+
import { regenerate } from "../src/regenerate.mjs";
|
|
14
|
+
import { compare } from "../src/compare.mjs";
|
|
7
15
|
|
|
8
16
|
const HELP = `hyperspec <command> [options]
|
|
9
17
|
|
|
10
18
|
lint <file...> [--json] score each hyperspec against the nine tests
|
|
11
19
|
exit 0 pass, 1 a test fails, 3 blocked on an open decision, 2 usage
|
|
12
20
|
init <file> [--title T] [--kind K] write a new hyperspec skeleton (refuses to overwrite)
|
|
21
|
+
init <file> --profile writing [--title T] [--form F] [--fiction]
|
|
22
|
+
write a writing-profile skeleton: every required block (materials,
|
|
23
|
+
dna, persona, audience, goal, form, spine, sources) shown in full
|
|
24
|
+
with placeholder values, dna/persona/audience/goal also carrying an
|
|
25
|
+
open decision naming the question only the operator can answer;
|
|
26
|
+
--fiction adds one character, same treatment; the skeleton never
|
|
27
|
+
passes until its placeholders and open decisions are replaced with
|
|
28
|
+
real content; exit 2 for a --profile with no value or one this
|
|
29
|
+
linter does not know, --fiction or --form without --profile
|
|
30
|
+
writing, --kind with it (use --form), or a folder that does not
|
|
31
|
+
exist
|
|
32
|
+
|
|
33
|
+
recipe check <output-or-recipe> [--json]
|
|
34
|
+
check a recipe's completeness (a path not ending .recipe.json
|
|
35
|
+
means <path>.recipe.json)
|
|
36
|
+
exit 0 ok (warns only), 1 no recipe found or a check failed,
|
|
37
|
+
2 unreadable/invalid recipe JSON
|
|
38
|
+
recipe approve <recipe> --by <slug> [--json]
|
|
39
|
+
set the recipe's approver, print remaining findings
|
|
40
|
+
exit 0 once --by and the recipe are valid, 2 missing --by or
|
|
41
|
+
unreadable recipe
|
|
42
|
+
reproduce <recipe> [--restore] [--store d] [--json]
|
|
43
|
+
replay a recipe's hash checks against the blob store; never
|
|
44
|
+
runs a model
|
|
45
|
+
exit 0 reproduces cleanly, 1 a check failed, 2 unreadable recipe
|
|
46
|
+
regenerate <recipe> --out <path> --clicker <slug>
|
|
47
|
+
(--add-input n=p [--reads id]... | --swap-input n=p | --factory-version v)
|
|
48
|
+
[--run cmd] [--change text] [--store d] [--json]
|
|
49
|
+
make a child recipe from a parent plus exactly one change
|
|
50
|
+
exit 0 ok, 1 a stage failed or the child has failing verdicts,
|
|
51
|
+
2 usage, 3 pending a runner
|
|
52
|
+
compare <child-recipe> --doctor cmd [--parent r] [--spec s] [--json]
|
|
53
|
+
grade a child and its parent through one doctor against one spec
|
|
54
|
+
exit 0 not regressed, 1 regressed, 2 usage or unreadable input
|
|
13
55
|
|
|
14
56
|
Spec: SPEC.md`;
|
|
15
57
|
|
|
@@ -19,11 +61,39 @@ const cmd = argv[0];
|
|
|
19
61
|
|
|
20
62
|
if (!cmd || cmd === "--help" || cmd === "-h") { console.log(HELP); process.exit(cmd ? 0 : 2); }
|
|
21
63
|
|
|
64
|
+
// Each profile that wants its own init skeleton adds one entry here; a profile absent from this
|
|
65
|
+
// map still lints (via PROFILES in profiles.mjs) but init falls back to the plain template for it.
|
|
66
|
+
const PROFILE_TEMPLATES = { writing: writingTemplate };
|
|
67
|
+
|
|
22
68
|
if (cmd === "init") {
|
|
23
69
|
const file = argv[1];
|
|
24
70
|
if (!file || file.startsWith("--")) { console.error("init needs a file path"); process.exit(2); }
|
|
25
71
|
if (existsSync(file)) { console.error(`refusing to overwrite ${file}`); process.exit(2); }
|
|
26
|
-
|
|
72
|
+
const usage = (msg) => { console.error(msg); process.exit(2); };
|
|
73
|
+
const given = (name) => argv.includes(name);
|
|
74
|
+
// A value flag with nothing after it, or another flag after it, has no value.
|
|
75
|
+
const value = (name) => { const v = flag(name); return v === undefined || v.startsWith("--") ? undefined : v; };
|
|
76
|
+
const profileName = value("--profile");
|
|
77
|
+
if (given("--profile") && profileName === undefined) usage("--profile needs a value; known profiles: " + Object.keys(PROFILES).join(", "));
|
|
78
|
+
if (profileName !== undefined && !knownProfile(profileName)) {
|
|
79
|
+
usage(`unknown profile: ${profileName}; known profiles: ${Object.keys(PROFILES).join(", ") || "(none)"}`);
|
|
80
|
+
}
|
|
81
|
+
const writeTemplate = profileName !== undefined && Object.hasOwn(PROFILE_TEMPLATES, profileName) ? PROFILE_TEMPLATES[profileName] : undefined;
|
|
82
|
+
// The writing flags mean nothing to the plain template, and --kind means nothing to the writing
|
|
83
|
+
// one (its kind comes from --form); either would be silently dropped, so both are refused.
|
|
84
|
+
if (!writeTemplate) {
|
|
85
|
+
for (const f of ["--fiction", "--form"]) if (given(f)) usage(`${f} only applies with --profile writing`);
|
|
86
|
+
} else if (given("--kind")) {
|
|
87
|
+
usage("--kind does not apply with --profile writing; use --form, which sets kind and writing.form.name together");
|
|
88
|
+
}
|
|
89
|
+
if (given("--form") && value("--form") === undefined) usage("--form needs a value");
|
|
90
|
+
const folder = dirname(resolve(file));
|
|
91
|
+
if (!existsSync(folder) || !statSync(folder).isDirectory()) usage(`the folder ${dirname(file)} does not exist; create it first`);
|
|
92
|
+
const content = writeTemplate
|
|
93
|
+
? writeTemplate({ title: flag("--title"), form: value("--form"), fiction: given("--fiction") })
|
|
94
|
+
: template({ title: flag("--title"), kind: flag("--kind") });
|
|
95
|
+
try { writeFileSync(file, content); }
|
|
96
|
+
catch (e) { usage(`could not write ${file}: ${e.code === "EACCES" ? "permission denied" : e.message}`); }
|
|
27
97
|
console.log(`wrote ${file}; run: hyperspec lint ${file}`);
|
|
28
98
|
process.exit(0);
|
|
29
99
|
}
|
|
@@ -51,11 +121,261 @@ if (cmd === "lint") {
|
|
|
51
121
|
else for (const r of reports) {
|
|
52
122
|
if (r.error) { console.log(`${r.file}: ${r.error}`); continue; }
|
|
53
123
|
console.log(`${r.file}: ${r.status} (${r.passed}/9)${r.open.length ? `, open: ${r.open.join(", ")}` : ""}`);
|
|
124
|
+
if (r.profile) console.log(` ${r.profile.name}: ${r.profile.complete}/${r.profile.total} blocks complete`);
|
|
54
125
|
for (const t of r.tests) if (!t.pass) console.log(` ✗ ${t.n}. ${t.name}`);
|
|
55
126
|
for (const f of r.findings) console.log(` ${f.severity === "fail" ? "fail" : "warn"} [${f.test}] ${f.message}\n fix: ${f.fix}`);
|
|
56
127
|
}
|
|
57
128
|
process.exit(worst);
|
|
58
129
|
}
|
|
59
130
|
|
|
131
|
+
// Generic flag/positional parser for the recipe verbs below. A value-taking flag (valueFlags,
|
|
132
|
+
// repeatableFlags) never swallows a following --flag as its value (missing value is an error, not
|
|
133
|
+
// a silent grab); a bool flag never eats the next token as a positional; any --flag not declared
|
|
134
|
+
// for this verb is an error. This is the same discipline lint's own hand-rolled parser follows
|
|
135
|
+
// above (a stray flag never swallows a file), generalized once repeated-flag verbs (regenerate's
|
|
136
|
+
// --reads) and value flags (--out, --by, --doctor, ...) showed up.
|
|
137
|
+
function parseArgs(args, { valueFlags = [], boolFlags = [], repeatableFlags = [] } = {}) {
|
|
138
|
+
const positionals = [];
|
|
139
|
+
const values = {};
|
|
140
|
+
for (const f of boolFlags) values[f] = false;
|
|
141
|
+
for (const f of repeatableFlags) values[f] = [];
|
|
142
|
+
let i = 0;
|
|
143
|
+
while (i < args.length) {
|
|
144
|
+
const a = args[i];
|
|
145
|
+
if (a.startsWith("--")) {
|
|
146
|
+
if (repeatableFlags.includes(a)) {
|
|
147
|
+
const v = args[i + 1];
|
|
148
|
+
if (v === undefined || v.startsWith("--")) return { error: `${a} needs a value` };
|
|
149
|
+
values[a].push(v);
|
|
150
|
+
i += 2;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (valueFlags.includes(a)) {
|
|
154
|
+
const v = args[i + 1];
|
|
155
|
+
if (v === undefined || v.startsWith("--")) return { error: `${a} needs a value` };
|
|
156
|
+
values[a] = v;
|
|
157
|
+
i += 2;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (boolFlags.includes(a)) { values[a] = true; i += 1; continue; }
|
|
161
|
+
return { error: `unknown flag: ${a}` };
|
|
162
|
+
}
|
|
163
|
+
positionals.push(a);
|
|
164
|
+
i += 1;
|
|
165
|
+
}
|
|
166
|
+
return { positionals, values };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// name=path, split on the FIRST =, so a path that itself contains = survives intact. A value with
|
|
170
|
+
// no = is a usage error (exit 2).
|
|
171
|
+
function namePath(flagName, raw) {
|
|
172
|
+
const eq = raw.indexOf("=");
|
|
173
|
+
if (eq === -1) { console.error(`${flagName} needs name=path`); process.exit(2); }
|
|
174
|
+
return { name: raw.slice(0, eq), path: raw.slice(eq + 1) };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function printFindings(findings) {
|
|
178
|
+
if (!findings.length) { console.log("ok"); return; }
|
|
179
|
+
for (const f of findings) console.log(`${f.severity === "fail" ? "fail" : "warn"} [${f.field}] ${f.message}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function printPlan(plan) {
|
|
183
|
+
for (const p of plan ?? []) console.log(` ${p.id}: ${p.action}${p.reason ? ` (${p.reason})` : ""}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (cmd === "recipe") {
|
|
187
|
+
const sub = argv[1];
|
|
188
|
+
|
|
189
|
+
if (sub === "check") {
|
|
190
|
+
const parsed = parseArgs(argv.slice(2), { boolFlags: ["--json"] });
|
|
191
|
+
if (parsed.error) { console.error(parsed.error); process.exit(2); }
|
|
192
|
+
const [target] = parsed.positionals;
|
|
193
|
+
const json = parsed.values["--json"];
|
|
194
|
+
if (!target) { console.error("recipe check needs a recipe or output path"); process.exit(2); }
|
|
195
|
+
const recipePath = target.endsWith(".recipe.json") ? target : `${target}.recipe.json`;
|
|
196
|
+
|
|
197
|
+
if (!existsSync(recipePath)) {
|
|
198
|
+
const msg = `no recipe beside ${target}`;
|
|
199
|
+
if (json) console.log(JSON.stringify({ recipe: recipePath, error: msg }, null, 2));
|
|
200
|
+
else console.error(msg);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const loaded = readRecipe(recipePath);
|
|
205
|
+
if (loaded.error) {
|
|
206
|
+
if (json) console.log(JSON.stringify({ recipe: recipePath, error: loaded.error }, null, 2));
|
|
207
|
+
else console.error(loaded.error);
|
|
208
|
+
process.exit(2);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const findings = checkRecipe(loaded.data);
|
|
212
|
+
const hasFail = findings.some((f) => f.severity === "fail");
|
|
213
|
+
if (json) console.log(JSON.stringify({ recipe: recipePath, findings }, null, 2));
|
|
214
|
+
else { console.log(`${recipePath}:`); printFindings(findings); }
|
|
215
|
+
process.exit(hasFail ? 1 : 0);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (sub === "approve") {
|
|
219
|
+
const parsed = parseArgs(argv.slice(2), { valueFlags: ["--by"], boolFlags: ["--json"] });
|
|
220
|
+
if (parsed.error) { console.error(parsed.error); process.exit(2); }
|
|
221
|
+
const [recipePath] = parsed.positionals;
|
|
222
|
+
const json = parsed.values["--json"];
|
|
223
|
+
if (!recipePath) { console.error("recipe approve needs a recipe path"); process.exit(2); }
|
|
224
|
+
if (!parsed.values["--by"]) { console.error("recipe approve needs --by <slug>"); process.exit(2); }
|
|
225
|
+
|
|
226
|
+
let findings;
|
|
227
|
+
try {
|
|
228
|
+
findings = approve(recipePath, parsed.values["--by"]);
|
|
229
|
+
} catch (e) {
|
|
230
|
+
if (json) console.log(JSON.stringify({ recipe: recipePath, error: e.message }, null, 2));
|
|
231
|
+
else console.error(e.message);
|
|
232
|
+
process.exit(2);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (json) console.log(JSON.stringify({ recipe: recipePath, approver: parsed.values["--by"], findings }, null, 2));
|
|
236
|
+
else { console.log(`${recipePath}: approver set to ${parsed.values["--by"]}`); printFindings(findings); }
|
|
237
|
+
process.exit(0);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
console.error(`unknown recipe subcommand: ${sub}\n\n${HELP}`);
|
|
241
|
+
process.exit(2);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (cmd === "reproduce") {
|
|
245
|
+
const parsed = parseArgs(argv.slice(1), { valueFlags: ["--store"], boolFlags: ["--restore", "--json"] });
|
|
246
|
+
if (parsed.error) { console.error(parsed.error); process.exit(2); }
|
|
247
|
+
const [recipePath] = parsed.positionals;
|
|
248
|
+
const json = parsed.values["--json"];
|
|
249
|
+
if (!recipePath) { console.error("reproduce needs a recipe path"); process.exit(2); }
|
|
250
|
+
|
|
251
|
+
const result = reproduce(recipePath, { store: parsed.values["--store"], restore: parsed.values["--restore"] });
|
|
252
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
253
|
+
|
|
254
|
+
if (result.error) {
|
|
255
|
+
if (!json) console.error(result.error);
|
|
256
|
+
process.exit(2);
|
|
257
|
+
}
|
|
258
|
+
if (!result.ok) {
|
|
259
|
+
if (!json) {
|
|
260
|
+
console.error(`first mismatch: ${result.firstMismatch}`);
|
|
261
|
+
for (const step of result.steps) console.log(` ${step.ok ? "ok " : "FAIL"} ${step.ref}${step.why ? `: ${step.why}` : ""}`);
|
|
262
|
+
if (result.restored) console.log("restored the output file from its blob");
|
|
263
|
+
}
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
if (!json) {
|
|
267
|
+
for (const step of result.steps) console.log(` ok ${step.ref}`);
|
|
268
|
+
if (result.restored) console.log("restored the output file from its blob");
|
|
269
|
+
console.log("reproduces cleanly");
|
|
270
|
+
}
|
|
271
|
+
process.exit(0);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (cmd === "regenerate") {
|
|
275
|
+
const parsed = parseArgs(argv.slice(1), {
|
|
276
|
+
valueFlags: ["--out", "--clicker", "--add-input", "--swap-input", "--factory-version", "--run", "--change", "--store"],
|
|
277
|
+
boolFlags: ["--json"],
|
|
278
|
+
repeatableFlags: ["--reads"],
|
|
279
|
+
});
|
|
280
|
+
if (parsed.error) { console.error(parsed.error); process.exit(2); }
|
|
281
|
+
const [recipePath] = parsed.positionals;
|
|
282
|
+
const json = parsed.values["--json"];
|
|
283
|
+
if (!recipePath) { console.error("regenerate needs a parent recipe path"); process.exit(2); }
|
|
284
|
+
if (parsed.values["--reads"].length && parsed.values["--add-input"] === undefined) {
|
|
285
|
+
console.error("--reads is only valid with --add-input");
|
|
286
|
+
process.exit(2);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const change = {};
|
|
290
|
+
if (parsed.values["--add-input"] !== undefined) {
|
|
291
|
+
const pair = namePath("--add-input", parsed.values["--add-input"]);
|
|
292
|
+
change.addInput = { ...pair, reads: parsed.values["--reads"] };
|
|
293
|
+
}
|
|
294
|
+
if (parsed.values["--swap-input"] !== undefined) {
|
|
295
|
+
change.swapInput = namePath("--swap-input", parsed.values["--swap-input"]);
|
|
296
|
+
}
|
|
297
|
+
if (parsed.values["--factory-version"] !== undefined) change.factoryVersion = parsed.values["--factory-version"];
|
|
298
|
+
|
|
299
|
+
const result = regenerate(recipePath, {
|
|
300
|
+
out: parsed.values["--out"],
|
|
301
|
+
clicker: parsed.values["--clicker"],
|
|
302
|
+
change,
|
|
303
|
+
run: parsed.values["--run"],
|
|
304
|
+
changeText: parsed.values["--change"],
|
|
305
|
+
store: parsed.values["--store"],
|
|
306
|
+
});
|
|
307
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
308
|
+
|
|
309
|
+
if (result.usage) {
|
|
310
|
+
if (!json) console.error(result.error);
|
|
311
|
+
process.exit(2);
|
|
312
|
+
}
|
|
313
|
+
if (!result.ok) {
|
|
314
|
+
if (!json) {
|
|
315
|
+
console.error(result.failedStage ? `stage ${result.failedStage} failed: ${result.error}` : result.error);
|
|
316
|
+
if (result.plan?.length) printPlan(result.plan);
|
|
317
|
+
}
|
|
318
|
+
process.exit(1);
|
|
319
|
+
}
|
|
320
|
+
if (result.pending) {
|
|
321
|
+
if (!json) {
|
|
322
|
+
const waiting = result.plan.filter((p) => p.action === "rerun").map((p) => p.id);
|
|
323
|
+
console.log(`pending; stages waiting for a runner: ${waiting.join(", ") || "(none)"}`);
|
|
324
|
+
printPlan(result.plan);
|
|
325
|
+
console.log(`child recipe: ${result.childRecipe}`);
|
|
326
|
+
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");
|
|
327
|
+
}
|
|
328
|
+
process.exit(3);
|
|
329
|
+
}
|
|
330
|
+
if (result.failedVerdicts?.length) {
|
|
331
|
+
if (!json) {
|
|
332
|
+
console.log(`child written despite failing verdict(s): ${result.failedVerdicts.join(", ")}`);
|
|
333
|
+
printPlan(result.plan);
|
|
334
|
+
console.log(`child recipe: ${result.childRecipe}`);
|
|
335
|
+
console.log(`output: ${result.output}`);
|
|
336
|
+
}
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
if (!json) {
|
|
340
|
+
printPlan(result.plan);
|
|
341
|
+
console.log(`child recipe: ${result.childRecipe}`);
|
|
342
|
+
console.log(`output: ${result.output}`);
|
|
343
|
+
}
|
|
344
|
+
process.exit(0);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (cmd === "compare") {
|
|
348
|
+
const parsed = parseArgs(argv.slice(1), { valueFlags: ["--doctor", "--parent", "--spec"], boolFlags: ["--json"] });
|
|
349
|
+
if (parsed.error) { console.error(parsed.error); process.exit(2); }
|
|
350
|
+
const [childRecipePath] = parsed.positionals;
|
|
351
|
+
const json = parsed.values["--json"];
|
|
352
|
+
if (!childRecipePath) { console.error("compare needs a child recipe path"); process.exit(2); }
|
|
353
|
+
|
|
354
|
+
const result = compare(childRecipePath, {
|
|
355
|
+
doctor: parsed.values["--doctor"],
|
|
356
|
+
parent: parsed.values["--parent"],
|
|
357
|
+
spec: parsed.values["--spec"],
|
|
358
|
+
});
|
|
359
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
360
|
+
|
|
361
|
+
// Both a usage error and a check-failed error (missing output, escaping path, ...) map to
|
|
362
|
+
// 2 here — compare's own ok:false without a usage flag is still "could not grade", i.e. an
|
|
363
|
+
// unreadable-input class failure, not a graded-but-worse-1 class one.
|
|
364
|
+
if (!result.ok) {
|
|
365
|
+
if (!json) console.error(result.error);
|
|
366
|
+
process.exit(2);
|
|
367
|
+
}
|
|
368
|
+
if (!json) {
|
|
369
|
+
for (const w of result.warnings) console.log(`warn: ${w}`);
|
|
370
|
+
if (result.ledger) console.log(`ledger: ${result.ledger}`);
|
|
371
|
+
}
|
|
372
|
+
if (result.regressed) {
|
|
373
|
+
if (!json) console.log(`regression: child scored ${result.child.score} vs parent ${result.parent.score}; suspect: ${result.suspect ?? "unknown"}`);
|
|
374
|
+
process.exit(1);
|
|
375
|
+
}
|
|
376
|
+
if (!json) console.log(`child scored ${result.child.score} vs parent ${result.parent.score} (delta ${result.delta})`);
|
|
377
|
+
process.exit(0);
|
|
378
|
+
}
|
|
379
|
+
|
|
60
380
|
console.error(`unknown command: ${cmd}\n\n${HELP}`);
|
|
61
381
|
process.exit(2);
|
|
@@ -7,7 +7,7 @@ decisions:
|
|
|
7
7
|
state: decided
|
|
8
8
|
value: the operator, reading on a phone
|
|
9
9
|
source: interview A2
|
|
10
|
-
author:
|
|
10
|
+
author: example-author
|
|
11
11
|
chosen_by: human
|
|
12
12
|
- id: length
|
|
13
13
|
state: delegated
|
|
@@ -22,7 +22,7 @@ requirements:
|
|
|
22
22
|
check:
|
|
23
23
|
rubric: ask the simulated reader to define the term; pass only on a correct definition
|
|
24
24
|
source: design doc, audience block
|
|
25
|
-
author:
|
|
25
|
+
author: example-author
|
|
26
26
|
rejects:
|
|
27
27
|
- hype words about AI
|
|
28
28
|
examples:
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Interview notes, a thirty-minute call with an engineering manager of eight years, taken by the
|
|
2
|
+
author during the call. Considered: the manager reviewed these notes afterwards and corrected two
|
|
3
|
+
lines.
|
|
4
|
+
|
|
5
|
+
- Her rule: the report owns the agenda. She keeps a shared document per person; they add items
|
|
6
|
+
before the meeting, and she adds hers last, at the bottom.
|
|
7
|
+
- "If I have something urgent, it is not a one-on-one topic. I send it the day it happens."
|
|
8
|
+
- The first one-on-one with a new report is always the same: she asks how they like to receive
|
|
9
|
+
feedback, in writing or out loud, right away or at the end of the week.
|
|
10
|
+
- Her warning: new managers treat silence as a problem. "Wait. Count to five. The real answer is
|
|
11
|
+
the second one."
|
|
12
|
+
- She cancels a one-on-one only when the report asks her to, never on her own.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Summary of an internal survey the author's team ran in the spring, 41 responses, figures checked
|
|
2
|
+
against the raw export by a second person. Verified.
|
|
3
|
+
|
|
4
|
+
- 29 of 41 said their most useful one-on-one in the last quarter was one where they brought the
|
|
5
|
+
first topic.
|
|
6
|
+
- 11 of 41 said at least one of their one-on-ones in the last quarter was mostly project status.
|
|
7
|
+
- The most common free-text request, in 9 responses: "ask me what I want to work on next."
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Voice memo transcript, recorded by the author on a walk, lightly cleaned. Raw thinking.
|
|
2
|
+
|
|
3
|
+
My first one-on-one as a manager was a disaster, and the reason was simple: I ran it. I had a
|
|
4
|
+
list. I went down the list. Project status, blockers, the thing from Tuesday. Thirty minutes
|
|
5
|
+
later my report said "cool, thanks" and left, and I had learned nothing I could not have read in
|
|
6
|
+
the tracker.
|
|
7
|
+
|
|
8
|
+
What I wish someone had told me: the first one-on-one is the only meeting where they get to set
|
|
9
|
+
the agenda. If you set it, you have told them what the meeting is for, and it is for you.
|
|
10
|
+
|
|
11
|
+
The three questions I use now. What is taking more of your energy than it should? What do you
|
|
12
|
+
want to be doing more of in six months? What should I stop doing, or start doing, that would
|
|
13
|
+
make your week easier? Then I shut up.
|
|
14
|
+
|
|
15
|
+
Status goes in the tracker. If a one-on-one is a status meeting, cancel it and read the tracker.
|
|
16
|
+
|
|
17
|
+
Aside, probably not for this piece: my second manager used to walk the one-on-ones outside. I
|
|
18
|
+
liked it but I do not think it is the point.
|
|
File without changes
|