@supersuit/hyperspec 0.2.0 → 0.4.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 (43) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +53 -3
  3. package/SPEC.md +24 -10
  4. package/WRITING.md +597 -0
  5. package/bin/hyperspec.mjs +94 -3
  6. package/examples/minimal.hyperspec.md +2 -2
  7. package/examples/writing/essay/goldens/close.md +2 -0
  8. package/examples/writing/essay/goldens/opening.md +2 -0
  9. package/examples/writing/essay/materials/interview-notes.md +12 -0
  10. package/examples/writing/essay/materials/interview-notes.md.segments.jsonl +10 -0
  11. package/examples/writing/essay/materials/team-survey.md +7 -0
  12. package/examples/writing/essay/materials/team-survey.md.segments.jsonl +6 -0
  13. package/examples/writing/essay/materials/voice-memo.md +18 -0
  14. package/examples/writing/essay/materials/voice-memo.md.segments.jsonl +7 -0
  15. package/examples/writing/essay/runs.jsonl +0 -0
  16. package/examples/writing/essay.hyperspec.md +220 -0
  17. package/examples/writing/story/goldens/dialogue.md +3 -0
  18. package/examples/writing/story/goldens/opening.md +3 -0
  19. package/examples/writing/story/materials/bakery-visit.md +9 -0
  20. package/examples/writing/story/materials/bakery-visit.md.segments.jsonl +13 -0
  21. package/examples/writing/story/materials/notes.md +16 -0
  22. package/examples/writing/story/materials/notes.md.segments.jsonl +7 -0
  23. package/examples/writing/story/materials/scene-list.md +7 -0
  24. package/examples/writing/story/materials/scene-list.md.segments.jsonl +14 -0
  25. package/examples/writing/story/runs.jsonl +0 -0
  26. package/examples/writing/story.hyperspec.md +288 -0
  27. package/examples/writing/style-rules.md +19 -0
  28. package/package.json +4 -2
  29. package/src/blobs.mjs +1 -1
  30. package/src/compare.mjs +6 -6
  31. package/src/fsutil.mjs +1 -1
  32. package/src/labels.mjs +6 -0
  33. package/src/placeholder.mjs +20 -0
  34. package/src/profiles.mjs +50 -0
  35. package/src/reproduce.mjs +5 -5
  36. package/src/rules.mjs +25 -13
  37. package/src/score.mjs +7 -1
  38. package/src/segments.mjs +407 -0
  39. package/src/template.mjs +4 -1
  40. package/src/writing-exports.mjs +6 -0
  41. package/src/writing-fields.mjs +418 -0
  42. package/src/writing-template.mjs +199 -0
  43. package/src/writing.mjs +181 -0
@@ -0,0 +1,418 @@
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
+ import { readSegments } from "./segments.mjs";
24
+
25
+ const f = (test, id, severity, message, fix) => ({ test, id, severity, message, fix });
26
+ const list = (v) => (Array.isArray(v) ? v : []);
27
+ const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
28
+ // "file", "other" (a directory or a device), or null when nothing is there at all. This is the same
29
+ // three-way classification the core examples rule uses (src/rules.mjs's kind()), so a real
30
+ // directory is reported as "is not a file" rather than the misleading "does not exist".
31
+ const pathKind = (here, p) => { try { return statSync(here(p)).isFile() ? "file" : "other"; } catch { return null; } };
32
+ // The two-finding shape every path-bearing field in this file shares: idBase-missing when
33
+ // nothing is there, idBase-not-file when something is there but it is not a file (a directory).
34
+ // subject is the human-readable name used in the message ("material", "dna.rules", "golden",
35
+ // "character ... entity"); fixHint is the same for both branches, since the fix is the same path
36
+ // edit either way.
37
+ function pathFindings(here, p, idBase, subject, fixHint) {
38
+ const k = pathKind(here, p);
39
+ if (!k) return [f(6, `${idBase}-missing`, "fail", `${subject} "${p}" does not exist`, fixHint)];
40
+ if (k !== "file") return [f(6, `${idBase}-not-file`, "fail", `${subject} "${p}" is not a file`, fixHint)];
41
+ return [];
42
+ }
43
+
44
+ const TRUST_VALUES = ["raw", "considered", "verified"];
45
+ const READER_VALUES = ["person", "agent"];
46
+ const CHANGE_KINDS = ["belief", "action", "feeling"];
47
+ const STANCE_VALUES = ["peer", "mentor", "witness", "guide"];
48
+ const IDENTITY_SHAPE = /^(role|character):(.+)$/;
49
+
50
+ // ---------------------------------------------------------------- 1. materials ----------------
51
+
52
+ function materialsFields(raw, d, here, idPrefix) {
53
+ const out = [];
54
+ const items = list(raw.items);
55
+ const seen = new Set();
56
+ // A third (or later) item sharing an already-duplicated id must not produce a second,
57
+ // textually identical finding: report each duplicated id once, the first time it repeats.
58
+ const reportedDup = new Set();
59
+ items.forEach((it, i) => {
60
+ const id = str(it?.id);
61
+ const tag = id || `#${i + 1}`;
62
+ 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."));
63
+ else if (seen.has(id)) {
64
+ 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."));
65
+ reportedDup.add(id);
66
+ }
67
+ seen.add(id);
68
+ 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:."));
69
+ if (!str(it?.captured)) out.push(f(1, `${idPrefix}-item-${i}-captured`, "fail", `material ${tag} does not say when it was captured`, "Add captured:."));
70
+ if (!str(it?.how)) out.push(f(1, `${idPrefix}-item-${i}-how`, "fail", `material ${tag} does not say how it was captured`, "Add how:."));
71
+ const trust = str(it?.trust);
72
+ 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."));
73
+ const p = str(it?.path);
74
+ if (!p) out.push(f(1, `${idPrefix}-item-${i}-path`, "fail", `material ${tag} has no path`, "Add path: to the material."));
75
+ else out.push(...pathFindings(here, p, `${idPrefix}-item-${i}-path`, "material", "Fix the path, or add the material file."));
76
+
77
+ // Marking is required from 0.4 on. A material item with no segments: field is not
78
+ // marked at all (the design puts marking before specifying), so it fails on its own, distinct
79
+ // from the segments file existing but being broken (readSegments' own findings below). The
80
+ // material's text-dependent checks (verbatim, coverage, overlap, staleness) only run when the
81
+ // path itself already resolved to a real file, so a broken path is never reported twice: once
82
+ // here for the path field and again for the material readSegments could not read.
83
+ const segPath = str(it?.segments);
84
+ if (!segPath) {
85
+ out.push(f(1, "writing-materials-unmarked", "fail",
86
+ `material ${tag} is not marked (no segments field)`,
87
+ "Run `hyperspec segments init <material> --id <id>`, then add segments: to the material item."));
88
+ } else {
89
+ const { findings: segFindings } = readSegments(here(segPath), { materialPath: materialFilePath(it, here), materialId: id || undefined, ...shownPaths(it, segPath) });
90
+ out.push(...segFindings);
91
+ }
92
+ });
93
+ return out;
94
+ }
95
+
96
+ // The material file readSegments checks segment text against, or undefined when the item's own
97
+ // path is missing or is not a file. That broken path is already reported by pathFindings (test 6);
98
+ // passing it on would make readSegments report the same root cause a second time, under test 1.
99
+ // materialsFields and resolveMaterialSegments both resolve through here, so they cannot disagree.
100
+ function materialFilePath(item, here) {
101
+ const p = str(item?.path);
102
+ return p && pathKind(here, p) === "file" ? here(p) : undefined;
103
+ }
104
+
105
+ // The paths readSegments' messages print: exactly as the spec wrote them, the way every other path
106
+ // finding in the linter reads, never resolved against the spec's folder. A finding pasted into a
107
+ // public issue then names no one's home folder, and --json is the same on every machine.
108
+ function shownPaths(item, segPath) {
109
+ return { displayPath: segPath, materialDisplayPath: str(item?.path) || undefined };
110
+ }
111
+
112
+ // Resolves ONE material item's segments (for spine ref resolution below). Never pushes
113
+ // readSegments' own findings: those are already reported once, by materialsFields, under the
114
+ // materials block; this is read-only lookup. When nothing resolves, `why` says which of the three
115
+ // causes it was, because each needs a different fix: "unmarked" (no segments: field), "unreadable"
116
+ // (the segments file does not exist or cannot be read) or "empty" (read, but no segment lines).
117
+ function resolveMaterialSegments(item, here) {
118
+ const segPath = str(item?.segments);
119
+ if (!segPath) return { segments: [], loaded: false, why: "unmarked" };
120
+ const { segments, findings } = readSegments(here(segPath), { materialPath: materialFilePath(item, here), materialId: str(item?.id) || undefined, ...shownPaths(item, segPath) });
121
+ if (segments.length > 0) return { segments, loaded: true };
122
+ const unreadable = findings.some((x) => x.id === "writing-materials-segments-missing");
123
+ return { segments, loaded: false, why: unreadable ? "unreadable" : "empty" };
124
+ }
125
+
126
+ const UNRESOLVABLE_BECAUSE = {
127
+ unmarked: "it is not marked (no segments field)",
128
+ unreadable: "its segments file could not be read",
129
+ empty: "its segments file has no segments",
130
+ };
131
+
132
+ // ---------------------------------------------------------------- 2. dna ----------------------
133
+
134
+ function dnaFields(raw, d, here, idPrefix) {
135
+ const out = [];
136
+ if (!str(raw.writer)) out.push(f(1, `${idPrefix}-writer`, "fail", "writing.dna has no writer", "Add writer:."));
137
+ const scope = isObj(raw.scope) ? raw.scope : {};
138
+ if (!str(scope.form)) out.push(f(1, `${idPrefix}-scope-form`, "fail", "writing.dna.scope has no form", "Add scope.form:."));
139
+ if (!str(scope.audience)) out.push(f(1, `${idPrefix}-scope-audience`, "fail", "writing.dna.scope has no audience", "Add scope.audience:."));
140
+ if (!str(scope.purpose)) out.push(f(1, `${idPrefix}-scope-purpose`, "fail", "writing.dna.scope has no purpose", "Add scope.purpose:."));
141
+ const rulesPath = str(raw.rules);
142
+ if (!rulesPath) out.push(f(1, `${idPrefix}-rules`, "fail", "writing.dna has no rules", "Add rules: the path to the always-on writing style."));
143
+ else out.push(...pathFindings(here, rulesPath, `${idPrefix}-rules`, "dna.rules", "Fix the path, or add the file."));
144
+ const goldens = list(raw.goldens);
145
+ if (!goldens.length) out.push(f(1, `${idPrefix}-goldens`, "fail", "writing.dna has no goldens", "Add at least one golden under dna.goldens."));
146
+ goldens.forEach((g, i) => {
147
+ const p = str(g?.path);
148
+ if (!p) out.push(f(1, `${idPrefix}-golden-${i}-path`, "fail", `dna.goldens[${i + 1}] has no path`, "Add path: to the golden."));
149
+ else out.push(...pathFindings(here, p, `${idPrefix}-golden-${i}`, "golden", "Fix the path, or add the golden file."));
150
+ 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."));
151
+ });
152
+ return out;
153
+ }
154
+
155
+ // ---------------------------------------------------------------- 3. persona ------------------
156
+
157
+ function personaFields(raw, d, here, idPrefix) {
158
+ const out = [];
159
+ const identity = str(raw.identity);
160
+ if (!identity) {
161
+ out.push(f(1, `${idPrefix}-identity`, "fail", "writing.persona has no identity", "Add identity: self, role:<name>, or character:<id>."));
162
+ } else if (identity !== "self") {
163
+ const m = IDENTITY_SHAPE.exec(identity);
164
+ if (!m || !m[2].trim()) {
165
+ 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>."));
166
+ } else if (m[1] === "character") {
167
+ const cid = m[2].trim();
168
+ const chars = list(d.writing?.characters);
169
+ if (!chars.some((c) => str(c?.id) === cid)) {
170
+ 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."));
171
+ }
172
+ }
173
+ }
174
+ const stance = str(raw.stance);
175
+ if (!stance) out.push(f(1, `${idPrefix}-stance`, "fail", "writing.persona has no stance", "Add stance: peer, mentor, witness or guide."));
176
+ 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."));
177
+ 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."));
178
+ 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."));
179
+ const factsFrom = str(raw.facts_from);
180
+ if (factsFrom !== "sources") out.push(f(5, `${idPrefix}-facts-from`, "fail", `writing.persona.facts_from is "${factsFrom || "(none)"}", not "sources"`, "Set facts_from: sources."));
181
+ return out;
182
+ }
183
+
184
+ // ---------------------------------------------------------------- 4. audience -----------------
185
+
186
+ const AUDIENCE_REQUIRED = ["who", "funnel_now", "believes_now", "wants", "reads_on"];
187
+
188
+ function audienceFields(raw, d, here, idPrefix) {
189
+ const out = [];
190
+ for (const field of AUDIENCE_REQUIRED) {
191
+ if (!str(raw[field])) out.push(f(1, `${idPrefix}-${field.replace(/_/g, "-")}`, "fail", `writing.audience has no ${field}`, `Add ${field}:.`));
192
+ }
193
+ 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."));
194
+ const reader = str(raw.reader);
195
+ if (!READER_VALUES.includes(reader)) out.push(f(1, `${idPrefix}-reader`, "fail", `writing.audience.reader is "${reader || "(none)"}"`, "Set reader to person or agent."));
196
+ return out;
197
+ }
198
+
199
+ // ---------------------------------------------------------------- 5. goal ---------------------
200
+
201
+ const GOAL_REQUIRED = ["from", "to", "next_if_worked"];
202
+
203
+ function goalFields(raw, d, here, idPrefix) {
204
+ const out = [];
205
+ for (const field of GOAL_REQUIRED) {
206
+ if (!str(raw[field])) out.push(f(1, `${idPrefix}-${field.replace(/_/g, "-")}`, "fail", `writing.goal has no ${field}`, `Add ${field}:.`));
207
+ }
208
+ const change = isObj(raw.change) ? raw.change : {};
209
+ const kind = str(change.kind);
210
+ 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."));
211
+ if (!str(change.text)) out.push(f(1, `${idPrefix}-change-text`, "fail", "writing.goal.change has no text", "Add change.text:."));
212
+
213
+ const listed = list(raw.conditions).map(str).filter(Boolean);
214
+ // Distinct ids only: five copies of one requirement are one condition, not five.
215
+ const conditions = [...new Set(listed)];
216
+ const repeated = [...new Set(listed.filter((cid, i) => listed.indexOf(cid) !== i))];
217
+ repeated.forEach((cid) => {
218
+ out.push(f(2, `${idPrefix}-conditions-duplicate`, "fail", `writing.goal.conditions lists "${cid}" more than once`, "List each requirement id once."));
219
+ });
220
+ if (conditions.length < 5 || conditions.length > 10) {
221
+ 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."));
222
+ }
223
+ const reqIds = new Set(list(d.requirements).map((r) => str(r?.id)).filter(Boolean));
224
+ conditions.forEach((cid) => {
225
+ 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:."));
226
+ });
227
+ return out;
228
+ }
229
+
230
+ // ---------------------------------------------------------------- 6. form ---------------------
231
+
232
+ function formFields(raw, d, here, idPrefix) {
233
+ const out = [];
234
+ if (!str(raw.name)) out.push(f(1, `${idPrefix}-name`, "fail", "writing.form has no name", "Add name:."));
235
+ const length = isObj(raw.length) ? raw.length : {};
236
+ const minStr = str(length.min);
237
+ const maxStr = str(length.max);
238
+ // The YAML reader returns every scalar as a string, min: 600 included, so a numeric field is
239
+ // parsed explicitly here rather than compared as a closed-set string; a non-numeric length is a
240
+ // test 1 fail like any other malformed required field.
241
+ // Both bounds are whole numbers of at least 1: a length of zero, a negative length or half a
242
+ // word describes no piece anyone could write.
243
+ const positiveInt = (s) => /^\d+$/.test(s) && Number(s) >= 1;
244
+ const min = Number(minStr);
245
+ const max = Number(maxStr);
246
+ const minOk = positiveInt(minStr);
247
+ const maxOk = positiveInt(maxStr);
248
+ 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."));
249
+ 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."));
250
+ 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."));
251
+ if (!str(length.unit)) out.push(f(1, `${idPrefix}-length-unit`, "fail", "writing.form.length has no unit", "Add length.unit:, e.g. words."));
252
+ 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."));
253
+ return out;
254
+ }
255
+
256
+ // ---------------------------------------------------------------- 7. spine --------------------
257
+
258
+ function spineFields(raw, d, here, idPrefix) {
259
+ const out = [];
260
+ if (!str(raw.kind)) out.push(f(1, `${idPrefix}-kind`, "fail", "writing.spine has no kind", "Add kind:."));
261
+ const claims = list(raw.claims);
262
+ // A repeated claim id is reported once per id and counts once toward 3 to 7: three copies of
263
+ // one claim are one claim. A claim with no id still counts (its own finding says what is wrong).
264
+ const seenClaims = new Set();
265
+ const reportedClaims = new Set();
266
+ let distinct = 0;
267
+ claims.forEach((c) => {
268
+ const id = str(c?.id);
269
+ if (id && seenClaims.has(id)) {
270
+ 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."));
271
+ reportedClaims.add(id);
272
+ return;
273
+ }
274
+ if (id) seenClaims.add(id);
275
+ distinct += 1;
276
+ });
277
+ 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."));
278
+ const items = list(d.writing?.materials?.items);
279
+ const itemsById = new Map(items.map((m) => [str(m?.id), m]).filter(([id]) => id));
280
+ const materialIds = new Set(itemsById.keys());
281
+ // A material whose segments cannot be resolved at all (no segments: field, a segments file that
282
+ // cannot be read, or one with no segment lines) makes every #segment ref against it equally
283
+ // unresolvable. Report that once per material, not once per ref: two claims both pointing at
284
+ // "m1#s1" and "m1#s2" when m1 is unmarked are the same underlying problem, not two.
285
+ const segmentsCache = new Map();
286
+ const segmentsFor = (mid) => {
287
+ if (!segmentsCache.has(mid)) segmentsCache.set(mid, resolveMaterialSegments(itemsById.get(mid), here));
288
+ return segmentsCache.get(mid);
289
+ };
290
+ const reportedUnresolvable = new Set();
291
+ claims.forEach((c, i) => {
292
+ const cid = str(c?.id) || `#${i + 1}`;
293
+ 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."));
294
+ if (!str(c?.text)) out.push(f(1, `${idPrefix}-claim-${i}-text`, "fail", `spine claim "${cid}" has no text`, "Add text: to the claim."));
295
+ const refs = list(c?.materials).map(str).filter(Boolean);
296
+ if (!refs.length) {
297
+ out.push(f(4, `${idPrefix}-claim-${i}-materials`, "fail", `spine claim "${cid}" has no materials`, "Point materials: at one or more material ids."));
298
+ } else {
299
+ refs.forEach((ref) => {
300
+ const hashIdx = ref.indexOf("#");
301
+ const mid = hashIdx === -1 ? ref : ref.slice(0, hashIdx);
302
+ const segId = hashIdx === -1 ? "" : ref.slice(hashIdx + 1);
303
+ if (!materialIds.has(mid)) {
304
+ 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."));
305
+ return;
306
+ }
307
+ // A bare material id (no "#") stays valid on its own; only a ref naming a specific segment
308
+ // needs resolving against that material's segments file. "m1#" names an empty segment id,
309
+ // which is not a bare ref, so it goes on to fail as an unknown segment.
310
+ if (hashIdx === -1) return;
311
+ const { segments, loaded, why } = segmentsFor(mid);
312
+ if (!loaded) {
313
+ if (!reportedUnresolvable.has(mid)) {
314
+ out.push(f(4, `${idPrefix}-materials-segments-unresolvable-${mid}`, "fail",
315
+ `spine claims point at material "${mid}"'s segments, but ${UNRESOLVABLE_BECAUSE[why]}`,
316
+ "Run `hyperspec segments init` on the material, label every segment, then re-check the spine refs."));
317
+ reportedUnresolvable.add(mid);
318
+ }
319
+ return;
320
+ }
321
+ const seg = segId ? segments.find((s) => str(s?.id) === segId) : undefined;
322
+ if (!seg) {
323
+ out.push(f(4, `${idPrefix}-claim-${i}-materials-segment-unknown`, "fail",
324
+ `spine claim "${cid}" points at material "${ref}", which is not a segment in "${mid}"'s segments file`,
325
+ "Point materials: at a segment id that exists in the material's segments file, or drop the #segment suffix to reference the whole material."));
326
+ return;
327
+ }
328
+ const label = typeof seg.label === "string" ? seg.label : "";
329
+ if (label === "private") {
330
+ out.push(f(5, `${idPrefix}-claim-${i}-materials-segment-private`, "fail",
331
+ `spine claim "${cid}" points at material "${ref}", which is labeled private (private is never used)`,
332
+ "Point materials: at a different segment, or drop this ref."));
333
+ } else if (label === "question") {
334
+ out.push(f(5, `${idPrefix}-claim-${i}-materials-segment-question`, "fail",
335
+ `spine claim "${cid}" points at material "${ref}", which is labeled question (a question is never an assertion)`,
336
+ "Point materials: at a different segment, or drop this ref."));
337
+ }
338
+ });
339
+ }
340
+ });
341
+ return out;
342
+ }
343
+
344
+ // ---------------------------------------------------------------- 8. sources ------------------
345
+
346
+ function sourcesFields(raw, d, here, idPrefix) {
347
+ const out = [];
348
+ 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."));
349
+ // sources.ledger is a path that need not exist before drafting: no existence check here, unlike
350
+ // every other path in this file.
351
+ const unsourced = str(raw.unsourced_claim);
352
+ if (!["fail", "warn"].includes(unsourced)) {
353
+ out.push(f(1, `${idPrefix}-unsourced-claim`, "fail", `writing.sources.unsourced_claim is "${unsourced || "(none)"}"`, "Set unsourced_claim to fail or warn."));
354
+ } else if (unsourced === "warn") {
355
+ 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."));
356
+ }
357
+ return out;
358
+ }
359
+
360
+ // ---------------------------------------------------------------- 9. characters ---------------
361
+
362
+ // One character entry, called per-entry from writing.mjs the same way checkOwner is. idPrefix is
363
+ // already writing-characters-<index>, matching the block-attribution prefix blockStatus expects.
364
+ function characterFields(c, here, idPrefix) {
365
+ const out = [];
366
+ const tag = str(c?.id) || "?";
367
+ if (!str(c?.id)) out.push(f(1, `${idPrefix}-id`, "fail", "character has no id", "Give it a short id."));
368
+
369
+ // Raw array length, matching dnaFields' goldens.length check: a present-but-malformed entry
370
+ // (missing by or knows) must not ALSO trigger "has no knowledge"; that is only true when the
371
+ // list is literally empty.
372
+ const knowledge = list(c?.knowledge);
373
+ if (!knowledge.length) out.push(f(1, `${idPrefix}-knowledge`, "fail", `character "${tag}" has no knowledge`, "Add at least one { by, knows } entry under knowledge."));
374
+ knowledge.forEach((k, i) => {
375
+ 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."));
376
+ 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."));
377
+ });
378
+
379
+ 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."));
380
+ 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."));
381
+ // A line cannot be both how the character speaks and how they never would: the consistency
382
+ // check has nothing to grade against. Compared trimmed and case-folded.
383
+ const fold = (x) => str(x).toLowerCase();
384
+ const golden = new Set(list(c?.golden_lines).map(fold).filter(Boolean));
385
+ const clash = list(c?.rejected_lines).map(fold).filter((x) => x && golden.has(x));
386
+ 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."));
387
+
388
+ // speech.uses, speech.never, wants, fears, hides and arc_state are what make dialogue
389
+ // specifiable. relationships is deliberately not required here: a character may genuinely
390
+ // relate to no one yet.
391
+ const speech = isObj(c?.speech) ? c.speech : {};
392
+ 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."));
393
+ 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."));
394
+ if (!str(c?.wants)) out.push(f(1, `${idPrefix}-wants`, "fail", `character "${tag}" has no wants`, "Add wants:."));
395
+ if (!str(c?.fears)) out.push(f(1, `${idPrefix}-fears`, "fail", `character "${tag}" has no fears`, "Add fears:."));
396
+ if (!str(c?.hides)) out.push(f(1, `${idPrefix}-hides`, "fail", `character "${tag}" has no hides`, "Add hides:."));
397
+ if (!str(c?.arc_state)) out.push(f(1, `${idPrefix}-arc-state`, "fail", `character "${tag}" has no arc_state`, "Add arc_state:."));
398
+
399
+ const entity = str(c?.entity);
400
+ if (entity) out.push(...pathFindings(here, entity, `${idPrefix}-entity`, `character "${tag}" entity`, "Fix the path, or remove entity."));
401
+
402
+ return out;
403
+ }
404
+
405
+ // The eight object blocks' field rules, keyed by block name. characters is a list and is called
406
+ // per-entry (characterFields) directly from writing.mjs, not through this map.
407
+ export const BLOCK_FIELD_RULES = Object.freeze({
408
+ materials: materialsFields,
409
+ dna: dnaFields,
410
+ persona: personaFields,
411
+ audience: audienceFields,
412
+ goal: goalFields,
413
+ form: formFields,
414
+ spine: spineFields,
415
+ sources: sourcesFields,
416
+ });
417
+
418
+ export { characterFields };
@@ -0,0 +1,199 @@
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
+ // Two values are not placeholders. The material item's segments: names the file `hyperspec
18
+ // segments init materials/TODO.md` would write, so it follows the path placeholder beside it and
19
+ // fails as a segments file that does not exist yet (every material must be marked). And the
20
+ // materials check.station is the marking station itself, since that check is the same for every
21
+ // writing spec: the linter enforces it, and there is nothing for the operator to decide there.
22
+ //
23
+ // init with no --profile never imports or calls this file: template.mjs's own template() is
24
+ // untouched, so a bare init is still byte-for-byte what it always was.
25
+ import { scalar } from "./template.mjs";
26
+
27
+ const DECISION_QUESTIONS = Object.freeze({
28
+ dna: "whose voice is this, scoped to what form, audience and purpose, and which goldens define it?",
29
+ persona: "who does the piece speak as, what may it assert, and what will it never say?",
30
+ audience: "who reads this, what do they already believe, and what do they want when they arrive?",
31
+ goal: "what belief, action or feeling should move, and which requirements would prove it did?",
32
+ });
33
+
34
+ // Schema order (writing.mjs's BLOCKS): materials, dna, persona, audience, goal, form, spine,
35
+ // sources, characters. These four get an open decision in ADDITION to their placeholder block,
36
+ // because they are the ones that need real judgment before the placeholder means anything; every
37
+ // other required block gets only the placeholder.
38
+ const DECISION_BLOCKS = ["dna", "persona", "audience", "goal"];
39
+
40
+ function deferredDecision(block) {
41
+ return ` - id: writing-${block}
42
+ state: open
43
+ question: ${scalar(DECISION_QUESTIONS[block])}
44
+ source: hyperspec init --profile writing
45
+ author: agent:hyperspec-init
46
+ chosen_by: agent`;
47
+ }
48
+
49
+ const CHARACTER_BLOCK = `
50
+ characters:
51
+ - id: TODO
52
+ speech:
53
+ uses:
54
+ - TODO
55
+ never:
56
+ - TODO
57
+ rhythm: TODO
58
+ wants: TODO
59
+ fears: TODO
60
+ hides: TODO
61
+ knowledge:
62
+ - by: TODO
63
+ knows: TODO
64
+ arc_state: TODO
65
+ golden_lines:
66
+ - TODO
67
+ rejected_lines:
68
+ - TODO
69
+ check:
70
+ rubric: TODO
71
+ source: TODO
72
+ author: TODO`;
73
+
74
+ export function writingTemplate({ title = "Untitled", form = "essay", fiction = false } = {}) {
75
+ const heading = String(title).replace(/\s+/g, " ").trim();
76
+ const kind = String(form || "essay");
77
+ const decisions = DECISION_BLOCKS.map(deferredDecision).join("\n");
78
+ const characters = fiction ? CHARACTER_BLOCK : "";
79
+
80
+ return `---
81
+ hyperspec: "0.1"
82
+ title: ${scalar(String(title))}
83
+ kind: ${scalar(kind)}
84
+ profile: writing
85
+ decisions:
86
+ ${decisions}
87
+ requirements: []
88
+ rejects: []
89
+ examples: []
90
+ resume:
91
+ next_action: answer the open decisions, then fill in every writing block below
92
+ feedback:
93
+ issues: ""
94
+ fork: ""
95
+ improvement:
96
+ ledger: runs.jsonl
97
+ writing:
98
+ materials:
99
+ items:
100
+ - id: m1
101
+ path: materials/TODO.md
102
+ segments: materials/TODO.md.segments.jsonl
103
+ produced_by: TODO
104
+ captured: TODO
105
+ how: TODO
106
+ trust: TODO
107
+ check:
108
+ station: every segment of every material carries a label from the closed set, matches its source verbatim, and the markings are current
109
+ source: TODO
110
+ author: TODO
111
+ dna:
112
+ writer: TODO
113
+ scope:
114
+ form: TODO
115
+ audience: TODO
116
+ purpose: TODO
117
+ rules: TODO
118
+ goldens:
119
+ - path: goldens/TODO.md
120
+ why: TODO
121
+ check:
122
+ rubric: TODO
123
+ source: TODO
124
+ author: TODO
125
+ persona:
126
+ identity: TODO
127
+ stance: TODO
128
+ may_assert:
129
+ - TODO
130
+ will_not_say:
131
+ - TODO
132
+ facts_from: TODO
133
+ check:
134
+ rubric: TODO
135
+ source: TODO
136
+ author: TODO
137
+ audience:
138
+ who: TODO
139
+ funnel_now: TODO
140
+ knows:
141
+ - TODO
142
+ believes_now: TODO
143
+ wants: TODO
144
+ reads_on: TODO
145
+ reader: TODO
146
+ check:
147
+ station: TODO
148
+ rubric: TODO
149
+ source: TODO
150
+ author: TODO
151
+ goal:
152
+ from: TODO
153
+ to: TODO
154
+ next_if_worked: TODO
155
+ change:
156
+ kind: TODO
157
+ text: TODO
158
+ conditions:
159
+ - TODO
160
+ check:
161
+ rubric: TODO
162
+ source: TODO
163
+ author: TODO
164
+ form:
165
+ name: ${scalar(kind)}
166
+ length:
167
+ min: TODO
168
+ max: TODO
169
+ unit: TODO
170
+ required_parts:
171
+ - TODO
172
+ stations: []
173
+ check:
174
+ station: TODO
175
+ source: TODO
176
+ author: TODO
177
+ spine:
178
+ kind: TODO
179
+ claims:
180
+ - id: c1
181
+ text: TODO
182
+ materials: [m1]
183
+ check:
184
+ rubric: TODO
185
+ source: TODO
186
+ author: TODO
187
+ sources:
188
+ ledger: TODO.claims.jsonl
189
+ unsourced_claim: TODO
190
+ check:
191
+ station: TODO
192
+ source: TODO
193
+ author: TODO${characters}
194
+ fiction: ${fiction ? "true" : "false"}
195
+ ---
196
+
197
+ # ${heading}
198
+ `;
199
+ }