scriveno 2.0.6 → 2.0.8

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,520 @@
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
+ function pathExists(filePath) {
95
+ try {
96
+ fs.accessSync(filePath);
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ function safeStat(filePath) {
104
+ try {
105
+ return fs.statSync(filePath);
106
+ } catch (err) {
107
+ if (err.code === 'ENOENT') return null;
108
+ throw err;
109
+ }
110
+ }
111
+
112
+ function readText(filePath) {
113
+ try {
114
+ return fs.readFileSync(filePath, 'utf8');
115
+ } catch (err) {
116
+ if (err.code === 'ENOENT') return '';
117
+ throw err;
118
+ }
119
+ }
120
+
121
+ function readJson(filePath) {
122
+ try {
123
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
124
+ } catch (err) {
125
+ if (err.code === 'ENOENT') return null;
126
+ if (err instanceof SyntaxError) return null;
127
+ throw err;
128
+ }
129
+ }
130
+
131
+ function listFiles(dir, options = {}) {
132
+ const { extensions = null, recursive = true } = options;
133
+ if (!pathExists(dir)) return [];
134
+ const out = [];
135
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
136
+ const fullPath = path.join(dir, entry.name);
137
+ if (entry.isDirectory()) {
138
+ if (recursive) out.push(...listFiles(fullPath, options));
139
+ } else if (!extensions || extensions.includes(path.extname(entry.name))) {
140
+ out.push(fullPath);
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+
146
+ function newestMtime(files) {
147
+ let newest = 0;
148
+ for (const file of files) {
149
+ const stat = safeStat(file);
150
+ if (stat && stat.mtimeMs > newest) newest = stat.mtimeMs;
151
+ }
152
+ return newest;
153
+ }
154
+
155
+ function countMarkdownFiles(dir) {
156
+ return listFiles(dir, { extensions: ['.md'], recursive: true }).length;
157
+ }
158
+
159
+ function containsAny(text, keywords) {
160
+ const haystack = text.toUpperCase();
161
+ return keywords.some((keyword) => haystack.includes(keyword.toUpperCase()));
162
+ }
163
+
164
+ function scanReviewSignals(manuscriptDir) {
165
+ const reviewDirs = [
166
+ 'reviews',
167
+ 'reports',
168
+ 'voice',
169
+ 'continuity',
170
+ 'translation',
171
+ ].map((name) => path.join(manuscriptDir, name));
172
+ const files = reviewDirs.flatMap((dir) => listFiles(dir, { extensions: ['.md', '.txt'], recursive: true }));
173
+ const pending = [];
174
+ for (const file of files) {
175
+ const text = readText(file);
176
+ if (containsAny(text, REVIEW_KEYWORDS)) {
177
+ pending.push(path.relative(manuscriptDir, file));
178
+ }
179
+ }
180
+ return pending;
181
+ }
182
+
183
+ function findNewestOutput(manuscriptDir) {
184
+ const outputDirs = [
185
+ path.join(manuscriptDir, 'output'),
186
+ path.join(manuscriptDir, 'build'),
187
+ path.join(manuscriptDir, 'exports'),
188
+ ];
189
+ return newestMtime(outputDirs.flatMap((dir) => listFiles(dir, { recursive: true })));
190
+ }
191
+
192
+ function detectTranslationSignal(manuscriptDir, config) {
193
+ const translationDir = path.join(manuscriptDir, 'translation');
194
+ const configuredTargets = [
195
+ ...(Array.isArray(config?.target_languages) ? config.target_languages : []),
196
+ ...(Array.isArray(config?.translation?.target_languages) ? config.translation.target_languages : []),
197
+ ...(Array.isArray(config?.translations) ? config.translations : []),
198
+ ];
199
+ const translationFiles = listFiles(translationDir, { recursive: true });
200
+ if (translationFiles.length > 0 || configuredTargets.length > 0) {
201
+ return {
202
+ state: 'follow-up available',
203
+ count: translationFiles.length,
204
+ configuredTargets,
205
+ };
206
+ }
207
+ return {
208
+ state: 'none',
209
+ count: 0,
210
+ configuredTargets: [],
211
+ };
212
+ }
213
+
214
+ function detectHistorySignal(manuscriptDir) {
215
+ const historyPath = path.join(manuscriptDir, 'HISTORY.log');
216
+ if (!pathExists(historyPath)) {
217
+ return { state: 'missing', lastFailed: false };
218
+ }
219
+ const lines = readText(historyPath).split(/\r?\n/).filter(Boolean);
220
+ const last = lines[lines.length - 1] || '';
221
+ return {
222
+ state: 'present',
223
+ lastFailed: /\b(fail|failed|error|blocked)\b/i.test(last),
224
+ };
225
+ }
226
+
227
+ function detectContextSignal(manuscriptDir, draftFiles) {
228
+ const contextPath = path.join(manuscriptDir, 'CONTEXT.md');
229
+ const statePath = path.join(manuscriptDir, 'STATE.md');
230
+ const contextStat = safeStat(contextPath);
231
+ const stateStat = safeStat(statePath);
232
+ const newestDraft = newestMtime(draftFiles);
233
+
234
+ if (!contextStat) {
235
+ return { state: 'missing', suggest: '/scr:save' };
236
+ }
237
+ if (stateStat && contextStat.mtimeMs < stateStat.mtimeMs) {
238
+ return { state: 'stale', suggest: '/scr:scan' };
239
+ }
240
+ if (newestDraft > 0 && contextStat.mtimeMs < newestDraft) {
241
+ return { state: 'stale', suggest: '/scr:save' };
242
+ }
243
+ return { state: 'fresh', suggest: null };
244
+ }
245
+
246
+ function detectExportSignal(manuscriptDir, sourceFiles) {
247
+ const newestSource = newestMtime(sourceFiles);
248
+ const newestOutput = findNewestOutput(manuscriptDir);
249
+ if (newestOutput === 0) {
250
+ return { state: sourceFiles.length ? 'missing' : 'none', suggest: sourceFiles.length ? '/scr:export' : null };
251
+ }
252
+ if (newestSource > newestOutput) {
253
+ return { state: 'stale', suggest: '/scr:export' };
254
+ }
255
+ return { state: 'fresh', suggest: null };
256
+ }
257
+
258
+ function detectSaveSignal(historySignal, draftFiles) {
259
+ if (draftFiles.length === 0) return { state: 'clean', suggest: null };
260
+ if (historySignal.state === 'missing') return { state: 'unsaved manuscript changes', suggest: '/scr:save' };
261
+ return { state: 'clean', suggest: null };
262
+ }
263
+
264
+ function chooseRecommendation(signals, counts) {
265
+ if (!signals.hasProject) {
266
+ return {
267
+ command: '/scr:new-work',
268
+ reason: 'No .manuscript directory was found.',
269
+ alternatives: ['/scr:demo', '/scr:import', '/scr:profile-writer'],
270
+ };
271
+ }
272
+ if (!signals.hasState) {
273
+ return {
274
+ command: '/scr:scan',
275
+ reason: 'The project is missing STATE.md.',
276
+ alternatives: ['/scr:health', '/scr:next'],
277
+ };
278
+ }
279
+ if (signals.history.lastFailed) {
280
+ return {
281
+ command: '/scr:troubleshoot',
282
+ reason: 'The last history entry appears to have failed.',
283
+ alternatives: ['/scr:scan', '/scr:health'],
284
+ };
285
+ }
286
+ if (signals.context.state === 'stale') {
287
+ return {
288
+ command: signals.context.suggest || '/scr:scan',
289
+ reason: 'CONTEXT.md is older than the current project state.',
290
+ alternatives: ['/scr:progress', '/scr:resume-work'],
291
+ };
292
+ }
293
+ if (signals.reviews.count > 0) {
294
+ return {
295
+ command: '/scr:editor-review',
296
+ reason: `${signals.reviews.count} review signal(s) still look unresolved.`,
297
+ alternatives: ['/scr:voice-check', '/scr:continuity-check', '/scr:progress'],
298
+ };
299
+ }
300
+ if (counts.drafts === 0) {
301
+ return {
302
+ command: '/scr:plan',
303
+ reason: 'No draft files were found yet.',
304
+ alternatives: ['/scr:discuss', '/scr:draft', '/scr:voice-test'],
305
+ };
306
+ }
307
+ if (signals.translation.state !== 'none') {
308
+ return {
309
+ command: '/scr:back-translate',
310
+ reason: 'Translation work exists and may need a verification pass.',
311
+ alternatives: ['/scr:cultural-adaptation', '/scr:multi-publish', '/scr:progress'],
312
+ };
313
+ }
314
+ if (signals.export.state === 'stale' || signals.export.state === 'missing') {
315
+ return {
316
+ command: signals.export.suggest || '/scr:export',
317
+ reason: `Export output is ${signals.export.state}.`,
318
+ alternatives: ['/scr:publish', '/scr:progress', '/scr:save'],
319
+ };
320
+ }
321
+ if (signals.save.state !== 'clean') {
322
+ return {
323
+ command: signals.save.suggest || '/scr:save',
324
+ reason: 'Draft files exist without a current history signal.',
325
+ alternatives: ['/scr:progress', '/scr:scan'],
326
+ };
327
+ }
328
+ return {
329
+ command: '/scr:next',
330
+ reason: 'Project state looks consistent; continue with the lifecycle route.',
331
+ alternatives: ['/scr:progress', '/scr:editor-review', '/scr:save'],
332
+ };
333
+ }
334
+
335
+ function analyzeProject(projectRoot = process.cwd(), options = {}) {
336
+ const root = path.resolve(projectRoot);
337
+ const manuscriptDir = options.manuscriptDir || path.join(root, '.manuscript');
338
+ const hasProject = pathExists(manuscriptDir);
339
+ const statePath = path.join(manuscriptDir, 'STATE.md');
340
+ const config = readJson(path.join(manuscriptDir, 'config.json')) || {};
341
+
342
+ if (!hasProject) {
343
+ const signals = {
344
+ hasProject: false,
345
+ hasState: false,
346
+ context: { state: 'none', suggest: null },
347
+ history: { state: 'none', lastFailed: false },
348
+ reviews: { state: 'none', count: 0, files: [] },
349
+ translation: { state: 'none', count: 0, configuredTargets: [] },
350
+ export: { state: 'none', suggest: null },
351
+ save: { state: 'clean', suggest: null },
352
+ };
353
+ const recommendation = chooseRecommendation(signals, { drafts: 0 });
354
+ return {
355
+ projectRoot: root,
356
+ manuscriptDir,
357
+ commandUnit: config.command_unit || 'unit',
358
+ workType: config.work_type || '',
359
+ counts: { drafts: 0, plans: 0, reviews: 0 },
360
+ signals,
361
+ recommendation,
362
+ };
363
+ }
364
+
365
+ const draftFiles = listFiles(path.join(manuscriptDir, 'drafts'), { extensions: ['.md'], recursive: true });
366
+ const planCount = countMarkdownFiles(path.join(manuscriptDir, 'plans'));
367
+ const reviewFiles = scanReviewSignals(manuscriptDir);
368
+ const historySignal = detectHistorySignal(manuscriptDir);
369
+ const sourceFiles = [
370
+ statePath,
371
+ path.join(manuscriptDir, 'OUTLINE.md'),
372
+ path.join(manuscriptDir, 'RECORD.md'),
373
+ path.join(manuscriptDir, 'STYLE-GUIDE.md'),
374
+ ...draftFiles,
375
+ ].filter(pathExists);
376
+
377
+ const signals = {
378
+ hasProject: true,
379
+ hasState: pathExists(statePath),
380
+ context: detectContextSignal(manuscriptDir, draftFiles),
381
+ history: historySignal,
382
+ reviews: {
383
+ state: reviewFiles.length ? 'pending' : 'none',
384
+ count: reviewFiles.length,
385
+ files: reviewFiles,
386
+ },
387
+ translation: detectTranslationSignal(manuscriptDir, config),
388
+ export: detectExportSignal(manuscriptDir, sourceFiles),
389
+ save: detectSaveSignal(historySignal, draftFiles),
390
+ };
391
+ const counts = {
392
+ drafts: draftFiles.length,
393
+ plans: planCount,
394
+ reviews: reviewFiles.length,
395
+ };
396
+ const recommendation = chooseRecommendation(signals, counts);
397
+ return {
398
+ projectRoot: root,
399
+ manuscriptDir,
400
+ commandUnit: config.command_unit || 'unit',
401
+ workType: config.work_type || '',
402
+ counts,
403
+ signals,
404
+ recommendation,
405
+ };
406
+ }
407
+
408
+ function formatProactiveChecks(analysis) {
409
+ const { signals } = analysis;
410
+ const stateLine = signals.hasProject
411
+ ? ` State: ${signals.hasState ? 'fresh' : 'missing, suggest /scr:scan'}`
412
+ : ' Project: missing, suggest /scr:new-work';
413
+ return [
414
+ 'Proactive checks:',
415
+ stateLine,
416
+ ` Session: ${signals.context.state}${signals.context.suggest ? `, suggest ${signals.context.suggest}` : ''}`,
417
+ ` Reviews: ${signals.reviews.count ? `${signals.reviews.count} pending, suggest /scr:editor-review` : 'none'}`,
418
+ ` Translation: ${signals.translation.state}`,
419
+ ` Export: ${signals.export.state}${signals.export.suggest ? `, suggest ${signals.export.suggest}` : ''}`,
420
+ ` Save: ${signals.save.state}${signals.save.suggest ? `, suggest ${signals.save.suggest}` : ''}`,
421
+ ].join('\n');
422
+ }
423
+
424
+ function formatAutomationStatus(analysis, options = {}) {
425
+ const trigger = options.trigger || '/scr:next';
426
+ const localOperation = options.localOperation || 'auto-invoke engine: read-only';
427
+ const autoInvoked = options.autoInvoked || `${analysis.recommendation.command}: no`;
428
+ return [
429
+ 'Automation status:',
430
+ `Trigger: ${trigger}`,
431
+ 'Spawned agents:',
432
+ '- none',
433
+ 'Local operations:',
434
+ `- ${localOperation}`,
435
+ `- state route computed: ${analysis.signals.hasProject ? 'yes' : 'no project'}`,
436
+ 'Auto-invoked:',
437
+ `- ${autoInvoked}`,
438
+ `Why: ${analysis.recommendation.reason}`,
439
+ ].join('\n');
440
+ }
441
+
442
+ function formatRecommendation(analysis) {
443
+ const lines = [
444
+ `${analysis.recommendation.command} is the recommended next command.`,
445
+ analysis.recommendation.reason,
446
+ '',
447
+ 'Next commands:',
448
+ `- \`${analysis.recommendation.command}\`: Run the highest-confidence next step from disk state.`,
449
+ ];
450
+ for (const command of analysis.recommendation.alternatives.slice(0, 3)) {
451
+ lines.push(`- \`${command}\`: Use this alternate path if it better matches the writer's intent.`);
452
+ }
453
+ return lines.join('\n');
454
+ }
455
+
456
+ function formatReport(analysis, options = {}) {
457
+ return [
458
+ formatProactiveChecks(analysis),
459
+ '',
460
+ formatAutomationStatus(analysis, options),
461
+ '',
462
+ formatRecommendation(analysis),
463
+ ].join('\n');
464
+ }
465
+
466
+ function getRuntimeAgentSupport(runtimeKey) {
467
+ return DEFAULT_RUNTIME_SUPPORT[runtimeKey] || null;
468
+ }
469
+
470
+ function listRuntimeAgentSupport() {
471
+ return Object.entries(DEFAULT_RUNTIME_SUPPORT).map(([key, value]) => ({
472
+ key,
473
+ ...value,
474
+ }));
475
+ }
476
+
477
+ function parseCliArgs(argv) {
478
+ const out = {
479
+ projectRoot: process.cwd(),
480
+ trigger: '/scr:next',
481
+ json: false,
482
+ };
483
+ for (let i = 0; i < argv.length; i++) {
484
+ const arg = argv[i];
485
+ if (arg === '--project') {
486
+ out.projectRoot = argv[++i] || out.projectRoot;
487
+ } else if (arg.startsWith('--project=')) {
488
+ out.projectRoot = arg.slice('--project='.length);
489
+ } else if (arg === '--trigger') {
490
+ out.trigger = argv[++i] || out.trigger;
491
+ } else if (arg.startsWith('--trigger=')) {
492
+ out.trigger = arg.slice('--trigger='.length);
493
+ } else if (arg === '--json') {
494
+ out.json = true;
495
+ }
496
+ }
497
+ return out;
498
+ }
499
+
500
+ if (require.main === module) {
501
+ const args = parseCliArgs(process.argv.slice(2));
502
+ const analysis = analyzeProject(args.projectRoot);
503
+ if (args.json) {
504
+ console.log(JSON.stringify(analysis, null, 2));
505
+ } else {
506
+ console.log(formatReport(analysis, { trigger: args.trigger }));
507
+ }
508
+ }
509
+
510
+ module.exports = {
511
+ DEFAULT_RUNTIME_SUPPORT,
512
+ analyzeProject,
513
+ formatProactiveChecks,
514
+ formatAutomationStatus,
515
+ formatRecommendation,
516
+ formatReport,
517
+ getRuntimeAgentSupport,
518
+ listRuntimeAgentSupport,
519
+ parseCliArgs,
520
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scriveno",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "description": "Spec-driven creative writing, publishing, and translation pipeline for AI coding agents. From blank page to published book.",
5
5
  "bin": {
6
6
  "scriveno": "bin/install.js"
@@ -1,5 +1,5 @@
1
1
  {
2
- "scriveno_version": "2.0.6",
2
+ "scriveno_version": "2.0.8",
3
3
  "work_type": "",
4
4
  "group": "",
5
5
  "command_unit": "",