@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,285 @@
1
+ ---
2
+ hyperspec: "0.1"
3
+ title: The Rye
4
+ kind: short story
5
+ profile: writing
6
+ decisions:
7
+ - id: point-of-view
8
+ state: decided
9
+ value: first person, told by Theo, in the past tense
10
+ source: story/materials/notes.md
11
+ author: example-author
12
+ chosen_by: human
13
+ - id: time-span
14
+ state: decided
15
+ value: one morning, 3:40 to 7:00, in the four scenes of the scene list and in that order
16
+ source: story/materials/scene-list.md
17
+ author: example-author
18
+ chosen_by: human
19
+ - id: bakery-name
20
+ state: delegated
21
+ rule: the bakery is never named; it is always "the bakery" or "Ines's"
22
+ source: editor's note on the scene list
23
+ author: agent:claude
24
+ chosen_by: agent
25
+ requirements:
26
+ - id: r1
27
+ text: the two voices cannot be confused
28
+ fails_when: a judge shown ten lines of dialogue with the speaker hidden names the wrong speaker for more than one of them
29
+ check:
30
+ rubric: blind attribution test across both characters, ten lines drawn at random from the draft
31
+ source: story/materials/notes.md
32
+ author: example-author
33
+ - id: r2
34
+ text: no one says what they could not know yet
35
+ fails_when: Theo mentions the sale before scene-3, or Ines mentions the school before scene-4
36
+ check:
37
+ station: knowledge-leak check of every line against each character's knowledge timeline
38
+ source: story/materials/scene-list.md
39
+ author: example-author
40
+ - id: r3
41
+ text: the bakery's process matches a real working morning
42
+ fails_when: a proofing time, the tray turn, or the opening time differs from story/materials/bakery-visit.md
43
+ check:
44
+ station: every process detail in the claims ledger points at a span of the bakery visit notes
45
+ source: story/materials/bakery-visit.md
46
+ author: agent:claude
47
+ - id: r4
48
+ text: the story is the four scenes of the scene list, in order
49
+ fails_when: the draft has more or fewer than four scenes, or they run out of the scene list's order
50
+ check:
51
+ station: structure check against story/materials/scene-list.md
52
+ source: story/materials/scene-list.md
53
+ author: example-author
54
+ - id: r5
55
+ text: the last line is about bread
56
+ fails_when: the final sentence names a feeling, or does not mention bread, dough, flour or the starter
57
+ check:
58
+ rubric: read the final sentence; fail if it names an emotion or mentions none of bread, dough, flour or the starter
59
+ source: story/materials/notes.md
60
+ author: example-author
61
+ - id: r6
62
+ text: the story stays inside its length envelope
63
+ fails_when: the word count is under 2,500 or over 4,000
64
+ check:
65
+ station: word count against form.length
66
+ source: editor's brief
67
+ author: example-author
68
+ rejects:
69
+ - either character saying out loud that they love or will miss the other
70
+ - a flashback outside the one morning
71
+ - a scene where the oven noise is explained
72
+ - an ending that tells the reader what the starter jar means
73
+ examples:
74
+ - path: story/goldens/dialogue.md
75
+ why: three short lines carry a ritual both characters know, without either of them naming it
76
+ resume:
77
+ next_action: mark the three materials into labeled segments, then draft scene-1 from the scene list with Theo's knowledge as of scene-1
78
+ feedback:
79
+ issues: https://github.com/SupersuitUp/hyperspec/issues
80
+ fork: MIT; fork it for your own purposes
81
+ improvement:
82
+ ledger: story/runs.jsonl
83
+ writing:
84
+ materials:
85
+ items:
86
+ - id: notes
87
+ path: story/materials/notes.md
88
+ produced_by: example-author
89
+ captured: "2026-08-20"
90
+ how: typed notes
91
+ trust: raw
92
+ - id: bakery-visit
93
+ path: story/materials/bakery-visit.md
94
+ produced_by: example-author
95
+ captured: "2026-08-28"
96
+ how: notes taken on site, corrected by the bakery owner afterwards
97
+ trust: considered
98
+ - id: scene-list
99
+ path: story/materials/scene-list.md
100
+ produced_by: example-author
101
+ captured: "2026-09-02"
102
+ how: scene list agreed with the editor
103
+ trust: considered
104
+ check:
105
+ station: every segment of every material carries a label from the closed set
106
+ source: capture step
107
+ author: agent:claude
108
+ dna:
109
+ writer: example-author
110
+ scope:
111
+ form: short story
112
+ audience: literary magazine readers
113
+ purpose: move
114
+ rules: style-rules.md
115
+ goldens:
116
+ - path: story/goldens/opening.md
117
+ why: the narrator's voice arrives through one concrete sound, and the sentence about the mixer tells you how he sees the world
118
+ - path: story/goldens/dialogue.md
119
+ why: the narration between lines says only what Theo notices, never what Ines feels
120
+ check:
121
+ rubric: blind lineup within this scope; a judge shown a generated passage beside the two goldens cannot pick it out
122
+ source: goldens marked on the review page
123
+ author: example-author
124
+ persona:
125
+ identity: character:theo
126
+ stance: witness
127
+ may_assert:
128
+ - what Theo sees, hears and does in the bakery that morning
129
+ - what Theo knows as of the current scene, per his knowledge timeline
130
+ will_not_say:
131
+ - what Ines is thinking or feeling
132
+ - anything Theo does not know yet at that point in the morning
133
+ facts_from: sources
134
+ check:
135
+ rubric: persona-consistency judge; Theo's narration stays a witness's, and no process detail appears that is not in the claims ledger
136
+ source: persona interview
137
+ author: example-author
138
+ audience:
139
+ who: readers of a quarterly literary magazine who read short fiction in print
140
+ funnel_now: has turned to the story in the magazine without knowing the author
141
+ knows:
142
+ - bakery
143
+ - apprentice
144
+ - sourdough
145
+ believes_now: nothing yet about this author or these two people
146
+ wants: a story they can finish in one sitting and keep thinking about
147
+ reads_on: print, in one sitting of about fifteen minutes
148
+ reader: person
149
+ check:
150
+ station: term check against knows; proof, starter and deck oven are made plain by context on first use
151
+ rubric: simulated reader reports where it got lost and where it stopped reading
152
+ source: editor's brief
153
+ author: example-author
154
+ goal:
155
+ from: has never read the author
156
+ to: finishes the story and remembers the starter jar
157
+ next_if_worked: looks for the author's other stories
158
+ change:
159
+ kind: feeling
160
+ text: the reader feels the handover of the starter jar as the moment the two say what neither says aloud
161
+ conditions: [r1, r2, r3, r4, r5, r6]
162
+ check:
163
+ rubric: the doctor grades the draft against every condition; the simulated reader is asked what the starter jar meant and whether it would look for the author's next story
164
+ source: goal interview
165
+ author: example-author
166
+ form:
167
+ name: short story
168
+ length:
169
+ min: 2500
170
+ max: 4000
171
+ unit: words
172
+ required_parts:
173
+ - four scenes, in the scene list's order
174
+ - a last line about bread
175
+ stations:
176
+ - continuity against the scene list
177
+ - knowledge-leak check, per character, per scene
178
+ check:
179
+ station: structure and length check against required_parts and length
180
+ source: editor's brief
181
+ author: example-author
182
+ spine:
183
+ kind: story
184
+ claims:
185
+ - id: c1
186
+ text: each of them hides their news to protect the other, and the hiding is the thing they share
187
+ materials: [notes, scene-list]
188
+ - id: c2
189
+ text: people who love each other at work say it through the work
190
+ materials: [notes, bakery-visit]
191
+ - id: c3
192
+ text: a craft outlives the room it was practiced in
193
+ materials: [notes, scene-list]
194
+ check:
195
+ rubric: each claim lands, in order, through what the characters do, and the story argues nothing outside the chain
196
+ source: spine interview
197
+ author: example-author
198
+ sources:
199
+ ledger: story/claims.jsonl
200
+ unsourced_claim: fail
201
+ check:
202
+ station: every process detail in the ledger points at a source span; every quote matches its source verbatim
203
+ source: sourcing pass
204
+ author: agent:claude
205
+ characters:
206
+ - id: ines
207
+ speech:
208
+ uses:
209
+ - instructions in the imperative
210
+ - numbers, weights and times
211
+ - first names, for customers
212
+ never:
213
+ - an apology in words
214
+ - a sentence about her own feelings
215
+ - a question she does not need answered
216
+ rhythm: short, flat sentences, often without a subject; silence where another person would reassure
217
+ wants: to leave the bakery in hands she trusts, without having to say that she is leaving it
218
+ fears: that Theo will stay in a trade with no bakery to do it in, because of her
219
+ hides: that she sold the bakery two weeks ago
220
+ knowledge:
221
+ - by: scene-1
222
+ knows: the sale closes on Friday, and the new owners will not keep it a bakery
223
+ - by: scene-4
224
+ knows: Theo has a place at a baking school in another city and leaves in the autumn
225
+ relationships:
226
+ - to: theo
227
+ how: gives him instructions where another person would give praise
228
+ - to: customers
229
+ how: warm and unhurried, asks after their families by name
230
+ arc_state: has let go of the bakery, and has not yet let go of Theo
231
+ golden_lines:
232
+ - Flour first. Then you can talk.
233
+ - Left side runs hot. Turn them at eight minutes.
234
+ - Sold means sold. Shape the rye.
235
+ rejected_lines:
236
+ - I'm so sorry I didn't tell you sooner, Theo.
237
+ - This place has been my whole life, you know?
238
+ - Would you like to try the rye today?
239
+ check:
240
+ rubric: blind attribution test, knowledge-leak check against the timeline, consistency against golden and rejected lines
241
+ source: story/materials/notes.md
242
+ author: example-author
243
+ - id: theo
244
+ speech:
245
+ uses:
246
+ - questions he already knows the answer to
247
+ - hedges such as "I mean" and "kind of"
248
+ - a joke when he is nervous
249
+ never:
250
+ - a flat instruction
251
+ - a word of baking jargon he has not heard Ines use first
252
+ rhythm: long sentences that double back on themselves and end in a question
253
+ wants: Ines to tell him he is ready, in words, once
254
+ fears: that leaving for school is a betrayal of the person who taught him
255
+ hides: that he has a place at a baking school in another city and leaves in the autumn
256
+ knowledge:
257
+ - by: scene-1
258
+ knows: the oven has made the noise since March, and he leaves for school in the autumn
259
+ - by: scene-3
260
+ knows: the bakery is sold, and the new owners will not keep it a bakery
261
+ relationships:
262
+ - to: ines
263
+ how: asks questions to keep her talking, and never interrupts her while she shapes
264
+ arc_state: still acting the apprentice while privately already leaving
265
+ golden_lines:
266
+ - Okay but like, if the oven's made that noise since March, is it a noise, or is that just how the oven talks now?
267
+ - I can do the rye. I mean, I think I can do the rye. I did it Tuesday, kind of.
268
+ - So is that a yes, or is that the face you make when it's a yes?
269
+ rejected_lines:
270
+ - Shape the rye.
271
+ - I have been accepted to a culinary program and will be leaving in the autumn.
272
+ - Left side runs hot, turn them early.
273
+ check:
274
+ rubric: blind attribution test, knowledge-leak check against the timeline, consistency against golden and rejected lines
275
+ source: story/materials/notes.md
276
+ author: example-author
277
+ fiction: true
278
+ ---
279
+
280
+ # The Rye
281
+
282
+ A worked example of the writing profile for fiction: a short story with two characters, each
283
+ specified well enough that an agent can write their dialogue and a judge can tell them apart.
284
+ Every file this spec names ships beside it. `story/claims.jsonl` does not exist yet, because the
285
+ claims ledger is written during drafting.
@@ -0,0 +1,19 @@
1
+ # Style rules
2
+
3
+ The always-on layer. These apply to everything this writer publishes, whatever the form, the
4
+ audience or the purpose. Goldens change with the scope; these do not.
5
+
6
+ ## Never
7
+
8
+ - No em dashes. Use a comma, a colon, or a new sentence.
9
+ - No sentence that sets up a claim only to knock down a weaker one first. State the claim.
10
+ - No adjective standing in for evidence. If a sentence says a thing is important, it shows why.
11
+ - No filler openers ("In today's world", "It goes without saying").
12
+ - No exclamation marks outside quoted dialogue.
13
+
14
+ ## Always
15
+
16
+ - The first line of a piece states what it is about.
17
+ - A number carries its source, or it does not go in.
18
+ - A quotation is word for word, or it is not in quotation marks.
19
+ - One idea per paragraph. A paragraph that needs a second idea is two paragraphs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/hyperspec",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A hyperspec is a spec written for an agent: every decision accounted for, every requirement failable and checked, every field traced. The standard and its linter.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "src/",
16
16
  "examples/",
17
17
  "SPEC.md",
18
+ "WRITING.md",
18
19
  "runs.jsonl",
19
20
  "README.md",
20
21
  "CHANGELOG.md",
@@ -39,6 +40,6 @@
39
40
  "outcome-factory"
40
41
  ],
41
42
  "dependencies": {
42
- "@supersuit/superskill": "^0.2.1"
43
+ "@supersuit/superskill": "^0.2.2"
43
44
  }
44
45
  }
@@ -0,0 +1,20 @@
1
+ // A value only a human or an agent would recognize as "not actually written yet" never counts as
2
+ // present, wherever a presence check reads it:
3
+ //
4
+ // - null and ~ (the YAML reader hands these back as the literal strings "null" and "~" rather
5
+ // than resolving them to YAML's own null);
6
+ // - the placeholder words a scaffold or a hurried author leaves behind: todo, tbd, fixme, xxx,
7
+ // placeholder, <placeholder>, n/a;
8
+ // - a run of dashes, a run of question marks, or an ellipsis ("...", or the single character);
9
+ // - any of those followed by trailing ".", ":" or "!" (TODO., tbd:, FIXME!).
10
+ //
11
+ // Matched only against the WHOLE trimmed value, case-insensitive: "TODO: write the opening" is
12
+ // real text that happens to start with the word, and still counts as present. "none" is not on
13
+ // the list, because it is a legitimate decided value ("rejects: none of the above").
14
+ //
15
+ // This is the one place this pattern is defined. src/rules.mjs (the core tests), src/writing.mjs
16
+ // and src/writing-fields.mjs (the writing profile) all import str() from here rather than keeping
17
+ // their own copy, so a word added here closes every presence check in the linter at once. The
18
+ // rule exists because a scaffold that fills every field with "TODO" would otherwise lint clean.
19
+ export const PLACEHOLDER = /^(?:null|~|(?:todo|tbd|fixme|xxx|placeholder|<placeholder>|n\/a|-+|\?+|\.\.\.|…)[.:!]*)$/is;
20
+ export const str = (v) => { const t = typeof v === "string" ? v.trim() : ""; return PLACEHOLDER.test(t) ? "" : t; };
@@ -0,0 +1,50 @@
1
+ // A profile is an opt-in: a spec sets profile: <name> and gains a typed map of extra content
2
+ // (writing: for profile: writing) that this file's rules check, on top of everything the core
3
+ // format already requires. Every profile finding still reports under one of the nine tests; a
4
+ // profile adds no tenth test and no separate score.
5
+ //
6
+ // This is the one place that knows which profile names exist. Adding a profile means adding one
7
+ // entry here; rules.mjs and score.mjs both go through this registry rather than naming "writing"
8
+ // themselves, so a second profile needs no change to either.
9
+ import { lintWriting, blockStatus } from "./writing.mjs";
10
+
11
+ const str = (v) => (typeof v === "string" ? v.trim() : "");
12
+ const f = (test, id, severity, message, fix) => ({ test, id, severity, message, fix });
13
+
14
+ export const PROFILES = Object.freeze({
15
+ writing: Object.freeze({
16
+ lint: lintWriting,
17
+ // Block completeness for the CLI's "<name>: k/n blocks complete" line and its --json twin.
18
+ // Reads the same findings lint just produced, so the count and the findings can never
19
+ // disagree with each other.
20
+ status: (data, findings) => blockStatus(data, findings),
21
+ }),
22
+ });
23
+
24
+ // Own-property lookup only: a profile named after something every object inherits
25
+ // (constructor, toString, __proto__) is an unknown profile, never a function to call.
26
+ export const knownProfile = (name) => (Object.hasOwn(PROFILES, name) ? PROFILES[name] : undefined);
27
+
28
+ // Runs the declared profile's rules against a loaded spec. A spec with no profile: at all runs no
29
+ // profile rules, so an unprofiled spec lints exactly as it always has. A profile: this linter does
30
+ // not know is a warning under test 7 (a stranger resuming the spec still needs to know its rules
31
+ // were not checked), not a failure: an unknown profile is not necessarily a wrong one, only one
32
+ // this version cannot yet check.
33
+ export function lintProfile(spec) {
34
+ const name = str(spec?.data?.profile);
35
+ if (!name) return [];
36
+ const profile = knownProfile(name);
37
+ if (!profile) {
38
+ return [f(7, "unknown-profile", "warn", `this linter does not know profile "${name}"; its rules were not checked`, "Set profile: to one this linter knows (writing), or remove it.")];
39
+ }
40
+ return profile.lint(spec);
41
+ }
42
+
43
+ // The { name, complete, total } score.mjs merges into a passing profile's score, or undefined
44
+ // when no profile ran or the declared one is unknown (nothing to count).
45
+ export function profileStatus(data, findings) {
46
+ const name = str(data?.profile);
47
+ const profile = knownProfile(name);
48
+ if (!profile) return undefined;
49
+ return { name, ...profile.status(data, findings) };
50
+ }
package/src/rules.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
+ import { lintProfile } from "./profiles.mjs";
4
+ import { str } from "./placeholder.mjs";
3
5
 
4
6
  export const TESTS = Object.freeze([
5
7
  { n: 1, name: "every decision is accounted for" },
@@ -25,15 +27,13 @@ const FENCE = /^ {0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:^ {0,3}\1[`~]*[ \t]*$|(?![
25
27
  const INLINE_CODE = /(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)/g;
26
28
  const prose = (body) => String(body || "").replace(FENCE, "").replace(INLINE_CODE, "");
27
29
  const list = (v) => (Array.isArray(v) ? v : []);
28
- // A value that is only null or ~ is a placeholder: the reader (@supersuit/superskill/yaml) keeps
29
- // these as the literal strings "null" and "~" rather than resolving them to YAML's own null, so
30
- // they must never count as present. A value that is only a YAML comment (source: # TODO) is
31
- // handled upstream since superskill 0.2.1: the reader returns "" for it, same as any other blank
32
- // scalar, so it already fails str()'s own emptiness check and needs no rule here. A QUOTED value
33
- // that happens to start with "#" (source: "# literal") is real text and must count as present.
34
- // Every presence check goes through str().
35
- const PLACEHOLDER = /^(null|~)$/is;
36
- const str = (v) => { const t = typeof v === "string" ? v.trim() : ""; return PLACEHOLDER.test(t) ? "" : t; };
30
+ // str() (a value that is null/~/todo/tbd/fixme/xxx/placeholder never counts as present) is
31
+ // shared, from src/placeholder.mjs: writing.mjs and writing-fields.mjs import the same function,
32
+ // so a placeholder word closes every presence check in the linter at once. A value that is only a
33
+ // YAML comment (source: # TODO) is handled upstream since superskill 0.2.1: the reader returns ""
34
+ // for it, same as any other blank scalar, so it already fails str()'s own emptiness check and
35
+ // needs no rule here. A QUOTED value that happens to start with "#" (source: "# literal") is real
36
+ // text and must count as present.
37
37
  const f = (test, id, severity, message, fix) => ({ test, id, severity, message, fix });
38
38
  // The versions of the format this linter knows. A spec naming any other may follow rules it
39
39
  // cannot check, so it is warned about rather than failed.
@@ -52,6 +52,11 @@ export function lintSpec(spec) {
52
52
  const version = str(d.hyperspec);
53
53
  if (!KNOWN_VERSIONS.includes(version)) out.push(f(7, "hyperspec-version", "warn", `hyperspec version "${version}" is not one this linter knows (${KNOWN_VERSIONS.join(", ")})`, "Set hyperspec: to a version this linter knows, or upgrade @supersuit/hyperspec."));
54
54
 
55
+ // A spec with no profile: runs no profile rules at all, so it lints exactly as it always has.
56
+ // One declared runs that profile's own rules (writing.mjs for profile: writing), every finding
57
+ // still reported under one of the nine tests below; an unknown profile name is a warning here.
58
+ out.push(...lintProfile(spec));
59
+
55
60
  // 1 and 4, decisions
56
61
  const decisions = list(d.decisions);
57
62
  if (!decisions.length) out.push(f(1, "decisions", "fail", "no decisions are listed", "List every decision this kind of work has under decisions:, each decided, delegated or open."));
@@ -119,10 +124,17 @@ export function lintSpec(spec) {
119
124
  });
120
125
 
121
126
  // 7
122
- const next = str(d.resume?.next_action);
123
- if (!next) out.push(f(7, "next-action", "fail", "no resume.next_action", "Write the single concrete step that starts the next session."));
124
- else if (NO_ACTION.test(next)) out.push(f(7, "next-action-vague", "fail", `next action "${next}" names no action`, "Name the concrete step."));
125
- else if (CONVERSATION.test(next)) out.push(f(7, "next-action-vague", "fail", `next action "${next}" points into a conversation the next reader cannot see`, "State the step itself."));
127
+ // NO_ACTION is checked against the RAW trimmed value, ahead of str()'s placeholder-blanking:
128
+ // two of its own words (tbd, todo) are now also placeholder words str() treats as blank, and
129
+ // "next action names no action" is the more specific, more correct message for those than "no
130
+ // resume.next_action" would be (something WAS written; it just names no action).
131
+ const rawNext = typeof d.resume?.next_action === "string" ? d.resume.next_action.trim() : "";
132
+ if (rawNext && NO_ACTION.test(rawNext)) out.push(f(7, "next-action-vague", "fail", `next action "${rawNext}" names no action`, "Name the concrete step."));
133
+ else {
134
+ const next = str(d.resume?.next_action);
135
+ if (!next) out.push(f(7, "next-action", "fail", "no resume.next_action", "Write the single concrete step that starts the next session."));
136
+ else if (CONVERSATION.test(next)) out.push(f(7, "next-action-vague", "fail", `next action "${next}" points into a conversation the next reader cannot see`, "State the step itself."));
137
+ }
126
138
  if (CONVERSATION.test(prose(spec.body))) out.push(f(7, "conversation-pointer", "warn", "the body points into a conversation the next reader cannot see", "State the thing itself."));
127
139
 
128
140
  // 8
package/src/score.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { TESTS } from "./rules.mjs";
2
+ import { profileStatus } from "./profiles.mjs";
2
3
 
3
4
  export function score(findings, data = {}) {
4
5
  const failed = new Set(findings.filter((x) => x.severity === "fail").map((x) => x.test));
@@ -6,7 +7,12 @@ export function score(findings, data = {}) {
6
7
  const open = (Array.isArray(data.decisions) ? data.decisions : [])
7
8
  .filter((x) => String(x?.state || "").trim() === "open").map((x) => String(x.id || "").trim());
8
9
  const status = failed.size ? "fail" : open.length ? "blocked" : "pass";
9
- return { tests, passed: tests.filter((t) => t.pass).length, open, status };
10
+ const out = { tests, passed: tests.filter((t) => t.pass).length, open, status };
11
+ // Only when a known profile ran: profileStatus returns undefined for no profile: at all, and
12
+ // for one this linter does not know (nothing to count when its rules were never checked).
13
+ const profile = profileStatus(data, findings);
14
+ if (profile) out.profile = profile;
15
+ return out;
10
16
  }
11
17
 
12
18
  export const exitCode = (status) => ({ pass: 0, fail: 1, blocked: 3 })[status] ?? 2;
package/src/template.mjs CHANGED
@@ -4,7 +4,10 @@
4
4
  // which is also a valid YAML double-quoted scalar.
5
5
  const PLAIN = /^[A-Za-z0-9][A-Za-z0-9 _.,'()/-]*$/;
6
6
  const RESOLVES = /^(null|~|true|false|yes|no|on|off|y|n|[-+]?(\d[\d_]*)?\.?\d+([eE][-+]?\d+)?|0x[0-9a-f]+|0o[0-7]+|\.inf|\.nan)$/i;
7
- const scalar = (v) => (PLAIN.test(v) && !/\s$/.test(v) && !RESOLVES.test(v) ? v : JSON.stringify(v));
7
+ // Exported so any other init-time template (writing-template.mjs's writingTemplate, and whatever
8
+ // profile templates come after it) quotes titles, kinds and other free-text scalars the same way,
9
+ // rather than re-deriving this regex pair.
10
+ export const scalar = (v) => (PLAIN.test(v) && !/\s$/.test(v) && !RESOLVES.test(v) ? v : JSON.stringify(v));
8
11
 
9
12
  export function template({ title = "Untitled", kind = "document" } = {}) {
10
13
  const heading = String(title).replace(/\s+/g, " ").trim();