@nebulacomponents/citable 1.15.0 → 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 (212) hide show
  1. package/CHANGELOG.md +32 -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/release/surfaces.json +2 -2
  151. package/schemas/experiment.schema.json +1 -1
  152. package/schemas/fleet.schema.json +93 -0
  153. package/schemas/funnel.schema.json +116 -0
  154. package/schemas/mcp-transport-envelope.schema.json +244 -0
  155. package/schemas/observation.schema.json +1 -1
  156. package/skill/SKILL.md +5 -1
  157. package/skill/commands/README.md +3 -1
  158. package/skill/commands/governance.md +12 -0
  159. package/skill/commands/measurement.md +20 -0
  160. package/skill/commands/reporting.md +16 -0
  161. package/src/cli/index.js +383 -8
  162. package/src/commands/attributeImpact.js +54 -0
  163. package/src/commands/connect.js +3 -0
  164. package/src/commands/edgeRules.js +120 -0
  165. package/src/commands/exceptions.js +241 -0
  166. package/src/commands/fleet.js +104 -0
  167. package/src/commands/generateLlmsTxt.js +111 -0
  168. package/src/commands/inspectAeo.js +74 -0
  169. package/src/commands/inspectCro.js +82 -0
  170. package/src/commands/inspectGeo.js +52 -0
  171. package/src/commands/inspectSerp.js +317 -0
  172. package/src/commands/lintComponents.js +132 -0
  173. package/src/commands/monitor.js +138 -0
  174. package/src/commands/observe.js +6 -1
  175. package/src/commands/planExperiment.js +68 -0
  176. package/src/commands/probe.js +70 -0
  177. package/src/commands/reportConsensus.js +48 -0
  178. package/src/commands/testFunnel.js +107 -0
  179. package/src/connectors/index.js +6 -1
  180. package/src/connectors/indexnow.js +431 -0
  181. package/src/connectors/mcp/allowlist.js +107 -0
  182. package/src/connectors/mcp/client.js +239 -0
  183. package/src/connectors/mcp/diagnostics.js +214 -0
  184. package/src/connectors/mcp/gscServer.js +155 -0
  185. package/src/connectors/mcp/http.js +130 -0
  186. package/src/connectors/mcp/index.js +7 -0
  187. package/src/connectors/mcp/pilot.js +138 -0
  188. package/src/connectors/mcp/pilotServer.js +80 -0
  189. package/src/connectors/mcp/sanitizer.js +38 -0
  190. package/src/connectors/mcp/stdio.js +178 -0
  191. package/src/crawler/ipRanges.js +161 -0
  192. package/src/detectors/agent.js +127 -0
  193. package/src/detectors/ans.js +282 -0
  194. package/src/detectors/crawl.js +46 -0
  195. package/src/detectors/cro.js +777 -0
  196. package/src/detectors/cwv.js +38 -1
  197. package/src/detectors/evd.js +101 -0
  198. package/src/detectors/framework.js +1 -1
  199. package/src/detectors/geoReco.js +408 -0
  200. package/src/detectors/index.js +4 -2
  201. package/src/detectors/link.js +112 -1
  202. package/src/detectors/page.js +101 -0
  203. package/src/detectors/schemaData.js +529 -0
  204. package/src/detectors/tech.js +306 -0
  205. package/src/extractor/page.js +186 -2
  206. package/src/extractor/site.js +9 -2
  207. package/src/installer/index.js +45 -2
  208. package/src/monitoring/alertDelivery.js +101 -7
  209. package/src/observations/attribution.js +217 -0
  210. package/src/registries/index.js +14 -0
  211. package/src/reporting/consensus.js +416 -0
  212. package/src/reporting/executiveExport.js +132 -0
@@ -0,0 +1,120 @@
1
+ import path from "node:path";
2
+ import { loadRegistries } from "../registries/index.js";
3
+
4
+ /**
5
+ * Generates edge rules (redirects, Cloudflare WAF expressions, or Worker scripts)
6
+ * from Citable registries.
7
+ */
8
+ export async function exportEdgeRules(root, { format = "cloudflare-redirects", output = null } = {}) {
9
+ const { registries } = loadRegistries(root);
10
+ const crawlers = registries?.crawlers?.entries || [];
11
+ const pages = registries?.pages?.entries || [];
12
+
13
+ let content = "";
14
+
15
+ if (format === "cloudflare-redirects") {
16
+ const lines = ["# Cloudflare Pages / Edge _redirects generated by Citable", ""];
17
+ for (const page of pages) {
18
+ if (page.redirect_target) {
19
+ lines.push(`${page.path || page.url} ${page.redirect_target} 301`);
20
+ }
21
+ }
22
+ if (lines.length <= 2) {
23
+ lines.push("# No explicit redirects found in pages.yaml");
24
+ }
25
+ content = lines.join("\n") + "\n";
26
+ } else if (format === "cloudflare-waf") {
27
+ // Generate Cloudflare Firewall Expression for blocked crawler bots
28
+ const blockedBots = crawlers.filter((c) => c.status === "blocked" || c.policy === "disallow" || c.policy === "block");
29
+ if (blockedBots.length > 0) {
30
+ const expressions = blockedBots.map((c) => `(http.user_agent contains "${c.user_agent || c.crawler_id}")`);
31
+ content = `# Cloudflare Custom WAF Expression\nAction: Block\nExpression:\n${expressions.join(" or ")}\n`;
32
+ } else {
33
+ content = "# Cloudflare Custom WAF Expression\n# No crawlers marked as blocked in crawlers.yaml\n";
34
+ }
35
+ } else if (format === "cloudflare-worker") {
36
+ content = `// Cloudflare Worker generated by Citable Edge Adapter
37
+ export default {
38
+ async fetch(request, env, ctx) {
39
+ const url = new URL(request.url);
40
+
41
+ // Dynamic edge routing for /llms.txt
42
+ if (url.pathname === "/llms.txt") {
43
+ return fetch(new URL("/resources/citable/llms.txt", url.origin));
44
+ }
45
+
46
+ const response = await fetch(request);
47
+
48
+ // Apply security and canonical headers if missing
49
+ const newHeaders = new Headers(response.headers);
50
+ if (!newHeaders.has("X-Content-Type-Options")) {
51
+ newHeaders.set("X-Content-Type-Options", "nosniff");
52
+ }
53
+
54
+ return new Response(response.body, {
55
+ status: response.status,
56
+ statusText: response.statusText,
57
+ headers: newHeaders,
58
+ });
59
+ },
60
+ };\n`;
61
+ } else {
62
+ throw new Error(`unsupported edge format "${format}". Supported: cloudflare-redirects, cloudflare-waf, cloudflare-worker`);
63
+ }
64
+
65
+ if (output) {
66
+ fs.writeFileSync(path.resolve(root, output), content, "utf8");
67
+ }
68
+
69
+ return {
70
+ format,
71
+ output_path: output,
72
+ content,
73
+ rules_count: content.split("\n").filter((l) => l && !l.startsWith("#")).length,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Tests edge routing and header preservation for Cloudflare-backed properties.
79
+ */
80
+ export async function testEdgeRules(root, { provider = "cloudflare", baseUrl = "https://example.test", headers = {} } = {}) {
81
+ const checks = [];
82
+
83
+ // Check 1: Cloudflare Ray ID header expectation
84
+ const cfRayPresent = Boolean(headers["cf-ray"] || headers["CF-RAY"]);
85
+ checks.push({
86
+ check_id: "EDGE-CF-RAY",
87
+ name: "Cloudflare Edge Ray ID Header",
88
+ passed: provider === "cloudflare" ? cfRayPresent : true,
89
+ detail: cfRayPresent ? `Observed ${headers["cf-ray"] || headers["CF-RAY"]}` : "cf-ray header absent (expected behind Cloudflare edge)",
90
+ });
91
+
92
+ // Check 2: Content Type Options security header
93
+ const nosniff = (headers["x-content-type-options"] || "").toLowerCase() === "nosniff";
94
+ checks.push({
95
+ check_id: "EDGE-SEC-NOSNIFF",
96
+ name: "X-Content-Type-Options Header",
97
+ passed: nosniff,
98
+ detail: nosniff ? "nosniff header present" : "x-content-type-options: nosniff missing from edge response",
99
+ });
100
+
101
+ // Check 3: Bot Management / Challenge detection
102
+ const isChallenge = Boolean(headers["cf-mitigated"] === "challenge" || (headers["server"] || "").includes("cloudflare-challenge"));
103
+ checks.push({
104
+ check_id: "EDGE-BOT-CHALLENGE",
105
+ name: "Edge Bot Mitigation Status",
106
+ passed: !isChallenge,
107
+ detail: isChallenge ? "Edge presented CAPTCHA / Managed Challenge to probe" : "Clean pass-through without bot mitigation challenge",
108
+ });
109
+
110
+ const allPassed = checks.every((c) => c.passed);
111
+
112
+ return {
113
+ provider,
114
+ base_url: baseUrl,
115
+ status: allPassed ? "healthy" : "remediation_needed",
116
+ total_checks: checks.length,
117
+ passed_checks: checks.filter((c) => c.passed).length,
118
+ checks,
119
+ };
120
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Governed Exception Lifecycle & Expiry Review.
3
+ *
4
+ * Provides inspection, renewal, and revocation of entries in .citable/exceptions.yaml
5
+ * under strict schema, reviewer authority, and audit trail rules.
6
+ *
7
+ * Governed Invariants:
8
+ * - Expired exceptions fail closed and are surfaced explicitly.
9
+ * - Renewals require an active, authorized reviewer and cannot exceed policy or renewal limits.
10
+ * - Revocations and renewals append immutable audit history events.
11
+ * - Candidate registries are validated against exception.schema.json before saving.
12
+ */
13
+
14
+ import { loadRegistries, saveRegistry } from "../registries/index.js";
15
+ import { validateAgainst } from "../shared/schemaValidator.js";
16
+ import { parseRefDate, sha256 } from "../shared/io.js";
17
+
18
+ export function listExceptions(root, {
19
+ expiredOnly = false,
20
+ expiringSoonDays = 14,
21
+ refDate = null,
22
+ } = {}) {
23
+ const { registries, problems } = loadRegistries(root);
24
+ if (problems.length) throw new Error(`registry validation failed: ${problems.join("; ")}`);
25
+
26
+ const ref = parseRefDate(refDate);
27
+ const entries = registries.exceptions?.entries || [];
28
+
29
+ const evaluated = entries.map((entry) => {
30
+ const expiresAt = new Date(entry.expires_at);
31
+ const isExpired = expiresAt < ref;
32
+ const daysRemaining = Math.ceil((expiresAt.getTime() - ref.getTime()) / 86400000);
33
+ const isExpiringSoon = !isExpired && daysRemaining <= Number(expiringSoonDays) && daysRemaining >= 0;
34
+
35
+ let computedStatus = entry.status;
36
+ if (entry.status === "revoked") computedStatus = "revoked";
37
+ else if (entry.status === "superseded") computedStatus = "superseded";
38
+ else if (isExpired) computedStatus = "expired";
39
+ else if (isExpiringSoon) computedStatus = "expiring_soon";
40
+
41
+ return {
42
+ exception_id: entry.exception_id,
43
+ policy_id: entry.policy_id,
44
+ status: entry.status,
45
+ computed_status: computedStatus,
46
+ is_expired: isExpired,
47
+ is_expiring_soon: isExpiringSoon,
48
+ days_remaining: daysRemaining,
49
+ created_at: entry.created_at,
50
+ expires_at: entry.expires_at,
51
+ renewal_count: entry.renewal_count,
52
+ renewal_limit: entry.renewal_limit,
53
+ owner_reviewer_id: entry.owner_reviewer_id,
54
+ reason: entry.reason,
55
+ finding_ids: entry.finding_ids,
56
+ };
57
+ });
58
+
59
+ const filtered = expiredOnly
60
+ ? evaluated.filter((e) => e.is_expired)
61
+ : evaluated;
62
+
63
+ const summary = {
64
+ total: evaluated.length,
65
+ active: evaluated.filter((e) => e.computed_status === "approved").length,
66
+ expiring_soon: evaluated.filter((e) => e.is_expiring_soon).length,
67
+ expired: evaluated.filter((e) => e.is_expired).length,
68
+ revoked: evaluated.filter((e) => e.status === "revoked").length,
69
+ superseded: evaluated.filter((e) => e.status === "superseded").length,
70
+ };
71
+
72
+ return {
73
+ exceptions: filtered,
74
+ summary,
75
+ };
76
+ }
77
+
78
+ export function renewException(root, {
79
+ id,
80
+ until,
81
+ reviewer,
82
+ evidence = null,
83
+ note = null,
84
+ write = false,
85
+ refDate = null,
86
+ } = {}) {
87
+ if (!id) throw new Error("--id <exception-id> is required to renew an exception");
88
+ if (!until) throw new Error("--until <YYYY-MM-DD> is required to renew an exception");
89
+ if (!reviewer) throw new Error("--reviewer <reviewer-id> is required to renew an exception");
90
+
91
+ const { registries, problems } = loadRegistries(root);
92
+ if (problems.length) throw new Error(`registry validation failed: ${problems.join("; ")}`);
93
+
94
+ const exceptions = registries.exceptions?.entries || [];
95
+ const entryIndex = exceptions.findIndex((e) => e.exception_id === id);
96
+ if (entryIndex === -1) throw new Error(`exception not found: ${id}`);
97
+
98
+ const entry = structuredClone(exceptions[entryIndex]);
99
+ if (entry.status === "revoked") throw new Error(`cannot renew revoked exception: ${id}`);
100
+ if (entry.status === "superseded") throw new Error(`cannot renew superseded exception: ${id}`);
101
+
102
+ // Verify reviewer authority
103
+ const reviewers = registries.reviewers?.entries || [];
104
+ const rev = reviewers.find((r) => r.reviewer_id === reviewer);
105
+ if (!rev || rev.status !== "active") {
106
+ throw new Error(`reviewer not found or inactive: ${reviewer}`);
107
+ }
108
+
109
+ // Check renewal limits
110
+ if (entry.renewal_count >= entry.renewal_limit) {
111
+ throw new Error(`exception ${id} has reached its renewal limit (${entry.renewal_limit})`);
112
+ }
113
+
114
+ const ref = parseRefDate(refDate);
115
+ const untilIso = until.includes("T") ? until : `${until}T23:59:59.999Z`;
116
+ const newExpiry = new Date(untilIso);
117
+
118
+ if (Number.isNaN(newExpiry.getTime())) {
119
+ throw new Error(`invalid renewal date format: ${until}`);
120
+ }
121
+ if (newExpiry <= ref) {
122
+ throw new Error("renewal date must be in the future relative to the reference date");
123
+ }
124
+ if (newExpiry <= new Date(entry.expires_at)) {
125
+ throw new Error("renewal date must extend past current expiration date");
126
+ }
127
+
128
+ // Check review policy limits if defined
129
+ const policies = registries.review_policies?.entries || [];
130
+ const policy = policies.find((p) => p.policy_id === entry.policy_id);
131
+ if (policy) {
132
+ if (entry.renewal_count + 1 > policy.max_renewals) {
133
+ throw new Error(`renewal would exceed policy max_renewals (${policy.max_renewals})`);
134
+ }
135
+ const durationDays = (newExpiry.getTime() - new Date(entry.created_at).getTime()) / 86400000;
136
+ if (durationDays > policy.max_exception_days) {
137
+ throw new Error(`renewal would exceed policy max_exception_days (${policy.max_exception_days} days)`);
138
+ }
139
+ }
140
+
141
+ entry.renewal_count += 1;
142
+ entry.expires_at = newExpiry.toISOString();
143
+
144
+ if (evidence && !entry.evidence_ids.includes(evidence)) {
145
+ entry.evidence_ids.push(evidence);
146
+ entry.evidence_hashes[evidence] = sha256(evidence);
147
+ }
148
+
149
+ entry.audit_history.push({
150
+ timestamp: new Date().toISOString(),
151
+ actor_reviewer_id: reviewer,
152
+ action: "renewed",
153
+ note: note || `Renewed until ${until} by ${reviewer}`,
154
+ });
155
+
156
+ const updatedEntries = [...exceptions];
157
+ updatedEntries[entryIndex] = entry;
158
+
159
+ const candidate = {
160
+ ...registries.exceptions,
161
+ updated: new Date().toISOString(),
162
+ entries: updatedEntries,
163
+ };
164
+
165
+ const check = validateAgainst("exception.schema.json", candidate);
166
+ if (!check.valid) {
167
+ throw new Error(`renewed exception violates schema: ${check.errors.join("; ")}`);
168
+ }
169
+
170
+ if (write) {
171
+ saveRegistry(root, "exceptions", candidate);
172
+ }
173
+
174
+ return {
175
+ exception_id: id,
176
+ expires_at: entry.expires_at,
177
+ renewal_count: entry.renewal_count,
178
+ renewal_limit: entry.renewal_limit,
179
+ written: write,
180
+ };
181
+ }
182
+
183
+ export function invalidateException(root, {
184
+ id,
185
+ reason,
186
+ reviewer,
187
+ write = false,
188
+ } = {}) {
189
+ if (!id) throw new Error("--id <exception-id> is required to invalidate an exception");
190
+ if (!reason) throw new Error("--reason <text> is required to invalidate an exception");
191
+ if (!reviewer) throw new Error("--reviewer <reviewer-id> is required to record who revoked the exception");
192
+
193
+ const { registries, problems } = loadRegistries(root);
194
+ if (problems.length) throw new Error(`registry validation failed: ${problems.join("; ")}`);
195
+
196
+ const exceptions = registries.exceptions?.entries || [];
197
+ const entryIndex = exceptions.findIndex((e) => e.exception_id === id);
198
+ if (entryIndex === -1) throw new Error(`exception not found: ${id}`);
199
+
200
+ const entry = structuredClone(exceptions[entryIndex]);
201
+ if (entry.status === "revoked") throw new Error(`exception ${id} is already revoked`);
202
+
203
+ // Verify reviewer authority
204
+ const reviewers = registries.reviewers?.entries || [];
205
+ const rev = reviewers.find((r) => r.reviewer_id === reviewer);
206
+ if (!rev || rev.status !== "active") {
207
+ throw new Error(`reviewer not found or inactive: ${reviewer}`);
208
+ }
209
+
210
+ entry.status = "revoked";
211
+ entry.audit_history.push({
212
+ timestamp: new Date().toISOString(),
213
+ actor_reviewer_id: reviewer,
214
+ action: "revoked",
215
+ note: reason,
216
+ });
217
+
218
+ const updatedEntries = [...exceptions];
219
+ updatedEntries[entryIndex] = entry;
220
+
221
+ const candidate = {
222
+ ...registries.exceptions,
223
+ updated: new Date().toISOString(),
224
+ entries: updatedEntries,
225
+ };
226
+
227
+ const check = validateAgainst("exception.schema.json", candidate);
228
+ if (!check.valid) {
229
+ throw new Error(`revoked exception violates schema: ${check.errors.join("; ")}`);
230
+ }
231
+
232
+ if (write) {
233
+ saveRegistry(root, "exceptions", candidate);
234
+ }
235
+
236
+ return {
237
+ exception_id: id,
238
+ status: "revoked",
239
+ written: write,
240
+ };
241
+ }
@@ -0,0 +1,104 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import { loadRegistries, contextDir } from "../registries/index.js";
4
+ import { audit } from "./audit.js";
5
+
6
+ /**
7
+ * Returns summary of all properties defined in the fleet registry.
8
+ */
9
+ export async function fleetSummary(root) {
10
+ const { registries } = loadRegistries(root);
11
+ const fleet = registries?.fleet?.entries || [];
12
+
13
+ const byEnv = {};
14
+ const byOwner = {};
15
+ for (const p of fleet) {
16
+ byEnv[p.environment] = (byEnv[p.environment] || 0) + 1;
17
+ if (p.owner) byOwner[p.owner] = (byOwner[p.owner] || 0) + 1;
18
+ }
19
+
20
+ return {
21
+ total_properties: fleet.length,
22
+ active_properties: fleet.filter((p) => p.status === "active").length,
23
+ by_environment: byEnv,
24
+ by_owner: byOwner,
25
+ properties: fleet.map((p) => ({
26
+ property_id: p.property_id,
27
+ name: p.name,
28
+ environment: p.environment,
29
+ primary_domain: p.primary_domain,
30
+ status: p.status,
31
+ owner: p.owner,
32
+ })),
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Runs fleet-wide audit across registered fleet properties.
38
+ */
39
+ export async function fleetAudit(root, { propertyId = null, environment = null, scope = null } = {}) {
40
+ const { registries } = loadRegistries(root);
41
+ const fleet = registries?.fleet?.entries || [];
42
+
43
+ if (fleet.length === 0) {
44
+ throw new Error("no fleet properties declared in registries (expected .citable/fleet.yaml)");
45
+ }
46
+
47
+ let targets = fleet.filter((p) => p.status === "active");
48
+ if (propertyId) {
49
+ targets = targets.filter((p) => p.property_id === propertyId || p.primary_domain === propertyId);
50
+ if (targets.length === 0) throw new Error(`property "${propertyId}" not found in active fleet`);
51
+ }
52
+ if (environment) {
53
+ targets = targets.filter((p) => p.environment === environment);
54
+ }
55
+
56
+ const results = [];
57
+ for (const prop of targets) {
58
+ const propTarget = prop.built_output_dir && fs.existsSync(path.resolve(root, prop.built_output_dir))
59
+ ? path.resolve(root, prop.built_output_dir)
60
+ : null;
61
+
62
+ let auditRes = null;
63
+ let error = null;
64
+
65
+ try {
66
+ if (propTarget || prop.base_url) {
67
+ auditRes = await audit(root, {
68
+ target: propTarget || prop.base_url,
69
+ baseUrl: prop.base_url,
70
+ scope,
71
+ });
72
+ }
73
+ } catch (err) {
74
+ error = err.message;
75
+ }
76
+
77
+ results.push({
78
+ property_id: prop.property_id,
79
+ name: prop.name,
80
+ primary_domain: prop.primary_domain,
81
+ environment: prop.environment,
82
+ status: auditRes ? "audited" : (error ? "error" : "skipped"),
83
+ findings_count: auditRes?.findings?.length ?? 0,
84
+ critical_count: auditRes?.findings?.filter((f) => f.severity === "critical").length ?? 0,
85
+ high_count: auditRes?.findings?.filter((f) => f.severity === "high").length ?? 0,
86
+ medium_count: auditRes?.findings?.filter((f) => f.severity === "medium").length ?? 0,
87
+ low_count: auditRes?.findings?.filter((f) => f.severity === "low").length ?? 0,
88
+ error,
89
+ });
90
+ }
91
+
92
+ const totalFindings = results.reduce((acc, r) => acc + r.findings_count, 0);
93
+ const totalCritical = results.reduce((acc, r) => acc + r.critical_count, 0);
94
+ const totalHigh = results.reduce((acc, r) => acc + r.high_count, 0);
95
+
96
+ return {
97
+ fleet_size: targets.length,
98
+ total_findings: totalFindings,
99
+ total_critical: totalCritical,
100
+ total_high: totalHigh,
101
+ fleet_health: totalCritical === 0 && totalHigh === 0 ? "healthy" : (totalCritical > 0 ? "critical" : "needs_attention"),
102
+ properties: results,
103
+ };
104
+ }
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { loadRegistries } from '../registries/index.js';
4
+
5
+ /**
6
+ * Build spec-compliant /llms.txt and /llms-full.txt from Citable registries.
7
+ * Adheres to https://llmstxt.org/ standard:
8
+ * - H1 title with brand/project name
9
+ * - Blockquote summary
10
+ * - Markdown links with descriptions
11
+ * - Optional links section pointing to /llms-full.txt
12
+ */
13
+ export function buildLlmsTxt(root, options = {}) {
14
+ const { registries } = loadRegistries(root);
15
+ const siteConfig = registries.config?.site || {};
16
+ const siteName = siteConfig.name || registries.entities?.entries?.[0]?.canonical_name || 'Project Documentation';
17
+ const siteUrl = options.siteUrl || siteConfig.base_url || 'https://example.test';
18
+ const description = siteConfig.description || registries.entities?.entries?.[0]?.description || 'Evidence-backed documentation and verified specifications.';
19
+
20
+ const pages = (registries.pages?.entries || []).filter((p) => !p.noindex);
21
+ const claims = (registries.claims?.entries || []).filter((c) => ['verified', 'verified_narrowed', 'stated'].includes(c.status));
22
+
23
+ // Build llms.txt
24
+ const lines = [];
25
+ lines.push(`# ${siteName}`);
26
+ lines.push('');
27
+ lines.push(`> ${description}`);
28
+ lines.push('');
29
+
30
+ if (pages.length > 0) {
31
+ lines.push('## Documentation & Key Pages');
32
+ lines.push('');
33
+ for (const p of pages) {
34
+ const url = p.url ? p.url : new URL(p.path || '/', siteUrl).href;
35
+ const title = p.title || p.page_id || 'Untitled page';
36
+ const desc = p.description ? `: ${p.description}` : '';
37
+ lines.push(`- [${title}](${url})${desc}`);
38
+ }
39
+ lines.push('');
40
+ }
41
+
42
+ if (claims.length > 0) {
43
+ lines.push('## Verified Capabilities & Claims');
44
+ lines.push('');
45
+ for (const c of claims) {
46
+ const target = c.publication_surfaces?.[0] || siteUrl;
47
+ const fullUrl = target.startsWith('http') ? target : new URL(target, siteUrl).href;
48
+ lines.push(`- [${c.claim}](${fullUrl}): Verified claim (${c.claim_type})`);
49
+ }
50
+ lines.push('');
51
+ }
52
+
53
+ lines.push('## Optional');
54
+ lines.push('');
55
+ const fullUrl = new URL('/llms-full.txt', siteUrl).href;
56
+ lines.push(`- [Full Content Directory](${fullUrl}): Complete uncompressed documentation representation`);
57
+ lines.push('');
58
+
59
+ const llmsTxt = lines.join('\n');
60
+
61
+ // Build llms-full.txt
62
+ const fullLines = [];
63
+ fullLines.push(`# ${siteName} (Full Text Specification)`);
64
+ fullLines.push('');
65
+ fullLines.push(`> Complete textual representation of ${siteName} for generative engines and autonomous agents.`);
66
+ fullLines.push('');
67
+ for (const p of pages) {
68
+ const url = p.url ? p.url : new URL(p.path || '/', siteUrl).href;
69
+ fullLines.push(`### ${p.title || p.page_id}`);
70
+ fullLines.push(`- URL: ${url}`);
71
+ if (p.description) fullLines.push(`- Description: ${p.description}`);
72
+ if (p.published_claims?.length) fullLines.push(`- Published Claims: ${p.published_claims.join(', ')}`);
73
+ fullLines.push('');
74
+ }
75
+ const llmsFullTxt = fullLines.join('\n');
76
+
77
+ return {
78
+ siteName,
79
+ siteUrl,
80
+ llmsTxt,
81
+ llmsFullTxt,
82
+ pageCount: pages.length,
83
+ claimCount: claims.length,
84
+ };
85
+ }
86
+
87
+ export function generateLlmsTxt(root, options = {}) {
88
+ const built = buildLlmsTxt(root, options);
89
+ const outDir = options.output || root;
90
+
91
+ let written = false;
92
+ let llmsTxtPath = null;
93
+ let llmsFullTxtPath = null;
94
+
95
+ if (options.write) {
96
+ fs.mkdirSync(outDir, { recursive: true });
97
+ llmsTxtPath = path.join(outDir, 'llms.txt');
98
+ llmsFullTxtPath = path.join(outDir, 'llms-full.txt');
99
+ fs.writeFileSync(llmsTxtPath, built.llmsTxt, 'utf8');
100
+ fs.writeFileSync(llmsFullTxtPath, built.llmsFullTxt, 'utf8');
101
+ written = true;
102
+ }
103
+
104
+ return {
105
+ ...built,
106
+ written,
107
+ outputDir: outDir,
108
+ llmsTxtPath,
109
+ llmsFullTxtPath,
110
+ };
111
+ }
@@ -0,0 +1,74 @@
1
+ import { buildContext } from './context.js';
2
+ import { registryPageFor, safePath } from '../detectors/framework.js';
3
+ import { selectDetectors } from '../detectors/index.js';
4
+ import { runDetectors } from '../detectors/framework.js';
5
+
6
+ /**
7
+ * `citable inspect aeo <page>` — evaluate page answer extractability, question density,
8
+ * direct lead conciseness, grounded quantitative metrics, and synthesis readiness.
9
+ */
10
+ export async function inspectAeo(root, pageRef, { target, baseUrl, refDate } = {}) {
11
+ const ctx = await buildContext(root, { target, baseUrl, refDate });
12
+ if (!ctx.site) throw new Error('inspect aeo requires a site target (built output directory or URL)');
13
+ const wanted = safePath(pageRef, ctx.site.baseUrl);
14
+ const page = ctx.site.pages.find((p) => safePath(p.url) === wanted || p.sourceFile === pageRef);
15
+ if (!page) throw new Error(`page not found in audited output: ${pageRef}`);
16
+
17
+ const reg = registryPageFor(ctx, page);
18
+
19
+ // Evaluate findings for this single page across ANS namespace
20
+ const single = { ...ctx, site: { ...ctx.site, pages: [page] } };
21
+ const { findings } = runDetectors(selectDetectors({ namespaces: ['ANS'] }), single);
22
+ const aeoFindings = findings.filter((f) => f.subject.identifier === page.url || f.subject.url === page.url);
23
+
24
+ // Analyze question headings and answer leads
25
+ const QUESTION_RX = /^(?:how|what|why|when|where|who|can|is|does|which|should)\b|\?$/i;
26
+ const questionHeadings = (page.headings || []).filter((h) => QUESTION_RX.test(h.text.trim()));
27
+
28
+ // Definitional copula check
29
+ const COPULA_RX = /\b(?:is|are|refers\s+to|denotes|represents|means)\b/i;
30
+ const firstParagraph = (page.paragraphs && page.paragraphs[0]) || '';
31
+ const hasCopularDefinition = COPULA_RX.test(firstParagraph.slice(0, 140));
32
+
33
+ // Structured elements
34
+ const tablesCount = (page.tables || []).length;
35
+ const listsCount = (page.orderedLists || []).length;
36
+
37
+ // Executive summary
38
+ const SUMMARY_RX = /\b(?:executive\s+summary|key\s+takeaways?|summary|overview|at\s+a\s+glance|tl;?dr|quick\s+summary|highlights?)\b/i;
39
+ const hasExecutiveSummary = (page.headings || []).some((h) => SUMMARY_RX.test(h.text));
40
+
41
+ // Determine synthesis posture
42
+ let extractabilityStatus = 'optimal';
43
+ if (aeoFindings.some((f) => f.classification.severity === 'high')) {
44
+ extractabilityStatus = 'obstructed';
45
+ } else if (aeoFindings.length >= 2) {
46
+ extractabilityStatus = 'suboptimal';
47
+ } else if (aeoFindings.length === 1) {
48
+ extractabilityStatus = 'minor_improvements';
49
+ }
50
+
51
+ return {
52
+ url: page.url,
53
+ sourceFile: page.sourceFile,
54
+ status: page.status,
55
+ page_type: reg?.page_type ?? null,
56
+ word_count: page.rawVisibleWordCount || page.wordCount || 0,
57
+ extractability_status: extractabilityStatus,
58
+ headings: {
59
+ total: (page.headings || []).length,
60
+ questions: questionHeadings.map((h) => ({ level: h.level, text: h.text })),
61
+ },
62
+ answer_structure: {
63
+ has_copular_definition: hasCopularDefinition,
64
+ has_executive_summary: hasExecutiveSummary,
65
+ tables_count: tablesCount,
66
+ ordered_lists_count: listsCount,
67
+ },
68
+ findings: aeoFindings.map((f) => ({
69
+ detector_id: f.detector_id,
70
+ severity: f.classification.severity,
71
+ summary: f.observation.summary,
72
+ })),
73
+ };
74
+ }