agentlinkops 0.6.13 → 0.6.16

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 (178) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/.codex-plugin/plugin.json +2 -2
  3. package/CHANGELOG.md +25 -2
  4. package/PACK-ARTIFACT.json +16 -0
  5. package/PACKS.json +591 -0
  6. package/README.md +34 -48
  7. package/cli/adapters/suppliers.js +1 -1
  8. package/cli/agent-setup.js +101 -17
  9. package/cli/campaign-bridge.js +325 -0
  10. package/cli/campaign-outcomes.js +107 -0
  11. package/cli/catalog.js +45702 -18674
  12. package/cli/check.js +40 -10
  13. package/cli/client.js +2 -1
  14. package/cli/commands.js +17 -8
  15. package/cli/connection.js +92 -18
  16. package/cli/contact-adoption.js +91 -0
  17. package/cli/context.js +13 -2
  18. package/cli/doctor.js +15 -4
  19. package/cli/import.js +7 -1
  20. package/cli/key-store.js +115 -0
  21. package/cli/ledger.js +1 -0
  22. package/cli/local-result.js +41 -6
  23. package/cli/main.js +51 -12
  24. package/cli/mcp-client.js +2 -2
  25. package/cli/migration.js +308 -0
  26. package/cli/prompt.js +25 -0
  27. package/cli/public-http.js +168 -0
  28. package/cli/state.js +14 -0
  29. package/cli/sync.js +1 -1
  30. package/package.json +7 -8
  31. package/references/citation-panels.md +2 -0
  32. package/references/cli.md +2 -2
  33. package/references/disavow.md +1 -1
  34. package/references/discovery-handoff.md +1 -1
  35. package/references/local-crm-draft-handoff.md +1 -1
  36. package/references/portable-ledger-handoff.md +11 -10
  37. package/references/recipes/catalog.json +263 -7
  38. package/references/recipes/command-notes.md +5 -3
  39. package/references/recipes/internal-link-improvement.md +25 -0
  40. package/references/recipes/placement-reconciliation.md +53 -1
  41. package/scripts/portable-handoff.py +17 -4
  42. package/shared/admission-contract.js +3 -2
  43. package/shared/admission-report-contract.js +3 -2
  44. package/shared/analytics-history-contract.js +15 -0
  45. package/shared/campaign-outcome-contract.js +12 -0
  46. package/shared/campaign-prospect-contract.js +23 -0
  47. package/shared/classification-contract.js +54 -0
  48. package/shared/contact-record-contract.js +27 -0
  49. package/shared/disavow-contract.js +37 -9
  50. package/shared/index-observation-contract.js +5 -4
  51. package/shared/internal-links-contract.js +143 -0
  52. package/shared/link-category-contract.js +35 -0
  53. package/shared/link-category.js +138 -0
  54. package/shared/link-labels.js +26 -0
  55. package/shared/link-summary-contract.js +17 -0
  56. package/shared/observation-event.js +1 -1
  57. package/shared/onboarding.js +20 -0
  58. package/shared/response-contract.js +38 -5
  59. package/shared/search-connection-contract.js +2 -1
  60. package/shared/table-page-contract.js +13 -0
  61. package/skills/agentlinkops-assets/SKILL-DEPS.json +290 -0
  62. package/skills/agentlinkops-assets/SKILL.md +3 -1
  63. package/skills/agentlinkops-assets/agents/openai.yaml +1 -1
  64. package/skills/agentlinkops-assets/references/portable/references/cli.md +220 -0
  65. package/skills/agentlinkops-assets/references/portable/references/disavow.md +73 -0
  66. package/skills/agentlinkops-assets/references/portable/references/reports.md +53 -0
  67. package/skills/agentlinkops-assets/references/portable/scripts/agentlinkops.py +676 -0
  68. package/skills/agentlinkops-assets/references/portable/scripts/disavow.py +207 -0
  69. package/skills/agentlinkops-assets/references/portable/scripts/migrations/001_crm.sql +45 -0
  70. package/skills/agentlinkops-assets/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
  71. package/skills/agentlinkops-assets/references/portable/scripts/migrations/003_disavow.sql +20 -0
  72. package/skills/agentlinkops-assets/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
  73. package/skills/agentlinkops-assets/references/portable/scripts/report.py +113 -0
  74. package/skills/agentlinkops-assets/references/portable/templates/campaigns.v1.json +65 -0
  75. package/skills/agentlinkops-campaigns/SKILL-DEPS.json +336 -0
  76. package/skills/agentlinkops-campaigns/SKILL.md +7 -5
  77. package/skills/agentlinkops-campaigns/agents/openai.yaml +1 -1
  78. package/skills/agentlinkops-campaigns/references/first-campaign.md +31 -0
  79. package/skills/agentlinkops-campaigns/references/mailbox-campaign.md +46 -0
  80. package/skills/agentlinkops-campaigns/references/portable/references/cli.md +220 -0
  81. package/skills/agentlinkops-campaigns/references/portable/references/disavow.md +73 -0
  82. package/skills/agentlinkops-campaigns/references/portable/references/reports.md +53 -0
  83. package/skills/agentlinkops-campaigns/references/portable/scripts/agentlinkops.py +676 -0
  84. package/skills/agentlinkops-campaigns/references/portable/scripts/disavow.py +207 -0
  85. package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/001_crm.sql +45 -0
  86. package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
  87. package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/003_disavow.sql +20 -0
  88. package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
  89. package/skills/agentlinkops-campaigns/references/portable/scripts/report.py +113 -0
  90. package/skills/agentlinkops-campaigns/references/portable/templates/campaigns.v1.json +65 -0
  91. package/skills/agentlinkops-connect/SKILL-DEPS.json +104 -0
  92. package/skills/agentlinkops-connect/SKILL.md +2 -0
  93. package/skills/agentlinkops-connect/agents/openai.yaml +1 -1
  94. package/skills/agentlinkops-connect/references/agentlinkops.md +164 -62
  95. package/skills/agentlinkops-connect/references/placement-workflows.md +19 -0
  96. package/skills/agentlinkops-crm/SKILL-DEPS.json +381 -0
  97. package/skills/agentlinkops-crm/SKILL.md +26 -9
  98. package/skills/agentlinkops-crm/agents/openai.yaml +1 -1
  99. package/skills/agentlinkops-crm/references/portable/references/cli.md +220 -0
  100. package/skills/agentlinkops-crm/references/portable/references/disavow.md +73 -0
  101. package/skills/agentlinkops-crm/references/portable/references/local-crm-draft-handoff.md +76 -0
  102. package/skills/agentlinkops-crm/references/portable/references/portable-ledger-handoff.md +51 -0
  103. package/skills/agentlinkops-crm/references/portable/references/reports.md +53 -0
  104. package/skills/agentlinkops-crm/references/portable/scripts/agentlinkops.py +676 -0
  105. package/skills/agentlinkops-crm/references/portable/scripts/disavow.py +207 -0
  106. package/skills/agentlinkops-crm/references/portable/scripts/migrations/001_crm.sql +45 -0
  107. package/skills/agentlinkops-crm/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
  108. package/skills/agentlinkops-crm/references/portable/scripts/migrations/003_disavow.sql +20 -0
  109. package/skills/agentlinkops-crm/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
  110. package/skills/agentlinkops-crm/references/portable/scripts/outreach-crm-draft.py +121 -0
  111. package/skills/agentlinkops-crm/references/portable/scripts/portable-handoff.py +197 -0
  112. package/skills/agentlinkops-crm/references/portable/scripts/report.py +113 -0
  113. package/skills/agentlinkops-crm/references/portable/templates/campaigns.v1.json +65 -0
  114. package/skills/agentlinkops-discovery/SKILL-DEPS.json +301 -0
  115. package/skills/agentlinkops-discovery/SKILL.md +4 -2
  116. package/skills/agentlinkops-discovery/references/portable/references/cli.md +220 -0
  117. package/skills/agentlinkops-discovery/references/portable/references/disavow.md +73 -0
  118. package/skills/agentlinkops-discovery/references/portable/references/discovery-handoff.md +32 -0
  119. package/skills/agentlinkops-discovery/references/portable/references/reports.md +53 -0
  120. package/skills/agentlinkops-discovery/references/portable/scripts/agentlinkops.py +676 -0
  121. package/skills/agentlinkops-discovery/references/portable/scripts/disavow.py +207 -0
  122. package/skills/agentlinkops-discovery/references/portable/scripts/migrations/001_crm.sql +45 -0
  123. package/skills/agentlinkops-discovery/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
  124. package/skills/agentlinkops-discovery/references/portable/scripts/migrations/003_disavow.sql +20 -0
  125. package/skills/agentlinkops-discovery/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
  126. package/skills/agentlinkops-discovery/references/portable/scripts/report.py +113 -0
  127. package/skills/agentlinkops-discovery/references/portable/templates/campaigns.v1.json +65 -0
  128. package/skills/agentlinkops-discovery/references/saved-backlink-workflows.md +23 -0
  129. package/src/competitors/contract.js +17 -1
  130. package/src/context/site-profile.js +34 -121
  131. package/src/corpus/acquire.js +68 -32
  132. package/src/corpus/extract.js +147 -0
  133. package/src/corpus/frontier.js +151 -0
  134. package/src/discovery/contract.js +8 -0
  135. package/src/discovery/import.js +13 -1
  136. package/src/mentions.js +193 -0
  137. package/src/page-acquisition/acquire.js +50 -0
  138. package/src/page-acquisition/bytes.js +138 -0
  139. package/src/page-acquisition/capture.js +11 -0
  140. package/src/page-acquisition/contracts.js +67 -0
  141. package/src/page-acquisition/documents.js +166 -0
  142. package/src/page-acquisition/http.js +307 -0
  143. package/src/page-acquisition/policy.js +20 -0
  144. package/src/page-acquisition/recovery.js +164 -0
  145. package/src/page-acquisition/render-failure.js +82 -0
  146. package/src/page-extraction/html.js +359 -0
  147. package/src/page-extraction/index.js +30 -0
  148. package/src/page-extraction/metadata.js +16 -0
  149. package/src/response-shaping.js +4 -3
  150. package/src/tool-views.js +22 -12
  151. package/src/verifier/fetch.js +4 -339
  152. package/src/verifier/index.js +81 -23
  153. package/src/verifier/page-quality.js +14 -0
  154. package/src/verifier/parser.js +2 -241
  155. package/src/verifier/readiness.js +64 -13
  156. package/src/verifier/render-evidence.js +25 -2
  157. package/src/verifier/target.js +201 -0
  158. package/src/verifier/transition.js +18 -3
  159. package/src/verifier/url.js +31 -0
  160. package/src/watch-crawlability.js +144 -0
  161. package/skills/content-defingerprinting/SKILL.md +0 -29
  162. package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -102
  163. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -26
  164. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -52
  165. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -21
  166. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -96
  167. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -432
  168. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -86
  169. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -460
  170. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -651
  171. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -27
  172. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -212
  173. package/skills/internal-linking-optimizer/SKILL.md +0 -56
  174. package/skills/internal-linking-optimizer/references/source-prompt.md +0 -76
  175. package/skills/pre-publish-review/SKILL.md +0 -179
  176. package/skills/source-cited-content-builder/SKILL.md +0 -56
  177. package/skills/source-cited-content-builder/references/source-prompt.md +0 -19
  178. package/skills/source-cited-humanizer/SKILL.md +0 -20
@@ -0,0 +1,308 @@
1
+ // DP-0069-T02: local migration assessment over the existing verifiers.
2
+ //
3
+ // The customer supplies an old-to-new URL map and a ledger. This module validates the map,
4
+ // joins it to ledger placements, and either plans the work (dry run: no fetch, no check, no
5
+ // enrollment) or runs the EXISTING link verifier and target verifier and reports, per affected
6
+ // placement, source presence and destination continuity as two independent observations.
7
+ //
8
+ // What it deliberately does not do:
9
+ // - It never rewrites a ledger entry. The original source-to-old-target expectation stays.
10
+ // - It never decides equivalence. HTTP success to the mapped URL is route evidence; only the
11
+ // map's recorded decision makes it `continuous`.
12
+ // - It does not invent a failure state machine: destination repeats go through the existing
13
+ // `transitionTargetState` reducer, and a mapping mismatch is not a backlink-loss event.
14
+ // - It does not enroll watches, call the cloud or edit redirects. Monitoring is DP-0069-T04.
15
+ import { readFile } from 'node:fs/promises';
16
+ import { verifyLink } from '../src/verifier/index.js';
17
+ import { verifyTarget, transitionTargetState } from '../src/verifier/target.js';
18
+ import { validatePublicUrl } from '../src/verifier/url.js';
19
+ import { createPacer } from './check.js';
20
+
21
+ export const MIGRATION_REPORT_SCHEMA = 'agentlinkops.migration_report/v1';
22
+ export const DISPOSITIONS = Object.freeze(['move', 'merge', 'retire']);
23
+ export const EQUIVALENCE_DECISIONS = Object.freeze(['equivalent', 'not_equivalent', 'undecided']);
24
+ export const MAX_MAP_ROWS = 5000;
25
+
26
+ const ISO = value => typeof value === 'string' && /^\d{4}-\d\d-\d\dT.*(?:Z|[+-]\d\d:\d\d)$/.test(value) && Number.isFinite(Date.parse(value));
27
+ const text = value => typeof value === 'string' && value.trim().length > 0;
28
+ const plain = value => value != null && typeof value === 'object' && !Array.isArray(value);
29
+ // Fragments are never sent over HTTP, so two URLs differing only there are one fetch identity.
30
+ const fetchKey = href => { const url = new URL(href); url.hash = ''; return url.href; };
31
+
32
+ export class MigrationMapError extends Error {
33
+ constructor(problems) {
34
+ super(`migration map rejected: ${problems.map(p => `${p.row == null ? 'map' : `row ${p.row}`} ${p.code}`).join('; ')}`);
35
+ this.name = 'MigrationMapError';
36
+ this.problems = problems;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Resolves one map URL against the declared base, preserving path, query and fragment, and
42
+ * screens it with the same public-destination rules every redirect hop must pass.
43
+ */
44
+ function resolveUrl(raw, base) {
45
+ if (!text(raw)) return { error: 'missing_url' };
46
+ let url;
47
+ try { url = new URL(raw); }
48
+ catch {
49
+ if (!base) return { error: 'relative_url_without_base' };
50
+ try { url = new URL(raw, base); } catch { return { error: 'invalid_url' }; }
51
+ }
52
+ const screened = validatePublicUrl(url.href);
53
+ if (!screened.valid) return { error: `non_public_url:${screened.reason}` };
54
+ // validatePublicUrl drops the fragment; keep it as the customer wrote it.
55
+ return { href: url.hash ? `${screened.url}${url.hash}` : screened.url };
56
+ }
57
+
58
+ /**
59
+ * Validates a supplied migration map. Every problem is collected and the whole map is rejected:
60
+ * a partially accepted map would produce a report whose denominator nobody chose.
61
+ */
62
+ export function parseMigrationMap(input) {
63
+ const problems = [];
64
+ const fail = (code, row = null, detail) => problems.push({ row, code, ...(detail ? { detail } : {}) });
65
+ if (!plain(input)) throw new MigrationMapError([{ row: null, code: 'map_must_be_object' }]);
66
+ if (typeof input.migration_id !== 'string' || !/^[A-Za-z0-9._:-]{1,80}$/.test(input.migration_id)) fail('invalid_migration_id');
67
+ if (!Number.isInteger(input.version) || input.version < 1) fail('invalid_version');
68
+ if (input.cutover_at != null && !ISO(input.cutover_at)) fail('invalid_cutover_at');
69
+ if (!plain(input.provenance) || !text(input.provenance.source)) fail('missing_provenance');
70
+ else if (input.provenance.prepared_at != null && !ISO(input.provenance.prepared_at)) fail('invalid_provenance_prepared_at');
71
+
72
+ let base = null;
73
+ if (input.base != null) {
74
+ const resolved = resolveUrl(input.base, null);
75
+ if (resolved.error) fail('invalid_base', null, resolved.error); else base = resolved.href;
76
+ }
77
+ const origins = new Set();
78
+ if (!plain(input.site) || !Array.isArray(input.site.origins) || !input.site.origins.length) fail('missing_site_scope');
79
+ else for (const origin of input.site.origins) {
80
+ const resolved = resolveUrl(origin, null);
81
+ if (resolved.error) fail('invalid_site_origin', null, String(origin));
82
+ else origins.add(new URL(resolved.href).origin);
83
+ }
84
+ if (!Array.isArray(input.rows) || !input.rows.length) fail('missing_rows');
85
+ else if (input.rows.length > MAX_MAP_ROWS) fail('too_many_rows', null, `at most ${MAX_MAP_ROWS}`);
86
+ if (problems.length) throw new MigrationMapError(problems);
87
+
88
+ const rows = [];
89
+ input.rows.forEach((raw, index) => {
90
+ const n = index + 1;
91
+ const before = problems.length;
92
+ if (!plain(raw)) { fail('row_must_be_object', n); return; }
93
+ const disposition = DISPOSITIONS.includes(raw.disposition) ? raw.disposition : null;
94
+ if (!disposition) fail('invalid_disposition', n);
95
+ const old = resolveUrl(raw.old_url, base);
96
+ if (old.error) fail('invalid_old_url', n, old.error);
97
+ else if (!origins.has(new URL(old.href).origin)) fail('old_url_outside_site_scope', n);
98
+ let next = null;
99
+ if (disposition === 'retire') {
100
+ if (raw.new_url != null) fail('retire_must_not_have_new_url', n);
101
+ } else if (disposition) {
102
+ const resolved = resolveUrl(raw.new_url, base);
103
+ if (resolved.error) fail('invalid_new_url', n, resolved.error);
104
+ else if (!origins.has(new URL(resolved.href).origin)) fail('new_url_outside_site_scope', n);
105
+ else next = resolved.href;
106
+ }
107
+ if (!old.error && next && fetchKey(old.href) === fetchKey(next)) fail('self_mapping', n);
108
+ let equivalence = null;
109
+ if (disposition && disposition !== 'retire') {
110
+ const decision = raw.equivalence?.decision;
111
+ if (!EQUIVALENCE_DECISIONS.includes(decision)) fail('missing_equivalence_decision', n);
112
+ else if (decision !== 'undecided' && !text(raw.equivalence.reason)) fail('equivalence_reason_required', n);
113
+ else equivalence = { decision, reason: raw.equivalence.reason ?? null };
114
+ if (disposition === 'merge' && decision !== 'equivalent') fail('merge_requires_equivalent_decision', n);
115
+ }
116
+ const variants = [];
117
+ if (raw.approved_variants != null && !Array.isArray(raw.approved_variants)) fail('approved_variants_must_be_array', n);
118
+ for (const variant of Array.isArray(raw.approved_variants) ? raw.approved_variants : []) {
119
+ const resolved = plain(variant) ? resolveUrl(variant.url, base) : { error: 'variant_must_be_object' };
120
+ if (resolved.error) fail('invalid_approved_variant', n, resolved.error);
121
+ // A variant is an accepted final URL for the same row, so it stays inside the declared site.
122
+ else if (!origins.has(new URL(resolved.href).origin)) fail('approved_variant_outside_site_scope', n);
123
+ else if (!text(variant.evidence)) fail('approved_variant_requires_evidence', n);
124
+ else variants.push({ url: resolved.href, evidence: variant.evidence });
125
+ }
126
+ if (variants.length && disposition === 'retire') fail('retire_cannot_have_variants', n);
127
+ if (problems.length > before) return;
128
+ rows.push({ row: n, old_url: old.href, new_url: next, disposition, equivalence,
129
+ approved_variants: variants, evidence: raw.evidence ?? null, note: raw.note ?? null });
130
+ });
131
+ if (problems.length) throw new MigrationMapError(problems);
132
+
133
+ // Identity checks run over the accepted rows so every collision is named at once.
134
+ const byOld = new Map(), byKey = new Map(), byNew = new Map();
135
+ const content = row => JSON.stringify([row.new_url, row.disposition, row.equivalence, row.approved_variants]);
136
+ for (const row of rows) {
137
+ const exact = byOld.get(row.old_url);
138
+ if (exact) fail(content(exact) === content(row) ? 'duplicate_row' : 'conflicting_row', row.row, `same old_url as row ${exact.row}`);
139
+ else {
140
+ const sibling = byKey.get(fetchKey(row.old_url));
141
+ if (sibling) fail('ambiguous_old_url', row.row, `differs from row ${sibling.row} only by fragment`);
142
+ byOld.set(row.old_url, row);
143
+ byKey.set(fetchKey(row.old_url), row);
144
+ }
145
+ if (row.new_url) {
146
+ const key = fetchKey(row.new_url);
147
+ if (!byNew.has(key)) byNew.set(key, []);
148
+ byNew.get(key).push(row);
149
+ }
150
+ }
151
+ for (const group of byNew.values()) {
152
+ if (group.length < 2) continue;
153
+ // Many-to-one is a merge, and a merge is a documented decision, never an inference.
154
+ for (const row of group) if (row.disposition !== 'merge') fail('undocumented_merge', row.row, `shares new_url with rows ${group.filter(r => r !== row).map(r => r.row).join(', ')}`);
155
+ }
156
+ if (problems.length) throw new MigrationMapError(problems);
157
+
158
+ const warnings = [];
159
+ for (const row of rows) {
160
+ if (row.new_url && byKey.has(fetchKey(row.new_url))) warnings.push({ row: row.row, code: 'chained_mapping', detail: `new_url is the old_url of row ${byKey.get(fetchKey(row.new_url)).row}` });
161
+ if (row.new_url && new URL(row.new_url).pathname === '/' && new URL(row.old_url).pathname !== '/') warnings.push({ row: row.row, code: 'homepage_target', detail: 'a homepage redirect does not establish equivalence' });
162
+ }
163
+ return { migration_id: input.migration_id, version: input.version, cutover_at: input.cutover_at ?? null,
164
+ provenance: input.provenance, base, origins: [...origins].sort(), rows, warnings };
165
+ }
166
+
167
+ export async function readMigrationMap(path) {
168
+ let raw;
169
+ try { raw = JSON.parse(await readFile(path, 'utf8')); }
170
+ catch (error) { throw new MigrationMapError([{ row: null, code: error.code === 'ENOENT' ? 'map_file_missing' : 'map_not_json', detail: error.message }]); }
171
+ return parseMigrationMap(raw);
172
+ }
173
+
174
+ /**
175
+ * Joins the map to ledger placements. Pure: the entries come back by reference to their ids,
176
+ * never modified. `retired` ledger entries are kept for history and not selected.
177
+ */
178
+ export function selectAffected(map, entries) {
179
+ const byKey = new Map(map.rows.map(row => [fetchKey(row.old_url), row]));
180
+ const newKeys = new Set(map.rows.filter(row => row.new_url).map(row => fetchKey(row.new_url)));
181
+ const affected = [], unmapped = [];
182
+ let retiredExcluded = 0, onNewUrl = 0, outsideScope = 0;
183
+ const usedRows = new Set();
184
+ for (const entry of entries) {
185
+ let key, origin;
186
+ try { key = fetchKey(entry.target); origin = new URL(entry.target).origin; } catch { continue; }
187
+ if (!map.origins.includes(origin)) { outsideScope++; continue; }
188
+ if (entry.intent === 'retired') { retiredExcluded++; continue; }
189
+ const row = byKey.get(key);
190
+ const identity = { ledger_id: entry.id, intent: entry.intent, group: entry.intent === 'expected' ? 'earned' : 'wanted',
191
+ source: entry.source, original_target: entry.target, scope: entry.scope ?? 'exact' };
192
+ // `expect` rides along for the source check only, so presence is judged exactly as `check` judges it.
193
+ if (row) { usedRows.add(row.row); affected.push({ ...identity, map_row: row.row, row, expect: entry.expect ?? null }); continue; }
194
+ if (newKeys.has(key)) { onNewUrl++; continue; }
195
+ // No row is not a failure: the map simply says nothing about this target.
196
+ unmapped.push(identity);
197
+ }
198
+ return { affected, unmapped, rowsWithoutPlacements: map.rows.filter(row => !usedRows.has(row.row)).map(row => row.row),
199
+ counts: { ledger_entries: entries.length, retired_excluded: retiredExcluded, already_on_new_url: onNewUrl, outside_site_scope: outsideScope } };
200
+ }
201
+
202
+ const sourceSummary = result => ({ state: result.state, reason: result.reason, complete: result.evidence?.complete === true,
203
+ occurrences: result.occurrences?.length ?? 0,
204
+ expectations: result.expectations ?? null, http_status: result.httpStatus ?? null, final_url: result.finalUrl ?? null, checked_at: result.checkedAt });
205
+
206
+ const destinationSummary = (result, transition) => ({ state: result.state, reason: result.reason,
207
+ complete: result.evidence?.complete === true, http_status: result.httpStatus ?? null, requested_url: result.targetUrl,
208
+ // Exact hops as observed, including partial chains ended by a loop, limit or unsafe hop.
209
+ redirects: (result.redirects ?? []).map(hop => ({ from: hop.from, to: hop.to, status: hop.status })),
210
+ final_url: result.finalUrl ?? null, checked_at: result.checkedAt,
211
+ confirmation: transition ? { state: transition.state, confirmed_state: transition.confirmedState,
212
+ unavailable_count: transition.unavailableCount, next_check_at: transition.nextCheckAt, event: transition.event?.type ?? null } : null });
213
+
214
+ const REFUSED = /^(?:unsafe_redirect|invalid_target|invalid_redirect|unsafe_robots_redirect|public_fetch_not_configured)/;
215
+
216
+ /** Destination continuity for one map row, from the target observation alone. */
217
+ export function classifyContinuity(row, destination) {
218
+ if (row.disposition === 'retire') return { status: 'intentional_retirement', continuity: 'intentional_retirement' };
219
+ if (destination.state === 'unknown') {
220
+ return REFUSED.test(destination.reason) ? { status: 'refused', continuity: 'refused' } : { status: 'unknown', continuity: 'unknown' };
221
+ }
222
+ if (destination.state === 'unavailable') return { status: 'checked', continuity: 'destination_unavailable' };
223
+ const final = fetchKey(destination.final_url);
224
+ const route = final === fetchKey(row.new_url) ? 'mapped'
225
+ : row.approved_variants.some(variant => fetchKey(variant.url) === final) ? 'approved_variant' : null;
226
+ // A reachable wrong destination is a mismatch even when it returned 200.
227
+ if (!route) return { status: 'checked', continuity: 'mismatch', route: null, redirected: destination.redirects.length > 0 };
228
+ // HTTP reached the mapped route; only the customer's recorded decision makes it equivalent.
229
+ // A recorded `not_equivalent` is the customer's decision and is reported as such, not as "unconfirmed".
230
+ const decision = row.equivalence?.decision;
231
+ return { status: 'checked', route, continuity: decision === 'equivalent' ? 'continuous'
232
+ : decision === 'not_equivalent' ? 'route_matched_not_equivalent' : 'route_matched_equivalence_unconfirmed' };
233
+ }
234
+
235
+ function coverage(map, selection, rows) {
236
+ const tally = {};
237
+ for (const row of rows) tally[row.status] = (tally[row.status] ?? 0) + 1;
238
+ return { denominator: 'supplied map rows and ledger entries only', map_rows: map.rows.length,
239
+ map_rows_without_placements: selection.rowsWithoutPlacements.length, ...selection.counts,
240
+ affected_placements: selection.affected.length, unmapped_placements: selection.unmapped.length,
241
+ earned: rows.filter(row => row.group === 'earned').length, wanted: rows.filter(row => row.group === 'wanted').length,
242
+ by_status: tally };
243
+ }
244
+
245
+ function header(map, mode, generatedAt) {
246
+ return { schema: MIGRATION_REPORT_SCHEMA, mode, generated_at: generatedAt, migration_id: map.migration_id,
247
+ map_version: map.version, cutover_at: map.cutover_at, provenance: map.provenance, warnings: map.warnings,
248
+ boundary: 'Technical continuity only. No ranking, PageRank, index transfer or traffic claim. No watch was created.' };
249
+ }
250
+
251
+ const rowBase = item => ({ ledger_id: item.ledger_id, group: item.group, intent: item.intent, source: item.source,
252
+ original_target: item.original_target, scope: item.scope, expected_target: item.row.new_url, disposition: item.row.disposition,
253
+ map_row: item.map_row, equivalence: item.row.equivalence });
254
+
255
+ /**
256
+ * Dry run. Accepts the supplied inputs and reports what a checked run would do, with no fetch,
257
+ * no check consumed and no enrollment. It takes no transport on purpose.
258
+ */
259
+ export function planMigration(map, entries, { now = new Date().toISOString() } = {}) {
260
+ const selection = selectAffected(map, entries);
261
+ const rows = selection.affected.map(item => ({ ...rowBase(item),
262
+ status: item.row.disposition === 'retire' ? 'intentional_retirement' : 'unattempted' }));
263
+ for (const item of selection.unmapped) rows.push({ ...item, expected_target: null, map_row: null, status: 'unmapped' });
264
+ const destinations = new Set(selection.affected.map(item => fetchKey(item.row.old_url)));
265
+ return { ...header(map, 'dry_run', now), network: false,
266
+ planned: { source_checks: selection.affected.length, destination_checks: destinations.size, enrollments: 0 },
267
+ coverage: coverage(map, selection, rows), rows };
268
+ }
269
+
270
+ /**
271
+ * Checked run. Source presence uses `verifyLink` against the ORIGINAL target; destination uses
272
+ * `verifyTarget` on the old URL, once per distinct old URL. Both use the public-fetch boundary
273
+ * and the same pacing as `agentlinkops check`. `priorTargetStates` (old URL -> reducer state)
274
+ * lets repeated observations reuse the existing confirmation rules; the next states come back
275
+ * in `target_states` for the caller to keep locally.
276
+ */
277
+ export async function assessMigration(map, entries, {
278
+ now = () => new Date().toISOString(), timeoutMs = 20_000, hostDelayMs = 2000, fetchImpl,
279
+ verify = verifyLink, verifyDestination = verifyTarget, pacer = null, priorTargetStates = {},
280
+ } = {}) {
281
+ const selection = selectAffected(map, entries);
282
+ const paced = pacer ?? createPacer({ hostDelayMs });
283
+ const common = { publicFetchSafe: true, timeoutMs, includeHtml: false, beforeFetch: paced.beforeFetch,
284
+ ...(fetchImpl ? { fetchImpl } : {}) };
285
+ const destinations = new Map(), targetStates = {};
286
+ for (const item of selection.affected) {
287
+ const key = fetchKey(item.row.old_url);
288
+ if (destinations.has(key)) continue;
289
+ const result = await verifyDestination({ targetUrl: item.row.old_url }, { ...common, now: now() });
290
+ const transition = transitionTargetState(priorTargetStates[key] ?? null, result, result.checkedAt);
291
+ targetStates[key] = transition;
292
+ destinations.set(key, destinationSummary(result, transition));
293
+ }
294
+ const rows = [];
295
+ for (const item of selection.affected) {
296
+ const destination = destinations.get(fetchKey(item.row.old_url));
297
+ const source = await verify({ sourceUrl: item.source, targetUrl: item.original_target, targetScope: item.scope,
298
+ expectedAnchor: item.expect?.anchor, expectedRel: item.expect?.rel }, { ...common, now: now() });
299
+ rows.push({ ...rowBase(item), ...classifyContinuity(item.row, destination), source_presence: sourceSummary(source), destination });
300
+ }
301
+ for (const item of selection.unmapped) rows.push({ ...item, expected_target: null, map_row: null, status: 'unmapped' });
302
+ const generatedAt = now();
303
+ const failures = rows.filter(row => ['mismatch', 'destination_unavailable'].includes(row.continuity));
304
+ return { ...header(map, 'checked', generatedAt), network: true, coverage: coverage(map, selection, rows), rows,
305
+ target_states: targetStates,
306
+ // A mapping mismatch or failed destination is not a backlink-loss event; loss stays with `check`.
307
+ loss_events: [], exitCode: failures.length ? 1 : 0 };
308
+ }
package/cli/prompt.js ADDED
@@ -0,0 +1,25 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { Writable } from 'node:stream';
3
+
4
+ /**
5
+ * Asks once on the terminal without echoing what is typed or pasted (an API key). The question
6
+ * goes to stderr so stdout stays clean for JSON readers. Resolves '' on Enter, Ctrl+C or Ctrl+D.
7
+ */
8
+ export function askSecret(question, { input = process.stdin, output = process.stderr } = {}) {
9
+ return new Promise(resolve => {
10
+ let echo = true, settled = false;
11
+ const sink = new Writable({ write(chunk, encoding, done) { if (echo) output.write(chunk, encoding); done(); } });
12
+ const rl = createInterface({ input, output: sink, terminal: true });
13
+ const finish = answer => {
14
+ if (settled) return;
15
+ settled = true;
16
+ rl.close();
17
+ output.write('\n');
18
+ resolve(String(answer ?? '').trim());
19
+ };
20
+ rl.on('SIGINT', () => finish(''));
21
+ rl.on('close', () => finish(''));
22
+ rl.question(question, finish);
23
+ echo = false;
24
+ });
25
+ }
@@ -0,0 +1,168 @@
1
+ // Accountless CLI transport. One fresh connection per hop; the canonical fetcher
2
+ // continues to own robots, redirect policy, parsing and the whole-check deadline.
3
+ import dns from 'node:dns/promises';
4
+ import http from 'node:http';
5
+ import https from 'node:https';
6
+ import { Readable } from 'node:stream';
7
+ import ipaddr from 'ipaddr.js';
8
+ import { validatePublicUrl } from '../src/verifier/url.js';
9
+ import { VerificationError, withAbort } from '../src/page-acquisition/bytes.js';
10
+
11
+ // Match the qualified public executor policy. Neither a public-looking hostname
12
+ // nor the Workers compatibility flag establishes this boundary in a local CLI.
13
+ const V4_DENIED = ['0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16',
14
+ '172.16.0.0/12', '192.0.0.0/24', '192.0.2.0/24', '192.88.99.0/24', '192.168.0.0/16',
15
+ '198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4'].map(ipaddr.parseCIDR);
16
+ const V6_DENIED = ['2001::/23', '2001:db8::/32', '2002::/16', '3fff::/20'].map(ipaddr.parseCIDR);
17
+ const V6_GLOBAL = ipaddr.parseCIDR('2000::/3');
18
+ const REQUEST_HEADERS = new Set(['user-agent', 'accept', 'if-none-match', 'if-modified-since']);
19
+ const RESPONSE_HEADERS = new Set(['location', 'content-type', 'content-encoding', 'content-length',
20
+ 'etag', 'last-modified', 'retry-after', 'link', 'x-robots-tag', 'cf-mitigated']);
21
+
22
+ export function isPublicNodeAddress(value) {
23
+ let address;
24
+ try { if (typeof value !== 'string' || value.includes('%')) return false; address = ipaddr.parse(value); }
25
+ catch { return false; }
26
+ if (address.kind() === 'ipv6' && address.isIPv4MappedAddress()) address = address.toIPv4Address();
27
+ return address.kind() === 'ipv4' ? !V4_DENIED.some(range => address.match(range))
28
+ : address.match(V6_GLOBAL) && !V6_DENIED.some(range => address.match(range));
29
+ }
30
+ const normalizeAddress = value => {
31
+ let address = ipaddr.parse(value);
32
+ if (address.kind() === 'ipv6' && address.isIPv4MappedAddress()) address = address.toIPv4Address();
33
+ return address.toString();
34
+ };
35
+
36
+ export async function resolveNodePublicDestination(hostname, { lookup = (...args) => dns.lookup(...args), signal } = {}) {
37
+ let answers;
38
+ try { answers = await withAbort(Promise.resolve().then(() => lookup(hostname, { all: true, verbatim: true })), signal); }
39
+ catch (error) { throw error instanceof VerificationError ? error : new VerificationError('dns_resolution_failed'); }
40
+ if (!Array.isArray(answers) || !answers.length || answers.length > 64 || answers.some(answer => {
41
+ if (!answer || ![4, 6].includes(answer.family) || !isPublicNodeAddress(answer.address)) return true;
42
+ return ipaddr.parse(answer.address).kind() !== (answer.family === 4 ? 'ipv4' : 'ipv6');
43
+ })) throw new VerificationError('dns_destination_not_public');
44
+ const address = normalizeAddress(answers[0].address);
45
+ return { address, family: ipaddr.parse(address).kind() === 'ipv4' ? 4 : 6 };
46
+ }
47
+
48
+ /** Fetch-compatible single-hop GET. It never follows a redirect or uses credentials. */
49
+ export function createNodePublicFetch({ lookup, requestImpl } = {}) {
50
+ return async (rawUrl, init = {}) => {
51
+ const checked = validatePublicUrl(rawUrl);
52
+ if (!checked.valid) throw new VerificationError(`unsafe_url:${checked.reason}`);
53
+ if (init.method !== 'GET' || init.redirect !== 'manual' || init.credentials !== 'omit' || init.body !== undefined)
54
+ throw new VerificationError('transport_request_invalid');
55
+ const url = new URL(checked.url), headers = {};
56
+ for (const [name, value] of new Headers(init.headers)) {
57
+ if (!REQUEST_HEADERS.has(name) || value.length > 4096 || /[\r\n\u0000]/u.test(value))
58
+ throw new VerificationError('transport_request_invalid');
59
+ headers[name] = value;
60
+ }
61
+ const controller = new AbortController();
62
+ const onOwnerAbort = () => controller.abort();
63
+ const timer = setTimeout(onOwnerAbort, 20_000);
64
+ const cleanup = () => { clearTimeout(timer); init.signal?.removeEventListener('abort', onOwnerAbort); };
65
+ init.signal?.addEventListener('abort', onOwnerAbort, { once: true });
66
+ if (init.signal?.aborted) controller.abort();
67
+ let outbound, incoming, socket;
68
+ try {
69
+ if (controller.signal.aborted) throw new VerificationError('timeout');
70
+ const pinned = await resolveNodePublicDestination(url.hostname, { lookup, signal: controller.signal });
71
+ if (controller.signal.aborted) throw new VerificationError('timeout');
72
+ const assertSocket = value => {
73
+ try {
74
+ if (!value?.remoteAddress || normalizeAddress(value.remoteAddress) !== pinned.address)
75
+ throw new VerificationError('socket_destination_changed');
76
+ } catch (error) { throw error instanceof VerificationError ? error : new VerificationError('socket_destination_unverified'); }
77
+ };
78
+ return await new Promise((resolve, reject) => {
79
+ let responded = false, stopped = false;
80
+ const stop = error => {
81
+ if (stopped) return;
82
+ stopped = true; cleanup();
83
+ controller.signal.removeEventListener('abort', onAbort);
84
+ outbound?.destroy(); incoming?.destroy(error);
85
+ if (!responded) reject(error);
86
+ };
87
+ const onAbort = () => stop(new VerificationError('timeout'));
88
+ controller.signal.addEventListener('abort', onAbort, { once: true });
89
+ const implementation = requestImpl ?? (url.protocol === 'https:' ? https.request : http.request);
90
+ try {
91
+ outbound = implementation({ protocol: url.protocol, hostname: url.hostname,
92
+ port: url.protocol === 'https:' ? 443 : 80, path: `${url.pathname}${url.search}`, method: 'GET',
93
+ agent: false, servername: url.hostname, rejectUnauthorized: true,
94
+ maxHeaderSize: 32 * 1024, family: pinned.family, autoSelectFamily: false,
95
+ lookup: (name, options, callback) => {
96
+ if (name !== url.hostname) return callback(new VerificationError('socket_hostname_changed'));
97
+ // Node versions may request the callback's all-answer form. Both
98
+ // forms contain only the already screened, pinned literal.
99
+ if (options?.all) return callback(null, [{ address: pinned.address, family: pinned.family }]);
100
+ callback(null, pinned.address, pinned.family);
101
+ },
102
+ headers: { ...headers, Host: url.host, 'Accept-Encoding': 'identity', Connection: 'close' },
103
+ }, response => {
104
+ incoming = response;
105
+ // Validation may destroy the stream before a Web reader attaches.
106
+ response.on('error', () => {});
107
+ if (stopped || controller.signal.aborted) { response.destroy(); return; }
108
+ try {
109
+ assertSocket(response.socket ?? socket);
110
+ const safeHeaders = new Headers();
111
+ for (const [name, value] of Object.entries(response.headers)) {
112
+ if (!RESPONSE_HEADERS.has(name.toLowerCase()) || value === undefined) continue;
113
+ const text = Array.isArray(value) ? value.join(', ') : String(value);
114
+ if (text.length > 8192 || /[\r\n\u0000]/u.test(text)) throw new VerificationError('response_headers_too_large');
115
+ safeHeaders.set(name, text);
116
+ }
117
+ // Identity is requested explicitly. Unsupported encoded bodies
118
+ // remain unknown rather than being mistaken for empty HTML.
119
+ if (safeHeaders.has('content-encoding') && safeHeaders.get('content-encoding').trim().toLowerCase() !== 'identity')
120
+ throw new VerificationError('content_encoding_unsupported');
121
+ if (safeHeaders.has('content-length') && !/^\d+$/u.test(safeHeaders.get('content-length')))
122
+ throw new VerificationError('transport_response_invalid');
123
+ const status = response.statusCode;
124
+ if (!Number.isInteger(status) || status < 200 || status > 599) throw new VerificationError('transport_response_invalid');
125
+ response.once('end', () => {
126
+ cleanup(); controller.signal.removeEventListener('abort', onAbort);
127
+ });
128
+ response.once('close', () => { cleanup(); controller.signal.removeEventListener('abort', onAbort); });
129
+ response.once('aborted', () => response.destroy(new VerificationError('incomplete_body')));
130
+ let body = null;
131
+ if (![204, 205, 304].includes(status)) {
132
+ const reader = Readable.toWeb(response).getReader();
133
+ body = new ReadableStream({
134
+ async pull(stream) {
135
+ try {
136
+ const item = await reader.read();
137
+ if (item.done) {
138
+ if (!response.complete) throw new VerificationError('incomplete_body');
139
+ reader.releaseLock(); stream.close();
140
+ } else stream.enqueue(item.value);
141
+ } catch (error) {
142
+ stream.error(error);
143
+ reader.cancel().catch(() => {}).finally(() => reader.releaseLock());
144
+ }
145
+ },
146
+ async cancel(reason) { try { await reader.cancel(reason); } finally { reader.releaseLock(); } },
147
+ });
148
+ }
149
+ const result = new Response(body, { status, headers: safeHeaders });
150
+ Object.defineProperty(result, 'url', { value: checked.url });
151
+ responded = true; resolve(result);
152
+ if (!body) { response.destroy(); cleanup(); controller.signal.removeEventListener('abort', onAbort); }
153
+ } catch (error) { stop(error instanceof VerificationError ? error : new VerificationError('transport_response_invalid')); }
154
+ });
155
+ outbound.on('socket', value => {
156
+ socket = value;
157
+ socket.once('connect', () => { try { assertSocket(socket); } catch (error) { stop(error); } });
158
+ });
159
+ outbound.on('error', error => stop(error instanceof VerificationError ? error : new VerificationError('network_error')));
160
+ if (controller.signal.aborted) return onAbort();
161
+ outbound.end();
162
+ } catch (error) { stop(error instanceof VerificationError ? error : new VerificationError('network_error')); }
163
+ });
164
+ } catch (error) {
165
+ cleanup(); outbound?.destroy(); incoming?.destroy(); throw error;
166
+ }
167
+ };
168
+ }
package/cli/state.js CHANGED
@@ -14,6 +14,17 @@ import { placementIdentity, attemptSummary, isConclusive, isLinkVerification, va
14
14
 
15
15
  export const STATE_VERSION = 3;
16
16
 
17
+ // A newer activity format may carry semantics this reducer cannot preserve.
18
+ // Refuse before loading, reducing or replacing it; unknown fields in supported
19
+ // versions still round-trip through the existing migration rules.
20
+ function assertSupportedStateVersion(raw) {
21
+ const version = Number.isInteger(raw?.v) ? raw.v : 1;
22
+ if (version <= STATE_VERSION) return;
23
+ const error = new Error(`Activity state format ${version} is unsupported; this CLI supports up to ${STATE_VERSION}. Keep the original state and history; use a compatible CLI.`);
24
+ error.code = 'UNSUPPORTED_STATE_VERSION';
25
+ throw error;
26
+ }
27
+
17
28
  /**
18
29
  * Brings a state file forward without losing anything it already held.
19
30
  *
@@ -25,6 +36,7 @@ export const STATE_VERSION = 3;
25
36
  * cursor. Seeding it would skip every target event ever emitted, and the feed would look quiet.
26
37
  */
27
38
  export function upgradeState(raw) {
39
+ assertSupportedStateVersion(raw);
28
40
  const state = { ...(raw ?? {}) };
29
41
  const version = Number.isInteger(state.v) ? state.v : 1;
30
42
  const cursors = { ...(state.cursors ?? {}) };
@@ -50,6 +62,7 @@ export async function readState(path) {
50
62
  }
51
63
 
52
64
  export async function writeState(path, state) {
65
+ assertSupportedStateVersion(state);
53
66
  await mkdir(dirname(path), { recursive: true });
54
67
  const temporary = join(dirname(path), `.${crypto.randomUUID()}.state.tmp`);
55
68
  // Sorted keys so a run that changes one entry produces a one-line diff.
@@ -129,6 +142,7 @@ export function selectChanged(rows, state) {
129
142
 
130
143
  /** Applies a run to the activity record. Every check counts, recorded or not. */
131
144
  export function applyRun(state, rows) {
145
+ assertSupportedStateVersion(state);
132
146
  const entries = { ...(state.entries ?? {}) };
133
147
  for (const row of rows) {
134
148
  const previous = entries[row.id];
package/cli/sync.js CHANGED
@@ -72,7 +72,7 @@ export async function pushExpectations(client, entries, state, { projectId, onPr
72
72
  for (const entry of entries.filter(item => actions.get(item.id) === 'pause')) {
73
73
  const watchId = mapping[entry.id];
74
74
  if (!watchId) continue;
75
- try { await client.updateWatch(watchId, { status: 'paused' }); retired.push(entry.id); }
75
+ try { await client.updateWatch(watchId, { status: 'paused', intent: 'retired' }); retired.push(entry.id); }
76
76
  catch (error) { failed.push({ id: entry.id, code: error.code, message: error.message }); }
77
77
  }
78
78
  return { pushed: watched.length, created: created.length, retired: retired.length, failed, watches: mapping, local: plan.filter(row => row.action === 'stay_local').length };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlinkops",
3
- "version": "0.6.13",
3
+ "version": "0.6.16",
4
4
  "description": "Backlink checks with dated evidence and portable results, optional repository ledgers, supplier-export imports and sync to AgentLinkOps.",
5
5
  "author": "AgentLinkOps (https://agentlinkops.com)",
6
6
  "license": "Apache-2.0",
@@ -22,6 +22,8 @@
22
22
  "references",
23
23
  "scripts",
24
24
  ".mcp.json",
25
+ "PACKS.json",
26
+ "PACK-ARTIFACT.json",
25
27
  ".claude-plugin",
26
28
  ".codex-plugin",
27
29
  "README.md",
@@ -46,24 +48,21 @@
46
48
  "mcp",
47
49
  "backlinks",
48
50
  "seo",
49
- "citation-monitoring",
50
- "ai-citations",
51
51
  "agent",
52
52
  "cli",
53
53
  "link-monitoring",
54
- "citation-watch",
55
54
  "ledger",
56
55
  "jsonl"
57
56
  ],
58
57
  "dependencies": {
59
- "@modelcontextprotocol/client": "2.2.0",
60
- "@modelcontextprotocol/server": "2.0.0",
58
+ "@modelcontextprotocol/client": "2.3.1",
59
+ "@modelcontextprotocol/server": "2.3.1",
61
60
  "ipaddr.js": "2.2.0",
62
61
  "koffi": "3.3.2",
63
62
  "parse5": "8.0.1",
64
63
  "turndown": "^7.2.4",
65
- "undici": "7.29.1",
66
- "zod": "4.6.0"
64
+ "undici": "7.30.1",
65
+ "zod": "4.6.7"
67
66
  },
68
67
  "devDependencies": {
69
68
  "esbuild": "^0.28.2"
@@ -1,5 +1,7 @@
1
1
  # Citation panels
2
2
 
3
+ Status: AI citation measurement is not part of the AgentLinkOps offer. No default skill links to this page, and the hosted service lists no citation commands for customers. This reference covers the local `agentlinkops citation` commands, which stay in the CLI for existing panels.
4
+
3
5
  A panel records the questions, target sites, engines and sample budget for a citation check. Start with questions your audience has actually asked. Keep each question's source and collection date in your own research notes. Search Console queries, support conversations and public discussion threads can help when you already have access; the panel builder does not fetch or validate them.
4
6
 
5
7
  Create an editable draft without calling a provider:
package/references/cli.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Local CRM contract
2
2
 
3
3
  Version 0.2.0; SQLite schema version 4. Python 3.10+ is the only prerequisite.
4
- Examples assume the working directory is this plugin root. Resolve paths from
5
- the installed plugin location when running from a skill. Choose a database
4
+ Resolve example helper paths from the plugin root for shared references, or from
5
+ the skill directory for a standalone archive's bundled references. Choose a database
6
6
  path in the user's working directory, outside the replaceable plugin cache.
7
7
 
8
8
  ```sh
@@ -16,7 +16,7 @@ checked September 9, 2026.
16
16
 
17
17
  ## Commands
18
18
 
19
- Run from the installed plugin root and keep the database outside its cache:
19
+ Resolve example helper paths from the plugin root for shared references, or from the skill directory for a standalone archive's bundled references. Keep the database outside the installation directory:
20
20
 
21
21
  ```sh
22
22
  python3 scripts/agentlinkops.py --db ./agentlinkops.sqlite migrate
@@ -29,4 +29,4 @@ The local check includes wanted entries; `--all` rechecks the selected rows even
29
29
 
30
30
  For an already connected workspace, `agentlinkops tools monitoring` and `agentlinkops describe monitor_link` expose the current hosted contract. Inspect project IDs and usage first. Enroll selected source/target pairs only when recurring monitoring is within the user's request; record the returned watch IDs. A successful import or local check alone does not create a recurring hosted job.
31
31
 
32
- The [campaign skill](../skills/agentlinkops-campaigns/SKILL.md) handles subsequent campaign judgment. The [CRM reference](cli.md) covers optional records when the user needs contacts and activities beside the ledger. Outreach stays in the user's chosen tool and requires their instruction.
32
+ Use the customer's brief and evidence to decide the next campaign step. For additional guidance, select `agentlinkops agent setup --skill agentlinkops-campaigns` and read that installed skill. The [CRM reference](cli.md) covers optional records when the user needs contacts and activities beside the ledger. Outreach stays in the user's chosen tool and requires their instruction.
@@ -7,7 +7,7 @@ This adapter writes an inert body and companion manifest under that project's
7
7
  drafts directory. It does not write contacts, replies, suppression, send logs or
8
8
  sequence state. Native vendor APIs remain unsupported by this adapter.
9
9
 
10
- Run from the plugin root:
10
+ Resolve example helper paths from the plugin root for shared references, or from the skill directory for a standalone archive's bundled references:
11
11
 
12
12
  ```sh
13
13
  python3 scripts/outreach-crm-draft.py --crm-root /path/to/customer-crm --request request.json --body-file reviewed-body.txt