@supersuit/hyperspec 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +102 -0
- package/README.md +105 -4
- package/SPEC.md +205 -13
- package/WRITING.md +426 -0
- package/bin/hyperspec.mjs +322 -2
- package/examples/minimal.hyperspec.md +2 -2
- package/examples/recipe/doctor.mjs +11 -0
- package/examples/recipe/essay.hyperspec.md +50 -0
- package/examples/recipe/factory.mjs +29 -0
- package/examples/recipe/materials/call-2.md +2 -0
- package/examples/recipe/materials/call.md +3 -0
- package/examples/recipe/materials/notes.md +3 -0
- package/examples/recipe/runner.mjs +20 -0
- package/examples/recipe/runs.jsonl +0 -0
- package/examples/recipe/stages.mjs +31 -0
- package/examples/writing/essay/goldens/close.md +2 -0
- package/examples/writing/essay/goldens/opening.md +2 -0
- package/examples/writing/essay/materials/interview-notes.md +12 -0
- package/examples/writing/essay/materials/team-survey.md +7 -0
- package/examples/writing/essay/materials/voice-memo.md +18 -0
- package/examples/writing/essay/runs.jsonl +0 -0
- package/examples/writing/essay.hyperspec.md +217 -0
- package/examples/writing/story/goldens/dialogue.md +3 -0
- package/examples/writing/story/goldens/opening.md +3 -0
- package/examples/writing/story/materials/bakery-visit.md +8 -0
- package/examples/writing/story/materials/notes.md +14 -0
- package/examples/writing/story/materials/scene-list.md +7 -0
- package/examples/writing/story/runs.jsonl +0 -0
- package/examples/writing/story.hyperspec.md +285 -0
- package/examples/writing/style-rules.md +19 -0
- package/package.json +8 -2
- package/runs.jsonl +0 -0
- package/src/blobs.mjs +77 -0
- package/src/compare.mjs +189 -0
- package/src/fsutil.mjs +33 -0
- package/src/hash.mjs +26 -0
- package/src/placeholder.mjs +20 -0
- package/src/profiles.mjs +50 -0
- package/src/recipe.mjs +164 -0
- package/src/regenerate.mjs +318 -0
- package/src/reproduce.mjs +156 -0
- package/src/rules.mjs +58 -19
- package/src/score.mjs +7 -1
- package/src/template.mjs +15 -3
- package/src/writer.mjs +125 -0
- package/src/writing-fields.mjs +317 -0
- package/src/writing-template.mjs +192 -0
- package/src/writing.mjs +181 -0
package/src/writing.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// The writing profile (profile: writing). Ten blocks live under writing:; nine of them are
|
|
2
|
+
// checked by this file (materials, dna, persona, audience, goal, form, spine, sources,
|
|
3
|
+
// characters). "progress" is the tenth word in the design and is not a block at all: it is
|
|
4
|
+
// forbidden, because stored progress goes stale the moment a session dies mid-arc, and this file
|
|
5
|
+
// is the one place that refusal is enforced.
|
|
6
|
+
//
|
|
7
|
+
// This module carries the GENERIC rules, the ones true of every block regardless which one it is:
|
|
8
|
+
// present or openly deferred, carrying a check and a source and an author. Each block's own field
|
|
9
|
+
// rules (a golden's why, a claim's material refs, a character's golden and rejected lines, ...)
|
|
10
|
+
// live in writing-fields.mjs and are dispatched from the loop below, under the same ids and the
|
|
11
|
+
// same nine tests; they do not change the shape here.
|
|
12
|
+
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { BLOCK_FIELD_RULES, characterFields } from "./writing-fields.mjs";
|
|
15
|
+
import { str } from "./placeholder.mjs";
|
|
16
|
+
|
|
17
|
+
const f = (test, id, severity, message, fix) => ({ test, id, severity, message, fix });
|
|
18
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
19
|
+
const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
|
|
20
|
+
|
|
21
|
+
// The closed vocabulary a later version will enforce on every segment of a material file. This
|
|
22
|
+
// version does not read segment files; the export exists so the vocabulary is defined once, here,
|
|
23
|
+
// rather than copied into whatever later reads it.
|
|
24
|
+
export const MATERIAL_LABELS = Object.freeze(["claim", "story", "quote", "stance", "question", "aside", "private"]);
|
|
25
|
+
|
|
26
|
+
// The nine writing blocks, in schema order. "characters" is the one block that is not always
|
|
27
|
+
// required: it is required only when fiction: true, everywhere else in this file and in
|
|
28
|
+
// blockStatus below.
|
|
29
|
+
export const BLOCKS = Object.freeze(["materials", "dna", "persona", "audience", "goal", "form", "spine", "sources", "characters"]);
|
|
30
|
+
|
|
31
|
+
const required = (block, fiction) => block !== "characters" || fiction;
|
|
32
|
+
|
|
33
|
+
// Whether a block's raw value under writing: counts as present at all, before any of its own
|
|
34
|
+
// fields are checked. materials is present when it has at least one item; characters is present
|
|
35
|
+
// when the list has at least one entry; every other block is present when it is an object with at
|
|
36
|
+
// least one field. This is deliberately shallow: it is the bar for "something was written here",
|
|
37
|
+
// not the bar for "this block is correct", which is what checkOwner and the field rules in
|
|
38
|
+
// writing-fields.mjs are for.
|
|
39
|
+
function blockPresent(block, raw) {
|
|
40
|
+
if (block === "characters") return Array.isArray(raw) && raw.length > 0;
|
|
41
|
+
if (block === "materials") return isObj(raw) && Array.isArray(raw.items) && raw.items.length > 0;
|
|
42
|
+
return isObj(raw) && Object.keys(raw).length > 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A block is deferred when a decision with id "writing-<block>" exists in state open, or in
|
|
46
|
+
// state delegated WITH a rule. Open defers it to a question only a human can answer, so the spec
|
|
47
|
+
// is blocked on that decision the same way any open decision blocks a spec (score.mjs already
|
|
48
|
+
// does this; no separate mechanism is needed here); it exempts the block on state alone, since
|
|
49
|
+
// whether it also carries a well-formed question is the ordinary decision rule's job (test 1),
|
|
50
|
+
// not this one's. Delegated defers it to a standing rule the agent follows instead of writing the
|
|
51
|
+
// block out, and "delegated (with a rule)" is a precondition on the exemption, not just a
|
|
52
|
+
// description of delegated's normal shape: a delegated decision with no rule has deferred to
|
|
53
|
+
// nothing, so it does not stand in for the block, and writing-<block>-missing still fires
|
|
54
|
+
// alongside the decision's own delegated-rule finding (test 1). Either way, when a deferral does
|
|
55
|
+
// apply, the block's absence from writing: is not itself a finding.
|
|
56
|
+
function deferredBy(decisions, block) {
|
|
57
|
+
const d = decisions.find((x) => str(x?.id) === `writing-${block}`);
|
|
58
|
+
if (!d) return null;
|
|
59
|
+
const st = str(d.state);
|
|
60
|
+
if (st === "open") return st;
|
|
61
|
+
if (st === "delegated" && str(d.rule)) return st;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// check: (station: or rubric:), source: and author: on one owner object (a block, or one
|
|
66
|
+
// character entry). idPrefix becomes the finding id's prefix (kept unique per owner so
|
|
67
|
+
// blockStatus below can attribute a failure to the right block); label is the human-readable name
|
|
68
|
+
// used in every message.
|
|
69
|
+
function checkOwner(idPrefix, label, owner) {
|
|
70
|
+
const out = [];
|
|
71
|
+
const check = isObj(owner?.check) ? owner.check : {};
|
|
72
|
+
if (!str(check.station) && !str(check.rubric)) {
|
|
73
|
+
out.push(f(3, `${idPrefix}-check`, "fail", `${label} names no check`, "Add check: with station: <a deterministic check> or rubric: <what a grader applies>."));
|
|
74
|
+
}
|
|
75
|
+
if (!str(owner?.source)) {
|
|
76
|
+
out.push(f(4, `${idPrefix}-source`, "fail", `${label} does not say where it came from`, `Add source: to ${label}.`));
|
|
77
|
+
}
|
|
78
|
+
if (!str(owner?.author)) {
|
|
79
|
+
out.push(f(4, `${idPrefix}-author`, "fail", `${label} does not say who wrote it`, `Add author: to ${label}.`));
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function lintWriting(spec) {
|
|
85
|
+
const d = spec.data || {};
|
|
86
|
+
const out = [];
|
|
87
|
+
const decisions = list(d.decisions);
|
|
88
|
+
const writing = isObj(d.writing) ? d.writing : {};
|
|
89
|
+
// Paths inside writing: resolve the same way examples: does elsewhere in this linter: relative
|
|
90
|
+
// to the spec file, never to process.cwd().
|
|
91
|
+
const here = (p) => resolve(spec.dir || ".", p);
|
|
92
|
+
// The frontmatter reader (parseSkillFile) treats every scalar as a string, so "fiction: true"
|
|
93
|
+
// is read back as the string "true", never the boolean; comparing through str() is the same
|
|
94
|
+
// discipline every closed-set field in this file and in rules.mjs already follows.
|
|
95
|
+
const fiction = str(d.fiction) === "true";
|
|
96
|
+
|
|
97
|
+
// fiction is a closed set: absent means false; present, it must be exactly true or false. A
|
|
98
|
+
// typo here would otherwise drop the whole characters block from a story without a word.
|
|
99
|
+
if (d.fiction !== undefined) {
|
|
100
|
+
const raw = typeof d.fiction === "string" ? d.fiction.trim() : "";
|
|
101
|
+
if (str(d.fiction) !== "true" && str(d.fiction) !== "false") {
|
|
102
|
+
out.push(f(1, "writing-fiction", "fail", `fiction is "${raw || "(none)"}", not true or false`, "Set fiction: true or fiction: false, or remove it (absent means false)."));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Progress is never stored, under any key spelled writing.progress: test 7, stale state.
|
|
107
|
+
if ("progress" in writing) {
|
|
108
|
+
out.push(f(7, "writing-progress", "fail", "writing.progress is stored state; progress is derived from disk, never saved", "Remove writing.progress; derive progress by reading the drafted work itself, not by saving a record of it."));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const block of BLOCKS) {
|
|
112
|
+
const raw = writing[block];
|
|
113
|
+
if (!blockPresent(block, raw)) {
|
|
114
|
+
// Missing is only ever a finding for a REQUIRED block; an unrequired, unwritten block (only
|
|
115
|
+
// characters, only with fiction: false) is simply absent, nothing to check and nothing to
|
|
116
|
+
// defer. A required block's absence fails test 1, unless deferred.
|
|
117
|
+
if (!required(block, fiction)) continue;
|
|
118
|
+
if (deferredBy(decisions, block)) continue;
|
|
119
|
+
out.push(f(1, `writing-${block}-missing`, "fail", `writing.${block} is missing`, `Add writing.${block}, or defer it with a decision id "writing-${block}" in state open (a question) or delegated (a rule).`));
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
// Present, so its content is checked whether or not the block was required: an author who
|
|
123
|
+
// wrote a characters: list with fiction: false still owes it a real check/source/author on
|
|
124
|
+
// every entry, the same as any other present block. Ownership (check/source/author) is
|
|
125
|
+
// generic, from this file; a block's own field rules (the schema inside it) live in
|
|
126
|
+
// writing-fields.mjs and are applied right alongside it, under the same id prefix, so a
|
|
127
|
+
// block's completeness (blockStatus below) reflects both without either file needing to know
|
|
128
|
+
// about the other's findings.
|
|
129
|
+
if (block === "characters") {
|
|
130
|
+
// A character id names one person: persona.identity: character:<id> and every later check
|
|
131
|
+
// resolve through it, so a repeated id is reported once per id, like a repeated material.
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
const reported = new Set();
|
|
134
|
+
raw.forEach((c) => {
|
|
135
|
+
const id = str(c?.id);
|
|
136
|
+
if (!id) return;
|
|
137
|
+
if (seen.has(id) && !reported.has(id)) {
|
|
138
|
+
out.push(f(1, "writing-characters-id", "fail", `character id "${id}" is used twice`, "Ids must be unique across writing.characters; rename one."));
|
|
139
|
+
reported.add(id);
|
|
140
|
+
}
|
|
141
|
+
seen.add(id);
|
|
142
|
+
});
|
|
143
|
+
raw.forEach((c, i) => {
|
|
144
|
+
const cid = str(c?.id) || `#${i + 1}`;
|
|
145
|
+
const idPrefix = `writing-characters-${i}`;
|
|
146
|
+
out.push(...checkOwner(idPrefix, `character "${cid}"`, c));
|
|
147
|
+
out.push(...characterFields(c, here, idPrefix));
|
|
148
|
+
});
|
|
149
|
+
} else {
|
|
150
|
+
out.push(...checkOwner(`writing-${block}`, `writing.${block}`, raw));
|
|
151
|
+
const fieldRule = BLOCK_FIELD_RULES[block];
|
|
152
|
+
if (fieldRule) out.push(...fieldRule(raw, d, here, `writing-${block}`));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Derives the "writing: k/9 blocks complete" count from the same data and findings lintWriting
|
|
160
|
+
// just produced, so the two can never disagree. A block counts complete when it has no
|
|
161
|
+
// fail-severity finding attributed to it AND (it is present, or it is simply not required, only
|
|
162
|
+
// characters with fiction: false). A block that is present but unrequired is still held to the
|
|
163
|
+
// same bar as any other present block: writing it with broken content does not count as complete
|
|
164
|
+
// just because nothing required it to be written at all.
|
|
165
|
+
export function blockStatus(data, findings) {
|
|
166
|
+
const d = data || {};
|
|
167
|
+
const writing = isObj(d.writing) ? d.writing : {};
|
|
168
|
+
const fiction = str(d.fiction) === "true";
|
|
169
|
+
const failedIds = new Set((Array.isArray(findings) ? findings : []).filter((x) => x.severity === "fail").map((x) => x.id));
|
|
170
|
+
let complete = 0;
|
|
171
|
+
for (const block of BLOCKS) {
|
|
172
|
+
const present = blockPresent(block, writing[block]);
|
|
173
|
+
if (!present) {
|
|
174
|
+
if (!required(block, fiction)) complete += 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const broken = [...failedIds].some((id) => typeof id === "string" && id.startsWith(`writing-${block}-`));
|
|
178
|
+
if (!broken) complete += 1;
|
|
179
|
+
}
|
|
180
|
+
return { complete, total: BLOCKS.length };
|
|
181
|
+
}
|