@nerviq/cli 1.11.0 → 1.13.0

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 (62) hide show
  1. package/README.md +216 -124
  2. package/bin/cli.js +620 -183
  3. package/package.json +3 -2
  4. package/src/activity.js +49 -9
  5. package/src/adoption-advisor.js +299 -0
  6. package/src/aider/freshness.js +65 -20
  7. package/src/aider/techniques.js +16 -11
  8. package/src/analyze.js +128 -0
  9. package/src/anti-patterns.js +13 -0
  10. package/src/audit/instruction-files.js +180 -0
  11. package/src/audit/recommendations.js +531 -0
  12. package/src/audit.js +53 -681
  13. package/src/behavioral-drift.js +801 -0
  14. package/src/codex/freshness.js +84 -25
  15. package/src/continuous-ops.js +681 -0
  16. package/src/copilot/freshness.js +57 -20
  17. package/src/cost-tracking.js +61 -0
  18. package/src/cursor/freshness.js +65 -20
  19. package/src/cursor/techniques.js +17 -12
  20. package/src/deep-review.js +83 -0
  21. package/src/diff-only.js +280 -0
  22. package/src/doctor.js +118 -55
  23. package/src/freshness.js +74 -21
  24. package/src/gemini/freshness.js +66 -21
  25. package/src/governance.js +59 -43
  26. package/src/hook-validation.js +342 -0
  27. package/src/index.js +5 -0
  28. package/src/integrations.js +42 -5
  29. package/src/mcp-server.js +95 -59
  30. package/src/mcp-validation.js +337 -0
  31. package/src/opencode/freshness.js +66 -21
  32. package/src/opencode/techniques.js +12 -7
  33. package/src/operating-profile.js +574 -0
  34. package/src/org.js +97 -13
  35. package/src/plans.js +192 -8
  36. package/src/platform-change-manifest.js +86 -0
  37. package/src/policy-layers.js +210 -0
  38. package/src/profiles.js +4 -1
  39. package/src/prompt-injection.js +74 -0
  40. package/src/repo-archetype.js +386 -0
  41. package/src/setup/analysis.js +619 -0
  42. package/src/setup/runtime.js +172 -0
  43. package/src/setup.js +62 -748
  44. package/src/source-urls.js +132 -132
  45. package/src/supplemental-checks.js +13 -12
  46. package/src/techniques/api.js +407 -0
  47. package/src/techniques/automation.js +316 -0
  48. package/src/techniques/compliance.js +257 -0
  49. package/src/techniques/hygiene.js +294 -0
  50. package/src/techniques/instructions.js +243 -0
  51. package/src/techniques/observability.js +226 -0
  52. package/src/techniques/optimization.js +142 -0
  53. package/src/techniques/quality.js +317 -0
  54. package/src/techniques/security.js +237 -0
  55. package/src/techniques/shared.js +443 -0
  56. package/src/techniques/stacks.js +2294 -0
  57. package/src/techniques/tools.js +106 -0
  58. package/src/techniques/workflow.js +413 -0
  59. package/src/techniques.js +78 -5607
  60. package/src/watch.js +18 -0
  61. package/src/windsurf/freshness.js +36 -21
  62. package/src/windsurf/techniques.js +17 -12
package/src/org.js CHANGED
@@ -1,48 +1,119 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const { resolvePolicyLayers, applyPolicyLayersToOptions } = require('./policy-layers');
3
4
 
4
- async function scanOrg(dirs, platform = 'claude') {
5
+ function summarizePolicyCoverage(contract) {
6
+ const validLayers = (contract?.layers || []).filter((layer) => layer.valid);
7
+ return {
8
+ layerCount: validLayers.length,
9
+ layerKeys: validLayers.map((layer) => layer.layer),
10
+ org: validLayers.some((layer) => layer.layer === 'org'),
11
+ team: validLayers.some((layer) => layer.layer === 'team'),
12
+ repo: validLayers.some((layer) => layer.layer === 'repo'),
13
+ };
14
+ }
15
+
16
+ function buildScoreBands(repos) {
17
+ const bands = {
18
+ strong: 0,
19
+ developing: 0,
20
+ bootstrap: 0,
21
+ unknown: 0,
22
+ };
23
+
24
+ for (const repo of repos) {
25
+ if (typeof repo.score !== 'number') {
26
+ bands.unknown += 1;
27
+ } else if (repo.score >= 70) {
28
+ bands.strong += 1;
29
+ } else if (repo.score >= 40) {
30
+ bands.developing += 1;
31
+ } else {
32
+ bands.bootstrap += 1;
33
+ }
34
+ }
35
+
36
+ return bands;
37
+ }
38
+
39
+ function buildTopEvidence(repos) {
40
+ const counts = new Map();
41
+ for (const repo of repos) {
42
+ const key = repo.topActionKey;
43
+ if (!key) continue;
44
+ counts.set(key, (counts.get(key) || 0) + 1);
45
+ }
46
+
47
+ return [...counts.entries()]
48
+ .map(([key, repoCount]) => ({ key, repoCount }))
49
+ .sort((a, b) => b.repoCount - a.repoCount)
50
+ .slice(0, 5);
51
+ }
52
+
53
+ async function scanOrg(dirs, options = {}) {
5
54
  const { audit } = require('./audit');
6
55
  const targets = Array.isArray(dirs) ? dirs : [];
7
56
  const repos = [];
57
+ const fallbackPlatform = options.platform || 'claude';
8
58
 
9
59
  for (const dir of targets) {
10
- const resolved = path.resolve(dir);
11
- if (!fs.existsSync(resolved)) {
60
+ const resolvedDir = path.resolve(dir);
61
+ const policyContract = resolvePolicyLayers(resolvedDir);
62
+ const policyCoverage = summarizePolicyCoverage(policyContract);
63
+
64
+ if (!fs.existsSync(resolvedDir)) {
12
65
  repos.push({
13
66
  name: path.basename(dir),
14
- dir: resolved,
15
- platform,
67
+ dir: resolvedDir,
68
+ platform: fallbackPlatform,
69
+ scoreType: 'live-repo-audit-score',
16
70
  score: null,
17
71
  passed: 0,
18
72
  total: 0,
19
73
  topAction: null,
74
+ topActionKey: null,
75
+ policyCoverage,
76
+ policyLayers: policyContract,
20
77
  error: 'directory not found',
21
78
  });
22
79
  continue;
23
80
  }
24
81
 
82
+ const repoOptions = applyPolicyLayersToOptions(policyContract, {
83
+ ...options,
84
+ dir: resolvedDir,
85
+ silent: true,
86
+ });
87
+
25
88
  try {
26
- const result = await audit({ dir: resolved, platform, silent: true });
89
+ const result = await audit(repoOptions);
27
90
  repos.push({
28
- name: path.basename(resolved),
29
- dir: resolved,
30
- platform,
91
+ name: path.basename(resolvedDir),
92
+ dir: resolvedDir,
93
+ platform: repoOptions.platform || fallbackPlatform,
94
+ scoreType: 'live-repo-audit-score',
31
95
  score: result.score,
32
96
  passed: result.passed,
33
97
  total: result.checkCount,
34
98
  topAction: result.topNextActions?.[0]?.name || null,
99
+ topActionKey: result.topNextActions?.[0]?.key || null,
100
+ policyCoverage,
101
+ policyLayers: policyContract,
35
102
  result,
36
103
  });
37
104
  } catch (error) {
38
105
  repos.push({
39
- name: path.basename(resolved),
40
- dir: resolved,
41
- platform,
106
+ name: path.basename(resolvedDir),
107
+ dir: resolvedDir,
108
+ platform: repoOptions.platform || fallbackPlatform,
109
+ scoreType: 'live-repo-audit-score',
42
110
  score: null,
43
111
  passed: 0,
44
112
  total: 0,
45
113
  topAction: null,
114
+ topActionKey: null,
115
+ policyCoverage,
116
+ policyLayers: policyContract,
46
117
  error: error.message,
47
118
  });
48
119
  }
@@ -54,11 +125,24 @@ async function scanOrg(dirs, platform = 'claude') {
54
125
  : 0;
55
126
 
56
127
  return {
57
- platform,
128
+ platform: fallbackPlatform,
58
129
  repoCount: repos.length,
59
130
  averageScore,
131
+ scoreType: 'org-live-average-score',
132
+ scoreSemantics: {
133
+ repoScoreType: 'live-repo-audit-score',
134
+ rollupScoreType: 'org-live-average-score',
135
+ note: 'Repo rows are live per-repo audits. The org average is a rollup across those live repo scores, not a snapshot score or benchmark projection.',
136
+ },
60
137
  maxScore: validScores.length > 0 ? Math.max(...validScores) : 0,
61
138
  minScore: validScores.length > 0 ? Math.min(...validScores) : 0,
139
+ scoreBands: buildScoreBands(repos),
140
+ policyCoverage: {
141
+ orgPolicyRepos: repos.filter((repo) => repo.policyCoverage.org).length,
142
+ teamPolicyRepos: repos.filter((repo) => repo.policyCoverage.team).length,
143
+ repoPolicyRepos: repos.filter((repo) => repo.policyCoverage.repo).length,
144
+ },
145
+ topEvidence: buildTopEvidence(repos),
62
146
  repos,
63
147
  };
64
148
  }
package/src/plans.js CHANGED
@@ -59,6 +59,32 @@ const FALLBACK_TEMPLATE_BY_KEY = {
59
59
  agentsHaveMaxTurns: 'agents',
60
60
  };
61
61
 
62
+ const VERIFICATION_TRIGGER_KEYS = new Set([
63
+ 'verificationLoop',
64
+ 'testCommand',
65
+ 'lintCommand',
66
+ 'buildCommand',
67
+ ]);
68
+
69
+ const GOVERNANCE_TRIGGER_KEYS = new Set([
70
+ 'permissionDeny',
71
+ 'secretsProtection',
72
+ 'preToolUseHook',
73
+ 'postToolUseHook',
74
+ 'sessionStartHook',
75
+ 'hooksInSettings',
76
+ 'settingsPermissions',
77
+ 'securityReview',
78
+ ]);
79
+
80
+ const AUTOMATION_TRIGGER_KEYS = new Set([
81
+ 'customCommands',
82
+ 'skills',
83
+ 'agents',
84
+ 'multipleAgents',
85
+ 'multipleMcpServers',
86
+ ]);
87
+
62
88
  function previewContent(content) {
63
89
  return content.split('\n').slice(0, 12).join('\n');
64
90
  }
@@ -372,9 +398,140 @@ function toProposal(templateKey, triggers, templateFiles, ctx) {
372
398
  };
373
399
  }
374
400
 
401
+ function proposalMatchesTriggerKeys(proposal, keySet) {
402
+ return (proposal.triggers || []).some((trigger) => keySet.has(trigger.key));
403
+ }
404
+
405
+ function collectCampaignProposals(proposals, predicate) {
406
+ return proposals.filter((proposal) => proposal.readyToApply && predicate(proposal));
407
+ }
408
+
409
+ function buildCampaigns(bundle) {
410
+ const proposals = Array.isArray(bundle?.proposals) ? bundle.proposals : [];
411
+ const campaigns = [];
412
+ const maturity = bundle?.projectSummary?.maturity || 'unknown';
413
+
414
+ const starterBaseline = collectCampaignProposals(
415
+ proposals,
416
+ (proposal) => ['claude-md', 'commands', 'rules', 'hooks', 'agents'].includes(proposal.id),
417
+ );
418
+ if (starterBaseline.length > 0) {
419
+ campaigns.push({
420
+ key: 'starter-baseline',
421
+ label: 'Starter baseline',
422
+ summary: 'Establish the managed baseline surfaces Nerviq expects before deeper upgrades.',
423
+ proposalIds: starterBaseline.map((proposal) => proposal.id),
424
+ milestone: maturity === 'mature' ? 'pre-upgrade' : 'baseline',
425
+ focusAreas: ['config-drift', 'maturity-opportunity'],
426
+ });
427
+ }
428
+
429
+ const verificationClosure = collectCampaignProposals(
430
+ proposals,
431
+ (proposal) => proposalMatchesTriggerKeys(proposal, VERIFICATION_TRIGGER_KEYS),
432
+ );
433
+ if (verificationClosure.length > 0) {
434
+ campaigns.push({
435
+ key: 'verification-closure',
436
+ label: 'Verification closure',
437
+ summary: 'Close missing test/lint/build loops so audits can be verified continuously.',
438
+ proposalIds: verificationClosure.map((proposal) => proposal.id),
439
+ milestone: 'post-fix',
440
+ focusAreas: ['config-drift', 'maturity-opportunity'],
441
+ });
442
+ }
443
+
444
+ const governanceHardening = collectCampaignProposals(
445
+ proposals,
446
+ (proposal) => proposal.id === 'hooks' || proposalMatchesTriggerKeys(proposal, GOVERNANCE_TRIGGER_KEYS),
447
+ );
448
+ if (governanceHardening.length > 0) {
449
+ campaigns.push({
450
+ key: 'governance-hardening',
451
+ label: 'Governance hardening',
452
+ summary: 'Tighten permissions, hooks, secret protection, and reviewable safety defaults.',
453
+ proposalIds: governanceHardening.map((proposal) => proposal.id),
454
+ milestone: 'pre-upgrade',
455
+ focusAreas: ['policy-drift', 'config-drift'],
456
+ });
457
+ }
458
+
459
+ const reviewableAutomation = collectCampaignProposals(
460
+ proposals,
461
+ (proposal) => proposal.id === 'agents' || proposal.id === 'commands' || proposalMatchesTriggerKeys(proposal, AUTOMATION_TRIGGER_KEYS),
462
+ );
463
+ if (reviewableAutomation.length > 0) {
464
+ campaigns.push({
465
+ key: 'reviewable-automation',
466
+ label: 'Reviewable automation',
467
+ summary: 'Add automation surfaces that keep upgrades inspectable instead of ad-hoc.',
468
+ proposalIds: reviewableAutomation.map((proposal) => proposal.id),
469
+ milestone: 'pre-upgrade',
470
+ focusAreas: ['maturity-opportunity', 'config-drift'],
471
+ });
472
+ }
473
+
474
+ return campaigns.filter((campaign, index, all) =>
475
+ all.findIndex((item) => item.key === campaign.key) === index,
476
+ );
477
+ }
478
+
479
+ function filterBundleByCampaigns(bundle, campaignKeys = []) {
480
+ if (!Array.isArray(campaignKeys) || campaignKeys.length === 0) {
481
+ return bundle;
482
+ }
483
+
484
+ const available = new Map((bundle.campaigns || []).map((campaign) => [campaign.key, campaign]));
485
+ const missing = campaignKeys.filter((key) => !available.has(key));
486
+ if (missing.length > 0) {
487
+ throw new Error(`unknown campaign(s): ${missing.join(', ')}`);
488
+ }
489
+
490
+ const selectedIds = new Set();
491
+ for (const key of campaignKeys) {
492
+ for (const proposalId of available.get(key).proposalIds || []) {
493
+ selectedIds.add(proposalId);
494
+ }
495
+ }
496
+
497
+ return {
498
+ ...bundle,
499
+ selectedCampaigns: campaignKeys,
500
+ proposals: bundle.proposals.filter((proposal) => selectedIds.has(proposal.id)),
501
+ campaigns: (bundle.campaigns || []).filter((campaign) => campaignKeys.includes(campaign.key)),
502
+ };
503
+ }
504
+
505
+ function resolveSelectionSet(bundle, options = {}) {
506
+ const proposalIds = new Set((bundle.proposals || []).map((proposal) => proposal.id));
507
+ const onlySet = options.only && options.only.length > 0 ? new Set(options.only) : null;
508
+ const campaignSet = options.campaigns && options.campaigns.length > 0
509
+ ? new Set((bundle.campaigns || []).flatMap((campaign) => {
510
+ if (!options.campaigns.includes(campaign.key)) return [];
511
+ return campaign.proposalIds || [];
512
+ }))
513
+ : null;
514
+
515
+ if (onlySet && campaignSet) {
516
+ return new Set([...onlySet].filter((proposalId) => campaignSet.has(proposalId) && proposalIds.has(proposalId)));
517
+ }
518
+ if (onlySet) {
519
+ return new Set([...onlySet].filter((proposalId) => proposalIds.has(proposalId)));
520
+ }
521
+ if (campaignSet) {
522
+ return new Set([...campaignSet].filter((proposalId) => proposalIds.has(proposalId)));
523
+ }
524
+
525
+ return null;
526
+ }
527
+
375
528
  async function buildProposalBundle(options) {
376
529
  if (options.platform === 'codex') {
377
- return buildCodexProposalBundle(options);
530
+ const bundle = await buildCodexProposalBundle(options);
531
+ return filterBundleByCampaigns({
532
+ ...bundle,
533
+ campaigns: buildCampaigns(bundle),
534
+ }, options.campaigns);
378
535
  }
379
536
 
380
537
  const ctx = new ProjectContext(options.dir);
@@ -405,7 +562,7 @@ async function buildProposalBundle(options) {
405
562
  return impactB - impactA;
406
563
  });
407
564
 
408
- return {
565
+ return filterBundleByCampaigns({
409
566
  schemaVersion: 1,
410
567
  generatedBy: `nerviq@${version}`,
411
568
  createdAt: new Date().toISOString(),
@@ -416,7 +573,8 @@ async function buildProposalBundle(options) {
416
573
  riskNotes: report.riskNotes,
417
574
  mcpPreflightWarnings,
418
575
  proposals,
419
- };
576
+ campaigns: buildCampaigns({ proposals, projectSummary: report.projectSummary }),
577
+ }, options.campaigns);
420
578
  }
421
579
 
422
580
  function printProposalBundle(bundle, options = {}) {
@@ -429,8 +587,19 @@ function printProposalBundle(bundle, options = {}) {
429
587
  console.log(' nerviq plan');
430
588
  console.log(' ═══════════════════════════════════════');
431
589
  console.log(` ${bundle.projectSummary.name} | maturity=${bundle.projectSummary.maturity} | score=${bundle.projectSummary.score}/100`);
590
+ if (bundle.projectSummary.archetype) {
591
+ console.log(` archetype=${bundle.projectSummary.archetype} | workflow=${bundle.projectSummary.workflow || 'unknown'} | risk=${bundle.projectSummary.riskLevel || 'unknown'}`);
592
+ }
593
+ if (bundle.projectSummary.operatingProfile) {
594
+ console.log(` operating-profile=${bundle.projectSummary.operatingProfile}`);
595
+ }
432
596
  console.log('');
433
597
 
598
+ if (bundle.selectedCampaigns && bundle.selectedCampaigns.length > 0) {
599
+ console.log(` Selected campaigns: ${bundle.selectedCampaigns.join(', ')}`);
600
+ console.log('');
601
+ }
602
+
434
603
  if (bundle.mcpPreflightWarnings && bundle.mcpPreflightWarnings.length > 0) {
435
604
  console.log(' MCP Preflight Warnings');
436
605
  for (const warning of bundle.mcpPreflightWarnings) {
@@ -445,6 +614,16 @@ function printProposalBundle(bundle, options = {}) {
445
614
  return;
446
615
  }
447
616
 
617
+ if (bundle.campaigns && bundle.campaigns.length > 0) {
618
+ console.log(' Upgrade campaigns');
619
+ for (const campaign of bundle.campaigns) {
620
+ console.log(` - ${campaign.key} (${campaign.milestone})`);
621
+ console.log(` ${campaign.label} — ${campaign.summary}`);
622
+ console.log(` proposals: ${campaign.proposalIds.join(', ')}`);
623
+ }
624
+ console.log('');
625
+ }
626
+
448
627
  console.log(' Proposal Bundles');
449
628
  for (const proposal of bundle.proposals) {
450
629
  const applyState = proposal.readyToApply ? 'ready' : 'manual-review';
@@ -536,9 +715,12 @@ function applyRuntimeSettingsOverlays(bundle, options) {
536
715
 
537
716
  function resolvePlan(bundle, options) {
538
717
  if (options.planFile) {
539
- return applyRuntimeSettingsOverlays(JSON.parse(fs.readFileSync(options.planFile, 'utf8')), options);
718
+ return filterBundleByCampaigns(
719
+ applyRuntimeSettingsOverlays(JSON.parse(fs.readFileSync(options.planFile, 'utf8')), options),
720
+ options.campaigns,
721
+ );
540
722
  }
541
- return applyRuntimeSettingsOverlays(bundle, options);
723
+ return filterBundleByCampaigns(applyRuntimeSettingsOverlays(bundle, options), options.campaigns);
542
724
  }
543
725
 
544
726
  async function applyProposalBundle(options) {
@@ -546,9 +728,7 @@ async function applyProposalBundle(options) {
546
728
  const bundle = resolvePlan(liveBundle, options);
547
729
  const mcpPreflightWarnings = getMcpPackPreflight(options.mcpPacks || [])
548
730
  .filter(item => item.missingEnvVars.length > 0);
549
- const selectedIds = options.only && options.only.length > 0
550
- ? new Set(options.only)
551
- : null;
731
+ const selectedIds = resolveSelectionSet(bundle, options);
552
732
  const selected = bundle.proposals.filter(proposal => {
553
733
  if (selectedIds && !selectedIds.has(proposal.id)) return false;
554
734
  return proposal.readyToApply;
@@ -606,6 +786,7 @@ async function applyProposalBundle(options) {
606
786
  return {
607
787
  proposalCount: bundle.proposals.length,
608
788
  appliedProposalIds: selected.map(item => item.id),
789
+ selectedCampaigns: bundle.selectedCampaigns || options.campaigns || [],
609
790
  createdFiles,
610
791
  patchedFiles: patchedFiles.map(file => file.path),
611
792
  skippedFiles,
@@ -629,6 +810,9 @@ function printApplyResult(result, options = {}) {
629
810
  console.log(' Dry-run only. No files were written.');
630
811
  }
631
812
  console.log(` Applied proposal bundles: ${result.appliedProposalIds.join(', ') || 'none'}`);
813
+ if (result.selectedCampaigns && result.selectedCampaigns.length > 0) {
814
+ console.log(` Campaigns: ${result.selectedCampaigns.join(', ')}`);
815
+ }
632
816
  console.log(` Created files: ${result.createdFiles.join(', ') || 'none'}`);
633
817
  console.log(` Patched files: ${result.patchedFiles.join(', ') || 'none'}`);
634
818
  if (result.mcpPreflightWarnings && result.mcpPreflightWarnings.length > 0) {
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ const claudeFreshness = require('./freshness');
4
+ const codexFreshness = require('./codex/freshness');
5
+ const cursorFreshness = require('./cursor/freshness');
6
+ const copilotFreshness = require('./copilot/freshness');
7
+ const geminiFreshness = require('./gemini/freshness');
8
+ const windsurfFreshness = require('./windsurf/freshness');
9
+ const aiderFreshness = require('./aider/freshness');
10
+ const opencodeFreshness = require('./opencode/freshness');
11
+
12
+ const DAILY_FRESHNESS_WORKFLOW = {
13
+ workflow: '.github/workflows/freshness-check.yml',
14
+ cadence: 'Daily at 06:00 UTC plus manual dispatch',
15
+ issuePolicy: 'Open or refresh GitHub issues for stale P0 sources without failing the main CI pipeline.',
16
+ };
17
+
18
+ const PLATFORM_CHANGE_MANIFEST = [
19
+ { key: 'claude', label: 'Claude Code', modulePath: 'src/freshness.js', freshness: claudeFreshness },
20
+ { key: 'codex', label: 'Codex', modulePath: 'src/codex/freshness.js', freshness: codexFreshness },
21
+ { key: 'cursor', label: 'Cursor', modulePath: 'src/cursor/freshness.js', freshness: cursorFreshness },
22
+ { key: 'copilot', label: 'Copilot', modulePath: 'src/copilot/freshness.js', freshness: copilotFreshness },
23
+ { key: 'gemini', label: 'Gemini CLI', modulePath: 'src/gemini/freshness.js', freshness: geminiFreshness },
24
+ { key: 'windsurf', label: 'Windsurf', modulePath: 'src/windsurf/freshness.js', freshness: windsurfFreshness },
25
+ { key: 'aider', label: 'Aider', modulePath: 'src/aider/freshness.js', freshness: aiderFreshness },
26
+ { key: 'opencode', label: 'OpenCode', modulePath: 'src/opencode/freshness.js', freshness: opencodeFreshness },
27
+ ].map((entry) => {
28
+ const sources = (entry.freshness.P0_SOURCES || []).map((source) => ({
29
+ key: source.key,
30
+ label: source.label,
31
+ url: source.url,
32
+ stalenessThresholdDays: source.stalenessThresholdDays,
33
+ verifiedAt: source.verifiedAt || null,
34
+ }));
35
+ const thresholds = [...new Set(sources.map((source) => source.stalenessThresholdDays))].sort((a, b) => a - b);
36
+
37
+ return {
38
+ key: entry.key,
39
+ label: entry.label,
40
+ modulePath: entry.modulePath,
41
+ trackedSources: sources,
42
+ trackedSourceCount: sources.length,
43
+ reviewCadence: {
44
+ automation: DAILY_FRESHNESS_WORKFLOW.cadence,
45
+ thresholdsDays: thresholds,
46
+ manualExpectation: thresholds.includes(14)
47
+ ? 'Review high-volatility sources weekly and verify any stale source immediately.'
48
+ : 'Review sources at least monthly and verify any stale source immediately.',
49
+ },
50
+ freshnessWorkflow: {
51
+ ...DAILY_FRESHNESS_WORKFLOW,
52
+ manualTrigger: true,
53
+ },
54
+ updateTriggers: (entry.freshness.PROPAGATION_CHECKLIST || []).map((item) => ({
55
+ trigger: item.trigger,
56
+ targets: item.targets || [],
57
+ })),
58
+ };
59
+ });
60
+
61
+ function getPlatformChangeManifest() {
62
+ return JSON.parse(JSON.stringify(PLATFORM_CHANGE_MANIFEST));
63
+ }
64
+
65
+ function summarizePlatformChangeManifest() {
66
+ const manifest = getPlatformChangeManifest();
67
+ return {
68
+ platformCount: manifest.length,
69
+ trackedSourceCount: manifest.reduce((sum, entry) => sum + entry.trackedSourceCount, 0),
70
+ workflow: DAILY_FRESHNESS_WORKFLOW,
71
+ platforms: manifest.map((entry) => ({
72
+ key: entry.key,
73
+ label: entry.label,
74
+ trackedSourceCount: entry.trackedSourceCount,
75
+ thresholdDays: entry.reviewCadence.thresholdsDays,
76
+ updateTriggerCount: entry.updateTriggers.length,
77
+ })),
78
+ };
79
+ }
80
+
81
+ module.exports = {
82
+ DAILY_FRESHNESS_WORKFLOW,
83
+ PLATFORM_CHANGE_MANIFEST,
84
+ getPlatformChangeManifest,
85
+ summarizePlatformChangeManifest,
86
+ };