agentlinkops 0.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 (115) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.codex-plugin/plugin.json +24 -0
  3. package/CHANGELOG.md +52 -0
  4. package/LICENSE +190 -0
  5. package/NOTICE +17 -0
  6. package/README.md +202 -0
  7. package/bin/agentlinkops.mjs +4 -0
  8. package/bin/linktrail.mjs +6 -0
  9. package/cli/adapters/csv.js +68 -0
  10. package/cli/adapters/sqlite-crm.js +79 -0
  11. package/cli/adapters/suppliers.js +180 -0
  12. package/cli/agent-setup.js +203 -0
  13. package/cli/agentlinkops.mjs +4 -0
  14. package/cli/args.js +18 -0
  15. package/cli/catalog.js +7483 -0
  16. package/cli/check.js +119 -0
  17. package/cli/citation.js +109 -0
  18. package/cli/client.js +60 -0
  19. package/cli/commands.js +183 -0
  20. package/cli/config.js +104 -0
  21. package/cli/connection.js +90 -0
  22. package/cli/context-mcp.js +193 -0
  23. package/cli/context.js +340 -0
  24. package/cli/delivery.js +67 -0
  25. package/cli/doctor.js +283 -0
  26. package/cli/env.js +66 -0
  27. package/cli/fleet.js +82 -0
  28. package/cli/gsc-browser.js +239 -0
  29. package/cli/import.js +108 -0
  30. package/cli/ledger.js +232 -0
  31. package/cli/linktrail.mjs +6 -0
  32. package/cli/local-result.js +161 -0
  33. package/cli/main.js +523 -0
  34. package/cli/mirror.js +126 -0
  35. package/cli/mix.js +123 -0
  36. package/cli/platforms.js +161 -0
  37. package/cli/receipt-performance.js +64 -0
  38. package/cli/receipts.js +122 -0
  39. package/cli/report.js +158 -0
  40. package/cli/setup-plan.js +211 -0
  41. package/cli/skill.js +39 -0
  42. package/cli/state.js +106 -0
  43. package/cli/status.js +51 -0
  44. package/cli/sync.js +179 -0
  45. package/package.json +59 -0
  46. package/references/cli.md +198 -0
  47. package/references/disavow.md +73 -0
  48. package/references/reports.md +53 -0
  49. package/scripts/agentlinkops.py +651 -0
  50. package/scripts/disavow.py +207 -0
  51. package/scripts/linktrail.py +10 -0
  52. package/scripts/migrations/001_crm.sql +45 -0
  53. package/scripts/migrations/002_cloud_events.sql +13 -0
  54. package/scripts/migrations/003_disavow.sql +20 -0
  55. package/scripts/migrations/004_placement_costs.sql +2 -0
  56. package/scripts/report.py +113 -0
  57. package/shared/agent-clients.js +49 -0
  58. package/shared/observation-event.js +43 -0
  59. package/shared/response-contract.js +59 -0
  60. package/shared/search-connection-contract.js +18 -0
  61. package/skills/agentlinkops-assets/SKILL.md +13 -0
  62. package/skills/agentlinkops-assets/agents/openai.yaml +5 -0
  63. package/skills/agentlinkops-campaigns/SKILL.md +17 -0
  64. package/skills/agentlinkops-campaigns/agents/openai.yaml +12 -0
  65. package/skills/agentlinkops-connect/SKILL.md +33 -0
  66. package/skills/agentlinkops-connect/agents/openai.yaml +12 -0
  67. package/skills/agentlinkops-connect/references/agentlinkops.md +266 -0
  68. package/skills/agentlinkops-crm/SKILL.md +27 -0
  69. package/skills/agentlinkops-crm/agents/openai.yaml +12 -0
  70. package/skills/content-defingerprinting/SKILL.md +29 -0
  71. package/skills/content-defingerprinting/scripts/watermarks/README.md +102 -0
  72. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +26 -0
  73. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +52 -0
  74. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +21 -0
  75. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +96 -0
  76. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +432 -0
  77. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +86 -0
  78. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +460 -0
  79. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +651 -0
  80. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +27 -0
  81. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +212 -0
  82. package/skills/internal-linking-optimizer/SKILL.md +56 -0
  83. package/skills/internal-linking-optimizer/references/source-prompt.md +76 -0
  84. package/skills/pre-publish-review/SKILL.md +179 -0
  85. package/skills/source-cited-content-builder/SKILL.md +56 -0
  86. package/skills/source-cited-content-builder/references/source-prompt.md +19 -0
  87. package/skills/source-cited-humanizer/SKILL.md +20 -0
  88. package/src/citations/adapter.js +141 -0
  89. package/src/citations/contract.js +171 -0
  90. package/src/citations/match.js +120 -0
  91. package/src/citations/runner.js +151 -0
  92. package/src/citations/stats.js +74 -0
  93. package/src/competitors/contract.js +157 -0
  94. package/src/competitors/domain-mix.js +199 -0
  95. package/src/context/ga4.js +652 -0
  96. package/src/context/gsc.js +925 -0
  97. package/src/context/site-profile.js +445 -0
  98. package/src/context/util.js +71 -0
  99. package/src/context-tool-registry.js +27 -0
  100. package/src/corpus/acquire.js +135 -0
  101. package/src/corpus/seeds.js +182 -0
  102. package/src/discovery/contract.js +237 -0
  103. package/src/discovery/import.js +156 -0
  104. package/src/tool-views.js +117 -0
  105. package/src/verifier/fetch.js +303 -0
  106. package/src/verifier/index.js +311 -0
  107. package/src/verifier/observation-kind.js +91 -0
  108. package/src/verifier/parser.js +241 -0
  109. package/src/verifier/readiness.js +90 -0
  110. package/src/verifier/render-evidence.js +57 -0
  111. package/src/verifier/robots.js +85 -0
  112. package/src/verifier/transition.js +129 -0
  113. package/src/verifier/url.js +56 -0
  114. package/templates/SITE.template.md +59 -0
  115. package/templates/campaigns.v1.json +65 -0
package/cli/mix.js ADDED
@@ -0,0 +1,123 @@
1
+ // `agentlinkops mix` — the referring-domain mix over the LOCAL ledger (DP-0003-T08/T09/T10).
2
+ //
3
+ // The cloud serves this report to a customer's agent (POST /v1/competitor-sets/:id/domain-mix,
4
+ // get_domain_mix_report, the console panel). This command is the accountless local surface for
5
+ // the same question over the same ledger the agent would supply as lanes: are our referring
6
+ // domains generic padding, niche, or unlabelled — and how did that change since last run?
7
+ //
8
+ // The honesty rules are the library's, not re-stated here: class labels come only from the
9
+ // row's own tags (never inferred from a domain's shape), ratios run over labelled domains
10
+ // only, and a diff across different lanes or datasets refuses with the reason instead of
11
+ // rendering index churn as link acquisition.
12
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
13
+ import { dirname } from 'node:path';
14
+ import { loadConfig } from './config.js';
15
+ import { readLedger } from './ledger.js';
16
+ import { buildDomainMixReport, diffDomainMixReports, renderDomainMixDiff } from '../src/competitors/domain-mix.js';
17
+
18
+ const CLASS_LABELS = new Set(['generic', 'niche', 'outreach', 'webmcp']);
19
+
20
+ const USAGE = `agentlinkops mix — the referring-domain mix over this ledger
21
+
22
+ agentlinkops mix [--lane-file FILE]… [--save FILE] [--against FILE] [--json]
23
+
24
+ Class labels are the row tags (generic, niche, outreach, webmcp); lanes split on the
25
+ row's src:<name> tag. Extra lanes (e.g. scripts/graph-domain-mix-lanes.mjs output) merge
26
+ in unchanged. --save writes the full report JSON; --against diffs the current mix against
27
+ a saved one in the watch format.`;
28
+
29
+ /** One lane per row-source tag, exactly the fleet-run mapping: class from the row's own tags. */
30
+ export function lanesFromEntries(entries) {
31
+ const byLane = new Map();
32
+ for (const entry of entries) {
33
+ const tags = entry.tags ?? [];
34
+ const klass = tags.find(tag => CLASS_LABELS.has(tag)) ?? null;
35
+ const srcTag = tags.find(tag => tag.startsWith('src:'));
36
+ const lane = srcTag ? srcTag.slice(4) : 'unattributed';
37
+ if (!byLane.has(lane)) byLane.set(lane, []);
38
+ byLane.get(lane).push({ source_url: entry.source, class: klass, added: entry.added ?? null });
39
+ }
40
+ return [...byLane.entries()].map(([lane, rows]) => ({ lane, rows })).sort((a, b) => a.lane < b.lane ? -1 : 1);
41
+ }
42
+
43
+ /** The one-line-per-lane human rendering. Numbers stay within their dataset, always. */
44
+ export function renderMix(report, out) {
45
+ const m = report.metadata;
46
+ out(`referring-domain mix — lanes: ${m.lanes.length}, competitor side: ${m.competitor_side_state}`);
47
+ for (const lane of report.ours) {
48
+ const c = lane.classes;
49
+ const share = lane.generic_share_of_labelled === null ? 'no labelled domains' : `generic ${lane.generic_share_of_labelled} of labelled`;
50
+ out(` ${lane.lane}: ${lane.referring_domains} domains — generic ${c.generic ?? 0}, niche ${c.niche ?? 0}, unlabelled ${c.unlabelled ?? 0} (${share}; label coverage ${lane.label_coverage})`);
51
+ }
52
+ for (const member of report.members) {
53
+ const c = member.classes;
54
+ out(` ${member.member_id} (${member.member_role}, inventory coverage ${member.coverage}): ${member.referring_domains} domains — generic ${c.generic ?? 0}, niche ${c.niche ?? 0}, unlabelled ${c.unlabelled ?? 0}; our labels reach ${member.label_coverage}`);
55
+ }
56
+ out(`report ${m.report_hash}`);
57
+ }
58
+
59
+ const parseMixArgs = argv => {
60
+ const parsed = { 'lane-file': [], save: null, against: null, json: false, help: false, _: [] };
61
+ for (let i = 0; i < argv.length; i++) {
62
+ const arg = argv[i];
63
+ if (arg === '--json') parsed.json = true;
64
+ else if (arg === '--help') parsed.help = true;
65
+ else if (arg === '--save') parsed.save = argv[++i] ?? null;
66
+ else if (arg === '--against') parsed.against = argv[++i] ?? null;
67
+ else if (arg === '--lane-file') parsed['lane-file'].push(argv[++i] ?? null);
68
+ else if (arg.startsWith('--')) throw new Error(`unknown flag: ${arg}`);
69
+ else parsed._.push(arg);
70
+ }
71
+ return parsed;
72
+ };
73
+
74
+ export async function mixMain(argv = [], { cwd = process.cwd(), out = console.log, err = console.error } = {}) {
75
+ let args;
76
+ try { args = parseMixArgs(argv); } catch (error) { err(`${error.message}\n\n${USAGE}`); return 2; }
77
+ if (args.help || args._.length) { out(USAGE); return args.help ? 0 : 2; }
78
+
79
+ const config = await loadConfig({ cwd, root: cwd });
80
+ const { entries, problems, missing } = await readLedger(config.paths.ledger);
81
+ if (missing) { err('no ledger here — run `agentlinkops init` first'); return 2; }
82
+ if (problems.length) {
83
+ // Malformed lines are reported and excluded, never silently dropped.
84
+ err(`${problems.length} ledger line(s) could not be read and are excluded from the mix:`);
85
+ for (const problem of problems.slice(0, 10)) err(` line ${problem.line}${problem.id ? ` (${problem.id})` : ''}: ${problem.reason}`);
86
+ }
87
+ const lanes = lanesFromEntries(entries);
88
+ for (const file of args['lane-file']) {
89
+ if (!file) { err('--lane-file needs a file'); return 2; }
90
+ const extra = JSON.parse(await readFile(file, 'utf8'));
91
+ if (!Array.isArray(extra)) { err(`${file}: a lane file is a JSON array of { lane, rows }`); return 2; }
92
+ lanes.push(...extra);
93
+ }
94
+ if (!lanes.length) {
95
+ // A confirmed absence, not a usage error: the ledger exists and names no rows we can
96
+ // split. Exit 1 says so with the reason instead of printing an empty report.
97
+ err('no labelled rows: the ledger is empty, or no row carries a class tag (generic, niche, outreach, webmcp)');
98
+ return 1;
99
+ }
100
+
101
+ let report;
102
+ try {
103
+ report = await buildDomainMixReport({ lanes });
104
+ } catch (error) {
105
+ err(`the mix could not be built: ${error.message}`);
106
+ return 2;
107
+ }
108
+
109
+ if (args.save) {
110
+ await mkdir(dirname(String(args.save)), { recursive: true });
111
+ await writeFile(String(args.save), `${JSON.stringify(report, null, 1)}\n`, 'utf8');
112
+ out(`saved report ${report.metadata.report_hash} to ${args.save}`);
113
+ }
114
+ if (args.against) {
115
+ const previous = JSON.parse(await readFile(String(args.against), 'utf8'));
116
+ const diff = diffDomainMixReports(previous, report);
117
+ if (!diff.comparable) out(`not comparable: ${diff.reason}`);
118
+ else renderDomainMixDiff(diff, out);
119
+ }
120
+ if (args.json) out(JSON.stringify(report, null, 1));
121
+ else if (!args.save && !args.against) renderMix(report, out);
122
+ return 0;
123
+ }
@@ -0,0 +1,161 @@
1
+ // `agentlinkops platforms`: the per-platform visibility aggregate over verification results.
2
+ //
3
+ // A placement agent's first question about a self-serve platform is not "can I get a link"
4
+ // but "will the link be VISIBLE" — in served HTML a crawler reads, or written by JavaScript
5
+ // a crawler never runs, or flattened to plain text, or on a page that is noindexed. Those
6
+ // four classes are exactly what the verifier already measures, page by page:
7
+ //
8
+ // server-html the link was found in the served HTML (state `present`, link_found)
9
+ // noindex the page was read and carries a robots noindex (present or not)
10
+ // js-only the page provably needs a browser (`possible_render_required`), or an
11
+ // adjudication found the target only in script / in no served bytes
12
+ // mention-no-anchor an adjudication found the target in markup but never as a link
13
+ //
14
+ // What the aggregate REFUSES to do matters as much: an `absent` on a rich page contributes
15
+ // the observation but no class vote, because static HTML cannot tell "does not link to us"
16
+ // from "links to us via JavaScript" — the T01 decision, kept. A platform with no observations
17
+ // is unknown, and nothing about an absent platform is inferred.
18
+ //
19
+ // The aggregate is claim-free context: counts, classes and dates from real observations, with
20
+ // their provenance, and no verdict. It can travel with candidate rows for exactly that reason.
21
+ import { readFile } from 'node:fs/promises';
22
+
23
+ export const PLATFORM_CLASSES = Object.freeze(['server-html', 'js-only', 'mention-no-anchor', 'noindex']);
24
+
25
+ const normalizeHost = value => {
26
+ try { return new URL(value).hostname.replace(/^www\./u, ''); } catch { return null; }
27
+ };
28
+
29
+ /**
30
+ * One observation row -> one class vote, or none. The precedence is explicit:
31
+ * noindex beats server-html (a link on a noindexed page is present but not visible),
32
+ * and an absent/unknown page never votes js-only unless the verifier or an adjudication
33
+ * PROVED the browser dependency. An `absent` with complete evidence votes nothing.
34
+ */
35
+ export function classifyObservation(row) {
36
+ const result = row.result ?? row;
37
+ const noindex = result.directives?.noindex ?? row.noindex ?? null;
38
+ if (result.state === 'present' || row.state === 'present') return noindex ? 'noindex' : 'server-html';
39
+ const reason = result.reason ?? row.reason;
40
+ if (reason === 'possible_render_required') return 'js-only';
41
+ return null;
42
+ }
43
+
44
+ /** A benchmark result row (scripts/verifier-benchmark.mjs shape) classifies the same way. */
45
+ export function classifyBenchmarkRow(row) {
46
+ if (row.state === 'present') return row.noindex ? 'noindex' : 'server-html';
47
+ if (row.renderFixable) return 'js-only';
48
+ return null;
49
+ }
50
+
51
+ async function readJsonl(path) {
52
+ const text = await readFile(path, 'utf8');
53
+ return text.split('\n').filter(line => line.trim()).map(line => JSON.parse(line));
54
+ }
55
+
56
+ const dayOf = value => String(value ?? '').slice(0, 10);
57
+
58
+ /**
59
+ * The aggregate. Sources are stated per platform, because "one adjudication last year" and
60
+ * "eleven checks this month" are different amounts of knowledge and the reader can tell.
61
+ */
62
+ export async function platformAggregate({ observations = [], benchmark = [], seed = [] } = {}) {
63
+ const platforms = new Map();
64
+ const observe = (host, { klass, at, source }) => {
65
+ if (!host) return;
66
+ if (!platforms.has(host)) platforms.set(host, {
67
+ platform: host, observations: 0, classes: Object.fromEntries(PLATFORM_CLASSES.map(c => [c, 0])),
68
+ unclassified: 0, last_seen: null, sources: {},
69
+ });
70
+ const row = platforms.get(host);
71
+ row.observations++;
72
+ if (klass) row.classes[klass]++; else row.unclassified++;
73
+ if (!row.last_seen || dayOf(at) > dayOf(row.last_seen)) row.last_seen = dayOf(at);
74
+ row.sources[source] = (row.sources[source] ?? 0) + 1;
75
+ };
76
+
77
+ for (const path of observations) {
78
+ for (const row of await readJsonl(path)) {
79
+ observe(normalizeHost(row.result?.sourceUrl ?? row.source), {
80
+ klass: classifyObservation(row), at: row.checked_at, source: row.source === 'cloud' ? 'cloud-observation' : 'local-observation',
81
+ });
82
+ }
83
+ }
84
+ for (const path of benchmark) {
85
+ for (const row of await readJsonl(path)) {
86
+ observe(normalizeHost(row.sourceUrl), {
87
+ klass: classifyBenchmarkRow(row), at: row.checked_at, source: 'verifier-benchmark',
88
+ });
89
+ }
90
+ }
91
+ for (const path of seed) {
92
+ for (const row of await readJsonl(path)) {
93
+ observe(normalizeHost(`https://${row.platform}/`), {
94
+ klass: row.class, at: row.checked_at ?? row.last_seen, source: row.provenance,
95
+ });
96
+ }
97
+ }
98
+
99
+ const rows = [...platforms.values()].map(row => {
100
+ const votes = PLATFORM_CLASSES.map(klass => [klass, row.classes[klass]]).filter(([, n]) => n > 0);
101
+ votes.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
102
+ // A majority needs MORE THAN HALF the classified votes; a tie is not a majority, and
103
+ // zero classified votes is unknown rather than a default.
104
+ const classified = votes.reduce((n, [, count]) => n + count, 0);
105
+ const majority = classified > 0 && votes[0][1] > classified / 2 ? votes[0][0] : null;
106
+ return { ...row, classified, majority_class: majority,
107
+ // "unknown" here means "no class evidence", never "checked and absent".
108
+ status: classified ? 'classified' : 'observed-unclassified' };
109
+ }).sort((a, b) => b.observations - a.observations || a.platform.localeCompare(b.platform));
110
+
111
+ return {
112
+ v: 1,
113
+ // No inference from absence: a platform NOT in `platforms` has never been observed,
114
+ // and the CLI prints exactly that when asked about one.
115
+ classes: PLATFORM_CLASSES, platforms: rows,
116
+ totals: {
117
+ platforms: rows.length,
118
+ classified: rows.filter(row => row.classified).length,
119
+ observed_unclassified: rows.filter(row => !row.classified).length,
120
+ observations: rows.reduce((n, row) => n + row.observations, 0),
121
+ },
122
+ };
123
+ }
124
+
125
+ /** Claim-free context for candidate rows: the platform's numbers, or an explicit unknown. */
126
+ export function attachToCandidates(candidates, aggregate) {
127
+ const byHost = new Map(aggregate.platforms.map(row => [row.platform, row]));
128
+ return candidates.map(candidate => {
129
+ const host = normalizeHost(candidate.source_url ?? candidate.sourceUrl ?? '');
130
+ const row = host ? byHost.get(host) : null;
131
+ return {
132
+ ...candidate,
133
+ platform_context: row ? {
134
+ platform: row.platform, observations: row.observations, classified: row.classified,
135
+ majority_class: row.majority_class, classes: row.classes, last_seen: row.last_seen, sources: row.sources,
136
+ claim: 'counts_from_our_observations_only',
137
+ } : { platform: host, observations: 0, majority_class: null, claim: 'no_observations_unknown' },
138
+ };
139
+ });
140
+ }
141
+
142
+ export function renderPlatforms(aggregate, query, out = console.log) {
143
+ const rows = query
144
+ ? aggregate.platforms.filter(row => row.platform.includes(query.replace(/^www\./u, '')))
145
+ : aggregate.platforms;
146
+ if (query && !rows.length) {
147
+ out(`no observations for "${query}" — unknown. Nothing is inferred from absence; the platform may be excellent, unmeasured.`);
148
+ return 0;
149
+ }
150
+ out(`platform obs class majority-class last-seen sources`);
151
+ for (const row of rows.slice(0, query ? 50 : 40)) {
152
+ out(`${row.platform.padEnd(24)} ${String(row.observations).padStart(4)} ${String(row.classified).padStart(6)} `
153
+ + `${(row.majority_class ?? '—').padEnd(16)} ${String(row.unclassified).padStart(3)}u ${(row.last_seen ?? '').padEnd(10)} ${JSON.stringify(row.sources)}`);
154
+ }
155
+ const t = aggregate.totals;
156
+ out('');
157
+ out(`${t.platforms} platform(s), ${t.classified} with a class, ${t.observed_unclassified} observed without one, ${t.observations} observations total.`);
158
+ out('A platform absent from this list has never been observed by us; that is unknown, not a judgement.');
159
+ if (!query && rows.length > 40) out(`… and ${rows.length - 40} more (pass a HOST substring to filter)`);
160
+ return 0;
161
+ }
@@ -0,0 +1,64 @@
1
+ // Optional local context for a receipt report. Never edits the receipt or verification history.
2
+ import { newestWinningRows, windowComparison, windowFromRange, assertComparable, pageWithinProperty } from '../src/context/gsc.js';
3
+ import { landingPageContext } from '../src/context/ga4.js';
4
+
5
+ const dayIn = (value, zone) => new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(value));
6
+ function window(value) {
7
+ for (const key of ['start', 'end']) {
8
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value?.[key] ?? '') || new Date(`${value[key]}T00:00:00Z`).toISOString().slice(0, 10) !== value[key]) throw new Error('performance windows require valid calendar dates');
9
+ }
10
+ return windowFromRange(value.start, value.end);
11
+ }
12
+ const sameWindow = (a, b) => a?.start === b.start && a?.end === b.end;
13
+ const caveats = [
14
+ 'Traffic and search changes are observations, not evidence that this action caused ranking or traffic lift.',
15
+ 'Content edits, technical changes, other links, seasonality, search updates and measurement changes may explain differences; these alternatives have not been ruled out.',
16
+ 'GSC calendar dates use America/Los_Angeles; GA4 retains its property timezone. The populations are separate and are not joined.',
17
+ ];
18
+
19
+ export function attachReceiptPerformance(history, spec, { gscRows = [], ga4Rows = [], now = new Date().toISOString() } = {}) {
20
+ if (!spec || typeof spec.receipt_id !== 'string') throw new Error('performance requires receipt_id');
21
+ const selected = history.find(row => row.receipt.id === spec.receipt_id);
22
+ if (!selected) throw new Error('performance receipt_id not found');
23
+ const before = window(spec.before), after = window(spec.after);
24
+ assertComparable(before, after);
25
+ const actedDay = dayIn(selected.receipt.acted_at, 'America/Los_Angeles');
26
+ if (before.end >= actedDay || after.start <= actedDay || after.end >= dayIn(now, 'America/Los_Angeles')) throw new Error('performance windows must exclude the action day and unfinished days');
27
+ if (typeof spec.gsc_property !== 'string' || !pageWithinProperty(selected.receipt.target, spec.gsc_property)) throw new Error('performance requires a GSC property covering the receipt target');
28
+ if (spec.confounders != null && (!Array.isArray(spec.confounders) || spec.confounders.some(v => typeof v !== 'string' || !v.trim()))) throw new Error('confounders must be nonempty strings');
29
+ const page = selected.receipt.target;
30
+ const latest = newestWinningRows(gscRows);
31
+ const rows = latest.filter(row => row.kind === 'gsc.page' && row.page === page && row.property === spec.gsc_property
32
+ && row.type === (spec.search_type ?? 'web') && row.aggregationType === 'byPage' && JSON.stringify(row.dimensions) === '["page"]' && row.dataState === 'final');
33
+ const a = rows.find(row => sameWindow(row.window, after)), b = rows.find(row => sameWindow(row.window, before));
34
+ let search;
35
+ if (!a || !b) search = { state: 'missing_window', delta: null };
36
+ else if ([a, b].some(row => latest.some(marker => marker.kind === 'gsc.window' && marker.property === row.property
37
+ && marker.type === row.type && marker.aggregationType === row.aggregationType && marker.dataState === row.dataState
38
+ && JSON.stringify(marker.dimensions) === JSON.stringify(row.dimensions) && sameWindow(marker.window, row.window)
39
+ && (marker.fetched_at ?? '') > (row.fetched_at ?? '')))) search = { state: 'superseded_window', delta: null };
40
+ else if ([a, b].some(row => row.first_incomplete_date || row.incomplete || row.truncated)) search = { state: 'incomplete_window', delta: null };
41
+ else if ([a, b].some(row => ['clicks', 'impressions', 'position'].some(key => !Number.isFinite(row[key])))) search = { state: 'missing_metrics', delta: null };
42
+ else search = windowComparison({ rows: [a, b], windowA: after, windowB: before, property: spec.gsc_property, page });
43
+ search = { ...search, property: spec.gsc_property, page, timezone: 'America/Los_Angeles', before: b ?? null, after: a ?? null, delta_direction: 'after_minus_before' };
44
+ let business = { state: 'ga4_absent', before: null, after: null };
45
+ if (spec.ga4_property) {
46
+ const propertyRows = ga4Rows.filter(row => row.property === spec.ga4_property);
47
+ const read = wanted => ({ ...landingPageContext({ rows: propertyRows, page, window: wanted, connection: { property: spec.ga4_property, grant: 'unknown' } }),
48
+ provenance: propertyRows.filter(row => sameWindow(row.window, wanted)).map(row => ({ fetched_at: row.fetched_at ?? null, retrieved_by: row.retrieved_by ?? null })) });
49
+ business = { relation: 'side_by_side_not_joined', property: spec.ga4_property, before: read(before), after: read(after), delta: null };
50
+ const zones = [business.before.time_zone, business.after.time_zone];
51
+ const comparable = zones[0] && zones[0] === zones[1] && business.before.state === 'ok' && business.after.state === 'ok'
52
+ && before.end < dayIn(selected.receipt.acted_at, zones[0]) && after.start > dayIn(selected.receipt.acted_at, zones[0])
53
+ && after.end < dayIn(now, zones[0]) && business.before.currency_code === business.after.currency_code;
54
+ business.state = comparable ? 'dated_context' : 'not_comparable';
55
+ }
56
+ const performance_context = {
57
+ generated_at: now, receipt_id: selected.receipt.id, acted_at: selected.receipt.acted_at,
58
+ declared_at: selected.receipt.declared_at, verification_baseline: selected.receipt.baseline,
59
+ windows: { before, after, action_day_excluded: actedDay }, search, business,
60
+ confounders: { reported: spec.confounders ?? [], assessment: 'not_ruled_out' }, caveats,
61
+ attribution: 'not_established',
62
+ };
63
+ return history.map(row => row === selected ? { ...row, performance_context } : row);
64
+ }
@@ -0,0 +1,122 @@
1
+ // Local claims, joined to existing facts by ledger id. No network and no cloud receipt.
2
+ import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { normalizeEntry, readLedger } from './ledger.js';
5
+ import { checkObservationKind } from '../src/verifier/observation-kind.js';
6
+ import { readObservations } from './mirror.js';
7
+ import { readState } from './state.js';
8
+
9
+ const textAt = async path => readFile(path, 'utf8').catch(error => {
10
+ if (error.code === 'ENOENT') return ''; throw error;
11
+ });
12
+ const core = row => JSON.stringify([row.kind, row.source, row.target, row.scope ?? 'exact', row.acted_at]);
13
+ const placement = row => JSON.stringify([row.source, row.target, row.scope ?? 'exact']);
14
+ const date = value => typeof value === 'string' && /^\d{4}-\d\d-\d\dT.*(?:Z|[+-]\d\d:\d\d)$/.test(value) && Number.isFinite(Date.parse(value));
15
+
16
+ export async function readReceipts(path) {
17
+ const rows = [], ids = new Set(), keys = new Set();
18
+ for (const [i, line] of (await textAt(path)).split('\n').entries()) {
19
+ if (!line.trim()) continue;
20
+ try {
21
+ const row = JSON.parse(line);
22
+ if (!/^ar_[a-z0-9]{4,32}$/.test(row.id) || !/^lk_[a-z0-9]{4,32}$/.test(row.ledger)
23
+ || !['internal', 'external'].includes(row.kind) || row.report?.state !== 'claimed'
24
+ || !date(row.acted_at) || !date(row.declared_at) || !('baseline' in row)
25
+ || !['human', 'agent'].includes(row.actor?.role)) throw new Error('invalid receipt claim');
26
+ normalizeEntry({ ...row, id: row.ledger, intent: row.intent_after ?? 'wanted' });
27
+ if (ids.has(row.id) || (row.idempotency && keys.has(row.idempotency))) throw new Error('duplicate receipt identity or idempotency key');
28
+ ids.add(row.id); if (row.idempotency) keys.add(row.idempotency);
29
+ rows.push(row);
30
+ } catch (error) { throw new Error(`receipts line ${i + 1}: ${error.message}`); }
31
+ }
32
+ return rows;
33
+ }
34
+
35
+ // A recoverable two-file append. A crash after the entry write cannot mint another identity.
36
+ // Before/after comparisons refuse concurrent manual edits; recovery never overwrites them.
37
+ async function finishTransaction(path, tx, afterWrite = async () => {}) {
38
+ for (const part of tx.parts) {
39
+ const held = await textAt(part.path);
40
+ if (held === part.after) continue;
41
+ if (held !== part.before) throw new Error(`receipt transaction conflict at ${part.path}; preserve edits and reconcile ${path}`);
42
+ await mkdir(dirname(part.path), { recursive: true });
43
+ const tmp = `${part.path}.${crypto.randomUUID()}.tmp`;
44
+ await writeFile(tmp, part.after, 'utf8');
45
+ await rename(tmp, part.path);
46
+ await afterWrite(part.path);
47
+ }
48
+ await unlink(path);
49
+ }
50
+
51
+ export async function writeReceipt(config, input, { now = new Date().toISOString(), afterWrite } = {}) {
52
+ await mkdir(config.dir, { recursive: true });
53
+ const lock = `${config.paths.ledger}.lock`;
54
+ let handle;
55
+ try { handle = await import('node:fs/promises').then(fs => fs.open(lock, 'wx')); }
56
+ catch (error) { if (error.code === 'EEXIST') throw new Error(`receipt writer locked: ${lock}; remove only after confirming its writer stopped`); throw error; }
57
+ const journal = join(config.dir, 'receipt-transaction.json');
58
+ try {
59
+ const pending = await textAt(journal);
60
+ if (pending) await finishTransaction(journal, JSON.parse(pending), afterWrite);
61
+ const ledger = await readLedger(config.paths.ledger);
62
+ if (ledger.missing || ledger.problems.length) throw new Error('receipt requires a valid existing ledger');
63
+ const receipts = await readReceipts(config.paths.receipts);
64
+ const proposed = input.intent_after ?? (input.kind === 'internal' ? 'expected' : 'wanted');
65
+ const entry = normalizeEntry({ source: input.source, target: input.target, scope: input.scope,
66
+ intent: proposed, expect: input.expect, ref: input.ref, tags: input.tags, note: input.note,
67
+ added: now.slice(0, 10) }, { assignId: true });
68
+ const kind = checkObservationKind({ kind: input.kind, sourceUrl: entry.source, targetUrl: entry.target }, config.project?.site);
69
+ if (!kind.valid) throw new Error(kind.reason);
70
+ if (!date(input.acted_at) || !date(now)) throw new Error('acted_at must be an ISO timestamp with timezone');
71
+ if (!['human', 'agent'].includes(input.actor?.role)) throw new Error('actor.role must be human or agent');
72
+ if (input.report && input.report.state !== 'claimed') throw new Error('report.state must remain claimed');
73
+ if (input.idempotency != null && (typeof input.idempotency !== 'string' || !input.idempotency.trim())) throw new Error('idempotency must be a nonempty string');
74
+ const replay = input.idempotency && receipts.find(row => row.idempotency === input.idempotency);
75
+ if (replay) {
76
+ if (core(replay) !== core(input)) throw new Error('idempotency key is bound to a different core claim');
77
+ return { receipt: replay, created: false };
78
+ }
79
+ const matches = ledger.entries.filter(row => placement(row) === placement(entry));
80
+ if (matches.length > 1) throw new Error('ambiguous placement: multiple ledger entries');
81
+ const existing = input.ledger ? ledger.entries.find(row => row.id === input.ledger) : matches[0];
82
+ if (input.ledger && (!existing || placement(existing) !== placement(entry))) throw new Error('ledger reference does not match receipt placement');
83
+ const linked = existing ?? entry;
84
+ const observations = await readObservations(config.paths.observations);
85
+ if (observations.problems.length) throw new Error('cannot pin baseline from malformed observations');
86
+ const state = await readState(config.paths.state);
87
+ const prior = observations.rows.filter(row => row.id === linked.id && row.result?.sourceUrl === entry.source && row.result?.targetUrl === entry.target && (row.result?.targetScope ?? 'exact') === entry.scope && Date.parse(row.checked_at) <= Date.parse(now))
88
+ .sort((a, b) => Date.parse(b.checked_at) - Date.parse(a.checked_at))[0];
89
+ let baseline = prior ? { checked_at: prior.checked_at, state: prior.state, reason: prior.reason ?? null, complete: prior.complete === true } : null;
90
+ const activity = state.entries?.[linked.id];
91
+ if (activity && activity.last_placement === placement(entry) && Date.parse(activity.last_checked) <= Date.parse(now) && (!baseline || Date.parse(activity.last_checked) > Date.parse(baseline.checked_at))) {
92
+ baseline = { checked_at: activity.last_checked, state: activity.last_state, reason: activity.last_reason ?? null,
93
+ complete: activity.last_complete ?? (prior?.state === activity.last_state && prior?.reason === activity.last_reason ? prior.complete === true : false) };
94
+ }
95
+ const receipt = { ...input, id: `ar_${crypto.randomUUID().replaceAll('-', '').slice(0, 16)}`,
96
+ source: entry.source, target: entry.target, scope: entry.scope, declared_at: now,
97
+ report: { ...input.report, state: 'claimed' }, baseline, ledger: linked.id, intent_after: proposed };
98
+ const parts = [];
99
+ for (const [path, row] of [[config.paths.ledger, existing ? null : entry], [config.paths.receipts, receipt]]) {
100
+ if (!row) continue;
101
+ const before = await textAt(path);
102
+ parts.push({ path, before, after: before + (before && !before.endsWith('\n') ? '\n' : '') + JSON.stringify(row) + '\n' });
103
+ }
104
+ await writeFile(journal, JSON.stringify({ parts }), { flag: 'wx' });
105
+ await finishTransaction(journal, { parts }, afterWrite);
106
+ return { receipt, created: true };
107
+ } finally { await handle.close(); await unlink(lock); }
108
+ }
109
+
110
+ export function receiptHistory(receipts, entries, observations, state) {
111
+ return receipts.map(receipt => {
112
+ const entry = entries.find(row => row.id === receipt.ledger);
113
+ const history = observations.filter(row => row.id === receipt.ledger).sort((a, b) => Date.parse(a.checked_at) - Date.parse(b.checked_at));
114
+ const activity = state.entries?.[receipt.ledger];
115
+ const firstPresent = [activity?.first_present_placement === placement(receipt) ? activity.first_present : null, ...history.filter(row => row.state === 'present' && row.complete && row.result?.sourceUrl === receipt.source && row.result?.targetUrl === receipt.target && (row.result?.targetScope ?? 'exact') === receipt.scope).map(row => row.checked_at)]
116
+ .filter(Boolean).sort((a, b) => Date.parse(a) - Date.parse(b))[0] ?? null;
117
+ return { receipt, entry_missing: !entry, placement_changed: !!entry && placement(entry) !== placement(receipt),
118
+ intent_proposal_differs: !!entry && entry.intent !== receipt.intent_after,
119
+ latest: history.at(-1) ?? null, first_present: firstPresent,
120
+ novelty: receipt.baseline?.state === 'present' || (firstPresent && Date.parse(firstPresent) < Date.parse(receipt.acted_at)) ? 'contradicted' : 'unproven', history };
121
+ });
122
+ }
package/cli/report.js ADDED
@@ -0,0 +1,158 @@
1
+ // `agentlinkops report`: one self-contained HTML file, reproducible from a frozen dataset.
2
+ //
3
+ // **Reproducible is the requirement, and it is the one that constrains the design.** The same
4
+ // ledger and the same observations must produce byte-identical output, because a report a
5
+ // customer sends to a client is a document they may have to produce again months later and
6
+ // defend as unchanged. So: no clock read that is not an input, no random ids, stable ordering,
7
+ // and a content digest printed in the footer so "identical" is checkable rather than claimed.
8
+ //
9
+ // **A report timestamp does not make its observations fresh.** Every row carries the date IT was
10
+ // observed, and the header states the range, because the single most misleading thing a link
11
+ // report can do is print today's date above a figure from March.
12
+ //
13
+ // Self-contained: no external stylesheet, font, script or image. That makes it openable offline
14
+ // forever, printable to PDF from any browser, and incapable of telling anyone it was opened.
15
+ import { createHash } from 'node:crypto';
16
+ import { latestByEntry } from './mirror.js';
17
+
18
+ export const REPORT_VERSION = 1;
19
+
20
+ /** HTML-escape. Every value below is publisher or customer content and none of it is trusted. */
21
+ export const escape = value => String(value ?? '')
22
+ .replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
23
+ .replaceAll('"', '&quot;').replaceAll("'", '&#39;');
24
+
25
+ const STATE_LABEL = Object.freeze({
26
+ present: 'Present', absent: 'Not found', unknown: 'Could not check',
27
+ source_unavailable: 'Page unavailable', unchecked: 'Never checked',
28
+ });
29
+
30
+ /**
31
+ * The frozen dataset a report renders.
32
+ *
33
+ * Selection happens here and nowhere else, so a caller can hold this object, assert it, and know
34
+ * the rendered document is a pure function of it.
35
+ */
36
+ export function freezeDataset(entries, observations, { asOf, title = 'Backlink report', includeNotes = false, includeRetired = false } = {}) {
37
+ const latest = latestByEntry(observations);
38
+ const rows = entries
39
+ .filter(entry => includeRetired || entry.intent !== 'retired')
40
+ .map(entry => {
41
+ const row = latest.get(entry.id) ?? null;
42
+ return {
43
+ id: entry.id, intent: entry.intent, source: entry.source, target: entry.target, scope: entry.scope,
44
+ expected_anchor: entry.expect?.anchor ?? null,
45
+ state: row?.state ?? 'unchecked',
46
+ reason: row?.reason ?? null,
47
+ occurrences: row?.occurrences ?? 0,
48
+ checked_at: row?.checked_at ?? null,
49
+ checked_by: row?.source ?? null,
50
+ anchor: row?.result?.occurrences?.[0]?.anchor ?? null,
51
+ rel: row?.result?.occurrences?.[0]?.rel ?? [],
52
+ // Opt-in, because a note can hold commercial context — what a placement cost, what a
53
+ // publisher said — and a report is the document most likely to be forwarded.
54
+ note: includeNotes ? (entry.note ?? null) : null,
55
+ ref: includeNotes ? (entry.ref ?? null) : null,
56
+ };
57
+ })
58
+ // Stable ordering, by state then id: byte-identical output needs a total order with no ties
59
+ // broken by insertion.
60
+ .sort((a, b) => (a.state === b.state ? (a.id < b.id ? -1 : 1) : (a.state < b.state ? -1 : 1)));
61
+
62
+ const dates = rows.map(row => row.checked_at).filter(Boolean).sort();
63
+ const counts = rows.reduce((acc, row) => { acc[row.state] = (acc[row.state] ?? 0) + 1; return acc; }, {});
64
+ return {
65
+ v: REPORT_VERSION, title, as_of: asOf, include_notes: includeNotes,
66
+ rows,
67
+ totals: { entries: rows.length, ...counts },
68
+ // Stated rather than implied. A report over a ledger nobody has checked is not a report about
69
+ // links, and the reader should see that before the table.
70
+ coverage: {
71
+ checked: rows.filter(row => row.checked_at).length,
72
+ never_checked: rows.filter(row => !row.checked_at).length,
73
+ observed_from: dates[0] ?? null,
74
+ observed_to: dates.at(-1) ?? null,
75
+ },
76
+ };
77
+ }
78
+
79
+ const STYLE = `
80
+ :root{color-scheme:light}
81
+ *{box-sizing:border-box}
82
+ body{margin:0;padding:32px;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;background:#fff}
83
+ h1{font-size:22px;margin:0 0 4px}
84
+ .sub{color:#555;margin:0 0 24px}
85
+ .cards{display:flex;flex-wrap:wrap;gap:12px;margin:0 0 24px}
86
+ .card{border:1px solid #ddd;border-radius:6px;padding:10px 14px;min-width:120px}
87
+ .card b{display:block;font-size:20px}
88
+ .card span{color:#555;font-size:12px}
89
+ table{border-collapse:collapse;width:100%;font-size:13px}
90
+ th,td{text-align:left;vertical-align:top;padding:8px 10px;border-bottom:1px solid #eee}
91
+ th{background:#fafafa;border-bottom:1px solid #ddd;font-weight:600}
92
+ td.url{word-break:break-all;max-width:340px}
93
+ .state{display:inline-block;padding:1px 8px;border-radius:10px;font-size:12px;border:1px solid #ccc}
94
+ .present{background:#eef7ee;border-color:#b6d7b6}
95
+ .absent{background:#fdeeee;border-color:#e2b6b6}
96
+ .unknown,.unchecked,.source_unavailable{background:#f6f6f6}
97
+ .note{color:#555;font-size:12px}
98
+ footer{margin-top:28px;padding-top:12px;border-top:1px solid #eee;color:#555;font-size:12px}
99
+ .caveat{margin:18px 0;padding:10px 14px;border-left:3px solid #ddd;color:#444;font-size:13px}
100
+ @media print{body{padding:0}th{background:#fff}tr{break-inside:avoid}.card{break-inside:avoid}}
101
+ `.trim();
102
+
103
+ /**
104
+ * Renders the frozen dataset. A pure function of its argument: same input, same bytes.
105
+ *
106
+ * The digest in the footer is over the DATASET rather than over the HTML, so it stays stable
107
+ * across a cosmetic change to this template while still changing the moment a figure does.
108
+ */
109
+ export function renderReport(dataset, { brand = null } = {}) {
110
+ const digest = createHash('sha256').update(JSON.stringify(dataset)).digest('hex');
111
+ const card = (label, value) => `<div class="card"><b>${escape(value)}</b><span>${escape(label)}</span></div>`;
112
+ const coverage = dataset.coverage;
113
+ const rows = dataset.rows.map(row => `<tr>
114
+ <td class="url"><a href="${escape(row.source)}">${escape(row.source)}</a></td>
115
+ <td class="url">${escape(row.target)}</td>
116
+ <td><span class="state ${escape(row.state)}">${escape(STATE_LABEL[row.state] ?? row.state)}</span></td>
117
+ <td>${row.anchor ? escape(row.anchor) : '<span class="note">&mdash;</span>'}${(row.rel ?? []).length ? `<div class="note">rel: ${escape(row.rel.join(' '))}</div>` : ''}</td>
118
+ <td>${row.checked_at ? escape(row.checked_at.slice(0, 10)) : '<span class="note">never</span>'}</td>
119
+ ${dataset.include_notes ? `<td class="note">${escape(row.ref ?? '')}${row.note ? `<div>${escape(row.note)}</div>` : ''}</td>` : ''}
120
+ </tr>`).join('\n');
121
+
122
+ return `<!doctype html>
123
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
124
+ <title>${escape(dataset.title)}</title><style>${STYLE}</style></head>
125
+ <body>
126
+ <h1>${escape(dataset.title)}</h1>
127
+ <p class="sub">${brand ? `${escape(brand)} &middot; ` : ''}Prepared ${escape(dataset.as_of)}</p>
128
+ <div class="cards">
129
+ ${card('links in report', dataset.totals.entries)}
130
+ ${card('present', dataset.totals.present ?? 0)}
131
+ ${card('not found', dataset.totals.absent ?? 0)}
132
+ ${card('could not check', (dataset.totals.unknown ?? 0) + (dataset.totals.source_unavailable ?? 0))}
133
+ ${card('never checked', dataset.totals.unchecked ?? 0)}
134
+ </div>
135
+ <p class="caveat"><strong>What these dates mean.</strong> Every row shows the date that link was
136
+ last observed, between ${escape(coverage.observed_from ?? 'n/a')} and ${escape(coverage.observed_to ?? 'n/a')}.
137
+ The date at the top is when this document was prepared and does not make any observation newer
138
+ than it is. ${coverage.never_checked ? `${coverage.never_checked} of ${dataset.totals.entries} links have never been checked and are listed as such.` : ''}</p>
139
+ <p class="caveat"><strong>What a result means.</strong> &ldquo;Present&rdquo; means the link was in
140
+ the HTML the publisher served. &ldquo;Could not check&rdquo; is not the same as
141
+ &ldquo;not found&rdquo;: it means the page could not be read, and no conclusion was drawn.
142
+ JavaScript execution and visual visibility were not checked.</p>
143
+ <table><thead><tr>
144
+ <th>Source page</th><th>Target</th><th>Result</th><th>Anchor</th><th>Observed</th>${dataset.include_notes ? '<th>Notes</th>' : ''}
145
+ </tr></thead><tbody>
146
+ ${rows}
147
+ </tbody></table>
148
+ <footer>
149
+ AgentLinkOps report v${dataset.v}. Dataset digest <code>${escape(digest)}</code> &mdash; the same
150
+ ledger and observations always produce this digest.
151
+ ${dataset.include_notes ? 'Private notes are included in this document.' : 'Private notes and references are excluded.'}
152
+ </footer>
153
+ </body></html>
154
+ `;
155
+ }
156
+
157
+ /** The digest a caller can compare without rendering. */
158
+ export const datasetDigest = dataset => createHash('sha256').update(JSON.stringify(dataset)).digest('hex');