@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/src/template.mjs
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
|
+
// A scalar left unquoted only when every YAML reader reads it back as the same text: it starts
|
|
2
|
+
// with a letter or digit, holds only letters, digits, spaces and plain punctuation, and is not a
|
|
3
|
+
// word YAML turns into null, a boolean or a number. Anything else is written as a JSON string,
|
|
4
|
+
// which is also a valid YAML double-quoted scalar.
|
|
5
|
+
const PLAIN = /^[A-Za-z0-9][A-Za-z0-9 _.,'()/-]*$/;
|
|
6
|
+
const RESOLVES = /^(null|~|true|false|yes|no|on|off|y|n|[-+]?(\d[\d_]*)?\.?\d+([eE][-+]?\d+)?|0x[0-9a-f]+|0o[0-7]+|\.inf|\.nan)$/i;
|
|
7
|
+
// Exported so any other init-time template (writing-template.mjs's writingTemplate, and whatever
|
|
8
|
+
// profile templates come after it) quotes titles, kinds and other free-text scalars the same way,
|
|
9
|
+
// rather than re-deriving this regex pair.
|
|
10
|
+
export const scalar = (v) => (PLAIN.test(v) && !/\s$/.test(v) && !RESOLVES.test(v) ? v : JSON.stringify(v));
|
|
11
|
+
|
|
1
12
|
export function template({ title = "Untitled", kind = "document" } = {}) {
|
|
13
|
+
const heading = String(title).replace(/\s+/g, " ").trim();
|
|
2
14
|
return `---
|
|
3
15
|
hyperspec: "0.1"
|
|
4
|
-
title: ${title}
|
|
5
|
-
kind: ${kind}
|
|
16
|
+
title: ${scalar(String(title))}
|
|
17
|
+
kind: ${scalar(String(kind))}
|
|
6
18
|
decisions:
|
|
7
19
|
- id: audience
|
|
8
20
|
state: open
|
|
@@ -22,6 +34,6 @@ improvement:
|
|
|
22
34
|
ledger: runs.jsonl
|
|
23
35
|
---
|
|
24
36
|
|
|
25
|
-
# ${
|
|
37
|
+
# ${heading}
|
|
26
38
|
`;
|
|
27
39
|
}
|
package/src/writer.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, relative, resolve } from "node:path";
|
|
3
|
+
import { sha256 } from "./hash.mjs";
|
|
4
|
+
import { writeFileAtomic } from "./fsutil.mjs";
|
|
5
|
+
import { storeRoot, putBlob, getBlob } from "./blobs.mjs";
|
|
6
|
+
import { checkRecipe, readRecipe, stageKey, writeRecipe } from "./recipe.mjs";
|
|
7
|
+
import { loadSpec } from "./load.mjs";
|
|
8
|
+
|
|
9
|
+
// What every outcome factory calls at the end of a run to record what it made, from what, and
|
|
10
|
+
// how. output and spec are paths, resolved against process.cwd(); every path stored inside the
|
|
11
|
+
// recipe (output.path, spec.path, each input's path) is relative to the recipe file's own
|
|
12
|
+
// directory, per the recipe file convention in SPEC.md.
|
|
13
|
+
export function startRecipe({ output, factory, spec, clicker, store } = {}) {
|
|
14
|
+
const outputAbs = resolve(process.cwd(), output);
|
|
15
|
+
const recipePath = `${outputAbs}.recipe.json`;
|
|
16
|
+
const recipeDir = dirname(recipePath);
|
|
17
|
+
const root = storeRoot({ from: recipeDir, store });
|
|
18
|
+
|
|
19
|
+
const specLoaded = loadSpec(spec);
|
|
20
|
+
if (specLoaded.error) throw new Error(specLoaded.error);
|
|
21
|
+
const specBytes = readFileSync(specLoaded.path);
|
|
22
|
+
const specHash = putBlob(root, specBytes);
|
|
23
|
+
// spec.authors is id -> author across decisions and requirements together; the two arrays
|
|
24
|
+
// share one id namespace here (every field is traced back to its source), so an id reused
|
|
25
|
+
// across them is a collision, not a silent overwrite, whether or not the authors agree.
|
|
26
|
+
const authors = {};
|
|
27
|
+
for (const d of [...(specLoaded.data.decisions ?? []), ...(specLoaded.data.requirements ?? [])]) {
|
|
28
|
+
if (!d || typeof d.id !== "string") continue;
|
|
29
|
+
if (Object.prototype.hasOwnProperty.call(authors, d.id)) {
|
|
30
|
+
throw new Error(`spec id ${d.id} is used twice; ids must be unique across decisions and requirements`);
|
|
31
|
+
}
|
|
32
|
+
// null rather than undefined, which JSON would drop: recipe check then names the id.
|
|
33
|
+
authors[d.id] = typeof d.author === "string" && d.author.trim() ? d.author : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const recipe = {
|
|
37
|
+
recipe: "0.1",
|
|
38
|
+
created: new Date().toISOString(),
|
|
39
|
+
output: { path: relative(recipeDir, outputAbs), sha256: null },
|
|
40
|
+
factory,
|
|
41
|
+
spec: { path: relative(recipeDir, specLoaded.path), sha256: specHash, authors },
|
|
42
|
+
inputs: [],
|
|
43
|
+
stages: [],
|
|
44
|
+
clicker,
|
|
45
|
+
approver: null,
|
|
46
|
+
parent: null,
|
|
47
|
+
change: null,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
// Stores the file's bytes as a blob now and records {name, path, sha256, order}. order is
|
|
52
|
+
// assigned by call order, starting at 1. A duplicate name throws.
|
|
53
|
+
input(name, path) {
|
|
54
|
+
if (recipe.inputs.some((i) => i.name === name)) throw new Error(`duplicate input name: ${name}`);
|
|
55
|
+
const abs = resolve(process.cwd(), path);
|
|
56
|
+
const bytes = readFileSync(abs);
|
|
57
|
+
const hex = putBlob(root, bytes);
|
|
58
|
+
const entry = { name, path: relative(recipeDir, abs), sha256: hex, order: recipe.inputs.length + 1 };
|
|
59
|
+
recipe.inputs.push(entry);
|
|
60
|
+
return entry;
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// Stores output (string or Buffer) as a blob, computes key with stageKey, and validates reads
|
|
64
|
+
// by delegating to stageKey/resolveReads (an unknown ref throws). A duplicate id throws too,
|
|
65
|
+
// since resolveReads resolves a stage: ref by id and a duplicate would make that ambiguous.
|
|
66
|
+
stage({ id, reads, model, output: stageOutput, verdict } = {}) {
|
|
67
|
+
if (!id) throw new Error("stage needs an id");
|
|
68
|
+
if (recipe.stages.some((s) => s.id === id)) throw new Error(`duplicate stage id: ${id}`);
|
|
69
|
+
const hex = putBlob(root, stageOutput);
|
|
70
|
+
const index = recipe.stages.length;
|
|
71
|
+
// The key must be computed from exactly what the recipe file will hold. A Date, an
|
|
72
|
+
// undefined, or anything else JSON writes differently from how it hashes would otherwise
|
|
73
|
+
// produce a recipe that fails its own key check the moment it is read back.
|
|
74
|
+
const entry = { id, reads: reads ?? [], model: asWritten(model ?? undefined), key: undefined, output: { sha256: hex }, verdict: asWritten(verdict) };
|
|
75
|
+
recipe.stages.push(entry);
|
|
76
|
+
try {
|
|
77
|
+
entry.key = stageKey(recipe, index);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
recipe.stages.pop();
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
return entry;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
// Writes the output file from the last stage's blob if it is absent, or checks the file on
|
|
86
|
+
// disk against it if present (throwing on a mismatch: the output on disk must be what the
|
|
87
|
+
// recipe says). Writes the recipe file and returns checkRecipe's findings; an unapproved
|
|
88
|
+
// recipe returning only the approver fail is expected, not an error. Refuses a stage-less
|
|
89
|
+
// recipe before writing anything (neither the output file nor the recipe file): a recipe
|
|
90
|
+
// with no stages has nothing to have made, so finishing one would write an "approved" recipe
|
|
91
|
+
// for content that does not exist.
|
|
92
|
+
finish({ approver = null } = {}) {
|
|
93
|
+
if (!recipe.stages.length) throw new Error("a recipe needs at least one stage");
|
|
94
|
+
recipe.approver = approver;
|
|
95
|
+
const last = recipe.stages[recipe.stages.length - 1];
|
|
96
|
+
recipe.output.sha256 = last.output.sha256;
|
|
97
|
+
if (existsSync(outputAbs)) {
|
|
98
|
+
const onDisk = readFileSync(outputAbs);
|
|
99
|
+
if (sha256(onDisk) !== last.output.sha256) {
|
|
100
|
+
throw new Error(`${output} does not match the recipe's last stage output`);
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
writeFileAtomic(outputAbs, getBlob(root, last.output.sha256));
|
|
104
|
+
}
|
|
105
|
+
writeRecipe(recipePath, recipe);
|
|
106
|
+
return { path: recipePath, findings: checkRecipe(asWritten(recipe)) };
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Rereads the recipe, sets approver, rewrites it, and returns the new findings.
|
|
112
|
+
export function approve(recipePath, by) {
|
|
113
|
+
const { data, error } = readRecipe(recipePath);
|
|
114
|
+
if (error) throw new Error(error);
|
|
115
|
+
data.approver = by;
|
|
116
|
+
writeRecipe(recipePath, data);
|
|
117
|
+
return checkRecipe(data);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A value exactly as the recipe file will hold it: what JSON.stringify writes, read back.
|
|
121
|
+
function asWritten(value) {
|
|
122
|
+
if (value === undefined) return undefined;
|
|
123
|
+
const text = JSON.stringify(value);
|
|
124
|
+
return text === undefined ? undefined : JSON.parse(text);
|
|
125
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Field-level rules for the nine writing blocks. src/writing.mjs owns the generic shape (a block
|
|
2
|
+
// is present or openly deferred, and every present block carries a check, a source and an
|
|
3
|
+
// author); this file owns what is INSIDE each block once it is present, block by block, as
|
|
4
|
+
// WRITING.md's schema documents it. Every finding here still reports under one of the nine core
|
|
5
|
+
// tests, with an id prefixed writing-<block>- (or writing-characters-<index>- for a character
|
|
6
|
+
// entry), so writing.mjs's blockStatus (which reads findings by id prefix, not by calling back
|
|
7
|
+
// into this file) keeps attributing brokenness correctly with no change on its side.
|
|
8
|
+
//
|
|
9
|
+
// Scope, stated once rather than re-argued at each block: every schema field not marked optional
|
|
10
|
+
// is required, and its absence fails test 1 (a missing required field), unless a more specific
|
|
11
|
+
// test owns that exact violation (a golden's why is test 6, goal.conditions is test 2, and so on).
|
|
12
|
+
//
|
|
13
|
+
// Paths (materials/dna/audience/... path-bearing fields) resolve the same way examples do
|
|
14
|
+
// elsewhere in this linter: relative to the spec file. That resolver (here) is supplied by
|
|
15
|
+
// writing.mjs, which already has spec.dir in scope; this file never touches spec directly.
|
|
16
|
+
//
|
|
17
|
+
// A character's speech.uses, speech.never, wants, fears, hides and arc_state are required
|
|
18
|
+
// (test 1), because dialogue cannot be specified without them. relationships stays optional: a
|
|
19
|
+
// character may genuinely relate to no one yet, and nothing gives it a closed set or a count.
|
|
20
|
+
|
|
21
|
+
import { statSync } from "node:fs";
|
|
22
|
+
import { str } from "./placeholder.mjs";
|
|
23
|
+
|
|
24
|
+
const f = (test, id, severity, message, fix) => ({ test, id, severity, message, fix });
|
|
25
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
26
|
+
const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
|
|
27
|
+
// "file", "other" (a directory or a device), or null when nothing is there at all — the same
|
|
28
|
+
// three-way classification the core examples rule uses (src/rules.mjs's kind()), so a real
|
|
29
|
+
// directory is reported as "is not a file" rather than the misleading "does not exist".
|
|
30
|
+
const pathKind = (here, p) => { try { return statSync(here(p)).isFile() ? "file" : "other"; } catch { return null; } };
|
|
31
|
+
// The two-finding shape every path-bearing field in this file shares: idBase-missing when
|
|
32
|
+
// nothing is there, idBase-not-file when something is there but it is not a file (a directory).
|
|
33
|
+
// subject is the human-readable name used in the message ("material", "dna.rules", "golden",
|
|
34
|
+
// "character ... entity"); fixHint is the same for both branches, since the fix is the same path
|
|
35
|
+
// edit either way.
|
|
36
|
+
function pathFindings(here, p, idBase, subject, fixHint) {
|
|
37
|
+
const k = pathKind(here, p);
|
|
38
|
+
if (!k) return [f(6, `${idBase}-missing`, "fail", `${subject} "${p}" does not exist`, fixHint)];
|
|
39
|
+
if (k !== "file") return [f(6, `${idBase}-not-file`, "fail", `${subject} "${p}" is not a file`, fixHint)];
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TRUST_VALUES = ["raw", "considered", "verified"];
|
|
44
|
+
const READER_VALUES = ["person", "agent"];
|
|
45
|
+
const CHANGE_KINDS = ["belief", "action", "feeling"];
|
|
46
|
+
const STANCE_VALUES = ["peer", "mentor", "witness", "guide"];
|
|
47
|
+
const IDENTITY_SHAPE = /^(role|character):(.+)$/;
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------- 1. materials ----------------
|
|
50
|
+
|
|
51
|
+
function materialsFields(raw, d, here, idPrefix) {
|
|
52
|
+
const out = [];
|
|
53
|
+
const items = list(raw.items);
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
// A third (or later) item sharing an already-duplicated id must not produce a second,
|
|
56
|
+
// textually identical finding: report each duplicated id once, the first time it repeats.
|
|
57
|
+
const reportedDup = new Set();
|
|
58
|
+
items.forEach((it, i) => {
|
|
59
|
+
const id = str(it?.id);
|
|
60
|
+
const tag = id || `#${i + 1}`;
|
|
61
|
+
if (!id) out.push(f(1, `${idPrefix}-item-${i}-id`, "fail", `materials item ${tag} has no id`, "Give it a short id, e.g. m1."));
|
|
62
|
+
else if (seen.has(id)) {
|
|
63
|
+
if (!reportedDup.has(id)) out.push(f(1, `${idPrefix}-item-id`, "fail", `materials id "${id}" is used twice`, "Ids must be unique across materials.items; rename one."));
|
|
64
|
+
reportedDup.add(id);
|
|
65
|
+
}
|
|
66
|
+
seen.add(id);
|
|
67
|
+
if (!str(it?.produced_by)) out.push(f(1, `${idPrefix}-item-${i}-produced-by`, "fail", `material ${tag} does not say who produced it`, "Add produced_by:."));
|
|
68
|
+
if (!str(it?.captured)) out.push(f(1, `${idPrefix}-item-${i}-captured`, "fail", `material ${tag} does not say when it was captured`, "Add captured:."));
|
|
69
|
+
if (!str(it?.how)) out.push(f(1, `${idPrefix}-item-${i}-how`, "fail", `material ${tag} does not say how it was captured`, "Add how:."));
|
|
70
|
+
const trust = str(it?.trust);
|
|
71
|
+
if (!TRUST_VALUES.includes(trust)) out.push(f(1, `${idPrefix}-item-${i}-trust`, "fail", `material ${tag} has trust "${trust || "(none)"}"`, "Set trust to raw, considered or verified."));
|
|
72
|
+
const p = str(it?.path);
|
|
73
|
+
if (!p) out.push(f(1, `${idPrefix}-item-${i}-path`, "fail", `material ${tag} has no path`, "Add path: to the material."));
|
|
74
|
+
else out.push(...pathFindings(here, p, `${idPrefix}-item-${i}-path`, "material", "Fix the path, or add the material file."));
|
|
75
|
+
});
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------- 2. dna ----------------------
|
|
80
|
+
|
|
81
|
+
function dnaFields(raw, d, here, idPrefix) {
|
|
82
|
+
const out = [];
|
|
83
|
+
if (!str(raw.writer)) out.push(f(1, `${idPrefix}-writer`, "fail", "writing.dna has no writer", "Add writer:."));
|
|
84
|
+
const scope = isObj(raw.scope) ? raw.scope : {};
|
|
85
|
+
if (!str(scope.form)) out.push(f(1, `${idPrefix}-scope-form`, "fail", "writing.dna.scope has no form", "Add scope.form:."));
|
|
86
|
+
if (!str(scope.audience)) out.push(f(1, `${idPrefix}-scope-audience`, "fail", "writing.dna.scope has no audience", "Add scope.audience:."));
|
|
87
|
+
if (!str(scope.purpose)) out.push(f(1, `${idPrefix}-scope-purpose`, "fail", "writing.dna.scope has no purpose", "Add scope.purpose:."));
|
|
88
|
+
const rulesPath = str(raw.rules);
|
|
89
|
+
if (!rulesPath) out.push(f(1, `${idPrefix}-rules`, "fail", "writing.dna has no rules", "Add rules: the path to the always-on writing style."));
|
|
90
|
+
else out.push(...pathFindings(here, rulesPath, `${idPrefix}-rules`, "dna.rules", "Fix the path, or add the file."));
|
|
91
|
+
const goldens = list(raw.goldens);
|
|
92
|
+
if (!goldens.length) out.push(f(1, `${idPrefix}-goldens`, "fail", "writing.dna has no goldens", "Add at least one golden under dna.goldens."));
|
|
93
|
+
goldens.forEach((g, i) => {
|
|
94
|
+
const p = str(g?.path);
|
|
95
|
+
if (!p) out.push(f(1, `${idPrefix}-golden-${i}-path`, "fail", `dna.goldens[${i + 1}] has no path`, "Add path: to the golden."));
|
|
96
|
+
else out.push(...pathFindings(here, p, `${idPrefix}-golden-${i}`, "golden", "Fix the path, or add the golden file."));
|
|
97
|
+
if (!str(g?.why)) out.push(f(6, `${idPrefix}-golden-${i}-why`, "fail", `golden "${p || `#${i + 1}`}" has no why`, "Add why: what it shows that an adjective could not."));
|
|
98
|
+
});
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------- 3. persona ------------------
|
|
103
|
+
|
|
104
|
+
function personaFields(raw, d, here, idPrefix) {
|
|
105
|
+
const out = [];
|
|
106
|
+
const identity = str(raw.identity);
|
|
107
|
+
if (!identity) {
|
|
108
|
+
out.push(f(1, `${idPrefix}-identity`, "fail", "writing.persona has no identity", "Add identity: self, role:<name>, or character:<id>."));
|
|
109
|
+
} else if (identity !== "self") {
|
|
110
|
+
const m = IDENTITY_SHAPE.exec(identity);
|
|
111
|
+
if (!m || !m[2].trim()) {
|
|
112
|
+
out.push(f(1, `${idPrefix}-identity`, "fail", `writing.persona.identity "${identity}" is not self, role:<name> or character:<id>`, "Set identity to self, role:<name>, or character:<id>."));
|
|
113
|
+
} else if (m[1] === "character") {
|
|
114
|
+
const cid = m[2].trim();
|
|
115
|
+
const chars = list(d.writing?.characters);
|
|
116
|
+
if (!chars.some((c) => str(c?.id) === cid)) {
|
|
117
|
+
out.push(f(1, `${idPrefix}-identity`, "fail", `writing.persona.identity names character "${cid}", which is not in writing.characters`, "Point identity at a character id that exists in writing.characters."));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const stance = str(raw.stance);
|
|
122
|
+
if (!stance) out.push(f(1, `${idPrefix}-stance`, "fail", "writing.persona has no stance", "Add stance: peer, mentor, witness or guide."));
|
|
123
|
+
else if (!STANCE_VALUES.includes(stance)) out.push(f(1, `${idPrefix}-stance`, "warn", `writing.persona.stance "${stance}" is outside peer, mentor, witness, guide`, "Consider peer, mentor, witness or guide."));
|
|
124
|
+
if (!list(raw.may_assert).some((x) => str(x))) out.push(f(1, `${idPrefix}-may-assert`, "fail", "writing.persona has no may_assert", "List at least one thing the persona may assert."));
|
|
125
|
+
if (!list(raw.will_not_say).some((x) => str(x))) out.push(f(5, `${idPrefix}-will-not-say`, "fail", "writing.persona.will_not_say is empty", "List at least one thing the persona will not say."));
|
|
126
|
+
const factsFrom = str(raw.facts_from);
|
|
127
|
+
if (factsFrom !== "sources") out.push(f(5, `${idPrefix}-facts-from`, "fail", `writing.persona.facts_from is "${factsFrom || "(none)"}", not "sources"`, "Set facts_from: sources."));
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------- 4. audience -----------------
|
|
132
|
+
|
|
133
|
+
const AUDIENCE_REQUIRED = ["who", "funnel_now", "believes_now", "wants", "reads_on"];
|
|
134
|
+
|
|
135
|
+
function audienceFields(raw, d, here, idPrefix) {
|
|
136
|
+
const out = [];
|
|
137
|
+
for (const field of AUDIENCE_REQUIRED) {
|
|
138
|
+
if (!str(raw[field])) out.push(f(1, `${idPrefix}-${field.replace(/_/g, "-")}`, "fail", `writing.audience has no ${field}`, `Add ${field}:.`));
|
|
139
|
+
}
|
|
140
|
+
if (!list(raw.knows).some((x) => str(x))) out.push(f(1, `${idPrefix}-knows`, "fail", "writing.audience has no knows", "List at least one term the reader already has."));
|
|
141
|
+
const reader = str(raw.reader);
|
|
142
|
+
if (!READER_VALUES.includes(reader)) out.push(f(1, `${idPrefix}-reader`, "fail", `writing.audience.reader is "${reader || "(none)"}"`, "Set reader to person or agent."));
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------- 5. goal ---------------------
|
|
147
|
+
|
|
148
|
+
const GOAL_REQUIRED = ["from", "to", "next_if_worked"];
|
|
149
|
+
|
|
150
|
+
function goalFields(raw, d, here, idPrefix) {
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const field of GOAL_REQUIRED) {
|
|
153
|
+
if (!str(raw[field])) out.push(f(1, `${idPrefix}-${field.replace(/_/g, "-")}`, "fail", `writing.goal has no ${field}`, `Add ${field}:.`));
|
|
154
|
+
}
|
|
155
|
+
const change = isObj(raw.change) ? raw.change : {};
|
|
156
|
+
const kind = str(change.kind);
|
|
157
|
+
if (!CHANGE_KINDS.includes(kind)) out.push(f(1, `${idPrefix}-change-kind`, "fail", `writing.goal.change.kind is "${kind || "(none)"}"`, "Set change.kind to belief, action or feeling."));
|
|
158
|
+
if (!str(change.text)) out.push(f(1, `${idPrefix}-change-text`, "fail", "writing.goal.change has no text", "Add change.text:."));
|
|
159
|
+
|
|
160
|
+
const listed = list(raw.conditions).map(str).filter(Boolean);
|
|
161
|
+
// Distinct ids only: five copies of one requirement are one condition, not five.
|
|
162
|
+
const conditions = [...new Set(listed)];
|
|
163
|
+
const repeated = [...new Set(listed.filter((cid, i) => listed.indexOf(cid) !== i))];
|
|
164
|
+
repeated.forEach((cid) => {
|
|
165
|
+
out.push(f(2, `${idPrefix}-conditions-duplicate`, "fail", `writing.goal.conditions lists "${cid}" more than once`, "List each requirement id once."));
|
|
166
|
+
});
|
|
167
|
+
if (conditions.length < 5 || conditions.length > 10) {
|
|
168
|
+
out.push(f(2, `${idPrefix}-conditions-count`, "fail", `writing.goal.conditions has ${conditions.length} distinct ids, outside 5 to 10`, "List 5 to 10 distinct requirement ids under goal.conditions."));
|
|
169
|
+
}
|
|
170
|
+
const reqIds = new Set(list(d.requirements).map((r) => str(r?.id)).filter(Boolean));
|
|
171
|
+
conditions.forEach((cid) => {
|
|
172
|
+
if (!reqIds.has(cid)) out.push(f(2, `${idPrefix}-conditions-unknown`, "fail", `writing.goal.conditions names "${cid}", which is not a requirement id`, "Point conditions at ids that exist under requirements:."));
|
|
173
|
+
});
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------- 6. form ---------------------
|
|
178
|
+
|
|
179
|
+
function formFields(raw, d, here, idPrefix) {
|
|
180
|
+
const out = [];
|
|
181
|
+
if (!str(raw.name)) out.push(f(1, `${idPrefix}-name`, "fail", "writing.form has no name", "Add name:."));
|
|
182
|
+
const length = isObj(raw.length) ? raw.length : {};
|
|
183
|
+
const minStr = str(length.min);
|
|
184
|
+
const maxStr = str(length.max);
|
|
185
|
+
// The YAML reader returns every scalar as a string, min: 600 included, so a numeric field is
|
|
186
|
+
// parsed explicitly here rather than compared as a closed-set string; a non-numeric length is a
|
|
187
|
+
// test 1 fail like any other malformed required field.
|
|
188
|
+
// Both bounds are whole numbers of at least 1: a length of zero, a negative length or half a
|
|
189
|
+
// word describes no piece anyone could write.
|
|
190
|
+
const positiveInt = (s) => /^\d+$/.test(s) && Number(s) >= 1;
|
|
191
|
+
const min = Number(minStr);
|
|
192
|
+
const max = Number(maxStr);
|
|
193
|
+
const minOk = positiveInt(minStr);
|
|
194
|
+
const maxOk = positiveInt(maxStr);
|
|
195
|
+
if (!minOk) out.push(f(1, `${idPrefix}-length-min`, "fail", `writing.form.length.min "${minStr || "(none)"}" is not a whole number of at least 1`, "Set length.min to a whole number, 1 or more."));
|
|
196
|
+
if (!maxOk) out.push(f(1, `${idPrefix}-length-max`, "fail", `writing.form.length.max "${maxStr || "(none)"}" is not a whole number of at least 1`, "Set length.max to a whole number, 1 or more."));
|
|
197
|
+
if (minOk && maxOk && min > max) out.push(f(1, `${idPrefix}-length-range`, "fail", `writing.form.length.min (${min}) is greater than length.max (${max})`, "Set min to no more than max."));
|
|
198
|
+
if (!str(length.unit)) out.push(f(1, `${idPrefix}-length-unit`, "fail", "writing.form.length has no unit", "Add length.unit:, e.g. words."));
|
|
199
|
+
if (!list(raw.required_parts).some((x) => str(x))) out.push(f(1, `${idPrefix}-required-parts`, "fail", "writing.form has no required_parts", "List at least one required part."));
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------- 7. spine --------------------
|
|
204
|
+
|
|
205
|
+
function spineFields(raw, d, here, idPrefix) {
|
|
206
|
+
const out = [];
|
|
207
|
+
if (!str(raw.kind)) out.push(f(1, `${idPrefix}-kind`, "fail", "writing.spine has no kind", "Add kind:."));
|
|
208
|
+
const claims = list(raw.claims);
|
|
209
|
+
// A repeated claim id is reported once per id and counts once toward 3 to 7: three copies of
|
|
210
|
+
// one claim are one claim. A claim with no id still counts (its own finding says what is wrong).
|
|
211
|
+
const seenClaims = new Set();
|
|
212
|
+
const reportedClaims = new Set();
|
|
213
|
+
let distinct = 0;
|
|
214
|
+
claims.forEach((c) => {
|
|
215
|
+
const id = str(c?.id);
|
|
216
|
+
if (id && seenClaims.has(id)) {
|
|
217
|
+
if (!reportedClaims.has(id)) out.push(f(1, `${idPrefix}-claim-id`, "fail", `spine claim id "${id}" is used twice`, "Ids must be unique across spine.claims; rename one, or merge the claims."));
|
|
218
|
+
reportedClaims.add(id);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (id) seenClaims.add(id);
|
|
222
|
+
distinct += 1;
|
|
223
|
+
});
|
|
224
|
+
if (distinct < 3 || distinct > 7) out.push(f(1, `${idPrefix}-claims-count`, "fail", `writing.spine has ${distinct} distinct claims, outside 3 to 7`, "List 3 to 7 claims, each with its own id, under spine.claims."));
|
|
225
|
+
const materialIds = new Set(list(d.writing?.materials?.items).map((m) => str(m?.id)).filter(Boolean));
|
|
226
|
+
claims.forEach((c, i) => {
|
|
227
|
+
const cid = str(c?.id) || `#${i + 1}`;
|
|
228
|
+
if (!str(c?.id)) out.push(f(1, `${idPrefix}-claim-${i}-id`, "fail", `spine claim ${cid} has no id`, "Give it a short id, e.g. c1."));
|
|
229
|
+
if (!str(c?.text)) out.push(f(1, `${idPrefix}-claim-${i}-text`, "fail", `spine claim "${cid}" has no text`, "Add text: to the claim."));
|
|
230
|
+
const refs = list(c?.materials).map(str).filter(Boolean);
|
|
231
|
+
if (!refs.length) {
|
|
232
|
+
out.push(f(4, `${idPrefix}-claim-${i}-materials`, "fail", `spine claim "${cid}" has no materials`, "Point materials: at one or more material ids."));
|
|
233
|
+
} else {
|
|
234
|
+
refs.forEach((ref) => {
|
|
235
|
+
const mid = ref.split("#")[0];
|
|
236
|
+
if (!materialIds.has(mid)) out.push(f(4, `${idPrefix}-claim-${i}-materials-unknown`, "fail", `spine claim "${cid}" points at material "${ref}", which is not in writing.materials.items`, "Point materials: at an id that exists in writing.materials.items."));
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------- 8. sources ------------------
|
|
244
|
+
|
|
245
|
+
function sourcesFields(raw, d, here, idPrefix) {
|
|
246
|
+
const out = [];
|
|
247
|
+
if (!str(raw.ledger)) out.push(f(1, `${idPrefix}-ledger`, "fail", "writing.sources has no ledger", "Add ledger: the path each run's claims are checked against."));
|
|
248
|
+
// sources.ledger is a path that need not exist before drafting: no existence check here, unlike
|
|
249
|
+
// every other path in this file.
|
|
250
|
+
const unsourced = str(raw.unsourced_claim);
|
|
251
|
+
if (!["fail", "warn"].includes(unsourced)) {
|
|
252
|
+
out.push(f(1, `${idPrefix}-unsourced-claim`, "fail", `writing.sources.unsourced_claim is "${unsourced || "(none)"}"`, "Set unsourced_claim to fail or warn."));
|
|
253
|
+
} else if (unsourced === "warn") {
|
|
254
|
+
out.push(f(1, `${idPrefix}-unsourced-claim-warn`, "warn", "writing.sources.unsourced_claim is warn; an unsourced claim will only warn, not fail the draft", "Set unsourced_claim: fail if an unsourced claim should block the draft."));
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------- 9. characters ---------------
|
|
260
|
+
|
|
261
|
+
// One character entry, called per-entry from writing.mjs the same way checkOwner is. idPrefix is
|
|
262
|
+
// already writing-characters-<index>, matching the block-attribution prefix blockStatus expects.
|
|
263
|
+
function characterFields(c, here, idPrefix) {
|
|
264
|
+
const out = [];
|
|
265
|
+
const tag = str(c?.id) || "?";
|
|
266
|
+
if (!str(c?.id)) out.push(f(1, `${idPrefix}-id`, "fail", "character has no id", "Give it a short id."));
|
|
267
|
+
|
|
268
|
+
// Raw array length, matching dnaFields' goldens.length check: a present-but-malformed entry
|
|
269
|
+
// (missing by or knows) must not ALSO trigger "has no knowledge" — that's only true when the
|
|
270
|
+
// list is literally empty.
|
|
271
|
+
const knowledge = list(c?.knowledge);
|
|
272
|
+
if (!knowledge.length) out.push(f(1, `${idPrefix}-knowledge`, "fail", `character "${tag}" has no knowledge`, "Add at least one { by, knows } entry under knowledge."));
|
|
273
|
+
knowledge.forEach((k, i) => {
|
|
274
|
+
if (!str(k?.by)) out.push(f(1, `${idPrefix}-knowledge-${i}-by`, "fail", `character "${tag}" knowledge entry ${i + 1} has no by`, "Add by: to the knowledge entry."));
|
|
275
|
+
if (!str(k?.knows)) out.push(f(1, `${idPrefix}-knowledge-${i}-knows`, "fail", `character "${tag}" knowledge entry ${i + 1} has no knows`, "Add knows: to the knowledge entry."));
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
if (!list(c?.golden_lines).some((x) => str(x))) out.push(f(6, `${idPrefix}-golden-lines`, "fail", `character "${tag}" has no golden_lines`, "Add at least one golden line."));
|
|
279
|
+
if (!list(c?.rejected_lines).some((x) => str(x))) out.push(f(6, `${idPrefix}-rejected-lines`, "fail", `character "${tag}" has no rejected_lines`, "Add at least one rejected line."));
|
|
280
|
+
// A line cannot be both how the character speaks and how they never would: the consistency
|
|
281
|
+
// check has nothing to grade against. Compared trimmed and case-folded.
|
|
282
|
+
const fold = (x) => str(x).toLowerCase();
|
|
283
|
+
const golden = new Set(list(c?.golden_lines).map(fold).filter(Boolean));
|
|
284
|
+
const clash = list(c?.rejected_lines).map(fold).filter((x) => x && golden.has(x));
|
|
285
|
+
if (clash.length) out.push(f(6, `${idPrefix}-line-conflict`, "fail", `character "${tag}" has "${clash[0]}" as both a golden and a rejected line`, "Remove the line from one of golden_lines or rejected_lines."));
|
|
286
|
+
|
|
287
|
+
// speech.uses, speech.never, wants, fears, hides and arc_state are what make dialogue
|
|
288
|
+
// specifiable. relationships is deliberately not required here: a character may genuinely
|
|
289
|
+
// relate to no one yet.
|
|
290
|
+
const speech = isObj(c?.speech) ? c.speech : {};
|
|
291
|
+
if (!list(speech.uses).some((x) => str(x))) out.push(f(1, `${idPrefix}-speech-uses`, "fail", `character "${tag}" speech.uses is empty`, "List at least one thing the character says."));
|
|
292
|
+
if (!list(speech.never).some((x) => str(x))) out.push(f(1, `${idPrefix}-speech-never`, "fail", `character "${tag}" speech.never is empty`, "List at least one thing the character never says."));
|
|
293
|
+
if (!str(c?.wants)) out.push(f(1, `${idPrefix}-wants`, "fail", `character "${tag}" has no wants`, "Add wants:."));
|
|
294
|
+
if (!str(c?.fears)) out.push(f(1, `${idPrefix}-fears`, "fail", `character "${tag}" has no fears`, "Add fears:."));
|
|
295
|
+
if (!str(c?.hides)) out.push(f(1, `${idPrefix}-hides`, "fail", `character "${tag}" has no hides`, "Add hides:."));
|
|
296
|
+
if (!str(c?.arc_state)) out.push(f(1, `${idPrefix}-arc-state`, "fail", `character "${tag}" has no arc_state`, "Add arc_state:."));
|
|
297
|
+
|
|
298
|
+
const entity = str(c?.entity);
|
|
299
|
+
if (entity) out.push(...pathFindings(here, entity, `${idPrefix}-entity`, `character "${tag}" entity`, "Fix the path, or remove entity."));
|
|
300
|
+
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// The eight object blocks' field rules, keyed by block name. characters is a list and is called
|
|
305
|
+
// per-entry (characterFields) directly from writing.mjs, not through this map.
|
|
306
|
+
export const BLOCK_FIELD_RULES = Object.freeze({
|
|
307
|
+
materials: materialsFields,
|
|
308
|
+
dna: dnaFields,
|
|
309
|
+
persona: personaFields,
|
|
310
|
+
audience: audienceFields,
|
|
311
|
+
goal: goalFields,
|
|
312
|
+
form: formFields,
|
|
313
|
+
spine: spineFields,
|
|
314
|
+
sources: sourcesFields,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
export { characterFields };
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// The writing profile's `hyperspec init --profile writing` skeleton.
|
|
2
|
+
//
|
|
3
|
+
// Every required writing block appears in full, in schema order, with every field present as a
|
|
4
|
+
// placeholder value. An operator opening the file is owed the shape of every block inline, not a
|
|
5
|
+
// pointer to go read the schema elsewhere. dna, persona, audience and goal also carry an open
|
|
6
|
+
// decision (id writing-<block>, the deferral id writing.mjs knows) naming the question that has
|
|
7
|
+
// to be answered before the placeholder means anything. The decision and the shape do not
|
|
8
|
+
// conflict: the placeholder content already fails its own field rules, so the decision never
|
|
9
|
+
// changes whether the spec passes, only what the operator is told to go decide.
|
|
10
|
+
//
|
|
11
|
+
// Every placeholder scalar in this file is the bare word "TODO". That is load-bearing: str() in
|
|
12
|
+
// src/placeholder.mjs treats a value that IS a placeholder word as blank, so every presence check
|
|
13
|
+
// on every field here fails on its own, without this file having to pick a field to break per
|
|
14
|
+
// block. Without that rule the character block, whose checks are all presence checks, would lint
|
|
15
|
+
// clean the moment init wrote it.
|
|
16
|
+
//
|
|
17
|
+
// init with no --profile never imports or calls this file: template.mjs's own template() is
|
|
18
|
+
// untouched, so a bare init is still byte-for-byte what it always was.
|
|
19
|
+
import { scalar } from "./template.mjs";
|
|
20
|
+
|
|
21
|
+
const DECISION_QUESTIONS = Object.freeze({
|
|
22
|
+
dna: "whose voice is this, scoped to what form, audience and purpose, and which goldens define it?",
|
|
23
|
+
persona: "who does the piece speak as, what may it assert, and what will it never say?",
|
|
24
|
+
audience: "who reads this, what do they already believe, and what do they want when they arrive?",
|
|
25
|
+
goal: "what belief, action or feeling should move, and which requirements would prove it did?",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Schema order (writing.mjs's BLOCKS): materials, dna, persona, audience, goal, form, spine,
|
|
29
|
+
// sources, characters. These four get an open decision in ADDITION to their placeholder block,
|
|
30
|
+
// because they are the ones that need real judgment before the placeholder means anything; every
|
|
31
|
+
// other required block gets only the placeholder.
|
|
32
|
+
const DECISION_BLOCKS = ["dna", "persona", "audience", "goal"];
|
|
33
|
+
|
|
34
|
+
function deferredDecision(block) {
|
|
35
|
+
return ` - id: writing-${block}
|
|
36
|
+
state: open
|
|
37
|
+
question: ${scalar(DECISION_QUESTIONS[block])}
|
|
38
|
+
source: hyperspec init --profile writing
|
|
39
|
+
author: agent:hyperspec-init
|
|
40
|
+
chosen_by: agent`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const CHARACTER_BLOCK = `
|
|
44
|
+
characters:
|
|
45
|
+
- id: TODO
|
|
46
|
+
speech:
|
|
47
|
+
uses:
|
|
48
|
+
- TODO
|
|
49
|
+
never:
|
|
50
|
+
- TODO
|
|
51
|
+
rhythm: TODO
|
|
52
|
+
wants: TODO
|
|
53
|
+
fears: TODO
|
|
54
|
+
hides: TODO
|
|
55
|
+
knowledge:
|
|
56
|
+
- by: TODO
|
|
57
|
+
knows: TODO
|
|
58
|
+
arc_state: TODO
|
|
59
|
+
golden_lines:
|
|
60
|
+
- TODO
|
|
61
|
+
rejected_lines:
|
|
62
|
+
- TODO
|
|
63
|
+
check:
|
|
64
|
+
rubric: TODO
|
|
65
|
+
source: TODO
|
|
66
|
+
author: TODO`;
|
|
67
|
+
|
|
68
|
+
export function writingTemplate({ title = "Untitled", form = "essay", fiction = false } = {}) {
|
|
69
|
+
const heading = String(title).replace(/\s+/g, " ").trim();
|
|
70
|
+
const kind = String(form || "essay");
|
|
71
|
+
const decisions = DECISION_BLOCKS.map(deferredDecision).join("\n");
|
|
72
|
+
const characters = fiction ? CHARACTER_BLOCK : "";
|
|
73
|
+
|
|
74
|
+
return `---
|
|
75
|
+
hyperspec: "0.1"
|
|
76
|
+
title: ${scalar(String(title))}
|
|
77
|
+
kind: ${scalar(kind)}
|
|
78
|
+
profile: writing
|
|
79
|
+
decisions:
|
|
80
|
+
${decisions}
|
|
81
|
+
requirements: []
|
|
82
|
+
rejects: []
|
|
83
|
+
examples: []
|
|
84
|
+
resume:
|
|
85
|
+
next_action: answer the open decisions, then fill in every writing block below
|
|
86
|
+
feedback:
|
|
87
|
+
issues: ""
|
|
88
|
+
fork: ""
|
|
89
|
+
improvement:
|
|
90
|
+
ledger: runs.jsonl
|
|
91
|
+
writing:
|
|
92
|
+
materials:
|
|
93
|
+
items:
|
|
94
|
+
- id: m1
|
|
95
|
+
path: materials/TODO.md
|
|
96
|
+
produced_by: TODO
|
|
97
|
+
captured: TODO
|
|
98
|
+
how: TODO
|
|
99
|
+
trust: TODO
|
|
100
|
+
check:
|
|
101
|
+
station: TODO
|
|
102
|
+
source: TODO
|
|
103
|
+
author: TODO
|
|
104
|
+
dna:
|
|
105
|
+
writer: TODO
|
|
106
|
+
scope:
|
|
107
|
+
form: TODO
|
|
108
|
+
audience: TODO
|
|
109
|
+
purpose: TODO
|
|
110
|
+
rules: TODO
|
|
111
|
+
goldens:
|
|
112
|
+
- path: goldens/TODO.md
|
|
113
|
+
why: TODO
|
|
114
|
+
check:
|
|
115
|
+
rubric: TODO
|
|
116
|
+
source: TODO
|
|
117
|
+
author: TODO
|
|
118
|
+
persona:
|
|
119
|
+
identity: TODO
|
|
120
|
+
stance: TODO
|
|
121
|
+
may_assert:
|
|
122
|
+
- TODO
|
|
123
|
+
will_not_say:
|
|
124
|
+
- TODO
|
|
125
|
+
facts_from: TODO
|
|
126
|
+
check:
|
|
127
|
+
rubric: TODO
|
|
128
|
+
source: TODO
|
|
129
|
+
author: TODO
|
|
130
|
+
audience:
|
|
131
|
+
who: TODO
|
|
132
|
+
funnel_now: TODO
|
|
133
|
+
knows:
|
|
134
|
+
- TODO
|
|
135
|
+
believes_now: TODO
|
|
136
|
+
wants: TODO
|
|
137
|
+
reads_on: TODO
|
|
138
|
+
reader: TODO
|
|
139
|
+
check:
|
|
140
|
+
station: TODO
|
|
141
|
+
rubric: TODO
|
|
142
|
+
source: TODO
|
|
143
|
+
author: TODO
|
|
144
|
+
goal:
|
|
145
|
+
from: TODO
|
|
146
|
+
to: TODO
|
|
147
|
+
next_if_worked: TODO
|
|
148
|
+
change:
|
|
149
|
+
kind: TODO
|
|
150
|
+
text: TODO
|
|
151
|
+
conditions:
|
|
152
|
+
- TODO
|
|
153
|
+
check:
|
|
154
|
+
rubric: TODO
|
|
155
|
+
source: TODO
|
|
156
|
+
author: TODO
|
|
157
|
+
form:
|
|
158
|
+
name: ${scalar(kind)}
|
|
159
|
+
length:
|
|
160
|
+
min: TODO
|
|
161
|
+
max: TODO
|
|
162
|
+
unit: TODO
|
|
163
|
+
required_parts:
|
|
164
|
+
- TODO
|
|
165
|
+
stations: []
|
|
166
|
+
check:
|
|
167
|
+
station: TODO
|
|
168
|
+
source: TODO
|
|
169
|
+
author: TODO
|
|
170
|
+
spine:
|
|
171
|
+
kind: TODO
|
|
172
|
+
claims:
|
|
173
|
+
- id: c1
|
|
174
|
+
text: TODO
|
|
175
|
+
materials: [m1]
|
|
176
|
+
check:
|
|
177
|
+
rubric: TODO
|
|
178
|
+
source: TODO
|
|
179
|
+
author: TODO
|
|
180
|
+
sources:
|
|
181
|
+
ledger: TODO.claims.jsonl
|
|
182
|
+
unsourced_claim: TODO
|
|
183
|
+
check:
|
|
184
|
+
station: TODO
|
|
185
|
+
source: TODO
|
|
186
|
+
author: TODO${characters}
|
|
187
|
+
fiction: ${fiction ? "true" : "false"}
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
# ${heading}
|
|
191
|
+
`;
|
|
192
|
+
}
|