stego-cli 0.3.3 → 0.4.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.
Files changed (78) hide show
  1. package/README.md +10 -0
  2. package/dist/comments/comment-domain.js +919 -0
  3. package/dist/comments/comments-command.js +356 -64
  4. package/dist/metadata/metadata-command.js +127 -0
  5. package/dist/metadata/metadata-domain.js +209 -0
  6. package/dist/spine/spine-command.js +129 -0
  7. package/dist/spine/spine-domain.js +274 -0
  8. package/dist/stego-cli.js +205 -426
  9. package/package.json +3 -2
  10. package/projects/fiction-example/spine/characters/CHAR-AGNES.md +17 -0
  11. package/projects/fiction-example/spine/characters/CHAR-ETIENNE.md +17 -0
  12. package/projects/fiction-example/spine/characters/CHAR-MATTHAEUS.md +17 -0
  13. package/projects/fiction-example/spine/characters/CHAR-RAOUL.md +17 -0
  14. package/projects/fiction-example/spine/characters/_category.md +6 -0
  15. package/projects/fiction-example/spine/locations/LOC-CHARNEL.md +17 -0
  16. package/projects/fiction-example/spine/locations/LOC-COLLEGE.md +17 -0
  17. package/projects/fiction-example/spine/locations/LOC-HOTELDIEU.md +17 -0
  18. package/projects/fiction-example/spine/locations/LOC-QUAY.md +17 -0
  19. package/projects/fiction-example/spine/locations/_category.md +6 -0
  20. package/projects/fiction-example/spine/sources/SRC-CONJUNCTION.md +20 -0
  21. package/projects/fiction-example/spine/sources/SRC-GALEN.md +20 -0
  22. package/projects/fiction-example/spine/sources/SRC-WARD-DATA.md +20 -0
  23. package/projects/fiction-example/spine/sources/_category.md +6 -0
  24. package/projects/fiction-example/stego-project.json +1 -18
  25. package/projects/stego-docs/manuscript/500-project-configuration.md +3 -3
  26. package/projects/stego-docs/spine/commands/CMD-BUILD.md +11 -0
  27. package/projects/stego-docs/spine/commands/CMD-CHECK-STAGE.md +11 -0
  28. package/projects/stego-docs/spine/commands/CMD-EXPORT.md +11 -0
  29. package/projects/stego-docs/spine/commands/CMD-INIT.md +11 -0
  30. package/projects/stego-docs/spine/commands/CMD-LIST-PROJECTS.md +10 -0
  31. package/projects/stego-docs/spine/commands/CMD-NEW-PROJECT.md +10 -0
  32. package/projects/stego-docs/spine/commands/CMD-NEW.md +11 -0
  33. package/projects/stego-docs/spine/commands/CMD-VALIDATE.md +11 -0
  34. package/projects/stego-docs/spine/commands/_category.md +6 -0
  35. package/projects/stego-docs/spine/concepts/CON-COMPILE-STRUCTURE.md +9 -0
  36. package/projects/stego-docs/spine/concepts/CON-DIST.md +9 -0
  37. package/projects/stego-docs/spine/concepts/CON-MANUSCRIPT.md +9 -0
  38. package/projects/stego-docs/spine/concepts/CON-METADATA.md +9 -0
  39. package/projects/stego-docs/spine/concepts/CON-NOTES.md +9 -0
  40. package/projects/stego-docs/spine/concepts/CON-PROJECT.md +9 -0
  41. package/projects/stego-docs/spine/concepts/CON-SPINE-CATEGORY.md +11 -0
  42. package/projects/stego-docs/spine/concepts/CON-SPINE.md +9 -0
  43. package/projects/stego-docs/spine/concepts/CON-STAGE-GATE.md +10 -0
  44. package/projects/stego-docs/spine/concepts/CON-WORKSPACE.md +9 -0
  45. package/projects/stego-docs/spine/concepts/_category.md +6 -0
  46. package/projects/stego-docs/spine/configuration/CFG-ALLOWED-STATUSES.md +9 -0
  47. package/projects/stego-docs/spine/configuration/CFG-COMPILE-LEVELS.md +9 -0
  48. package/projects/stego-docs/spine/configuration/CFG-COMPILE-STRUCTURE.md +9 -0
  49. package/projects/stego-docs/spine/configuration/CFG-REQUIRED-METADATA.md +9 -0
  50. package/projects/stego-docs/spine/configuration/CFG-SPINE-CATEGORIES.md +9 -0
  51. package/projects/stego-docs/spine/configuration/CFG-STAGE-POLICIES.md +9 -0
  52. package/projects/stego-docs/spine/configuration/CFG-STEGO-CONFIG.md +9 -0
  53. package/projects/stego-docs/spine/configuration/CFG-STEGO-PROJECT.md +9 -0
  54. package/projects/stego-docs/spine/configuration/_category.md +6 -0
  55. package/projects/stego-docs/spine/integrations/INT-CSPELL.md +9 -0
  56. package/projects/stego-docs/spine/integrations/INT-MARKDOWNLINT.md +9 -0
  57. package/projects/stego-docs/spine/integrations/INT-PANDOC.md +9 -0
  58. package/projects/stego-docs/spine/integrations/INT-SAURUS-EXTENSION.md +9 -0
  59. package/projects/stego-docs/spine/integrations/INT-STEGO-EXTENSION.md +9 -0
  60. package/projects/stego-docs/spine/integrations/INT-VSCODE.md +9 -0
  61. package/projects/stego-docs/spine/integrations/_category.md +6 -0
  62. package/projects/stego-docs/spine/workflows/FLOW-BUILD-EXPORT.md +10 -0
  63. package/projects/stego-docs/spine/workflows/FLOW-DAILY-WRITING.md +10 -0
  64. package/projects/stego-docs/spine/workflows/FLOW-INIT-WORKSPACE.md +9 -0
  65. package/projects/stego-docs/spine/workflows/FLOW-NEW-PROJECT.md +10 -0
  66. package/projects/stego-docs/spine/workflows/FLOW-PROOF-RELEASE.md +10 -0
  67. package/projects/stego-docs/spine/workflows/FLOW-STAGE-PROMOTION.md +10 -0
  68. package/projects/stego-docs/spine/workflows/_category.md +6 -0
  69. package/projects/stego-docs/stego-project.json +1 -28
  70. package/dist/comments/add-comment.js +0 -382
  71. package/projects/fiction-example/spine/characters.md +0 -35
  72. package/projects/fiction-example/spine/locations.md +0 -37
  73. package/projects/fiction-example/spine/sources.md +0 -31
  74. package/projects/stego-docs/spine/commands.md +0 -71
  75. package/projects/stego-docs/spine/concepts.md +0 -72
  76. package/projects/stego-docs/spine/configuration.md +0 -57
  77. package/projects/stego-docs/spine/integrations.md +0 -43
  78. package/projects/stego-docs/spine/workflows.md +0 -48
package/dist/stego-cli.js CHANGED
@@ -8,8 +8,12 @@ import { createInterface } from "node:readline/promises";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { markdownExporter } from "./exporters/markdown-exporter.js";
10
10
  import { createPandocExporter } from "./exporters/pandoc-exporter.js";
11
+ import { parseCommentAppendix } from "./comments/comment-domain.js";
11
12
  import { runCommentsCommand } from "./comments/comments-command.js";
12
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";
13
17
  const STATUS_RANK = {
14
18
  draft: 0,
15
19
  revise: 1,
@@ -110,6 +114,12 @@ let config;
110
114
  void main();
111
115
  async function main() {
112
116
  const { command, options } = parseArgs(process.argv.slice(2));
117
+ if (command === "version" || command === "--version" || command === "-v") {
118
+ const cliPackage = readJson(path.join(packageRoot, "package.json"));
119
+ const version = typeof cliPackage.version === "string" ? cliPackage.version : "0.0.0";
120
+ console.log(version);
121
+ return;
122
+ }
113
123
  if (!command || command === "help" || command === "--help" || command === "-h") {
114
124
  printUsage();
115
125
  return;
@@ -125,13 +135,31 @@ async function main() {
125
135
  return;
126
136
  case "new-project":
127
137
  activateWorkspace(options);
128
- await createProject(readStringOption(options, "project"), readStringOption(options, "title"));
138
+ await createProject({
139
+ projectId: readStringOption(options, "project"),
140
+ title: readStringOption(options, "title"),
141
+ proseFont: readStringOption(options, "prose-font"),
142
+ outputFormat: readStringOption(options, "format")
143
+ });
129
144
  return;
130
145
  case "new": {
131
146
  activateWorkspace(options);
132
147
  const project = resolveProject(readStringOption(options, "project"));
133
148
  const createdPath = createNewManuscript(project, readStringOption(options, "i"), readStringOption(options, "filename"));
134
- logLine(`Created manuscript: ${createdPath}`);
149
+ const outputFormat = parseTextOrJsonFormat(readStringOption(options, "format"));
150
+ if (outputFormat === "json") {
151
+ process.stdout.write(`${JSON.stringify({
152
+ ok: true,
153
+ operation: "new",
154
+ result: {
155
+ projectId: project.id,
156
+ filePath: createdPath
157
+ }
158
+ }, null, 2)}\n`);
159
+ }
160
+ else {
161
+ logLine(`Created manuscript: ${createdPath}`);
162
+ }
135
163
  return;
136
164
  }
137
165
  case "validate": {
@@ -201,6 +229,20 @@ async function main() {
201
229
  case "comments":
202
230
  await runCommentsCommand(options, process.cwd());
203
231
  return;
232
+ case "metadata":
233
+ await runMetadataCommand(options, process.cwd());
234
+ return;
235
+ case "spine": {
236
+ activateWorkspace(options);
237
+ const project = resolveProject(readStringOption(options, "project"));
238
+ runSpineCommand(options, {
239
+ id: project.id,
240
+ root: project.root,
241
+ spineDir: project.spineDir,
242
+ meta: project.meta
243
+ });
244
+ return;
245
+ }
204
246
  default:
205
247
  throw new Error(`Unknown command '${command}'. Run with 'help' for usage.`);
206
248
  }
@@ -231,6 +273,28 @@ function readStringOption(options, key) {
231
273
  function readBooleanOption(options, key) {
232
274
  return options[key] === true;
233
275
  }
276
+ function parseTextOrJsonFormat(raw) {
277
+ if (!raw || raw === "text") {
278
+ return "text";
279
+ }
280
+ if (raw === "json") {
281
+ return "json";
282
+ }
283
+ throw new Error("Invalid --format value. Use 'text' or 'json'.");
284
+ }
285
+ function parseProseFontMode(raw) {
286
+ const normalized = (raw || "prompt").trim().toLowerCase();
287
+ if (normalized === "yes" || normalized === "true" || normalized === "y") {
288
+ return "yes";
289
+ }
290
+ if (normalized === "no" || normalized === "false" || normalized === "n") {
291
+ return "no";
292
+ }
293
+ if (normalized === "prompt" || normalized === "ask") {
294
+ return "prompt";
295
+ }
296
+ throw new Error("Invalid --prose-font value. Use 'yes', 'no', or 'prompt'.");
297
+ }
234
298
  function activateWorkspace(options) {
235
299
  const workspace = resolveWorkspaceContext(readStringOption(options, "root"));
236
300
  repoRoot = workspace.repoRoot;
@@ -243,75 +307,6 @@ function isStageName(value) {
243
307
  function isExportFormat(value) {
244
308
  return value === "md" || value === "docx" || value === "pdf" || value === "epub";
245
309
  }
246
- function resolveSpineSchema(project) {
247
- const issues = [];
248
- const projectFile = path.relative(repoRoot, path.join(project.root, "stego-project.json"));
249
- const rawCategories = project.meta.spineCategories;
250
- if (rawCategories == null) {
251
- return { schema: { categories: [], inlineIdRegex: null }, issues };
252
- }
253
- if (!Array.isArray(rawCategories)) {
254
- issues.push(makeIssue("error", "metadata", "Project 'spineCategories' must be an array when defined.", projectFile));
255
- return { schema: { categories: [], inlineIdRegex: null }, issues };
256
- }
257
- const categories = [];
258
- const keySet = new Set();
259
- const prefixSet = new Set();
260
- const notesSet = new Set();
261
- for (const [index, categoryEntry] of rawCategories.entries()) {
262
- if (!isPlainObject(categoryEntry)) {
263
- issues.push(makeIssue("error", "metadata", `Invalid spineCategories entry at index ${index}. Expected object with key, prefix, notesFile.`, projectFile));
264
- continue;
265
- }
266
- const key = typeof categoryEntry.key === "string" ? categoryEntry.key.trim() : "";
267
- const prefix = typeof categoryEntry.prefix === "string" ? categoryEntry.prefix.trim() : "";
268
- const notesFile = typeof categoryEntry.notesFile === "string" ? categoryEntry.notesFile.trim() : "";
269
- if (!/^[a-z][a-z0-9_-]*$/.test(key)) {
270
- issues.push(makeIssue("error", "metadata", `Invalid spine category key '${key || "<empty>"}'. Use lowercase key names like 'cast' or 'incidents'.`, projectFile));
271
- continue;
272
- }
273
- if (!/^[A-Z][A-Z0-9-]*$/.test(prefix)) {
274
- issues.push(makeIssue("error", "metadata", `Invalid spine category prefix '${prefix || "<empty>"}'. Use uppercase prefixes like 'CHAR' or 'STATUTE'.`, projectFile));
275
- continue;
276
- }
277
- if (prefix.toUpperCase() === RESERVED_COMMENT_PREFIX) {
278
- issues.push(makeIssue("error", "metadata", `Invalid spine category prefix '${prefix}'. '${RESERVED_COMMENT_PREFIX}' is reserved for Stego comment IDs (e.g. CMT-0001).`, projectFile));
279
- continue;
280
- }
281
- if (!/^[A-Za-z0-9._-]+\.md$/.test(notesFile)) {
282
- issues.push(makeIssue("error", "metadata", `Invalid notesFile '${notesFile || "<empty>"}'. Use markdown filenames like 'characters.md' (resolved in spine/).`, projectFile));
283
- continue;
284
- }
285
- if (keySet.has(key)) {
286
- issues.push(makeIssue("error", "metadata", `Duplicate spine category key '${key}'.`, projectFile));
287
- continue;
288
- }
289
- if (prefixSet.has(prefix)) {
290
- issues.push(makeIssue("error", "metadata", `Duplicate spine category prefix '${prefix}'.`, projectFile));
291
- continue;
292
- }
293
- if (notesSet.has(notesFile)) {
294
- issues.push(makeIssue("error", "metadata", `Duplicate spine category notesFile '${notesFile}'.`, projectFile));
295
- continue;
296
- }
297
- keySet.add(key);
298
- prefixSet.add(prefix);
299
- notesSet.add(notesFile);
300
- categories.push({
301
- key,
302
- prefix,
303
- notesFile,
304
- idPattern: new RegExp(`^${escapeRegex(prefix)}-[A-Z0-9-]+$`)
305
- });
306
- }
307
- return {
308
- schema: {
309
- categories,
310
- inlineIdRegex: buildInlineIdRegex(categories)
311
- },
312
- issues
313
- };
314
- }
315
310
  function resolveRequiredMetadata(project, runtimeConfig) {
316
311
  const issues = [];
317
312
  const projectFile = path.relative(repoRoot, path.join(project.root, "stego-project.json"));
@@ -405,16 +400,6 @@ function resolveCompileStructure(project) {
405
400
  }
406
401
  return { levels, issues };
407
402
  }
408
- function buildInlineIdRegex(categories) {
409
- if (categories.length === 0) {
410
- return null;
411
- }
412
- const prefixes = categories.map((category) => escapeRegex(category.prefix)).join("|");
413
- return new RegExp(`\\b(?:${prefixes})-[A-Z0-9-]+\\b`, "g");
414
- }
415
- function escapeRegex(value) {
416
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
417
- }
418
403
  function isPlainObject(value) {
419
404
  return typeof value === "object" && value !== null && !Array.isArray(value);
420
405
  }
@@ -752,6 +737,8 @@ function writeInitRootPackageJson(targetRoot) {
752
737
  "list-projects": "stego list-projects",
753
738
  "new-project": "stego new-project",
754
739
  new: "stego new",
740
+ spine: "stego spine",
741
+ metadata: "stego metadata",
755
742
  lint: "stego lint",
756
743
  validate: "stego validate",
757
744
  build: "stego build",
@@ -767,7 +754,7 @@ function writeInitRootPackageJson(targetRoot) {
767
754
  fs.writeFileSync(path.join(targetRoot, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
768
755
  }
769
756
  function printUsage() {
770
- 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 add <manuscript> [--message <text> | --input <path|->] [--author <name>] [--start-line <n> --start-col <n> --end-line <n> --end-col <n>] [--format <text|json>]\n`);
757
+ 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`);
771
758
  }
772
759
  function listProjects() {
773
760
  const ids = getProjectIds();
@@ -780,8 +767,8 @@ function listProjects() {
780
767
  console.log(`- ${id}`);
781
768
  }
782
769
  }
783
- async function createProject(projectIdOption, titleOption) {
784
- const projectId = (projectIdOption || "").trim();
770
+ async function createProject(options) {
771
+ const projectId = (options.projectId || "").trim();
785
772
  if (!projectId) {
786
773
  throw new Error("Project id is required. Use --project <project-id>.");
787
774
  }
@@ -801,7 +788,7 @@ async function createProject(projectIdOption, titleOption) {
801
788
  const manuscriptDir = path.join(projectRoot, config.chapterDir);
802
789
  const projectJson = {
803
790
  id: projectId,
804
- title: titleOption?.trim() || toDisplayTitle(projectId),
791
+ title: options.title?.trim() || toDisplayTitle(projectId),
805
792
  requiredMetadata: ["status"],
806
793
  compileStructure: {
807
794
  levels: [
@@ -814,14 +801,7 @@ async function createProject(projectIdOption, titleOption) {
814
801
  pageBreak: "between-groups"
815
802
  }
816
803
  ]
817
- },
818
- spineCategories: [
819
- {
820
- key: "characters",
821
- prefix: "CHAR",
822
- notesFile: "characters.md"
823
- }
824
- ]
804
+ }
825
805
  };
826
806
  const projectJsonPath = path.join(projectRoot, "stego-project.json");
827
807
  fs.writeFileSync(projectJsonPath, `${JSON.stringify(projectJson, null, 2)}\n`, "utf8");
@@ -830,6 +810,8 @@ async function createProject(projectIdOption, titleOption) {
830
810
  private: true,
831
811
  scripts: {
832
812
  new: "npx --no-install stego new",
813
+ "spine:new": "npx --no-install stego spine new",
814
+ "spine:new-category": "npx --no-install stego spine new-category",
833
815
  lint: "npx --no-install stego lint",
834
816
  validate: "npx --no-install stego validate",
835
817
  build: "npx --no-install stego build",
@@ -850,20 +832,57 @@ chapter_title: Hello World
850
832
 
851
833
  Start writing here.
852
834
  `, "utf8");
853
- const charactersNotesPath = path.join(spineDir, "characters.md");
854
- fs.writeFileSync(charactersNotesPath, "# Characters\n\n", "utf8");
835
+ const charactersDir = path.join(spineDir, "characters");
836
+ fs.mkdirSync(charactersDir, { recursive: true });
837
+ const charactersCategoryPath = path.join(charactersDir, "_category.md");
838
+ fs.writeFileSync(charactersCategoryPath, `---
839
+ label: Characters
840
+ ---
841
+
842
+ # Characters
843
+
844
+ `, "utf8");
845
+ const charactersEntryPath = path.join(charactersDir, "example-character.md");
846
+ fs.writeFileSync(charactersEntryPath, `# Example Character
847
+
848
+ `, "utf8");
855
849
  const projectExtensionsPath = path.join(projectRoot, ".vscode", "extensions.json");
856
850
  ensureProjectExtensionsRecommendations(projectRoot);
857
851
  let projectSettingsPath = null;
858
- const enableProseFont = await promptYesNo(PROSE_FONT_PROMPT, true);
852
+ const proseFontMode = parseProseFontMode(options.proseFont);
853
+ const enableProseFont = proseFontMode === "prompt"
854
+ ? await promptYesNo(PROSE_FONT_PROMPT, true)
855
+ : proseFontMode === "yes";
859
856
  if (enableProseFont) {
860
857
  projectSettingsPath = writeProseEditorSettingsForProject(projectRoot);
861
858
  }
859
+ const outputFormat = parseTextOrJsonFormat(options.outputFormat);
860
+ if (outputFormat === "json") {
861
+ process.stdout.write(`${JSON.stringify({
862
+ ok: true,
863
+ operation: "new-project",
864
+ result: {
865
+ projectId,
866
+ projectPath: path.relative(repoRoot, projectRoot),
867
+ files: [
868
+ path.relative(repoRoot, projectJsonPath),
869
+ path.relative(repoRoot, projectPackagePath),
870
+ path.relative(repoRoot, starterManuscriptPath),
871
+ path.relative(repoRoot, charactersCategoryPath),
872
+ path.relative(repoRoot, charactersEntryPath),
873
+ path.relative(repoRoot, projectExtensionsPath),
874
+ ...(projectSettingsPath ? [path.relative(repoRoot, projectSettingsPath)] : [])
875
+ ]
876
+ }
877
+ }, null, 2)}\n`);
878
+ return;
879
+ }
862
880
  logLine(`Created project: ${path.relative(repoRoot, projectRoot)}`);
863
881
  logLine(`- ${path.relative(repoRoot, projectJsonPath)}`);
864
882
  logLine(`- ${path.relative(repoRoot, projectPackagePath)}`);
865
883
  logLine(`- ${path.relative(repoRoot, starterManuscriptPath)}`);
866
- logLine(`- ${path.relative(repoRoot, charactersNotesPath)}`);
884
+ logLine(`- ${path.relative(repoRoot, charactersCategoryPath)}`);
885
+ logLine(`- ${path.relative(repoRoot, charactersEntryPath)}`);
867
886
  logLine(`- ${path.relative(repoRoot, projectExtensionsPath)}`);
868
887
  if (projectSettingsPath) {
869
888
  logLine(`- ${path.relative(repoRoot, projectSettingsPath)}`);
@@ -1060,13 +1079,16 @@ function inferProjectIdFromCwd(cwd) {
1060
1079
  }
1061
1080
  function inspectProject(project, runtimeConfig, options = {}) {
1062
1081
  const issues = [];
1063
- const emptySpineState = { ids: new Set(), issues: [] };
1064
- const spineSchema = resolveSpineSchema(project);
1082
+ const emptySpineState = { categories: [], entriesByCategory: new Map(), issues: [] };
1065
1083
  const requiredMetadataState = resolveRequiredMetadata(project, runtimeConfig);
1066
1084
  const compileStructureState = resolveCompileStructure(project);
1067
- issues.push(...spineSchema.issues);
1068
1085
  issues.push(...requiredMetadataState.issues);
1069
1086
  issues.push(...compileStructureState.issues);
1087
+ if (project.meta.spineCategories !== undefined) {
1088
+ 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"))));
1089
+ }
1090
+ const spineState = readSpine(project);
1091
+ issues.push(...spineState.issues);
1070
1092
  let chapterFiles = [];
1071
1093
  const onlyFile = options.onlyFile?.trim();
1072
1094
  if (onlyFile) {
@@ -1106,7 +1128,7 @@ function inspectProject(project, runtimeConfig, options = {}) {
1106
1128
  return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
1107
1129
  }
1108
1130
  }
1109
- const chapters = chapterFiles.map((chapterPath) => parseChapter(chapterPath, runtimeConfig, requiredMetadataState.requiredMetadata, spineSchema.schema.categories, spineSchema.schema.inlineIdRegex, compileStructureState.levels));
1131
+ const chapters = chapterFiles.map((chapterPath) => parseChapter(chapterPath, runtimeConfig, requiredMetadataState.requiredMetadata, spineState.categories, compileStructureState.levels));
1110
1132
  for (const chapter of chapters) {
1111
1133
  issues.push(...chapter.issues);
1112
1134
  }
@@ -1133,10 +1155,8 @@ function inspectProject(project, runtimeConfig, options = {}) {
1133
1155
  }
1134
1156
  return a.order - b.order;
1135
1157
  });
1136
- const spineState = readSpine(project.spineDir, spineSchema.schema.categories, spineSchema.schema.inlineIdRegex);
1137
- issues.push(...spineState.issues);
1138
1158
  for (const chapter of chapters) {
1139
- issues.push(...findUnknownSpineIds(chapter.referenceIds, spineState.ids, chapter.relativePath));
1159
+ issues.push(...findUnknownSpineReferences(chapter.referenceKeysByCategory, spineState.entriesByCategory, chapter.relativePath));
1140
1160
  }
1141
1161
  return {
1142
1162
  chapters,
@@ -1145,7 +1165,7 @@ function inspectProject(project, runtimeConfig, options = {}) {
1145
1165
  compileStructureLevels: compileStructureState.levels
1146
1166
  };
1147
1167
  }
1148
- function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategories, inlineIdRegex, compileStructureLevels) {
1168
+ function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategories, compileStructureLevels) {
1149
1169
  const relativePath = path.relative(repoRoot, chapterPath);
1150
1170
  const raw = fs.readFileSync(chapterPath, "utf8");
1151
1171
  const { metadata, body, comments, issues } = parseMetadata(raw, chapterPath, false);
@@ -1174,9 +1194,8 @@ function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategor
1174
1194
  void normalizeGroupingValue(metadata[level.titleKey], relativePath, chapterIssues, level.titleKey);
1175
1195
  }
1176
1196
  }
1177
- const referenceValidation = extractReferenceIds(metadata, relativePath, spineCategories);
1197
+ const referenceValidation = extractReferenceKeysByCategory(metadata, relativePath, spineCategories);
1178
1198
  chapterIssues.push(...referenceValidation.issues);
1179
- chapterIssues.push(...findInlineSpineIdMentions(body, relativePath, inlineIdRegex));
1180
1199
  chapterIssues.push(...validateMarkdownBody(body, chapterPath));
1181
1200
  return {
1182
1201
  path: chapterPath,
@@ -1184,7 +1203,7 @@ function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategor
1184
1203
  title,
1185
1204
  order,
1186
1205
  status,
1187
- referenceIds: referenceValidation.ids,
1206
+ referenceKeysByCategory: referenceValidation.referencesByCategory,
1188
1207
  groupValues,
1189
1208
  metadata,
1190
1209
  body,
@@ -1227,49 +1246,39 @@ function parseOrderFromFilename(chapterPath, relativePath, issues) {
1227
1246
  }
1228
1247
  return Number(match[1]);
1229
1248
  }
1230
- function extractReferenceIds(metadata, relativePath, spineCategories) {
1249
+ function extractReferenceKeysByCategory(metadata, relativePath, spineCategories) {
1231
1250
  const issues = [];
1232
- const ids = new Set();
1251
+ const referencesByCategory = {};
1233
1252
  for (const category of spineCategories) {
1234
1253
  const rawValue = metadata[category.key];
1235
1254
  if (rawValue == null || rawValue === "") {
1236
1255
  continue;
1237
1256
  }
1238
1257
  if (!Array.isArray(rawValue)) {
1239
- issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' must be an array, for example: [\"${category.prefix}-...\"]`, relativePath));
1258
+ issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' must be an array of spine entry keys (for example: [\"matthaeus\"]).`, relativePath));
1240
1259
  continue;
1241
1260
  }
1261
+ const seen = new Set();
1262
+ const values = [];
1242
1263
  for (const entry of rawValue) {
1243
1264
  if (typeof entry !== "string") {
1244
1265
  issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' entries must be strings.`, relativePath));
1245
1266
  continue;
1246
1267
  }
1247
- const id = entry.trim();
1248
- if (!category.idPattern.test(id)) {
1249
- issues.push(makeIssue("error", "metadata", `Invalid ${category.key} reference '${id}'. Expected pattern '${category.idPattern.source}'.`, relativePath));
1268
+ const normalized = entry.trim();
1269
+ if (!normalized) {
1270
+ issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' contains an empty entry key.`, relativePath));
1250
1271
  continue;
1251
1272
  }
1252
- ids.add(id);
1253
- }
1254
- }
1255
- return { ids: [...ids], issues };
1256
- }
1257
- function findInlineSpineIdMentions(body, relativePath, inlineIdRegex) {
1258
- const issues = [];
1259
- if (!inlineIdRegex) {
1260
- return issues;
1261
- }
1262
- const lines = body.split(/\r?\n/);
1263
- for (let index = 0; index < lines.length; index += 1) {
1264
- const matches = lines[index].match(inlineIdRegex);
1265
- if (!matches) {
1266
- continue;
1267
- }
1268
- for (const id of matches) {
1269
- issues.push(makeIssue("error", "continuity", `Inline ID '${id}' found in prose. Move canon IDs to metadata fields only.`, relativePath, index + 1));
1273
+ if (seen.has(normalized)) {
1274
+ continue;
1275
+ }
1276
+ seen.add(normalized);
1277
+ values.push(normalized);
1270
1278
  }
1279
+ referencesByCategory[category.key] = values;
1271
1280
  }
1272
- return issues;
1281
+ return { referencesByCategory, issues };
1273
1282
  }
1274
1283
  function parseMetadata(raw, chapterPath, required) {
1275
1284
  const relativePath = path.relative(repoRoot, chapterPath);
@@ -1375,265 +1384,29 @@ function parseMetadata(raw, chapterPath, required) {
1375
1384
  };
1376
1385
  }
1377
1386
  function parseStegoCommentsAppendix(body, relativePath, bodyStartLine) {
1378
- const lineEnding = body.includes("\r\n") ? "\r\n" : "\n";
1379
- const lines = body.split(/\r?\n/);
1380
- const startMarker = "<!-- stego-comments:start -->";
1381
- const endMarker = "<!-- stego-comments:end -->";
1382
- const issues = [];
1383
- const startIndexes = findTrimmedLineIndexes(lines, startMarker);
1384
- const endIndexes = findTrimmedLineIndexes(lines, endMarker);
1385
- if (startIndexes.length === 0 && endIndexes.length === 0) {
1386
- return { bodyWithoutComments: body, comments: [], issues };
1387
- }
1388
- if (startIndexes.length !== 1 || endIndexes.length !== 1) {
1389
- if (startIndexes.length !== 1) {
1390
- issues.push(makeIssue("error", "comments", `Expected exactly one '${startMarker}' marker.`, relativePath));
1391
- }
1392
- if (endIndexes.length !== 1) {
1393
- issues.push(makeIssue("error", "comments", `Expected exactly one '${endMarker}' marker.`, relativePath));
1394
- }
1395
- return { bodyWithoutComments: body, comments: [], issues };
1396
- }
1397
- const start = startIndexes[0];
1398
- const end = endIndexes[0];
1399
- if (end <= start) {
1400
- issues.push(makeIssue("error", "comments", `'${endMarker}' must appear after '${startMarker}'.`, relativePath, bodyStartLine + end));
1401
- return { bodyWithoutComments: body, comments: [], issues };
1402
- }
1403
- const blockLines = lines.slice(start + 1, end);
1404
- const comments = parseStegoCommentThreads(blockLines, relativePath, bodyStartLine + start + 1, issues);
1405
- let removeStart = start;
1406
- if (removeStart > 0 && lines[removeStart - 1].trim().length === 0) {
1407
- removeStart -= 1;
1408
- }
1409
- const kept = [...lines.slice(0, removeStart), ...lines.slice(end + 1)];
1410
- while (kept.length > 0 && kept[kept.length - 1].trim().length === 0) {
1411
- kept.pop();
1412
- }
1387
+ const parsed = parseCommentAppendix(body);
1388
+ const issues = parsed.errors.map((error) => parseCommentIssueFromParserError(error, relativePath, bodyStartLine));
1389
+ const comments = parsed.comments.map((comment) => ({
1390
+ id: comment.id,
1391
+ resolved: comment.status === "resolved",
1392
+ thread: comment.thread
1393
+ }));
1413
1394
  return {
1414
- bodyWithoutComments: kept.join(lineEnding),
1395
+ bodyWithoutComments: parsed.contentWithoutComments,
1415
1396
  comments,
1416
1397
  issues
1417
1398
  };
1418
1399
  }
1419
- function parseStegoCommentThreads(lines, relativePath, baseLine, issues) {
1420
- const comments = [];
1421
- let index = 0;
1422
- while (index < lines.length) {
1423
- const trimmed = lines[index].trim();
1424
- if (!trimmed) {
1425
- index += 1;
1426
- continue;
1427
- }
1428
- const id = parseCommentThreadDelimiter(trimmed);
1429
- if (!id) {
1430
- issues.push(makeIssue("error", "comments", "Invalid comments appendix line. Expected comment delimiter '<!-- comment: CMT-0001 -->'.", relativePath, baseLine + index));
1431
- index += 1;
1432
- continue;
1433
- }
1434
- index += 1;
1435
- const rowLines = [];
1436
- const rowLineNumbers = [];
1437
- while (index < lines.length) {
1438
- const nextTrimmed = lines[index].trim();
1439
- if (parseCommentThreadDelimiter(nextTrimmed)) {
1440
- break;
1441
- }
1442
- rowLines.push(lines[index]);
1443
- rowLineNumbers.push(baseLine + index);
1444
- index += 1;
1445
- }
1446
- let resolved;
1447
- let sawMeta64 = false;
1448
- const thread = [];
1449
- let rowIndex = 0;
1450
- while (rowIndex < rowLines.length) {
1451
- const rawRow = rowLines[rowIndex];
1452
- const lineNumber = rowLineNumbers[rowIndex];
1453
- const trimmedRow = rawRow.trim();
1454
- if (!trimmedRow) {
1455
- rowIndex += 1;
1456
- continue;
1457
- }
1458
- if (thread.length > 0) {
1459
- issues.push(makeIssue("error", "comments", `Multiple message blocks found for ${id}. Create a new CMT id for each reply.`, relativePath, lineNumber));
1460
- break;
1461
- }
1462
- if (!sawMeta64) {
1463
- const metaMatch = trimmedRow.match(/^<!--\s*meta64:\s*(\S+)\s*-->\s*$/);
1464
- if (!metaMatch) {
1465
- issues.push(makeIssue("error", "comments", `Invalid comment metadata row '${trimmedRow}'. Expected '<!-- meta64: <base64url-json> -->'.`, relativePath, lineNumber));
1466
- rowIndex += 1;
1467
- continue;
1468
- }
1469
- sawMeta64 = true;
1470
- const decoded = decodeCommentMeta64(metaMatch[1], id, relativePath, lineNumber, issues);
1471
- if (decoded) {
1472
- resolved = decoded.resolved;
1473
- }
1474
- rowIndex += 1;
1475
- continue;
1476
- }
1477
- const headerQuote = extractQuotedLine(rawRow);
1478
- if (headerQuote === undefined) {
1479
- issues.push(makeIssue("error", "comments", `Invalid thread header '${trimmedRow}'. Expected blockquote header like '> _timestamp — author_'.`, relativePath, lineNumber));
1480
- rowIndex += 1;
1481
- continue;
1482
- }
1483
- const header = parseThreadHeader(headerQuote);
1484
- if (!header) {
1485
- issues.push(makeIssue("error", "comments", `Invalid thread header '${headerQuote.trim()}'. Expected '> _timestamp — author_'.`, relativePath, lineNumber));
1486
- rowIndex += 1;
1487
- continue;
1488
- }
1489
- rowIndex += 1;
1490
- while (rowIndex < rowLines.length) {
1491
- const separatorRaw = rowLines[rowIndex];
1492
- const separatorTrimmed = separatorRaw.trim();
1493
- if (!separatorTrimmed) {
1494
- rowIndex += 1;
1495
- continue;
1496
- }
1497
- const separatorQuote = extractQuotedLine(separatorRaw);
1498
- if (separatorQuote !== undefined && separatorQuote.trim().length === 0) {
1499
- rowIndex += 1;
1500
- }
1501
- break;
1502
- }
1503
- const messageLines = [];
1504
- while (rowIndex < rowLines.length) {
1505
- const messageRaw = rowLines[rowIndex];
1506
- const messageLineNumber = rowLineNumbers[rowIndex];
1507
- const messageTrimmed = messageRaw.trim();
1508
- if (!messageTrimmed) {
1509
- rowIndex += 1;
1510
- if (messageLines.length > 0) {
1511
- break;
1512
- }
1513
- continue;
1514
- }
1515
- const messageQuote = extractQuotedLine(messageRaw);
1516
- if (messageQuote === undefined) {
1517
- issues.push(makeIssue("error", "comments", `Invalid thread line '${messageTrimmed}'. Expected blockquote content starting with '>'.`, relativePath, messageLineNumber));
1518
- rowIndex += 1;
1519
- if (messageLines.length > 0) {
1520
- break;
1521
- }
1522
- continue;
1523
- }
1524
- if (parseThreadHeader(messageQuote)) {
1525
- break;
1526
- }
1527
- messageLines.push(messageQuote);
1528
- rowIndex += 1;
1529
- }
1530
- while (messageLines.length > 0 && messageLines[messageLines.length - 1].trim().length === 0) {
1531
- messageLines.pop();
1532
- }
1533
- if (messageLines.length === 0) {
1534
- issues.push(makeIssue("error", "comments", `Thread entry for comment ${id} is missing message text.`, relativePath, lineNumber));
1535
- continue;
1536
- }
1537
- const message = messageLines.join("\n").trim();
1538
- thread.push(`${header.timestamp} | ${header.author} | ${message}`);
1539
- }
1540
- if (!sawMeta64) {
1541
- issues.push(makeIssue("error", "comments", `Comment ${id} is missing metadata row ('<!-- meta64: <base64url-json> -->').`, relativePath));
1542
- resolved = false;
1543
- }
1544
- if (thread.length === 0) {
1545
- issues.push(makeIssue("error", "comments", `Comment ${id} is missing valid blockquote thread entries.`, relativePath));
1546
- }
1547
- comments.push({ id, resolved: Boolean(resolved), thread });
1400
+ function parseCommentIssueFromParserError(error, relativePath, bodyStartLine) {
1401
+ const lineMatch = error.match(/^Line\\s+(\\d+):\\s+([\\s\\S]+)$/);
1402
+ if (!lineMatch) {
1403
+ return makeIssue("error", "comments", error, relativePath);
1548
1404
  }
1549
- return comments;
1550
- }
1551
- function parseCommentThreadDelimiter(line) {
1552
- const markerMatch = line.match(/^<!--\s*comment:\s*(CMT-\d{4,})\s*-->\s*$/i);
1553
- if (markerMatch?.[1]) {
1554
- return markerMatch[1].toUpperCase();
1555
- }
1556
- const legacyHeadingMatch = line.match(/^###\s+(CMT-\d{4,})\s*$/i);
1557
- if (legacyHeadingMatch?.[1]) {
1558
- return legacyHeadingMatch[1].toUpperCase();
1559
- }
1560
- return undefined;
1561
- }
1562
- function decodeCommentMeta64(encoded, commentId, relativePath, lineNumber, issues) {
1563
- let rawJson = "";
1564
- try {
1565
- rawJson = Buffer.from(encoded, "base64url").toString("utf8");
1566
- }
1567
- catch {
1568
- issues.push(makeIssue("error", "comments", `Invalid meta64 payload for comment ${commentId}; expected base64url-encoded JSON.`, relativePath, lineNumber));
1569
- return undefined;
1570
- }
1571
- let parsed;
1572
- try {
1573
- parsed = JSON.parse(rawJson);
1574
- }
1575
- catch {
1576
- issues.push(makeIssue("error", "comments", `Invalid meta64 JSON for comment ${commentId}.`, relativePath, lineNumber));
1577
- return undefined;
1578
- }
1579
- if (!isPlainObject(parsed)) {
1580
- issues.push(makeIssue("error", "comments", `Invalid meta64 object for comment ${commentId}.`, relativePath, lineNumber));
1581
- return undefined;
1582
- }
1583
- const allowedKeys = new Set([
1584
- "status",
1585
- "created_at",
1586
- "timezone",
1587
- "timezone_offset_minutes",
1588
- "paragraph_index",
1589
- "excerpt_start_line",
1590
- "excerpt_start_col",
1591
- "excerpt_end_line",
1592
- "excerpt_end_col",
1593
- "anchor",
1594
- "excerpt",
1595
- "signature"
1596
- ]);
1597
- for (const key of Object.keys(parsed)) {
1598
- if (!allowedKeys.has(key)) {
1599
- issues.push(makeIssue("error", "comments", `meta64 for comment ${commentId} contains unsupported key '${key}'.`, relativePath, lineNumber));
1600
- return undefined;
1601
- }
1602
- }
1603
- const status = typeof parsed.status === "string" ? parsed.status.trim().toLowerCase() : "";
1604
- if (status !== "open" && status !== "resolved") {
1605
- issues.push(makeIssue("error", "comments", `meta64 for comment ${commentId} must include status 'open' or 'resolved'.`, relativePath, lineNumber));
1606
- return undefined;
1607
- }
1608
- return { resolved: status === "resolved" };
1609
- }
1610
- function extractQuotedLine(raw) {
1611
- const quoteMatch = raw.match(/^\s*>\s?(.*)$/);
1612
- if (!quoteMatch) {
1613
- return undefined;
1614
- }
1615
- return quoteMatch[1];
1616
- }
1617
- function parseThreadHeader(value) {
1618
- const match = value.trim().match(/^_(.+?)\s*(?:—|\|)\s*(.+?)_\s*$/);
1619
- if (!match) {
1620
- return undefined;
1621
- }
1622
- const timestamp = match[1].trim();
1623
- const author = match[2].trim();
1624
- if (!timestamp || !author) {
1625
- return undefined;
1626
- }
1627
- return { timestamp, author };
1628
- }
1629
- function findTrimmedLineIndexes(lines, marker) {
1630
- const indexes = [];
1631
- for (let index = 0; index < lines.length; index += 1) {
1632
- if (lines[index].trim() === marker) {
1633
- indexes.push(index);
1634
- }
1635
- }
1636
- return indexes;
1405
+ const relativeLine = Number.parseInt(lineMatch[1], 10);
1406
+ const absoluteLine = Number.isFinite(relativeLine)
1407
+ ? bodyStartLine + relativeLine - 1
1408
+ : undefined;
1409
+ return makeIssue("error", "comments", lineMatch[2], relativePath, absoluteLine);
1637
1410
  }
1638
1411
  function coerceMetadataValue(value) {
1639
1412
  if (!value) {
@@ -1762,39 +1535,31 @@ function runStyleHeuristics(body, relativePath) {
1762
1535
  function countWords(text) {
1763
1536
  return text.trim().split(/\s+/).filter(Boolean).length;
1764
1537
  }
1765
- function readSpine(spineDir, spineCategories, inlineIdRegex) {
1766
- const issues = [];
1767
- const ids = new Set();
1768
- if (spineCategories.length === 0) {
1769
- return { ids, issues };
1770
- }
1771
- if (!fs.existsSync(spineDir)) {
1772
- issues.push(makeIssue("warning", "continuity", `Missing spine directory: ${spineDir}`));
1773
- return { ids, issues };
1774
- }
1775
- for (const category of spineCategories) {
1776
- const fullPath = path.join(spineDir, category.notesFile);
1777
- const relativePath = path.relative(repoRoot, fullPath);
1778
- if (!fs.existsSync(fullPath)) {
1779
- issues.push(makeIssue("warning", "continuity", `Missing spine file '${category.notesFile}' for category '${category.key}'.`, relativePath));
1780
- continue;
1781
- }
1782
- const text = fs.readFileSync(fullPath, "utf8");
1783
- if (!inlineIdRegex) {
1784
- continue;
1785
- }
1786
- const matches = text.match(inlineIdRegex) || [];
1787
- for (const id of matches) {
1788
- ids.add(id);
1789
- }
1538
+ function readSpine(project) {
1539
+ const catalog = readSpineCatalog(project.root, project.spineDir);
1540
+ const categories = [];
1541
+ const entriesByCategory = new Map();
1542
+ for (const category of catalog.categories) {
1543
+ const entries = new Set(category.entries.map((entry) => entry.key));
1544
+ categories.push({ key: category.key, entries });
1545
+ entriesByCategory.set(category.key, entries);
1790
1546
  }
1791
- return { ids, issues };
1547
+ const issues = catalog.issues.map((message) => makeIssue("warning", "continuity", message));
1548
+ return { categories, entriesByCategory, issues };
1792
1549
  }
1793
- function findUnknownSpineIds(referenceIds, knownIds, relativePath) {
1550
+ function findUnknownSpineReferences(referencesByCategory, entriesByCategory, relativePath) {
1794
1551
  const issues = [];
1795
- for (const id of referenceIds) {
1796
- if (!knownIds.has(id)) {
1797
- issues.push(makeIssue("warning", "continuity", `Metadata reference '${id}' does not exist in the spine files.`, relativePath));
1552
+ for (const [categoryKey, values] of Object.entries(referencesByCategory)) {
1553
+ const known = entriesByCategory.get(categoryKey);
1554
+ if (!known) {
1555
+ issues.push(makeIssue("warning", "continuity", `Metadata category '${categoryKey}' has references but no matching spine category directory was found in spine/.`, relativePath));
1556
+ continue;
1557
+ }
1558
+ for (const value of values) {
1559
+ if (known.has(value)) {
1560
+ continue;
1561
+ }
1562
+ issues.push(makeIssue("warning", "continuity", `Metadata reference '${categoryKey}: ${value}' does not exist in spine/${categoryKey}/.`, relativePath));
1798
1563
  }
1799
1564
  }
1800
1565
  return issues;
@@ -1831,8 +1596,11 @@ function runStageCheck(project, runtimeConfig, stage, onlyFile) {
1831
1596
  }
1832
1597
  }
1833
1598
  if (policy.requireSpine) {
1599
+ if (report.spineState.categories.length === 0) {
1600
+ issues.push(makeIssue("error", "continuity", "No spine categories found. Add at least one category under spine/<category>/ before this stage."));
1601
+ }
1834
1602
  for (const spineIssue of report.issues.filter((issue) => issue.category === "continuity")) {
1835
- if (spineIssue.message.startsWith("Missing spine file")) {
1603
+ if (spineIssue.message.startsWith("Missing spine directory")) {
1836
1604
  issues.push({ ...spineIssue, level: "error" });
1837
1605
  }
1838
1606
  }
@@ -1844,7 +1612,7 @@ function runStageCheck(project, runtimeConfig, stage, onlyFile) {
1844
1612
  }
1845
1613
  }
1846
1614
  const chapterPaths = report.chapters.map((chapter) => chapter.path);
1847
- const spineWords = collectSpineWordsForSpellcheck(report.spineState.ids);
1615
+ const spineWords = collectSpineWordsForSpellcheck(report.spineState.entriesByCategory);
1848
1616
  if (policy.enforceMarkdownlint) {
1849
1617
  issues.push(...runMarkdownlint(project, chapterPaths, true, "manuscript"));
1850
1618
  }
@@ -2002,18 +1770,29 @@ function runMarkdownlint(project, files, required, profile = "default") {
2002
1770
  prepared.cleanup();
2003
1771
  }
2004
1772
  }
2005
- function collectSpineWordsForSpellcheck(ids) {
1773
+ function collectSpineWordsForSpellcheck(entriesByCategory) {
2006
1774
  const words = new Set();
2007
- for (const id of ids) {
2008
- const parts = id
2009
- .split("-")
1775
+ for (const [category, entries] of entriesByCategory) {
1776
+ const categoryParts = category
1777
+ .split(/[-_/]+/)
2010
1778
  .map((part) => part.trim())
2011
1779
  .filter(Boolean);
2012
- for (const part of parts.slice(1)) {
2013
- if (!/[A-Za-z]/.test(part)) {
2014
- continue;
1780
+ for (const part of categoryParts) {
1781
+ if (/[A-Za-z]/.test(part)) {
1782
+ words.add(part.toLowerCase());
1783
+ }
1784
+ }
1785
+ for (const entry of entries) {
1786
+ const entryParts = entry
1787
+ .split(/[-_/]+/)
1788
+ .map((part) => part.trim())
1789
+ .filter(Boolean);
1790
+ for (const part of entryParts) {
1791
+ if (!/[A-Za-z]/.test(part)) {
1792
+ continue;
1793
+ }
1794
+ words.add(part.toLowerCase());
2015
1795
  }
2016
- words.add(part.toLowerCase());
2017
1796
  }
2018
1797
  }
2019
1798
  return Array.from(words).sort();