myaidev-method 0.2.24-1 → 0.2.24-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.claude-plugin/plugin.json +251 -0
  2. package/PLUGIN_ARCHITECTURE.md +276 -0
  3. package/README.md +204 -0
  4. package/USER_GUIDE.md +436 -9
  5. package/bin/cli.js +152 -0
  6. package/extension.json +174 -0
  7. package/hooks/hooks.json +221 -0
  8. package/marketplace.json +179 -0
  9. package/package.json +15 -3
  10. package/skills/content-verifier/SKILL.md +178 -0
  11. package/skills/content-writer/SKILL.md +151 -0
  12. package/skills/coolify-deployer/SKILL.md +207 -0
  13. package/skills/openstack-manager/SKILL.md +213 -0
  14. package/skills/security-auditor/SKILL.md +180 -0
  15. package/skills/security-tester/SKILL.md +171 -0
  16. package/skills/sparc-architect/SKILL.md +146 -0
  17. package/skills/sparc-coder/SKILL.md +136 -0
  18. package/skills/sparc-documenter/SKILL.md +195 -0
  19. package/skills/sparc-reviewer/SKILL.md +179 -0
  20. package/skills/sparc-tester/SKILL.md +156 -0
  21. package/skills/visual-generator/SKILL.md +147 -0
  22. package/skills/wordpress-publisher/SKILL.md +150 -0
  23. package/src/lib/content-coordinator.js +2562 -0
  24. package/src/lib/installation-detector.js +266 -0
  25. package/src/lib/visual-config-utils.js +1 -1
  26. package/src/lib/visual-generation-utils.js +34 -14
  27. package/src/scripts/generate-visual-cli.js +39 -10
  28. package/src/scripts/ping.js +0 -1
  29. package/src/templates/claude/agents/content-production-coordinator.md +689 -15
  30. package/src/templates/claude/commands/myai-content-enrichment.md +227 -0
  31. package/src/templates/claude/commands/myai-content-writer.md +48 -37
  32. package/src/templates/claude/commands/myai-coordinate-content.md +347 -11
@@ -0,0 +1,2562 @@
1
+ /**
2
+ * Content Production Coordinator
3
+ *
4
+ * State machine implementation for coordinating content verification and publishing.
5
+ * Provides checkpoint/resume capability, error isolation, progress tracking,
6
+ * multi-platform publishing, analytics, webhooks, and queue management.
7
+ *
8
+ * @module content-coordinator
9
+ */
10
+
11
+ import fs from 'fs/promises';
12
+ import path from 'path';
13
+ import https from 'https';
14
+ import http from 'http';
15
+ import os from 'os';
16
+
17
+ /**
18
+ * Workflow phases for the content production coordinator
19
+ */
20
+ const PHASES = {
21
+ INITIALIZE: 'initialize',
22
+ VERIFY: 'verify',
23
+ CATEGORIZE: 'categorize',
24
+ REPORT: 'report',
25
+ NOTIFY: 'notify',
26
+ PUBLISH: 'publish',
27
+ COMPLETE: 'complete'
28
+ };
29
+
30
+ /**
31
+ * Content item status values
32
+ */
33
+ const ITEM_STATUS = {
34
+ PENDING: 'pending',
35
+ VERIFYING: 'verifying',
36
+ VERIFIED: 'verified',
37
+ READY: 'ready',
38
+ NEEDS_REVIEW: 'needs_review',
39
+ PUBLISHING: 'publishing',
40
+ PUBLISHED: 'published',
41
+ FAILED: 'failed',
42
+ SKIPPED: 'skipped'
43
+ };
44
+
45
+ /**
46
+ * Redundancy score thresholds
47
+ */
48
+ const REDUNDANCY_THRESHOLDS = {
49
+ MINIMAL: 'minimal',
50
+ LOW: 'low',
51
+ MEDIUM: 'medium',
52
+ HIGH: 'high'
53
+ };
54
+
55
+ /**
56
+ * Supported publishing platforms
57
+ */
58
+ const PLATFORMS = {
59
+ WORDPRESS: 'wordpress',
60
+ PAYLOADCMS: 'payloadcms',
61
+ STATIC: 'static',
62
+ DOCUSAURUS: 'docusaurus',
63
+ MINTLIFY: 'mintlify',
64
+ ASTRO: 'astro'
65
+ };
66
+
67
+ /**
68
+ * Default configuration for the coordinator
69
+ */
70
+ const DEFAULT_CONFIG = {
71
+ concurrency: 3,
72
+ retryAttempts: 2,
73
+ retryDelay: 1000,
74
+ checkpointInterval: 5000,
75
+ stateFileName: '.content-coordinator-state.json',
76
+ queueFileName: '.content-queue.json',
77
+ outputDir: '.',
78
+ dryRun: false,
79
+ force: false,
80
+ verbose: false,
81
+ // Content rules
82
+ contentRulesPath: null,
83
+ // Webhooks
84
+ webhookUrl: null,
85
+ webhookEvents: ['complete', 'error'],
86
+ // Analytics
87
+ enableAnalytics: true,
88
+ // Platform
89
+ defaultPlatform: PLATFORMS.WORDPRESS,
90
+ // Cron/Scheduling
91
+ cronMode: false,
92
+ lockFileName: '.content-coordinator.lock',
93
+ lastRunFileName: '.content-coordinator-lastrun.json',
94
+ minRunInterval: 0, // Minimum seconds between runs (0 = no limit)
95
+ quietMode: false, // Suppress non-error output for cron
96
+ // WordPress Scheduling
97
+ useWordPressScheduling: true, // Use WP native scheduling for future posts
98
+ defaultPublishDelay: 0, // Hours to delay publication (0 = immediate)
99
+ publishSpreadInterval: 0 // Hours between scheduled posts (0 = no spreading)
100
+ };
101
+
102
+ /**
103
+ * ContentRulesManager - Handles loading and applying content rules
104
+ */
105
+ class ContentRulesManager {
106
+ constructor(rulesPath) {
107
+ this.rulesPath = rulesPath;
108
+ this.rules = null;
109
+ this.loaded = false;
110
+ }
111
+
112
+ /**
113
+ * Load content rules from file
114
+ * @returns {Promise<Object|null>} Parsed rules or null
115
+ */
116
+ async load() {
117
+ if (!this.rulesPath) return null;
118
+
119
+ try {
120
+ const content = await fs.readFile(this.rulesPath, 'utf8');
121
+ this.rules = this.parseContentRules(content);
122
+ this.loaded = true;
123
+ return this.rules;
124
+ } catch (err) {
125
+ if (err.code !== 'ENOENT') {
126
+ console.error('[ContentRules] Error loading rules:', err.message);
127
+ }
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Parse content-rules.md file
134
+ * @param {string} content - Raw file content
135
+ * @returns {Object} Parsed rules
136
+ */
137
+ parseContentRules(content) {
138
+ const rules = {
139
+ brandIdentity: {},
140
+ voiceTone: {},
141
+ writingStyle: {},
142
+ seoGuidelines: {},
143
+ formatting: {},
144
+ contentBoundaries: {}
145
+ };
146
+
147
+ // Extract sections using markdown headers
148
+ const sections = content.split(/^## /m).slice(1);
149
+
150
+ for (const section of sections) {
151
+ const lines = section.split('\n');
152
+ const header = lines[0].trim().toLowerCase();
153
+ const body = lines.slice(1).join('\n').trim();
154
+
155
+ if (header.includes('brand') || header.includes('identity')) {
156
+ rules.brandIdentity = this.parseSection(body);
157
+ } else if (header.includes('voice') || header.includes('tone')) {
158
+ rules.voiceTone = this.parseSection(body);
159
+ } else if (header.includes('writing') || header.includes('style')) {
160
+ rules.writingStyle = this.parseSection(body);
161
+ } else if (header.includes('seo')) {
162
+ rules.seoGuidelines = this.parseSection(body);
163
+ } else if (header.includes('format')) {
164
+ rules.formatting = this.parseSection(body);
165
+ } else if (header.includes('boundar') || header.includes('avoid')) {
166
+ rules.contentBoundaries = this.parseSection(body);
167
+ }
168
+ }
169
+
170
+ return rules;
171
+ }
172
+
173
+ /**
174
+ * Parse a section into key-value pairs
175
+ * @param {string} body - Section body
176
+ * @returns {Object} Parsed key-values
177
+ */
178
+ parseSection(body) {
179
+ const result = {};
180
+ const lines = body.split('\n');
181
+
182
+ for (const line of lines) {
183
+ // Parse list items: - **Key**: Value
184
+ const match = line.match(/^[-*]\s*\*\*([^*]+)\*\*:\s*(.+)$/);
185
+ if (match) {
186
+ const key = match[1].trim().toLowerCase().replace(/\s+/g, '_');
187
+ result[key] = match[2].trim();
188
+ }
189
+ // Parse simple list items: - Value
190
+ const simpleMatch = line.match(/^[-*]\s+(.+)$/);
191
+ if (simpleMatch && !match) {
192
+ if (!result.items) result.items = [];
193
+ result.items.push(simpleMatch[1].trim());
194
+ }
195
+ }
196
+
197
+ return result;
198
+ }
199
+
200
+ /**
201
+ * Get rules for verification context
202
+ * @returns {Object} Rules relevant for verification
203
+ */
204
+ getVerificationContext() {
205
+ if (!this.rules) return null;
206
+
207
+ return {
208
+ brandVoice: this.rules.voiceTone,
209
+ writingStyle: this.rules.writingStyle,
210
+ contentBoundaries: this.rules.contentBoundaries,
211
+ checkBrandAlignment: true
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Get rules for publishing context
217
+ * @returns {Object} Rules relevant for publishing
218
+ */
219
+ getPublishingContext() {
220
+ if (!this.rules) return null;
221
+
222
+ return {
223
+ brandIdentity: this.rules.brandIdentity,
224
+ seoGuidelines: this.rules.seoGuidelines,
225
+ formatting: this.rules.formatting
226
+ };
227
+ }
228
+ }
229
+
230
+ /**
231
+ * AnalyticsTracker - Tracks timing, success rates, and quality metrics
232
+ */
233
+ class AnalyticsTracker {
234
+ constructor() {
235
+ this.metrics = {
236
+ workflow: {
237
+ startTime: null,
238
+ endTime: null,
239
+ duration: null,
240
+ phases: {}
241
+ },
242
+ items: {
243
+ total: 0,
244
+ verified: 0,
245
+ published: 0,
246
+ failed: 0,
247
+ skipped: 0
248
+ },
249
+ performance: {
250
+ avgVerificationTime: 0,
251
+ avgPublishTime: 0,
252
+ totalVerificationTime: 0,
253
+ totalPublishTime: 0
254
+ },
255
+ quality: {
256
+ avgRedundancyScore: 0,
257
+ avgUniquenessScore: 0,
258
+ contentQualityTrend: []
259
+ },
260
+ errors: []
261
+ };
262
+ this.phaseTimers = {};
263
+ }
264
+
265
+ /**
266
+ * Start workflow tracking
267
+ */
268
+ startWorkflow() {
269
+ this.metrics.workflow.startTime = Date.now();
270
+ }
271
+
272
+ /**
273
+ * End workflow tracking
274
+ */
275
+ endWorkflow() {
276
+ this.metrics.workflow.endTime = Date.now();
277
+ this.metrics.workflow.duration = this.metrics.workflow.endTime - this.metrics.workflow.startTime;
278
+ }
279
+
280
+ /**
281
+ * Start phase timing
282
+ * @param {string} phase - Phase name
283
+ */
284
+ startPhase(phase) {
285
+ this.phaseTimers[phase] = Date.now();
286
+ }
287
+
288
+ /**
289
+ * End phase timing
290
+ * @param {string} phase - Phase name
291
+ */
292
+ endPhase(phase) {
293
+ if (this.phaseTimers[phase]) {
294
+ this.metrics.workflow.phases[phase] = {
295
+ duration: Date.now() - this.phaseTimers[phase],
296
+ completedAt: new Date().toISOString()
297
+ };
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Track item verification
303
+ * @param {Object} item - Content item
304
+ * @param {number} duration - Verification duration in ms
305
+ */
306
+ trackVerification(item, duration) {
307
+ this.metrics.items.verified++;
308
+ this.metrics.performance.totalVerificationTime += duration;
309
+ this.metrics.performance.avgVerificationTime =
310
+ this.metrics.performance.totalVerificationTime / this.metrics.items.verified;
311
+
312
+ // Track quality metrics
313
+ if (item.verification) {
314
+ const scores = { minimal: 1, low: 2, medium: 3, high: 4 };
315
+ const score = (item.verification.redundancyScore || '').toLowerCase();
316
+ if (scores[score]) {
317
+ this.metrics.quality.contentQualityTrend.push({
318
+ item: item.metadata.title,
319
+ redundancyScore: scores[score],
320
+ uniquenessScore: item.verification.uniquenessScore || 0,
321
+ timestamp: new Date().toISOString()
322
+ });
323
+
324
+ // Update averages
325
+ const trend = this.metrics.quality.contentQualityTrend;
326
+ this.metrics.quality.avgRedundancyScore =
327
+ trend.reduce((sum, t) => sum + t.redundancyScore, 0) / trend.length;
328
+ this.metrics.quality.avgUniquenessScore =
329
+ trend.reduce((sum, t) => sum + (t.uniquenessScore || 0), 0) / trend.length;
330
+ }
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Track item publishing
336
+ * @param {Object} item - Content item
337
+ * @param {number} duration - Publishing duration in ms
338
+ */
339
+ trackPublishing(item, duration) {
340
+ this.metrics.items.published++;
341
+ this.metrics.performance.totalPublishTime += duration;
342
+ this.metrics.performance.avgPublishTime =
343
+ this.metrics.performance.totalPublishTime / this.metrics.items.published;
344
+ }
345
+
346
+ /**
347
+ * Track error
348
+ * @param {Object} item - Content item
349
+ * @param {Error} error - Error object
350
+ * @param {string} phase - Phase where error occurred
351
+ */
352
+ trackError(item, error, phase) {
353
+ this.metrics.items.failed++;
354
+ this.metrics.errors.push({
355
+ item: item.metadata?.title || 'Unknown',
356
+ error: error.message,
357
+ phase,
358
+ timestamp: new Date().toISOString()
359
+ });
360
+ }
361
+
362
+ /**
363
+ * Set total items count
364
+ * @param {number} count - Total items
365
+ */
366
+ setTotalItems(count) {
367
+ this.metrics.items.total = count;
368
+ }
369
+
370
+ /**
371
+ * Get analytics summary
372
+ * @returns {Object} Analytics summary
373
+ */
374
+ getSummary() {
375
+ const successRate = this.metrics.items.total > 0
376
+ ? ((this.metrics.items.published / this.metrics.items.total) * 100).toFixed(1)
377
+ : 0;
378
+
379
+ return {
380
+ ...this.metrics,
381
+ summary: {
382
+ successRate: `${successRate}%`,
383
+ totalDuration: this.formatDuration(this.metrics.workflow.duration),
384
+ avgVerificationTime: this.formatDuration(this.metrics.performance.avgVerificationTime),
385
+ avgPublishTime: this.formatDuration(this.metrics.performance.avgPublishTime),
386
+ avgQualityScore: (5 - this.metrics.quality.avgRedundancyScore).toFixed(2) + '/4'
387
+ }
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Format duration to human-readable string
393
+ * @param {number} ms - Duration in milliseconds
394
+ * @returns {string} Formatted duration
395
+ */
396
+ formatDuration(ms) {
397
+ if (!ms) return 'N/A';
398
+ const seconds = Math.floor(ms / 1000);
399
+ const minutes = Math.floor(seconds / 60);
400
+ const hours = Math.floor(minutes / 60);
401
+
402
+ if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
403
+ if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
404
+ return `${seconds}s`;
405
+ }
406
+ }
407
+
408
+ /**
409
+ * WebhookNotifier - Sends notifications to external systems
410
+ */
411
+ class WebhookNotifier {
412
+ constructor(config) {
413
+ this.url = config.webhookUrl;
414
+ this.events = config.webhookEvents || ['complete', 'error'];
415
+ this.enabled = !!this.url;
416
+ }
417
+
418
+ /**
419
+ * Send webhook notification
420
+ * @param {string} event - Event type
421
+ * @param {Object} payload - Event payload
422
+ * @returns {Promise<boolean>} Success status
423
+ */
424
+ async notify(event, payload) {
425
+ if (!this.enabled || !this.events.includes(event)) {
426
+ return false;
427
+ }
428
+
429
+ const data = JSON.stringify({
430
+ event,
431
+ timestamp: new Date().toISOString(),
432
+ payload
433
+ });
434
+
435
+ return new Promise((resolve) => {
436
+ try {
437
+ const urlObj = new URL(this.url);
438
+ const options = {
439
+ hostname: urlObj.hostname,
440
+ port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
441
+ path: urlObj.pathname + urlObj.search,
442
+ method: 'POST',
443
+ headers: {
444
+ 'Content-Type': 'application/json',
445
+ 'Content-Length': Buffer.byteLength(data),
446
+ 'User-Agent': 'ContentCoordinator/1.0'
447
+ }
448
+ };
449
+
450
+ const protocol = urlObj.protocol === 'https:' ? https : http;
451
+ const req = protocol.request(options, (res) => {
452
+ resolve(res.statusCode >= 200 && res.statusCode < 300);
453
+ });
454
+
455
+ req.on('error', (err) => {
456
+ console.error('[Webhook] Error:', err.message);
457
+ resolve(false);
458
+ });
459
+
460
+ req.setTimeout(5000, () => {
461
+ req.destroy();
462
+ resolve(false);
463
+ });
464
+
465
+ req.write(data);
466
+ req.end();
467
+ } catch (err) {
468
+ console.error('[Webhook] Error:', err.message);
469
+ resolve(false);
470
+ }
471
+ });
472
+ }
473
+
474
+ /**
475
+ * Notify workflow start
476
+ * @param {Object} info - Workflow info
477
+ */
478
+ async notifyStart(info) {
479
+ await this.notify('start', info);
480
+ }
481
+
482
+ /**
483
+ * Notify workflow complete
484
+ * @param {Object} results - Final results
485
+ */
486
+ async notifyComplete(results) {
487
+ await this.notify('complete', results);
488
+ }
489
+
490
+ /**
491
+ * Notify error
492
+ * @param {Object} error - Error info
493
+ */
494
+ async notifyError(error) {
495
+ await this.notify('error', error);
496
+ }
497
+
498
+ /**
499
+ * Notify item published
500
+ * @param {Object} item - Published item info
501
+ */
502
+ async notifyPublished(item) {
503
+ await this.notify('published', item);
504
+ }
505
+ }
506
+
507
+ /**
508
+ * QueueManager - Manages persistent content queue
509
+ */
510
+ class QueueManager {
511
+ constructor(queuePath) {
512
+ this.queuePath = queuePath;
513
+ this.queue = {
514
+ version: '1.0',
515
+ created: null,
516
+ updated: null,
517
+ items: []
518
+ };
519
+ }
520
+
521
+ /**
522
+ * Load queue from file
523
+ * @returns {Promise<Object>} Queue data
524
+ */
525
+ async load() {
526
+ try {
527
+ const data = await fs.readFile(this.queuePath, 'utf8');
528
+ this.queue = JSON.parse(data);
529
+ return this.queue;
530
+ } catch (err) {
531
+ if (err.code !== 'ENOENT') {
532
+ console.error('[QueueManager] Error loading queue:', err.message);
533
+ }
534
+ this.queue.created = new Date().toISOString();
535
+ return this.queue;
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Save queue to file
541
+ * @returns {Promise<void>}
542
+ */
543
+ async save() {
544
+ this.queue.updated = new Date().toISOString();
545
+ await fs.writeFile(this.queuePath, JSON.stringify(this.queue, null, 2));
546
+ }
547
+
548
+ /**
549
+ * Add item to queue
550
+ * @param {Object} item - Item to add
551
+ * @returns {Object} Added item with ID
552
+ */
553
+ async addItem(item) {
554
+ const queueItem = {
555
+ id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
556
+ addedAt: new Date().toISOString(),
557
+ status: 'pending',
558
+ priority: item.priority || 'normal',
559
+ filePath: item.filePath,
560
+ metadata: item.metadata || {},
561
+ ...item
562
+ };
563
+
564
+ this.queue.items.push(queueItem);
565
+ await this.save();
566
+ return queueItem;
567
+ }
568
+
569
+ /**
570
+ * Remove item from queue
571
+ * @param {string} id - Item ID to remove
572
+ * @returns {boolean} Whether item was removed
573
+ */
574
+ async removeItem(id) {
575
+ const index = this.queue.items.findIndex(item => item.id === id);
576
+ if (index === -1) return false;
577
+
578
+ this.queue.items.splice(index, 1);
579
+ await this.save();
580
+ return true;
581
+ }
582
+
583
+ /**
584
+ * Update item status
585
+ * @param {string} id - Item ID
586
+ * @param {string} status - New status
587
+ * @param {Object} [extra] - Additional data to merge
588
+ */
589
+ async updateItemStatus(id, status, extra = {}) {
590
+ const item = this.queue.items.find(i => i.id === id);
591
+ if (item) {
592
+ item.status = status;
593
+ item.updatedAt = new Date().toISOString();
594
+ Object.assign(item, extra);
595
+ await this.save();
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Get pending items sorted by priority
601
+ * @returns {Object[]} Pending items
602
+ */
603
+ getPendingItems() {
604
+ const priorityOrder = { high: 0, normal: 1, low: 2 };
605
+ return this.queue.items
606
+ .filter(item => item.status === 'pending')
607
+ .sort((a, b) => {
608
+ const pA = priorityOrder[a.priority] ?? 1;
609
+ const pB = priorityOrder[b.priority] ?? 1;
610
+ if (pA !== pB) return pA - pB;
611
+ return new Date(a.addedAt) - new Date(b.addedAt);
612
+ });
613
+ }
614
+
615
+ /**
616
+ * Get queue statistics
617
+ * @returns {Object} Queue stats
618
+ */
619
+ getStats() {
620
+ const stats = {
621
+ total: this.queue.items.length,
622
+ pending: 0,
623
+ processing: 0,
624
+ completed: 0,
625
+ failed: 0,
626
+ byPriority: { high: 0, normal: 0, low: 0 }
627
+ };
628
+
629
+ for (const item of this.queue.items) {
630
+ if (item.status === 'pending') stats.pending++;
631
+ else if (item.status === 'processing') stats.processing++;
632
+ else if (item.status === 'completed' || item.status === 'published') stats.completed++;
633
+ else if (item.status === 'failed') stats.failed++;
634
+
635
+ if (stats.byPriority[item.priority] !== undefined) {
636
+ stats.byPriority[item.priority]++;
637
+ }
638
+ }
639
+
640
+ return stats;
641
+ }
642
+
643
+ /**
644
+ * Clear completed items from queue
645
+ * @returns {number} Number of items cleared
646
+ */
647
+ async clearCompleted() {
648
+ const before = this.queue.items.length;
649
+ this.queue.items = this.queue.items.filter(
650
+ item => !['completed', 'published'].includes(item.status)
651
+ );
652
+ await this.save();
653
+ return before - this.queue.items.length;
654
+ }
655
+ }
656
+
657
+ /**
658
+ * PlatformPublisher - Abstract publishing interface for multiple platforms
659
+ */
660
+ class PlatformPublisher {
661
+ constructor(config) {
662
+ this.config = config;
663
+ this.platforms = new Map();
664
+ this.initializePlatforms();
665
+ }
666
+
667
+ /**
668
+ * Initialize platform handlers
669
+ */
670
+ initializePlatforms() {
671
+ // WordPress publisher
672
+ this.platforms.set(PLATFORMS.WORDPRESS, {
673
+ name: 'WordPress',
674
+ publish: async (item, publishFn) => {
675
+ return publishFn(item, {
676
+ platform: PLATFORMS.WORDPRESS,
677
+ endpoint: 'wp-json/wp/v2/posts'
678
+ });
679
+ },
680
+ validateConfig: () => {
681
+ return !!(process.env.WORDPRESS_URL && process.env.WORDPRESS_APP_PASSWORD);
682
+ }
683
+ });
684
+
685
+ // PayloadCMS publisher
686
+ this.platforms.set(PLATFORMS.PAYLOADCMS, {
687
+ name: 'PayloadCMS',
688
+ publish: async (item, publishFn) => {
689
+ return publishFn(item, {
690
+ platform: PLATFORMS.PAYLOADCMS,
691
+ endpoint: 'api/posts'
692
+ });
693
+ },
694
+ validateConfig: () => {
695
+ return !!(process.env.PAYLOADCMS_URL && process.env.PAYLOADCMS_EMAIL);
696
+ }
697
+ });
698
+
699
+ // Static site generator (writes to file)
700
+ this.platforms.set(PLATFORMS.STATIC, {
701
+ name: 'Static',
702
+ publish: async (item, publishFn) => {
703
+ return publishFn(item, {
704
+ platform: PLATFORMS.STATIC,
705
+ outputDir: this.config.staticOutputDir || './content'
706
+ });
707
+ },
708
+ validateConfig: () => true
709
+ });
710
+
711
+ // Docusaurus
712
+ this.platforms.set(PLATFORMS.DOCUSAURUS, {
713
+ name: 'Docusaurus',
714
+ publish: async (item, publishFn) => {
715
+ return publishFn(item, {
716
+ platform: PLATFORMS.DOCUSAURUS,
717
+ outputDir: this.config.docusaurusDir || './docs'
718
+ });
719
+ },
720
+ validateConfig: () => true
721
+ });
722
+
723
+ // Mintlify
724
+ this.platforms.set(PLATFORMS.MINTLIFY, {
725
+ name: 'Mintlify',
726
+ publish: async (item, publishFn) => {
727
+ return publishFn(item, {
728
+ platform: PLATFORMS.MINTLIFY,
729
+ outputDir: this.config.mintlifyDir || './docs'
730
+ });
731
+ },
732
+ validateConfig: () => true
733
+ });
734
+
735
+ // Astro
736
+ this.platforms.set(PLATFORMS.ASTRO, {
737
+ name: 'Astro',
738
+ publish: async (item, publishFn) => {
739
+ return publishFn(item, {
740
+ platform: PLATFORMS.ASTRO,
741
+ outputDir: this.config.astroDir || './src/content'
742
+ });
743
+ },
744
+ validateConfig: () => true
745
+ });
746
+ }
747
+
748
+ /**
749
+ * Get publisher for platform
750
+ * @param {string} platform - Platform name
751
+ * @returns {Object|null} Platform publisher
752
+ */
753
+ getPublisher(platform) {
754
+ return this.platforms.get(platform) || this.platforms.get(this.config.defaultPlatform);
755
+ }
756
+
757
+ /**
758
+ * Validate platform configuration
759
+ * @param {string} platform - Platform name
760
+ * @returns {boolean} Whether config is valid
761
+ */
762
+ validatePlatform(platform) {
763
+ const publisher = this.getPublisher(platform);
764
+ return publisher ? publisher.validateConfig() : false;
765
+ }
766
+
767
+ /**
768
+ * Get available platforms
769
+ * @returns {string[]} List of configured platforms
770
+ */
771
+ getAvailablePlatforms() {
772
+ const available = [];
773
+ for (const [name, publisher] of this.platforms) {
774
+ if (publisher.validateConfig()) {
775
+ available.push(name);
776
+ }
777
+ }
778
+ return available;
779
+ }
780
+ }
781
+
782
+ /**
783
+ * CronHelper - Manages cron-safe execution and crontab integration
784
+ *
785
+ * Makes the coordinator safe to run from Linux crontab by:
786
+ * - Using lock files to prevent concurrent runs
787
+ * - Tracking last run times
788
+ * - Providing quiet mode for cron output
789
+ * - Generating crontab entry suggestions
790
+ */
791
+ class CronHelper {
792
+ constructor(config) {
793
+ this.config = config;
794
+ this.lockPath = path.join(config.outputDir, config.lockFileName);
795
+ this.lastRunPath = path.join(config.outputDir, config.lastRunFileName);
796
+ this.lockFd = null;
797
+ }
798
+
799
+ /**
800
+ * Acquire exclusive lock to prevent concurrent runs
801
+ * @returns {Promise<boolean>} Whether lock was acquired
802
+ */
803
+ async acquireLock() {
804
+ try {
805
+ // Check if lock file exists and is stale
806
+ try {
807
+ const lockData = await fs.readFile(this.lockPath, 'utf8');
808
+ const lock = JSON.parse(lockData);
809
+
810
+ // Check if lock is stale (older than 1 hour)
811
+ const lockAge = Date.now() - new Date(lock.timestamp).getTime();
812
+ const maxAge = 60 * 60 * 1000; // 1 hour
813
+
814
+ if (lockAge > maxAge) {
815
+ console.error('[CronHelper] Stale lock detected, removing...');
816
+ await fs.unlink(this.lockPath);
817
+ } else {
818
+ // Lock is held by another process
819
+ if (!this.config.quietMode) {
820
+ console.log(`[CronHelper] Lock held by PID ${lock.pid} since ${lock.timestamp}`);
821
+ }
822
+ return false;
823
+ }
824
+ } catch (err) {
825
+ // Lock file doesn't exist, we can proceed
826
+ if (err.code !== 'ENOENT') throw err;
827
+ }
828
+
829
+ // Create lock file
830
+ const lockData = {
831
+ pid: process.pid,
832
+ timestamp: new Date().toISOString(),
833
+ hostname: os.hostname()
834
+ };
835
+
836
+ await fs.writeFile(this.lockPath, JSON.stringify(lockData, null, 2), { flag: 'wx' });
837
+ return true;
838
+ } catch (err) {
839
+ if (err.code === 'EEXIST') {
840
+ // Another process created the lock first
841
+ return false;
842
+ }
843
+ throw err;
844
+ }
845
+ }
846
+
847
+ /**
848
+ * Release the lock
849
+ * @returns {Promise<void>}
850
+ */
851
+ async releaseLock() {
852
+ try {
853
+ await fs.unlink(this.lockPath);
854
+ } catch (err) {
855
+ if (err.code !== 'ENOENT') {
856
+ console.error('[CronHelper] Error releasing lock:', err.message);
857
+ }
858
+ }
859
+ }
860
+
861
+ /**
862
+ * Check if minimum run interval has passed
863
+ * @returns {Promise<{canRun: boolean, nextRunTime: Date|null, lastRun: Date|null}>}
864
+ */
865
+ async checkRunInterval() {
866
+ if (this.config.minRunInterval <= 0) {
867
+ return { canRun: true, nextRunTime: null, lastRun: null };
868
+ }
869
+
870
+ try {
871
+ const data = await fs.readFile(this.lastRunPath, 'utf8');
872
+ const lastRunInfo = JSON.parse(data);
873
+ const lastRun = new Date(lastRunInfo.timestamp);
874
+ const elapsed = (Date.now() - lastRun.getTime()) / 1000;
875
+
876
+ if (elapsed < this.config.minRunInterval) {
877
+ const nextRunTime = new Date(lastRun.getTime() + this.config.minRunInterval * 1000);
878
+ return { canRun: false, nextRunTime, lastRun };
879
+ }
880
+
881
+ return { canRun: true, nextRunTime: null, lastRun };
882
+ } catch (err) {
883
+ if (err.code === 'ENOENT') {
884
+ // No last run file, first run
885
+ return { canRun: true, nextRunTime: null, lastRun: null };
886
+ }
887
+ throw err;
888
+ }
889
+ }
890
+
891
+ /**
892
+ * Record successful run
893
+ * @param {Object} results - Run results summary
894
+ * @returns {Promise<void>}
895
+ */
896
+ async recordRun(results) {
897
+ const runInfo = {
898
+ timestamp: new Date().toISOString(),
899
+ pid: process.pid,
900
+ results: {
901
+ total: results.stats?.totalItems || 0,
902
+ published: results.stats?.published || 0,
903
+ failed: results.stats?.failed || 0,
904
+ duration: results.duration
905
+ }
906
+ };
907
+
908
+ await fs.writeFile(this.lastRunPath, JSON.stringify(runInfo, null, 2));
909
+ }
910
+
911
+ /**
912
+ * Get last run information
913
+ * @returns {Promise<Object|null>}
914
+ */
915
+ async getLastRun() {
916
+ try {
917
+ const data = await fs.readFile(this.lastRunPath, 'utf8');
918
+ return JSON.parse(data);
919
+ } catch (err) {
920
+ if (err.code === 'ENOENT') return null;
921
+ throw err;
922
+ }
923
+ }
924
+
925
+ /**
926
+ * Generate crontab entry for scheduling
927
+ * @param {Object} options - Scheduling options
928
+ * @returns {string} Crontab entry
929
+ */
930
+ generateCrontabEntry(options = {}) {
931
+ const {
932
+ schedule = '0 */6 * * *', // Default: every 6 hours
933
+ workDir = process.cwd(),
934
+ contentDir = './content-queue',
935
+ logFile = '/var/log/content-coordinator.log',
936
+ user = process.env.USER || 'ubuntu',
937
+ extraFlags = ''
938
+ } = options;
939
+
940
+ const command = `cd ${workDir} && /usr/bin/npx myaidev-method coordinate-content ${contentDir} --cron --force ${extraFlags}`.trim();
941
+
942
+ return `# Content Coordinator - Automated publishing
943
+ # Schedule: ${this.describeCronSchedule(schedule)}
944
+ # Added: ${new Date().toISOString()}
945
+ ${schedule} ${user} ${command} >> ${logFile} 2>&1
946
+
947
+ # Alternative with flock for extra safety:
948
+ # ${schedule} ${user} /usr/bin/flock -n /tmp/content-coordinator.lock ${command} >> ${logFile} 2>&1
949
+ `;
950
+ }
951
+
952
+ /**
953
+ * Describe cron schedule in human-readable format
954
+ * @param {string} schedule - Cron schedule expression
955
+ * @returns {string} Human-readable description
956
+ */
957
+ describeCronSchedule(schedule) {
958
+ const parts = schedule.split(' ');
959
+ if (parts.length !== 5) return schedule;
960
+
961
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
962
+
963
+ // Common patterns
964
+ if (schedule === '0 * * * *') return 'Every hour';
965
+ if (schedule === '*/15 * * * *') return 'Every 15 minutes';
966
+ if (schedule === '*/30 * * * *') return 'Every 30 minutes';
967
+ if (schedule === '0 */2 * * *') return 'Every 2 hours';
968
+ if (schedule === '0 */6 * * *') return 'Every 6 hours';
969
+ if (schedule === '0 */12 * * *') return 'Every 12 hours';
970
+ if (schedule === '0 0 * * *') return 'Daily at midnight';
971
+ if (schedule === '0 9 * * *') return 'Daily at 9:00 AM';
972
+ if (schedule === '0 9 * * 1-5') return 'Weekdays at 9:00 AM';
973
+ if (schedule === '0 0 * * 0') return 'Weekly on Sunday at midnight';
974
+ if (schedule === '0 0 1 * *') return 'Monthly on the 1st at midnight';
975
+
976
+ return schedule;
977
+ }
978
+
979
+ /**
980
+ * Get suggested cron schedules for different use cases
981
+ * @returns {Object[]} Array of schedule suggestions
982
+ */
983
+ getSuggestedSchedules() {
984
+ return [
985
+ {
986
+ name: 'High frequency',
987
+ schedule: '*/30 * * * *',
988
+ description: 'Every 30 minutes - for time-sensitive content'
989
+ },
990
+ {
991
+ name: 'Regular',
992
+ schedule: '0 */6 * * *',
993
+ description: 'Every 6 hours - balanced approach'
994
+ },
995
+ {
996
+ name: 'Daily morning',
997
+ schedule: '0 9 * * *',
998
+ description: 'Daily at 9:00 AM - publish during business hours'
999
+ },
1000
+ {
1001
+ name: 'Weekdays only',
1002
+ schedule: '0 9 * * 1-5',
1003
+ description: 'Weekdays at 9:00 AM - avoid weekend publishing'
1004
+ },
1005
+ {
1006
+ name: 'Twice daily',
1007
+ schedule: '0 9,17 * * *',
1008
+ description: '9:00 AM and 5:00 PM - morning and evening'
1009
+ },
1010
+ {
1011
+ name: 'Weekly',
1012
+ schedule: '0 9 * * 1',
1013
+ description: 'Every Monday at 9:00 AM - weekly content drops'
1014
+ }
1015
+ ];
1016
+ }
1017
+
1018
+ /**
1019
+ * Validate cron expression
1020
+ * @param {string} expression - Cron expression to validate
1021
+ * @returns {{valid: boolean, error?: string}}
1022
+ */
1023
+ validateCronExpression(expression) {
1024
+ const parts = expression.trim().split(/\s+/);
1025
+
1026
+ if (parts.length !== 5) {
1027
+ return {
1028
+ valid: false,
1029
+ error: `Expected 5 fields (minute hour day month weekday), got ${parts.length}`
1030
+ };
1031
+ }
1032
+
1033
+ const ranges = [
1034
+ { name: 'minute', min: 0, max: 59 },
1035
+ { name: 'hour', min: 0, max: 23 },
1036
+ { name: 'day of month', min: 1, max: 31 },
1037
+ { name: 'month', min: 1, max: 12 },
1038
+ { name: 'day of week', min: 0, max: 7 }
1039
+ ];
1040
+
1041
+ for (let i = 0; i < 5; i++) {
1042
+ const part = parts[i];
1043
+ const range = ranges[i];
1044
+
1045
+ // Skip wildcards
1046
+ if (part === '*') continue;
1047
+
1048
+ // Check step values (*/n)
1049
+ if (part.startsWith('*/')) {
1050
+ const step = parseInt(part.slice(2), 10);
1051
+ if (isNaN(step) || step < 1) {
1052
+ return { valid: false, error: `Invalid step value in ${range.name}: ${part}` };
1053
+ }
1054
+ continue;
1055
+ }
1056
+
1057
+ // Check ranges (n-m)
1058
+ if (part.includes('-')) {
1059
+ const [start, end] = part.split('-').map(n => parseInt(n, 10));
1060
+ if (isNaN(start) || isNaN(end) || start < range.min || end > range.max || start > end) {
1061
+ return { valid: false, error: `Invalid range in ${range.name}: ${part}` };
1062
+ }
1063
+ continue;
1064
+ }
1065
+
1066
+ // Check lists (n,m,...)
1067
+ if (part.includes(',')) {
1068
+ const values = part.split(',').map(n => parseInt(n, 10));
1069
+ for (const val of values) {
1070
+ if (isNaN(val) || val < range.min || val > range.max) {
1071
+ return { valid: false, error: `Invalid value in ${range.name} list: ${val}` };
1072
+ }
1073
+ }
1074
+ continue;
1075
+ }
1076
+
1077
+ // Check single value
1078
+ const val = parseInt(part, 10);
1079
+ if (isNaN(val) || val < range.min || val > range.max) {
1080
+ return { valid: false, error: `Invalid ${range.name}: ${part} (must be ${range.min}-${range.max})` };
1081
+ }
1082
+ }
1083
+
1084
+ return { valid: true };
1085
+ }
1086
+ }
1087
+
1088
+ /**
1089
+ * ScheduleManager - Manages WordPress native scheduling and content spreading
1090
+ *
1091
+ * Uses WordPress REST API to schedule posts for future publication:
1092
+ * - status: 'future' with date parameter
1093
+ * - Spreads posts across time to avoid publishing everything at once
1094
+ * - Respects publish_date from content front matter
1095
+ */
1096
+ class ScheduleManager {
1097
+ constructor(config) {
1098
+ this.config = config;
1099
+ this.scheduledPosts = [];
1100
+ }
1101
+
1102
+ /**
1103
+ * Calculate publish date for an item
1104
+ * @param {Object} item - Content item
1105
+ * @param {number} index - Item index in batch
1106
+ * @param {number} totalItems - Total items in batch
1107
+ * @returns {Date} Scheduled publish date
1108
+ */
1109
+ calculatePublishDate(item, index, totalItems) {
1110
+ // Check if item has explicit publish date in front matter
1111
+ if (item.metadata.publish_date || item.metadata.scheduled_date) {
1112
+ const explicitDate = new Date(item.metadata.publish_date || item.metadata.scheduled_date);
1113
+ if (!isNaN(explicitDate.getTime())) {
1114
+ return explicitDate;
1115
+ }
1116
+ }
1117
+
1118
+ // If publish immediately
1119
+ if (this.config.defaultPublishDelay === 0 && this.config.publishSpreadInterval === 0) {
1120
+ return new Date(); // Now
1121
+ }
1122
+
1123
+ // Calculate base time (now + default delay)
1124
+ const baseTime = new Date();
1125
+ baseTime.setHours(baseTime.getHours() + this.config.defaultPublishDelay);
1126
+
1127
+ // Apply spread interval if configured
1128
+ if (this.config.publishSpreadInterval > 0) {
1129
+ baseTime.setHours(baseTime.getHours() + (index * this.config.publishSpreadInterval));
1130
+ }
1131
+
1132
+ return baseTime;
1133
+ }
1134
+
1135
+ /**
1136
+ * Get WordPress post status based on publish date
1137
+ * @param {Date} publishDate - Scheduled publish date
1138
+ * @returns {string} WordPress post status ('publish' or 'future')
1139
+ */
1140
+ getWordPressStatus(publishDate) {
1141
+ const now = new Date();
1142
+ // If publish date is in the future (more than 1 minute from now), use 'future' status
1143
+ if (publishDate.getTime() > now.getTime() + 60000) {
1144
+ return 'future';
1145
+ }
1146
+ return 'publish';
1147
+ }
1148
+
1149
+ /**
1150
+ * Format date for WordPress REST API (ISO 8601)
1151
+ * @param {Date} date - Date to format
1152
+ * @returns {string} ISO 8601 formatted date
1153
+ */
1154
+ formatForWordPress(date) {
1155
+ return date.toISOString();
1156
+ }
1157
+
1158
+ /**
1159
+ * Get scheduling context for publishing
1160
+ * @param {Object} item - Content item
1161
+ * @param {number} index - Item index
1162
+ * @param {number} totalItems - Total items
1163
+ * @returns {Object} Scheduling context for publisher
1164
+ */
1165
+ getSchedulingContext(item, index, totalItems) {
1166
+ const publishDate = this.calculatePublishDate(item, index, totalItems);
1167
+ const status = this.getWordPressStatus(publishDate);
1168
+
1169
+ return {
1170
+ useWordPressScheduling: this.config.useWordPressScheduling,
1171
+ publishDate: this.formatForWordPress(publishDate),
1172
+ status: status,
1173
+ isScheduled: status === 'future',
1174
+ scheduledFor: publishDate.toLocaleString()
1175
+ };
1176
+ }
1177
+
1178
+ /**
1179
+ * Track scheduled post
1180
+ * @param {Object} item - Content item
1181
+ * @param {Object} result - Publishing result
1182
+ * @param {Object} scheduleContext - Scheduling context used
1183
+ */
1184
+ trackScheduledPost(item, result, scheduleContext) {
1185
+ this.scheduledPosts.push({
1186
+ title: item.metadata.title,
1187
+ url: result.url,
1188
+ scheduledFor: scheduleContext.publishDate,
1189
+ status: scheduleContext.status,
1190
+ platform: item.metadata.target_platform
1191
+ });
1192
+ }
1193
+
1194
+ /**
1195
+ * Get summary of scheduled posts
1196
+ * @returns {Object} Scheduling summary
1197
+ */
1198
+ getSummary() {
1199
+ const immediate = this.scheduledPosts.filter(p => p.status === 'publish');
1200
+ const future = this.scheduledPosts.filter(p => p.status === 'future');
1201
+
1202
+ return {
1203
+ total: this.scheduledPosts.length,
1204
+ publishedImmediately: immediate.length,
1205
+ scheduledForFuture: future.length,
1206
+ posts: this.scheduledPosts,
1207
+ nextScheduled: future.length > 0
1208
+ ? future.sort((a, b) => new Date(a.scheduledFor) - new Date(b.scheduledFor))[0]
1209
+ : null
1210
+ };
1211
+ }
1212
+
1213
+ /**
1214
+ * Generate scheduling report
1215
+ * @returns {string} Markdown report of scheduled posts
1216
+ */
1217
+ generateScheduleReport() {
1218
+ const summary = this.getSummary();
1219
+
1220
+ let report = `# Content Scheduling Report
1221
+ Generated: ${new Date().toISOString()}
1222
+
1223
+ ## Summary
1224
+ - Total Posts: ${summary.total}
1225
+ - Published Immediately: ${summary.publishedImmediately}
1226
+ - Scheduled for Future: ${summary.scheduledForFuture}
1227
+
1228
+ `;
1229
+
1230
+ if (summary.scheduledForFuture > 0) {
1231
+ report += `## Scheduled Posts
1232
+
1233
+ | Title | Scheduled For | Platform |
1234
+ |-------|---------------|----------|
1235
+ `;
1236
+ const futurePosts = this.scheduledPosts
1237
+ .filter(p => p.status === 'future')
1238
+ .sort((a, b) => new Date(a.scheduledFor) - new Date(b.scheduledFor));
1239
+
1240
+ for (const post of futurePosts) {
1241
+ const date = new Date(post.scheduledFor).toLocaleString();
1242
+ report += `| ${post.title} | ${date} | ${post.platform} |\n`;
1243
+ }
1244
+ report += '\n';
1245
+ }
1246
+
1247
+ if (summary.publishedImmediately > 0) {
1248
+ report += `## Published Immediately
1249
+
1250
+ | Title | URL | Platform |
1251
+ |-------|-----|----------|
1252
+ `;
1253
+ const immediatePosts = this.scheduledPosts.filter(p => p.status === 'publish');
1254
+
1255
+ for (const post of immediatePosts) {
1256
+ report += `| ${post.title} | ${post.url} | ${post.platform} |\n`;
1257
+ }
1258
+ }
1259
+
1260
+ return report;
1261
+ }
1262
+ }
1263
+
1264
+ /**
1265
+ * ContentCoordinator class
1266
+ * Manages the state machine for content production workflow
1267
+ */
1268
+ class ContentCoordinator {
1269
+ /**
1270
+ * Create a new ContentCoordinator instance
1271
+ * @param {Object} options - Configuration options
1272
+ */
1273
+ constructor(options = {}) {
1274
+ this.config = { ...DEFAULT_CONFIG, ...options };
1275
+ this.workDir = options.workDir;
1276
+ this.state = this.createInitialState();
1277
+ this.checkpointTimer = null;
1278
+
1279
+ // Initialize managers
1280
+ this.contentRules = new ContentRulesManager(this.config.contentRulesPath);
1281
+ this.analytics = new AnalyticsTracker();
1282
+ this.webhook = new WebhookNotifier(this.config);
1283
+ this.queue = new QueueManager(
1284
+ path.join(this.config.outputDir, this.config.queueFileName)
1285
+ );
1286
+ this.publisher = new PlatformPublisher(this.config);
1287
+ this.cronHelper = new CronHelper(this.config);
1288
+ this.scheduler = new ScheduleManager(this.config);
1289
+
1290
+ this.callbacks = {
1291
+ onProgress: null,
1292
+ onPhaseChange: null,
1293
+ onItemComplete: null,
1294
+ onError: null,
1295
+ onConfirmationNeeded: null
1296
+ };
1297
+ }
1298
+
1299
+ /**
1300
+ * Create initial state object
1301
+ * @returns {Object} Initial state
1302
+ */
1303
+ createInitialState() {
1304
+ return {
1305
+ phase: PHASES.INITIALIZE,
1306
+ startedAt: null,
1307
+ lastUpdated: null,
1308
+ items: [],
1309
+ results: {
1310
+ ready: [],
1311
+ needsReview: [],
1312
+ failed: [],
1313
+ published: []
1314
+ },
1315
+ reports: {
1316
+ readyForPublishing: null,
1317
+ needsReview: null,
1318
+ analytics: null
1319
+ },
1320
+ stats: {
1321
+ totalItems: 0,
1322
+ verified: 0,
1323
+ ready: 0,
1324
+ needsReview: 0,
1325
+ published: 0,
1326
+ failed: 0
1327
+ },
1328
+ errors: [],
1329
+ checkpoints: [],
1330
+ contentRulesLoaded: false
1331
+ };
1332
+ }
1333
+
1334
+ /**
1335
+ * Set callback functions
1336
+ * @param {Object} callbacks - Callback functions
1337
+ */
1338
+ setCallbacks(callbacks) {
1339
+ this.callbacks = { ...this.callbacks, ...callbacks };
1340
+ }
1341
+
1342
+ /**
1343
+ * Load state from checkpoint file
1344
+ * @returns {Promise<boolean>} Whether state was loaded
1345
+ */
1346
+ async loadCheckpoint() {
1347
+ const statePath = path.join(this.config.outputDir, this.config.stateFileName);
1348
+ try {
1349
+ const data = await fs.readFile(statePath, 'utf8');
1350
+ this.state = JSON.parse(data);
1351
+ this.log('Loaded checkpoint from', statePath);
1352
+ return true;
1353
+ } catch (err) {
1354
+ if (err.code !== 'ENOENT') {
1355
+ this.logError('Error loading checkpoint:', err.message);
1356
+ }
1357
+ return false;
1358
+ }
1359
+ }
1360
+
1361
+ /**
1362
+ * Save state to checkpoint file
1363
+ * @returns {Promise<void>}
1364
+ */
1365
+ async saveCheckpoint() {
1366
+ const statePath = path.join(this.config.outputDir, this.config.stateFileName);
1367
+ this.state.lastUpdated = new Date().toISOString();
1368
+ this.state.checkpoints.push({
1369
+ phase: this.state.phase,
1370
+ timestamp: this.state.lastUpdated,
1371
+ stats: { ...this.state.stats }
1372
+ });
1373
+
1374
+ try {
1375
+ await fs.writeFile(statePath, JSON.stringify(this.state, null, 2));
1376
+ this.log('Saved checkpoint to', statePath);
1377
+ } catch (err) {
1378
+ this.logError('Error saving checkpoint:', err.message);
1379
+ }
1380
+ }
1381
+
1382
+ /**
1383
+ * Clear checkpoint file after successful completion
1384
+ * @returns {Promise<void>}
1385
+ */
1386
+ async clearCheckpoint() {
1387
+ const statePath = path.join(this.config.outputDir, this.config.stateFileName);
1388
+ try {
1389
+ await fs.unlink(statePath);
1390
+ this.log('Cleared checkpoint file');
1391
+ } catch (err) {
1392
+ if (err.code !== 'ENOENT') {
1393
+ this.logError('Error clearing checkpoint:', err.message);
1394
+ }
1395
+ }
1396
+ }
1397
+
1398
+ /**
1399
+ * Load content rules
1400
+ * @returns {Promise<void>}
1401
+ */
1402
+ async loadContentRules() {
1403
+ // Try default locations if not specified
1404
+ const possiblePaths = [
1405
+ this.config.contentRulesPath,
1406
+ path.join(this.workDir, 'content-rules.md'),
1407
+ path.join(this.workDir, '..', 'content-rules.md'),
1408
+ path.join(process.cwd(), 'content-rules.md')
1409
+ ].filter(Boolean);
1410
+
1411
+ for (const rulesPath of possiblePaths) {
1412
+ this.contentRules = new ContentRulesManager(rulesPath);
1413
+ const rules = await this.contentRules.load();
1414
+ if (rules) {
1415
+ this.log('Loaded content rules from', rulesPath);
1416
+ this.state.contentRulesLoaded = true;
1417
+ return;
1418
+ }
1419
+ }
1420
+
1421
+ this.log('No content-rules.md found, proceeding without brand voice rules');
1422
+ }
1423
+
1424
+ /**
1425
+ * Parse content file front matter
1426
+ * @param {string} filePath - Path to content file
1427
+ * @returns {Promise<Object>} Parsed content item
1428
+ */
1429
+ async parseContentFile(filePath) {
1430
+ const content = await fs.readFile(filePath, 'utf8');
1431
+ const item = {
1432
+ id: path.basename(filePath, path.extname(filePath)),
1433
+ filePath,
1434
+ status: ITEM_STATUS.PENDING,
1435
+ metadata: {},
1436
+ content: '',
1437
+ verification: null,
1438
+ publishedUrl: null,
1439
+ errors: [],
1440
+ timing: {}
1441
+ };
1442
+
1443
+ // Parse YAML front matter
1444
+ const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
1445
+
1446
+ if (frontMatterMatch) {
1447
+ const frontMatter = frontMatterMatch[1];
1448
+ item.content = frontMatterMatch[2].trim();
1449
+
1450
+ // Parse front matter fields
1451
+ const lines = frontMatter.split('\n');
1452
+ let currentKey = null;
1453
+ let arrayMode = false;
1454
+
1455
+ for (const line of lines) {
1456
+ // Check for array continuation
1457
+ if (arrayMode && line.match(/^\s+-\s+/)) {
1458
+ const value = line.replace(/^\s+-\s+/, '').trim().replace(/^["']|["']$/g, '');
1459
+ if (!Array.isArray(item.metadata[currentKey])) {
1460
+ item.metadata[currentKey] = [];
1461
+ }
1462
+ item.metadata[currentKey].push(value);
1463
+ continue;
1464
+ }
1465
+
1466
+ arrayMode = false;
1467
+ const colonIndex = line.indexOf(':');
1468
+ if (colonIndex > 0) {
1469
+ const key = line.slice(0, colonIndex).trim().toLowerCase().replace(/\s+/g, '_');
1470
+ let value = line.slice(colonIndex + 1).trim();
1471
+
1472
+ // Check if this starts an array
1473
+ if (value === '' || value === '[]') {
1474
+ currentKey = key;
1475
+ arrayMode = true;
1476
+ item.metadata[key] = [];
1477
+ continue;
1478
+ }
1479
+
1480
+ // Remove quotes if present
1481
+ if ((value.startsWith('"') && value.endsWith('"')) ||
1482
+ (value.startsWith("'") && value.endsWith("'"))) {
1483
+ value = value.slice(1, -1);
1484
+ }
1485
+
1486
+ item.metadata[key] = value;
1487
+ currentKey = key;
1488
+ }
1489
+ }
1490
+ } else {
1491
+ // No front matter, treat entire content as body
1492
+ item.content = content.trim();
1493
+
1494
+ // Try to extract title from first heading
1495
+ const headingMatch = content.match(/^#\s+(.+)$/m);
1496
+ if (headingMatch) {
1497
+ item.metadata.title = headingMatch[1];
1498
+ }
1499
+ }
1500
+
1501
+ // Set defaults for required fields
1502
+ item.metadata.title = item.metadata.title || path.basename(filePath, path.extname(filePath));
1503
+ item.metadata.status = item.metadata.status || 'pending';
1504
+ item.metadata.target_platform = item.metadata.target_platform || this.config.defaultPlatform;
1505
+ item.metadata.priority = item.metadata.priority || 'normal';
1506
+
1507
+ return item;
1508
+ }
1509
+
1510
+ /**
1511
+ * Discover content files in work directory
1512
+ * @returns {Promise<string[]>} Array of file paths
1513
+ */
1514
+ async discoverContentFiles() {
1515
+ const files = [];
1516
+ const entries = await fs.readdir(this.workDir, { withFileTypes: true });
1517
+
1518
+ for (const entry of entries) {
1519
+ if (entry.isFile() && /\.(md|markdown)$/i.test(entry.name)) {
1520
+ // Skip system files
1521
+ if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
1522
+ files.push(path.join(this.workDir, entry.name));
1523
+ }
1524
+ }
1525
+
1526
+ return files.sort();
1527
+ }
1528
+
1529
+ /**
1530
+ * Phase 1: Initialize - Load and parse content files
1531
+ * @returns {Promise<void>}
1532
+ */
1533
+ async phaseInitialize() {
1534
+ this.setPhase(PHASES.INITIALIZE);
1535
+ this.analytics.startWorkflow();
1536
+ this.analytics.startPhase(PHASES.INITIALIZE);
1537
+ this.state.startedAt = new Date().toISOString();
1538
+
1539
+ // Load content rules
1540
+ await this.loadContentRules();
1541
+
1542
+ // Load queue if exists
1543
+ await this.queue.load();
1544
+
1545
+ const files = await this.discoverContentFiles();
1546
+ this.log(`Found ${files.length} content files in ${this.workDir}`);
1547
+
1548
+ for (const filePath of files) {
1549
+ try {
1550
+ const item = await this.parseContentFile(filePath);
1551
+ this.state.items.push(item);
1552
+ } catch (err) {
1553
+ this.logError(`Error parsing ${filePath}:`, err.message);
1554
+ this.state.items.push({
1555
+ id: path.basename(filePath),
1556
+ filePath,
1557
+ status: ITEM_STATUS.FAILED,
1558
+ metadata: { title: path.basename(filePath) },
1559
+ errors: [err.message],
1560
+ timing: {}
1561
+ });
1562
+ }
1563
+ }
1564
+
1565
+ this.state.stats.totalItems = this.state.items.length;
1566
+ this.analytics.setTotalItems(this.state.items.length);
1567
+ this.analytics.endPhase(PHASES.INITIALIZE);
1568
+
1569
+ // Send webhook notification
1570
+ await this.webhook.notifyStart({
1571
+ totalItems: this.state.stats.totalItems,
1572
+ workDir: this.workDir,
1573
+ contentRulesLoaded: this.state.contentRulesLoaded
1574
+ });
1575
+
1576
+ await this.saveCheckpoint();
1577
+ }
1578
+
1579
+ /**
1580
+ * Phase 2: Verify - Run content verification
1581
+ * @param {Function} verifyFn - Function to verify a single item
1582
+ * @returns {Promise<void>}
1583
+ */
1584
+ async phaseVerify(verifyFn) {
1585
+ this.setPhase(PHASES.VERIFY);
1586
+ this.analytics.startPhase(PHASES.VERIFY);
1587
+
1588
+ const pendingItems = this.state.items.filter(
1589
+ item => item.status === ITEM_STATUS.PENDING
1590
+ );
1591
+
1592
+ this.log(`Verifying ${pendingItems.length} items with concurrency ${this.config.concurrency}`);
1593
+
1594
+ // Get content rules context for verification
1595
+ const rulesContext = this.contentRules.getVerificationContext();
1596
+
1597
+ // Process in batches based on concurrency
1598
+ for (let i = 0; i < pendingItems.length; i += this.config.concurrency) {
1599
+ const batch = pendingItems.slice(i, i + this.config.concurrency);
1600
+
1601
+ await Promise.all(batch.map(async (item) => {
1602
+ item.status = ITEM_STATUS.VERIFYING;
1603
+ const startTime = Date.now();
1604
+
1605
+ try {
1606
+ // Pass content rules context to verifier
1607
+ const result = await this.withRetry(() => verifyFn(item, rulesContext));
1608
+ const duration = Date.now() - startTime;
1609
+
1610
+ item.verification = result;
1611
+ item.status = ITEM_STATUS.VERIFIED;
1612
+ item.timing.verification = duration;
1613
+ this.state.stats.verified++;
1614
+
1615
+ this.analytics.trackVerification(item, duration);
1616
+ this.notifyItemComplete(item, 'verified');
1617
+ } catch (err) {
1618
+ item.status = ITEM_STATUS.FAILED;
1619
+ item.errors.push(err.message);
1620
+ item.timing.verification = Date.now() - startTime;
1621
+ this.state.stats.failed++;
1622
+
1623
+ this.analytics.trackError(item, err, PHASES.VERIFY);
1624
+ this.notifyError(item, err);
1625
+ await this.webhook.notifyError({
1626
+ item: item.metadata.title,
1627
+ error: err.message,
1628
+ phase: PHASES.VERIFY
1629
+ });
1630
+ }
1631
+ }));
1632
+
1633
+ await this.saveCheckpoint();
1634
+ }
1635
+
1636
+ this.analytics.endPhase(PHASES.VERIFY);
1637
+ }
1638
+
1639
+ /**
1640
+ * Phase 3: Categorize - Sort items by verification results
1641
+ * @returns {Promise<void>}
1642
+ */
1643
+ async phaseCategorize() {
1644
+ this.setPhase(PHASES.CATEGORIZE);
1645
+ this.analytics.startPhase(PHASES.CATEGORIZE);
1646
+
1647
+ for (const item of this.state.items) {
1648
+ if (item.status === ITEM_STATUS.FAILED) {
1649
+ this.state.results.failed.push(item);
1650
+ continue;
1651
+ }
1652
+
1653
+ if (item.status !== ITEM_STATUS.VERIFIED) {
1654
+ continue;
1655
+ }
1656
+
1657
+ const verification = item.verification || {};
1658
+ const score = (verification.redundancyScore || '').toLowerCase();
1659
+ const recommendation = (verification.recommendation || '').toLowerCase();
1660
+
1661
+ // Check if ready for publishing
1662
+ const isLowRedundancy = [REDUNDANCY_THRESHOLDS.MINIMAL, REDUNDANCY_THRESHOLDS.LOW].includes(score);
1663
+ const isProceedRecommended = recommendation.includes('proceed');
1664
+
1665
+ if (isLowRedundancy && isProceedRecommended) {
1666
+ item.status = ITEM_STATUS.READY;
1667
+ this.state.results.ready.push(item);
1668
+ this.state.stats.ready++;
1669
+ } else {
1670
+ item.status = ITEM_STATUS.NEEDS_REVIEW;
1671
+ this.state.results.needsReview.push(item);
1672
+ this.state.stats.needsReview++;
1673
+ }
1674
+ }
1675
+
1676
+ this.analytics.endPhase(PHASES.CATEGORIZE);
1677
+ await this.saveCheckpoint();
1678
+ }
1679
+
1680
+ /**
1681
+ * Phase 4: Report - Generate output reports
1682
+ * @returns {Promise<void>}
1683
+ */
1684
+ async phaseReport() {
1685
+ this.setPhase(PHASES.REPORT);
1686
+ this.analytics.startPhase(PHASES.REPORT);
1687
+
1688
+ const timestamp = this.getTimestamp();
1689
+
1690
+ // Generate Ready for Publishing report
1691
+ if (this.state.results.ready.length > 0) {
1692
+ const readyReport = this.generateReadyReport(timestamp);
1693
+ const readyPath = path.join(
1694
+ this.config.outputDir,
1695
+ `ready-for-publishing-${timestamp}.md`
1696
+ );
1697
+ await fs.writeFile(readyPath, readyReport);
1698
+ this.state.reports.readyForPublishing = readyPath;
1699
+ this.log('Generated report:', readyPath);
1700
+ }
1701
+
1702
+ // Generate Needs Review report
1703
+ if (this.state.results.needsReview.length > 0 || this.state.results.failed.length > 0) {
1704
+ const reviewReport = this.generateNeedsReviewReport(timestamp);
1705
+ const reviewPath = path.join(
1706
+ this.config.outputDir,
1707
+ `needs-review-${timestamp}.md`
1708
+ );
1709
+ await fs.writeFile(reviewPath, reviewReport);
1710
+ this.state.reports.needsReview = reviewPath;
1711
+ this.log('Generated report:', reviewPath);
1712
+ }
1713
+
1714
+ // Generate analytics report if enabled
1715
+ if (this.config.enableAnalytics) {
1716
+ const analyticsReport = this.generateAnalyticsReport(timestamp);
1717
+ const analyticsPath = path.join(
1718
+ this.config.outputDir,
1719
+ `analytics-${timestamp}.md`
1720
+ );
1721
+ await fs.writeFile(analyticsPath, analyticsReport);
1722
+ this.state.reports.analytics = analyticsPath;
1723
+ this.log('Generated analytics report:', analyticsPath);
1724
+ }
1725
+
1726
+ this.analytics.endPhase(PHASES.REPORT);
1727
+ await this.saveCheckpoint();
1728
+ }
1729
+
1730
+ /**
1731
+ * Phase 5: Notify - Inform user of results
1732
+ * @returns {Promise<Object>} Summary for user notification
1733
+ */
1734
+ async phaseNotify() {
1735
+ this.setPhase(PHASES.NOTIFY);
1736
+
1737
+ return {
1738
+ summary: {
1739
+ total: this.state.stats.totalItems,
1740
+ ready: this.state.stats.ready,
1741
+ needsReview: this.state.stats.needsReview,
1742
+ failed: this.state.stats.failed
1743
+ },
1744
+ reports: this.state.reports,
1745
+ readyItems: this.state.results.ready.map(item => ({
1746
+ title: item.metadata.title,
1747
+ score: item.verification?.redundancyScore,
1748
+ recommendation: item.verification?.recommendation,
1749
+ platform: item.metadata.target_platform
1750
+ })),
1751
+ needsReviewItems: this.state.results.needsReview.map(item => ({
1752
+ title: item.metadata.title,
1753
+ score: item.verification?.redundancyScore,
1754
+ reason: item.verification?.feedback || 'Requires manual review'
1755
+ })),
1756
+ contentRulesApplied: this.state.contentRulesLoaded
1757
+ };
1758
+ }
1759
+
1760
+ /**
1761
+ * Phase 6: Publish - Publish approved content
1762
+ * @param {Function} publishFn - Function to publish a single item
1763
+ * @returns {Promise<void>}
1764
+ */
1765
+ async phasePublish(publishFn) {
1766
+ if (this.config.dryRun) {
1767
+ this.log('Dry run mode - skipping publish phase');
1768
+ this.setPhase(PHASES.COMPLETE);
1769
+ return;
1770
+ }
1771
+
1772
+ this.setPhase(PHASES.PUBLISH);
1773
+ this.analytics.startPhase(PHASES.PUBLISH);
1774
+
1775
+ const readyItems = this.state.results.ready.filter(
1776
+ item => item.status === ITEM_STATUS.READY
1777
+ );
1778
+
1779
+ // Get publishing context from content rules
1780
+ const publishContext = this.contentRules.getPublishingContext();
1781
+
1782
+ this.log(`Publishing ${readyItems.length} items with concurrency ${this.config.concurrency}`);
1783
+
1784
+ // Process in batches based on concurrency
1785
+ for (let i = 0; i < readyItems.length; i += this.config.concurrency) {
1786
+ const batch = readyItems.slice(i, i + this.config.concurrency);
1787
+ const batchStartIndex = i;
1788
+
1789
+ await Promise.all(batch.map(async (item, batchIndex) => {
1790
+ item.status = ITEM_STATUS.PUBLISHING;
1791
+ const startTime = Date.now();
1792
+ const itemIndex = batchStartIndex + batchIndex;
1793
+
1794
+ try {
1795
+ // Get platform-specific publisher
1796
+ const platform = item.metadata.target_platform || this.config.defaultPlatform;
1797
+ const platformPublisher = this.publisher.getPublisher(platform);
1798
+
1799
+ // Get scheduling context for WordPress native scheduling
1800
+ const scheduleContext = this.scheduler.getSchedulingContext(
1801
+ item,
1802
+ itemIndex,
1803
+ readyItems.length
1804
+ );
1805
+
1806
+ // Publish with platform context, content rules, and scheduling
1807
+ const result = await this.withRetry(() =>
1808
+ platformPublisher.publish(item, (item, platformContext) =>
1809
+ publishFn(item, {
1810
+ ...platformContext,
1811
+ contentRules: publishContext,
1812
+ scheduling: scheduleContext
1813
+ })
1814
+ )
1815
+ );
1816
+
1817
+ const duration = Date.now() - startTime;
1818
+
1819
+ item.publishedUrl = result.url;
1820
+ item.publishedAt = new Date().toISOString();
1821
+ item.scheduledFor = scheduleContext.isScheduled ? scheduleContext.publishDate : null;
1822
+ item.status = scheduleContext.isScheduled ? ITEM_STATUS.PUBLISHED : ITEM_STATUS.PUBLISHED;
1823
+ item.timing.publishing = duration;
1824
+ this.state.results.published.push(item);
1825
+ this.state.stats.published++;
1826
+
1827
+ // Track in scheduler
1828
+ this.scheduler.trackScheduledPost(item, result, scheduleContext);
1829
+
1830
+ this.analytics.trackPublishing(item, duration);
1831
+ this.notifyItemComplete(item, scheduleContext.isScheduled ? 'scheduled' : 'published');
1832
+
1833
+ // Update queue status
1834
+ await this.queue.updateItemStatus(item.id, 'published', {
1835
+ publishedUrl: result.url,
1836
+ publishedAt: item.publishedAt,
1837
+ scheduledFor: item.scheduledFor
1838
+ });
1839
+
1840
+ // Webhook notification
1841
+ await this.webhook.notifyPublished({
1842
+ title: item.metadata.title,
1843
+ url: result.url,
1844
+ platform,
1845
+ scheduled: scheduleContext.isScheduled,
1846
+ scheduledFor: scheduleContext.scheduledFor
1847
+ });
1848
+
1849
+ } catch (err) {
1850
+ item.status = ITEM_STATUS.FAILED;
1851
+ item.errors.push(err.message);
1852
+ item.timing.publishing = Date.now() - startTime;
1853
+ this.state.stats.failed++;
1854
+
1855
+ this.analytics.trackError(item, err, PHASES.PUBLISH);
1856
+ this.notifyError(item, err);
1857
+
1858
+ await this.queue.updateItemStatus(item.id, 'failed', {
1859
+ error: err.message
1860
+ });
1861
+
1862
+ await this.webhook.notifyError({
1863
+ item: item.metadata.title,
1864
+ error: err.message,
1865
+ phase: PHASES.PUBLISH
1866
+ });
1867
+ }
1868
+ }));
1869
+
1870
+ await this.saveCheckpoint();
1871
+ }
1872
+
1873
+ this.analytics.endPhase(PHASES.PUBLISH);
1874
+ this.setPhase(PHASES.COMPLETE);
1875
+ }
1876
+
1877
+ /**
1878
+ * Run the complete workflow
1879
+ * @param {Object} handlers - Phase handlers
1880
+ * @returns {Promise<Object>} Final results
1881
+ */
1882
+ async run(handlers) {
1883
+ const { verify, publish, confirm } = handlers;
1884
+
1885
+ // Cron mode: acquire lock and check run interval
1886
+ if (this.config.cronMode) {
1887
+ const lockAcquired = await this.cronHelper.acquireLock();
1888
+ if (!lockAcquired) {
1889
+ if (!this.config.quietMode) {
1890
+ console.log('[ContentCoordinator] Another instance is running, exiting');
1891
+ }
1892
+ return { skipped: true, reason: 'lock_held' };
1893
+ }
1894
+
1895
+ const intervalCheck = await this.cronHelper.checkRunInterval();
1896
+ if (!intervalCheck.canRun) {
1897
+ await this.cronHelper.releaseLock();
1898
+ if (!this.config.quietMode) {
1899
+ console.log(`[ContentCoordinator] Minimum interval not met, next run at ${intervalCheck.nextRunTime}`);
1900
+ }
1901
+ return { skipped: true, reason: 'interval_not_met', nextRunTime: intervalCheck.nextRunTime };
1902
+ }
1903
+ }
1904
+
1905
+ try {
1906
+ // Try to resume from checkpoint
1907
+ const resumed = await this.loadCheckpoint();
1908
+ if (resumed && this.state.phase !== PHASES.INITIALIZE) {
1909
+ this.log(`Resuming from phase: ${this.state.phase}`);
1910
+ }
1911
+
1912
+ // Run phases based on current state
1913
+ if (this.state.phase === PHASES.INITIALIZE) {
1914
+ await this.phaseInitialize();
1915
+ }
1916
+
1917
+ if (this.state.phase === PHASES.INITIALIZE || this.state.phase === PHASES.VERIFY) {
1918
+ await this.phaseVerify(verify);
1919
+ }
1920
+
1921
+ if ([PHASES.INITIALIZE, PHASES.VERIFY, PHASES.CATEGORIZE].includes(this.state.phase)) {
1922
+ await this.phaseCategorize();
1923
+ }
1924
+
1925
+ if ([PHASES.INITIALIZE, PHASES.VERIFY, PHASES.CATEGORIZE, PHASES.REPORT].includes(this.state.phase)) {
1926
+ await this.phaseReport();
1927
+ }
1928
+
1929
+ const notification = await this.phaseNotify();
1930
+
1931
+ // Request confirmation if not in force mode (skip in cron mode)
1932
+ if (!this.config.force && !this.config.cronMode && this.state.results.ready.length > 0) {
1933
+ if (confirm) {
1934
+ const confirmed = await confirm(notification);
1935
+ if (!confirmed) {
1936
+ this.log('Publishing cancelled by user');
1937
+ this.analytics.endWorkflow();
1938
+ if (this.config.cronMode) {
1939
+ await this.cronHelper.releaseLock();
1940
+ }
1941
+ return this.getFinalResults();
1942
+ }
1943
+ }
1944
+ }
1945
+
1946
+ if (this.state.results.ready.length > 0) {
1947
+ await this.phasePublish(publish);
1948
+ }
1949
+
1950
+ // End workflow tracking
1951
+ this.analytics.endWorkflow();
1952
+
1953
+ // Clear checkpoint on successful completion
1954
+ await this.clearCheckpoint();
1955
+
1956
+ // Final webhook notification
1957
+ const results = this.getFinalResults();
1958
+
1959
+ // Add scheduling summary to results
1960
+ results.scheduling = this.scheduler.getSummary();
1961
+
1962
+ await this.webhook.notifyComplete(results);
1963
+
1964
+ // Cron mode: record run and release lock
1965
+ if (this.config.cronMode) {
1966
+ await this.cronHelper.recordRun(results);
1967
+ await this.cronHelper.releaseLock();
1968
+ }
1969
+
1970
+ return results;
1971
+
1972
+ } catch (err) {
1973
+ this.logError('Workflow error:', err.message);
1974
+ this.analytics.endWorkflow();
1975
+ await this.saveCheckpoint();
1976
+ await this.webhook.notifyError({
1977
+ error: err.message,
1978
+ phase: this.state.phase
1979
+ });
1980
+
1981
+ // Cron mode: release lock on error
1982
+ if (this.config.cronMode) {
1983
+ await this.cronHelper.releaseLock();
1984
+ }
1985
+
1986
+ throw err;
1987
+ }
1988
+ }
1989
+
1990
+ /**
1991
+ * Get final results summary
1992
+ * @returns {Object} Final results
1993
+ */
1994
+ getFinalResults() {
1995
+ return {
1996
+ success: this.state.stats.failed === 0,
1997
+ stats: this.state.stats,
1998
+ reports: this.state.reports,
1999
+ published: this.state.results.published.map(item => ({
2000
+ title: item.metadata.title,
2001
+ url: item.publishedUrl,
2002
+ platform: item.metadata.target_platform
2003
+ })),
2004
+ needsReview: this.state.results.needsReview.map(item => ({
2005
+ title: item.metadata.title,
2006
+ reason: item.verification?.feedback || 'Requires review'
2007
+ })),
2008
+ failed: this.state.results.failed.map(item => ({
2009
+ title: item.metadata.title,
2010
+ errors: item.errors
2011
+ })),
2012
+ duration: this.calculateDuration(),
2013
+ analytics: this.config.enableAnalytics ? this.analytics.getSummary() : null,
2014
+ contentRulesApplied: this.state.contentRulesLoaded
2015
+ };
2016
+ }
2017
+
2018
+ /**
2019
+ * Generate ready for publishing report
2020
+ * @param {string} timestamp - Report timestamp
2021
+ * @returns {string} Markdown report
2022
+ */
2023
+ generateReadyReport(timestamp) {
2024
+ const items = this.state.results.ready;
2025
+ const totalWords = items.reduce((sum, item) => {
2026
+ return sum + (item.content?.split(/\s+/).length || 0);
2027
+ }, 0);
2028
+
2029
+ let report = `# Content Ready for Publishing
2030
+ Generated: ${this.formatTimestamp(timestamp)}
2031
+
2032
+ ## Summary
2033
+ - Total Items: ${items.length}
2034
+ - Total Word Count: ~${Math.round(totalWords / 1000)}K words
2035
+ - Average Redundancy Score: ${this.calculateAverageScore(items)}
2036
+ - Content Rules Applied: ${this.state.contentRulesLoaded ? 'Yes' : 'No'}
2037
+
2038
+ ## Items
2039
+
2040
+ `;
2041
+
2042
+ items.forEach((item, index) => {
2043
+ const v = item.verification || {};
2044
+ report += `### ${index + 1}. ${item.metadata.title}
2045
+ - **File**: ${path.basename(item.filePath)}
2046
+ - **Redundancy Score**: ${v.redundancyScore || 'N/A'}
2047
+ - **Recommendation**: ${v.recommendation || 'N/A'}
2048
+ - **Word Count**: ~${item.content?.split(/\s+/).length || 0} words
2049
+ - **Verifier Feedback**: ${v.feedback || 'No feedback provided'}
2050
+ - **Target Platform**: ${item.metadata.target_platform || 'wordpress'}
2051
+ - **Priority**: ${item.metadata.priority || 'normal'}
2052
+
2053
+ `;
2054
+ });
2055
+
2056
+ return report;
2057
+ }
2058
+
2059
+ /**
2060
+ * Generate needs review report
2061
+ * @param {string} timestamp - Report timestamp
2062
+ * @returns {string} Markdown report
2063
+ */
2064
+ generateNeedsReviewReport(timestamp) {
2065
+ const reviewItems = this.state.results.needsReview;
2066
+ const failedItems = this.state.results.failed;
2067
+
2068
+ let report = `# Content Requiring Review
2069
+ Generated: ${this.formatTimestamp(timestamp)}
2070
+
2071
+ ## Summary
2072
+ - Items Needing Review: ${reviewItems.length}
2073
+ - Failed Items: ${failedItems.length}
2074
+ - Total: ${reviewItems.length + failedItems.length}
2075
+
2076
+ `;
2077
+
2078
+ if (reviewItems.length > 0) {
2079
+ report += `## Items Needing Review
2080
+
2081
+ `;
2082
+ reviewItems.forEach((item, index) => {
2083
+ const v = item.verification || {};
2084
+ report += `### ${index + 1}. ${item.metadata.title}
2085
+ - **File**: ${path.basename(item.filePath)}
2086
+ - **Redundancy Score**: ${v.redundancyScore || 'N/A'}
2087
+ - **Recommendation**: ${v.recommendation || 'N/A'}
2088
+ - **Issue**: ${this.getReviewReason(item)}
2089
+ - **Verifier Feedback**: ${v.feedback || 'No feedback provided'}
2090
+ - **Suggested Actions**:
2091
+ - Review and revise proprietary content
2092
+ - Ensure content adds unique value
2093
+ - Re-submit for verification after changes
2094
+
2095
+ `;
2096
+ });
2097
+ }
2098
+
2099
+ if (failedItems.length > 0) {
2100
+ report += `## Failed Items
2101
+
2102
+ `;
2103
+ failedItems.forEach((item, index) => {
2104
+ report += `### ${index + 1}. ${item.metadata.title}
2105
+ - **File**: ${path.basename(item.filePath)}
2106
+ - **Errors**:
2107
+ ${item.errors.map(e => ` - ${e}`).join('\n')}
2108
+ - **Suggested Actions**:
2109
+ - Review file format and content
2110
+ - Fix any parsing errors
2111
+ - Re-submit for processing
2112
+
2113
+ `;
2114
+ });
2115
+ }
2116
+
2117
+ return report;
2118
+ }
2119
+
2120
+ /**
2121
+ * Generate analytics report
2122
+ * @param {string} timestamp - Report timestamp
2123
+ * @returns {string} Markdown report
2124
+ */
2125
+ generateAnalyticsReport(timestamp) {
2126
+ const analytics = this.analytics.getSummary();
2127
+
2128
+ let report = `# Content Coordination Analytics
2129
+ Generated: ${this.formatTimestamp(timestamp)}
2130
+
2131
+ ## Workflow Summary
2132
+ - **Total Duration**: ${analytics.summary.totalDuration}
2133
+ - **Success Rate**: ${analytics.summary.successRate}
2134
+ - **Average Quality Score**: ${analytics.summary.avgQualityScore}
2135
+
2136
+ ## Item Statistics
2137
+ | Metric | Count |
2138
+ |--------|-------|
2139
+ | Total Items | ${analytics.items.total} |
2140
+ | Verified | ${analytics.items.verified} |
2141
+ | Published | ${analytics.items.published} |
2142
+ | Failed | ${analytics.items.failed} |
2143
+
2144
+ ## Performance Metrics
2145
+ | Metric | Value |
2146
+ |--------|-------|
2147
+ | Avg Verification Time | ${analytics.summary.avgVerificationTime} |
2148
+ | Avg Publish Time | ${analytics.summary.avgPublishTime} |
2149
+ | Total Verification Time | ${this.analytics.formatDuration(analytics.performance.totalVerificationTime)} |
2150
+ | Total Publish Time | ${this.analytics.formatDuration(analytics.performance.totalPublishTime)} |
2151
+
2152
+ ## Phase Timing
2153
+ `;
2154
+
2155
+ for (const [phase, data] of Object.entries(analytics.workflow.phases)) {
2156
+ report += `- **${phase}**: ${this.analytics.formatDuration(data.duration)}\n`;
2157
+ }
2158
+
2159
+ report += `
2160
+ ## Quality Metrics
2161
+ - **Average Redundancy Score**: ${analytics.quality.avgRedundancyScore.toFixed(2)}/4 (lower is better)
2162
+ - **Average Uniqueness Score**: ${(analytics.quality.avgUniquenessScore * 100).toFixed(1)}%
2163
+
2164
+ `;
2165
+
2166
+ if (analytics.errors.length > 0) {
2167
+ report += `## Errors (${analytics.errors.length})
2168
+ `;
2169
+ analytics.errors.forEach((err, i) => {
2170
+ report += `${i + 1}. **${err.item}** (${err.phase}): ${err.error}\n`;
2171
+ });
2172
+ }
2173
+
2174
+ return report;
2175
+ }
2176
+
2177
+ /**
2178
+ * Get reason why item needs review
2179
+ * @param {Object} item - Content item
2180
+ * @returns {string} Review reason
2181
+ */
2182
+ getReviewReason(item) {
2183
+ const v = item.verification || {};
2184
+ const score = (v.redundancyScore || '').toLowerCase();
2185
+ const rec = (v.recommendation || '').toLowerCase();
2186
+
2187
+ if (score === 'high') return 'High redundancy - content may duplicate existing knowledge';
2188
+ if (score === 'medium') return 'Medium redundancy - consider adding more unique insights';
2189
+ if (rec.includes('reject')) return 'Content rejected by verifier';
2190
+ if (rec.includes('review')) return 'Manual review recommended';
2191
+
2192
+ return 'Content requires attention before publishing';
2193
+ }
2194
+
2195
+ /**
2196
+ * Calculate average redundancy score
2197
+ * @param {Object[]} items - Items to calculate from
2198
+ * @returns {string} Average score label
2199
+ */
2200
+ calculateAverageScore(items) {
2201
+ const scores = { minimal: 1, low: 2, medium: 3, high: 4 };
2202
+ let total = 0;
2203
+ let count = 0;
2204
+
2205
+ for (const item of items) {
2206
+ const score = (item.verification?.redundancyScore || '').toLowerCase();
2207
+ if (scores[score]) {
2208
+ total += scores[score];
2209
+ count++;
2210
+ }
2211
+ }
2212
+
2213
+ if (count === 0) return 'N/A';
2214
+
2215
+ const avg = total / count;
2216
+ if (avg <= 1.5) return 'Minimal';
2217
+ if (avg <= 2.5) return 'Low';
2218
+ if (avg <= 3.5) return 'Medium';
2219
+ return 'High';
2220
+ }
2221
+
2222
+ /**
2223
+ * Execute function with retry logic
2224
+ * @param {Function} fn - Function to execute
2225
+ * @returns {Promise<*>} Function result
2226
+ */
2227
+ async withRetry(fn) {
2228
+ let lastError;
2229
+
2230
+ for (let attempt = 1; attempt <= this.config.retryAttempts; attempt++) {
2231
+ try {
2232
+ return await fn();
2233
+ } catch (err) {
2234
+ lastError = err;
2235
+ if (attempt < this.config.retryAttempts) {
2236
+ this.log(`Retry attempt ${attempt}/${this.config.retryAttempts} after error:`, err.message);
2237
+ await this.sleep(this.config.retryDelay * attempt);
2238
+ }
2239
+ }
2240
+ }
2241
+
2242
+ throw lastError;
2243
+ }
2244
+
2245
+ /**
2246
+ * Set current phase and notify
2247
+ * @param {string} phase - New phase
2248
+ */
2249
+ setPhase(phase) {
2250
+ const previousPhase = this.state.phase;
2251
+ this.state.phase = phase;
2252
+
2253
+ if (this.callbacks.onPhaseChange) {
2254
+ this.callbacks.onPhaseChange(phase, previousPhase);
2255
+ }
2256
+
2257
+ this.log(`Phase: ${previousPhase} -> ${phase}`);
2258
+ }
2259
+
2260
+ /**
2261
+ * Notify item completion
2262
+ * @param {Object} item - Completed item
2263
+ * @param {string} action - Action completed
2264
+ */
2265
+ notifyItemComplete(item, action) {
2266
+ if (this.callbacks.onItemComplete) {
2267
+ this.callbacks.onItemComplete(item, action);
2268
+ }
2269
+
2270
+ if (this.callbacks.onProgress) {
2271
+ this.callbacks.onProgress(this.state.stats);
2272
+ }
2273
+ }
2274
+
2275
+ /**
2276
+ * Notify error
2277
+ * @param {Object} item - Item with error
2278
+ * @param {Error} error - Error object
2279
+ */
2280
+ notifyError(item, error) {
2281
+ this.state.errors.push({
2282
+ item: item.metadata.title,
2283
+ error: error.message,
2284
+ timestamp: new Date().toISOString()
2285
+ });
2286
+
2287
+ if (this.callbacks.onError) {
2288
+ this.callbacks.onError(item, error);
2289
+ }
2290
+ }
2291
+
2292
+ /**
2293
+ * Get timestamp string
2294
+ * @returns {string} Formatted timestamp
2295
+ */
2296
+ getTimestamp() {
2297
+ const now = new Date();
2298
+ return now.toISOString()
2299
+ .replace(/T/, '-')
2300
+ .replace(/:/g, '-')
2301
+ .replace(/\..+/, '');
2302
+ }
2303
+
2304
+ /**
2305
+ * Format timestamp for display
2306
+ * @param {string} timestamp - Timestamp to format
2307
+ * @returns {string} Human-readable timestamp
2308
+ */
2309
+ formatTimestamp(timestamp) {
2310
+ return timestamp.replace(/-/g, (m, i) => i > 9 ? ':' : '-').replace('-', ' ');
2311
+ }
2312
+
2313
+ /**
2314
+ * Calculate workflow duration
2315
+ * @returns {string} Duration string
2316
+ */
2317
+ calculateDuration() {
2318
+ if (!this.state.startedAt) return 'N/A';
2319
+
2320
+ const start = new Date(this.state.startedAt);
2321
+ const end = new Date();
2322
+ const ms = end - start;
2323
+
2324
+ const seconds = Math.floor(ms / 1000);
2325
+ const minutes = Math.floor(seconds / 60);
2326
+ const hours = Math.floor(minutes / 60);
2327
+
2328
+ if (hours > 0) return `${hours}h ${minutes % 60}m`;
2329
+ if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
2330
+ return `${seconds}s`;
2331
+ }
2332
+
2333
+ /**
2334
+ * Sleep for specified duration
2335
+ * @param {number} ms - Milliseconds to sleep
2336
+ * @returns {Promise<void>}
2337
+ */
2338
+ sleep(ms) {
2339
+ return new Promise(resolve => setTimeout(resolve, ms));
2340
+ }
2341
+
2342
+ /**
2343
+ * Log message if verbose mode
2344
+ * @param {...*} args - Log arguments
2345
+ */
2346
+ log(...args) {
2347
+ if (this.config.verbose) {
2348
+ console.log('[ContentCoordinator]', ...args);
2349
+ }
2350
+ }
2351
+
2352
+ /**
2353
+ * Log error
2354
+ * @param {...*} args - Error arguments
2355
+ */
2356
+ logError(...args) {
2357
+ console.error('[ContentCoordinator Error]', ...args);
2358
+ }
2359
+
2360
+ // ============================================
2361
+ // Queue Management Methods
2362
+ // ============================================
2363
+
2364
+ /**
2365
+ * Add item to persistent queue
2366
+ * @param {Object} item - Item to add
2367
+ * @returns {Promise<Object>} Added item
2368
+ */
2369
+ async addToQueue(item) {
2370
+ return this.queue.addItem(item);
2371
+ }
2372
+
2373
+ /**
2374
+ * Remove item from queue
2375
+ * @param {string} id - Item ID
2376
+ * @returns {Promise<boolean>} Success
2377
+ */
2378
+ async removeFromQueue(id) {
2379
+ return this.queue.removeItem(id);
2380
+ }
2381
+
2382
+ /**
2383
+ * Get queue statistics
2384
+ * @returns {Object} Queue stats
2385
+ */
2386
+ getQueueStats() {
2387
+ return this.queue.getStats();
2388
+ }
2389
+
2390
+ /**
2391
+ * Clear completed items from queue
2392
+ * @returns {Promise<number>} Number cleared
2393
+ */
2394
+ async clearCompletedFromQueue() {
2395
+ return this.queue.clearCompleted();
2396
+ }
2397
+
2398
+ /**
2399
+ * Get available publishing platforms
2400
+ * @returns {string[]} Platform names
2401
+ */
2402
+ getAvailablePlatforms() {
2403
+ return this.publisher.getAvailablePlatforms();
2404
+ }
2405
+
2406
+ // ============================================
2407
+ // Cron & Scheduling Methods
2408
+ // ============================================
2409
+
2410
+ /**
2411
+ * Generate crontab entry for automated scheduling
2412
+ * @param {Object} options - Crontab options
2413
+ * @returns {string} Crontab entry
2414
+ */
2415
+ generateCrontabEntry(options = {}) {
2416
+ return this.cronHelper.generateCrontabEntry({
2417
+ workDir: this.workDir || process.cwd(),
2418
+ ...options
2419
+ });
2420
+ }
2421
+
2422
+ /**
2423
+ * Get suggested cron schedules
2424
+ * @returns {Object[]} Array of schedule suggestions
2425
+ */
2426
+ getSuggestedSchedules() {
2427
+ return this.cronHelper.getSuggestedSchedules();
2428
+ }
2429
+
2430
+ /**
2431
+ * Validate a cron expression
2432
+ * @param {string} expression - Cron expression to validate
2433
+ * @returns {{valid: boolean, error?: string}}
2434
+ */
2435
+ validateCronExpression(expression) {
2436
+ return this.cronHelper.validateCronExpression(expression);
2437
+ }
2438
+
2439
+ /**
2440
+ * Get last run information
2441
+ * @returns {Promise<Object|null>}
2442
+ */
2443
+ async getLastRun() {
2444
+ return this.cronHelper.getLastRun();
2445
+ }
2446
+
2447
+ /**
2448
+ * Get scheduling summary
2449
+ * @returns {Object} Scheduling summary
2450
+ */
2451
+ getSchedulingSummary() {
2452
+ return this.scheduler.getSummary();
2453
+ }
2454
+
2455
+ /**
2456
+ * Generate scheduling report
2457
+ * @returns {string} Markdown scheduling report
2458
+ */
2459
+ generateScheduleReport() {
2460
+ return this.scheduler.generateScheduleReport();
2461
+ }
2462
+
2463
+ /**
2464
+ * Check if lock is currently held
2465
+ * @returns {Promise<boolean>}
2466
+ */
2467
+ async isLocked() {
2468
+ try {
2469
+ await fs.access(this.cronHelper.lockPath);
2470
+ return true;
2471
+ } catch {
2472
+ return false;
2473
+ }
2474
+ }
2475
+
2476
+ /**
2477
+ * Force release lock (use with caution)
2478
+ * @returns {Promise<void>}
2479
+ */
2480
+ async forceReleaseLock() {
2481
+ await this.cronHelper.releaseLock();
2482
+ }
2483
+
2484
+ /**
2485
+ * Setup crontab entry (prints instructions)
2486
+ * @param {Object} options - Setup options
2487
+ * @returns {string} Setup instructions
2488
+ */
2489
+ getCrontabSetupInstructions(options = {}) {
2490
+ const entry = this.generateCrontabEntry(options);
2491
+ const schedules = this.getSuggestedSchedules();
2492
+
2493
+ return `
2494
+ # Content Coordinator Crontab Setup
2495
+ # ==================================
2496
+
2497
+ ## Quick Setup
2498
+
2499
+ 1. Open crontab for editing:
2500
+ crontab -e
2501
+
2502
+ 2. Add one of the following entries:
2503
+
2504
+ ${entry}
2505
+
2506
+ ## Suggested Schedules
2507
+
2508
+ ${schedules.map(s => `### ${s.name}
2509
+ # ${s.description}
2510
+ # Schedule: ${s.schedule}
2511
+ `).join('\n')}
2512
+
2513
+ ## Environment Variables
2514
+
2515
+ Make sure these are set in your crontab or script:
2516
+ - WORDPRESS_URL
2517
+ - WORDPRESS_USERNAME
2518
+ - WORDPRESS_APP_PASSWORD
2519
+
2520
+ ## Log Rotation (optional)
2521
+
2522
+ Add to /etc/logrotate.d/content-coordinator:
2523
+ \`\`\`
2524
+ /var/log/content-coordinator.log {
2525
+ weekly
2526
+ rotate 4
2527
+ compress
2528
+ missingok
2529
+ notifempty
2530
+ }
2531
+ \`\`\`
2532
+
2533
+ ## Verify Setup
2534
+
2535
+ After adding, verify with:
2536
+ crontab -l
2537
+
2538
+ Check logs at:
2539
+ tail -f /var/log/content-coordinator.log
2540
+ `;
2541
+ }
2542
+ }
2543
+
2544
+ // Export for use in other modules
2545
+ export {
2546
+ ContentCoordinator,
2547
+ ContentRulesManager,
2548
+ AnalyticsTracker,
2549
+ WebhookNotifier,
2550
+ QueueManager,
2551
+ PlatformPublisher,
2552
+ CronHelper,
2553
+ ScheduleManager,
2554
+ PHASES,
2555
+ ITEM_STATUS,
2556
+ REDUNDANCY_THRESHOLDS,
2557
+ PLATFORMS,
2558
+ DEFAULT_CONFIG
2559
+ };
2560
+
2561
+ // Default export for convenience
2562
+ export default ContentCoordinator;