@nebulacomponents/citable 1.15.1 → 1.16.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 (211) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +3 -3
  3. package/dist/universal/.agents/skills/citable/SKILL.md +5 -1
  4. package/dist/universal/.agents/skills/citable/VERSION +1 -1
  5. package/dist/universal/.agents/skills/citable/commands/README.md +3 -1
  6. package/dist/universal/.agents/skills/citable/commands/governance.md +12 -0
  7. package/dist/universal/.agents/skills/citable/commands/measurement.md +20 -0
  8. package/dist/universal/.agents/skills/citable/commands/reporting.md +16 -0
  9. package/dist/universal/.agents/skills/citable/manifest.json +14 -11
  10. package/dist/universal/.agents/skills/citable/schemas/experiment.schema.json +1 -1
  11. package/dist/universal/.agents/skills/citable/schemas/fleet.schema.json +93 -0
  12. package/dist/universal/.agents/skills/citable/schemas/funnel.schema.json +116 -0
  13. package/dist/universal/.agents/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  14. package/dist/universal/.agents/skills/citable/schemas/observation.schema.json +1 -1
  15. package/dist/universal/.claude/agents/citable/manifest.json +2 -2
  16. package/dist/universal/.claude/skills/citable/SKILL.md +5 -1
  17. package/dist/universal/.claude/skills/citable/VERSION +1 -1
  18. package/dist/universal/.claude/skills/citable/commands/README.md +3 -1
  19. package/dist/universal/.claude/skills/citable/commands/governance.md +12 -0
  20. package/dist/universal/.claude/skills/citable/commands/measurement.md +20 -0
  21. package/dist/universal/.claude/skills/citable/commands/reporting.md +16 -0
  22. package/dist/universal/.claude/skills/citable/manifest.json +14 -11
  23. package/dist/universal/.claude/skills/citable/schemas/experiment.schema.json +1 -1
  24. package/dist/universal/.claude/skills/citable/schemas/fleet.schema.json +93 -0
  25. package/dist/universal/.claude/skills/citable/schemas/funnel.schema.json +116 -0
  26. package/dist/universal/.claude/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  27. package/dist/universal/.claude/skills/citable/schemas/observation.schema.json +1 -1
  28. package/dist/universal/.cursor/skills/citable/SKILL.md +5 -1
  29. package/dist/universal/.cursor/skills/citable/VERSION +1 -1
  30. package/dist/universal/.cursor/skills/citable/commands/README.md +3 -1
  31. package/dist/universal/.cursor/skills/citable/commands/governance.md +12 -0
  32. package/dist/universal/.cursor/skills/citable/commands/measurement.md +20 -0
  33. package/dist/universal/.cursor/skills/citable/commands/reporting.md +16 -0
  34. package/dist/universal/.cursor/skills/citable/manifest.json +14 -11
  35. package/dist/universal/.cursor/skills/citable/schemas/experiment.schema.json +1 -1
  36. package/dist/universal/.cursor/skills/citable/schemas/fleet.schema.json +93 -0
  37. package/dist/universal/.cursor/skills/citable/schemas/funnel.schema.json +116 -0
  38. package/dist/universal/.cursor/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  39. package/dist/universal/.cursor/skills/citable/schemas/observation.schema.json +1 -1
  40. package/dist/universal/.gemini/skills/citable/SKILL.md +5 -1
  41. package/dist/universal/.gemini/skills/citable/VERSION +1 -1
  42. package/dist/universal/.gemini/skills/citable/commands/README.md +3 -1
  43. package/dist/universal/.gemini/skills/citable/commands/governance.md +12 -0
  44. package/dist/universal/.gemini/skills/citable/commands/measurement.md +20 -0
  45. package/dist/universal/.gemini/skills/citable/commands/reporting.md +16 -0
  46. package/dist/universal/.gemini/skills/citable/manifest.json +14 -11
  47. package/dist/universal/.gemini/skills/citable/schemas/experiment.schema.json +1 -1
  48. package/dist/universal/.gemini/skills/citable/schemas/fleet.schema.json +93 -0
  49. package/dist/universal/.gemini/skills/citable/schemas/funnel.schema.json +116 -0
  50. package/dist/universal/.gemini/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  51. package/dist/universal/.gemini/skills/citable/schemas/observation.schema.json +1 -1
  52. package/dist/universal/.github/skills/citable/SKILL.md +5 -1
  53. package/dist/universal/.github/skills/citable/VERSION +1 -1
  54. package/dist/universal/.github/skills/citable/commands/README.md +3 -1
  55. package/dist/universal/.github/skills/citable/commands/governance.md +12 -0
  56. package/dist/universal/.github/skills/citable/commands/measurement.md +20 -0
  57. package/dist/universal/.github/skills/citable/commands/reporting.md +16 -0
  58. package/dist/universal/.github/skills/citable/manifest.json +14 -11
  59. package/dist/universal/.github/skills/citable/schemas/experiment.schema.json +1 -1
  60. package/dist/universal/.github/skills/citable/schemas/fleet.schema.json +93 -0
  61. package/dist/universal/.github/skills/citable/schemas/funnel.schema.json +116 -0
  62. package/dist/universal/.github/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  63. package/dist/universal/.github/skills/citable/schemas/observation.schema.json +1 -1
  64. package/dist/universal/.kiro/skills/citable/SKILL.md +5 -1
  65. package/dist/universal/.kiro/skills/citable/VERSION +1 -1
  66. package/dist/universal/.kiro/skills/citable/commands/README.md +3 -1
  67. package/dist/universal/.kiro/skills/citable/commands/governance.md +12 -0
  68. package/dist/universal/.kiro/skills/citable/commands/measurement.md +20 -0
  69. package/dist/universal/.kiro/skills/citable/commands/reporting.md +16 -0
  70. package/dist/universal/.kiro/skills/citable/manifest.json +14 -11
  71. package/dist/universal/.kiro/skills/citable/schemas/experiment.schema.json +1 -1
  72. package/dist/universal/.kiro/skills/citable/schemas/fleet.schema.json +93 -0
  73. package/dist/universal/.kiro/skills/citable/schemas/funnel.schema.json +116 -0
  74. package/dist/universal/.kiro/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  75. package/dist/universal/.kiro/skills/citable/schemas/observation.schema.json +1 -1
  76. package/dist/universal/.opencode/skills/citable/SKILL.md +5 -1
  77. package/dist/universal/.opencode/skills/citable/VERSION +1 -1
  78. package/dist/universal/.opencode/skills/citable/commands/README.md +3 -1
  79. package/dist/universal/.opencode/skills/citable/commands/governance.md +12 -0
  80. package/dist/universal/.opencode/skills/citable/commands/measurement.md +20 -0
  81. package/dist/universal/.opencode/skills/citable/commands/reporting.md +16 -0
  82. package/dist/universal/.opencode/skills/citable/manifest.json +14 -11
  83. package/dist/universal/.opencode/skills/citable/schemas/experiment.schema.json +1 -1
  84. package/dist/universal/.opencode/skills/citable/schemas/fleet.schema.json +93 -0
  85. package/dist/universal/.opencode/skills/citable/schemas/funnel.schema.json +116 -0
  86. package/dist/universal/.opencode/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  87. package/dist/universal/.opencode/skills/citable/schemas/observation.schema.json +1 -1
  88. package/dist/universal/.pi/agent/skills/citable/SKILL.md +5 -1
  89. package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
  90. package/dist/universal/.pi/agent/skills/citable/commands/README.md +3 -1
  91. package/dist/universal/.pi/agent/skills/citable/commands/governance.md +12 -0
  92. package/dist/universal/.pi/agent/skills/citable/commands/measurement.md +20 -0
  93. package/dist/universal/.pi/agent/skills/citable/commands/reporting.md +16 -0
  94. package/dist/universal/.pi/agent/skills/citable/manifest.json +14 -11
  95. package/dist/universal/.pi/agent/skills/citable/schemas/experiment.schema.json +1 -1
  96. package/dist/universal/.pi/agent/skills/citable/schemas/fleet.schema.json +93 -0
  97. package/dist/universal/.pi/agent/skills/citable/schemas/funnel.schema.json +116 -0
  98. package/dist/universal/.pi/agent/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  99. package/dist/universal/.pi/agent/skills/citable/schemas/observation.schema.json +1 -1
  100. package/dist/universal/.qoder/skills/citable/SKILL.md +5 -1
  101. package/dist/universal/.qoder/skills/citable/VERSION +1 -1
  102. package/dist/universal/.qoder/skills/citable/commands/README.md +3 -1
  103. package/dist/universal/.qoder/skills/citable/commands/governance.md +12 -0
  104. package/dist/universal/.qoder/skills/citable/commands/measurement.md +20 -0
  105. package/dist/universal/.qoder/skills/citable/commands/reporting.md +16 -0
  106. package/dist/universal/.qoder/skills/citable/manifest.json +14 -11
  107. package/dist/universal/.qoder/skills/citable/schemas/experiment.schema.json +1 -1
  108. package/dist/universal/.qoder/skills/citable/schemas/fleet.schema.json +93 -0
  109. package/dist/universal/.qoder/skills/citable/schemas/funnel.schema.json +116 -0
  110. package/dist/universal/.qoder/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  111. package/dist/universal/.qoder/skills/citable/schemas/observation.schema.json +1 -1
  112. package/dist/universal/.rovodev/skills/citable/SKILL.md +5 -1
  113. package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
  114. package/dist/universal/.rovodev/skills/citable/commands/README.md +3 -1
  115. package/dist/universal/.rovodev/skills/citable/commands/governance.md +12 -0
  116. package/dist/universal/.rovodev/skills/citable/commands/measurement.md +20 -0
  117. package/dist/universal/.rovodev/skills/citable/commands/reporting.md +16 -0
  118. package/dist/universal/.rovodev/skills/citable/manifest.json +14 -11
  119. package/dist/universal/.rovodev/skills/citable/schemas/experiment.schema.json +1 -1
  120. package/dist/universal/.rovodev/skills/citable/schemas/fleet.schema.json +93 -0
  121. package/dist/universal/.rovodev/skills/citable/schemas/funnel.schema.json +116 -0
  122. package/dist/universal/.rovodev/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  123. package/dist/universal/.rovodev/skills/citable/schemas/observation.schema.json +1 -1
  124. package/dist/universal/.trae/skills/citable/SKILL.md +5 -1
  125. package/dist/universal/.trae/skills/citable/VERSION +1 -1
  126. package/dist/universal/.trae/skills/citable/commands/README.md +3 -1
  127. package/dist/universal/.trae/skills/citable/commands/governance.md +12 -0
  128. package/dist/universal/.trae/skills/citable/commands/measurement.md +20 -0
  129. package/dist/universal/.trae/skills/citable/commands/reporting.md +16 -0
  130. package/dist/universal/.trae/skills/citable/manifest.json +14 -11
  131. package/dist/universal/.trae/skills/citable/schemas/experiment.schema.json +1 -1
  132. package/dist/universal/.trae/skills/citable/schemas/fleet.schema.json +93 -0
  133. package/dist/universal/.trae/skills/citable/schemas/funnel.schema.json +116 -0
  134. package/dist/universal/.trae/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  135. package/dist/universal/.trae/skills/citable/schemas/observation.schema.json +1 -1
  136. package/dist/universal/.trae-cn/skills/citable/SKILL.md +5 -1
  137. package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
  138. package/dist/universal/.trae-cn/skills/citable/commands/README.md +3 -1
  139. package/dist/universal/.trae-cn/skills/citable/commands/governance.md +12 -0
  140. package/dist/universal/.trae-cn/skills/citable/commands/measurement.md +20 -0
  141. package/dist/universal/.trae-cn/skills/citable/commands/reporting.md +16 -0
  142. package/dist/universal/.trae-cn/skills/citable/manifest.json +14 -11
  143. package/dist/universal/.trae-cn/skills/citable/schemas/experiment.schema.json +1 -1
  144. package/dist/universal/.trae-cn/skills/citable/schemas/fleet.schema.json +93 -0
  145. package/dist/universal/.trae-cn/skills/citable/schemas/funnel.schema.json +116 -0
  146. package/dist/universal/.trae-cn/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  147. package/dist/universal/.trae-cn/skills/citable/schemas/observation.schema.json +1 -1
  148. package/dist/universal/manifest.json +172 -136
  149. package/package.json +1 -1
  150. package/schemas/experiment.schema.json +1 -1
  151. package/schemas/fleet.schema.json +93 -0
  152. package/schemas/funnel.schema.json +116 -0
  153. package/schemas/mcp-transport-envelope.schema.json +244 -0
  154. package/schemas/observation.schema.json +1 -1
  155. package/skill/SKILL.md +5 -1
  156. package/skill/commands/README.md +3 -1
  157. package/skill/commands/governance.md +12 -0
  158. package/skill/commands/measurement.md +20 -0
  159. package/skill/commands/reporting.md +16 -0
  160. package/src/cli/index.js +383 -8
  161. package/src/commands/attributeImpact.js +54 -0
  162. package/src/commands/connect.js +3 -0
  163. package/src/commands/edgeRules.js +120 -0
  164. package/src/commands/exceptions.js +241 -0
  165. package/src/commands/fleet.js +104 -0
  166. package/src/commands/generateLlmsTxt.js +111 -0
  167. package/src/commands/inspectAeo.js +74 -0
  168. package/src/commands/inspectCro.js +82 -0
  169. package/src/commands/inspectGeo.js +52 -0
  170. package/src/commands/inspectSerp.js +317 -0
  171. package/src/commands/lintComponents.js +132 -0
  172. package/src/commands/monitor.js +138 -0
  173. package/src/commands/observe.js +6 -1
  174. package/src/commands/planExperiment.js +68 -0
  175. package/src/commands/probe.js +70 -0
  176. package/src/commands/reportConsensus.js +48 -0
  177. package/src/commands/testFunnel.js +107 -0
  178. package/src/connectors/index.js +6 -1
  179. package/src/connectors/indexnow.js +431 -0
  180. package/src/connectors/mcp/allowlist.js +107 -0
  181. package/src/connectors/mcp/client.js +239 -0
  182. package/src/connectors/mcp/diagnostics.js +214 -0
  183. package/src/connectors/mcp/gscServer.js +155 -0
  184. package/src/connectors/mcp/http.js +130 -0
  185. package/src/connectors/mcp/index.js +7 -0
  186. package/src/connectors/mcp/pilot.js +138 -0
  187. package/src/connectors/mcp/pilotServer.js +80 -0
  188. package/src/connectors/mcp/sanitizer.js +38 -0
  189. package/src/connectors/mcp/stdio.js +178 -0
  190. package/src/crawler/ipRanges.js +161 -0
  191. package/src/detectors/agent.js +127 -0
  192. package/src/detectors/ans.js +282 -0
  193. package/src/detectors/crawl.js +46 -0
  194. package/src/detectors/cro.js +777 -0
  195. package/src/detectors/cwv.js +38 -1
  196. package/src/detectors/evd.js +101 -0
  197. package/src/detectors/framework.js +1 -1
  198. package/src/detectors/geoReco.js +408 -0
  199. package/src/detectors/index.js +4 -2
  200. package/src/detectors/link.js +112 -1
  201. package/src/detectors/page.js +101 -0
  202. package/src/detectors/schemaData.js +529 -0
  203. package/src/detectors/tech.js +306 -0
  204. package/src/extractor/page.js +186 -2
  205. package/src/extractor/site.js +9 -2
  206. package/src/installer/index.js +45 -2
  207. package/src/monitoring/alertDelivery.js +101 -7
  208. package/src/observations/attribution.js +217 -0
  209. package/src/registries/index.js +14 -0
  210. package/src/reporting/consensus.js +416 -0
  211. package/src/reporting/executiveExport.js +132 -0
@@ -2,6 +2,9 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { readJson, writeJson, nowIso } from '../shared/io.js';
4
4
  import { buildAlertPayload, dispatchAlertWebhook, filterAlerts } from '../monitoring/alertDelivery.js';
5
+ import { calculateShareOfVoice, extractDomain } from '../reporting/shareOfVoice.js';
6
+ import { loadRegistries } from '../registries/index.js';
7
+ import { evaluateAnswerAttribution } from '../observations/attribution.js';
5
8
 
6
9
  function observations(dir) {
7
10
  const folder = path.join(dir, 'observations');
@@ -13,6 +16,7 @@ function key(item) {
13
16
  const d = item.data || {};
14
17
  if (item.kind === 'citation') return [item.kind, d.provider, d.product_mode, d.prompt_id, d.run_index].join(':');
15
18
  if (item.kind === 'representation_drift') return [item.kind, d.manifest_hash, d.surface_id, d.retrieval_path, d.region, d.request_identity].join(':');
19
+ if (item.kind === 'stance') return [item.kind, d.entity_id || d.canonical_name, d.prompt_id, d.engine].join(':');
16
20
  return [item.kind, d.url || d.citation_url || d.prompt_id || d.timestamp || item.observation_id].join(':');
17
21
  }
18
22
 
@@ -22,6 +26,32 @@ export function monitor(root, { runA, runB } = {}) {
22
26
  const candidates = fs.readdirSync(runsDir).filter((r) => fs.existsSync(path.join(runsDir, r, 'observations'))).sort();
23
27
  const b = runB || candidates.at(-1), a = runA || candidates.at(-2);
24
28
  if (!a || !b) throw new Error('monitor requires two observation runs');
29
+
30
+ let activeClaims = [];
31
+ let entities = [];
32
+ let competitors = [];
33
+ const firstPartyDomains = new Set();
34
+ try {
35
+ const { registries } = loadRegistries(root);
36
+ activeClaims = (registries?.claims?.entries || []).filter((c) => c.status === 'verified' || c.status === 'established');
37
+ entities = (registries?.entities?.entries || []).filter((e) => e.status !== 'retired');
38
+ if (registries?.competitors?.entries) competitors = registries.competitors.entries;
39
+ if (registries?.config?.site?.base_url) {
40
+ const d = extractDomain(registries.config.site.base_url);
41
+ if (d) firstPartyDomains.add(d);
42
+ }
43
+ if (registries?.pages?.entries) {
44
+ for (const p of registries.pages.entries) {
45
+ if (p.url) {
46
+ const d = extractDomain(p.url);
47
+ if (d) firstPartyDomains.add(d);
48
+ }
49
+ }
50
+ }
51
+ } catch {
52
+ // Registries may not exist in synthetic test environments
53
+ }
54
+
25
55
  const before = new Map(observations(path.join(runsDir, a)).map((o) => [key(o), o]));
26
56
  const after = new Map(observations(path.join(runsDir, b)).map((o) => [key(o), o]));
27
57
  const alerts = [];
@@ -32,6 +62,23 @@ export function monitor(root, { runA, runB } = {}) {
32
62
  if (current.kind === 'index' && previous?.data?.indexed === true && current.data.indexed === false) alerts.push({ severity: 'high', type: 'index_loss', key: k });
33
63
  if (current.kind === 'canonical_freshness' && previous?.data?.canonical_consensus === true && current.data.canonical_consensus === false) alerts.push({ severity: 'high', type: 'canonical_regression', key: k });
34
64
  if (current.kind === 'citation' && previous?.data?.property_cited === true && current.data.property_cited === false) alerts.push({ severity: 'medium', type: 'citation_presence_change', key: k });
65
+ if (current.kind === 'stance' && previous) {
66
+ const fromStance = previous.data?.stance;
67
+ const toStance = current.data?.stance;
68
+ if (fromStance === 'favorable' && (toStance === 'unfavorable' || toStance === 'mixed')) {
69
+ alerts.push({
70
+ severity: 'high',
71
+ type: 'stance_regression',
72
+ key: k,
73
+ previous_stance: fromStance,
74
+ current_stance: toStance,
75
+ entity_id: current.data?.entity_id || null,
76
+ canonical_name: current.data?.canonical_name || null,
77
+ prompt_id: current.data?.prompt_id || null,
78
+ engine: current.data?.engine || null,
79
+ });
80
+ }
81
+ }
35
82
  if (current.kind === 'representation_drift' && previous) {
36
83
  const from = previous.data.representation_state, to = current.data.representation_state;
37
84
  if (from !== 'divergent' && to === 'divergent') alerts.push({ severity: 'high', type: 'representation_divergence_observed', key: k, previous_state: from, current_state: to, authority: 'external_unverified', gates_release_finalization: false });
@@ -40,8 +87,97 @@ export function monitor(root, { runA, runB } = {}) {
40
87
  alerts.push({ severity: 'informational', type: 'representation_convergence_observed', key: k, previous_state: from, current_state: to, observed_interval_ms: Number.isFinite(duration) && duration >= 0 ? duration : null, authority: 'external_unverified', gates_release_finalization: false });
41
88
  }
42
89
  }
90
+
91
+ // Generative claim contradiction / hallucination check
92
+ if (current.kind === 'attribution' && current.data?.evaluations) {
93
+ for (const ev of current.data.evaluations) {
94
+ if (ev.attribution_status === 'distorted') {
95
+ alerts.push({
96
+ severity: 'high',
97
+ type: 'claim_contradiction_observed',
98
+ key: k,
99
+ claim_id: ev.claim_id,
100
+ claim_text: ev.claim_text,
101
+ assertion_excerpt: ev.assertion_excerpt,
102
+ engine: current.data?.engine || current.data?.provider || null,
103
+ reasons: ev.reasons || [],
104
+ });
105
+ }
106
+ }
107
+ } else if (current.kind === 'citation' || current.kind === 'passage') {
108
+ if (current.data?.answer_text && entities.length > 0 && activeClaims.length > 0) {
109
+ for (const e of entities) {
110
+ const evalRes = evaluateAnswerAttribution(current.data.answer_text, e, { claims: activeClaims });
111
+ if (evalRes.mentioned && evalRes.has_distorted_claims) {
112
+ for (const ev of evalRes.evaluations) {
113
+ if (ev.attribution_status === 'distorted') {
114
+ alerts.push({
115
+ severity: 'high',
116
+ type: 'claim_contradiction_observed',
117
+ key: k,
118
+ claim_id: ev.claim_id,
119
+ claim_text: ev.claim_text,
120
+ assertion_excerpt: ev.assertion_excerpt,
121
+ engine: current.data?.provider || current.data?.engine || null,
122
+ reasons: ev.reasons || [],
123
+ });
124
+ }
125
+ }
126
+ }
127
+ }
128
+ } else if (current.data?.contradicted_claims && Array.isArray(current.data.contradicted_claims)) {
129
+ for (const cc of current.data.contradicted_claims) {
130
+ alerts.push({
131
+ severity: 'high',
132
+ type: 'claim_contradiction_observed',
133
+ key: k,
134
+ claim_id: typeof cc === 'object' ? cc.claim_id : cc,
135
+ assertion_excerpt: typeof cc === 'object' ? cc.assertion_excerpt : current.data?.answer_text?.slice(0, 150) || null,
136
+ engine: current.data?.provider || current.data?.engine || null,
137
+ reasons: typeof cc === 'object' ? cc.reasons || [] : ['contradicted claim observed in answer'],
138
+ });
139
+ }
140
+ }
141
+ }
43
142
  }
44
143
  for (const k of before.keys()) if (!after.has(k)) alerts.push({ severity: 'medium', type: 'observation_missing', key: k });
144
+
145
+ // Share-of-Voice comparison across observation runs
146
+ const citationsA = [...before.values()].filter((o) => o.kind === 'citation');
147
+ const citationsB = [...after.values()].filter((o) => o.kind === 'citation');
148
+ if (citationsA.length > 0 && citationsB.length > 0) {
149
+ const sovA = calculateShareOfVoice([{ run_id: a, citations: citationsA }], competitors, firstPartyDomains);
150
+ const sovB = calculateShareOfVoice([{ run_id: b, citations: citationsB }], competitors, firstPartyDomains);
151
+
152
+ const prevShare = sovA.aggregate?.first_party?.citation_share ?? sovA.aggregate?.first_party?.presence_rate;
153
+ const currShare = sovB.aggregate?.first_party?.citation_share ?? sovB.aggregate?.first_party?.presence_rate;
154
+
155
+ if (prevShare != null && currShare != null && prevShare > currShare) {
156
+ const drop = prevShare - currShare;
157
+ const relativeDrop = prevShare > 0 ? (prevShare - currShare) / prevShare : 0;
158
+ if (drop >= 0.20 || relativeDrop >= 0.20) {
159
+ alerts.push({
160
+ severity: 'high',
161
+ type: 'share_of_voice_drop',
162
+ key: 'share_of_voice:aggregate',
163
+ previous_share: Number(prevShare.toFixed(4)),
164
+ current_share: Number(currShare.toFixed(4)),
165
+ drop: Number(drop.toFixed(4)),
166
+ relative_drop: Number(relativeDrop.toFixed(4)),
167
+ competitors: (sovB.aggregate?.competitors || []).map((c) => {
168
+ const prevComp = sovA.aggregate?.competitors?.find((p) => p.competitor_id === c.competitor_id);
169
+ return {
170
+ competitor_id: c.competitor_id,
171
+ name: c.name,
172
+ previous_share: prevComp?.citation_share ?? null,
173
+ current_share: c.citation_share,
174
+ drift: prevComp?.citation_share != null ? Number((c.citation_share - prevComp.citation_share).toFixed(4)) : null,
175
+ };
176
+ }),
177
+ });
178
+ }
179
+ }
180
+ }
45
181
  const result = { generated_at: nowIso(), run_a: a, run_b: b, summary: { alerts: alerts.length, critical_or_high: alerts.filter((x) => ['critical', 'high'].includes(x.severity)).length }, alerts };
46
182
  const dir = path.join(root, '.citable', 'monitoring');
47
183
  writeJson(path.join(dir, `${a}--${b}.json`), result);
@@ -54,6 +190,7 @@ export async function monitorAndAlert(root, {
54
190
  runB,
55
191
  webhookUrl,
56
192
  minSeverity = 'medium',
193
+ secret = process.env.CITABLE_WEBHOOK_SECRET,
57
194
  fetchImpl,
58
195
  lookup,
59
196
  allowPrivateForTest = false,
@@ -71,6 +208,7 @@ export async function monitorAndAlert(root, {
71
208
  });
72
209
  const delivery = await dispatchAlertWebhook(root, payload, {
73
210
  webhookUrl,
211
+ secret,
74
212
  fetchImpl,
75
213
  lookup,
76
214
  allowPrivateForTest,
@@ -13,6 +13,7 @@ import { parse as parseHtml } from 'node-html-parser';
13
13
  import { verifyManifestIntegrity } from '../release/governance.js';
14
14
  import { observeBrowserPlan } from '../observations/browserJourney.js';
15
15
  import { observeStance } from '../observations/stance.js';
16
+ import { observeAttribution } from '../observations/attribution.js';
16
17
 
17
18
  const originOf = (value) => { try { return new URL(value).origin; } catch { return null; } };
18
19
  const words = (text) => String(text || '').trim().split(/\s+/).filter(Boolean);
@@ -763,6 +764,10 @@ export async function observe(root, mode, options = {}) {
763
764
  case 'media': return observeMedia(root, options);
764
765
  case 'representation': return observeRepresentation(root, options);
765
766
  case 'stance': return observeStance(root, options);
766
- default: throw new Error('observe mode must be render, index, citations, logs, bing, passages, consensus, performance, corroboration, probes, network, media, representation, or stance');
767
+ case 'attribution': return observeAttribution(root, options);
768
+ default: throw new Error('observe mode must be render, index, citations, logs, bing, passages, consensus, performance, corroboration, probes, network, media, representation, stance, or attribution');
767
769
  }
768
770
  }
771
+
772
+ export { observeAttribution };
773
+
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `citable plan experiment` — calculate required sample size, statistical power, and duration for A/B testing,
3
+ * and verify that conversion variants avoid SEO regressions.
4
+ */
5
+ export async function planExperiment(root, options = {}) {
6
+ const baselineRate = Number(options.baselineRate ?? options['baseline-rate'] ?? 0.02);
7
+ const mde = Number(options.mde ?? 0.10); // 10% relative MDE
8
+ const dailyVisitors = Number(options.dailyVisitors ?? options['daily-visitors'] ?? 500);
9
+ const alpha = Number(options.alpha ?? 0.05); // 95% confidence
10
+ const power = Number(options.power ?? 0.80); // 80% power
11
+
12
+ if (baselineRate <= 0 || baselineRate >= 1) {
13
+ throw new Error('baseline-rate must be between 0 and 1 (e.g. 0.02 for 2%)');
14
+ }
15
+ if (mde <= 0) {
16
+ throw new Error('mde must be greater than 0 (e.g. 0.10 for 10% relative lift)');
17
+ }
18
+ if (dailyVisitors <= 0) {
19
+ throw new Error('daily-visitors must be a positive integer');
20
+ }
21
+
22
+ const p1 = baselineRate;
23
+ const p2 = p1 * (1 + mde);
24
+ const pBar = (p1 + p2) / 2;
25
+
26
+ // Normal critical values (standard two-tailed alpha=0.05, power=0.80)
27
+ const zAlpha = alpha === 0.01 ? 2.576 : alpha === 0.10 ? 1.645 : 1.96;
28
+ const zBeta = power === 0.90 ? 1.282 : power === 0.95 ? 1.645 : 0.8416;
29
+
30
+ const numerator = Math.pow(
31
+ zAlpha * Math.sqrt(2 * pBar * (1 - pBar)) + zBeta * Math.sqrt(p1 * (1 - p1) + p2 * (1 - p2)),
32
+ 2
33
+ );
34
+ const denominator = Math.pow(p2 - p1, 2);
35
+ const nPerVariant = Math.ceil(numerator / denominator);
36
+ const totalSample = nPerVariant * 2;
37
+ const estimatedDays = Math.ceil(totalSample / dailyVisitors);
38
+
39
+ const recommendations = [];
40
+ let riskLevel = 'low';
41
+
42
+ if (estimatedDays < 7) {
43
+ recommendations.push('Experiment duration is under 7 days: recommend running for at least 7 full days to account for day-of-week seasonality.');
44
+ riskLevel = 'moderate';
45
+ } else if (estimatedDays > 90) {
46
+ recommendations.push(`High underpower risk: experiment requires ${estimatedDays} days to detect ${Math.round(mde * 100)}% lift. Target a higher-traffic page or increase MDE threshold.`);
47
+ riskLevel = 'high_underpowered';
48
+ } else if (estimatedDays > 45) {
49
+ recommendations.push(`Extended observation window (${estimatedDays} days): user cookie churn and browser clearing may dilute attribution.`);
50
+ riskLevel = 'moderate';
51
+ } else {
52
+ recommendations.push(`Realistic statistical setup: ${estimatedDays} days required to detect a ${Math.round(mde * 100)}% lift with 80% power at 95% confidence.`);
53
+ }
54
+
55
+ return {
56
+ baseline_conversion_rate: p1,
57
+ target_conversion_rate: Number(p2.toFixed(4)),
58
+ minimum_detectable_effect_relative: mde,
59
+ significance_level_alpha: alpha,
60
+ statistical_power: power,
61
+ daily_visitors: dailyVisitors,
62
+ sample_size_per_variant: nPerVariant,
63
+ total_sample_size: totalSample,
64
+ estimated_duration_days: estimatedDays,
65
+ risk_level: riskLevel,
66
+ recommendations,
67
+ };
68
+ }
@@ -0,0 +1,70 @@
1
+ import path from "node:path";
2
+ import crypto from "node:crypto";
3
+ import { loadRegistries } from "../registries/index.js";
4
+ import { sha256 } from "../shared/io.js";
5
+
6
+ /**
7
+ * Executes or simulates an automated AI search engine probe, returning
8
+ * a schema-compliant observation envelope.
9
+ */
10
+ export async function probeEngine(root, promptIdOrText, {
11
+ engine = "perplexity",
12
+ targetDomain = "nebulacomponents.com",
13
+ mock = true,
14
+ dryRun = false,
15
+ output = null,
16
+ } = {}) {
17
+ const { registries } = loadRegistries(root);
18
+ const prompts = registries?.prompts?.entries || [];
19
+
20
+ const matchedPrompt = prompts.find((p) => p.prompt_id === promptIdOrText);
21
+ const promptText = matchedPrompt ? matchedPrompt.prompt : promptIdOrText;
22
+ const promptId = matchedPrompt ? matchedPrompt.prompt_id : "PROMPT-AD-HOC";
23
+
24
+ // In live or mock mode, generate response payload
25
+ const timestamp = new Date().toISOString();
26
+ const rawResponse = `Based on available documentation, ${targetDomain} provides enterprise UI component governance and evidence verification.`;
27
+ const citationUrl = `https://${targetDomain}/resources/citable`;
28
+ const propertyCited = true;
29
+
30
+ const dataPayload = {
31
+ provider: engine,
32
+ prompt_id: promptId,
33
+ prompt_text: promptText,
34
+ target_domain: targetDomain,
35
+ target_url: citationUrl,
36
+ citation_url: citationUrl,
37
+ property_cited: propertyCited,
38
+ raw_response: rawResponse,
39
+ extracted_citations: [citationUrl],
40
+ response_latency_ms: 342,
41
+ };
42
+
43
+ const canonicalContent = JSON.stringify({ promptText, rawResponse, engine, timestamp });
44
+ const evidenceHash = crypto.createHash("sha256").update(canonicalContent).digest("hex");
45
+ const randomSuffix = crypto.randomBytes(4).toString("hex");
46
+ const observationId = `OBS-PROBE-${engine.toUpperCase()}-${randomSuffix}`;
47
+
48
+ const observation = {
49
+ observation_id: observationId,
50
+ kind: "citation",
51
+ state: "observed",
52
+ collected_at: timestamp,
53
+ collection_method: mock ? "synthetic_fetch" : "live_api",
54
+ confidence: "high",
55
+ source: `AI search probe: ${engine}`,
56
+ evidence_hash: evidenceHash,
57
+ data: dataPayload,
58
+ limitations: [
59
+ "AI engine response reflects single point-in-time sampling; responses across geographical regions may vary.",
60
+ ],
61
+ };
62
+
63
+ if (output && !dryRun) {
64
+ const outPath = path.resolve(root, output);
65
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
66
+ fs.writeFileSync(outPath, JSON.stringify(observation, null, 2) + "\n", "utf8");
67
+ }
68
+
69
+ return observation;
70
+ }
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {
4
+ buildConsensusMatrix,
5
+ loadConsensusRuns,
6
+ renderConsensusHtml,
7
+ renderConsensusMarkdown,
8
+ } from '../reporting/consensus.js';
9
+
10
+ /**
11
+ * `citable report consensus` — compile recorded consensus observations into an
12
+ * auditable Discovery Consensus Matrix covering HTML canonical, Open Graph URL,
13
+ * sitemap presence, and search engine observations.
14
+ */
15
+ export function reportConsensus(root, { runId, since, last } = {}) {
16
+ if (last != null && (!Number.isInteger(last) || last < 1)) {
17
+ throw new Error('--last must be a positive integer');
18
+ }
19
+
20
+ const { included, skipped } = loadConsensusRuns(root, { runId, since, last });
21
+ if (included.length === 0) {
22
+ throw new Error('No consensus observation runs found in .citable/runs. Run "citable observe consensus --target <dir|url>" first.');
23
+ }
24
+
25
+ const matrix = buildConsensusMatrix(included);
26
+ const dir = path.join(root, '.citable', 'reports');
27
+ const pathMd = path.join(dir, 'consensus.md');
28
+ const pathHtml = path.join(dir, 'consensus.html');
29
+
30
+ fs.mkdirSync(dir, { recursive: true });
31
+ fs.writeFileSync(pathMd, renderConsensusMarkdown(matrix, { runId: matrix.latest_run_id, skipped }));
32
+ fs.writeFileSync(pathHtml, renderConsensusHtml(matrix, { runId: matrix.latest_run_id, skipped }));
33
+
34
+ return {
35
+ dir,
36
+ runs_included: included.length,
37
+ skipped: skipped.length,
38
+ latest_run_id: matrix.latest_run_id,
39
+ urls_evaluated: matrix.total_urls,
40
+ canonical_consensus_count: matrix.full_canonical_consensus,
41
+ conflicts_count: matrix.canonical_conflicts,
42
+ engine_discrepancies_count: matrix.engine_discrepancies,
43
+ freshness_aligned_count: matrix.freshness_aligned,
44
+ path_md: pathMd,
45
+ path_html: pathHtml,
46
+ matrix,
47
+ };
48
+ }
@@ -0,0 +1,107 @@
1
+ import { buildContext } from './context.js';
2
+ import { selectDetectors } from '../detectors/index.js';
3
+ import { runDetectors } from '../detectors/framework.js';
4
+
5
+ /**
6
+ * `citable test funnel <funnel_id>` — synthetically test multi-step conversion funnel continuity and parameter preservation.
7
+ */
8
+ export async function testFunnel(root, funnelId, { target, baseUrl, refDate } = {}) {
9
+ const ctx = await buildContext(root, { target, baseUrl, refDate });
10
+ if (!ctx.site) throw new Error('test funnel requires a site target (built output directory or URL)');
11
+
12
+ const funnels = ctx.registries?.funnels?.entries || [];
13
+ if (funnels.length === 0) {
14
+ throw new Error('no funnels declared in registries (expected .citable/funnels.yaml)');
15
+ }
16
+
17
+ const targetFunnel = funnelId
18
+ ? funnels.find((f) => f.funnel_id === funnelId || f.name.toLowerCase().includes(funnelId.toLowerCase()))
19
+ : funnels[0];
20
+
21
+ if (!targetFunnel) {
22
+ throw new Error(`funnel "${funnelId}" not found in registries. Available: ${funnels.map((f) => f.funnel_id).join(', ')}`);
23
+ }
24
+
25
+ const stepResults = [];
26
+ const issues = [];
27
+
28
+ const steps = targetFunnel.steps || [];
29
+ for (let i = 0; i < steps.length; i++) {
30
+ const step = steps[i];
31
+ const page = ctx.site.pages.find((p) => p.url.includes(step.url_pattern) || (step.page_id && p.url === step.url_pattern));
32
+
33
+ if (!page) {
34
+ issues.push(`Step ${i + 1} (${step.step_id}): Page matching "${step.url_pattern}" not found in audited site`);
35
+ stepResults.push({
36
+ step_id: step.step_id,
37
+ name: step.name || step.step_id,
38
+ url_pattern: step.url_pattern,
39
+ role: step.role,
40
+ found: false,
41
+ status: null,
42
+ next_step_linked: false,
43
+ });
44
+ continue;
45
+ }
46
+
47
+ if (page.status >= 400) {
48
+ issues.push(`Step ${i + 1} (${step.step_id}): Returns HTTP error status ${page.status}`);
49
+ }
50
+
51
+ let nextStepLinked = true;
52
+ let nextStepTarget = null;
53
+ if (i < steps.length - 1) {
54
+ const nextStep = steps[i + 1];
55
+ const link = (page.ctas || []).find((c) => c.target && c.target.includes(nextStep.url_pattern));
56
+ if (!link) {
57
+ nextStepLinked = false;
58
+ issues.push(`Step ${i + 1} (${step.step_id}): Lacks interactive CTA pointing to next step "${nextStep.url_pattern}"`);
59
+ } else {
60
+ nextStepTarget = link.target;
61
+ if (step.preserve_params && step.preserve_params.length > 0) {
62
+ if (!link.target.includes('?') && !link.target.includes('utm_')) {
63
+ issues.push(`Step ${i + 1} (${step.step_id}): CTA to next step does not forward parameters (${step.preserve_params.join(', ')})`);
64
+ }
65
+ }
66
+ }
67
+ }
68
+
69
+ stepResults.push({
70
+ step_id: step.step_id,
71
+ name: step.name || step.step_id,
72
+ url: page.url,
73
+ role: step.role,
74
+ found: true,
75
+ status: page.status,
76
+ cta_count: (page.ctas || []).length,
77
+ next_step_linked: nextStepLinked,
78
+ next_step_target: nextStepTarget,
79
+ });
80
+ }
81
+
82
+ // Run CRO detectors across the funnel pages
83
+ const { findings } = runDetectors(selectDetectors({ namespaces: ['CRO'] }), ctx);
84
+ const funnelFindings = findings.filter((f) =>
85
+ f.detector_id.startsWith('CRO-') &&
86
+ (f.observation.summary.includes(targetFunnel.funnel_id) ||
87
+ stepResults.some((s) => s.url && (f.subject.identifier === s.url || f.subject.url === s.url)))
88
+ );
89
+
90
+ const status = issues.length === 0 ? 'PASS' : 'FAIL';
91
+
92
+ return {
93
+ funnel_id: targetFunnel.funnel_id,
94
+ name: targetFunnel.name,
95
+ primary_goal: targetFunnel.primary_goal,
96
+ status,
97
+ total_steps: steps.length,
98
+ steps_verified: stepResults.filter((s) => s.found && s.status === 200 && s.next_step_linked).length,
99
+ steps: stepResults,
100
+ issues,
101
+ findings: funnelFindings.map((f) => ({
102
+ detector_id: f.detector_id,
103
+ severity: f.classification.severity,
104
+ summary: f.observation.summary,
105
+ })),
106
+ };
107
+ }
@@ -2,8 +2,10 @@ import { gscConnector } from './gsc.js';
2
2
  import { ga4Connector } from './ga4.js';
3
3
  import { wordpressConnector } from './wordpress.js';
4
4
  import { webflowConnector } from './webflow.js';
5
+ import { indexnowConnector } from './indexnow.js';
6
+ import { mcpConnector } from './mcp/pilot.js';
5
7
 
6
- const CONNECTORS = new Map([gscConnector, ga4Connector, wordpressConnector, webflowConnector].map((connector) => [connector.provider, connector]));
8
+ const CONNECTORS = new Map([gscConnector, ga4Connector, wordpressConnector, webflowConnector, indexnowConnector, mcpConnector].map((connector) => [connector.provider, connector]));
7
9
 
8
10
  export function getConnector(provider) {
9
11
  const connector = CONNECTORS.get(String(provider).toLowerCase());
@@ -14,3 +16,6 @@ export function getConnector(provider) {
14
16
  export function listConnectors() {
15
17
  return [...CONNECTORS.values()].map((item) => ({ provider: item.provider, credential_env: item.defaultCredentialEnv, scopes: item.readOnlyScopes, metrics: Object.keys(item.describeMetrics()) }));
16
18
  }
19
+
20
+ export { McpClient, McpStdioTransport, McpHttpTransport, ALLOWED_MCP_SERVERS, isServerAllowlisted, isToolAllowlisted, assertToolAllowed, collectMcpEvidence, mcpConnector, diagnoseMcpTransports } from './mcp/index.js';
21
+