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.
- package/README.md +10 -0
- package/dist/metadata/metadata-command.js +127 -0
- package/dist/metadata/metadata-domain.js +209 -0
- package/dist/spine/spine-command.js +129 -0
- package/dist/spine/spine-domain.js +274 -0
- package/dist/stego-cli.js +187 -173
- package/package.json +3 -2
- package/projects/fiction-example/spine/characters/CHAR-AGNES.md +17 -0
- package/projects/fiction-example/spine/characters/CHAR-ETIENNE.md +17 -0
- package/projects/fiction-example/spine/characters/CHAR-MATTHAEUS.md +17 -0
- package/projects/fiction-example/spine/characters/CHAR-RAOUL.md +17 -0
- package/projects/fiction-example/spine/characters/_category.md +6 -0
- package/projects/fiction-example/spine/locations/LOC-CHARNEL.md +17 -0
- package/projects/fiction-example/spine/locations/LOC-COLLEGE.md +17 -0
- package/projects/fiction-example/spine/locations/LOC-HOTELDIEU.md +17 -0
- package/projects/fiction-example/spine/locations/LOC-QUAY.md +17 -0
- package/projects/fiction-example/spine/locations/_category.md +6 -0
- package/projects/fiction-example/spine/sources/SRC-CONJUNCTION.md +20 -0
- package/projects/fiction-example/spine/sources/SRC-GALEN.md +20 -0
- package/projects/fiction-example/spine/sources/SRC-WARD-DATA.md +20 -0
- package/projects/fiction-example/spine/sources/_category.md +6 -0
- package/projects/fiction-example/stego-project.json +1 -18
- package/projects/stego-docs/manuscript/500-project-configuration.md +3 -3
- package/projects/stego-docs/spine/commands/CMD-BUILD.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-CHECK-STAGE.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-EXPORT.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-INIT.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-LIST-PROJECTS.md +10 -0
- package/projects/stego-docs/spine/commands/CMD-NEW-PROJECT.md +10 -0
- package/projects/stego-docs/spine/commands/CMD-NEW.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-VALIDATE.md +11 -0
- package/projects/stego-docs/spine/commands/_category.md +6 -0
- package/projects/stego-docs/spine/concepts/CON-COMPILE-STRUCTURE.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-DIST.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-MANUSCRIPT.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-METADATA.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-NOTES.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-PROJECT.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-SPINE-CATEGORY.md +11 -0
- package/projects/stego-docs/spine/concepts/CON-SPINE.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-STAGE-GATE.md +10 -0
- package/projects/stego-docs/spine/concepts/CON-WORKSPACE.md +9 -0
- package/projects/stego-docs/spine/concepts/_category.md +6 -0
- package/projects/stego-docs/spine/configuration/CFG-ALLOWED-STATUSES.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-COMPILE-LEVELS.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-COMPILE-STRUCTURE.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-REQUIRED-METADATA.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-SPINE-CATEGORIES.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-STAGE-POLICIES.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-STEGO-CONFIG.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-STEGO-PROJECT.md +9 -0
- package/projects/stego-docs/spine/configuration/_category.md +6 -0
- package/projects/stego-docs/spine/integrations/INT-CSPELL.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-MARKDOWNLINT.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-PANDOC.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-SAURUS-EXTENSION.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-STEGO-EXTENSION.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-VSCODE.md +9 -0
- package/projects/stego-docs/spine/integrations/_category.md +6 -0
- package/projects/stego-docs/spine/workflows/FLOW-BUILD-EXPORT.md +10 -0
- package/projects/stego-docs/spine/workflows/FLOW-DAILY-WRITING.md +10 -0
- package/projects/stego-docs/spine/workflows/FLOW-INIT-WORKSPACE.md +9 -0
- package/projects/stego-docs/spine/workflows/FLOW-NEW-PROJECT.md +10 -0
- package/projects/stego-docs/spine/workflows/FLOW-PROOF-RELEASE.md +10 -0
- package/projects/stego-docs/spine/workflows/FLOW-STAGE-PROMOTION.md +10 -0
- package/projects/stego-docs/spine/workflows/_category.md +6 -0
- package/projects/stego-docs/stego-project.json +1 -28
- package/projects/fiction-example/spine/characters.md +0 -35
- package/projects/fiction-example/spine/locations.md +0 -37
- package/projects/fiction-example/spine/sources.md +0 -31
- package/projects/stego-docs/spine/commands.md +0 -71
- package/projects/stego-docs/spine/concepts.md +0 -72
- package/projects/stego-docs/spine/configuration.md +0 -57
- package/projects/stego-docs/spine/integrations.md +0 -43
- 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(
|
|
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
|
-
|
|
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(
|
|
785
|
-
const projectId = (
|
|
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:
|
|
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
|
|
855
|
-
fs.
|
|
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
|
|
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,
|
|
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 = {
|
|
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,
|
|
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(...
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1249
|
+
function extractReferenceKeysByCategory(metadata, relativePath, spineCategories) {
|
|
1232
1250
|
const issues = [];
|
|
1233
|
-
const
|
|
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
|
|
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
|
|
1249
|
-
if (!
|
|
1250
|
-
issues.push(makeIssue("error", "metadata", `
|
|
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
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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(
|
|
1531
|
-
const
|
|
1532
|
-
const
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
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
|
-
|
|
1547
|
+
const issues = catalog.issues.map((message) => makeIssue("warning", "continuity", message));
|
|
1548
|
+
return { categories, entriesByCategory, issues };
|
|
1557
1549
|
}
|
|
1558
|
-
function
|
|
1550
|
+
function findUnknownSpineReferences(referencesByCategory, entriesByCategory, relativePath) {
|
|
1559
1551
|
const issues = [];
|
|
1560
|
-
for (const
|
|
1561
|
-
|
|
1562
|
-
|
|
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
|
|
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.
|
|
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(
|
|
1773
|
+
function collectSpineWordsForSpellcheck(entriesByCategory) {
|
|
1771
1774
|
const words = new Set();
|
|
1772
|
-
for (const
|
|
1773
|
-
const
|
|
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
|
|
1778
|
-
if (
|
|
1779
|
-
|
|
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
|
+
"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": "
|
|
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
|