@nebulacomponents/citable 1.3.1 → 1.5.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 (181) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +49 -9
  3. package/dist/universal/.agents/skills/citable/SKILL.md +14 -3
  4. package/dist/universal/.agents/skills/citable/VERSION +1 -1
  5. package/dist/universal/.agents/skills/citable/commands/README.md +8 -2
  6. package/dist/universal/.agents/skills/citable/commands/measurement.md +22 -5
  7. package/dist/universal/.agents/skills/citable/commands/observe-and-act.md +40 -0
  8. package/dist/universal/.agents/skills/citable/manifest.json +15 -8
  9. package/dist/universal/.agents/skills/citable/references/capability-boundaries.md +8 -8
  10. package/dist/universal/.agents/skills/citable/schemas/connection.schema.json +32 -0
  11. package/dist/universal/.agents/skills/citable/schemas/intervention.schema.json +33 -0
  12. package/dist/universal/.agents/skills/citable/schemas/metric.schema.json +42 -0
  13. package/dist/universal/.agents/skills/citable/schemas/objective.schema.json +65 -0
  14. package/dist/universal/.agents/skills/citable/schemas/observation.schema.json +20 -0
  15. package/dist/universal/.agents/skills/citable/schemas/remediation-spec.schema.json +28 -0
  16. package/dist/universal/.claude/skills/citable/SKILL.md +14 -3
  17. package/dist/universal/.claude/skills/citable/VERSION +1 -1
  18. package/dist/universal/.claude/skills/citable/commands/README.md +8 -2
  19. package/dist/universal/.claude/skills/citable/commands/measurement.md +22 -5
  20. package/dist/universal/.claude/skills/citable/commands/observe-and-act.md +40 -0
  21. package/dist/universal/.claude/skills/citable/manifest.json +15 -8
  22. package/dist/universal/.claude/skills/citable/references/capability-boundaries.md +8 -8
  23. package/dist/universal/.claude/skills/citable/schemas/connection.schema.json +32 -0
  24. package/dist/universal/.claude/skills/citable/schemas/intervention.schema.json +33 -0
  25. package/dist/universal/.claude/skills/citable/schemas/metric.schema.json +42 -0
  26. package/dist/universal/.claude/skills/citable/schemas/objective.schema.json +65 -0
  27. package/dist/universal/.claude/skills/citable/schemas/observation.schema.json +20 -0
  28. package/dist/universal/.claude/skills/citable/schemas/remediation-spec.schema.json +28 -0
  29. package/dist/universal/.cursor/skills/citable/SKILL.md +14 -3
  30. package/dist/universal/.cursor/skills/citable/VERSION +1 -1
  31. package/dist/universal/.cursor/skills/citable/commands/README.md +8 -2
  32. package/dist/universal/.cursor/skills/citable/commands/measurement.md +22 -5
  33. package/dist/universal/.cursor/skills/citable/commands/observe-and-act.md +40 -0
  34. package/dist/universal/.cursor/skills/citable/manifest.json +15 -8
  35. package/dist/universal/.cursor/skills/citable/references/capability-boundaries.md +8 -8
  36. package/dist/universal/.cursor/skills/citable/schemas/connection.schema.json +32 -0
  37. package/dist/universal/.cursor/skills/citable/schemas/intervention.schema.json +33 -0
  38. package/dist/universal/.cursor/skills/citable/schemas/metric.schema.json +42 -0
  39. package/dist/universal/.cursor/skills/citable/schemas/objective.schema.json +65 -0
  40. package/dist/universal/.cursor/skills/citable/schemas/observation.schema.json +20 -0
  41. package/dist/universal/.cursor/skills/citable/schemas/remediation-spec.schema.json +28 -0
  42. package/dist/universal/.gemini/skills/citable/SKILL.md +14 -3
  43. package/dist/universal/.gemini/skills/citable/VERSION +1 -1
  44. package/dist/universal/.gemini/skills/citable/commands/README.md +8 -2
  45. package/dist/universal/.gemini/skills/citable/commands/measurement.md +22 -5
  46. package/dist/universal/.gemini/skills/citable/commands/observe-and-act.md +40 -0
  47. package/dist/universal/.gemini/skills/citable/manifest.json +15 -8
  48. package/dist/universal/.gemini/skills/citable/references/capability-boundaries.md +8 -8
  49. package/dist/universal/.gemini/skills/citable/schemas/connection.schema.json +32 -0
  50. package/dist/universal/.gemini/skills/citable/schemas/intervention.schema.json +33 -0
  51. package/dist/universal/.gemini/skills/citable/schemas/metric.schema.json +42 -0
  52. package/dist/universal/.gemini/skills/citable/schemas/objective.schema.json +65 -0
  53. package/dist/universal/.gemini/skills/citable/schemas/observation.schema.json +20 -0
  54. package/dist/universal/.gemini/skills/citable/schemas/remediation-spec.schema.json +28 -0
  55. package/dist/universal/.github/skills/citable/SKILL.md +14 -3
  56. package/dist/universal/.github/skills/citable/VERSION +1 -1
  57. package/dist/universal/.github/skills/citable/commands/README.md +8 -2
  58. package/dist/universal/.github/skills/citable/commands/measurement.md +22 -5
  59. package/dist/universal/.github/skills/citable/commands/observe-and-act.md +40 -0
  60. package/dist/universal/.github/skills/citable/manifest.json +15 -8
  61. package/dist/universal/.github/skills/citable/references/capability-boundaries.md +8 -8
  62. package/dist/universal/.github/skills/citable/schemas/connection.schema.json +32 -0
  63. package/dist/universal/.github/skills/citable/schemas/intervention.schema.json +33 -0
  64. package/dist/universal/.github/skills/citable/schemas/metric.schema.json +42 -0
  65. package/dist/universal/.github/skills/citable/schemas/objective.schema.json +65 -0
  66. package/dist/universal/.github/skills/citable/schemas/observation.schema.json +20 -0
  67. package/dist/universal/.github/skills/citable/schemas/remediation-spec.schema.json +28 -0
  68. package/dist/universal/.kiro/skills/citable/SKILL.md +14 -3
  69. package/dist/universal/.kiro/skills/citable/VERSION +1 -1
  70. package/dist/universal/.kiro/skills/citable/commands/README.md +8 -2
  71. package/dist/universal/.kiro/skills/citable/commands/measurement.md +22 -5
  72. package/dist/universal/.kiro/skills/citable/commands/observe-and-act.md +40 -0
  73. package/dist/universal/.kiro/skills/citable/manifest.json +15 -8
  74. package/dist/universal/.kiro/skills/citable/references/capability-boundaries.md +8 -8
  75. package/dist/universal/.kiro/skills/citable/schemas/connection.schema.json +32 -0
  76. package/dist/universal/.kiro/skills/citable/schemas/intervention.schema.json +33 -0
  77. package/dist/universal/.kiro/skills/citable/schemas/metric.schema.json +42 -0
  78. package/dist/universal/.kiro/skills/citable/schemas/objective.schema.json +65 -0
  79. package/dist/universal/.kiro/skills/citable/schemas/observation.schema.json +20 -0
  80. package/dist/universal/.kiro/skills/citable/schemas/remediation-spec.schema.json +28 -0
  81. package/dist/universal/.opencode/skills/citable/SKILL.md +14 -3
  82. package/dist/universal/.opencode/skills/citable/VERSION +1 -1
  83. package/dist/universal/.opencode/skills/citable/commands/README.md +8 -2
  84. package/dist/universal/.opencode/skills/citable/commands/measurement.md +22 -5
  85. package/dist/universal/.opencode/skills/citable/commands/observe-and-act.md +40 -0
  86. package/dist/universal/.opencode/skills/citable/manifest.json +15 -8
  87. package/dist/universal/.opencode/skills/citable/references/capability-boundaries.md +8 -8
  88. package/dist/universal/.opencode/skills/citable/schemas/connection.schema.json +32 -0
  89. package/dist/universal/.opencode/skills/citable/schemas/intervention.schema.json +33 -0
  90. package/dist/universal/.opencode/skills/citable/schemas/metric.schema.json +42 -0
  91. package/dist/universal/.opencode/skills/citable/schemas/objective.schema.json +65 -0
  92. package/dist/universal/.opencode/skills/citable/schemas/observation.schema.json +20 -0
  93. package/dist/universal/.opencode/skills/citable/schemas/remediation-spec.schema.json +28 -0
  94. package/dist/universal/.pi/agent/skills/citable/SKILL.md +14 -3
  95. package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
  96. package/dist/universal/.pi/agent/skills/citable/commands/README.md +8 -2
  97. package/dist/universal/.pi/agent/skills/citable/commands/measurement.md +22 -5
  98. package/dist/universal/.pi/agent/skills/citable/commands/observe-and-act.md +40 -0
  99. package/dist/universal/.pi/agent/skills/citable/manifest.json +15 -8
  100. package/dist/universal/.pi/agent/skills/citable/references/capability-boundaries.md +8 -8
  101. package/dist/universal/.pi/agent/skills/citable/schemas/connection.schema.json +32 -0
  102. package/dist/universal/.pi/agent/skills/citable/schemas/intervention.schema.json +33 -0
  103. package/dist/universal/.pi/agent/skills/citable/schemas/metric.schema.json +42 -0
  104. package/dist/universal/.pi/agent/skills/citable/schemas/objective.schema.json +65 -0
  105. package/dist/universal/.pi/agent/skills/citable/schemas/observation.schema.json +20 -0
  106. package/dist/universal/.pi/agent/skills/citable/schemas/remediation-spec.schema.json +28 -0
  107. package/dist/universal/.qoder/skills/citable/SKILL.md +14 -3
  108. package/dist/universal/.qoder/skills/citable/VERSION +1 -1
  109. package/dist/universal/.qoder/skills/citable/commands/README.md +8 -2
  110. package/dist/universal/.qoder/skills/citable/commands/measurement.md +22 -5
  111. package/dist/universal/.qoder/skills/citable/commands/observe-and-act.md +40 -0
  112. package/dist/universal/.qoder/skills/citable/manifest.json +15 -8
  113. package/dist/universal/.qoder/skills/citable/references/capability-boundaries.md +8 -8
  114. package/dist/universal/.qoder/skills/citable/schemas/connection.schema.json +32 -0
  115. package/dist/universal/.qoder/skills/citable/schemas/intervention.schema.json +33 -0
  116. package/dist/universal/.qoder/skills/citable/schemas/metric.schema.json +42 -0
  117. package/dist/universal/.qoder/skills/citable/schemas/objective.schema.json +65 -0
  118. package/dist/universal/.qoder/skills/citable/schemas/observation.schema.json +20 -0
  119. package/dist/universal/.qoder/skills/citable/schemas/remediation-spec.schema.json +28 -0
  120. package/dist/universal/.rovodev/skills/citable/SKILL.md +14 -3
  121. package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
  122. package/dist/universal/.rovodev/skills/citable/commands/README.md +8 -2
  123. package/dist/universal/.rovodev/skills/citable/commands/measurement.md +22 -5
  124. package/dist/universal/.rovodev/skills/citable/commands/observe-and-act.md +40 -0
  125. package/dist/universal/.rovodev/skills/citable/manifest.json +15 -8
  126. package/dist/universal/.rovodev/skills/citable/references/capability-boundaries.md +8 -8
  127. package/dist/universal/.rovodev/skills/citable/schemas/connection.schema.json +32 -0
  128. package/dist/universal/.rovodev/skills/citable/schemas/intervention.schema.json +33 -0
  129. package/dist/universal/.rovodev/skills/citable/schemas/metric.schema.json +42 -0
  130. package/dist/universal/.rovodev/skills/citable/schemas/objective.schema.json +65 -0
  131. package/dist/universal/.rovodev/skills/citable/schemas/observation.schema.json +20 -0
  132. package/dist/universal/.rovodev/skills/citable/schemas/remediation-spec.schema.json +28 -0
  133. package/dist/universal/.trae/skills/citable/SKILL.md +14 -3
  134. package/dist/universal/.trae/skills/citable/VERSION +1 -1
  135. package/dist/universal/.trae/skills/citable/commands/README.md +8 -2
  136. package/dist/universal/.trae/skills/citable/commands/measurement.md +22 -5
  137. package/dist/universal/.trae/skills/citable/commands/observe-and-act.md +40 -0
  138. package/dist/universal/.trae/skills/citable/manifest.json +15 -8
  139. package/dist/universal/.trae/skills/citable/references/capability-boundaries.md +8 -8
  140. package/dist/universal/.trae/skills/citable/schemas/connection.schema.json +32 -0
  141. package/dist/universal/.trae/skills/citable/schemas/intervention.schema.json +33 -0
  142. package/dist/universal/.trae/skills/citable/schemas/metric.schema.json +42 -0
  143. package/dist/universal/.trae/skills/citable/schemas/objective.schema.json +65 -0
  144. package/dist/universal/.trae/skills/citable/schemas/observation.schema.json +20 -0
  145. package/dist/universal/.trae/skills/citable/schemas/remediation-spec.schema.json +28 -0
  146. package/dist/universal/.trae-cn/skills/citable/SKILL.md +14 -3
  147. package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
  148. package/dist/universal/.trae-cn/skills/citable/commands/README.md +8 -2
  149. package/dist/universal/.trae-cn/skills/citable/commands/measurement.md +22 -5
  150. package/dist/universal/.trae-cn/skills/citable/commands/observe-and-act.md +40 -0
  151. package/dist/universal/.trae-cn/skills/citable/manifest.json +15 -8
  152. package/dist/universal/.trae-cn/skills/citable/references/capability-boundaries.md +8 -8
  153. package/dist/universal/.trae-cn/skills/citable/schemas/connection.schema.json +32 -0
  154. package/dist/universal/.trae-cn/skills/citable/schemas/intervention.schema.json +33 -0
  155. package/dist/universal/.trae-cn/skills/citable/schemas/metric.schema.json +42 -0
  156. package/dist/universal/.trae-cn/skills/citable/schemas/objective.schema.json +65 -0
  157. package/dist/universal/.trae-cn/skills/citable/schemas/observation.schema.json +20 -0
  158. package/dist/universal/.trae-cn/skills/citable/schemas/remediation-spec.schema.json +28 -0
  159. package/dist/universal/manifest.json +183 -99
  160. package/package.json +12 -1
  161. package/schemas/connection.schema.json +32 -0
  162. package/schemas/intervention.schema.json +33 -0
  163. package/schemas/metric.schema.json +42 -0
  164. package/schemas/objective.schema.json +65 -0
  165. package/schemas/observation.schema.json +20 -0
  166. package/schemas/remediation-spec.schema.json +28 -0
  167. package/skill/SKILL.md +14 -3
  168. package/skill/commands/README.md +8 -2
  169. package/skill/commands/measurement.md +22 -5
  170. package/skill/commands/observe-and-act.md +40 -0
  171. package/skill/references/capability-boundaries.md +8 -8
  172. package/src/cli/index.js +67 -0
  173. package/src/commands/applyRemediation.js +57 -0
  174. package/src/commands/measurement.js +205 -0
  175. package/src/commands/monitor.js +40 -0
  176. package/src/commands/observe.js +235 -0
  177. package/src/evidence/run.js +1 -1
  178. package/src/installer/index.js +35 -7
  179. package/src/installer/providers.js +1 -1
  180. package/src/observations/common.js +61 -0
  181. package/src/registries/index.js +13 -0
@@ -0,0 +1,205 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parse as parseCsv } from 'csv-parse/sync';
4
+ import { loadRegistries, saveRegistry } from '../registries/index.js';
5
+ import { envelope, observationRun } from '../observations/common.js';
6
+ import { parseRefDate, readJson, readYaml } from '../shared/io.js';
7
+ import { validateAgainst } from '../shared/schemaValidator.js';
8
+
9
+ function inputDocument(file) {
10
+ if (!file) throw new Error('--input <file> is required');
11
+ if (!fs.existsSync(file)) throw new Error(`input not found: ${file}`);
12
+ const raw = fs.readFileSync(file, 'utf8');
13
+ const ext = path.extname(file).toLowerCase();
14
+ if (ext === '.csv') return { raw, rows: parseCsv(raw, { columns: true, skip_empty_lines: true, trim: true }), file: path.resolve(file) };
15
+ const value = ext === '.yaml' || ext === '.yml' ? readYaml(file) : readJson(file);
16
+ return { raw, rows: Array.isArray(value) ? value : value.rows || value.entries || [value], file: path.resolve(file) };
17
+ }
18
+
19
+ function numericValue(raw, type, rowNumber) {
20
+ if (raw === '' || raw == null) throw new Error(`row ${rowNumber}: value is required`);
21
+ const value = Number(raw);
22
+ if (!Number.isFinite(value)) throw new Error(`row ${rowNumber}: value must be numeric`);
23
+ if (type === 'integer' && !Number.isInteger(value)) throw new Error(`row ${rowNumber}: value must be an integer`);
24
+ return value;
25
+ }
26
+
27
+ function isoTime(raw, field, rowNumber) {
28
+ if (!raw || Number.isNaN(new Date(raw).getTime())) throw new Error(`row ${rowNumber}: ${field} must be a valid date-time`);
29
+ return new Date(raw).toISOString();
30
+ }
31
+
32
+ export function importMetrics(root, { input, provider }) {
33
+ if (!provider) throw new Error('metrics import requires --provider <name>');
34
+ const source = inputDocument(input);
35
+ if (!source.rows.length) throw new Error('metric import contains no rows');
36
+ const { registries, problems } = loadRegistries(root);
37
+ if (problems.length) throw new Error(`registry validation failed: ${problems.join('; ')}`);
38
+ const definitions = new Map(registries.metrics.entries.map((metric) => [metric.metric_id, metric]));
39
+ const observations = source.rows.map((row, index) => {
40
+ const rowNumber = index + 1;
41
+ const metric = definitions.get(row.metric_id);
42
+ if (!metric) throw new Error(`row ${rowNumber}: unknown metric_id ${row.metric_id || '(missing)'}`);
43
+ if (metric.status === 'deprecated') throw new Error(`row ${rowNumber}: metric ${metric.metric_id} is deprecated`);
44
+ if (metric.provider.toLowerCase() !== String(provider).toLowerCase()) {
45
+ throw new Error(`row ${rowNumber}: metric ${metric.metric_id} belongs to ${metric.provider}, not ${provider}`);
46
+ }
47
+ const observedAt = isoTime(row.observed_at || row.date, 'observed_at', rowNumber);
48
+ const dimensions = {};
49
+ for (const dimension of metric.dimensions) {
50
+ if (row[dimension] != null && row[dimension] !== '') dimensions[dimension] = String(row[dimension]);
51
+ }
52
+ const data = {
53
+ metric_id: metric.metric_id,
54
+ provider: metric.provider,
55
+ external_name: metric.external_name,
56
+ value: numericValue(row.value, metric.value_type, rowNumber),
57
+ unit: metric.unit === 'custom' ? metric.custom_unit : metric.unit,
58
+ aggregation: metric.aggregation,
59
+ observed_at: observedAt,
60
+ period_start: row.period_start ? isoTime(row.period_start, 'period_start', rowNumber) : observedAt,
61
+ period_end: row.period_end ? isoTime(row.period_end, 'period_end', rowNumber) : observedAt,
62
+ dimensions,
63
+ source_row: rowNumber,
64
+ };
65
+ if (new Date(data.period_start) > new Date(data.period_end)) throw new Error(`row ${rowNumber}: period_start is after period_end`);
66
+ return envelope('metric', data, {
67
+ method: 'owner_import',
68
+ source: source.file,
69
+ raw: JSON.stringify(row),
70
+ limitations: metric.limitations,
71
+ });
72
+ });
73
+ return observationRun(root, 'metrics import', source.file, observations, { rawInputs: { metric_import: source.raw } });
74
+ }
75
+
76
+ function readObjectiveInput(input) {
77
+ const document = inputDocument(input);
78
+ if (document.rows.length !== 1) throw new Error('objectives init requires exactly one objective');
79
+ return document.rows[0];
80
+ }
81
+
82
+ export function initializeObjective(root, { input, write = false }) {
83
+ const objective = readObjectiveInput(input);
84
+ const { registries, problems } = loadRegistries(root);
85
+ if (problems.length) throw new Error(`registry validation failed: ${problems.join('; ')}`);
86
+ const candidate = { ...registries.objectives, entries: [...registries.objectives.entries, objective] };
87
+ const check = validateAgainst('objective.schema.json', candidate);
88
+ if (!check.valid) throw new Error(`objective violates contract: ${check.errors.join('; ')}`);
89
+ if (registries.objectives.entries.some((item) => item.objective_id === objective.objective_id)) throw new Error(`objective ${objective.objective_id} already exists`);
90
+ const metricIds = new Set(registries.metrics.entries.map((item) => item.metric_id));
91
+ const refs = [...objective.primary_metrics, ...objective.supporting_metrics, ...objective.guardrails.map((item) => item.metric_id)];
92
+ const unknown = [...new Set(refs.filter((ref) => !metricIds.has(ref)))];
93
+ if (unknown.length) throw new Error(`objective references unknown metrics: ${unknown.join(', ')}`);
94
+ if (write) saveRegistry(root, 'objectives', candidate);
95
+ return { objective, written: write, file: path.join(root, '.citable', 'objectives.yaml') };
96
+ }
97
+
98
+ export function validateObjectives(root) {
99
+ const { registries, problems } = loadRegistries(root);
100
+ const objectiveProblems = problems.filter((problem) => problem.startsWith('objectives/') || problem.startsWith('objectives.yaml'));
101
+ return { ok: objectiveProblems.length === 0, problems: objectiveProblems, count: registries.objectives.entries.length };
102
+ }
103
+
104
+ function metricObservations(root) {
105
+ const runs = path.join(root, '.citable', 'runs');
106
+ if (!fs.existsSync(runs)) return [];
107
+ const items = [];
108
+ for (const run of fs.readdirSync(runs)) {
109
+ const dir = path.join(runs, run, 'observations');
110
+ if (!fs.existsSync(dir)) continue;
111
+ for (const file of fs.readdirSync(dir).filter((name) => name.endsWith('-metric.json'))) {
112
+ const item = readJson(path.join(dir, file));
113
+ if (item.kind === 'metric') items.push(item);
114
+ }
115
+ }
116
+ return [...new Map(items.map((item) => [item.evidence_hash, item])).values()];
117
+ }
118
+
119
+ function inCohort(data, cohort) {
120
+ const url = data.dimensions.url || data.dimensions.page || null;
121
+ const query = data.dimensions.query || null;
122
+ const labels = String(data.dimensions.labels || data.dimensions.label || '').split(',').map((label) => label.trim()).filter(Boolean);
123
+ const urlMatch = !cohort.urls.length || (url && cohort.urls.some((pattern) => pattern.endsWith('*') ? url.startsWith(pattern.slice(0, -1)) : url === pattern));
124
+ const queryMatch = !cohort.queries.length || (query && cohort.queries.includes(query));
125
+ const labelMatch = !cohort.labels.length || cohort.labels.some((label) => labels.includes(label));
126
+ return urlMatch && queryMatch && labelMatch;
127
+ }
128
+
129
+ function aggregate(values, method) {
130
+ if (!values.length) return null;
131
+ if (method === 'sum') return values.reduce((sum, value) => sum + value, 0);
132
+ if (method === 'minimum') return Math.min(...values);
133
+ if (method === 'maximum') return Math.max(...values);
134
+ if (method === 'latest') return values.at(-1);
135
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
136
+ }
137
+
138
+ function evaluateGuardrail(guardrail, metric, definition) {
139
+ if (metric.state !== 'observed') return { ...guardrail, state: 'inconclusive', passed: null, reason: 'metric comparison is inconclusive' };
140
+ let passed;
141
+ if (guardrail.operator === 'gte') passed = metric.evaluation >= guardrail.threshold;
142
+ else if (guardrail.operator === 'lte') passed = metric.evaluation <= guardrail.threshold;
143
+ else if (guardrail.operator === 'increase_by') passed = metric.relative_change != null && metric.relative_change >= guardrail.threshold;
144
+ else if (guardrail.operator === 'decrease_by') passed = metric.relative_change != null && metric.relative_change <= -guardrail.threshold;
145
+ else if (definition.direction === 'increase') passed = metric.evaluation >= metric.baseline;
146
+ else if (definition.direction === 'decrease') passed = metric.evaluation <= metric.baseline;
147
+ else if (definition.direction === 'maintain') passed = metric.relative_change != null && Math.abs(metric.relative_change) <= guardrail.threshold;
148
+ else return { ...guardrail, state: 'inconclusive', passed: null, reason: 'contextual metric requires a user-defined directional guardrail' };
149
+ return { ...guardrail, state: 'observed', passed, reason: passed ? 'observed condition met' : 'observed condition not met' };
150
+ }
151
+
152
+ export function evaluateObjective(root, { objectiveId, refDate }) {
153
+ const { registries, problems } = loadRegistries(root);
154
+ if (problems.length) throw new Error(`registry validation failed: ${problems.join('; ')}`);
155
+ const objective = objectiveId ? registries.objectives.entries.find((item) => item.objective_id === objectiveId) : registries.objectives.entries[0];
156
+ if (!objective) throw new Error(objectiveId ? `objective not found: ${objectiveId}` : 'no objective configured');
157
+ const definitions = new Map(registries.metrics.entries.map((item) => [item.metric_id, item]));
158
+ const end = parseRefDate(refDate);
159
+ end.setUTCHours(23, 59, 59, 999);
160
+ const evaluationStart = new Date(end); evaluationStart.setUTCDate(evaluationStart.getUTCDate() - objective.comparison.evaluation_days + 1); evaluationStart.setUTCHours(0, 0, 0, 0);
161
+ const baselineEnd = new Date(evaluationStart.getTime() - 1);
162
+ const baselineStart = new Date(baselineEnd); baselineStart.setUTCDate(baselineStart.getUTCDate() - objective.comparison.baseline_days + 1); baselineStart.setUTCHours(0, 0, 0, 0);
163
+ const all = metricObservations(root).filter((item) => inCohort(item.data, objective.cohort));
164
+ const metricIds = [...new Set([...objective.primary_metrics, ...objective.supporting_metrics, ...objective.guardrails.map((item) => item.metric_id)])];
165
+ const metrics = metricIds.map((metricId) => {
166
+ const definition = definitions.get(metricId);
167
+ const observations = all.filter((item) => item.data.metric_id === metricId).sort((a, b) => a.data.observed_at.localeCompare(b.data.observed_at));
168
+ const baselineRows = observations.filter((item) => new Date(item.data.observed_at) >= baselineStart && new Date(item.data.observed_at) <= baselineEnd);
169
+ const evaluationRows = observations.filter((item) => new Date(item.data.observed_at) >= evaluationStart && new Date(item.data.observed_at) <= end);
170
+ const aggregatable = definition.aggregation !== 'provider_defined';
171
+ const enough = aggregatable && baselineRows.length >= objective.comparison.minimum_observations && evaluationRows.length >= objective.comparison.minimum_observations;
172
+ const baseline = enough ? aggregate(baselineRows.map((item) => item.data.value), definition.aggregation) : null;
173
+ const evaluation = enough ? aggregate(evaluationRows.map((item) => item.data.value), definition.aggregation) : null;
174
+ return {
175
+ metric_id: metricId,
176
+ provider: definition.provider,
177
+ state: enough ? 'observed' : 'inconclusive',
178
+ baseline,
179
+ evaluation,
180
+ absolute_change: enough ? evaluation - baseline : null,
181
+ relative_change: enough && baseline !== 0 ? (evaluation - baseline) / Math.abs(baseline) : null,
182
+ baseline_observations: baselineRows.length,
183
+ evaluation_observations: evaluationRows.length,
184
+ limitations: [
185
+ ...definition.limitations,
186
+ ...(!aggregatable ? ['provider_defined aggregation requires an explicit normalized aggregation before evaluation'] : []),
187
+ ...(aggregatable && !enough ? [`requires at least ${objective.comparison.minimum_observations} observations in each window`] : []),
188
+ ],
189
+ };
190
+ });
191
+ const guardrails = objective.guardrails.map((guardrail) => {
192
+ const metric = metrics.find((item) => item.metric_id === guardrail.metric_id);
193
+ return evaluateGuardrail(guardrail, metric, definitions.get(guardrail.metric_id));
194
+ });
195
+ return {
196
+ objective_id: objective.objective_id,
197
+ objective_name: objective.name,
198
+ windows: { baseline_start: baselineStart.toISOString(), baseline_end: baselineEnd.toISOString(), evaluation_start: evaluationStart.toISOString(), evaluation_end: end.toISOString() },
199
+ metrics,
200
+ guardrails,
201
+ guardrail_status: guardrails.some((item) => item.state === 'inconclusive') ? 'inconclusive' : guardrails.some((item) => !item.passed) ? 'not_met' : 'met',
202
+ status: metrics.every((metric) => metric.state === 'observed') ? 'observed' : 'inconclusive',
203
+ interpretation: 'Changes are temporal associations. This evaluation does not establish that an intervention caused an observed outcome.',
204
+ };
205
+ }
@@ -0,0 +1,40 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { readJson, writeJson, nowIso } from '../shared/io.js';
4
+
5
+ function observations(dir) {
6
+ const folder = path.join(dir, 'observations');
7
+ if (!fs.existsSync(folder)) return [];
8
+ return fs.readdirSync(folder).filter((f) => f.endsWith('.json')).sort().map((f) => readJson(path.join(folder, f)));
9
+ }
10
+
11
+ function key(item) {
12
+ const d = item.data || {};
13
+ if (item.kind === 'citation') return [item.kind, d.provider, d.product_mode, d.prompt_id, d.run_index].join(':');
14
+ return [item.kind, d.url || d.citation_url || d.prompt_id || d.timestamp || item.observation_id].join(':');
15
+ }
16
+
17
+ export function monitor(root, { runA, runB } = {}) {
18
+ const runsDir = path.join(root, '.citable', 'runs');
19
+ if (!fs.existsSync(runsDir)) throw new Error('no runs available to monitor');
20
+ const candidates = fs.readdirSync(runsDir).filter((r) => fs.existsSync(path.join(runsDir, r, 'observations'))).sort();
21
+ const b = runB || candidates.at(-1), a = runA || candidates.at(-2);
22
+ if (!a || !b) throw new Error('monitor requires two observation runs');
23
+ const before = new Map(observations(path.join(runsDir, a)).map((o) => [key(o), o]));
24
+ const after = new Map(observations(path.join(runsDir, b)).map((o) => [key(o), o]));
25
+ const alerts = [];
26
+ for (const [k, current] of after) {
27
+ const previous = before.get(k);
28
+ if (!previous) alerts.push({ severity: 'informational', type: 'new_observation', key: k, current_state: current.state });
29
+ else if (previous.state !== current.state) alerts.push({ severity: ['failed', 'not_observed'].includes(current.state) ? 'high' : 'medium', type: 'state_change', key: k, previous_state: previous.state, current_state: current.state });
30
+ if (current.kind === 'index' && previous?.data?.indexed === true && current.data.indexed === false) alerts.push({ severity: 'high', type: 'index_loss', key: k });
31
+ if (current.kind === 'canonical_freshness' && previous?.data?.canonical_consensus === true && current.data.canonical_consensus === false) alerts.push({ severity: 'high', type: 'canonical_regression', key: k });
32
+ if (current.kind === 'citation' && previous?.data?.property_cited === true && current.data.property_cited === false) alerts.push({ severity: 'medium', type: 'citation_presence_change', key: k });
33
+ }
34
+ for (const k of before.keys()) if (!after.has(k)) alerts.push({ severity: 'medium', type: 'observation_missing', key: k });
35
+ 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 };
36
+ const dir = path.join(root, '.citable', 'monitoring');
37
+ writeJson(path.join(dir, `${a}--${b}.json`), result);
38
+ writeJson(path.join(dir, 'latest.json'), result);
39
+ return { ...result, dir };
40
+ }
@@ -0,0 +1,235 @@
1
+ import { buildContext } from './context.js';
2
+ import { envelope, observationRun, readInput } from '../observations/common.js';
3
+ import { sha256 } from '../shared/io.js';
4
+ import { fetchUrl } from '../crawler/fetch.js';
5
+ import net from 'node:net';
6
+
7
+ const originOf = (value) => { try { return new URL(value).origin; } catch { return null; } };
8
+ const words = (text) => String(text || '').trim().split(/\s+/).filter(Boolean);
9
+
10
+ function canonicalReview(raw, targetOrigin) {
11
+ const citations = raw.citations || [];
12
+ return citations.map((citation, index) => {
13
+ const url = typeof citation === 'string' ? citation : citation.url;
14
+ const canonical = typeof citation === 'object' ? citation.canonical_url || url : url;
15
+ const supports = typeof citation === 'object' ? citation.support_status || 'review_required' : 'review_required';
16
+ return {
17
+ citation_url: url, canonical_url: canonical, citation_order: index + 1,
18
+ first_party: Boolean(targetOrigin && originOf(canonical) === targetOrigin),
19
+ support_status: supports,
20
+ answer_claim: typeof citation === 'object' ? citation.answer_claim || null : null,
21
+ source_passage: typeof citation === 'object' ? citation.source_passage || null : null,
22
+ reviewer: typeof citation === 'object' ? citation.reviewer || null : null,
23
+ };
24
+ });
25
+ }
26
+
27
+ async function observeRender(root, options) {
28
+ if (!options.target || !/^https?:\/\//.test(options.target)) throw new Error('render requires --target <http(s) URL>');
29
+ let playwright;
30
+ try { playwright = await import('playwright'); } catch {
31
+ const item = envelope('render', { url: options.target }, { method: 'browser', source: 'playwright', state: 'not_evidenced', confidence: 'unknown', limitations: ['Optional Playwright dependency is not installed.'] });
32
+ return observationRun(root, 'observe render', options.target, [item], { incomplete: ['Rendered DOM capture unavailable: install Playwright and a Chromium browser.'] });
33
+ }
34
+ const initial = await fetchUrl(options.target, { timeoutMs: options.timeout || 30000, maxRetries: 1 });
35
+ const browser = await playwright.chromium.launch({ headless: true });
36
+ try {
37
+ const capture = async (name, viewport, isMobile = false) => {
38
+ const context = await browser.newContext({ viewport, isMobile });
39
+ const page = await context.newPage();
40
+ const failures = [];
41
+ page.on('requestfailed', (request) => failures.push({ url: request.url(), error: request.failure()?.errorText || 'unknown' }));
42
+ const response = await page.goto(options.target, { waitUntil: 'networkidle', timeout: options.timeout || 30000 });
43
+ const html = await page.content(), text = await page.locator('body').innerText(), screenshot = await page.screenshot({ fullPage: true });
44
+ const result = { name, final_url: page.url(), status: response?.status() ?? null, viewport, html, text, screenshot, failed_requests: failures };
45
+ await context.close();
46
+ return result;
47
+ };
48
+ const desktop = await capture('desktop', { width: 1280, height: 900 });
49
+ const mobile = await capture('mobile', { width: 390, height: 844 }, true);
50
+ const initialText = initial.body.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ').replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
51
+ const data = {
52
+ url: options.target, initial: { final_url: initial.url, status: initial.status, html_hash: sha256(initial.body), text_hash: sha256(initialText), word_count: words(initialText).length, redirect_chain: initial.redirectChain },
53
+ desktop: { final_url: desktop.final_url, status: desktop.status, html_hash: sha256(desktop.html), text_hash: sha256(desktop.text), word_count: words(desktop.text).length, failed_requests: desktop.failed_requests },
54
+ mobile: { final_url: mobile.final_url, status: mobile.status, html_hash: sha256(mobile.html), text_hash: sha256(mobile.text), word_count: words(mobile.text).length, failed_requests: mobile.failed_requests },
55
+ parity: { initial_to_desktop_word_ratio: words(desktop.text).length ? Number((words(initialText).length / words(desktop.text).length).toFixed(3)) : null, mobile_to_desktop_word_ratio: words(desktop.text).length ? Number((words(mobile.text).length / words(desktop.text).length).toFixed(3)) : null },
56
+ };
57
+ const item = envelope('render', data, { method: 'browser', source: 'playwright/chromium', raw: `${initial.body}\n${desktop.html}\n${mobile.html}` });
58
+ return observationRun(root, 'observe render', options.target, [item], { artifacts: { 'initial/response.html': initial.body, 'rendered/desktop-dom.html': desktop.html, 'rendered/desktop-text.txt': desktop.text, 'rendered/mobile-dom.html': mobile.html, 'rendered/mobile-text.txt': mobile.text, 'screenshots/desktop.png': desktop.screenshot, 'screenshots/mobile.png': mobile.screenshot } });
59
+ } finally { await browser.close(); }
60
+ }
61
+
62
+ async function observeIndex(root, options) {
63
+ if (!options.input) {
64
+ if (!options.target || !options.siteUrl) throw new Error('live index observation requires --target <URL> and --site-url <Search Console property>');
65
+ const token = options.accessToken || process.env.GSC_ACCESS_TOKEN;
66
+ if (!token) {
67
+ const item = envelope('index', { engine: 'google', url: options.target }, { method: 'live_api', source: 'Google Search Console URL Inspection API', state: 'not_evidenced', confidence: 'unknown', limitations: ['GSC_ACCESS_TOKEN is not configured.'] });
68
+ return observationRun(root, 'observe index', options.target, [item], { incomplete: ['Google index inspection unavailable: GSC_ACCESS_TOKEN is not configured.'] });
69
+ }
70
+ const response = await fetch('https://searchconsole.googleapis.com/v1/urlInspection/index:inspect', { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ inspectionUrl: options.target, siteUrl: options.siteUrl, languageCode: options.locale || 'en-US' }) });
71
+ const raw = await response.text();
72
+ if (!response.ok) throw new Error(`Search Console returned ${response.status}: ${raw.slice(0, 300)}`);
73
+ const value = JSON.parse(raw).inspectionResult?.indexStatusResult || {};
74
+ const data = { engine: 'google', url: options.target, indexed: value.verdict === 'PASS', selected_canonical: value.googleCanonical || null, declared_canonical: value.userCanonical || null, last_crawl: value.lastCrawlTime || null, fetch_state: value.pageFetchState || null, raw_provider_result: value };
75
+ return observationRun(root, 'observe index', options.target, [envelope('index', data, { method: 'live_api', source: 'Google Search Console URL Inspection API', raw })], { rawInputs: { gsc_response: raw } });
76
+ }
77
+ const input = readInput(options.input);
78
+ const rows = Array.isArray(input.value) ? input.value : input.value.items || [input.value];
79
+ const observations = rows.map((row) => envelope('index', {
80
+ engine: row.engine || options.provider || 'unknown', url: row.url || row.inspectionUrl,
81
+ indexed: row.indexed ?? row.verdict === 'PASS', selected_canonical: row.selected_canonical || row.googleCanonical || null,
82
+ declared_canonical: row.declared_canonical || row.userCanonical || null, last_crawl: row.last_crawl || row.lastCrawlTime || null,
83
+ fetch_state: row.fetch_state || row.pageFetchState || null, raw_provider_result: row,
84
+ }, { method: 'owner_import', source: input.file, raw: JSON.stringify(row), confidence: 'confirmed' }));
85
+ return observationRun(root, 'observe index', input.file, observations, { rawInputs: { index_export: input.raw } });
86
+ }
87
+
88
+ async function observeCitations(root, options) {
89
+ const input = readInput(options.input);
90
+ let rows = Array.isArray(input.value) ? input.value : input.value.observations || [input.value];
91
+ let method = 'owner_import';
92
+ if (options.endpoint) {
93
+ const prompts = input.value.prompts || (Array.isArray(input.value) ? input.value : []);
94
+ if (!prompts.length) throw new Error('citation runner input must contain a prompts array');
95
+ if (!/^https:\/\//.test(options.endpoint) && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?\//.test(options.endpoint)) throw new Error('citation adapter endpoint must use HTTPS (loopback HTTP is allowed for testing)');
96
+ const repeat = Number.isInteger(options.repeat) && options.repeat > 0 && options.repeat <= 20 ? options.repeat : 3;
97
+ rows = [];
98
+ for (const prompt of prompts) for (let runIndex = 1; runIndex <= repeat; runIndex++) {
99
+ const response = await fetch(options.endpoint, { method: 'POST', headers: { 'content-type': 'application/json', ...(options.accessToken ? { authorization: `Bearer ${options.accessToken}` } : {}) }, body: JSON.stringify({ prompt_id: prompt.prompt_id, prompt_text: prompt.prompt_text, locale: prompt.locale || input.value.locale || 'en-US', run_index: runIndex, runs_in_series: repeat }) });
100
+ const rawResponse = await response.text();
101
+ if (!response.ok) throw new Error(`citation adapter returned ${response.status}: ${rawResponse.slice(0, 300)}`);
102
+ const result = JSON.parse(rawResponse);
103
+ rows.push({ ...result, prompt_id: prompt.prompt_id, prompt_text: prompt.prompt_text, locale: prompt.locale || input.value.locale || 'en-US', provider: result.provider || options.provider || 'custom-adapter', product_mode: result.product_mode || 'api-adapter', run_index: runIndex, runs_in_series: repeat });
104
+ }
105
+ method = 'live_api';
106
+ }
107
+ const targetOrigin = options.target ? originOf(options.target) : input.value.target_origin || null;
108
+ const observations = [];
109
+ for (const row of rows) {
110
+ const reviews = canonicalReview(row, targetOrigin);
111
+ observations.push(envelope('citation', {
112
+ prompt_id: row.prompt_id, prompt_text: row.prompt_text, provider: row.provider || row.engine,
113
+ product_mode: row.product_mode || row.interface || 'unknown', locale: row.locale || 'unknown',
114
+ answer_text: row.answer_text || '', citations: reviews, run_index: row.run_index || 1,
115
+ property_cited: reviews.some((r) => r.first_party),
116
+ }, { method, source: options.endpoint || input.file, raw: JSON.stringify(row) }));
117
+ for (const review of reviews) observations.push(envelope('citation_review', review, {
118
+ method: review.reviewer ? 'human_review' : 'static_analysis', source: options.endpoint || input.file,
119
+ state: review.support_status === 'review_required' ? 'review_required' : 'observed',
120
+ confidence: review.reviewer ? 'confirmed' : 'low', raw: JSON.stringify(review),
121
+ limitations: review.reviewer ? [] : ['Material support requires a named human reviewer.'],
122
+ }));
123
+ }
124
+ return observationRun(root, 'observe citations', input.file, observations, { rawInputs: { citation_results: input.raw } });
125
+ }
126
+
127
+ function observeLogs(root, options) {
128
+ const input = readInput(options.input);
129
+ const rows = Array.isArray(input.value) ? input.value : input.value.requests || [];
130
+ const ranges = input.value.provider_ranges || {};
131
+ const verify = (row) => {
132
+ const cidrs = ranges[row.user_agent] || [];
133
+ if (!row.source_ip || !cidrs.length) return { verified: false, method: null };
134
+ const family = net.isIP(row.source_ip);
135
+ if (!family) return { verified: false, method: 'invalid source IP' };
136
+ const block = new net.BlockList();
137
+ for (const cidr of cidrs) {
138
+ const [network, prefix] = cidr.split('/');
139
+ const rangeFamily = net.isIP(network);
140
+ if (rangeFamily === family && Number.isInteger(Number(prefix))) block.addSubnet(network, Number(prefix), family === 4 ? 'ipv4' : 'ipv6');
141
+ }
142
+ return { verified: block.check(row.source_ip, family === 4 ? 'ipv4' : 'ipv6'), method: `matched imported ${row.user_agent} CIDR set` };
143
+ };
144
+ const observations = rows.map((row) => {
145
+ const identity = verify(row);
146
+ return envelope('crawler_log', {
147
+ timestamp: row.timestamp, url: row.url, user_agent: row.user_agent, source_ip: row.source_ip,
148
+ status: row.status, bytes: row.bytes ?? null, latency_ms: row.latency_ms ?? null,
149
+ cache_status: row.cache_status ?? null, region: row.region ?? null,
150
+ identity_verified: identity.verified, verification_method: identity.method,
151
+ }, { method: 'owner_import', source: input.file, raw: JSON.stringify(row), confidence: identity.verified ? 'high' : 'low', limitations: identity.verified ? ['CIDR ranges were imported with the log evidence; their provider currency must be established by source metadata.'] : ['User-agent strings alone do not verify crawler identity.'] });
152
+ });
153
+ return observationRun(root, 'observe logs', input.file, observations, { rawInputs: { server_logs: input.raw }, incomplete: observations.some((o) => !o.data.identity_verified) ? ['Some crawler identities are not IP-verified.'] : [] });
154
+ }
155
+
156
+ async function observePassages(root, options) {
157
+ const ctx = await buildContext(root, options);
158
+ if (!ctx.site) throw new Error('passages requires --target <dir|url>');
159
+ const observations = [];
160
+ for (const page of ctx.site.pages) {
161
+ const rawWords = words(page.text).length;
162
+ for (let i = 0; i < page.paragraphs.length; i++) {
163
+ const passage = page.paragraphs.slice(i, i + 3).join(' ');
164
+ const count = words(passage).length;
165
+ if (count < 30) continue;
166
+ const dependencies = [];
167
+ if (/\b(this|that|these|those|it|they|above|below|here)\b/i.test(passage.slice(0, 100))) dependencies.push('possible external referent');
168
+ observations.push(envelope('passage', { url: page.url, passage_index: i, text: passage, word_count: count, independently_extractable: count >= 100 && count <= 300 && dependencies.length === 0, dependencies, content_to_noise_ratio: page.rawHtml.length ? Number((page.text.length / page.rawHtml.length).toFixed(3)) : null }, { method: 'static_analysis', source: page.sourceFile || page.url, raw: passage, confidence: 'medium' }));
169
+ }
170
+ if (!page.paragraphs.length) observations.push(envelope('passage', { url: page.url, word_count: rawWords, independently_extractable: false, dependencies: ['no paragraph passages extracted'] }, { method: 'static_analysis', source: page.sourceFile || page.url, state: 'not_observed', confidence: 'confirmed' }));
171
+ }
172
+ return observationRun(root, 'observe passages', options.target, observations);
173
+ }
174
+
175
+ async function observeConsensus(root, options) {
176
+ const ctx = await buildContext(root, options);
177
+ if (!ctx.site) throw new Error('consensus requires --target <dir|url>');
178
+ const sitemapRows = ctx.site.sitemaps.flatMap((s) => s.parsed?.urls || []);
179
+ const sitemapUrls = new Set(sitemapRows.map((u) => u.loc));
180
+ const observations = ctx.site.pages.map((page) => {
181
+ const declared = page.canonicals[0] || null;
182
+ const sitemapDate = sitemapRows.find((row) => row.loc === page.url)?.lastmod || null;
183
+ const visibleDate = page.metas['article:modified_time']?.[0] || page.metas['date.modified']?.[0] || page.metas['last-modified']?.[0] || null;
184
+ const signals = { final_url: page.url, html_canonical: declared, open_graph_url: page.ogUrl, sitemap_present: sitemapUrls.has(page.url), last_modified_header: page.headers['last-modified'] || null, sitemap_lastmod: sitemapDate, visible_or_meta_modified: visibleDate };
185
+ const urls = [page.url, declared, page.ogUrl].filter(Boolean);
186
+ const dates = [signals.last_modified_header, sitemapDate, visibleDate].filter(Boolean).map((value) => String(value).slice(0, 10));
187
+ return envelope('canonical_freshness', { url: page.url, signals, canonical_consensus: new Set(urls).size <= 1, date_consensus: dates.length >= 2 ? new Set(dates).size === 1 : null, engine_selected_canonical: null }, { method: 'static_analysis', source: page.sourceFile || page.url, raw: JSON.stringify(signals), confidence: 'high', limitations: ['Engine-selected canonical and content-difference date require external observations.'] });
188
+ });
189
+ return observationRun(root, 'observe consensus', options.target, observations);
190
+ }
191
+
192
+ async function observePerformance(root, options) {
193
+ if (options.input) {
194
+ const input = readInput(options.input);
195
+ const rows = Array.isArray(input.value) ? input.value : [input.value];
196
+ const observations = rows.map((row) => {
197
+ const provider = row.provider || (row.lighthouseVersion || row.categories ? 'Lighthouse' : row.record ? 'CrUX' : 'unknown');
198
+ const evidenceType = /lighthouse/i.test(provider) ? 'lab' : /crux|chrome ux/i.test(provider) ? 'field' : 'imported_unknown';
199
+ return envelope('performance', { ...row, provider, evidence_type: evidenceType }, { method: 'owner_import', source: input.file, raw: JSON.stringify(row), limitations: evidenceType === 'imported_unknown' ? ['Performance evidence was not identified as field or lab data.'] : [] });
200
+ });
201
+ return observationRun(root, 'observe performance', input.file, observations, { rawInputs: { performance_export: input.raw } });
202
+ }
203
+ if (!options.target) throw new Error('performance requires --target <URL> or --input <json>');
204
+ const key = options.apiKey || process.env.CRUX_API_KEY;
205
+ if (!key) {
206
+ const item = envelope('performance', { url: options.target, provider: 'CrUX' }, { method: 'live_api', source: 'CrUX API', state: 'not_evidenced', confidence: 'unknown', limitations: ['CRUX_API_KEY is not configured.'] });
207
+ return observationRun(root, 'observe performance', options.target, [item], { incomplete: ['CrUX collection unavailable: CRUX_API_KEY is not configured.'] });
208
+ }
209
+ const response = await fetch(`https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=${encodeURIComponent(key)}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url: options.target }) });
210
+ const raw = await response.text();
211
+ if (!response.ok) throw new Error(`CrUX API returned ${response.status}: ${raw.slice(0, 300)}`);
212
+ const data = JSON.parse(raw);
213
+ return observationRun(root, 'observe performance', options.target, [envelope('performance', { ...data, provider: 'CrUX', evidence_type: 'field' }, { method: 'live_api', source: 'CrUX API', raw })], { rawInputs: { crux_response: raw } });
214
+ }
215
+
216
+ function observeCorroboration(root, options) {
217
+ const input = readInput(options.input);
218
+ const rows = Array.isArray(input.value) ? input.value : input.value.mentions || [];
219
+ const observations = rows.map((row) => envelope('corroboration', row, { method: 'owner_import', source: input.file, raw: JSON.stringify(row), confidence: row.independent === true ? 'high' : 'low', limitations: row.independent === true ? [] : ['Source independence is not established.'] }));
220
+ return observationRun(root, 'observe corroboration', input.file, observations, { rawInputs: { corroboration_export: input.raw } });
221
+ }
222
+
223
+ export async function observe(root, mode, options = {}) {
224
+ switch (mode) {
225
+ case 'render': return observeRender(root, options);
226
+ case 'index': return observeIndex(root, options);
227
+ case 'citations': return observeCitations(root, options);
228
+ case 'logs': return observeLogs(root, options);
229
+ case 'passages': return observePassages(root, options);
230
+ case 'consensus': return observeConsensus(root, options);
231
+ case 'performance': return observePerformance(root, options);
232
+ case 'corroboration': return observeCorroboration(root, options);
233
+ default: throw new Error('observe mode must be render, index, citations, logs, passages, consensus, performance, or corroboration');
234
+ }
235
+ }
@@ -61,7 +61,7 @@ export function createRun(root, { command, argv = [], target, locale = process.e
61
61
  writeArtifact(relPath, data) {
62
62
  const file = path.join(dir, relPath);
63
63
  fs.mkdirSync(path.dirname(file), { recursive: true });
64
- if (typeof data === 'string') fs.writeFileSync(file, data);
64
+ if (typeof data === 'string' || Buffer.isBuffer(data)) fs.writeFileSync(file, data);
65
65
  else writeJson(file, data);
66
66
  manifest.output_hashes[relPath] = sha256File(file);
67
67
  return file;
@@ -105,6 +105,8 @@ export function parseInstallerArgs(argv) {
105
105
  help: false,
106
106
  version: false,
107
107
  failOnUpdate: false,
108
+ installMode: 'copy',
109
+ skillSelection: null,
108
110
  _: [],
109
111
  };
110
112
 
@@ -118,17 +120,29 @@ export function parseInstallerArgs(argv) {
118
120
  else if (arg === '--dry-run') args.dryRun = true;
119
121
  else if (arg === '--force') args.force = true;
120
122
  else if (arg === '--json') args.json = true;
121
- else if (arg === '--all') args.all = true;
123
+ else if (arg === '--all') { args.all = true; args.yes = true; }
122
124
  else if (arg === '--help' || arg === '-h') args.help = true;
123
125
  else if (arg === '--version' || arg === '-v') args.version = true;
124
- else if (arg === '--project' || arg === '--local') args.scope = mergeScope(args.scope, 'project');
125
- else if (arg === '--global' || arg === '--user') args.scope = mergeScope(args.scope, 'global');
126
+ else if (arg === '--project' || arg === '--local' || arg === '-p') args.scope = mergeScope(args.scope, 'project');
127
+ else if (arg === '--global' || arg === '--user' || arg === '-g') args.scope = mergeScope(args.scope, 'global');
128
+ else if (arg === '--copy') args.installMode = 'copy';
129
+ else if (arg === '--symlink') throw new CitableInstallerError('--symlink is not supported: Citable uses hashed managed copies for atomic validation and safe uninstall', EXIT_CODES.invalidArguments);
126
130
  else if (arg === '--fail-on-update') args.failOnUpdate = true;
127
131
  else if (arg === '--providers') {
128
132
  if (i + 1 >= argv.length) throw new CitableInstallerError('--providers requires a value', EXIT_CODES.invalidArguments);
129
133
  args.providersRaw = argv[++i];
130
134
  } else if (arg.startsWith('--providers=')) {
131
135
  args.providersRaw = arg.slice('--providers='.length);
136
+ } else if (arg === '--agent' || arg === '-a') {
137
+ if (i + 1 >= argv.length) throw new CitableInstallerError(`${arg} requires a value`, EXIT_CODES.invalidArguments);
138
+ args.providersRaw = [args.providersRaw, argv[++i]].filter(Boolean).join(',');
139
+ } else if (arg.startsWith('--agent=')) {
140
+ args.providersRaw = [args.providersRaw, arg.slice('--agent='.length)].filter(Boolean).join(',');
141
+ } else if (arg === '--skill' || arg === '-s') {
142
+ if (i + 1 >= argv.length) throw new CitableInstallerError(`${arg} requires a value`, EXIT_CODES.invalidArguments);
143
+ args.skillSelection = argv[++i];
144
+ } else if (arg.startsWith('--skill=')) {
145
+ args.skillSelection = arg.slice('--skill='.length);
132
146
  } else if (arg === '--scope') {
133
147
  if (i + 1 >= argv.length) throw new CitableInstallerError('--scope requires a value', EXIT_CODES.invalidArguments);
134
148
  args.scope = mergeScope(args.scope, normalizeScopeValue(argv[++i]));
@@ -141,7 +155,16 @@ export function parseInstallerArgs(argv) {
141
155
  }
142
156
  }
143
157
 
144
- if (args.providersRaw !== null) {
158
+ if (args.skillSelection !== null && !['citable', '*'].includes(String(args.skillSelection).trim().toLowerCase())) {
159
+ throw new CitableInstallerError(`this package contains only the citable skill; unsupported --skill value: ${args.skillSelection}`, EXIT_CODES.invalidArguments);
160
+ }
161
+
162
+ if (args.all) {
163
+ if (args.providersRaw !== null) throw new CitableInstallerError('--all cannot be combined with --providers or --agent', EXIT_CODES.invalidArguments);
164
+ args.providerSelection = { kind: 'all', providers: PROVIDER_IDS, unknown: [] };
165
+ }
166
+
167
+ if (args.providersRaw !== null && !args.providerSelection) {
145
168
  const parsed = parseProviderList(args.providersRaw);
146
169
  if (parsed.unknown.length) {
147
170
  const supported = PROVIDER_IDS.join(', ');
@@ -1102,12 +1125,17 @@ Install examples
1102
1125
  npx @nebulacomponents/citable install
1103
1126
  npx @nebulacomponents/citable install --yes
1104
1127
  npx @nebulacomponents/citable install --providers=claude,codex,cursor --project --yes
1128
+ npx @nebulacomponents/citable install --agent claude-code --agent codex --skill citable --copy -p -y
1105
1129
  npx @nebulacomponents/citable install --providers=all --global --dry-run
1106
1130
 
1107
1131
  Options
1108
- --providers=<list> Provider ids, detected, or all
1109
- --project, --local Install/check project-local skill locations
1110
- --global, --user Install/check user-global skill locations
1132
+ --providers=<list> Provider ids, detected, all, or *
1133
+ --agent=<id>, -a <id> Repeatable provider alias compatible with skills CLI
1134
+ --skill=<name>, -s <name> Select citable or * (this package contains one skill)
1135
+ --all Select every provider and confirm non-interactively
1136
+ --copy Use managed copies (default and only install mode)
1137
+ --project, --local, -p Install/check project-local skill locations
1138
+ --global, --user, -g Install/check user-global skill locations
1111
1139
  --scope=<project|global> Scope equivalent
1112
1140
  --yes, -y Confirm non-interactively
1113
1141
  --dry-run Preview filesystem changes without mutation
@@ -130,7 +130,7 @@ export function parseProviderList(value) {
130
130
  if (!raw) return { kind: 'explicit', providers: [], unknown: [] };
131
131
  const lowered = raw.toLowerCase();
132
132
  if (lowered === 'detected') return { kind: 'detected', providers: [], unknown: [] };
133
- if (lowered === 'all') return { kind: 'all', providers: PROVIDER_IDS, unknown: [] };
133
+ if (lowered === 'all' || lowered === '*') return { kind: 'all', providers: PROVIDER_IDS, unknown: [] };
134
134
 
135
135
  const providers = [];
136
136
  const unknown = [];