@supersuit/hyperspec 0.2.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.
@@ -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
+ }