@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.
- package/CHANGELOG.md +60 -0
- package/README.md +31 -2
- package/SPEC.md +22 -10
- package/WRITING.md +426 -0
- package/bin/hyperspec.mjs +45 -2
- package/examples/minimal.hyperspec.md +2 -2
- 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 +3 -2
- package/src/placeholder.mjs +20 -0
- package/src/profiles.mjs +50 -0
- package/src/rules.mjs +25 -13
- package/src/score.mjs +7 -1
- package/src/template.mjs +4 -1
- package/src/writing-fields.mjs +317 -0
- package/src/writing-template.mjs +192 -0
- package/src/writing.mjs +181 -0
|
@@ -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
|
+
}
|