stego-cli 0.3.4 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.vscode/extensions.json +7 -0
  2. package/README.md +45 -0
  3. package/dist/metadata/metadata-command.js +127 -0
  4. package/dist/metadata/metadata-domain.js +209 -0
  5. package/dist/spine/spine-command.js +129 -0
  6. package/dist/spine/spine-domain.js +274 -0
  7. package/dist/stego-cli.js +358 -187
  8. package/package.json +3 -2
  9. package/projects/fiction-example/spine/characters/CHAR-AGNES.md +17 -0
  10. package/projects/fiction-example/spine/characters/CHAR-ETIENNE.md +17 -0
  11. package/projects/fiction-example/spine/characters/CHAR-MATTHAEUS.md +17 -0
  12. package/projects/fiction-example/spine/characters/CHAR-RAOUL.md +17 -0
  13. package/projects/fiction-example/spine/characters/_category.md +6 -0
  14. package/projects/fiction-example/spine/locations/LOC-CHARNEL.md +17 -0
  15. package/projects/fiction-example/spine/locations/LOC-COLLEGE.md +17 -0
  16. package/projects/fiction-example/spine/locations/LOC-HOTELDIEU.md +17 -0
  17. package/projects/fiction-example/spine/locations/LOC-QUAY.md +17 -0
  18. package/projects/fiction-example/spine/locations/_category.md +6 -0
  19. package/projects/fiction-example/spine/sources/SRC-CONJUNCTION.md +20 -0
  20. package/projects/fiction-example/spine/sources/SRC-GALEN.md +20 -0
  21. package/projects/fiction-example/spine/sources/SRC-WARD-DATA.md +20 -0
  22. package/projects/fiction-example/spine/sources/_category.md +6 -0
  23. package/projects/fiction-example/stego-project.json +1 -18
  24. package/projects/stego-docs/manuscript/500-project-configuration.md +3 -3
  25. package/projects/stego-docs/spine/commands/CMD-BUILD.md +11 -0
  26. package/projects/stego-docs/spine/commands/CMD-CHECK-STAGE.md +11 -0
  27. package/projects/stego-docs/spine/commands/CMD-EXPORT.md +11 -0
  28. package/projects/stego-docs/spine/commands/CMD-INIT.md +11 -0
  29. package/projects/stego-docs/spine/commands/CMD-LIST-PROJECTS.md +10 -0
  30. package/projects/stego-docs/spine/commands/CMD-NEW-PROJECT.md +10 -0
  31. package/projects/stego-docs/spine/commands/CMD-NEW.md +11 -0
  32. package/projects/stego-docs/spine/commands/CMD-VALIDATE.md +11 -0
  33. package/projects/stego-docs/spine/commands/_category.md +6 -0
  34. package/projects/stego-docs/spine/concepts/CON-COMPILE-STRUCTURE.md +9 -0
  35. package/projects/stego-docs/spine/concepts/CON-DIST.md +9 -0
  36. package/projects/stego-docs/spine/concepts/CON-MANUSCRIPT.md +9 -0
  37. package/projects/stego-docs/spine/concepts/CON-METADATA.md +9 -0
  38. package/projects/stego-docs/spine/concepts/CON-NOTES.md +9 -0
  39. package/projects/stego-docs/spine/concepts/CON-PROJECT.md +9 -0
  40. package/projects/stego-docs/spine/concepts/CON-SPINE-CATEGORY.md +11 -0
  41. package/projects/stego-docs/spine/concepts/CON-SPINE.md +9 -0
  42. package/projects/stego-docs/spine/concepts/CON-STAGE-GATE.md +10 -0
  43. package/projects/stego-docs/spine/concepts/CON-WORKSPACE.md +9 -0
  44. package/projects/stego-docs/spine/concepts/_category.md +6 -0
  45. package/projects/stego-docs/spine/configuration/CFG-ALLOWED-STATUSES.md +9 -0
  46. package/projects/stego-docs/spine/configuration/CFG-COMPILE-LEVELS.md +9 -0
  47. package/projects/stego-docs/spine/configuration/CFG-COMPILE-STRUCTURE.md +9 -0
  48. package/projects/stego-docs/spine/configuration/CFG-REQUIRED-METADATA.md +9 -0
  49. package/projects/stego-docs/spine/configuration/CFG-SPINE-CATEGORIES.md +9 -0
  50. package/projects/stego-docs/spine/configuration/CFG-STAGE-POLICIES.md +9 -0
  51. package/projects/stego-docs/spine/configuration/CFG-STEGO-CONFIG.md +9 -0
  52. package/projects/stego-docs/spine/configuration/CFG-STEGO-PROJECT.md +9 -0
  53. package/projects/stego-docs/spine/configuration/_category.md +6 -0
  54. package/projects/stego-docs/spine/integrations/INT-CSPELL.md +9 -0
  55. package/projects/stego-docs/spine/integrations/INT-MARKDOWNLINT.md +9 -0
  56. package/projects/stego-docs/spine/integrations/INT-PANDOC.md +9 -0
  57. package/projects/stego-docs/spine/integrations/INT-SAURUS-EXTENSION.md +9 -0
  58. package/projects/stego-docs/spine/integrations/INT-STEGO-EXTENSION.md +9 -0
  59. package/projects/stego-docs/spine/integrations/INT-VSCODE.md +9 -0
  60. package/projects/stego-docs/spine/integrations/_category.md +6 -0
  61. package/projects/stego-docs/spine/workflows/FLOW-BUILD-EXPORT.md +10 -0
  62. package/projects/stego-docs/spine/workflows/FLOW-DAILY-WRITING.md +10 -0
  63. package/projects/stego-docs/spine/workflows/FLOW-INIT-WORKSPACE.md +9 -0
  64. package/projects/stego-docs/spine/workflows/FLOW-NEW-PROJECT.md +10 -0
  65. package/projects/stego-docs/spine/workflows/FLOW-PROOF-RELEASE.md +10 -0
  66. package/projects/stego-docs/spine/workflows/FLOW-STAGE-PROMOTION.md +10 -0
  67. package/projects/stego-docs/spine/workflows/_category.md +6 -0
  68. package/projects/stego-docs/stego-project.json +1 -28
  69. package/projects/fiction-example/spine/characters.md +0 -35
  70. package/projects/fiction-example/spine/locations.md +0 -37
  71. package/projects/fiction-example/spine/sources.md +0 -31
  72. package/projects/stego-docs/spine/commands.md +0 -71
  73. package/projects/stego-docs/spine/concepts.md +0 -72
  74. package/projects/stego-docs/spine/configuration.md +0 -57
  75. package/projects/stego-docs/spine/integrations.md +0 -43
  76. package/projects/stego-docs/spine/workflows.md +0 -48
package/dist/stego-cli.js CHANGED
@@ -11,6 +11,9 @@ import { createPandocExporter } from "./exporters/pandoc-exporter.js";
11
11
  import { parseCommentAppendix } from "./comments/comment-domain.js";
12
12
  import { runCommentsCommand } from "./comments/comments-command.js";
13
13
  import { CommentsCommandError } from "./comments/errors.js";
14
+ import { runMetadataCommand } from "./metadata/metadata-command.js";
15
+ import { runSpineCommand } from "./spine/spine-command.js";
16
+ import { readSpineCatalog } from "./spine/spine-domain.js";
14
17
  const STATUS_RANK = {
15
18
  draft: 0,
16
19
  revise: 1,
@@ -22,6 +25,7 @@ const RESERVED_COMMENT_PREFIX = "CMT";
22
25
  const DEFAULT_NEW_MANUSCRIPT_SLUG = "new-document";
23
26
  const ROOT_CONFIG_FILENAME = "stego.config.json";
24
27
  const PROSE_FONT_PROMPT = "Switch workspace to proportional (prose-style) font? (recommended)";
28
+ const COMMENT_AUTHOR_PROMPT = "Default comment author for stego.comments.author?";
25
29
  const SCAFFOLD_GITIGNORE_CONTENT = `node_modules/
26
30
  /dist/
27
31
  .DS_Store
@@ -89,6 +93,103 @@ stego new-project --project my-book --title "My Book"
89
93
  stego new --project fiction-example
90
94
  \`\`\`
91
95
  `;
96
+ const SCAFFOLD_AGENTS_CONTENT = `# AGENTS.md
97
+
98
+ ## Purpose
99
+
100
+ This workspace is designed to be AI-friendly for writing workflows.
101
+
102
+ ## Canonical CLI Interface
103
+
104
+ - Run \`stego --help\` for the full command reference.
105
+ - Run \`stego --version\` to confirm which CLI is active.
106
+ - Run project docs commands in \`projects/stego-docs\` when available.
107
+
108
+ ## CLI Resolution Rules
109
+
110
+ - Prefer local CLI over global CLI:
111
+ - \`npm exec -- stego ...\`
112
+ - \`npx --no-install stego ...\`
113
+ - At the start of mutation tasks, run \`stego --version\` and report the version used.
114
+
115
+ ## Workspace Discovery Checklist
116
+
117
+ 1. Confirm workspace root contains \`stego.config.json\`.
118
+ 2. Run \`stego list-projects\`.
119
+ 3. Use explicit \`--project <id>\` for project-scoped commands.
120
+
121
+ ## CLI-First Policy (Required)
122
+
123
+ When asked to edit Stego project content, use documented Stego CLI commands first.
124
+
125
+ Typical targets:
126
+
127
+ - manuscript files
128
+ - spine categories and entries
129
+ - frontmatter metadata
130
+ - comments
131
+ - stage/build/export workflows
132
+
133
+ Preferred commands include:
134
+
135
+ - \`stego new\`
136
+ - \`stego spine read\`
137
+ - \`stego spine new-category\`
138
+ - \`stego spine new\`
139
+ - \`stego metadata read\`
140
+ - \`stego metadata apply\`
141
+ - \`stego comments ...\`
142
+
143
+ ## Machine-Mode Output
144
+
145
+ - For automation and integrations, prefer \`--format json\` and parse structured output.
146
+ - Use text output only for human-facing summaries.
147
+
148
+ ## Mutation Protocol
149
+
150
+ 1. Read current state first (\`metadata read\`, \`spine read\`, \`comments read\`).
151
+ 2. Mutate via CLI commands.
152
+ 3. Verify after writes (\`stego validate --project <id>\` and relevant read commands).
153
+
154
+ ## Manual Edit Fallback
155
+
156
+ Manual file edits are a last resort.
157
+
158
+ If manual edits are required, the agent must:
159
+
160
+ 1. warn that CLI was bypassed,
161
+ 2. explain why CLI could not be used, and
162
+ 3. list which files were manually edited.
163
+
164
+ ## Failure Contract
165
+
166
+ When CLI fails:
167
+
168
+ 1. show the attempted command,
169
+ 2. summarize the error briefly,
170
+ 3. report the recovery attempt, and
171
+ 4. if fallback is required, apply the Manual Edit Fallback policy.
172
+
173
+ ## Validation Expectations
174
+
175
+ After mutations, run relevant checks when feasible (for example \`stego validate --project <id>\`) and report results.
176
+
177
+ ## Scope Guardrails
178
+
179
+ - Do not manually edit \`dist/\` outputs or compiled export artifacts.
180
+ - Do not modify files outside the requested project scope unless the user explicitly asks.
181
+
182
+ ## Task To Command Quick Map
183
+
184
+ - New manuscript: \`stego new --project <id> [--filename <name>]\`
185
+ - Read spine: \`stego spine read --project <id> --format json\`
186
+ - New spine category: \`stego spine new-category --project <id> --key <category>\`
187
+ - New spine entry: \`stego spine new --project <id> --category <category> [--filename <path>]\`
188
+ - Read metadata: \`stego metadata read <markdown-path> --format json\`
189
+ - Apply metadata: \`stego metadata apply <markdown-path> --input <path|-> --format json\`
190
+ - Read comments: \`stego comments read <manuscript> --format json\`
191
+ - Mutate comments: \`stego comments add|reply|set-status|delete|clear-resolved|sync-anchors ... --format json\`
192
+ `;
92
193
  const PROSE_MARKDOWN_EDITOR_SETTINGS = {
93
194
  "[markdown]": {
94
195
  "editor.fontFamily": "Inter, Helvetica Neue, Helvetica, Arial, sans-serif",
@@ -102,7 +203,8 @@ const PROSE_MARKDOWN_EDITOR_SETTINGS = {
102
203
  };
103
204
  const PROJECT_EXTENSION_RECOMMENDATIONS = [
104
205
  "matt-gold.stego-extension",
105
- "matt-gold.saurus-extension"
206
+ "matt-gold.saurus-extension",
207
+ "streetsidesoftware.code-spell-checker"
106
208
  ];
107
209
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
108
210
  const packageRoot = path.resolve(scriptDir, "..");
@@ -111,6 +213,12 @@ let config;
111
213
  void main();
112
214
  async function main() {
113
215
  const { command, options } = parseArgs(process.argv.slice(2));
216
+ if (command === "version" || command === "--version" || command === "-v") {
217
+ const cliPackage = readJson(path.join(packageRoot, "package.json"));
218
+ const version = typeof cliPackage.version === "string" ? cliPackage.version : "0.0.0";
219
+ console.log(version);
220
+ return;
221
+ }
114
222
  if (!command || command === "help" || command === "--help" || command === "-h") {
115
223
  printUsage();
116
224
  return;
@@ -126,13 +234,31 @@ async function main() {
126
234
  return;
127
235
  case "new-project":
128
236
  activateWorkspace(options);
129
- await createProject(readStringOption(options, "project"), readStringOption(options, "title"));
237
+ await createProject({
238
+ projectId: readStringOption(options, "project"),
239
+ title: readStringOption(options, "title"),
240
+ proseFont: readStringOption(options, "prose-font"),
241
+ outputFormat: readStringOption(options, "format")
242
+ });
130
243
  return;
131
244
  case "new": {
132
245
  activateWorkspace(options);
133
246
  const project = resolveProject(readStringOption(options, "project"));
134
247
  const createdPath = createNewManuscript(project, readStringOption(options, "i"), readStringOption(options, "filename"));
135
- logLine(`Created manuscript: ${createdPath}`);
248
+ const outputFormat = parseTextOrJsonFormat(readStringOption(options, "format"));
249
+ if (outputFormat === "json") {
250
+ process.stdout.write(`${JSON.stringify({
251
+ ok: true,
252
+ operation: "new",
253
+ result: {
254
+ projectId: project.id,
255
+ filePath: createdPath
256
+ }
257
+ }, null, 2)}\n`);
258
+ }
259
+ else {
260
+ logLine(`Created manuscript: ${createdPath}`);
261
+ }
136
262
  return;
137
263
  }
138
264
  case "validate": {
@@ -202,6 +328,20 @@ async function main() {
202
328
  case "comments":
203
329
  await runCommentsCommand(options, process.cwd());
204
330
  return;
331
+ case "metadata":
332
+ await runMetadataCommand(options, process.cwd());
333
+ return;
334
+ case "spine": {
335
+ activateWorkspace(options);
336
+ const project = resolveProject(readStringOption(options, "project"));
337
+ runSpineCommand(options, {
338
+ id: project.id,
339
+ root: project.root,
340
+ spineDir: project.spineDir,
341
+ meta: project.meta
342
+ });
343
+ return;
344
+ }
205
345
  default:
206
346
  throw new Error(`Unknown command '${command}'. Run with 'help' for usage.`);
207
347
  }
@@ -232,6 +372,28 @@ function readStringOption(options, key) {
232
372
  function readBooleanOption(options, key) {
233
373
  return options[key] === true;
234
374
  }
375
+ function parseTextOrJsonFormat(raw) {
376
+ if (!raw || raw === "text") {
377
+ return "text";
378
+ }
379
+ if (raw === "json") {
380
+ return "json";
381
+ }
382
+ throw new Error("Invalid --format value. Use 'text' or 'json'.");
383
+ }
384
+ function parseProseFontMode(raw) {
385
+ const normalized = (raw || "prompt").trim().toLowerCase();
386
+ if (normalized === "yes" || normalized === "true" || normalized === "y") {
387
+ return "yes";
388
+ }
389
+ if (normalized === "no" || normalized === "false" || normalized === "n") {
390
+ return "no";
391
+ }
392
+ if (normalized === "prompt" || normalized === "ask") {
393
+ return "prompt";
394
+ }
395
+ throw new Error("Invalid --prose-font value. Use 'yes', 'no', or 'prompt'.");
396
+ }
235
397
  function activateWorkspace(options) {
236
398
  const workspace = resolveWorkspaceContext(readStringOption(options, "root"));
237
399
  repoRoot = workspace.repoRoot;
@@ -244,75 +406,6 @@ function isStageName(value) {
244
406
  function isExportFormat(value) {
245
407
  return value === "md" || value === "docx" || value === "pdf" || value === "epub";
246
408
  }
247
- function resolveSpineSchema(project) {
248
- const issues = [];
249
- const projectFile = path.relative(repoRoot, path.join(project.root, "stego-project.json"));
250
- const rawCategories = project.meta.spineCategories;
251
- if (rawCategories == null) {
252
- return { schema: { categories: [], inlineIdRegex: null }, issues };
253
- }
254
- if (!Array.isArray(rawCategories)) {
255
- issues.push(makeIssue("error", "metadata", "Project 'spineCategories' must be an array when defined.", projectFile));
256
- return { schema: { categories: [], inlineIdRegex: null }, issues };
257
- }
258
- const categories = [];
259
- const keySet = new Set();
260
- const prefixSet = new Set();
261
- const notesSet = new Set();
262
- for (const [index, categoryEntry] of rawCategories.entries()) {
263
- if (!isPlainObject(categoryEntry)) {
264
- issues.push(makeIssue("error", "metadata", `Invalid spineCategories entry at index ${index}. Expected object with key, prefix, notesFile.`, projectFile));
265
- continue;
266
- }
267
- const key = typeof categoryEntry.key === "string" ? categoryEntry.key.trim() : "";
268
- const prefix = typeof categoryEntry.prefix === "string" ? categoryEntry.prefix.trim() : "";
269
- const notesFile = typeof categoryEntry.notesFile === "string" ? categoryEntry.notesFile.trim() : "";
270
- if (!/^[a-z][a-z0-9_-]*$/.test(key)) {
271
- issues.push(makeIssue("error", "metadata", `Invalid spine category key '${key || "<empty>"}'. Use lowercase key names like 'cast' or 'incidents'.`, projectFile));
272
- continue;
273
- }
274
- if (!/^[A-Z][A-Z0-9-]*$/.test(prefix)) {
275
- issues.push(makeIssue("error", "metadata", `Invalid spine category prefix '${prefix || "<empty>"}'. Use uppercase prefixes like 'CHAR' or 'STATUTE'.`, projectFile));
276
- continue;
277
- }
278
- if (prefix.toUpperCase() === RESERVED_COMMENT_PREFIX) {
279
- issues.push(makeIssue("error", "metadata", `Invalid spine category prefix '${prefix}'. '${RESERVED_COMMENT_PREFIX}' is reserved for Stego comment IDs (e.g. CMT-0001).`, projectFile));
280
- continue;
281
- }
282
- if (!/^[A-Za-z0-9._-]+\.md$/.test(notesFile)) {
283
- issues.push(makeIssue("error", "metadata", `Invalid notesFile '${notesFile || "<empty>"}'. Use markdown filenames like 'characters.md' (resolved in spine/).`, projectFile));
284
- continue;
285
- }
286
- if (keySet.has(key)) {
287
- issues.push(makeIssue("error", "metadata", `Duplicate spine category key '${key}'.`, projectFile));
288
- continue;
289
- }
290
- if (prefixSet.has(prefix)) {
291
- issues.push(makeIssue("error", "metadata", `Duplicate spine category prefix '${prefix}'.`, projectFile));
292
- continue;
293
- }
294
- if (notesSet.has(notesFile)) {
295
- issues.push(makeIssue("error", "metadata", `Duplicate spine category notesFile '${notesFile}'.`, projectFile));
296
- continue;
297
- }
298
- keySet.add(key);
299
- prefixSet.add(prefix);
300
- notesSet.add(notesFile);
301
- categories.push({
302
- key,
303
- prefix,
304
- notesFile,
305
- idPattern: new RegExp(`^${escapeRegex(prefix)}-[A-Z0-9-]+$`)
306
- });
307
- }
308
- return {
309
- schema: {
310
- categories,
311
- inlineIdRegex: buildInlineIdRegex(categories)
312
- },
313
- issues
314
- };
315
- }
316
409
  function resolveRequiredMetadata(project, runtimeConfig) {
317
410
  const issues = [];
318
411
  const projectFile = path.relative(repoRoot, path.join(project.root, "stego-project.json"));
@@ -406,16 +499,6 @@ function resolveCompileStructure(project) {
406
499
  }
407
500
  return { levels, issues };
408
501
  }
409
- function buildInlineIdRegex(categories) {
410
- if (categories.length === 0) {
411
- return null;
412
- }
413
- const prefixes = categories.map((category) => escapeRegex(category.prefix)).join("|");
414
- return new RegExp(`\\b(?:${prefixes})-[A-Z0-9-]+\\b`, "g");
415
- }
416
- function escapeRegex(value) {
417
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
418
- }
419
502
  function isPlainObject(value) {
420
503
  return typeof value === "object" && value !== null && !Array.isArray(value);
421
504
  }
@@ -529,6 +612,7 @@ async function initWorkspace(options) {
529
612
  const copiedPaths = [];
530
613
  writeScaffoldGitignore(targetRoot, copiedPaths);
531
614
  writeScaffoldReadme(targetRoot, copiedPaths);
615
+ writeScaffoldAgents(targetRoot, copiedPaths);
532
616
  copyTemplateAsset(".markdownlint.json", targetRoot, copiedPaths);
533
617
  copyTemplateAsset(".markdownlint.manuscript.json", targetRoot, copiedPaths);
534
618
  copyTemplateAsset(".cspell.json", targetRoot, copiedPaths);
@@ -538,8 +622,13 @@ async function initWorkspace(options) {
538
622
  copyTemplateAsset(path.join(".vscode", "extensions.json"), targetRoot, copiedPaths, { optional: true });
539
623
  rewriteTemplateProjectPackageScripts(targetRoot);
540
624
  const enableProseFont = await promptYesNo(PROSE_FONT_PROMPT, true);
541
- if (enableProseFont) {
542
- writeProjectProseEditorSettings(targetRoot, copiedPaths);
625
+ const suggestedCommentAuthor = resolveSuggestedCommentAuthor(targetRoot);
626
+ const commentAuthor = (await promptText(COMMENT_AUTHOR_PROMPT, suggestedCommentAuthor)).trim();
627
+ if (enableProseFont || commentAuthor) {
628
+ writeProjectProseEditorSettings(targetRoot, copiedPaths, {
629
+ enableProseFont,
630
+ commentAuthor
631
+ });
543
632
  }
544
633
  writeInitRootPackageJson(targetRoot);
545
634
  logLine(`Initialized Stego workspace in ${targetRoot}`);
@@ -582,6 +671,46 @@ async function promptYesNo(question, defaultYes) {
582
671
  rl.close();
583
672
  }
584
673
  }
674
+ async function promptText(question, defaultValue = "") {
675
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
676
+ return defaultValue;
677
+ }
678
+ const rl = createInterface({
679
+ input: process.stdin,
680
+ output: process.stdout
681
+ });
682
+ const suffix = defaultValue ? ` [${defaultValue}] ` : " ";
683
+ try {
684
+ const answer = (await rl.question(`${question}${suffix}`)).trim();
685
+ if (!answer) {
686
+ return defaultValue;
687
+ }
688
+ return answer;
689
+ }
690
+ finally {
691
+ rl.close();
692
+ }
693
+ }
694
+ function resolveSuggestedCommentAuthor(cwd) {
695
+ const gitAuthor = spawnSync("git", ["config", "--get", "user.name"], {
696
+ cwd,
697
+ encoding: "utf8"
698
+ });
699
+ const fromGit = (gitAuthor.stdout || "").trim();
700
+ if (gitAuthor.status === 0 && fromGit) {
701
+ return fromGit;
702
+ }
703
+ try {
704
+ const username = os.userInfo().username.trim();
705
+ if (username) {
706
+ return username;
707
+ }
708
+ }
709
+ catch {
710
+ // ignore lookup failure and fall back to empty
711
+ }
712
+ return "";
713
+ }
585
714
  function copyTemplateAsset(sourceRelativePath, targetRoot, copiedPaths, options) {
586
715
  const sourcePath = path.join(packageRoot, sourceRelativePath);
587
716
  if (!fs.existsSync(sourcePath)) {
@@ -616,6 +745,11 @@ function writeScaffoldReadme(targetRoot, copiedPaths) {
616
745
  fs.writeFileSync(destinationPath, SCAFFOLD_README_CONTENT, "utf8");
617
746
  copiedPaths.push("README.md");
618
747
  }
748
+ function writeScaffoldAgents(targetRoot, copiedPaths) {
749
+ const destinationPath = path.join(targetRoot, "AGENTS.md");
750
+ fs.writeFileSync(destinationPath, SCAFFOLD_AGENTS_CONTENT, "utf8");
751
+ copiedPaths.push("AGENTS.md");
752
+ }
619
753
  function shouldCopyTemplatePath(currentSourcePath) {
620
754
  const relativePath = path.relative(packageRoot, currentSourcePath);
621
755
  if (!relativePath || relativePath.startsWith("..")) {
@@ -691,7 +825,7 @@ function ensureProjectExtensionsRecommendations(projectRoot) {
691
825
  };
692
826
  fs.writeFileSync(extensionsPath, `${JSON.stringify(extensionsConfig, null, 2)}\n`, "utf8");
693
827
  }
694
- function writeProjectProseEditorSettings(targetRoot, copiedPaths) {
828
+ function writeProjectProseEditorSettings(targetRoot, copiedPaths, options) {
695
829
  const projectsRoot = path.join(targetRoot, "projects");
696
830
  if (!fs.existsSync(projectsRoot)) {
697
831
  return;
@@ -701,14 +835,16 @@ function writeProjectProseEditorSettings(targetRoot, copiedPaths) {
701
835
  continue;
702
836
  }
703
837
  const projectRoot = path.join(projectsRoot, entry.name);
704
- const settingsPath = writeProseEditorSettingsForProject(projectRoot);
838
+ const settingsPath = writeProseEditorSettingsForProject(projectRoot, options);
705
839
  copiedPaths.push(path.relative(targetRoot, settingsPath));
706
840
  }
707
841
  }
708
- function writeProseEditorSettingsForProject(projectRoot) {
842
+ function writeProseEditorSettingsForProject(projectRoot, options) {
709
843
  const vscodeDir = path.join(projectRoot, ".vscode");
710
844
  const settingsPath = path.join(vscodeDir, "settings.json");
711
845
  fs.mkdirSync(vscodeDir, { recursive: true });
846
+ const enableProseFont = options?.enableProseFont ?? true;
847
+ const commentAuthor = (options?.commentAuthor ?? "").trim();
712
848
  let existingSettings = {};
713
849
  if (fs.existsSync(settingsPath)) {
714
850
  try {
@@ -724,17 +860,22 @@ function writeProseEditorSettingsForProject(projectRoot) {
724
860
  const proseMarkdownSettings = isPlainObject(PROSE_MARKDOWN_EDITOR_SETTINGS["[markdown]"])
725
861
  ? PROSE_MARKDOWN_EDITOR_SETTINGS["[markdown]"]
726
862
  : {};
727
- const existingMarkdownSettings = isPlainObject(existingSettings["[markdown]"])
728
- ? existingSettings["[markdown]"]
729
- : {};
730
863
  const nextSettings = {
731
- ...existingSettings,
732
- "[markdown]": {
864
+ ...existingSettings
865
+ };
866
+ if (enableProseFont) {
867
+ const existingMarkdownSettings = isPlainObject(existingSettings["[markdown]"])
868
+ ? existingSettings["[markdown]"]
869
+ : {};
870
+ nextSettings["[markdown]"] = {
733
871
  ...existingMarkdownSettings,
734
872
  ...proseMarkdownSettings
735
- },
736
- "markdown.preview.fontFamily": PROSE_MARKDOWN_EDITOR_SETTINGS["markdown.preview.fontFamily"]
737
- };
873
+ };
874
+ nextSettings["markdown.preview.fontFamily"] = PROSE_MARKDOWN_EDITOR_SETTINGS["markdown.preview.fontFamily"];
875
+ }
876
+ if (commentAuthor) {
877
+ nextSettings["stego.comments.author"] = commentAuthor;
878
+ }
738
879
  fs.writeFileSync(settingsPath, `${JSON.stringify(nextSettings, null, 2)}\n`, "utf8");
739
880
  return settingsPath;
740
881
  }
@@ -753,6 +894,8 @@ function writeInitRootPackageJson(targetRoot) {
753
894
  "list-projects": "stego list-projects",
754
895
  "new-project": "stego new-project",
755
896
  new: "stego new",
897
+ spine: "stego spine",
898
+ metadata: "stego metadata",
756
899
  lint: "stego lint",
757
900
  validate: "stego validate",
758
901
  build: "stego build",
@@ -768,7 +911,7 @@ function writeInitRootPackageJson(targetRoot) {
768
911
  fs.writeFileSync(path.join(targetRoot, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
769
912
  }
770
913
  function printUsage() {
771
- console.log(`Stego CLI\n\nCommands:\n init [--force]\n list-projects [--root <path>]\n new-project --project <project-id> [--title <title>] [--root <path>]\n new --project <project-id> [--i <prefix>|-i <prefix>] [--root <path>]\n validate --project <project-id> [--file <project-relative-manuscript-path>] [--root <path>]\n build --project <project-id> [--root <path>]\n check-stage --project <project-id> --stage <draft|revise|line-edit|proof|final> [--file <project-relative-manuscript-path>] [--root <path>]\n lint --project <project-id> [--manuscript|--spine] [--root <path>]\n export --project <project-id> --format <md|docx|pdf|epub> [--output <path>] [--root <path>]\n comments read <manuscript> [--format <text|json>]\n comments add <manuscript> [--message <text> | --input <path|->] [--author <name>] [--start-line <n> --start-col <n> --end-line <n> --end-col <n>] [--cursor-line <n>] [--format <text|json>]\n comments reply <manuscript> --comment-id <CMT-####> [--message <text> | --input <path|->] [--author <name>] [--format <text|json>]\n comments set-status <manuscript> --comment-id <CMT-####> --status <open|resolved> [--thread] [--format <text|json>]\n comments delete <manuscript> --comment-id <CMT-####> [--format <text|json>]\n comments clear-resolved <manuscript> [--format <text|json>]\n comments sync-anchors <manuscript> --input <path|-> [--format <text|json>]\n`);
914
+ console.log(`Stego CLI\n\nCommands:\n init [--force]\n list-projects [--root <path>]\n new-project --project <project-id> [--title <title>] [--prose-font <yes|no|prompt>] [--format <text|json>] [--root <path>]\n new --project <project-id> [--i <prefix>|-i <prefix>] [--filename <name>] [--format <text|json>] [--root <path>]\n validate --project <project-id> [--file <project-relative-manuscript-path>] [--root <path>]\n build --project <project-id> [--root <path>]\n check-stage --project <project-id> --stage <draft|revise|line-edit|proof|final> [--file <project-relative-manuscript-path>] [--root <path>]\n lint --project <project-id> [--manuscript|--spine] [--root <path>]\n export --project <project-id> --format <md|docx|pdf|epub> [--output <path>] [--root <path>]\n spine read --project <project-id> [--format <text|json>] [--root <path>]\n spine new-category --project <project-id> --key <category> [--label <label>] [--require-metadata] [--format <text|json>] [--root <path>]\n spine new --project <project-id> --category <category> [--filename <relative-path>] [--format <text|json>] [--root <path>]\n metadata read <markdown-path> [--format <text|json>]\n metadata apply <markdown-path> --input <path|-> [--format <text|json>]\n comments read <manuscript> [--format <text|json>]\n comments add <manuscript> [--message <text> | --input <path|->] [--author <name>] [--start-line <n> --start-col <n> --end-line <n> --end-col <n>] [--cursor-line <n>] [--format <text|json>]\n comments reply <manuscript> --comment-id <CMT-####> [--message <text> | --input <path|->] [--author <name>] [--format <text|json>]\n comments set-status <manuscript> --comment-id <CMT-####> --status <open|resolved> [--thread] [--format <text|json>]\n comments delete <manuscript> --comment-id <CMT-####> [--format <text|json>]\n comments clear-resolved <manuscript> [--format <text|json>]\n comments sync-anchors <manuscript> --input <path|-> [--format <text|json>]\n`);
772
915
  }
773
916
  function listProjects() {
774
917
  const ids = getProjectIds();
@@ -781,8 +924,8 @@ function listProjects() {
781
924
  console.log(`- ${id}`);
782
925
  }
783
926
  }
784
- async function createProject(projectIdOption, titleOption) {
785
- const projectId = (projectIdOption || "").trim();
927
+ async function createProject(options) {
928
+ const projectId = (options.projectId || "").trim();
786
929
  if (!projectId) {
787
930
  throw new Error("Project id is required. Use --project <project-id>.");
788
931
  }
@@ -802,7 +945,7 @@ async function createProject(projectIdOption, titleOption) {
802
945
  const manuscriptDir = path.join(projectRoot, config.chapterDir);
803
946
  const projectJson = {
804
947
  id: projectId,
805
- title: titleOption?.trim() || toDisplayTitle(projectId),
948
+ title: options.title?.trim() || toDisplayTitle(projectId),
806
949
  requiredMetadata: ["status"],
807
950
  compileStructure: {
808
951
  levels: [
@@ -815,14 +958,7 @@ async function createProject(projectIdOption, titleOption) {
815
958
  pageBreak: "between-groups"
816
959
  }
817
960
  ]
818
- },
819
- spineCategories: [
820
- {
821
- key: "characters",
822
- prefix: "CHAR",
823
- notesFile: "characters.md"
824
- }
825
- ]
961
+ }
826
962
  };
827
963
  const projectJsonPath = path.join(projectRoot, "stego-project.json");
828
964
  fs.writeFileSync(projectJsonPath, `${JSON.stringify(projectJson, null, 2)}\n`, "utf8");
@@ -831,6 +967,8 @@ async function createProject(projectIdOption, titleOption) {
831
967
  private: true,
832
968
  scripts: {
833
969
  new: "npx --no-install stego new",
970
+ "spine:new": "npx --no-install stego spine new",
971
+ "spine:new-category": "npx --no-install stego spine new-category",
834
972
  lint: "npx --no-install stego lint",
835
973
  validate: "npx --no-install stego validate",
836
974
  build: "npx --no-install stego build",
@@ -851,20 +989,57 @@ chapter_title: Hello World
851
989
 
852
990
  Start writing here.
853
991
  `, "utf8");
854
- const charactersNotesPath = path.join(spineDir, "characters.md");
855
- fs.writeFileSync(charactersNotesPath, "# Characters\n\n", "utf8");
992
+ const charactersDir = path.join(spineDir, "characters");
993
+ fs.mkdirSync(charactersDir, { recursive: true });
994
+ const charactersCategoryPath = path.join(charactersDir, "_category.md");
995
+ fs.writeFileSync(charactersCategoryPath, `---
996
+ label: Characters
997
+ ---
998
+
999
+ # Characters
1000
+
1001
+ `, "utf8");
1002
+ const charactersEntryPath = path.join(charactersDir, "example-character.md");
1003
+ fs.writeFileSync(charactersEntryPath, `# Example Character
1004
+
1005
+ `, "utf8");
856
1006
  const projectExtensionsPath = path.join(projectRoot, ".vscode", "extensions.json");
857
1007
  ensureProjectExtensionsRecommendations(projectRoot);
858
1008
  let projectSettingsPath = null;
859
- const enableProseFont = await promptYesNo(PROSE_FONT_PROMPT, true);
1009
+ const proseFontMode = parseProseFontMode(options.proseFont);
1010
+ const enableProseFont = proseFontMode === "prompt"
1011
+ ? await promptYesNo(PROSE_FONT_PROMPT, true)
1012
+ : proseFontMode === "yes";
860
1013
  if (enableProseFont) {
861
1014
  projectSettingsPath = writeProseEditorSettingsForProject(projectRoot);
862
1015
  }
1016
+ const outputFormat = parseTextOrJsonFormat(options.outputFormat);
1017
+ if (outputFormat === "json") {
1018
+ process.stdout.write(`${JSON.stringify({
1019
+ ok: true,
1020
+ operation: "new-project",
1021
+ result: {
1022
+ projectId,
1023
+ projectPath: path.relative(repoRoot, projectRoot),
1024
+ files: [
1025
+ path.relative(repoRoot, projectJsonPath),
1026
+ path.relative(repoRoot, projectPackagePath),
1027
+ path.relative(repoRoot, starterManuscriptPath),
1028
+ path.relative(repoRoot, charactersCategoryPath),
1029
+ path.relative(repoRoot, charactersEntryPath),
1030
+ path.relative(repoRoot, projectExtensionsPath),
1031
+ ...(projectSettingsPath ? [path.relative(repoRoot, projectSettingsPath)] : [])
1032
+ ]
1033
+ }
1034
+ }, null, 2)}\n`);
1035
+ return;
1036
+ }
863
1037
  logLine(`Created project: ${path.relative(repoRoot, projectRoot)}`);
864
1038
  logLine(`- ${path.relative(repoRoot, projectJsonPath)}`);
865
1039
  logLine(`- ${path.relative(repoRoot, projectPackagePath)}`);
866
1040
  logLine(`- ${path.relative(repoRoot, starterManuscriptPath)}`);
867
- logLine(`- ${path.relative(repoRoot, charactersNotesPath)}`);
1041
+ logLine(`- ${path.relative(repoRoot, charactersCategoryPath)}`);
1042
+ logLine(`- ${path.relative(repoRoot, charactersEntryPath)}`);
868
1043
  logLine(`- ${path.relative(repoRoot, projectExtensionsPath)}`);
869
1044
  if (projectSettingsPath) {
870
1045
  logLine(`- ${path.relative(repoRoot, projectSettingsPath)}`);
@@ -1061,13 +1236,16 @@ function inferProjectIdFromCwd(cwd) {
1061
1236
  }
1062
1237
  function inspectProject(project, runtimeConfig, options = {}) {
1063
1238
  const issues = [];
1064
- const emptySpineState = { ids: new Set(), issues: [] };
1065
- const spineSchema = resolveSpineSchema(project);
1239
+ const emptySpineState = { categories: [], entriesByCategory: new Map(), issues: [] };
1066
1240
  const requiredMetadataState = resolveRequiredMetadata(project, runtimeConfig);
1067
1241
  const compileStructureState = resolveCompileStructure(project);
1068
- issues.push(...spineSchema.issues);
1069
1242
  issues.push(...requiredMetadataState.issues);
1070
1243
  issues.push(...compileStructureState.issues);
1244
+ if (project.meta.spineCategories !== undefined) {
1245
+ issues.push(makeIssue("error", "metadata", "Legacy 'spineCategories' in stego-project.json is no longer supported. Use spine/ category directories and files.", path.relative(repoRoot, path.join(project.root, "stego-project.json"))));
1246
+ }
1247
+ const spineState = readSpine(project);
1248
+ issues.push(...spineState.issues);
1071
1249
  let chapterFiles = [];
1072
1250
  const onlyFile = options.onlyFile?.trim();
1073
1251
  if (onlyFile) {
@@ -1107,7 +1285,7 @@ function inspectProject(project, runtimeConfig, options = {}) {
1107
1285
  return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
1108
1286
  }
1109
1287
  }
1110
- const chapters = chapterFiles.map((chapterPath) => parseChapter(chapterPath, runtimeConfig, requiredMetadataState.requiredMetadata, spineSchema.schema.categories, spineSchema.schema.inlineIdRegex, compileStructureState.levels));
1288
+ const chapters = chapterFiles.map((chapterPath) => parseChapter(chapterPath, runtimeConfig, requiredMetadataState.requiredMetadata, spineState.categories, compileStructureState.levels));
1111
1289
  for (const chapter of chapters) {
1112
1290
  issues.push(...chapter.issues);
1113
1291
  }
@@ -1134,10 +1312,8 @@ function inspectProject(project, runtimeConfig, options = {}) {
1134
1312
  }
1135
1313
  return a.order - b.order;
1136
1314
  });
1137
- const spineState = readSpine(project.spineDir, spineSchema.schema.categories, spineSchema.schema.inlineIdRegex);
1138
- issues.push(...spineState.issues);
1139
1315
  for (const chapter of chapters) {
1140
- issues.push(...findUnknownSpineIds(chapter.referenceIds, spineState.ids, chapter.relativePath));
1316
+ issues.push(...findUnknownSpineReferences(chapter.referenceKeysByCategory, spineState.entriesByCategory, chapter.relativePath));
1141
1317
  }
1142
1318
  return {
1143
1319
  chapters,
@@ -1146,7 +1322,7 @@ function inspectProject(project, runtimeConfig, options = {}) {
1146
1322
  compileStructureLevels: compileStructureState.levels
1147
1323
  };
1148
1324
  }
1149
- function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategories, inlineIdRegex, compileStructureLevels) {
1325
+ function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategories, compileStructureLevels) {
1150
1326
  const relativePath = path.relative(repoRoot, chapterPath);
1151
1327
  const raw = fs.readFileSync(chapterPath, "utf8");
1152
1328
  const { metadata, body, comments, issues } = parseMetadata(raw, chapterPath, false);
@@ -1175,9 +1351,8 @@ function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategor
1175
1351
  void normalizeGroupingValue(metadata[level.titleKey], relativePath, chapterIssues, level.titleKey);
1176
1352
  }
1177
1353
  }
1178
- const referenceValidation = extractReferenceIds(metadata, relativePath, spineCategories);
1354
+ const referenceValidation = extractReferenceKeysByCategory(metadata, relativePath, spineCategories);
1179
1355
  chapterIssues.push(...referenceValidation.issues);
1180
- chapterIssues.push(...findInlineSpineIdMentions(body, relativePath, inlineIdRegex));
1181
1356
  chapterIssues.push(...validateMarkdownBody(body, chapterPath));
1182
1357
  return {
1183
1358
  path: chapterPath,
@@ -1185,7 +1360,7 @@ function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategor
1185
1360
  title,
1186
1361
  order,
1187
1362
  status,
1188
- referenceIds: referenceValidation.ids,
1363
+ referenceKeysByCategory: referenceValidation.referencesByCategory,
1189
1364
  groupValues,
1190
1365
  metadata,
1191
1366
  body,
@@ -1228,49 +1403,39 @@ function parseOrderFromFilename(chapterPath, relativePath, issues) {
1228
1403
  }
1229
1404
  return Number(match[1]);
1230
1405
  }
1231
- function extractReferenceIds(metadata, relativePath, spineCategories) {
1406
+ function extractReferenceKeysByCategory(metadata, relativePath, spineCategories) {
1232
1407
  const issues = [];
1233
- const ids = new Set();
1408
+ const referencesByCategory = {};
1234
1409
  for (const category of spineCategories) {
1235
1410
  const rawValue = metadata[category.key];
1236
1411
  if (rawValue == null || rawValue === "") {
1237
1412
  continue;
1238
1413
  }
1239
1414
  if (!Array.isArray(rawValue)) {
1240
- issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' must be an array, for example: [\"${category.prefix}-...\"]`, relativePath));
1415
+ issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' must be an array of spine entry keys (for example: [\"matthaeus\"]).`, relativePath));
1241
1416
  continue;
1242
1417
  }
1418
+ const seen = new Set();
1419
+ const values = [];
1243
1420
  for (const entry of rawValue) {
1244
1421
  if (typeof entry !== "string") {
1245
1422
  issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' entries must be strings.`, relativePath));
1246
1423
  continue;
1247
1424
  }
1248
- const id = entry.trim();
1249
- if (!category.idPattern.test(id)) {
1250
- issues.push(makeIssue("error", "metadata", `Invalid ${category.key} reference '${id}'. Expected pattern '${category.idPattern.source}'.`, relativePath));
1425
+ const normalized = entry.trim();
1426
+ if (!normalized) {
1427
+ issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' contains an empty entry key.`, relativePath));
1251
1428
  continue;
1252
1429
  }
1253
- ids.add(id);
1254
- }
1255
- }
1256
- return { ids: [...ids], issues };
1257
- }
1258
- function findInlineSpineIdMentions(body, relativePath, inlineIdRegex) {
1259
- const issues = [];
1260
- if (!inlineIdRegex) {
1261
- return issues;
1262
- }
1263
- const lines = body.split(/\r?\n/);
1264
- for (let index = 0; index < lines.length; index += 1) {
1265
- const matches = lines[index].match(inlineIdRegex);
1266
- if (!matches) {
1267
- continue;
1268
- }
1269
- for (const id of matches) {
1270
- issues.push(makeIssue("error", "continuity", `Inline ID '${id}' found in prose. Move canon IDs to metadata fields only.`, relativePath, index + 1));
1430
+ if (seen.has(normalized)) {
1431
+ continue;
1432
+ }
1433
+ seen.add(normalized);
1434
+ values.push(normalized);
1271
1435
  }
1436
+ referencesByCategory[category.key] = values;
1272
1437
  }
1273
- return issues;
1438
+ return { referencesByCategory, issues };
1274
1439
  }
1275
1440
  function parseMetadata(raw, chapterPath, required) {
1276
1441
  const relativePath = path.relative(repoRoot, chapterPath);
@@ -1527,39 +1692,31 @@ function runStyleHeuristics(body, relativePath) {
1527
1692
  function countWords(text) {
1528
1693
  return text.trim().split(/\s+/).filter(Boolean).length;
1529
1694
  }
1530
- function readSpine(spineDir, spineCategories, inlineIdRegex) {
1531
- const issues = [];
1532
- const ids = new Set();
1533
- if (spineCategories.length === 0) {
1534
- return { ids, issues };
1535
- }
1536
- if (!fs.existsSync(spineDir)) {
1537
- issues.push(makeIssue("warning", "continuity", `Missing spine directory: ${spineDir}`));
1538
- return { ids, issues };
1539
- }
1540
- for (const category of spineCategories) {
1541
- const fullPath = path.join(spineDir, category.notesFile);
1542
- const relativePath = path.relative(repoRoot, fullPath);
1543
- if (!fs.existsSync(fullPath)) {
1544
- issues.push(makeIssue("warning", "continuity", `Missing spine file '${category.notesFile}' for category '${category.key}'.`, relativePath));
1545
- continue;
1546
- }
1547
- const text = fs.readFileSync(fullPath, "utf8");
1548
- if (!inlineIdRegex) {
1549
- continue;
1550
- }
1551
- const matches = text.match(inlineIdRegex) || [];
1552
- for (const id of matches) {
1553
- ids.add(id);
1554
- }
1695
+ function readSpine(project) {
1696
+ const catalog = readSpineCatalog(project.root, project.spineDir);
1697
+ const categories = [];
1698
+ const entriesByCategory = new Map();
1699
+ for (const category of catalog.categories) {
1700
+ const entries = new Set(category.entries.map((entry) => entry.key));
1701
+ categories.push({ key: category.key, entries });
1702
+ entriesByCategory.set(category.key, entries);
1555
1703
  }
1556
- return { ids, issues };
1704
+ const issues = catalog.issues.map((message) => makeIssue("warning", "continuity", message));
1705
+ return { categories, entriesByCategory, issues };
1557
1706
  }
1558
- function findUnknownSpineIds(referenceIds, knownIds, relativePath) {
1707
+ function findUnknownSpineReferences(referencesByCategory, entriesByCategory, relativePath) {
1559
1708
  const issues = [];
1560
- for (const id of referenceIds) {
1561
- if (!knownIds.has(id)) {
1562
- issues.push(makeIssue("warning", "continuity", `Metadata reference '${id}' does not exist in the spine files.`, relativePath));
1709
+ for (const [categoryKey, values] of Object.entries(referencesByCategory)) {
1710
+ const known = entriesByCategory.get(categoryKey);
1711
+ if (!known) {
1712
+ issues.push(makeIssue("warning", "continuity", `Metadata category '${categoryKey}' has references but no matching spine category directory was found in spine/.`, relativePath));
1713
+ continue;
1714
+ }
1715
+ for (const value of values) {
1716
+ if (known.has(value)) {
1717
+ continue;
1718
+ }
1719
+ issues.push(makeIssue("warning", "continuity", `Metadata reference '${categoryKey}: ${value}' does not exist in spine/${categoryKey}/.`, relativePath));
1563
1720
  }
1564
1721
  }
1565
1722
  return issues;
@@ -1596,8 +1753,11 @@ function runStageCheck(project, runtimeConfig, stage, onlyFile) {
1596
1753
  }
1597
1754
  }
1598
1755
  if (policy.requireSpine) {
1756
+ if (report.spineState.categories.length === 0) {
1757
+ issues.push(makeIssue("error", "continuity", "No spine categories found. Add at least one category under spine/<category>/ before this stage."));
1758
+ }
1599
1759
  for (const spineIssue of report.issues.filter((issue) => issue.category === "continuity")) {
1600
- if (spineIssue.message.startsWith("Missing spine file")) {
1760
+ if (spineIssue.message.startsWith("Missing spine directory")) {
1601
1761
  issues.push({ ...spineIssue, level: "error" });
1602
1762
  }
1603
1763
  }
@@ -1609,7 +1769,7 @@ function runStageCheck(project, runtimeConfig, stage, onlyFile) {
1609
1769
  }
1610
1770
  }
1611
1771
  const chapterPaths = report.chapters.map((chapter) => chapter.path);
1612
- const spineWords = collectSpineWordsForSpellcheck(report.spineState.ids);
1772
+ const spineWords = collectSpineWordsForSpellcheck(report.spineState.entriesByCategory);
1613
1773
  if (policy.enforceMarkdownlint) {
1614
1774
  issues.push(...runMarkdownlint(project, chapterPaths, true, "manuscript"));
1615
1775
  }
@@ -1767,18 +1927,29 @@ function runMarkdownlint(project, files, required, profile = "default") {
1767
1927
  prepared.cleanup();
1768
1928
  }
1769
1929
  }
1770
- function collectSpineWordsForSpellcheck(ids) {
1930
+ function collectSpineWordsForSpellcheck(entriesByCategory) {
1771
1931
  const words = new Set();
1772
- for (const id of ids) {
1773
- const parts = id
1774
- .split("-")
1932
+ for (const [category, entries] of entriesByCategory) {
1933
+ const categoryParts = category
1934
+ .split(/[-_/]+/)
1775
1935
  .map((part) => part.trim())
1776
1936
  .filter(Boolean);
1777
- for (const part of parts.slice(1)) {
1778
- if (!/[A-Za-z]/.test(part)) {
1779
- continue;
1937
+ for (const part of categoryParts) {
1938
+ if (/[A-Za-z]/.test(part)) {
1939
+ words.add(part.toLowerCase());
1940
+ }
1941
+ }
1942
+ for (const entry of entries) {
1943
+ const entryParts = entry
1944
+ .split(/[-_/]+/)
1945
+ .map((part) => part.trim())
1946
+ .filter(Boolean);
1947
+ for (const part of entryParts) {
1948
+ if (!/[A-Za-z]/.test(part)) {
1949
+ continue;
1950
+ }
1951
+ words.add(part.toLowerCase());
1780
1952
  }
1781
- words.add(part.toLowerCase());
1782
1953
  }
1783
1954
  }
1784
1955
  return Array.from(words).sort();