stego-cli 0.3.4 → 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 (75) hide show
  1. package/README.md +10 -0
  2. package/dist/metadata/metadata-command.js +127 -0
  3. package/dist/metadata/metadata-domain.js +209 -0
  4. package/dist/spine/spine-command.js +129 -0
  5. package/dist/spine/spine-domain.js +274 -0
  6. package/dist/stego-cli.js +187 -173
  7. package/package.json +3 -2
  8. package/projects/fiction-example/spine/characters/CHAR-AGNES.md +17 -0
  9. package/projects/fiction-example/spine/characters/CHAR-ETIENNE.md +17 -0
  10. package/projects/fiction-example/spine/characters/CHAR-MATTHAEUS.md +17 -0
  11. package/projects/fiction-example/spine/characters/CHAR-RAOUL.md +17 -0
  12. package/projects/fiction-example/spine/characters/_category.md +6 -0
  13. package/projects/fiction-example/spine/locations/LOC-CHARNEL.md +17 -0
  14. package/projects/fiction-example/spine/locations/LOC-COLLEGE.md +17 -0
  15. package/projects/fiction-example/spine/locations/LOC-HOTELDIEU.md +17 -0
  16. package/projects/fiction-example/spine/locations/LOC-QUAY.md +17 -0
  17. package/projects/fiction-example/spine/locations/_category.md +6 -0
  18. package/projects/fiction-example/spine/sources/SRC-CONJUNCTION.md +20 -0
  19. package/projects/fiction-example/spine/sources/SRC-GALEN.md +20 -0
  20. package/projects/fiction-example/spine/sources/SRC-WARD-DATA.md +20 -0
  21. package/projects/fiction-example/spine/sources/_category.md +6 -0
  22. package/projects/fiction-example/stego-project.json +1 -18
  23. package/projects/stego-docs/manuscript/500-project-configuration.md +3 -3
  24. package/projects/stego-docs/spine/commands/CMD-BUILD.md +11 -0
  25. package/projects/stego-docs/spine/commands/CMD-CHECK-STAGE.md +11 -0
  26. package/projects/stego-docs/spine/commands/CMD-EXPORT.md +11 -0
  27. package/projects/stego-docs/spine/commands/CMD-INIT.md +11 -0
  28. package/projects/stego-docs/spine/commands/CMD-LIST-PROJECTS.md +10 -0
  29. package/projects/stego-docs/spine/commands/CMD-NEW-PROJECT.md +10 -0
  30. package/projects/stego-docs/spine/commands/CMD-NEW.md +11 -0
  31. package/projects/stego-docs/spine/commands/CMD-VALIDATE.md +11 -0
  32. package/projects/stego-docs/spine/commands/_category.md +6 -0
  33. package/projects/stego-docs/spine/concepts/CON-COMPILE-STRUCTURE.md +9 -0
  34. package/projects/stego-docs/spine/concepts/CON-DIST.md +9 -0
  35. package/projects/stego-docs/spine/concepts/CON-MANUSCRIPT.md +9 -0
  36. package/projects/stego-docs/spine/concepts/CON-METADATA.md +9 -0
  37. package/projects/stego-docs/spine/concepts/CON-NOTES.md +9 -0
  38. package/projects/stego-docs/spine/concepts/CON-PROJECT.md +9 -0
  39. package/projects/stego-docs/spine/concepts/CON-SPINE-CATEGORY.md +11 -0
  40. package/projects/stego-docs/spine/concepts/CON-SPINE.md +9 -0
  41. package/projects/stego-docs/spine/concepts/CON-STAGE-GATE.md +10 -0
  42. package/projects/stego-docs/spine/concepts/CON-WORKSPACE.md +9 -0
  43. package/projects/stego-docs/spine/concepts/_category.md +6 -0
  44. package/projects/stego-docs/spine/configuration/CFG-ALLOWED-STATUSES.md +9 -0
  45. package/projects/stego-docs/spine/configuration/CFG-COMPILE-LEVELS.md +9 -0
  46. package/projects/stego-docs/spine/configuration/CFG-COMPILE-STRUCTURE.md +9 -0
  47. package/projects/stego-docs/spine/configuration/CFG-REQUIRED-METADATA.md +9 -0
  48. package/projects/stego-docs/spine/configuration/CFG-SPINE-CATEGORIES.md +9 -0
  49. package/projects/stego-docs/spine/configuration/CFG-STAGE-POLICIES.md +9 -0
  50. package/projects/stego-docs/spine/configuration/CFG-STEGO-CONFIG.md +9 -0
  51. package/projects/stego-docs/spine/configuration/CFG-STEGO-PROJECT.md +9 -0
  52. package/projects/stego-docs/spine/configuration/_category.md +6 -0
  53. package/projects/stego-docs/spine/integrations/INT-CSPELL.md +9 -0
  54. package/projects/stego-docs/spine/integrations/INT-MARKDOWNLINT.md +9 -0
  55. package/projects/stego-docs/spine/integrations/INT-PANDOC.md +9 -0
  56. package/projects/stego-docs/spine/integrations/INT-SAURUS-EXTENSION.md +9 -0
  57. package/projects/stego-docs/spine/integrations/INT-STEGO-EXTENSION.md +9 -0
  58. package/projects/stego-docs/spine/integrations/INT-VSCODE.md +9 -0
  59. package/projects/stego-docs/spine/integrations/_category.md +6 -0
  60. package/projects/stego-docs/spine/workflows/FLOW-BUILD-EXPORT.md +10 -0
  61. package/projects/stego-docs/spine/workflows/FLOW-DAILY-WRITING.md +10 -0
  62. package/projects/stego-docs/spine/workflows/FLOW-INIT-WORKSPACE.md +9 -0
  63. package/projects/stego-docs/spine/workflows/FLOW-NEW-PROJECT.md +10 -0
  64. package/projects/stego-docs/spine/workflows/FLOW-PROOF-RELEASE.md +10 -0
  65. package/projects/stego-docs/spine/workflows/FLOW-STAGE-PROMOTION.md +10 -0
  66. package/projects/stego-docs/spine/workflows/_category.md +6 -0
  67. package/projects/stego-docs/stego-project.json +1 -28
  68. package/projects/fiction-example/spine/characters.md +0 -35
  69. package/projects/fiction-example/spine/locations.md +0 -37
  70. package/projects/fiction-example/spine/sources.md +0 -31
  71. package/projects/stego-docs/spine/commands.md +0 -71
  72. package/projects/stego-docs/spine/concepts.md +0 -72
  73. package/projects/stego-docs/spine/configuration.md +0 -57
  74. package/projects/stego-docs/spine/integrations.md +0 -43
  75. 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,
@@ -111,6 +114,12 @@ let config;
111
114
  void main();
112
115
  async function main() {
113
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
+ }
114
123
  if (!command || command === "help" || command === "--help" || command === "-h") {
115
124
  printUsage();
116
125
  return;
@@ -126,13 +135,31 @@ async function main() {
126
135
  return;
127
136
  case "new-project":
128
137
  activateWorkspace(options);
129
- 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
+ });
130
144
  return;
131
145
  case "new": {
132
146
  activateWorkspace(options);
133
147
  const project = resolveProject(readStringOption(options, "project"));
134
148
  const createdPath = createNewManuscript(project, readStringOption(options, "i"), readStringOption(options, "filename"));
135
- 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
+ }
136
163
  return;
137
164
  }
138
165
  case "validate": {
@@ -202,6 +229,20 @@ async function main() {
202
229
  case "comments":
203
230
  await runCommentsCommand(options, process.cwd());
204
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
+ }
205
246
  default:
206
247
  throw new Error(`Unknown command '${command}'. Run with 'help' for usage.`);
207
248
  }
@@ -232,6 +273,28 @@ function readStringOption(options, key) {
232
273
  function readBooleanOption(options, key) {
233
274
  return options[key] === true;
234
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
+ }
235
298
  function activateWorkspace(options) {
236
299
  const workspace = resolveWorkspaceContext(readStringOption(options, "root"));
237
300
  repoRoot = workspace.repoRoot;
@@ -244,75 +307,6 @@ function isStageName(value) {
244
307
  function isExportFormat(value) {
245
308
  return value === "md" || value === "docx" || value === "pdf" || value === "epub";
246
309
  }
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
310
  function resolveRequiredMetadata(project, runtimeConfig) {
317
311
  const issues = [];
318
312
  const projectFile = path.relative(repoRoot, path.join(project.root, "stego-project.json"));
@@ -406,16 +400,6 @@ function resolveCompileStructure(project) {
406
400
  }
407
401
  return { levels, issues };
408
402
  }
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
403
  function isPlainObject(value) {
420
404
  return typeof value === "object" && value !== null && !Array.isArray(value);
421
405
  }
@@ -753,6 +737,8 @@ function writeInitRootPackageJson(targetRoot) {
753
737
  "list-projects": "stego list-projects",
754
738
  "new-project": "stego new-project",
755
739
  new: "stego new",
740
+ spine: "stego spine",
741
+ metadata: "stego metadata",
756
742
  lint: "stego lint",
757
743
  validate: "stego validate",
758
744
  build: "stego build",
@@ -768,7 +754,7 @@ function writeInitRootPackageJson(targetRoot) {
768
754
  fs.writeFileSync(path.join(targetRoot, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
769
755
  }
770
756
  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`);
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`);
772
758
  }
773
759
  function listProjects() {
774
760
  const ids = getProjectIds();
@@ -781,8 +767,8 @@ function listProjects() {
781
767
  console.log(`- ${id}`);
782
768
  }
783
769
  }
784
- async function createProject(projectIdOption, titleOption) {
785
- const projectId = (projectIdOption || "").trim();
770
+ async function createProject(options) {
771
+ const projectId = (options.projectId || "").trim();
786
772
  if (!projectId) {
787
773
  throw new Error("Project id is required. Use --project <project-id>.");
788
774
  }
@@ -802,7 +788,7 @@ async function createProject(projectIdOption, titleOption) {
802
788
  const manuscriptDir = path.join(projectRoot, config.chapterDir);
803
789
  const projectJson = {
804
790
  id: projectId,
805
- title: titleOption?.trim() || toDisplayTitle(projectId),
791
+ title: options.title?.trim() || toDisplayTitle(projectId),
806
792
  requiredMetadata: ["status"],
807
793
  compileStructure: {
808
794
  levels: [
@@ -815,14 +801,7 @@ async function createProject(projectIdOption, titleOption) {
815
801
  pageBreak: "between-groups"
816
802
  }
817
803
  ]
818
- },
819
- spineCategories: [
820
- {
821
- key: "characters",
822
- prefix: "CHAR",
823
- notesFile: "characters.md"
824
- }
825
- ]
804
+ }
826
805
  };
827
806
  const projectJsonPath = path.join(projectRoot, "stego-project.json");
828
807
  fs.writeFileSync(projectJsonPath, `${JSON.stringify(projectJson, null, 2)}\n`, "utf8");
@@ -831,6 +810,8 @@ async function createProject(projectIdOption, titleOption) {
831
810
  private: true,
832
811
  scripts: {
833
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",
834
815
  lint: "npx --no-install stego lint",
835
816
  validate: "npx --no-install stego validate",
836
817
  build: "npx --no-install stego build",
@@ -851,20 +832,57 @@ chapter_title: Hello World
851
832
 
852
833
  Start writing here.
853
834
  `, "utf8");
854
- const charactersNotesPath = path.join(spineDir, "characters.md");
855
- 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");
856
849
  const projectExtensionsPath = path.join(projectRoot, ".vscode", "extensions.json");
857
850
  ensureProjectExtensionsRecommendations(projectRoot);
858
851
  let projectSettingsPath = null;
859
- 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";
860
856
  if (enableProseFont) {
861
857
  projectSettingsPath = writeProseEditorSettingsForProject(projectRoot);
862
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
+ }
863
880
  logLine(`Created project: ${path.relative(repoRoot, projectRoot)}`);
864
881
  logLine(`- ${path.relative(repoRoot, projectJsonPath)}`);
865
882
  logLine(`- ${path.relative(repoRoot, projectPackagePath)}`);
866
883
  logLine(`- ${path.relative(repoRoot, starterManuscriptPath)}`);
867
- logLine(`- ${path.relative(repoRoot, charactersNotesPath)}`);
884
+ logLine(`- ${path.relative(repoRoot, charactersCategoryPath)}`);
885
+ logLine(`- ${path.relative(repoRoot, charactersEntryPath)}`);
868
886
  logLine(`- ${path.relative(repoRoot, projectExtensionsPath)}`);
869
887
  if (projectSettingsPath) {
870
888
  logLine(`- ${path.relative(repoRoot, projectSettingsPath)}`);
@@ -1061,13 +1079,16 @@ function inferProjectIdFromCwd(cwd) {
1061
1079
  }
1062
1080
  function inspectProject(project, runtimeConfig, options = {}) {
1063
1081
  const issues = [];
1064
- const emptySpineState = { ids: new Set(), issues: [] };
1065
- const spineSchema = resolveSpineSchema(project);
1082
+ const emptySpineState = { categories: [], entriesByCategory: new Map(), issues: [] };
1066
1083
  const requiredMetadataState = resolveRequiredMetadata(project, runtimeConfig);
1067
1084
  const compileStructureState = resolveCompileStructure(project);
1068
- issues.push(...spineSchema.issues);
1069
1085
  issues.push(...requiredMetadataState.issues);
1070
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);
1071
1092
  let chapterFiles = [];
1072
1093
  const onlyFile = options.onlyFile?.trim();
1073
1094
  if (onlyFile) {
@@ -1107,7 +1128,7 @@ function inspectProject(project, runtimeConfig, options = {}) {
1107
1128
  return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
1108
1129
  }
1109
1130
  }
1110
- 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));
1111
1132
  for (const chapter of chapters) {
1112
1133
  issues.push(...chapter.issues);
1113
1134
  }
@@ -1134,10 +1155,8 @@ function inspectProject(project, runtimeConfig, options = {}) {
1134
1155
  }
1135
1156
  return a.order - b.order;
1136
1157
  });
1137
- const spineState = readSpine(project.spineDir, spineSchema.schema.categories, spineSchema.schema.inlineIdRegex);
1138
- issues.push(...spineState.issues);
1139
1158
  for (const chapter of chapters) {
1140
- issues.push(...findUnknownSpineIds(chapter.referenceIds, spineState.ids, chapter.relativePath));
1159
+ issues.push(...findUnknownSpineReferences(chapter.referenceKeysByCategory, spineState.entriesByCategory, chapter.relativePath));
1141
1160
  }
1142
1161
  return {
1143
1162
  chapters,
@@ -1146,7 +1165,7 @@ function inspectProject(project, runtimeConfig, options = {}) {
1146
1165
  compileStructureLevels: compileStructureState.levels
1147
1166
  };
1148
1167
  }
1149
- function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategories, inlineIdRegex, compileStructureLevels) {
1168
+ function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategories, compileStructureLevels) {
1150
1169
  const relativePath = path.relative(repoRoot, chapterPath);
1151
1170
  const raw = fs.readFileSync(chapterPath, "utf8");
1152
1171
  const { metadata, body, comments, issues } = parseMetadata(raw, chapterPath, false);
@@ -1175,9 +1194,8 @@ function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategor
1175
1194
  void normalizeGroupingValue(metadata[level.titleKey], relativePath, chapterIssues, level.titleKey);
1176
1195
  }
1177
1196
  }
1178
- const referenceValidation = extractReferenceIds(metadata, relativePath, spineCategories);
1197
+ const referenceValidation = extractReferenceKeysByCategory(metadata, relativePath, spineCategories);
1179
1198
  chapterIssues.push(...referenceValidation.issues);
1180
- chapterIssues.push(...findInlineSpineIdMentions(body, relativePath, inlineIdRegex));
1181
1199
  chapterIssues.push(...validateMarkdownBody(body, chapterPath));
1182
1200
  return {
1183
1201
  path: chapterPath,
@@ -1185,7 +1203,7 @@ function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategor
1185
1203
  title,
1186
1204
  order,
1187
1205
  status,
1188
- referenceIds: referenceValidation.ids,
1206
+ referenceKeysByCategory: referenceValidation.referencesByCategory,
1189
1207
  groupValues,
1190
1208
  metadata,
1191
1209
  body,
@@ -1228,49 +1246,39 @@ function parseOrderFromFilename(chapterPath, relativePath, issues) {
1228
1246
  }
1229
1247
  return Number(match[1]);
1230
1248
  }
1231
- function extractReferenceIds(metadata, relativePath, spineCategories) {
1249
+ function extractReferenceKeysByCategory(metadata, relativePath, spineCategories) {
1232
1250
  const issues = [];
1233
- const ids = new Set();
1251
+ const referencesByCategory = {};
1234
1252
  for (const category of spineCategories) {
1235
1253
  const rawValue = metadata[category.key];
1236
1254
  if (rawValue == null || rawValue === "") {
1237
1255
  continue;
1238
1256
  }
1239
1257
  if (!Array.isArray(rawValue)) {
1240
- 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));
1241
1259
  continue;
1242
1260
  }
1261
+ const seen = new Set();
1262
+ const values = [];
1243
1263
  for (const entry of rawValue) {
1244
1264
  if (typeof entry !== "string") {
1245
1265
  issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' entries must be strings.`, relativePath));
1246
1266
  continue;
1247
1267
  }
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));
1268
+ const normalized = entry.trim();
1269
+ if (!normalized) {
1270
+ issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' contains an empty entry key.`, relativePath));
1251
1271
  continue;
1252
1272
  }
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));
1273
+ if (seen.has(normalized)) {
1274
+ continue;
1275
+ }
1276
+ seen.add(normalized);
1277
+ values.push(normalized);
1271
1278
  }
1279
+ referencesByCategory[category.key] = values;
1272
1280
  }
1273
- return issues;
1281
+ return { referencesByCategory, issues };
1274
1282
  }
1275
1283
  function parseMetadata(raw, chapterPath, required) {
1276
1284
  const relativePath = path.relative(repoRoot, chapterPath);
@@ -1527,39 +1535,31 @@ function runStyleHeuristics(body, relativePath) {
1527
1535
  function countWords(text) {
1528
1536
  return text.trim().split(/\s+/).filter(Boolean).length;
1529
1537
  }
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
- }
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);
1555
1546
  }
1556
- return { ids, issues };
1547
+ const issues = catalog.issues.map((message) => makeIssue("warning", "continuity", message));
1548
+ return { categories, entriesByCategory, issues };
1557
1549
  }
1558
- function findUnknownSpineIds(referenceIds, knownIds, relativePath) {
1550
+ function findUnknownSpineReferences(referencesByCategory, entriesByCategory, relativePath) {
1559
1551
  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));
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));
1563
1563
  }
1564
1564
  }
1565
1565
  return issues;
@@ -1596,8 +1596,11 @@ function runStageCheck(project, runtimeConfig, stage, onlyFile) {
1596
1596
  }
1597
1597
  }
1598
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
+ }
1599
1602
  for (const spineIssue of report.issues.filter((issue) => issue.category === "continuity")) {
1600
- if (spineIssue.message.startsWith("Missing spine file")) {
1603
+ if (spineIssue.message.startsWith("Missing spine directory")) {
1601
1604
  issues.push({ ...spineIssue, level: "error" });
1602
1605
  }
1603
1606
  }
@@ -1609,7 +1612,7 @@ function runStageCheck(project, runtimeConfig, stage, onlyFile) {
1609
1612
  }
1610
1613
  }
1611
1614
  const chapterPaths = report.chapters.map((chapter) => chapter.path);
1612
- const spineWords = collectSpineWordsForSpellcheck(report.spineState.ids);
1615
+ const spineWords = collectSpineWordsForSpellcheck(report.spineState.entriesByCategory);
1613
1616
  if (policy.enforceMarkdownlint) {
1614
1617
  issues.push(...runMarkdownlint(project, chapterPaths, true, "manuscript"));
1615
1618
  }
@@ -1767,18 +1770,29 @@ function runMarkdownlint(project, files, required, profile = "default") {
1767
1770
  prepared.cleanup();
1768
1771
  }
1769
1772
  }
1770
- function collectSpineWordsForSpellcheck(ids) {
1773
+ function collectSpineWordsForSpellcheck(entriesByCategory) {
1771
1774
  const words = new Set();
1772
- for (const id of ids) {
1773
- const parts = id
1774
- .split("-")
1775
+ for (const [category, entries] of entriesByCategory) {
1776
+ const categoryParts = category
1777
+ .split(/[-_/]+/)
1775
1778
  .map((part) => part.trim())
1776
1779
  .filter(Boolean);
1777
- for (const part of parts.slice(1)) {
1778
- if (!/[A-Za-z]/.test(part)) {
1779
- 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());
1780
1795
  }
1781
- words.add(part.toLowerCase());
1782
1796
  }
1783
1797
  }
1784
1798
  return Array.from(words).sort();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stego-cli",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Installable CLI for the Stego writing monorepo workflow.",
6
6
  "license": "Apache-2.0",
@@ -51,7 +51,8 @@
51
51
  "export": "node --experimental-strip-types tools/stego-cli.ts export",
52
52
  "test:compile-structure": "node --test tools/test/compile-structure.test.mjs",
53
53
  "test:comments": "node --test tools/test/comments-add.test.mjs",
54
- "test": "npm run test:compile-structure && npm run test:comments"
54
+ "test:spine-v2": "node --test tools/test/spine-v2.test.mjs",
55
+ "test": "npm run test:compile-structure && npm run test:comments && npm run test:spine-v2"
55
56
  },
56
57
  "devDependencies": {
57
58
  "@changesets/cli": "^2.29.8",
@@ -0,0 +1,17 @@
1
+ ---
2
+ label: Agnes the apothecary
3
+ ---
4
+
5
+ # Agnes the apothecary
6
+
7
+ ## Role
8
+ Compounder and practical healer near the Petit Pont; supplies Hôtel-Dieu (LOC-HOTELDIEU)
9
+
10
+ ## Disposition
11
+ Empirical, blunt, uninterested in frameworks that don't predict which street sickens next
12
+
13
+ ## Sources
14
+ Her own ward observations (SRC-WARD-DATA); she has no name for her method, which is part of its strength and its institutional weakness
15
+
16
+ ## Function
17
+ The counterweight; asks whether a system that can absorb any evidence without changing is a system or a habit
@@ -0,0 +1,17 @@
1
+ ---
2
+ label: Etienne of Saint-Marcel
3
+ ---
4
+
5
+ # Etienne of Saint-Marcel
6
+
7
+ ## Role
8
+ Young cleric-scribe attached to Matthaeus (CHAR-MATTHAEUS)
9
+
10
+ ## Disposition
11
+ Careful, loyal, doctrinally cautious; sees everything his master does and says nothing about the parts that trouble him
12
+
13
+ ## Sources
14
+ He works primarily from Matthaeus's memoranda and dictated classifications, preserving the system as it is built
15
+
16
+ ## Function
17
+ The witness; records the system without fully believing it, and without claiming a replacement for it
@@ -0,0 +1,17 @@
1
+ ---
2
+ label: Magister Matthaeus de Rota
3
+ ---
4
+
5
+ # Magister Matthaeus de Rota
6
+
7
+ ## Role
8
+ Scholar of medicine and astrology at the University of Paris
9
+
10
+ ## Disposition
11
+ Methodical, sincere, incapable of leaving an observation outside his system; treats coherence as a moral obligation
12
+
13
+ ## Sources
14
+ Works primarily from the conjunction theory (SRC-CONJUNCTION) and Galenic tradition (SRC-GALEN); consults a prohibited astrological quire privately for its method of reasoning from hidden correspondences
15
+
16
+ ## Function
17
+ Builds a total explanation of the pestilence and dies at peace inside it; the question is whether his peace is understanding or its most sophisticated substitute