openclaw-telegram-manager 1.2.0 → 1.3.1

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 (48) hide show
  1. package/README.md +5 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +34 -2
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/include-generator.d.ts +1 -1
  6. package/dist/lib/include-generator.d.ts.map +1 -1
  7. package/dist/lib/include-generator.js +33 -2
  8. package/dist/lib/include-generator.js.map +1 -1
  9. package/dist/plugin.js +11106 -0
  10. package/dist/setup.js +29 -97
  11. package/dist/setup.js.map +1 -1
  12. package/package.json +4 -4
  13. package/src/commands/archive.ts +0 -89
  14. package/src/commands/doctor-all.ts +0 -243
  15. package/src/commands/doctor.ts +0 -100
  16. package/src/commands/help.ts +0 -11
  17. package/src/commands/init.ts +0 -376
  18. package/src/commands/list.ts +0 -28
  19. package/src/commands/rename.ts +0 -140
  20. package/src/commands/snooze.ts +0 -69
  21. package/src/commands/status.ts +0 -59
  22. package/src/commands/sync.ts +0 -46
  23. package/src/commands/upgrade.ts +0 -64
  24. package/src/index.ts +0 -54
  25. package/src/lib/audit.ts +0 -44
  26. package/src/lib/auth.ts +0 -96
  27. package/src/lib/capsule.ts +0 -206
  28. package/src/lib/config-restart.ts +0 -167
  29. package/src/lib/doctor-checks.ts +0 -639
  30. package/src/lib/include-generator.ts +0 -174
  31. package/src/lib/registry.ts +0 -197
  32. package/src/lib/security.ts +0 -174
  33. package/src/lib/telegram.ts +0 -311
  34. package/src/lib/types.ts +0 -172
  35. package/src/setup.ts +0 -558
  36. package/src/templates/base/COMMANDS.md +0 -3
  37. package/src/templates/base/CRON.md +0 -3
  38. package/src/templates/base/LINKS.md +0 -3
  39. package/src/templates/base/NOTES.md +0 -3
  40. package/src/templates/base/README.md +0 -3
  41. package/src/templates/base/TODO.md +0 -11
  42. package/src/templates/overlays/coding/ARCHITECTURE.md +0 -3
  43. package/src/templates/overlays/coding/DEPLOY.md +0 -3
  44. package/src/templates/overlays/marketing/CAMPAIGNS.md +0 -3
  45. package/src/templates/overlays/marketing/METRICS.md +0 -3
  46. package/src/templates/overlays/research/FINDINGS.md +0 -3
  47. package/src/templates/overlays/research/SOURCES.md +0 -3
  48. package/src/tool.ts +0 -282
@@ -1,639 +0,0 @@
1
- import * as fs from 'node:fs';
2
- import * as path from 'node:path';
3
- import {
4
- Severity,
5
- CAPSULE_VERSION,
6
- OVERLAY_FILES,
7
- SPAM_THRESHOLD,
8
- } from './types.js';
9
- import type { TopicEntry, DoctorCheckResult, Registry } from './types.js';
10
- import { jailCheck } from './security.js';
11
- import { computeRegistryHash, extractRegistryHash } from './include-generator.js';
12
-
13
- // ── Helper: check if a checkId should be ignored ───────────────────────
14
-
15
- function isIgnored(entry: TopicEntry, checkId: string): boolean {
16
- return entry.ignoreChecks.includes(checkId);
17
- }
18
-
19
- function check(
20
- severity: DoctorCheckResult['severity'],
21
- checkId: string,
22
- message: string,
23
- fixable: boolean,
24
- ): DoctorCheckResult {
25
- return { severity, checkId, message, fixable };
26
- }
27
-
28
- // ── Registry / mapping checks ──────────────────────────────────────────
29
-
30
- /**
31
- * Check that the registry entry's capsule path exists on disk.
32
- */
33
- export function runRegistryChecks(
34
- entry: TopicEntry,
35
- projectsBase: string,
36
- ): DoctorCheckResult[] {
37
- const results: DoctorCheckResult[] = [];
38
- const capsuleDir = path.join(projectsBase, entry.slug);
39
-
40
- // Check path exists
41
- if (!fs.existsSync(capsuleDir)) {
42
- results.push(
43
- check(Severity.ERROR, 'pathMissing', `Capsule path does not exist: projects/${entry.slug}/`, false),
44
- );
45
- return results; // No point checking further if path doesn't exist
46
- }
47
-
48
- // Check that the folder name matches the slug
49
- try {
50
- const stat = fs.statSync(capsuleDir);
51
- if (!stat.isDirectory()) {
52
- results.push(
53
- check(Severity.ERROR, 'pathNotDir', `projects/${entry.slug} exists but is not a directory`, false),
54
- );
55
- }
56
- } catch {
57
- results.push(
58
- check(Severity.ERROR, 'pathStatFailed', `Cannot stat projects/${entry.slug}/`, false),
59
- );
60
- }
61
-
62
- return results;
63
- }
64
-
65
- // ── Orphan detection ───────────────────────────────────────────────────
66
-
67
- /**
68
- * Check for capsule folders in projects/ that have no matching registry entry.
69
- */
70
- export function runOrphanCheck(
71
- projectsBase: string,
72
- registrySlugs: Set<string>,
73
- ): DoctorCheckResult[] {
74
- const results: DoctorCheckResult[] = [];
75
-
76
- let entries: fs.Dirent[];
77
- try {
78
- entries = fs.readdirSync(projectsBase, { withFileTypes: true });
79
- } catch {
80
- return results; // Can't read directory — skip
81
- }
82
-
83
- for (const dirent of entries) {
84
- if (!dirent.isDirectory()) continue;
85
- // Skip hidden dirs and special files
86
- if (dirent.name.startsWith('.') || dirent.name === 'audit.jsonl') continue;
87
-
88
- if (!registrySlugs.has(dirent.name)) {
89
- results.push(
90
- check(
91
- Severity.WARN,
92
- 'orphanFolder',
93
- `Folder projects/${dirent.name}/ has no registry entry. Register with /topic init or delete.`,
94
- false,
95
- ),
96
- );
97
- }
98
- }
99
-
100
- return results;
101
- }
102
-
103
- // ── Capsule structure checks ───────────────────────────────────────────
104
-
105
- /**
106
- * Check capsule structure: required files, overlay files, capsule version.
107
- */
108
- export function runCapsuleChecks(
109
- entry: TopicEntry,
110
- projectsBase: string,
111
- ): DoctorCheckResult[] {
112
- const results: DoctorCheckResult[] = [];
113
- const capsuleDir = path.join(projectsBase, entry.slug);
114
-
115
- if (!fs.existsSync(capsuleDir)) return results;
116
-
117
- // STATUS.md is critical
118
- if (!fs.existsSync(path.join(capsuleDir, 'STATUS.md'))) {
119
- results.push(
120
- check(Severity.ERROR, 'statusMissing', 'STATUS.md is missing from capsule', true),
121
- );
122
- }
123
-
124
- // TODO.md is important
125
- if (!fs.existsSync(path.join(capsuleDir, 'TODO.md'))) {
126
- if (!isIgnored(entry, 'todoMissing')) {
127
- results.push(
128
- check(Severity.WARN, 'todoMissing', 'TODO.md is missing from capsule', true),
129
- );
130
- }
131
- }
132
-
133
- // Overlay files are optional but worth noting
134
- const overlays = OVERLAY_FILES[entry.type] ?? [];
135
- for (const file of overlays) {
136
- if (!fs.existsSync(path.join(capsuleDir, file))) {
137
- const checkId = `overlayMissing:${file}`;
138
- if (!isIgnored(entry, checkId)) {
139
- results.push(
140
- check(Severity.INFO, checkId, `Optional overlay ${file} missing for type "${entry.type}"`, true),
141
- );
142
- }
143
- }
144
- }
145
-
146
- // Capsule version behind
147
- if (entry.capsuleVersion < CAPSULE_VERSION) {
148
- if (!isIgnored(entry, 'capsuleVersionBehind')) {
149
- results.push(
150
- check(
151
- Severity.INFO,
152
- 'capsuleVersionBehind',
153
- `Capsule version ${entry.capsuleVersion} is behind current ${CAPSULE_VERSION}. Run /topic upgrade.`,
154
- false,
155
- ),
156
- );
157
- }
158
- }
159
-
160
- return results;
161
- }
162
-
163
- // ── STATUS.md quality checks ───────────────────────────────────────────
164
-
165
- const LAST_DONE_RE = /^##\s*Last done\s*\(UTC\)/im;
166
- const NEXT_ACTIONS_RE = /^##\s*Next 3 actions/im;
167
- const TIMESTAMP_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
168
- const TASK_ID_RE = /\[T-\d+\]/g;
169
- const ADHOC_RE = /\[AD-HOC\]/g;
170
-
171
- /**
172
- * Check STATUS.md content quality.
173
- */
174
- export function runStatusQualityChecks(
175
- statusContent: string,
176
- entry: TopicEntry,
177
- ): DoctorCheckResult[] {
178
- const results: DoctorCheckResult[] = [];
179
-
180
- // "Last done (UTC)" section check
181
- if (!LAST_DONE_RE.test(statusContent)) {
182
- if (!isIgnored(entry, 'lastDoneMissing')) {
183
- results.push(
184
- check(Severity.ERROR, 'lastDoneMissing', 'STATUS.md missing "Last done (UTC)" section', true),
185
- );
186
- }
187
- } else {
188
- // Check for timestamp in the section
189
- const lastDoneIndex = statusContent.search(LAST_DONE_RE);
190
- const sectionAfter = statusContent.slice(lastDoneIndex);
191
- const nextSectionIndex = sectionAfter.indexOf('\n## ', 1);
192
- const lastDoneSection = nextSectionIndex > 0
193
- ? sectionAfter.slice(0, nextSectionIndex)
194
- : sectionAfter;
195
-
196
- if (!TIMESTAMP_RE.test(lastDoneSection)) {
197
- if (!isIgnored(entry, 'lastDoneNoTimestamp')) {
198
- results.push(
199
- check(Severity.ERROR, 'lastDoneNoTimestamp', 'STATUS.md "Last done" section has no timestamp', true),
200
- );
201
- }
202
- } else if (entry.status === 'active') {
203
- // Check timestamp age (default: 3 days)
204
- const tsMatch = lastDoneSection.match(TIMESTAMP_RE);
205
- if (tsMatch) {
206
- const ts = new Date(tsMatch[0]);
207
- const ageDays = (Date.now() - ts.getTime()) / (1000 * 60 * 60 * 24);
208
- if (ageDays > 3) {
209
- if (!isIgnored(entry, 'lastDoneStale')) {
210
- results.push(
211
- check(
212
- Severity.WARN,
213
- 'lastDoneStale',
214
- `STATUS.md "Last done" timestamp is ${Math.floor(ageDays)} days old`,
215
- false,
216
- ),
217
- );
218
- }
219
- }
220
- }
221
- }
222
- }
223
-
224
- // "Next 3 actions" section check
225
- if (!NEXT_ACTIONS_RE.test(statusContent)) {
226
- if (!isIgnored(entry, 'nextActionsMissing')) {
227
- results.push(
228
- check(Severity.ERROR, 'nextActionsMissing', 'STATUS.md missing "Next 3 actions" section', true),
229
- );
230
- }
231
- } else {
232
- // Check that next actions contain task IDs
233
- const nextActionsIndex = statusContent.search(NEXT_ACTIONS_RE);
234
- const sectionAfter = statusContent.slice(nextActionsIndex);
235
- const nextSectionIndex = sectionAfter.indexOf('\n## ', 1);
236
- const nextActionsSection = nextSectionIndex > 0
237
- ? sectionAfter.slice(0, nextSectionIndex)
238
- : sectionAfter;
239
-
240
- const taskIds = nextActionsSection.match(TASK_ID_RE) ?? [];
241
- const adhocs = nextActionsSection.match(ADHOC_RE) ?? [];
242
-
243
- if (taskIds.length === 0 && adhocs.length === 0) {
244
- if (!isIgnored(entry, 'nextActionsEmpty')) {
245
- results.push(
246
- check(
247
- Severity.WARN,
248
- 'nextActionsEmpty',
249
- '"Next 3 actions" has no task IDs or entries',
250
- false,
251
- ),
252
- );
253
- }
254
- }
255
- }
256
-
257
- return results;
258
- }
259
-
260
- // ── Next vs TODO cross-reference ───────────────────────────────────────
261
-
262
- /**
263
- * Check that task IDs in "Next 3 actions" exist in TODO.md.
264
- */
265
- export function runNextVsTodoChecks(
266
- statusContent: string,
267
- todoContent: string,
268
- ): DoctorCheckResult[] {
269
- const results: DoctorCheckResult[] = [];
270
-
271
- // Extract next actions section
272
- const nextActionsIndex = statusContent.search(NEXT_ACTIONS_RE);
273
- if (nextActionsIndex < 0) return results;
274
-
275
- const sectionAfter = statusContent.slice(nextActionsIndex);
276
- const nextSectionIndex = sectionAfter.indexOf('\n## ', 1);
277
- const nextActionsSection = nextSectionIndex > 0
278
- ? sectionAfter.slice(0, nextSectionIndex)
279
- : sectionAfter;
280
-
281
- // Get task IDs from next actions
282
- const nextTaskIds = nextActionsSection.match(TASK_ID_RE) ?? [];
283
- if (nextTaskIds.length === 0) return results;
284
-
285
- // Get task IDs from TODO
286
- const todoTaskIds = new Set(todoContent.match(TASK_ID_RE) ?? []);
287
-
288
- // Find task IDs in next that are not in TODO
289
- const missing = nextTaskIds.filter((id) => !todoTaskIds.has(id));
290
-
291
- // Only warn if 2+ are missing (allows 1 stale reference)
292
- if (missing.length >= 2) {
293
- results.push(
294
- check(
295
- Severity.WARN,
296
- 'nextNotInTodo',
297
- `${missing.length} task IDs in "Next 3 actions" not found in TODO.md: ${missing.join(', ')}`,
298
- false,
299
- ),
300
- );
301
- }
302
-
303
- return results;
304
- }
305
-
306
- // ── Commands / Links checks ────────────────────────────────────────────
307
-
308
- /**
309
- * Check COMMANDS.md and LINKS.md for relevant topic types.
310
- */
311
- export function runCommandsLinksChecks(
312
- entry: TopicEntry,
313
- capsuleFiles: Map<string, string>,
314
- ): DoctorCheckResult[] {
315
- const results: DoctorCheckResult[] = [];
316
-
317
- // COMMANDS.md empty for coding topics
318
- if (entry.type === 'coding') {
319
- const commandsContent = capsuleFiles.get('COMMANDS.md');
320
- if (commandsContent !== undefined && isEffectivelyEmpty(commandsContent)) {
321
- if (!isIgnored(entry, 'commandsEmpty')) {
322
- results.push(
323
- check(Severity.INFO, 'commandsEmpty', 'COMMANDS.md is empty for a coding topic', false),
324
- );
325
- }
326
- }
327
- }
328
-
329
- // LINKS.md empty for coding or research
330
- if (entry.type === 'coding' || entry.type === 'research') {
331
- const linksContent = capsuleFiles.get('LINKS.md');
332
- if (linksContent !== undefined && isEffectivelyEmpty(linksContent)) {
333
- if (!isIgnored(entry, 'linksEmpty')) {
334
- results.push(
335
- check(Severity.INFO, 'linksEmpty', 'LINKS.md is empty for a coding/research topic', false),
336
- );
337
- }
338
- }
339
- }
340
-
341
- return results;
342
- }
343
-
344
- /**
345
- * Check if a markdown file is effectively empty (only has a heading and template text).
346
- */
347
- function isEffectivelyEmpty(content: string): boolean {
348
- // Remove markdown heading lines and template placeholders
349
- const stripped = content
350
- .replace(/^#.*$/gm, '') // headings
351
- .replace(/^_.*_$/gm, '') // italic template text
352
- .replace(/\s+/g, '') // whitespace
353
- .trim();
354
- return stripped.length === 0;
355
- }
356
-
357
- // ── Cron checks ────────────────────────────────────────────────────────
358
-
359
- const JOB_ID_RE = /[a-zA-Z0-9_-]{8,}/;
360
-
361
- /**
362
- * Check CRON.md for job ID presence and optionally validate against cron/jobs.json.
363
- */
364
- export function runCronChecks(
365
- cronContent: string,
366
- cronJobsPath?: string,
367
- ): DoctorCheckResult[] {
368
- const results: DoctorCheckResult[] = [];
369
-
370
- // Skip if cron content is effectively empty
371
- if (isEffectivelyEmpty(cronContent)) return results;
372
-
373
- // Check for job IDs in CRON.md
374
- const lines = cronContent.split('\n').filter((l) => !l.startsWith('#') && l.trim().length > 0);
375
- const hasJobIds = lines.some((line) => JOB_ID_RE.test(line));
376
-
377
- if (!hasJobIds) {
378
- results.push(
379
- check(Severity.WARN, 'cronNoJobIds', 'CRON.md lists jobs but has no recognizable job IDs', false),
380
- );
381
- return results;
382
- }
383
-
384
- // Optionally validate job IDs against cron/jobs.json
385
- if (cronJobsPath && fs.existsSync(cronJobsPath)) {
386
- try {
387
- const jobsRaw = fs.readFileSync(cronJobsPath, 'utf-8');
388
- const jobs = JSON.parse(jobsRaw) as Record<string, unknown>;
389
- const knownJobIds = new Set(Object.keys(jobs));
390
-
391
- // Extract job IDs from CRON.md lines
392
- for (const line of lines) {
393
- const match = line.match(JOB_ID_RE);
394
- if (match && !knownJobIds.has(match[0])) {
395
- results.push(
396
- check(
397
- Severity.WARN,
398
- 'cronJobNotFound',
399
- `Job ID "${match[0]}" from CRON.md not found in cron/jobs.json`,
400
- false,
401
- ),
402
- );
403
- }
404
- }
405
- } catch {
406
- // Can't read jobs file — skip validation
407
- }
408
- }
409
-
410
- return results;
411
- }
412
-
413
- // ── Config enforcement checks ──────────────────────────────────────────
414
-
415
- /**
416
- * Check per-topic systemPrompt and skills against canonical templates.
417
- */
418
- export function runConfigChecks(
419
- entry: TopicEntry,
420
- includeContent: string,
421
- registry: Registry,
422
- ): DoctorCheckResult[] {
423
- const results: DoctorCheckResult[] = [];
424
-
425
- // Parse the include to check this topic's config
426
- let includeObj: Record<string, unknown>;
427
- try {
428
- // Strip comment lines before parsing
429
- const stripped = includeContent
430
- .split('\n')
431
- .filter((l) => !l.startsWith('//'))
432
- .join('\n');
433
- includeObj = JSON.parse(stripped) as Record<string, unknown>;
434
- } catch {
435
- // Try json5 parsing - but we don't import json5 here to keep this pure
436
- // If we can't parse, skip
437
- return results;
438
- }
439
-
440
- const groupConfig = includeObj[entry.groupId] as Record<string, unknown> | undefined;
441
- if (!groupConfig) {
442
- if (!isIgnored(entry, 'configGroupMissing')) {
443
- results.push(
444
- check(Severity.WARN, 'configGroupMissing', `Group ${entry.groupId} missing from generated include`, false),
445
- );
446
- }
447
- return results;
448
- }
449
-
450
- const topics = groupConfig['topics'] as Record<string, unknown> | undefined;
451
- const topicConfig = topics?.[entry.threadId] as Record<string, unknown> | undefined;
452
-
453
- if (!topicConfig) {
454
- if (!isIgnored(entry, 'configTopicMissing')) {
455
- results.push(
456
- check(Severity.WARN, 'configTopicMissing', `Topic config missing for thread ${entry.threadId}`, false),
457
- );
458
- }
459
- return results;
460
- }
461
-
462
- // Check systemPrompt exists
463
- if (!topicConfig['systemPrompt']) {
464
- if (!isIgnored(entry, 'configNoSystemPrompt')) {
465
- results.push(
466
- check(Severity.WARN, 'configNoSystemPrompt', 'Per-topic systemPrompt is missing in generated include', false),
467
- );
468
- }
469
- }
470
-
471
- // Check skills exist
472
- if (!topicConfig['skills'] || !Array.isArray(topicConfig['skills'])) {
473
- if (!isIgnored(entry, 'configNoSkills')) {
474
- results.push(
475
- check(Severity.WARN, 'configNoSkills', 'Per-topic skills list is missing in generated include', false),
476
- );
477
- }
478
- }
479
-
480
- return results;
481
- }
482
-
483
- // ── Include drift detection ────────────────────────────────────────────
484
-
485
- /**
486
- * Check if the generated include file's registry-hash matches the current registry.
487
- */
488
- export function runIncludeDriftCheck(
489
- includeFileContent: string,
490
- registry: Registry,
491
- ): DoctorCheckResult[] {
492
- const results: DoctorCheckResult[] = [];
493
-
494
- const fileHash = extractRegistryHash(includeFileContent);
495
- if (!fileHash) {
496
- results.push(
497
- check(
498
- Severity.WARN,
499
- 'includeDrift',
500
- 'Generated include file has no registry-hash comment. Run /topic sync.',
501
- false,
502
- ),
503
- );
504
- return results;
505
- }
506
-
507
- const currentHash = computeRegistryHash(registry.topics);
508
-
509
- if (fileHash !== currentHash) {
510
- results.push(
511
- check(
512
- Severity.WARN,
513
- 'includeDrift',
514
- 'Generated include is out of sync with registry. Run /topic sync.',
515
- false,
516
- ),
517
- );
518
- }
519
-
520
- return results;
521
- }
522
-
523
- // ── Spam control check ─────────────────────────────────────────────────
524
-
525
- /**
526
- * Check for spam control: if consecutiveSilentDoctors >= 3, suggest auto-snooze.
527
- */
528
- export function runSpamControlCheck(
529
- entry: TopicEntry,
530
- ): DoctorCheckResult[] {
531
- const results: DoctorCheckResult[] = [];
532
-
533
- if (entry.consecutiveSilentDoctors >= SPAM_THRESHOLD) {
534
- results.push(
535
- check(
536
- Severity.INFO,
537
- 'spamControl',
538
- `${entry.consecutiveSilentDoctors} consecutive doctor reports with no user interaction. Auto-snoozing for 30 days.`,
539
- true,
540
- ),
541
- );
542
- }
543
-
544
- return results;
545
- }
546
-
547
- // ── Convenience: run all checks for a single topic ─────────────────────
548
-
549
- /**
550
- * Run all applicable doctor checks for a single topic entry.
551
- * Returns combined results from all check categories.
552
- *
553
- * This is a convenience function for command handlers.
554
- * It reads capsule files and runs all checks.
555
- */
556
- export function runAllChecksForTopic(
557
- entry: TopicEntry,
558
- projectsBase: string,
559
- includeContent?: string,
560
- registry?: Registry,
561
- cronJobsPath?: string,
562
- ): DoctorCheckResult[] {
563
- const results: DoctorCheckResult[] = [];
564
- const capsuleDir = path.join(projectsBase, entry.slug);
565
-
566
- // Registry checks
567
- results.push(...runRegistryChecks(entry, projectsBase));
568
-
569
- // If path doesn't exist, skip capsule-dependent checks
570
- if (!fs.existsSync(capsuleDir)) return results;
571
-
572
- // Capsule structure checks
573
- results.push(...runCapsuleChecks(entry, projectsBase));
574
-
575
- // Read capsule files for content-based checks
576
- const capsuleFiles = readCapsuleFiles(capsuleDir);
577
-
578
- // STATUS quality checks
579
- const statusContent = capsuleFiles.get('STATUS.md');
580
- if (statusContent) {
581
- results.push(...runStatusQualityChecks(statusContent, entry));
582
-
583
- // Next vs TODO checks
584
- const todoContent = capsuleFiles.get('TODO.md');
585
- if (todoContent && !isIgnored(entry, 'nextNotInTodo')) {
586
- results.push(...runNextVsTodoChecks(statusContent, todoContent));
587
- }
588
- }
589
-
590
- // Commands / Links checks
591
- results.push(...runCommandsLinksChecks(entry, capsuleFiles));
592
-
593
- // Cron checks
594
- const cronContent = capsuleFiles.get('CRON.md');
595
- if (cronContent) {
596
- results.push(...runCronChecks(cronContent, cronJobsPath));
597
- }
598
-
599
- // Config checks (if include content provided)
600
- if (includeContent && registry) {
601
- results.push(...runConfigChecks(entry, includeContent, registry));
602
- }
603
-
604
- // Include drift (if include content provided)
605
- if (includeContent && registry) {
606
- results.push(...runIncludeDriftCheck(includeContent, registry));
607
- }
608
-
609
- // Spam control
610
- results.push(...runSpamControlCheck(entry));
611
-
612
- return results;
613
- }
614
-
615
- // ── File reading helper ────────────────────────────────────────────────
616
-
617
- function readCapsuleFiles(capsuleDir: string): Map<string, string> {
618
- const files = new Map<string, string>();
619
- const filenames = [
620
- 'STATUS.md', 'TODO.md', 'COMMANDS.md', 'LINKS.md',
621
- 'CRON.md', 'NOTES.md', 'README.md',
622
- 'ARCHITECTURE.md', 'DEPLOY.md',
623
- 'SOURCES.md', 'FINDINGS.md',
624
- 'CAMPAIGNS.md', 'METRICS.md',
625
- ];
626
-
627
- for (const name of filenames) {
628
- const filePath = path.join(capsuleDir, name);
629
- try {
630
- if (fs.existsSync(filePath)) {
631
- files.set(name, fs.readFileSync(filePath, 'utf-8'));
632
- }
633
- } catch {
634
- // Skip unreadable files
635
- }
636
- }
637
-
638
- return files;
639
- }