scriveno 2.0.8 → 2.0.10

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.
@@ -1,4 +1,5 @@
1
1
  const fs = require('fs');
2
+ const os = require('os');
2
3
  const path = require('path');
3
4
 
4
5
  const DEFAULT_RUNTIME_SUPPORT = {
@@ -91,6 +92,229 @@ const REVIEW_KEYWORDS = [
91
92
  'CONTINUITY',
92
93
  ];
93
94
 
95
+ const CORE_PROJECT_FILES = [
96
+ 'WORK.md',
97
+ 'OUTLINE.md',
98
+ 'STYLE-GUIDE.md',
99
+ 'RECORD.md',
100
+ 'config.json',
101
+ ];
102
+
103
+ const DEFAULT_AGENT_NAMES = [
104
+ 'continuity-checker',
105
+ 'drafter',
106
+ 'plan-checker',
107
+ 'researcher',
108
+ 'translator',
109
+ 'voice-checker',
110
+ ];
111
+
112
+ const ROUTE_PRIORITY_FIXTURES = [
113
+ {
114
+ name: 'empty workspace',
115
+ setup: 'no .manuscript directory',
116
+ expectedCommand: '/scr:new-work',
117
+ reason: 'start or import before lifecycle routing',
118
+ },
119
+ {
120
+ name: 'scanned project without drafts',
121
+ setup: 'STATE.md and CONTEXT.md exist, drafts are absent',
122
+ expectedCommand: '/scr:plan',
123
+ reason: 'planning comes before drafting when no plan is ready',
124
+ },
125
+ {
126
+ name: 'planned work without draft',
127
+ setup: 'plan files exist and drafts are absent',
128
+ expectedCommand: '/scr:draft',
129
+ reason: 'connected plan evidence should route to drafting',
130
+ },
131
+ {
132
+ name: 'draft without review coverage',
133
+ setup: 'draft files exist and reviews are absent',
134
+ expectedCommand: '/scr:editor-review',
135
+ reason: 'review should precede export and packaging',
136
+ },
137
+ {
138
+ name: 'revision proposal waiting',
139
+ setup: 'proposal files exist in .manuscript/proposals',
140
+ expectedCommand: '/scr:editor-review --proposal',
141
+ reason: 'proposal review is more urgent than general notes',
142
+ },
143
+ {
144
+ name: 'translation follow-up',
145
+ setup: 'translation folders or target languages exist after review coverage',
146
+ expectedCommand: '/scr:back-translate',
147
+ reason: 'translation needs verification before multi-publish',
148
+ },
149
+ {
150
+ name: 'publishing prerequisite gap',
151
+ setup: 'reviewed drafts exist without front matter, back matter, blurb, or cover handoff',
152
+ expectedCommand: '/scr:front-matter',
153
+ reason: 'specific packaging prerequisites come before final publish',
154
+ },
155
+ ];
156
+
157
+ const RUNTIME_INSTALL_SURFACES = {
158
+ 'claude-code': {
159
+ commands: (homeDir) => path.join(homeDir, '.claude', 'commands'),
160
+ agents: (homeDir) => path.join(homeDir, '.claude', 'agents'),
161
+ commandLayout: 'flat',
162
+ },
163
+ cursor: {
164
+ commands: (homeDir) => path.join(homeDir, '.cursor', 'commands', 'scr'),
165
+ agents: (homeDir) => path.join(homeDir, '.cursor', 'agents'),
166
+ commandLayout: 'nested',
167
+ },
168
+ 'gemini-cli': {
169
+ commands: (homeDir) => path.join(homeDir, '.gemini', 'commands', 'scr'),
170
+ agents: (homeDir) => path.join(homeDir, '.gemini', 'agents'),
171
+ commandLayout: 'nested',
172
+ },
173
+ codex: {
174
+ commands: (homeDir) => path.join(homeDir, '.codex', 'commands', 'scr'),
175
+ skills: (homeDir) => path.join(homeDir, '.codex', 'skills'),
176
+ agents: (homeDir) => path.join(homeDir, '.codex', 'agents'),
177
+ commandLayout: 'nested',
178
+ metadata: 'toml',
179
+ },
180
+ opencode: {
181
+ commands: (homeDir) => path.join(homeDir, '.config', 'opencode', 'commands', 'scr'),
182
+ agents: (homeDir) => path.join(homeDir, '.config', 'opencode', 'agents'),
183
+ commandLayout: 'nested',
184
+ },
185
+ copilot: {
186
+ commands: (homeDir) => path.join(homeDir, '.github', 'commands', 'scr'),
187
+ agents: (homeDir) => path.join(homeDir, '.github', 'agents'),
188
+ commandLayout: 'nested',
189
+ },
190
+ windsurf: {
191
+ commands: (homeDir) => path.join(homeDir, '.windsurf', 'commands', 'scr'),
192
+ agents: (homeDir) => path.join(homeDir, '.windsurf', 'agents'),
193
+ commandLayout: 'nested',
194
+ },
195
+ antigravity: {
196
+ commands: (homeDir) => path.join(homeDir, '.gemini', 'antigravity', 'commands', 'scr'),
197
+ agents: (homeDir) => path.join(homeDir, '.gemini', 'antigravity', 'agents'),
198
+ commandLayout: 'nested',
199
+ },
200
+ manus: {
201
+ skills: (homeDir) => path.join(homeDir, '.manus', 'skills', 'scriveno'),
202
+ agents: (homeDir) => path.join(homeDir, '.manus', 'skills', 'scriveno', 'agents'),
203
+ commandLayout: 'skill-bundle',
204
+ },
205
+ 'perplexity-desktop': {
206
+ guide: (homeDir) => path.join(homeDir, '.scriveno', 'perplexity'),
207
+ commandLayout: 'guided-mcp',
208
+ },
209
+ generic: {
210
+ skills: (homeDir) => path.join(homeDir, '.scriveno', 'skills'),
211
+ agents: (homeDir) => path.join(homeDir, '.scriveno', 'skills', 'agents'),
212
+ commandLayout: 'skill-bundle',
213
+ },
214
+ };
215
+
216
+ const AGENT_ROUTE_POLICIES = {
217
+ '/scr:plan': {
218
+ agents: ['plan-checker'],
219
+ reason: 'planning can validate unit plans before drafting',
220
+ },
221
+ '/scr:draft': {
222
+ agents: ['drafter', 'voice-checker'],
223
+ reason: 'drafting uses fresh-context prose generation and voice checks',
224
+ },
225
+ '/scr:editor-review': {
226
+ agents: ['diagnostic worker'],
227
+ reason: 'editor review can isolate flagged issue groups',
228
+ },
229
+ '/scr:voice-check': {
230
+ agents: ['voice-checker'],
231
+ reason: 'voice review compares drafts against STYLE-GUIDE.md',
232
+ },
233
+ '/scr:continuity-check': {
234
+ agents: ['continuity-checker'],
235
+ reason: 'continuity review checks contradictions and timeline drift',
236
+ },
237
+ '/scr:translate': {
238
+ agents: ['translator'],
239
+ reason: 'translation runs one fresh-context translation pass per unit',
240
+ },
241
+ '/scr:back-translate': {
242
+ agents: ['translator'],
243
+ reason: 'back-translation verifies target-language drift',
244
+ },
245
+ '/scr:beta-reader': {
246
+ agents: ['beta-reader worker'],
247
+ reason: 'beta review benefits from isolated reader perspectives',
248
+ },
249
+ '/scr:quick-write': {
250
+ agents: ['drafter', 'voice-checker'],
251
+ reason: 'quick writing still benefits from voice-aware isolation',
252
+ },
253
+ '/scr:map-manuscript': {
254
+ agents: ['voice analyst', 'structure analyst', 'character analyst', 'theme analyst', 'world analyst', 'pacing analyst'],
255
+ reason: 'manuscript import uses parallel analysis workers when available',
256
+ },
257
+ };
258
+
259
+ const LOCAL_ROUTE_POLICIES = {
260
+ '/scr:save': 'refresh CONTEXT.md, HISTORY.log, and project checkpoint state',
261
+ '/scr:scan': 'reconcile STATE.md and disk evidence',
262
+ '/scr:health': 'diagnose project and runtime health',
263
+ '/scr:sync': 'compare and refresh installed runtime surfaces',
264
+ '/scr:validate': 'run project validation checks',
265
+ '/scr:check-notes': 'surface unresolved writer notes',
266
+ '/scr:progress': 'compute read-only project progress',
267
+ '/scr:session-report': 'compute read-only session metrics',
268
+ };
269
+
270
+ const MANUAL_ROUTE_POLICIES = {
271
+ '/scr:publish': 'publication packaging can overwrite deliverables and needs writer choices',
272
+ '/scr:export': 'export writes output artifacts and may overwrite packages',
273
+ '/scr:track merge': 'merging revision tracks is a writer-owned decision',
274
+ '/scr:undo': 'undo changes state and should stay explicit',
275
+ };
276
+
277
+ const CATEGORY_ROUTE_POLICIES = {
278
+ core: { lane: 'mixed', level: 3, reason: 'core lifecycle routes may read, write, or spawn depending on the current stage' },
279
+ navigation: { lane: 'read-only', level: 1, reason: 'navigation routes should inspect and recommend by default' },
280
+ quality: { lane: 'agent-or-local', level: 3, reason: 'quality routes may run bounded diagnostics or text transforms' },
281
+ character_world: { lane: 'local-helper', level: 2, reason: 'character and world routes update project knowledge files' },
282
+ structure: { lane: 'local-helper', level: 2, reason: 'structure routes update maps, outlines, and state evidence' },
283
+ structure_management: { lane: 'manual-gated', level: 4, reason: 'structure management can rename, remove, or reorder manuscript units' },
284
+ review: { lane: 'agent-or-local', level: 3, reason: 'review routes may invoke bounded diagnostic workers' },
285
+ illustration: { lane: 'local-helper', level: 2, reason: 'illustration routes generate prompts and asset briefs' },
286
+ publishing: { lane: 'manual-gated', level: 4, reason: 'publishing routes write deliverables and package outputs' },
287
+ translation: { lane: 'agent-or-local', level: 3, reason: 'translation routes use translator agents or verification helpers' },
288
+ sacred_exclusive: { lane: 'agent-or-local', level: 3, reason: 'sacred routes perform specialized consistency and reference work' },
289
+ utility: { lane: 'local-helper', level: 2, reason: 'utility routes perform deterministic diagnostics or project updates' },
290
+ session: { lane: 'local-helper', level: 2, reason: 'session routes save, compare, resume, or report project state' },
291
+ collaboration: { lane: 'manual-gated', level: 4, reason: 'collaboration routes change revision tracks and require writer control' },
292
+ };
293
+
294
+ function normalizeCommandRef(commandName) {
295
+ if (commandName.startsWith('/scr:')) return commandName;
296
+ return `/scr:${commandName}`;
297
+ }
298
+
299
+ function getCommandAutomationPolicy(commandName, command = {}) {
300
+ const ref = normalizeCommandRef(commandName);
301
+ if (AGENT_ROUTE_POLICIES[ref]) {
302
+ return { ref, lane: 'agent-ready', level: 3, reason: AGENT_ROUTE_POLICIES[ref].reason };
303
+ }
304
+ if (LOCAL_ROUTE_POLICIES[ref]) {
305
+ return { ref, lane: 'local-helper', level: 2, reason: LOCAL_ROUTE_POLICIES[ref] };
306
+ }
307
+ if (MANUAL_ROUTE_POLICIES[ref]) {
308
+ return { ref, lane: 'manual-gated', level: 4, reason: MANUAL_ROUTE_POLICIES[ref] };
309
+ }
310
+ const categoryPolicy = CATEGORY_ROUTE_POLICIES[command.category] || {
311
+ lane: 'read-only',
312
+ level: 1,
313
+ reason: 'unclassified routes should only suggest until a category policy is added',
314
+ };
315
+ return { ref, ...categoryPolicy };
316
+ }
317
+
94
318
  function pathExists(filePath) {
95
319
  try {
96
320
  fs.accessSync(filePath);
@@ -156,11 +380,124 @@ function countMarkdownFiles(dir) {
156
380
  return listFiles(dir, { extensions: ['.md'], recursive: true }).length;
157
381
  }
158
382
 
383
+ function countFiles(dir, extensions = null) {
384
+ return listFiles(dir, { extensions, recursive: true }).length;
385
+ }
386
+
387
+ function anyPathExists(paths) {
388
+ return paths.some(pathExists);
389
+ }
390
+
159
391
  function containsAny(text, keywords) {
160
392
  const haystack = text.toUpperCase();
161
393
  return keywords.some((keyword) => haystack.includes(keyword.toUpperCase()));
162
394
  }
163
395
 
396
+ function detectProjectReadiness(manuscriptDir) {
397
+ const missing = CORE_PROJECT_FILES.filter((file) => !pathExists(path.join(manuscriptDir, file)));
398
+ return {
399
+ state: missing.length ? 'incomplete' : 'ready',
400
+ missing,
401
+ suggest: missing.length ? '/scr:scan' : null,
402
+ };
403
+ }
404
+
405
+ function detectPlanSignal(manuscriptDir, draftFiles) {
406
+ const files = listFiles(path.join(manuscriptDir, 'plans'), { extensions: ['.md'], recursive: true });
407
+ if (files.length === 0) {
408
+ return { state: 'missing', count: 0, suggest: '/scr:plan' };
409
+ }
410
+ if (draftFiles.length === 0) {
411
+ return { state: 'ready-to-draft', count: files.length, suggest: '/scr:draft' };
412
+ }
413
+ if (files.length > draftFiles.length) {
414
+ return { state: 'partially-drafted', count: files.length, suggest: '/scr:draft' };
415
+ }
416
+ return { state: 'covered', count: files.length, suggest: null };
417
+ }
418
+
419
+ function detectReviewCoverage(draftFiles, reviewFiles) {
420
+ if (draftFiles.length === 0) {
421
+ return { state: 'none', suggest: null };
422
+ }
423
+ if (reviewFiles.length === 0) {
424
+ return { state: 'missing', suggest: '/scr:editor-review' };
425
+ }
426
+ if (reviewFiles.length < draftFiles.length) {
427
+ return { state: 'partial', suggest: '/scr:editor-review' };
428
+ }
429
+ return { state: 'covered', suggest: null };
430
+ }
431
+
432
+ function detectNotesSignal(manuscriptDir) {
433
+ const noteFiles = [
434
+ ...listFiles(path.join(manuscriptDir, 'notes'), { extensions: ['.md', '.txt'], recursive: true }),
435
+ path.join(manuscriptDir, 'NOTES.md'),
436
+ path.join(manuscriptDir, 'TODO.md'),
437
+ ].filter(pathExists);
438
+ const pending = noteFiles.filter((file) => containsAny(readText(file), ['TODO', 'FIXME', 'UNRESOLVED', 'QUESTION:', 'NOTE:']));
439
+ return {
440
+ state: pending.length ? 'pending' : 'none',
441
+ count: pending.length,
442
+ files: pending.map((file) => path.relative(manuscriptDir, file)),
443
+ suggest: pending.length ? '/scr:check-notes' : null,
444
+ };
445
+ }
446
+
447
+ function detectTrackSignal(manuscriptDir) {
448
+ const tracks = readJson(path.join(manuscriptDir, 'tracks.json'));
449
+ const proposals = listFiles(path.join(manuscriptDir, 'proposals'), { extensions: ['.md'], recursive: true });
450
+ const activeTracks = Array.isArray(tracks?.tracks)
451
+ ? tracks.tracks.filter((track) => track && track.status !== 'merged')
452
+ : [];
453
+ let state = 'none';
454
+ let suggest = null;
455
+ if (proposals.length > 0) {
456
+ state = 'proposal-ready';
457
+ suggest = '/scr:editor-review --proposal';
458
+ } else if (activeTracks.length > 0) {
459
+ state = 'active';
460
+ suggest = '/scr:track';
461
+ }
462
+ return {
463
+ state,
464
+ activeCount: activeTracks.length,
465
+ proposalCount: proposals.length,
466
+ suggest,
467
+ };
468
+ }
469
+
470
+ function detectPublishingSignal(manuscriptDir, draftFiles) {
471
+ const frontMatter = countMarkdownFiles(path.join(manuscriptDir, 'front-matter'));
472
+ const backMatter = countMarkdownFiles(path.join(manuscriptDir, 'back-matter'));
473
+ const blurb = pathExists(path.join(manuscriptDir, 'output', 'blurb.md'));
474
+ const ebookCover = anyPathExists([
475
+ path.join(manuscriptDir, 'build', 'ebook-cover.jpg'),
476
+ path.join(manuscriptDir, 'build', 'ebook-cover.png'),
477
+ ]);
478
+ const printCover = anyPathExists([
479
+ path.join(manuscriptDir, 'build', 'paperback-cover.pdf'),
480
+ path.join(manuscriptDir, 'build', 'hardcover-cover.pdf'),
481
+ ]);
482
+ const promptFiles = countFiles(path.join(manuscriptDir, 'illustrations', 'cover'), ['.md']);
483
+ const gaps = [];
484
+ if (draftFiles.length > 0 && frontMatter === 0) gaps.push('front-matter');
485
+ if (draftFiles.length > 0 && backMatter === 0) gaps.push('back-matter');
486
+ if (draftFiles.length > 0 && !blurb) gaps.push('blurb');
487
+ if (draftFiles.length > 0 && !ebookCover && promptFiles === 0) gaps.push('cover-art');
488
+ return {
489
+ state: gaps.length ? 'gaps' : draftFiles.length ? 'ready' : 'not-started',
490
+ frontMatter,
491
+ backMatter,
492
+ blurb,
493
+ ebookCover,
494
+ printCover,
495
+ coverPrompts: promptFiles,
496
+ gaps,
497
+ suggest: gaps.length ? `/scr:${gaps[0]}` : null,
498
+ };
499
+ }
500
+
164
501
  function scanReviewSignals(manuscriptDir) {
165
502
  const reviewDirs = [
166
503
  'reviews',
@@ -290,6 +627,13 @@ function chooseRecommendation(signals, counts) {
290
627
  alternatives: ['/scr:progress', '/scr:resume-work'],
291
628
  };
292
629
  }
630
+ if (signals.tracks?.state === 'proposal-ready') {
631
+ return {
632
+ command: signals.tracks.suggest,
633
+ reason: `${signals.tracks.proposalCount} revision proposal(s) are waiting for review.`,
634
+ alternatives: ['/scr:track', '/scr:compare', '/scr:progress'],
635
+ };
636
+ }
293
637
  if (signals.reviews.count > 0) {
294
638
  return {
295
639
  command: '/scr:editor-review',
@@ -297,6 +641,20 @@ function chooseRecommendation(signals, counts) {
297
641
  alternatives: ['/scr:voice-check', '/scr:continuity-check', '/scr:progress'],
298
642
  };
299
643
  }
644
+ if (signals.notes?.count > 0) {
645
+ return {
646
+ command: signals.notes.suggest,
647
+ reason: `${signals.notes.count} note file(s) contain unresolved items.`,
648
+ alternatives: ['/scr:progress', '/scr:scan', '/scr:next'],
649
+ };
650
+ }
651
+ if (signals.plan?.state === 'ready-to-draft' || signals.plan?.state === 'partially-drafted') {
652
+ return {
653
+ command: signals.plan.suggest,
654
+ reason: `${signals.plan.count} plan file(s) exist and drafting is the next connected step.`,
655
+ alternatives: ['/scr:plan', '/scr:voice-test', '/scr:progress'],
656
+ };
657
+ }
300
658
  if (counts.drafts === 0) {
301
659
  return {
302
660
  command: '/scr:plan',
@@ -304,6 +662,13 @@ function chooseRecommendation(signals, counts) {
304
662
  alternatives: ['/scr:discuss', '/scr:draft', '/scr:voice-test'],
305
663
  };
306
664
  }
665
+ if (signals.reviewCoverage?.state === 'missing' || signals.reviewCoverage?.state === 'partial') {
666
+ return {
667
+ command: signals.reviewCoverage.suggest,
668
+ reason: `Drafts exist but review coverage is ${signals.reviewCoverage.state}.`,
669
+ alternatives: ['/scr:voice-check', '/scr:continuity-check', '/scr:progress'],
670
+ };
671
+ }
307
672
  if (signals.translation.state !== 'none') {
308
673
  return {
309
674
  command: '/scr:back-translate',
@@ -311,6 +676,13 @@ function chooseRecommendation(signals, counts) {
311
676
  alternatives: ['/scr:cultural-adaptation', '/scr:multi-publish', '/scr:progress'],
312
677
  };
313
678
  }
679
+ if (signals.publishing?.state === 'gaps' && signals.export.state === 'missing') {
680
+ return {
681
+ command: signals.publishing.suggest || '/scr:publish',
682
+ reason: `Publishing prerequisites have gaps: ${signals.publishing.gaps.join(', ')}.`,
683
+ alternatives: ['/scr:publish', '/scr:export', '/scr:progress'],
684
+ };
685
+ }
314
686
  if (signals.export.state === 'stale' || signals.export.state === 'missing') {
315
687
  return {
316
688
  command: signals.export.suggest || '/scr:export',
@@ -332,6 +704,90 @@ function chooseRecommendation(signals, counts) {
332
704
  };
333
705
  }
334
706
 
707
+ function dedupeByCommand(items) {
708
+ const seen = new Set();
709
+ return items.filter((item) => {
710
+ if (seen.has(item.command)) return false;
711
+ seen.add(item.command);
712
+ return true;
713
+ });
714
+ }
715
+
716
+ function buildAutomationPlan(signals, recommendation) {
717
+ const spawnPolicy = AGENT_ROUTE_POLICIES[recommendation.command];
718
+ const localPolicy = LOCAL_ROUTE_POLICIES[recommendation.command];
719
+ const manualPolicy = MANUAL_ROUTE_POLICIES[recommendation.command];
720
+ const spawnCandidates = [];
721
+ const localCandidates = [];
722
+ const manualGates = [];
723
+
724
+ if (spawnPolicy) {
725
+ spawnCandidates.push({
726
+ command: recommendation.command,
727
+ agents: spawnPolicy.agents,
728
+ reason: spawnPolicy.reason,
729
+ });
730
+ }
731
+ if (signals.plan?.state === 'ready-to-draft' || signals.plan?.state === 'partially-drafted') {
732
+ spawnCandidates.push({
733
+ command: '/scr:draft',
734
+ agents: AGENT_ROUTE_POLICIES['/scr:draft'].agents,
735
+ reason: 'planned units can be drafted by the drafter route',
736
+ });
737
+ }
738
+ if (signals.reviewCoverage?.state === 'missing' || signals.reviewCoverage?.state === 'partial') {
739
+ spawnCandidates.push({
740
+ command: '/scr:editor-review',
741
+ agents: AGENT_ROUTE_POLICIES['/scr:editor-review'].agents,
742
+ reason: 'drafts without review coverage should enter the review route',
743
+ });
744
+ }
745
+ if (signals.translation?.state !== 'none') {
746
+ spawnCandidates.push({
747
+ command: '/scr:back-translate',
748
+ agents: AGENT_ROUTE_POLICIES['/scr:back-translate'].agents,
749
+ reason: 'translation work needs a verification pass',
750
+ });
751
+ }
752
+
753
+ if (localPolicy) {
754
+ localCandidates.push({ command: recommendation.command, reason: localPolicy });
755
+ }
756
+ if (signals.context?.state === 'stale') {
757
+ localCandidates.push({ command: signals.context.suggest || '/scr:scan', reason: 'refresh stale context before chaining work' });
758
+ }
759
+ if (signals.notes?.count > 0) {
760
+ localCandidates.push({ command: '/scr:check-notes', reason: 'surface unresolved notes before the next writing route' });
761
+ }
762
+ if (signals.save?.state !== 'clean') {
763
+ localCandidates.push({ command: signals.save.suggest || '/scr:save', reason: 'save manuscript changes before branching or packaging' });
764
+ }
765
+
766
+ if (manualPolicy) {
767
+ manualGates.push({ command: recommendation.command, reason: manualPolicy });
768
+ }
769
+ if (signals.publishing?.state === 'gaps') {
770
+ manualGates.push({
771
+ command: '/scr:publish',
772
+ reason: `publishing still needs ${signals.publishing.gaps.join(', ')}`,
773
+ });
774
+ }
775
+ if (signals.tracks?.state === 'active' || signals.tracks?.state === 'proposal-ready') {
776
+ manualGates.push({
777
+ command: signals.tracks.suggest || '/scr:track',
778
+ reason: 'revision-track decisions belong to the writer',
779
+ });
780
+ }
781
+
782
+ const recommendationIsManual = manualGates.some((gate) => gate.command === recommendation.command);
783
+ return {
784
+ mode: recommendationIsManual ? 'manual-gated' : spawnCandidates.length ? 'agent-ready' : localCandidates.length ? 'local-helper' : 'read-only',
785
+ spawnCandidates: dedupeByCommand(spawnCandidates),
786
+ localCandidates: dedupeByCommand(localCandidates),
787
+ manualGates: dedupeByCommand(manualGates),
788
+ };
789
+ }
790
+
335
791
  function analyzeProject(projectRoot = process.cwd(), options = {}) {
336
792
  const root = path.resolve(projectRoot);
337
793
  const manuscriptDir = options.manuscriptDir || path.join(root, '.manuscript');
@@ -346,11 +802,18 @@ function analyzeProject(projectRoot = process.cwd(), options = {}) {
346
802
  context: { state: 'none', suggest: null },
347
803
  history: { state: 'none', lastFailed: false },
348
804
  reviews: { state: 'none', count: 0, files: [] },
805
+ reviewCoverage: { state: 'none', suggest: null },
806
+ readiness: { state: 'none', missing: [], suggest: null },
807
+ plan: { state: 'none', count: 0, suggest: null },
808
+ notes: { state: 'none', count: 0, files: [], suggest: null },
809
+ tracks: { state: 'none', activeCount: 0, proposalCount: 0, suggest: null },
349
810
  translation: { state: 'none', count: 0, configuredTargets: [] },
350
811
  export: { state: 'none', suggest: null },
812
+ publishing: { state: 'not-started', gaps: [], suggest: null },
351
813
  save: { state: 'clean', suggest: null },
352
814
  };
353
815
  const recommendation = chooseRecommendation(signals, { drafts: 0 });
816
+ const automation = buildAutomationPlan(signals, recommendation);
354
817
  return {
355
818
  projectRoot: root,
356
819
  manuscriptDir,
@@ -359,12 +822,13 @@ function analyzeProject(projectRoot = process.cwd(), options = {}) {
359
822
  counts: { drafts: 0, plans: 0, reviews: 0 },
360
823
  signals,
361
824
  recommendation,
825
+ automation,
362
826
  };
363
827
  }
364
828
 
365
829
  const draftFiles = listFiles(path.join(manuscriptDir, 'drafts'), { extensions: ['.md'], recursive: true });
366
- const planCount = countMarkdownFiles(path.join(manuscriptDir, 'plans'));
367
830
  const reviewFiles = scanReviewSignals(manuscriptDir);
831
+ const allReviewFiles = listFiles(path.join(manuscriptDir, 'reviews'), { extensions: ['.md', '.txt'], recursive: true });
368
832
  const historySignal = detectHistorySignal(manuscriptDir);
369
833
  const sourceFiles = [
370
834
  statePath,
@@ -379,21 +843,28 @@ function analyzeProject(projectRoot = process.cwd(), options = {}) {
379
843
  hasState: pathExists(statePath),
380
844
  context: detectContextSignal(manuscriptDir, draftFiles),
381
845
  history: historySignal,
846
+ readiness: detectProjectReadiness(manuscriptDir),
847
+ plan: detectPlanSignal(manuscriptDir, draftFiles),
382
848
  reviews: {
383
849
  state: reviewFiles.length ? 'pending' : 'none',
384
850
  count: reviewFiles.length,
385
851
  files: reviewFiles,
386
852
  },
853
+ reviewCoverage: detectReviewCoverage(draftFiles, allReviewFiles),
854
+ notes: detectNotesSignal(manuscriptDir),
855
+ tracks: detectTrackSignal(manuscriptDir),
387
856
  translation: detectTranslationSignal(manuscriptDir, config),
388
857
  export: detectExportSignal(manuscriptDir, sourceFiles),
858
+ publishing: detectPublishingSignal(manuscriptDir, draftFiles),
389
859
  save: detectSaveSignal(historySignal, draftFiles),
390
860
  };
391
861
  const counts = {
392
862
  drafts: draftFiles.length,
393
- plans: planCount,
863
+ plans: signals.plan.count,
394
864
  reviews: reviewFiles.length,
395
865
  };
396
866
  const recommendation = chooseRecommendation(signals, counts);
867
+ const automation = buildAutomationPlan(signals, recommendation);
397
868
  return {
398
869
  projectRoot: root,
399
870
  manuscriptDir,
@@ -402,6 +873,7 @@ function analyzeProject(projectRoot = process.cwd(), options = {}) {
402
873
  counts,
403
874
  signals,
404
875
  recommendation,
876
+ automation,
405
877
  };
406
878
  }
407
879
 
@@ -413,9 +885,15 @@ function formatProactiveChecks(analysis) {
413
885
  return [
414
886
  'Proactive checks:',
415
887
  stateLine,
888
+ ` Readiness: ${signals.readiness?.state || 'none'}${signals.readiness?.missing?.length ? `, missing ${signals.readiness.missing.join(', ')}` : ''}`,
416
889
  ` Session: ${signals.context.state}${signals.context.suggest ? `, suggest ${signals.context.suggest}` : ''}`,
890
+ ` Plans: ${signals.plan?.state || 'none'}${signals.plan?.suggest ? `, suggest ${signals.plan.suggest}` : ''}`,
417
891
  ` Reviews: ${signals.reviews.count ? `${signals.reviews.count} pending, suggest /scr:editor-review` : 'none'}`,
892
+ ` Review coverage: ${signals.reviewCoverage?.state || 'none'}${signals.reviewCoverage?.suggest ? `, suggest ${signals.reviewCoverage.suggest}` : ''}`,
893
+ ` Notes: ${signals.notes?.count ? `${signals.notes.count} pending, suggest ${signals.notes.suggest}` : 'none'}`,
894
+ ` Tracks: ${signals.tracks?.state || 'none'}${signals.tracks?.suggest ? `, suggest ${signals.tracks.suggest}` : ''}`,
418
895
  ` Translation: ${signals.translation.state}`,
896
+ ` Publishing: ${signals.publishing?.state || 'none'}${signals.publishing?.gaps?.length ? `, gaps ${signals.publishing.gaps.join(', ')}` : ''}`,
419
897
  ` Export: ${signals.export.state}${signals.export.suggest ? `, suggest ${signals.export.suggest}` : ''}`,
420
898
  ` Save: ${signals.save.state}${signals.save.suggest ? `, suggest ${signals.save.suggest}` : ''}`,
421
899
  ].join('\n');
@@ -425,14 +903,31 @@ function formatAutomationStatus(analysis, options = {}) {
425
903
  const trigger = options.trigger || '/scr:next';
426
904
  const localOperation = options.localOperation || 'auto-invoke engine: read-only';
427
905
  const autoInvoked = options.autoInvoked || `${analysis.recommendation.command}: no`;
906
+ const automation = analysis.automation || { mode: 'read-only', spawnCandidates: [], localCandidates: [], manualGates: [] };
907
+ const candidateAgentLines = automation.spawnCandidates.length
908
+ ? automation.spawnCandidates.map((candidate) => `- ${candidate.command}: ${candidate.agents.join(', ')} (${candidate.reason})`)
909
+ : ['- none'];
910
+ const localCandidateLines = automation.localCandidates.length
911
+ ? automation.localCandidates.map((candidate) => `- ${candidate.command}: ${candidate.reason}`)
912
+ : ['- none'];
913
+ const manualGateLines = automation.manualGates.length
914
+ ? automation.manualGates.map((gate) => `- ${gate.command}: ${gate.reason}`)
915
+ : ['- none'];
428
916
  return [
429
917
  'Automation status:',
430
918
  `Trigger: ${trigger}`,
919
+ `Mode: ${automation.mode}`,
431
920
  'Spawned agents:',
432
921
  '- none',
922
+ 'Candidate agents:',
923
+ ...candidateAgentLines,
433
924
  'Local operations:',
434
925
  `- ${localOperation}`,
435
926
  `- state route computed: ${analysis.signals.hasProject ? 'yes' : 'no project'}`,
927
+ 'Candidate local helpers:',
928
+ ...localCandidateLines,
929
+ 'Manual gates:',
930
+ ...manualGateLines,
436
931
  'Auto-invoked:',
437
932
  `- ${autoInvoked}`,
438
933
  `Why: ${analysis.recommendation.reason}`,
@@ -474,6 +969,381 @@ function listRuntimeAgentSupport() {
474
969
  }));
475
970
  }
476
971
 
972
+ function getPackageRoot() {
973
+ return path.resolve(__dirname, '..');
974
+ }
975
+
976
+ function loadConstraints(options = {}) {
977
+ const constraintsPath = options.constraintsPath || path.join(getPackageRoot(), 'data', 'CONSTRAINTS.json');
978
+ const constraints = readJson(constraintsPath);
979
+ return constraints && constraints.commands ? constraints : { commands: {}, command_intents: {}, dependencies: {} };
980
+ }
981
+
982
+ function expectedCommandCount(options = {}) {
983
+ return Object.keys(loadConstraints(options).commands || {}).length;
984
+ }
985
+
986
+ function getExpectedAgentNames(options = {}) {
987
+ if (Array.isArray(options.agentNames) && options.agentNames.length > 0) {
988
+ return options.agentNames.slice().sort();
989
+ }
990
+ const agentsRoot = options.agentsRoot || path.join(getPackageRoot(), 'agents');
991
+ const files = listFiles(agentsRoot, { extensions: ['.md'], recursive: false })
992
+ .map((file) => path.basename(file, '.md'))
993
+ .sort();
994
+ return files.length ? files : DEFAULT_AGENT_NAMES.slice().sort();
995
+ }
996
+
997
+ function collectSafeApplyActions(projectRoot = process.cwd(), options = {}) {
998
+ const analysis = options.analysis || analyzeProject(projectRoot);
999
+ const actions = [
1000
+ {
1001
+ name: 'status sweep',
1002
+ command: 'scriveno status',
1003
+ status: 'ran',
1004
+ mutation: false,
1005
+ reason: 'computed the current route, local-helper, agent, and manual-gate state',
1006
+ },
1007
+ ];
1008
+
1009
+ const readOnlyHelpers = new Set(['/scr:progress', '/scr:session-report', '/scr:check-notes', '/scr:health', '/scr:validate']);
1010
+ const writeOrInstallHelpers = new Set(['/scr:save', '/scr:scan', '/scr:sync']);
1011
+
1012
+ for (const candidate of analysis.automation.localCandidates || []) {
1013
+ const command = candidate.command;
1014
+ if (readOnlyHelpers.has(command)) {
1015
+ actions.push({
1016
+ name: command.replace('/scr:', ''),
1017
+ command,
1018
+ status: 'ready',
1019
+ mutation: false,
1020
+ reason: candidate.reason,
1021
+ });
1022
+ } else if (writeOrInstallHelpers.has(command)) {
1023
+ actions.push({
1024
+ name: command.replace('/scr:', ''),
1025
+ command,
1026
+ status: 'skipped',
1027
+ mutation: true,
1028
+ reason: `${candidate.reason}; safe apply reports this instead of writing files`,
1029
+ });
1030
+ } else {
1031
+ actions.push({
1032
+ name: command.replace('/scr:', ''),
1033
+ command,
1034
+ status: 'suggested',
1035
+ mutation: null,
1036
+ reason: candidate.reason,
1037
+ });
1038
+ }
1039
+ }
1040
+
1041
+ for (const candidate of analysis.automation.spawnCandidates || []) {
1042
+ actions.push({
1043
+ name: candidate.command.replace('/scr:', ''),
1044
+ command: candidate.command,
1045
+ status: 'agent-candidate',
1046
+ mutation: null,
1047
+ reason: `${candidate.agents.join(', ')}: ${candidate.reason}`,
1048
+ });
1049
+ }
1050
+
1051
+ for (const gate of analysis.automation.manualGates || []) {
1052
+ actions.push({
1053
+ name: gate.command.replace('/scr:', ''),
1054
+ command: gate.command,
1055
+ status: 'manual-gate',
1056
+ mutation: true,
1057
+ reason: gate.reason,
1058
+ });
1059
+ }
1060
+
1061
+ return {
1062
+ projectRoot: analysis.projectRoot,
1063
+ trigger: options.trigger || 'scriveno status --apply-safe',
1064
+ appliedCount: actions.filter((action) => action.status === 'ran').length,
1065
+ skippedCount: actions.filter((action) => action.status === 'skipped' || action.status === 'manual-gate').length,
1066
+ safeToRunCount: actions.filter((action) => action.status === 'ready').length,
1067
+ agentCandidateCount: actions.filter((action) => action.status === 'agent-candidate').length,
1068
+ actions,
1069
+ };
1070
+ }
1071
+
1072
+ function formatSafeApplyReport(result) {
1073
+ const actionLines = result.actions.length
1074
+ ? result.actions.map((action) => {
1075
+ const mutation = action.mutation === false ? 'read-only' : action.mutation === true ? 'writes or external action' : 'host-dependent';
1076
+ return `- ${action.command}: ${action.status} (${mutation}) - ${action.reason}`;
1077
+ })
1078
+ : ['- none'];
1079
+ return [
1080
+ 'Safe apply status:',
1081
+ `Trigger: ${result.trigger}`,
1082
+ `Project: ${result.projectRoot}`,
1083
+ `Read-only checks run: ${result.appliedCount}`,
1084
+ `Safe helpers ready: ${result.safeToRunCount}`,
1085
+ `Agent candidates: ${result.agentCandidateCount}`,
1086
+ `Manual or write-gated actions: ${result.skippedCount}`,
1087
+ 'Actions:',
1088
+ ...actionLines,
1089
+ ].join('\n');
1090
+ }
1091
+
1092
+ function runtimeSurfacePaths(runtimeKey, options = {}) {
1093
+ const homeDir = options.homeDir || os.homedir();
1094
+ const surface = RUNTIME_INSTALL_SURFACES[runtimeKey];
1095
+ if (!surface) return null;
1096
+ const out = { runtimeKey };
1097
+ for (const [key, value] of Object.entries(surface)) {
1098
+ if (typeof value === 'function') out[key] = value(homeDir);
1099
+ }
1100
+ out.commandLayout = surface.commandLayout;
1101
+ out.metadata = surface.metadata || 'none';
1102
+ return out;
1103
+ }
1104
+
1105
+ function inspectAgentAvailability(options = {}) {
1106
+ const runtimeKeys = options.runtimeKeys || Object.keys(DEFAULT_RUNTIME_SUPPORT);
1107
+ const agentNames = getExpectedAgentNames(options);
1108
+ const runtimes = [];
1109
+
1110
+ for (const runtimeKey of runtimeKeys) {
1111
+ const support = getRuntimeAgentSupport(runtimeKey);
1112
+ const paths = runtimeSurfacePaths(runtimeKey, options);
1113
+ if (!support || !paths) continue;
1114
+
1115
+ if (runtimeKey === 'perplexity-desktop') {
1116
+ const guideReady = pathExists(path.join(paths.guide || '', 'SETUP.md'));
1117
+ runtimes.push({
1118
+ runtime: runtimeKey,
1119
+ label: support.label,
1120
+ status: guideReady ? 'guided-ready' : 'guided-missing',
1121
+ nativeSpawn: support.nativeSpawn,
1122
+ fallback: support.fallback,
1123
+ agentsDir: null,
1124
+ promptCount: 0,
1125
+ missingPrompts: agentNames,
1126
+ metadataCount: 0,
1127
+ missingMetadata: [],
1128
+ });
1129
+ continue;
1130
+ }
1131
+
1132
+ const agentsDir = paths.agents || path.join(paths.skills || '', 'agents');
1133
+ const promptFiles = agentNames.map((name) => `${name}.md`);
1134
+ const missingPrompts = promptFiles
1135
+ .filter((fileName) => !pathExists(path.join(agentsDir, fileName)))
1136
+ .map((fileName) => path.basename(fileName, '.md'));
1137
+ const metadataFiles = support.metadata === 'toml'
1138
+ ? agentNames.map((name) => `${name}.toml`)
1139
+ : [];
1140
+ const missingMetadata = metadataFiles
1141
+ .filter((fileName) => !pathExists(path.join(agentsDir, fileName)))
1142
+ .map((fileName) => path.basename(fileName, '.toml'));
1143
+ const promptCount = promptFiles.length - missingPrompts.length;
1144
+ const metadataCount = metadataFiles.length - missingMetadata.length;
1145
+ let status = 'missing';
1146
+ if (missingPrompts.length === 0 && missingMetadata.length === 0 && support.metadata === 'toml') {
1147
+ status = 'metadata-ready';
1148
+ } else if (missingPrompts.length === 0) {
1149
+ status = 'prompt-fallback-ready';
1150
+ }
1151
+
1152
+ runtimes.push({
1153
+ runtime: runtimeKey,
1154
+ label: support.label,
1155
+ status,
1156
+ nativeSpawn: support.nativeSpawn,
1157
+ fallback: support.fallback,
1158
+ agentsDir,
1159
+ promptCount,
1160
+ missingPrompts,
1161
+ metadataCount,
1162
+ missingMetadata,
1163
+ });
1164
+ }
1165
+
1166
+ return {
1167
+ checkedAt: new Date().toISOString(),
1168
+ expectedAgents: agentNames,
1169
+ runtimes,
1170
+ };
1171
+ }
1172
+
1173
+ function formatAgentAvailabilityReport(report) {
1174
+ const lines = [
1175
+ 'Agent availability:',
1176
+ `Expected agents: ${report.expectedAgents.join(', ')}`,
1177
+ ];
1178
+ for (const runtime of report.runtimes) {
1179
+ lines.push(`- ${runtime.label}: ${runtime.status}`);
1180
+ if (runtime.agentsDir) lines.push(` Agents: ${runtime.agentsDir}`);
1181
+ lines.push(` Prompts: ${runtime.promptCount}/${report.expectedAgents.length}`);
1182
+ if (runtime.missingPrompts.length) lines.push(` Missing prompts: ${runtime.missingPrompts.join(', ')}`);
1183
+ if (runtime.missingMetadata.length) lines.push(` Missing metadata: ${runtime.missingMetadata.join(', ')}`);
1184
+ lines.push(` Fallback: ${runtime.fallback}`);
1185
+ }
1186
+ return lines.join('\n');
1187
+ }
1188
+
1189
+ function countInstalledCommands(paths) {
1190
+ if (!paths) return 0;
1191
+ if (paths.commandLayout === 'flat') {
1192
+ return listFiles(paths.commands, { extensions: ['.md'], recursive: false })
1193
+ .filter((file) => /^scr-/.test(path.basename(file)))
1194
+ .length;
1195
+ }
1196
+ if (paths.commandLayout === 'skill-bundle') {
1197
+ return countMarkdownFiles(path.join(paths.skills || '', 'commands', 'scr'));
1198
+ }
1199
+ if (paths.commandLayout === 'guided-mcp') {
1200
+ return pathExists(path.join(paths.guide || '', 'SETUP.md')) ? 1 : 0;
1201
+ }
1202
+ return countMarkdownFiles(paths.commands || '');
1203
+ }
1204
+
1205
+ function inspectRuntimeSmoke(options = {}) {
1206
+ const runtimeKeys = options.runtimeKeys || Object.keys(DEFAULT_RUNTIME_SUPPORT);
1207
+ const expectedCommands = options.expectedCommands || expectedCommandCount(options);
1208
+ const expectedAgents = getExpectedAgentNames(options);
1209
+ const dataDir = options.dataDir || path.join(options.homeDir || os.homedir(), '.scriveno');
1210
+ const enginePath = path.join(dataDir, 'lib', 'auto-invoke-engine.js');
1211
+ const results = [];
1212
+
1213
+ for (const runtimeKey of runtimeKeys) {
1214
+ const support = getRuntimeAgentSupport(runtimeKey);
1215
+ const paths = runtimeSurfacePaths(runtimeKey, options);
1216
+ if (!support || !paths) continue;
1217
+ const commandCount = countInstalledCommands(paths);
1218
+ const skillCount = runtimeKey === 'codex' && pathExists(paths.skills || '')
1219
+ ? fs.readdirSync(paths.skills, { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name.startsWith('scr-')).length
1220
+ : paths.skills && pathExists(path.join(paths.skills, 'SKILL.md')) ? 1 : 0;
1221
+ const agentsDir = paths.agents || (paths.skills ? path.join(paths.skills, 'agents') : null);
1222
+ const promptCount = countFiles(agentsDir, ['.md']);
1223
+ const metadataCount = runtimeKey === 'codex' ? countFiles(agentsDir, ['.toml']) : 0;
1224
+ const commandReady = runtimeKey === 'perplexity-desktop' ? commandCount === 1 : commandCount >= expectedCommands;
1225
+ const skillReady = runtimeKey === 'codex' ? skillCount >= expectedCommands : !paths.skills || skillCount >= 1;
1226
+ const agentReady = runtimeKey === 'perplexity-desktop' ? true : promptCount >= expectedAgents.length;
1227
+ const metadataReady = runtimeKey === 'codex' ? metadataCount >= expectedAgents.length : true;
1228
+ const engineReady = pathExists(enginePath);
1229
+ const ok = commandReady && skillReady && agentReady && metadataReady && engineReady;
1230
+
1231
+ results.push({
1232
+ runtime: runtimeKey,
1233
+ label: support.label,
1234
+ ok,
1235
+ commands: commandCount,
1236
+ expectedCommands: runtimeKey === 'perplexity-desktop' ? 1 : expectedCommands,
1237
+ skills: skillCount,
1238
+ agents: promptCount,
1239
+ expectedAgents: runtimeKey === 'perplexity-desktop' ? 0 : expectedAgents.length,
1240
+ metadata: metadataCount,
1241
+ expectedMetadata: runtimeKey === 'codex' ? expectedAgents.length : 0,
1242
+ engineReady,
1243
+ paths,
1244
+ });
1245
+ }
1246
+
1247
+ return {
1248
+ checkedAt: new Date().toISOString(),
1249
+ dataDir,
1250
+ enginePath,
1251
+ expectedCommands,
1252
+ expectedAgents,
1253
+ ok: results.every((result) => result.ok),
1254
+ runtimes: results,
1255
+ };
1256
+ }
1257
+
1258
+ function formatRuntimeSmokeReport(report) {
1259
+ const lines = [
1260
+ 'Runtime smoke status:',
1261
+ `Shared engine: ${report.enginePath} (${report.runtimes.some((runtime) => runtime.engineReady) ? 'present' : 'missing'})`,
1262
+ `Overall: ${report.ok ? 'pass' : 'needs attention'}`,
1263
+ ];
1264
+ for (const runtime of report.runtimes) {
1265
+ lines.push(`- ${runtime.label}: ${runtime.ok ? 'pass' : 'needs attention'}`);
1266
+ lines.push(` Commands: ${runtime.commands}/${runtime.expectedCommands}`);
1267
+ if (runtime.skills) lines.push(` Skills: ${runtime.skills}`);
1268
+ if (runtime.expectedAgents) lines.push(` Agent prompts: ${runtime.agents}/${runtime.expectedAgents}`);
1269
+ if (runtime.expectedMetadata) lines.push(` Agent metadata: ${runtime.metadata}/${runtime.expectedMetadata}`);
1270
+ lines.push(` Shared engine: ${runtime.engineReady ? 'present' : 'missing'}`);
1271
+ }
1272
+ return lines.join('\n');
1273
+ }
1274
+
1275
+ function buildRouteGraph(options = {}) {
1276
+ const constraints = options.commands ? options : loadConstraints(options);
1277
+ const commands = constraints.commands || {};
1278
+ const categories = {};
1279
+ const lanes = {};
1280
+ const nodes = Object.entries(commands).map(([name, command]) => {
1281
+ const policy = getCommandAutomationPolicy(name, command);
1282
+ categories[command.category || 'uncategorized'] = (categories[command.category || 'uncategorized'] || 0) + 1;
1283
+ lanes[policy.lane] = (lanes[policy.lane] || 0) + 1;
1284
+ return {
1285
+ id: `/scr:${name}`,
1286
+ name,
1287
+ category: command.category || 'uncategorized',
1288
+ lane: policy.lane,
1289
+ level: policy.level,
1290
+ available: command.available || [],
1291
+ reason: policy.reason,
1292
+ };
1293
+ });
1294
+
1295
+ const edges = [];
1296
+ for (const [intent, names] of Object.entries(constraints.command_intents || {})) {
1297
+ for (let i = 0; i < names.length - 1; i++) {
1298
+ if (commands[names[i]] && commands[names[i + 1]]) {
1299
+ edges.push({ from: `/scr:${names[i]}`, to: `/scr:${names[i + 1]}`, type: 'intent-order', label: intent });
1300
+ }
1301
+ }
1302
+ }
1303
+ for (const [chainName, entries] of Object.entries(constraints.dependencies || {})) {
1304
+ if (!Array.isArray(entries)) continue;
1305
+ const commandEntries = entries.filter((entry) => entry && typeof entry === 'object' && entry.command);
1306
+ for (let i = 0; i < commandEntries.length - 1; i++) {
1307
+ const from = commandEntries[i].command;
1308
+ const to = commandEntries[i + 1].command;
1309
+ if (commands[from] && commands[to]) {
1310
+ edges.push({ from: `/scr:${from}`, to: `/scr:${to}`, type: 'dependency-chain', label: chainName });
1311
+ }
1312
+ }
1313
+ }
1314
+
1315
+ return {
1316
+ generatedAt: new Date().toISOString(),
1317
+ commandCount: nodes.length,
1318
+ edgeCount: edges.length,
1319
+ categories,
1320
+ lanes,
1321
+ agentRoutes: nodes.filter((node) => node.lane === 'agent-ready' || node.lane === 'agent-or-local').length,
1322
+ localRoutes: nodes.filter((node) => node.lane === 'local-helper').length,
1323
+ manualRoutes: nodes.filter((node) => node.lane === 'manual-gated').length,
1324
+ readOnlyRoutes: nodes.filter((node) => node.lane === 'read-only').length,
1325
+ nodes,
1326
+ edges,
1327
+ };
1328
+ }
1329
+
1330
+ function formatRouteGraphReport(graph) {
1331
+ const laneLines = Object.entries(graph.lanes)
1332
+ .sort(([a], [b]) => a.localeCompare(b))
1333
+ .map(([lane, count]) => `- ${lane}: ${count}`);
1334
+ return [
1335
+ 'Route graph audit:',
1336
+ `Commands: ${graph.commandCount}`,
1337
+ `Edges: ${graph.edgeCount}`,
1338
+ `Agent-capable routes: ${graph.agentRoutes}`,
1339
+ `Local-helper routes: ${graph.localRoutes}`,
1340
+ `Manual-gated routes: ${graph.manualRoutes}`,
1341
+ `Read-only routes: ${graph.readOnlyRoutes}`,
1342
+ 'Automation lanes:',
1343
+ ...laneLines,
1344
+ ].join('\n');
1345
+ }
1346
+
477
1347
  function parseCliArgs(argv) {
478
1348
  const out = {
479
1349
  projectRoot: process.cwd(),
@@ -508,13 +1378,30 @@ if (require.main === module) {
508
1378
  }
509
1379
 
510
1380
  module.exports = {
1381
+ AGENT_ROUTE_POLICIES,
1382
+ CATEGORY_ROUTE_POLICIES,
511
1383
  DEFAULT_RUNTIME_SUPPORT,
1384
+ DEFAULT_AGENT_NAMES,
1385
+ LOCAL_ROUTE_POLICIES,
1386
+ MANUAL_ROUTE_POLICIES,
1387
+ ROUTE_PRIORITY_FIXTURES,
512
1388
  analyzeProject,
1389
+ buildRouteGraph,
1390
+ collectSafeApplyActions,
1391
+ expectedCommandCount,
1392
+ formatAgentAvailabilityReport,
1393
+ formatRouteGraphReport,
513
1394
  formatProactiveChecks,
514
1395
  formatAutomationStatus,
515
1396
  formatRecommendation,
516
1397
  formatReport,
1398
+ formatRuntimeSmokeReport,
1399
+ formatSafeApplyReport,
1400
+ getCommandAutomationPolicy,
1401
+ getExpectedAgentNames,
517
1402
  getRuntimeAgentSupport,
1403
+ inspectAgentAvailability,
1404
+ inspectRuntimeSmoke,
518
1405
  listRuntimeAgentSupport,
519
1406
  parseCliArgs,
520
1407
  };