@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +105 -4
  3. package/SPEC.md +205 -13
  4. package/WRITING.md +426 -0
  5. package/bin/hyperspec.mjs +322 -2
  6. package/examples/minimal.hyperspec.md +2 -2
  7. package/examples/recipe/doctor.mjs +11 -0
  8. package/examples/recipe/essay.hyperspec.md +50 -0
  9. package/examples/recipe/factory.mjs +29 -0
  10. package/examples/recipe/materials/call-2.md +2 -0
  11. package/examples/recipe/materials/call.md +3 -0
  12. package/examples/recipe/materials/notes.md +3 -0
  13. package/examples/recipe/runner.mjs +20 -0
  14. package/examples/recipe/runs.jsonl +0 -0
  15. package/examples/recipe/stages.mjs +31 -0
  16. package/examples/writing/essay/goldens/close.md +2 -0
  17. package/examples/writing/essay/goldens/opening.md +2 -0
  18. package/examples/writing/essay/materials/interview-notes.md +12 -0
  19. package/examples/writing/essay/materials/team-survey.md +7 -0
  20. package/examples/writing/essay/materials/voice-memo.md +18 -0
  21. package/examples/writing/essay/runs.jsonl +0 -0
  22. package/examples/writing/essay.hyperspec.md +217 -0
  23. package/examples/writing/story/goldens/dialogue.md +3 -0
  24. package/examples/writing/story/goldens/opening.md +3 -0
  25. package/examples/writing/story/materials/bakery-visit.md +8 -0
  26. package/examples/writing/story/materials/notes.md +14 -0
  27. package/examples/writing/story/materials/scene-list.md +7 -0
  28. package/examples/writing/story/runs.jsonl +0 -0
  29. package/examples/writing/story.hyperspec.md +285 -0
  30. package/examples/writing/style-rules.md +19 -0
  31. package/package.json +8 -2
  32. package/runs.jsonl +0 -0
  33. package/src/blobs.mjs +77 -0
  34. package/src/compare.mjs +189 -0
  35. package/src/fsutil.mjs +33 -0
  36. package/src/hash.mjs +26 -0
  37. package/src/placeholder.mjs +20 -0
  38. package/src/profiles.mjs +50 -0
  39. package/src/recipe.mjs +164 -0
  40. package/src/regenerate.mjs +318 -0
  41. package/src/reproduce.mjs +156 -0
  42. package/src/rules.mjs +58 -19
  43. package/src/score.mjs +7 -1
  44. package/src/template.mjs +15 -3
  45. package/src/writer.mjs +125 -0
  46. package/src/writing-fields.mjs +317 -0
  47. package/src/writing-template.mjs +192 -0
  48. 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
- writeFileSync(file, template({ title: flag("--title"), kind: flag("--kind") }));
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: gary-sheng
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: gary-sheng
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,2 @@
1
+ Ana: A recipe you cannot rerun is a receipt.
2
+ Sam: So the second call goes in as one more input, and only the claims change.
@@ -0,0 +1,3 @@
1
+ Maya: The recipe is the part people skip.
2
+ Sam: Because writing it down feels like overhead.
3
+ Maya: It stops being overhead the day you have to redo the work.
@@ -0,0 +1,3 @@
1
+ stage: one step of a factory, keyed by everything it read
2
+ blob: the exact bytes of an input, stored by their hash
3
+ recipe: the record of what made an output, from what, and who approved it
@@ -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,2 @@
1
+ So write the three questions on a card. Ask the first one. Then wait, longer than feels polite,
2
+ because the first answer is the one they rehearsed and the second one is the one you came for.
@@ -0,0 +1,2 @@
1
+ Your first one-on-one with a new report is the only meeting on your calendar where they should
2
+ set the agenda. Everything else you run. This one you hand over.
@@ -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