@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/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,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
+ }
package/package.json CHANGED
@@ -1,16 +1,21 @@
1
1
  {
2
2
  "name": "@supersuit/hyperspec",
3
- "version": "0.1.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
+ }