scriveno 2.0.7 → 2.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,906 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const DEFAULT_RUNTIME_SUPPORT = {
5
+ 'claude-code': {
6
+ label: 'Claude Code',
7
+ surface: 'flat commands plus agent prompts',
8
+ nativeSpawn: 'host-supported when Claude Code exposes agents',
9
+ fallback: 'load prompt from .claude/agents',
10
+ metadata: 'none',
11
+ },
12
+ codex: {
13
+ label: 'Codex',
14
+ surface: 'skills, command mirrors, agent prompts, and metadata',
15
+ nativeSpawn: 'host-supported when Codex exposes agent roles',
16
+ fallback: 'load prompt from .codex/agents',
17
+ metadata: 'toml',
18
+ },
19
+ cursor: {
20
+ label: 'Cursor',
21
+ surface: 'nested commands plus agent prompts',
22
+ nativeSpawn: 'host-supported when Cursor exposes agents',
23
+ fallback: 'load prompt from .cursor/agents',
24
+ metadata: 'none',
25
+ },
26
+ 'gemini-cli': {
27
+ label: 'Gemini CLI',
28
+ surface: 'nested commands plus agent prompts',
29
+ nativeSpawn: 'host-supported when Gemini CLI exposes agents',
30
+ fallback: 'load prompt from .gemini/agents',
31
+ metadata: 'none',
32
+ },
33
+ opencode: {
34
+ label: 'OpenCode',
35
+ surface: 'nested commands plus agent prompts',
36
+ nativeSpawn: 'host-supported when OpenCode exposes agents',
37
+ fallback: 'load prompt from .config/opencode/agents',
38
+ metadata: 'none',
39
+ },
40
+ copilot: {
41
+ label: 'GitHub Copilot',
42
+ surface: 'nested commands plus agent prompts',
43
+ nativeSpawn: 'host-supported when Copilot exposes agents',
44
+ fallback: 'load prompt from .github/agents',
45
+ metadata: 'none',
46
+ },
47
+ windsurf: {
48
+ label: 'Windsurf',
49
+ surface: 'nested commands plus agent prompts',
50
+ nativeSpawn: 'host-supported when Windsurf exposes agents',
51
+ fallback: 'load prompt from .windsurf/agents',
52
+ metadata: 'none',
53
+ },
54
+ antigravity: {
55
+ label: 'Antigravity',
56
+ surface: 'nested commands plus agent prompts',
57
+ nativeSpawn: 'host-supported when Antigravity exposes agents',
58
+ fallback: 'load prompt from .gemini/antigravity/agents',
59
+ metadata: 'none',
60
+ },
61
+ manus: {
62
+ label: 'Manus Desktop',
63
+ surface: 'bundled skill, mirrored commands, and agent prompts',
64
+ nativeSpawn: 'host-supported when Manus exposes skill agents',
65
+ fallback: 'load prompt from bundled agents directory',
66
+ metadata: 'none',
67
+ },
68
+ 'perplexity-desktop': {
69
+ label: 'Perplexity Desktop',
70
+ surface: 'guided local MCP setup',
71
+ nativeSpawn: 'not assumed',
72
+ fallback: 'read project files through the filesystem connector',
73
+ metadata: 'none',
74
+ },
75
+ generic: {
76
+ label: 'Generic (SKILL.md)',
77
+ surface: 'bundled skill, mirrored commands, and agent prompts',
78
+ nativeSpawn: 'not assumed',
79
+ fallback: 'load prompt from bundled agents directory',
80
+ metadata: 'none',
81
+ },
82
+ };
83
+
84
+ const REVIEW_KEYWORDS = [
85
+ 'TODO',
86
+ 'FIXME',
87
+ 'UNRESOLVED',
88
+ 'NEEDS REVISION',
89
+ 'QUESTION: Blocking',
90
+ 'VOICE DRIFT',
91
+ 'CONTINUITY',
92
+ ];
93
+
94
+ const CORE_PROJECT_FILES = [
95
+ 'WORK.md',
96
+ 'OUTLINE.md',
97
+ 'STYLE-GUIDE.md',
98
+ 'RECORD.md',
99
+ 'config.json',
100
+ ];
101
+
102
+ const AGENT_ROUTE_POLICIES = {
103
+ '/scr:plan': {
104
+ agents: ['plan-checker'],
105
+ reason: 'planning can validate unit plans before drafting',
106
+ },
107
+ '/scr:draft': {
108
+ agents: ['drafter', 'voice-checker'],
109
+ reason: 'drafting uses fresh-context prose generation and voice checks',
110
+ },
111
+ '/scr:editor-review': {
112
+ agents: ['diagnostic worker'],
113
+ reason: 'editor review can isolate flagged issue groups',
114
+ },
115
+ '/scr:voice-check': {
116
+ agents: ['voice-checker'],
117
+ reason: 'voice review compares drafts against STYLE-GUIDE.md',
118
+ },
119
+ '/scr:continuity-check': {
120
+ agents: ['continuity-checker'],
121
+ reason: 'continuity review checks contradictions and timeline drift',
122
+ },
123
+ '/scr:translate': {
124
+ agents: ['translator'],
125
+ reason: 'translation runs one fresh-context translation pass per unit',
126
+ },
127
+ '/scr:back-translate': {
128
+ agents: ['translator'],
129
+ reason: 'back-translation verifies target-language drift',
130
+ },
131
+ '/scr:beta-reader': {
132
+ agents: ['beta-reader worker'],
133
+ reason: 'beta review benefits from isolated reader perspectives',
134
+ },
135
+ '/scr:quick-write': {
136
+ agents: ['drafter', 'voice-checker'],
137
+ reason: 'quick writing still benefits from voice-aware isolation',
138
+ },
139
+ '/scr:map-manuscript': {
140
+ agents: ['voice analyst', 'structure analyst', 'character analyst', 'theme analyst', 'world analyst', 'pacing analyst'],
141
+ reason: 'manuscript import uses parallel analysis workers when available',
142
+ },
143
+ };
144
+
145
+ const LOCAL_ROUTE_POLICIES = {
146
+ '/scr:save': 'refresh CONTEXT.md, HISTORY.log, and project checkpoint state',
147
+ '/scr:scan': 'reconcile STATE.md and disk evidence',
148
+ '/scr:health': 'diagnose project and runtime health',
149
+ '/scr:sync': 'compare and refresh installed runtime surfaces',
150
+ '/scr:validate': 'run project validation checks',
151
+ '/scr:check-notes': 'surface unresolved writer notes',
152
+ '/scr:progress': 'compute read-only project progress',
153
+ '/scr:session-report': 'compute read-only session metrics',
154
+ };
155
+
156
+ const MANUAL_ROUTE_POLICIES = {
157
+ '/scr:publish': 'publication packaging can overwrite deliverables and needs writer choices',
158
+ '/scr:export': 'export writes output artifacts and may overwrite packages',
159
+ '/scr:track merge': 'merging revision tracks is a writer-owned decision',
160
+ '/scr:undo': 'undo changes state and should stay explicit',
161
+ };
162
+
163
+ const CATEGORY_ROUTE_POLICIES = {
164
+ core: { lane: 'mixed', level: 3, reason: 'core lifecycle routes may read, write, or spawn depending on the current stage' },
165
+ navigation: { lane: 'read-only', level: 1, reason: 'navigation routes should inspect and recommend by default' },
166
+ quality: { lane: 'agent-or-local', level: 3, reason: 'quality routes may run bounded diagnostics or text transforms' },
167
+ character_world: { lane: 'local-helper', level: 2, reason: 'character and world routes update project knowledge files' },
168
+ structure: { lane: 'local-helper', level: 2, reason: 'structure routes update maps, outlines, and state evidence' },
169
+ structure_management: { lane: 'manual-gated', level: 4, reason: 'structure management can rename, remove, or reorder manuscript units' },
170
+ review: { lane: 'agent-or-local', level: 3, reason: 'review routes may invoke bounded diagnostic workers' },
171
+ illustration: { lane: 'local-helper', level: 2, reason: 'illustration routes generate prompts and asset briefs' },
172
+ publishing: { lane: 'manual-gated', level: 4, reason: 'publishing routes write deliverables and package outputs' },
173
+ translation: { lane: 'agent-or-local', level: 3, reason: 'translation routes use translator agents or verification helpers' },
174
+ sacred_exclusive: { lane: 'agent-or-local', level: 3, reason: 'sacred routes perform specialized consistency and reference work' },
175
+ utility: { lane: 'local-helper', level: 2, reason: 'utility routes perform deterministic diagnostics or project updates' },
176
+ session: { lane: 'local-helper', level: 2, reason: 'session routes save, compare, resume, or report project state' },
177
+ collaboration: { lane: 'manual-gated', level: 4, reason: 'collaboration routes change revision tracks and require writer control' },
178
+ };
179
+
180
+ function normalizeCommandRef(commandName) {
181
+ if (commandName.startsWith('/scr:')) return commandName;
182
+ return `/scr:${commandName}`;
183
+ }
184
+
185
+ function getCommandAutomationPolicy(commandName, command = {}) {
186
+ const ref = normalizeCommandRef(commandName);
187
+ if (AGENT_ROUTE_POLICIES[ref]) {
188
+ return { ref, lane: 'agent-ready', level: 3, reason: AGENT_ROUTE_POLICIES[ref].reason };
189
+ }
190
+ if (LOCAL_ROUTE_POLICIES[ref]) {
191
+ return { ref, lane: 'local-helper', level: 2, reason: LOCAL_ROUTE_POLICIES[ref] };
192
+ }
193
+ if (MANUAL_ROUTE_POLICIES[ref]) {
194
+ return { ref, lane: 'manual-gated', level: 4, reason: MANUAL_ROUTE_POLICIES[ref] };
195
+ }
196
+ const categoryPolicy = CATEGORY_ROUTE_POLICIES[command.category] || {
197
+ lane: 'read-only',
198
+ level: 1,
199
+ reason: 'unclassified routes should only suggest until a category policy is added',
200
+ };
201
+ return { ref, ...categoryPolicy };
202
+ }
203
+
204
+ function pathExists(filePath) {
205
+ try {
206
+ fs.accessSync(filePath);
207
+ return true;
208
+ } catch {
209
+ return false;
210
+ }
211
+ }
212
+
213
+ function safeStat(filePath) {
214
+ try {
215
+ return fs.statSync(filePath);
216
+ } catch (err) {
217
+ if (err.code === 'ENOENT') return null;
218
+ throw err;
219
+ }
220
+ }
221
+
222
+ function readText(filePath) {
223
+ try {
224
+ return fs.readFileSync(filePath, 'utf8');
225
+ } catch (err) {
226
+ if (err.code === 'ENOENT') return '';
227
+ throw err;
228
+ }
229
+ }
230
+
231
+ function readJson(filePath) {
232
+ try {
233
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
234
+ } catch (err) {
235
+ if (err.code === 'ENOENT') return null;
236
+ if (err instanceof SyntaxError) return null;
237
+ throw err;
238
+ }
239
+ }
240
+
241
+ function listFiles(dir, options = {}) {
242
+ const { extensions = null, recursive = true } = options;
243
+ if (!pathExists(dir)) return [];
244
+ const out = [];
245
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
246
+ const fullPath = path.join(dir, entry.name);
247
+ if (entry.isDirectory()) {
248
+ if (recursive) out.push(...listFiles(fullPath, options));
249
+ } else if (!extensions || extensions.includes(path.extname(entry.name))) {
250
+ out.push(fullPath);
251
+ }
252
+ }
253
+ return out;
254
+ }
255
+
256
+ function newestMtime(files) {
257
+ let newest = 0;
258
+ for (const file of files) {
259
+ const stat = safeStat(file);
260
+ if (stat && stat.mtimeMs > newest) newest = stat.mtimeMs;
261
+ }
262
+ return newest;
263
+ }
264
+
265
+ function countMarkdownFiles(dir) {
266
+ return listFiles(dir, { extensions: ['.md'], recursive: true }).length;
267
+ }
268
+
269
+ function countFiles(dir, extensions = null) {
270
+ return listFiles(dir, { extensions, recursive: true }).length;
271
+ }
272
+
273
+ function anyPathExists(paths) {
274
+ return paths.some(pathExists);
275
+ }
276
+
277
+ function containsAny(text, keywords) {
278
+ const haystack = text.toUpperCase();
279
+ return keywords.some((keyword) => haystack.includes(keyword.toUpperCase()));
280
+ }
281
+
282
+ function detectProjectReadiness(manuscriptDir) {
283
+ const missing = CORE_PROJECT_FILES.filter((file) => !pathExists(path.join(manuscriptDir, file)));
284
+ return {
285
+ state: missing.length ? 'incomplete' : 'ready',
286
+ missing,
287
+ suggest: missing.length ? '/scr:scan' : null,
288
+ };
289
+ }
290
+
291
+ function detectPlanSignal(manuscriptDir, draftFiles) {
292
+ const files = listFiles(path.join(manuscriptDir, 'plans'), { extensions: ['.md'], recursive: true });
293
+ if (files.length === 0) {
294
+ return { state: 'missing', count: 0, suggest: '/scr:plan' };
295
+ }
296
+ if (draftFiles.length === 0) {
297
+ return { state: 'ready-to-draft', count: files.length, suggest: '/scr:draft' };
298
+ }
299
+ if (files.length > draftFiles.length) {
300
+ return { state: 'partially-drafted', count: files.length, suggest: '/scr:draft' };
301
+ }
302
+ return { state: 'covered', count: files.length, suggest: null };
303
+ }
304
+
305
+ function detectReviewCoverage(draftFiles, reviewFiles) {
306
+ if (draftFiles.length === 0) {
307
+ return { state: 'none', suggest: null };
308
+ }
309
+ if (reviewFiles.length === 0) {
310
+ return { state: 'missing', suggest: '/scr:editor-review' };
311
+ }
312
+ if (reviewFiles.length < draftFiles.length) {
313
+ return { state: 'partial', suggest: '/scr:editor-review' };
314
+ }
315
+ return { state: 'covered', suggest: null };
316
+ }
317
+
318
+ function detectNotesSignal(manuscriptDir) {
319
+ const noteFiles = [
320
+ ...listFiles(path.join(manuscriptDir, 'notes'), { extensions: ['.md', '.txt'], recursive: true }),
321
+ path.join(manuscriptDir, 'NOTES.md'),
322
+ path.join(manuscriptDir, 'TODO.md'),
323
+ ].filter(pathExists);
324
+ const pending = noteFiles.filter((file) => containsAny(readText(file), ['TODO', 'FIXME', 'UNRESOLVED', 'QUESTION:', 'NOTE:']));
325
+ return {
326
+ state: pending.length ? 'pending' : 'none',
327
+ count: pending.length,
328
+ files: pending.map((file) => path.relative(manuscriptDir, file)),
329
+ suggest: pending.length ? '/scr:check-notes' : null,
330
+ };
331
+ }
332
+
333
+ function detectTrackSignal(manuscriptDir) {
334
+ const tracks = readJson(path.join(manuscriptDir, 'tracks.json'));
335
+ const proposals = listFiles(path.join(manuscriptDir, 'proposals'), { extensions: ['.md'], recursive: true });
336
+ const activeTracks = Array.isArray(tracks?.tracks)
337
+ ? tracks.tracks.filter((track) => track && track.status !== 'merged')
338
+ : [];
339
+ let state = 'none';
340
+ let suggest = null;
341
+ if (proposals.length > 0) {
342
+ state = 'proposal-ready';
343
+ suggest = '/scr:editor-review --proposal';
344
+ } else if (activeTracks.length > 0) {
345
+ state = 'active';
346
+ suggest = '/scr:track';
347
+ }
348
+ return {
349
+ state,
350
+ activeCount: activeTracks.length,
351
+ proposalCount: proposals.length,
352
+ suggest,
353
+ };
354
+ }
355
+
356
+ function detectPublishingSignal(manuscriptDir, draftFiles) {
357
+ const frontMatter = countMarkdownFiles(path.join(manuscriptDir, 'front-matter'));
358
+ const backMatter = countMarkdownFiles(path.join(manuscriptDir, 'back-matter'));
359
+ const blurb = pathExists(path.join(manuscriptDir, 'output', 'blurb.md'));
360
+ const ebookCover = anyPathExists([
361
+ path.join(manuscriptDir, 'build', 'ebook-cover.jpg'),
362
+ path.join(manuscriptDir, 'build', 'ebook-cover.png'),
363
+ ]);
364
+ const printCover = anyPathExists([
365
+ path.join(manuscriptDir, 'build', 'paperback-cover.pdf'),
366
+ path.join(manuscriptDir, 'build', 'hardcover-cover.pdf'),
367
+ ]);
368
+ const promptFiles = countFiles(path.join(manuscriptDir, 'illustrations', 'cover'), ['.md']);
369
+ const gaps = [];
370
+ if (draftFiles.length > 0 && frontMatter === 0) gaps.push('front-matter');
371
+ if (draftFiles.length > 0 && backMatter === 0) gaps.push('back-matter');
372
+ if (draftFiles.length > 0 && !blurb) gaps.push('blurb');
373
+ if (draftFiles.length > 0 && !ebookCover && promptFiles === 0) gaps.push('cover-art');
374
+ return {
375
+ state: gaps.length ? 'gaps' : draftFiles.length ? 'ready' : 'not-started',
376
+ frontMatter,
377
+ backMatter,
378
+ blurb,
379
+ ebookCover,
380
+ printCover,
381
+ coverPrompts: promptFiles,
382
+ gaps,
383
+ suggest: gaps.length ? `/scr:${gaps[0]}` : null,
384
+ };
385
+ }
386
+
387
+ function scanReviewSignals(manuscriptDir) {
388
+ const reviewDirs = [
389
+ 'reviews',
390
+ 'reports',
391
+ 'voice',
392
+ 'continuity',
393
+ 'translation',
394
+ ].map((name) => path.join(manuscriptDir, name));
395
+ const files = reviewDirs.flatMap((dir) => listFiles(dir, { extensions: ['.md', '.txt'], recursive: true }));
396
+ const pending = [];
397
+ for (const file of files) {
398
+ const text = readText(file);
399
+ if (containsAny(text, REVIEW_KEYWORDS)) {
400
+ pending.push(path.relative(manuscriptDir, file));
401
+ }
402
+ }
403
+ return pending;
404
+ }
405
+
406
+ function findNewestOutput(manuscriptDir) {
407
+ const outputDirs = [
408
+ path.join(manuscriptDir, 'output'),
409
+ path.join(manuscriptDir, 'build'),
410
+ path.join(manuscriptDir, 'exports'),
411
+ ];
412
+ return newestMtime(outputDirs.flatMap((dir) => listFiles(dir, { recursive: true })));
413
+ }
414
+
415
+ function detectTranslationSignal(manuscriptDir, config) {
416
+ const translationDir = path.join(manuscriptDir, 'translation');
417
+ const configuredTargets = [
418
+ ...(Array.isArray(config?.target_languages) ? config.target_languages : []),
419
+ ...(Array.isArray(config?.translation?.target_languages) ? config.translation.target_languages : []),
420
+ ...(Array.isArray(config?.translations) ? config.translations : []),
421
+ ];
422
+ const translationFiles = listFiles(translationDir, { recursive: true });
423
+ if (translationFiles.length > 0 || configuredTargets.length > 0) {
424
+ return {
425
+ state: 'follow-up available',
426
+ count: translationFiles.length,
427
+ configuredTargets,
428
+ };
429
+ }
430
+ return {
431
+ state: 'none',
432
+ count: 0,
433
+ configuredTargets: [],
434
+ };
435
+ }
436
+
437
+ function detectHistorySignal(manuscriptDir) {
438
+ const historyPath = path.join(manuscriptDir, 'HISTORY.log');
439
+ if (!pathExists(historyPath)) {
440
+ return { state: 'missing', lastFailed: false };
441
+ }
442
+ const lines = readText(historyPath).split(/\r?\n/).filter(Boolean);
443
+ const last = lines[lines.length - 1] || '';
444
+ return {
445
+ state: 'present',
446
+ lastFailed: /\b(fail|failed|error|blocked)\b/i.test(last),
447
+ };
448
+ }
449
+
450
+ function detectContextSignal(manuscriptDir, draftFiles) {
451
+ const contextPath = path.join(manuscriptDir, 'CONTEXT.md');
452
+ const statePath = path.join(manuscriptDir, 'STATE.md');
453
+ const contextStat = safeStat(contextPath);
454
+ const stateStat = safeStat(statePath);
455
+ const newestDraft = newestMtime(draftFiles);
456
+
457
+ if (!contextStat) {
458
+ return { state: 'missing', suggest: '/scr:save' };
459
+ }
460
+ if (stateStat && contextStat.mtimeMs < stateStat.mtimeMs) {
461
+ return { state: 'stale', suggest: '/scr:scan' };
462
+ }
463
+ if (newestDraft > 0 && contextStat.mtimeMs < newestDraft) {
464
+ return { state: 'stale', suggest: '/scr:save' };
465
+ }
466
+ return { state: 'fresh', suggest: null };
467
+ }
468
+
469
+ function detectExportSignal(manuscriptDir, sourceFiles) {
470
+ const newestSource = newestMtime(sourceFiles);
471
+ const newestOutput = findNewestOutput(manuscriptDir);
472
+ if (newestOutput === 0) {
473
+ return { state: sourceFiles.length ? 'missing' : 'none', suggest: sourceFiles.length ? '/scr:export' : null };
474
+ }
475
+ if (newestSource > newestOutput) {
476
+ return { state: 'stale', suggest: '/scr:export' };
477
+ }
478
+ return { state: 'fresh', suggest: null };
479
+ }
480
+
481
+ function detectSaveSignal(historySignal, draftFiles) {
482
+ if (draftFiles.length === 0) return { state: 'clean', suggest: null };
483
+ if (historySignal.state === 'missing') return { state: 'unsaved manuscript changes', suggest: '/scr:save' };
484
+ return { state: 'clean', suggest: null };
485
+ }
486
+
487
+ function chooseRecommendation(signals, counts) {
488
+ if (!signals.hasProject) {
489
+ return {
490
+ command: '/scr:new-work',
491
+ reason: 'No .manuscript directory was found.',
492
+ alternatives: ['/scr:demo', '/scr:import', '/scr:profile-writer'],
493
+ };
494
+ }
495
+ if (!signals.hasState) {
496
+ return {
497
+ command: '/scr:scan',
498
+ reason: 'The project is missing STATE.md.',
499
+ alternatives: ['/scr:health', '/scr:next'],
500
+ };
501
+ }
502
+ if (signals.history.lastFailed) {
503
+ return {
504
+ command: '/scr:troubleshoot',
505
+ reason: 'The last history entry appears to have failed.',
506
+ alternatives: ['/scr:scan', '/scr:health'],
507
+ };
508
+ }
509
+ if (signals.context.state === 'stale') {
510
+ return {
511
+ command: signals.context.suggest || '/scr:scan',
512
+ reason: 'CONTEXT.md is older than the current project state.',
513
+ alternatives: ['/scr:progress', '/scr:resume-work'],
514
+ };
515
+ }
516
+ if (signals.tracks?.state === 'proposal-ready') {
517
+ return {
518
+ command: signals.tracks.suggest,
519
+ reason: `${signals.tracks.proposalCount} revision proposal(s) are waiting for review.`,
520
+ alternatives: ['/scr:track', '/scr:compare', '/scr:progress'],
521
+ };
522
+ }
523
+ if (signals.reviews.count > 0) {
524
+ return {
525
+ command: '/scr:editor-review',
526
+ reason: `${signals.reviews.count} review signal(s) still look unresolved.`,
527
+ alternatives: ['/scr:voice-check', '/scr:continuity-check', '/scr:progress'],
528
+ };
529
+ }
530
+ if (signals.notes?.count > 0) {
531
+ return {
532
+ command: signals.notes.suggest,
533
+ reason: `${signals.notes.count} note file(s) contain unresolved items.`,
534
+ alternatives: ['/scr:progress', '/scr:scan', '/scr:next'],
535
+ };
536
+ }
537
+ if (signals.plan?.state === 'ready-to-draft' || signals.plan?.state === 'partially-drafted') {
538
+ return {
539
+ command: signals.plan.suggest,
540
+ reason: `${signals.plan.count} plan file(s) exist and drafting is the next connected step.`,
541
+ alternatives: ['/scr:plan', '/scr:voice-test', '/scr:progress'],
542
+ };
543
+ }
544
+ if (counts.drafts === 0) {
545
+ return {
546
+ command: '/scr:plan',
547
+ reason: 'No draft files were found yet.',
548
+ alternatives: ['/scr:discuss', '/scr:draft', '/scr:voice-test'],
549
+ };
550
+ }
551
+ if (signals.reviewCoverage?.state === 'missing' || signals.reviewCoverage?.state === 'partial') {
552
+ return {
553
+ command: signals.reviewCoverage.suggest,
554
+ reason: `Drafts exist but review coverage is ${signals.reviewCoverage.state}.`,
555
+ alternatives: ['/scr:voice-check', '/scr:continuity-check', '/scr:progress'],
556
+ };
557
+ }
558
+ if (signals.translation.state !== 'none') {
559
+ return {
560
+ command: '/scr:back-translate',
561
+ reason: 'Translation work exists and may need a verification pass.',
562
+ alternatives: ['/scr:cultural-adaptation', '/scr:multi-publish', '/scr:progress'],
563
+ };
564
+ }
565
+ if (signals.publishing?.state === 'gaps' && signals.export.state === 'missing') {
566
+ return {
567
+ command: signals.publishing.suggest || '/scr:publish',
568
+ reason: `Publishing prerequisites have gaps: ${signals.publishing.gaps.join(', ')}.`,
569
+ alternatives: ['/scr:publish', '/scr:export', '/scr:progress'],
570
+ };
571
+ }
572
+ if (signals.export.state === 'stale' || signals.export.state === 'missing') {
573
+ return {
574
+ command: signals.export.suggest || '/scr:export',
575
+ reason: `Export output is ${signals.export.state}.`,
576
+ alternatives: ['/scr:publish', '/scr:progress', '/scr:save'],
577
+ };
578
+ }
579
+ if (signals.save.state !== 'clean') {
580
+ return {
581
+ command: signals.save.suggest || '/scr:save',
582
+ reason: 'Draft files exist without a current history signal.',
583
+ alternatives: ['/scr:progress', '/scr:scan'],
584
+ };
585
+ }
586
+ return {
587
+ command: '/scr:next',
588
+ reason: 'Project state looks consistent; continue with the lifecycle route.',
589
+ alternatives: ['/scr:progress', '/scr:editor-review', '/scr:save'],
590
+ };
591
+ }
592
+
593
+ function dedupeByCommand(items) {
594
+ const seen = new Set();
595
+ return items.filter((item) => {
596
+ if (seen.has(item.command)) return false;
597
+ seen.add(item.command);
598
+ return true;
599
+ });
600
+ }
601
+
602
+ function buildAutomationPlan(signals, recommendation) {
603
+ const spawnPolicy = AGENT_ROUTE_POLICIES[recommendation.command];
604
+ const localPolicy = LOCAL_ROUTE_POLICIES[recommendation.command];
605
+ const manualPolicy = MANUAL_ROUTE_POLICIES[recommendation.command];
606
+ const spawnCandidates = [];
607
+ const localCandidates = [];
608
+ const manualGates = [];
609
+
610
+ if (spawnPolicy) {
611
+ spawnCandidates.push({
612
+ command: recommendation.command,
613
+ agents: spawnPolicy.agents,
614
+ reason: spawnPolicy.reason,
615
+ });
616
+ }
617
+ if (signals.plan?.state === 'ready-to-draft' || signals.plan?.state === 'partially-drafted') {
618
+ spawnCandidates.push({
619
+ command: '/scr:draft',
620
+ agents: AGENT_ROUTE_POLICIES['/scr:draft'].agents,
621
+ reason: 'planned units can be drafted by the drafter route',
622
+ });
623
+ }
624
+ if (signals.reviewCoverage?.state === 'missing' || signals.reviewCoverage?.state === 'partial') {
625
+ spawnCandidates.push({
626
+ command: '/scr:editor-review',
627
+ agents: AGENT_ROUTE_POLICIES['/scr:editor-review'].agents,
628
+ reason: 'drafts without review coverage should enter the review route',
629
+ });
630
+ }
631
+ if (signals.translation?.state !== 'none') {
632
+ spawnCandidates.push({
633
+ command: '/scr:back-translate',
634
+ agents: AGENT_ROUTE_POLICIES['/scr:back-translate'].agents,
635
+ reason: 'translation work needs a verification pass',
636
+ });
637
+ }
638
+
639
+ if (localPolicy) {
640
+ localCandidates.push({ command: recommendation.command, reason: localPolicy });
641
+ }
642
+ if (signals.context?.state === 'stale') {
643
+ localCandidates.push({ command: signals.context.suggest || '/scr:scan', reason: 'refresh stale context before chaining work' });
644
+ }
645
+ if (signals.notes?.count > 0) {
646
+ localCandidates.push({ command: '/scr:check-notes', reason: 'surface unresolved notes before the next writing route' });
647
+ }
648
+ if (signals.save?.state !== 'clean') {
649
+ localCandidates.push({ command: signals.save.suggest || '/scr:save', reason: 'save manuscript changes before branching or packaging' });
650
+ }
651
+
652
+ if (manualPolicy) {
653
+ manualGates.push({ command: recommendation.command, reason: manualPolicy });
654
+ }
655
+ if (signals.publishing?.state === 'gaps') {
656
+ manualGates.push({
657
+ command: '/scr:publish',
658
+ reason: `publishing still needs ${signals.publishing.gaps.join(', ')}`,
659
+ });
660
+ }
661
+ if (signals.tracks?.state === 'active' || signals.tracks?.state === 'proposal-ready') {
662
+ manualGates.push({
663
+ command: signals.tracks.suggest || '/scr:track',
664
+ reason: 'revision-track decisions belong to the writer',
665
+ });
666
+ }
667
+
668
+ const recommendationIsManual = manualGates.some((gate) => gate.command === recommendation.command);
669
+ return {
670
+ mode: recommendationIsManual ? 'manual-gated' : spawnCandidates.length ? 'agent-ready' : localCandidates.length ? 'local-helper' : 'read-only',
671
+ spawnCandidates: dedupeByCommand(spawnCandidates),
672
+ localCandidates: dedupeByCommand(localCandidates),
673
+ manualGates: dedupeByCommand(manualGates),
674
+ };
675
+ }
676
+
677
+ function analyzeProject(projectRoot = process.cwd(), options = {}) {
678
+ const root = path.resolve(projectRoot);
679
+ const manuscriptDir = options.manuscriptDir || path.join(root, '.manuscript');
680
+ const hasProject = pathExists(manuscriptDir);
681
+ const statePath = path.join(manuscriptDir, 'STATE.md');
682
+ const config = readJson(path.join(manuscriptDir, 'config.json')) || {};
683
+
684
+ if (!hasProject) {
685
+ const signals = {
686
+ hasProject: false,
687
+ hasState: false,
688
+ context: { state: 'none', suggest: null },
689
+ history: { state: 'none', lastFailed: false },
690
+ reviews: { state: 'none', count: 0, files: [] },
691
+ reviewCoverage: { state: 'none', suggest: null },
692
+ readiness: { state: 'none', missing: [], suggest: null },
693
+ plan: { state: 'none', count: 0, suggest: null },
694
+ notes: { state: 'none', count: 0, files: [], suggest: null },
695
+ tracks: { state: 'none', activeCount: 0, proposalCount: 0, suggest: null },
696
+ translation: { state: 'none', count: 0, configuredTargets: [] },
697
+ export: { state: 'none', suggest: null },
698
+ publishing: { state: 'not-started', gaps: [], suggest: null },
699
+ save: { state: 'clean', suggest: null },
700
+ };
701
+ const recommendation = chooseRecommendation(signals, { drafts: 0 });
702
+ const automation = buildAutomationPlan(signals, recommendation);
703
+ return {
704
+ projectRoot: root,
705
+ manuscriptDir,
706
+ commandUnit: config.command_unit || 'unit',
707
+ workType: config.work_type || '',
708
+ counts: { drafts: 0, plans: 0, reviews: 0 },
709
+ signals,
710
+ recommendation,
711
+ automation,
712
+ };
713
+ }
714
+
715
+ const draftFiles = listFiles(path.join(manuscriptDir, 'drafts'), { extensions: ['.md'], recursive: true });
716
+ const reviewFiles = scanReviewSignals(manuscriptDir);
717
+ const allReviewFiles = listFiles(path.join(manuscriptDir, 'reviews'), { extensions: ['.md', '.txt'], recursive: true });
718
+ const historySignal = detectHistorySignal(manuscriptDir);
719
+ const sourceFiles = [
720
+ statePath,
721
+ path.join(manuscriptDir, 'OUTLINE.md'),
722
+ path.join(manuscriptDir, 'RECORD.md'),
723
+ path.join(manuscriptDir, 'STYLE-GUIDE.md'),
724
+ ...draftFiles,
725
+ ].filter(pathExists);
726
+
727
+ const signals = {
728
+ hasProject: true,
729
+ hasState: pathExists(statePath),
730
+ context: detectContextSignal(manuscriptDir, draftFiles),
731
+ history: historySignal,
732
+ readiness: detectProjectReadiness(manuscriptDir),
733
+ plan: detectPlanSignal(manuscriptDir, draftFiles),
734
+ reviews: {
735
+ state: reviewFiles.length ? 'pending' : 'none',
736
+ count: reviewFiles.length,
737
+ files: reviewFiles,
738
+ },
739
+ reviewCoverage: detectReviewCoverage(draftFiles, allReviewFiles),
740
+ notes: detectNotesSignal(manuscriptDir),
741
+ tracks: detectTrackSignal(manuscriptDir),
742
+ translation: detectTranslationSignal(manuscriptDir, config),
743
+ export: detectExportSignal(manuscriptDir, sourceFiles),
744
+ publishing: detectPublishingSignal(manuscriptDir, draftFiles),
745
+ save: detectSaveSignal(historySignal, draftFiles),
746
+ };
747
+ const counts = {
748
+ drafts: draftFiles.length,
749
+ plans: signals.plan.count,
750
+ reviews: reviewFiles.length,
751
+ };
752
+ const recommendation = chooseRecommendation(signals, counts);
753
+ const automation = buildAutomationPlan(signals, recommendation);
754
+ return {
755
+ projectRoot: root,
756
+ manuscriptDir,
757
+ commandUnit: config.command_unit || 'unit',
758
+ workType: config.work_type || '',
759
+ counts,
760
+ signals,
761
+ recommendation,
762
+ automation,
763
+ };
764
+ }
765
+
766
+ function formatProactiveChecks(analysis) {
767
+ const { signals } = analysis;
768
+ const stateLine = signals.hasProject
769
+ ? ` State: ${signals.hasState ? 'fresh' : 'missing, suggest /scr:scan'}`
770
+ : ' Project: missing, suggest /scr:new-work';
771
+ return [
772
+ 'Proactive checks:',
773
+ stateLine,
774
+ ` Readiness: ${signals.readiness?.state || 'none'}${signals.readiness?.missing?.length ? `, missing ${signals.readiness.missing.join(', ')}` : ''}`,
775
+ ` Session: ${signals.context.state}${signals.context.suggest ? `, suggest ${signals.context.suggest}` : ''}`,
776
+ ` Plans: ${signals.plan?.state || 'none'}${signals.plan?.suggest ? `, suggest ${signals.plan.suggest}` : ''}`,
777
+ ` Reviews: ${signals.reviews.count ? `${signals.reviews.count} pending, suggest /scr:editor-review` : 'none'}`,
778
+ ` Review coverage: ${signals.reviewCoverage?.state || 'none'}${signals.reviewCoverage?.suggest ? `, suggest ${signals.reviewCoverage.suggest}` : ''}`,
779
+ ` Notes: ${signals.notes?.count ? `${signals.notes.count} pending, suggest ${signals.notes.suggest}` : 'none'}`,
780
+ ` Tracks: ${signals.tracks?.state || 'none'}${signals.tracks?.suggest ? `, suggest ${signals.tracks.suggest}` : ''}`,
781
+ ` Translation: ${signals.translation.state}`,
782
+ ` Publishing: ${signals.publishing?.state || 'none'}${signals.publishing?.gaps?.length ? `, gaps ${signals.publishing.gaps.join(', ')}` : ''}`,
783
+ ` Export: ${signals.export.state}${signals.export.suggest ? `, suggest ${signals.export.suggest}` : ''}`,
784
+ ` Save: ${signals.save.state}${signals.save.suggest ? `, suggest ${signals.save.suggest}` : ''}`,
785
+ ].join('\n');
786
+ }
787
+
788
+ function formatAutomationStatus(analysis, options = {}) {
789
+ const trigger = options.trigger || '/scr:next';
790
+ const localOperation = options.localOperation || 'auto-invoke engine: read-only';
791
+ const autoInvoked = options.autoInvoked || `${analysis.recommendation.command}: no`;
792
+ const automation = analysis.automation || { mode: 'read-only', spawnCandidates: [], localCandidates: [], manualGates: [] };
793
+ const candidateAgentLines = automation.spawnCandidates.length
794
+ ? automation.spawnCandidates.map((candidate) => `- ${candidate.command}: ${candidate.agents.join(', ')} (${candidate.reason})`)
795
+ : ['- none'];
796
+ const localCandidateLines = automation.localCandidates.length
797
+ ? automation.localCandidates.map((candidate) => `- ${candidate.command}: ${candidate.reason}`)
798
+ : ['- none'];
799
+ const manualGateLines = automation.manualGates.length
800
+ ? automation.manualGates.map((gate) => `- ${gate.command}: ${gate.reason}`)
801
+ : ['- none'];
802
+ return [
803
+ 'Automation status:',
804
+ `Trigger: ${trigger}`,
805
+ `Mode: ${automation.mode}`,
806
+ 'Spawned agents:',
807
+ '- none',
808
+ 'Candidate agents:',
809
+ ...candidateAgentLines,
810
+ 'Local operations:',
811
+ `- ${localOperation}`,
812
+ `- state route computed: ${analysis.signals.hasProject ? 'yes' : 'no project'}`,
813
+ 'Candidate local helpers:',
814
+ ...localCandidateLines,
815
+ 'Manual gates:',
816
+ ...manualGateLines,
817
+ 'Auto-invoked:',
818
+ `- ${autoInvoked}`,
819
+ `Why: ${analysis.recommendation.reason}`,
820
+ ].join('\n');
821
+ }
822
+
823
+ function formatRecommendation(analysis) {
824
+ const lines = [
825
+ `${analysis.recommendation.command} is the recommended next command.`,
826
+ analysis.recommendation.reason,
827
+ '',
828
+ 'Next commands:',
829
+ `- \`${analysis.recommendation.command}\`: Run the highest-confidence next step from disk state.`,
830
+ ];
831
+ for (const command of analysis.recommendation.alternatives.slice(0, 3)) {
832
+ lines.push(`- \`${command}\`: Use this alternate path if it better matches the writer's intent.`);
833
+ }
834
+ return lines.join('\n');
835
+ }
836
+
837
+ function formatReport(analysis, options = {}) {
838
+ return [
839
+ formatProactiveChecks(analysis),
840
+ '',
841
+ formatAutomationStatus(analysis, options),
842
+ '',
843
+ formatRecommendation(analysis),
844
+ ].join('\n');
845
+ }
846
+
847
+ function getRuntimeAgentSupport(runtimeKey) {
848
+ return DEFAULT_RUNTIME_SUPPORT[runtimeKey] || null;
849
+ }
850
+
851
+ function listRuntimeAgentSupport() {
852
+ return Object.entries(DEFAULT_RUNTIME_SUPPORT).map(([key, value]) => ({
853
+ key,
854
+ ...value,
855
+ }));
856
+ }
857
+
858
+ function parseCliArgs(argv) {
859
+ const out = {
860
+ projectRoot: process.cwd(),
861
+ trigger: '/scr:next',
862
+ json: false,
863
+ };
864
+ for (let i = 0; i < argv.length; i++) {
865
+ const arg = argv[i];
866
+ if (arg === '--project') {
867
+ out.projectRoot = argv[++i] || out.projectRoot;
868
+ } else if (arg.startsWith('--project=')) {
869
+ out.projectRoot = arg.slice('--project='.length);
870
+ } else if (arg === '--trigger') {
871
+ out.trigger = argv[++i] || out.trigger;
872
+ } else if (arg.startsWith('--trigger=')) {
873
+ out.trigger = arg.slice('--trigger='.length);
874
+ } else if (arg === '--json') {
875
+ out.json = true;
876
+ }
877
+ }
878
+ return out;
879
+ }
880
+
881
+ if (require.main === module) {
882
+ const args = parseCliArgs(process.argv.slice(2));
883
+ const analysis = analyzeProject(args.projectRoot);
884
+ if (args.json) {
885
+ console.log(JSON.stringify(analysis, null, 2));
886
+ } else {
887
+ console.log(formatReport(analysis, { trigger: args.trigger }));
888
+ }
889
+ }
890
+
891
+ module.exports = {
892
+ AGENT_ROUTE_POLICIES,
893
+ CATEGORY_ROUTE_POLICIES,
894
+ DEFAULT_RUNTIME_SUPPORT,
895
+ LOCAL_ROUTE_POLICIES,
896
+ MANUAL_ROUTE_POLICIES,
897
+ analyzeProject,
898
+ formatProactiveChecks,
899
+ formatAutomationStatus,
900
+ formatRecommendation,
901
+ formatReport,
902
+ getCommandAutomationPolicy,
903
+ getRuntimeAgentSupport,
904
+ listRuntimeAgentSupport,
905
+ parseCliArgs,
906
+ };