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
@@ -0,0 +1,207 @@
1
+ """Offline, user-managed disavow lists. No network access or rule recommendations."""
2
+ import datetime as dt
3
+ import hashlib
4
+ import ipaddress
5
+ import json
6
+ from pathlib import Path
7
+ import re
8
+ import urllib.parse
9
+
10
+ # Google's help page states "2MB" without defining the byte convention.
11
+ # Use decimal MB so every accepted export is below either common interpretation.
12
+ MAX_BYTES = 2000000
13
+ MAX_LINES = 100000
14
+ MAX_URL = 2048
15
+
16
+
17
+ class DisavowError(Exception):
18
+ def __init__(self, message, code="INVALID_DISAVOW"):
19
+ super().__init__(message)
20
+ self.code = code
21
+
22
+
23
+ def stamp():
24
+ return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
25
+
26
+
27
+ def stable_id(prefix, parts):
28
+ return prefix + hashlib.sha256(json.dumps(parts, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest()
29
+
30
+
31
+ def domain(value):
32
+ if not isinstance(value, str):
33
+ raise DisavowError("Domain rule must contain a hostname")
34
+ value = value.strip().lower().removesuffix(".")
35
+ try:
36
+ ipaddress.ip_address(value)
37
+ except ValueError:
38
+ pass
39
+ else:
40
+ raise DisavowError("A domain rule requires a DNS hostname, not an IP address")
41
+ if len(value) > 253 or "." not in value or not value.isascii() or any(not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) for label in value.split(".")):
42
+ raise DisavowError("Domain rules require an ASCII or Punycode hostname without scheme, port, path or wildcard")
43
+ return value
44
+
45
+
46
+ def url(value, property_url=False):
47
+ if not isinstance(value, str) or not value or len(value) > MAX_URL or any(ord(char) <= 32 or ord(char) == 127 for char in value) or "\\" in value:
48
+ raise DisavowError("URLs must be at most 2,048 characters without whitespace or control characters")
49
+ try:
50
+ parsed = urllib.parse.urlsplit(value)
51
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username is not None or parsed.password is not None:
52
+ raise ValueError()
53
+ port = parsed.port
54
+ try:
55
+ address = ipaddress.ip_address(parsed.hostname)
56
+ hostname = "[" + address.compressed + "]" if address.version == 6 else address.compressed
57
+ except ValueError:
58
+ hostname = domain(parsed.hostname)
59
+ if port is not None:
60
+ hostname += ":" + str(port)
61
+ except (ValueError, DisavowError) as exc:
62
+ raise DisavowError("Use an HTTP(S) URL with an ASCII/Punycode hostname and no credentials") from exc
63
+ if "*" in parsed.path or "*" in parsed.query:
64
+ raise DisavowError("Wildcard and subpath patterns are unsupported; specify an exact page URL or a domain rule")
65
+ if property_url and (parsed.query or parsed.fragment):
66
+ raise DisavowError("Property scope must be a URL prefix without query or fragment")
67
+ normalized = urllib.parse.urlunsplit((parsed.scheme, hostname, parsed.path or "/", parsed.query, parsed.fragment))
68
+ if len(normalized) > MAX_URL:
69
+ raise DisavowError("Normalized URL exceeds 2,048 characters")
70
+ return normalized
71
+
72
+
73
+ def rule(kind, value):
74
+ if kind == "domain":
75
+ return domain(value)
76
+ if kind == "url":
77
+ return url(value)
78
+ raise DisavowError("Rule kind must be domain or url")
79
+
80
+
81
+ def source_label(value):
82
+ if not isinstance(value, str) or not value.strip() or len(value) > 1024 or any(ord(char) < 32 for char in value):
83
+ raise DisavowError("Source provenance must be a nonempty label without control characters")
84
+ return value.strip()
85
+
86
+
87
+ def parse_file(path):
88
+ if Path(path).suffix.lower() != ".txt":
89
+ raise DisavowError("Disavow import requires a .txt file")
90
+ with Path(path).open("rb") as file:
91
+ raw = file.read(MAX_BYTES + 1)
92
+ if len(raw) > MAX_BYTES:
93
+ raise DisavowError("Disavow file exceeds the 2 MB limit")
94
+ try:
95
+ original = raw.decode("utf-8")
96
+ except UnicodeDecodeError as exc:
97
+ raise DisavowError("Disavow file must be UTF-8 or 7-bit ASCII") from exc
98
+ # Accept a UTF-8 BOM but retain the original bytes' hash and original decoded text.
99
+ content = original.removeprefix("\ufeff")
100
+ if "\x00" in content:
101
+ raise DisavowError("Disavow file contains NUL characters")
102
+ # Counting physical LF lines (including blank/comment lines) avoids treating
103
+ # Unicode separators inside a URL or comment as extra file lines.
104
+ lines = content.split("\n")
105
+ if lines and lines[-1] == "":
106
+ lines.pop()
107
+ if len(lines) > MAX_LINES:
108
+ raise DisavowError("Disavow file exceeds 100,000 lines including comments and blanks")
109
+ entries, comments = [], []
110
+ for number, original_line in enumerate(lines, 1):
111
+ line = original_line.rstrip("\r").strip()
112
+ if not line:
113
+ continue
114
+ if line.startswith("#"):
115
+ comments.append({"line": number, "text": line[1:].lstrip()})
116
+ continue
117
+ kind, value = ("domain", line[7:]) if line.startswith("domain:") else ("url", line)
118
+ try:
119
+ normalized = rule(kind, value)
120
+ except DisavowError as exc:
121
+ raise DisavowError(f"Line {number}: {exc}") from exc
122
+ entries.append({"kind": kind, "value": normalized, "line": number, "original": original_line.rstrip("\r"), "comments": comments})
123
+ comments = []
124
+ return original, hashlib.sha256(raw).hexdigest(), entries, len(lines)
125
+
126
+
127
+ def import_rules(connection, path, property_url, source):
128
+ property_url, source = url(property_url, True), source_label(source)
129
+ original, digest, entries, line_count = parse_file(path)
130
+ source_id = stable_id("dsrc_", [property_url, source, digest, "txt"])
131
+ added, existing, recorded = 0, 0, stamp()
132
+ connection.execute("BEGIN IMMEDIATE")
133
+ try:
134
+ connection.execute("INSERT OR IGNORE INTO disavow_sources VALUES (?,?,?,?,?,?,?)", (source_id, property_url, source, digest, "txt", original, recorded))
135
+ for entry in entries:
136
+ rule_id = stable_id("drule_", [property_url, entry["kind"], entry["value"]])
137
+ inserted = connection.execute("INSERT OR IGNORE INTO disavow_rules(id,property_url,kind,value,created_at,updated_at) VALUES (?,?,?,?,?,?)", (rule_id, property_url, entry["kind"], entry["value"], recorded, recorded)).rowcount
138
+ added += inserted
139
+ existing += 1 - inserted
140
+ # An import never overwrites local notes or reactivates a rule the user disabled.
141
+ connection.execute("INSERT OR IGNORE INTO disavow_provenance VALUES (?,?,?,?,?,?)", (rule_id, source_id, entry["line"], entry["original"], json.dumps(entry["comments"], ensure_ascii=False), recorded))
142
+ connection.execute("COMMIT")
143
+ except Exception:
144
+ connection.execute("ROLLBACK")
145
+ raise
146
+ return {"property_url": property_url, "source_id": source_id, "rules_added": added, "existing_rule_lines": existing, "lines": line_count, "mode": "merge", "uploaded": False}
147
+
148
+
149
+ def upsert_rule(connection, record, property_url, source):
150
+ property_url, source = url(property_url, True), source_label(source)
151
+ if not isinstance(record, dict) or record.keys() - {"kind", "value", "active", "notes"}:
152
+ raise DisavowError("Rule JSON accepts kind, value, active and notes")
153
+ kind = record.get("kind")
154
+ value = rule(kind, record.get("value"))
155
+ if "active" in record and type(record["active"]) is not bool:
156
+ raise DisavowError("active must be a JSON boolean")
157
+ if "notes" in record and not isinstance(record["notes"], str):
158
+ raise DisavowError("notes must be a string")
159
+ rule_id = stable_id("drule_", [property_url, kind, value])
160
+ raw = json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
161
+ digest = hashlib.sha256(raw.encode()).hexdigest()
162
+ source_id = stable_id("dsrc_", [property_url, source, digest, "manual"])
163
+ recorded = stamp()
164
+ connection.execute("BEGIN IMMEDIATE")
165
+ try:
166
+ existing = connection.execute("SELECT active,notes FROM disavow_rules WHERE id=?", (rule_id,)).fetchone()
167
+ active = int(record.get("active", bool(existing[0]) if existing else True))
168
+ notes = record.get("notes", existing[1] if existing else "")
169
+ connection.execute("INSERT INTO disavow_rules VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET active=excluded.active,notes=excluded.notes,updated_at=excluded.updated_at", (rule_id, property_url, kind, value, active, notes, recorded, recorded))
170
+ connection.execute("INSERT OR IGNORE INTO disavow_sources VALUES (?,?,?,?,?,?,?)", (source_id, property_url, source, digest, "manual", raw, recorded))
171
+ connection.execute("INSERT OR IGNORE INTO disavow_provenance VALUES (?,?,?,?,?,?)", (rule_id, source_id, 0, raw, "[]", recorded))
172
+ connection.execute("COMMIT")
173
+ except Exception:
174
+ connection.execute("ROLLBACK")
175
+ raise
176
+ return {"id": rule_id, "property_url": property_url, "kind": kind, "value": value, "active": bool(active), "notes": notes, "source_id": source_id}
177
+
178
+
179
+ def list_rules(connection, property_url, active_only=False):
180
+ property_url = url(property_url, True)
181
+ result = []
182
+ for row in connection.execute("SELECT * FROM disavow_rules WHERE property_url=?" + (" AND active=1" if active_only else "") + " ORDER BY kind,value", (property_url,)):
183
+ item = dict(row)
184
+ item["active"] = bool(item["active"])
185
+ item["provenance"] = []
186
+ for source in connection.execute("SELECT s.id AS source_id,s.source_label,s.source_sha256,s.format,p.line_number,p.line_text,p.comments_json,p.recorded_at FROM disavow_provenance p JOIN disavow_sources s ON s.id=p.source_id WHERE p.rule_id=? ORDER BY p.recorded_at,s.id,p.line_number", (item["id"],)):
187
+ entry = dict(source)
188
+ entry["comments"] = json.loads(entry.pop("comments_json"))
189
+ item["provenance"].append(entry)
190
+ result.append(item)
191
+ return result
192
+
193
+
194
+ def export_rules(connection, property_url):
195
+ property_url = url(property_url, True)
196
+ # No timestamps or private notes: the same active rule set produces identical bytes.
197
+ lines = ["# AgentLinkOps user-managed disavow rules", "# Property: " + property_url]
198
+ for kind, value in connection.execute("SELECT kind,value FROM disavow_rules WHERE property_url=? AND active=1 ORDER BY kind,value", (property_url,)):
199
+ lines.append("domain:" + value if kind == "domain" else value)
200
+ text = "\n".join(lines) + "\n"
201
+ if len(lines) > MAX_LINES or len(text.encode("utf-8")) > MAX_BYTES:
202
+ raise DisavowError("Export exceeds Google's .txt line or byte limits; review the list before exporting")
203
+ return text, {"property_url": property_url, "active_rules": len(lines) - 2, "format": "txt", "uploaded": False}
204
+
205
+
206
+ def snapshot(connection):
207
+ return {"rules": [dict(row) for row in connection.execute("SELECT * FROM disavow_rules ORDER BY property_url,kind,value")], "sources": [dict(row) for row in connection.execute("SELECT * FROM disavow_sources ORDER BY id")], "provenance": [dict(row) for row in connection.execute("SELECT * FROM disavow_provenance ORDER BY rule_id,source_id,line_number")]}
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env python3
2
+ """Compatibility alias (DP-0029): the CRM helper is now agentlinkops.py beside this file."""
3
+ import os
4
+ import runpy
5
+ import sys
6
+
7
+ sys.stderr.write("linktrail.py: this helper is now agentlinkops.py; the old name keeps working during the pilot compatibility window.\n")
8
+ TARGET = os.path.join(os.path.dirname(os.path.abspath(__file__)), "agentlinkops.py")
9
+ sys.argv[0] = TARGET
10
+ runpy.run_path(TARGET, run_name="__main__")
@@ -0,0 +1,45 @@
1
+ CREATE TABLE crm_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
2
+ CREATE TABLE products (
3
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, url TEXT NOT NULL,
4
+ audience TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
5
+ target_pages TEXT NOT NULL DEFAULT '[]', notes TEXT NOT NULL DEFAULT '',
6
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
7
+ );
8
+ CREATE TABLE campaigns (
9
+ id TEXT PRIMARY KEY, product_id TEXT REFERENCES products(id), name TEXT NOT NULL,
10
+ type TEXT NOT NULL, template_version INTEGER NOT NULL DEFAULT 1,
11
+ status TEXT NOT NULL DEFAULT 'draft', goal TEXT NOT NULL DEFAULT '',
12
+ target_url TEXT NOT NULL DEFAULT '', notes TEXT NOT NULL DEFAULT '',
13
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
14
+ );
15
+ CREATE TABLE contacts (
16
+ id TEXT PRIMARY KEY, publisher_url TEXT NOT NULL, name TEXT NOT NULL DEFAULT '',
17
+ email TEXT NOT NULL DEFAULT '', contact_url TEXT NOT NULL DEFAULT '',
18
+ source_url TEXT NOT NULL DEFAULT '', observed_at TEXT NOT NULL DEFAULT '',
19
+ confidence TEXT NOT NULL DEFAULT 'unverified', notes TEXT NOT NULL DEFAULT '',
20
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
21
+ );
22
+ CREATE TABLE opportunities (
23
+ id TEXT PRIMARY KEY, campaign_id TEXT REFERENCES campaigns(id),
24
+ contact_id TEXT REFERENCES contacts(id), source_url TEXT NOT NULL, target_url TEXT NOT NULL,
25
+ type TEXT NOT NULL DEFAULT 'custom', status TEXT NOT NULL DEFAULT 'candidate',
26
+ evidence TEXT NOT NULL DEFAULT '{}', notes TEXT NOT NULL DEFAULT '',
27
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
28
+ );
29
+ CREATE TABLE activities (
30
+ id TEXT PRIMARY KEY, opportunity_id TEXT REFERENCES opportunities(id),
31
+ contact_id TEXT REFERENCES contacts(id), campaign_id TEXT REFERENCES campaigns(id),
32
+ channel TEXT NOT NULL DEFAULT 'email', kind TEXT NOT NULL,
33
+ external_message_id TEXT NOT NULL DEFAULT '', occurred_at TEXT NOT NULL,
34
+ outcome TEXT NOT NULL DEFAULT '', notes TEXT NOT NULL DEFAULT '',
35
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
36
+ );
37
+ CREATE TABLE placements (
38
+ id TEXT PRIMARY KEY, opportunity_id TEXT REFERENCES opportunities(id),
39
+ project_id TEXT, watch_id TEXT UNIQUE, source_url TEXT NOT NULL, target_url TEXT NOT NULL,
40
+ status TEXT NOT NULL DEFAULT 'pending', notes TEXT NOT NULL DEFAULT '',
41
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
42
+ );
43
+ CREATE INDEX campaigns_product ON campaigns(product_id);
44
+ CREATE INDEX opportunities_campaign ON opportunities(campaign_id);
45
+ CREATE INDEX activities_opportunity ON activities(opportunity_id, occurred_at);
@@ -0,0 +1,13 @@
1
+ ALTER TABLE placements ADD COLUMN cloud_state TEXT NOT NULL DEFAULT '{}';
2
+ ALTER TABLE placements ADD COLUMN cloud_observed_at TEXT;
3
+ ALTER TABLE placements ADD COLUMN cloud_event_id TEXT;
4
+ CREATE TABLE cloud_events (
5
+ id TEXT PRIMARY KEY, cursor TEXT NOT NULL UNIQUE, type TEXT NOT NULL,
6
+ project_id TEXT, watch_id TEXT, created_at TEXT NOT NULL,
7
+ payload TEXT NOT NULL, payload_hash TEXT NOT NULL, applied_at TEXT NOT NULL
8
+ );
9
+ CREATE TABLE sync_state (
10
+ singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
11
+ endpoint TEXT, cursor TEXT, updated_at TEXT NOT NULL
12
+ );
13
+ INSERT INTO sync_state(singleton, updated_at) VALUES(1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
@@ -0,0 +1,20 @@
1
+ CREATE TABLE disavow_rules (
2
+ id TEXT PRIMARY KEY, property_url TEXT NOT NULL,
3
+ kind TEXT NOT NULL CHECK(kind IN ('domain','url')), value TEXT NOT NULL,
4
+ active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
5
+ notes TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
6
+ UNIQUE(property_url,kind,value)
7
+ );
8
+ CREATE TABLE disavow_sources (
9
+ id TEXT PRIMARY KEY, property_url TEXT NOT NULL, source_label TEXT NOT NULL,
10
+ source_sha256 TEXT NOT NULL, format TEXT NOT NULL CHECK(format IN ('txt','manual')),
11
+ raw_text TEXT NOT NULL, created_at TEXT NOT NULL
12
+ );
13
+ CREATE TABLE disavow_provenance (
14
+ rule_id TEXT NOT NULL REFERENCES disavow_rules(id),
15
+ source_id TEXT NOT NULL REFERENCES disavow_sources(id),
16
+ line_number INTEGER NOT NULL, line_text TEXT NOT NULL,
17
+ comments_json TEXT NOT NULL DEFAULT '[]', recorded_at TEXT NOT NULL,
18
+ PRIMARY KEY(rule_id,source_id,line_number)
19
+ );
20
+ CREATE INDEX disavow_property_rules ON disavow_rules(property_url,active,kind,value);
@@ -0,0 +1,2 @@
1
+ ALTER TABLE placements ADD COLUMN cost_amount TEXT;
2
+ ALTER TABLE placements ADD COLUMN cost_currency TEXT;
@@ -0,0 +1,113 @@
1
+ """Escaped, self-contained HTML reports from explicitly selected local CRM data."""
2
+ import datetime as dt
3
+ from decimal import Decimal, localcontext
4
+ from html import escape
5
+ import json
6
+ import re
7
+ import urllib.parse
8
+
9
+
10
+ def cost(amount, currency):
11
+ if amount is None and currency is None:
12
+ return None, None
13
+ if not isinstance(amount, str) or not re.fullmatch(r"(?:0|[1-9][0-9]{0,17})(?:\.[0-9]{1,6})?", amount):
14
+ raise ValueError("cost_amount must be a nonnegative decimal string with at most six decimal places")
15
+ if not isinstance(currency, str) or not re.fullmatch(r"[A-Z]{3}", currency):
16
+ raise ValueError("cost_currency must be an uppercase three-letter currency label paired with cost_amount")
17
+ return decimal_text(Decimal(amount)), currency
18
+
19
+
20
+ def decimal_text(value):
21
+ text = format(value, "f")
22
+ return text.rstrip("0").rstrip(".") if "." in text else text
23
+
24
+
25
+ def escaped(value):
26
+ return escape(str(value) if value is not None else "", quote=True)
27
+
28
+
29
+ def link(value):
30
+ try:
31
+ parsed = urllib.parse.urlsplit(value)
32
+ if parsed.scheme not in {"https", "http"} or not parsed.hostname or parsed.username or parsed.password or any(ord(char) < 32 for char in value):
33
+ raise ValueError()
34
+ except (ValueError, TypeError):
35
+ return escaped(value)
36
+ return '<a href="' + escaped(value) + '" target="_blank" rel="noopener noreferrer">' + escaped(value) + '</a>'
37
+
38
+
39
+ def render(connection, title, brand, generated_at, project_id=None, include_notes=False, include_costs=False):
40
+ for name, value in (("title", title), ("brand", brand)):
41
+ if not isinstance(value, str) or not value.strip() or len(value) > 300 or any(ord(char) < 32 for char in value):
42
+ raise ValueError(name + " must be a nonempty label of at most 300 characters")
43
+ query = "SELECT * FROM placements" + (" WHERE project_id=?" if project_id else "") + " ORDER BY COALESCE(project_id,''),source_url,target_url,id"
44
+ connection.execute("BEGIN")
45
+ try:
46
+ placements = [dict(row) for row in connection.execute(query, (project_id,) if project_id else ())]
47
+ connection.execute("COMMIT")
48
+ except Exception:
49
+ connection.execute("ROLLBACK")
50
+ raise
51
+ counts, totals, rows, observed_dates = {}, {}, [], []
52
+ for placement in placements:
53
+ cloud = json.loads(placement["cloud_state"])
54
+ if not isinstance(cloud, dict) or not isinstance(cloud.get("state", "unobserved"), str):
55
+ raise ValueError("A placement has malformed cloud state; repair the CRM before reporting")
56
+ state = cloud.get("state", "unobserved")
57
+ counts[state] = counts.get(state, 0) + 1
58
+ observed_at = placement["cloud_observed_at"]
59
+ if observed_at:
60
+ observed_dates.append(observed_at)
61
+ columns = [link(placement["source_url"]), link(placement["target_url"]), escaped(str(state).replace("_", " ")), escaped(observed_at or "No observation")]
62
+ if include_notes:
63
+ columns.append(escaped(placement["notes"]))
64
+ if include_costs:
65
+ amount, currency = cost(placement["cost_amount"], placement["cost_currency"])
66
+ if amount is not None:
67
+ with localcontext() as context:
68
+ context.prec = 50
69
+ totals[currency] = totals.get(currency, Decimal(0)) + Decimal(amount)
70
+ columns.append(escaped(currency + " " + amount))
71
+ else:
72
+ columns.append("Not recorded")
73
+ rows.append("<tr>" + "".join("<td>" + column + "</td>" for column in columns) + "</tr>")
74
+ headings = ["Source page", "Destination", "Observed state", "Observation time (UTC offset shown)"]
75
+ if include_notes:
76
+ headings.append("Local notes (included by request)")
77
+ if include_costs:
78
+ headings.append("Recorded local cost")
79
+ if not rows:
80
+ rows.append('<tr><td colspan="' + str(len(headings)) + '">No placements match this report selection.</td></tr>')
81
+ privacy = "Includes source and destination URLs, observed states and observation times."
82
+ privacy += " Local notes are included by request." if include_notes else " Local notes are excluded."
83
+ privacy += " Recorded costs are included by request." if include_costs else " Recorded costs are excluded."
84
+ scope = "Selected project" if project_id else "All local placements"
85
+ coverage = "No cloud observations are available for these placements."
86
+ if observed_dates:
87
+ chronological = sorted(observed_dates, key=lambda value: dt.datetime.fromisoformat(value.replace("Z", "+00:00")))
88
+ coverage = "Observed timestamps range from " + chronological[0] + " to " + chronological[-1] + "."
89
+ totals_html = ""
90
+ if include_costs:
91
+ totals_html = '<section class="costs"><h2>Recorded costs</h2>'
92
+ if totals:
93
+ totals_html += '<ul>' + ''.join('<li>' + escaped(currency + " " + decimal_text(amount)) + '</li>' for currency, amount in sorted(totals.items())) + '</ul>'
94
+ else:
95
+ totals_html += '<p>No costs are recorded for the selected placements.</p>'
96
+ totals_html += '<p>Totals combine recorded amounts within each currency. No currency conversion, market valuation or tax calculation is applied.</p></section>'
97
+ document = '''<!doctype html>
98
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
99
+ <meta name="referrer" content="no-referrer"><meta name="robots" content="noindex,nofollow">
100
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
101
+ <title>''' + escaped(title) + '''</title>
102
+ <style>
103
+ *{box-sizing:border-box}body{margin:0;color:#192b36;background:#f7f9fb;font:14px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{max-width:1400px;margin:auto;padding:48px 36px}.brand{font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:.06em;text-transform:uppercase;color:#c4472d}h1,h2{font-weight:600;line-height:1.12;letter-spacing:-.04em}h1{font-size:44px;margin:18px 0 20px}h2{font-size:27px}.meta,.coverage,.disclosure{color:#566570;font-size:13px;max-width:1000px}.coverage{border-left:2px solid #c4472d;padding-left:16px;margin-top:24px}.metrics{display:flex;flex-wrap:wrap;margin:32px 0;border-block:1px solid #d8dfe5}.metric{flex:1;min-width:160px;padding:20px 24px 20px 0}.metric+.metric{border-left:1px solid #d8dfe5;padding-left:24px}.metric strong{display:block;font-size:32px;font-weight:550;letter-spacing:-.03em}.metric span{font-size:12px;color:#566570}.table-region{overflow-x:auto;max-width:100%}table{border-collapse:collapse;width:100%;min-width:760px;background:#fff;font-size:13px;table-layout:fixed}caption{text-align:left;font-size:19px;font-weight:600;letter-spacing:-.025em;padding:0 0 16px}th,td{text-align:left;border-bottom:1px solid #d8dfe5;padding:15px;vertical-align:top;overflow-wrap:anywhere;white-space:pre-wrap}th{background:#eef2f5;color:#566570;font:11px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace}a{color:#192b36;text-underline-offset:3px}a:focus-visible{outline:3px solid #c4472d;outline-offset:3px}footer{border-top:1px solid #d8dfe5;margin-top:36px;padding-top:20px;font-size:12px;color:#566570}.costs{margin-top:32px}.costs p{font-size:13px;color:#566570}ul{padding-left:20px}@media(max-width:700px){main{padding:32px 20px}h1{font-size:34px}.metric{min-width:50%;padding:16px 12px}.metric+.metric{padding-left:12px}.metric:nth-child(odd){border-left:0}.metric:nth-child(n+3){border-top:1px solid #d8dfe5}}@media print{body{background:white}main{padding:0;max-width:none}.metric,th{background:#eef2f5}.table-region{overflow:visible}table{min-width:0;font-size:10px}th,td{padding:9px}th{font-size:9px}tr{break-inside:avoid}thead{display:table-header-group}a{color:#192b36;text-decoration:none}@page{size:landscape;margin:12mm}}
104
+ </style></head><body><main>
105
+ <div class="brand">''' + escaped(brand) + '''</div><h1>''' + escaped(title) + '''</h1>
106
+ <p class="meta">Generated ''' + escaped(generated_at) + " · " + escaped(scope) + ''' · Local CRM snapshot</p>
107
+ <p class="coverage">''' + escaped(coverage) + ''' This report does not perform new checks. Unknown or unobserved results do not establish link loss.</p>
108
+ <div class="metrics"><div class="metric"><strong>''' + str(len(placements)) + '''</strong><span>Selected placements</span></div><div class="metric"><strong>''' + str(counts.get("present", 0)) + '''</strong><span>Observed present</span></div><div class="metric"><strong>''' + str(counts.get("confirmed_missing", 0)) + '''</strong><span>Confirmed missing</span></div><div class="metric"><strong>''' + str(counts.get("unknown", 0) + counts.get("unobserved", 0)) + '''</strong><span>Unknown or unobserved</span></div></div>
109
+ <div class="table-region" role="region" aria-label="Selected placement observations" tabindex="0"><table><caption>Selected placement observations</caption><thead><tr>''' + ''.join('<th scope="col">' + escaped(heading) + '</th>' for heading in headings) + '''</tr></thead><tbody>''' + ''.join(rows) + '''</tbody></table></div>
110
+ ''' + totals_html + '''<footer><p>''' + escaped(privacy) + ''' Contacts, messages, API credentials, disavow rules and raw cloud event payloads are not included.</p><p>Created with the local AgentLinkOps toolkit. This is an HTML report; browser print options are separate from report generation.</p></footer>
111
+ </main></body></html>
112
+ '''
113
+ return document, {"format": "html", "placements": len(placements), "project_id": project_id, "generated_at": generated_at, "included_local_notes": include_notes, "included_local_costs": include_costs}
@@ -0,0 +1,49 @@
1
+ // DP-0036-T06: the client view table. Which MCP view each agent client should connect to and
2
+ // where it keeps skills and MCP configuration, from the client behavior recorded in the
3
+ // agent-surface research (docs/research/agent-surface-v2-2026-09-15/report.md §1 and §6).
4
+ // Read by `agentlinkops agent setup` (DP-0036-T09), GET /v1/catalog.json and the generators.
5
+ // Pure data; no imports. Paths use `~` for the home directory and are relative to the
6
+ // project root otherwise.
7
+ export const AGENT_CLIENTS = [
8
+ {
9
+ id: 'claude-code', name: 'Claude Code',
10
+ detect: ['~/.claude', '~/.claude.json'],
11
+ skills: { project: '.claude/skills', user: '~/.claude/skills' },
12
+ mcp: { scope: 'project', file: '.mcp.json', format: 'mcpServers-json' },
13
+ view: '/mcp/all', deferral: true,
14
+ reason: 'Claude Code defers MCP tool definitions above about 10K tokens behind its own tool search, so the flat view keeps every annotation, parallel read dispatch and direct calls with no meta layer.',
15
+ },
16
+ {
17
+ id: 'codex', name: 'Codex',
18
+ detect: ['~/.codex'],
19
+ skills: { project: '.agents/skills', user: '~/.agents/skills' },
20
+ mcp: { scope: 'user', file: '~/.codex/config.toml', format: 'toml-mcp_servers' },
21
+ view: '/mcp', deferral: false,
22
+ reason: 'Codex loads MCP tools flat (its catalog limit is 2,048 definitions, not a context budget) and budgets the skill list at 2% of context, so the bounded default view keeps the connection small.',
23
+ },
24
+ {
25
+ id: 'cursor', name: 'Cursor',
26
+ detect: ['~/.cursor'],
27
+ skills: { project: '.agents/skills', user: '~/.agents/skills' },
28
+ mcp: { scope: 'project', file: '.cursor/mcp.json', format: 'mcpServers-json' },
29
+ view: '/mcp', deferral: false,
30
+ reason: 'Cursor caps active tools at 40 across every connected server and drops the rest silently; the default view uses nine definitions.',
31
+ },
32
+ {
33
+ id: 'gemini-cli', name: 'Gemini CLI',
34
+ detect: ['~/.gemini'],
35
+ skills: { project: '.agents/skills', user: '~/.agents/skills' },
36
+ mcp: { scope: 'project', file: '.gemini/settings.json', format: 'mcpServers-httpUrl' },
37
+ view: '/mcp', deferral: false,
38
+ reason: 'Gemini CLI loads MCP tools flat and activates skills with a consent prompt; the default view keeps the listing small and the skills name the commands.',
39
+ },
40
+ {
41
+ id: 'hermes', name: 'Hermes Agent',
42
+ detect: ['~/.hermes'],
43
+ skills: { user: '~/.hermes/skills' },
44
+ mcp: { scope: 'user', file: '~/.hermes/config.yaml', format: 'yaml-mcp_servers' },
45
+ view: '/mcp/all', deferral: true,
46
+ reason: 'Hermes replaces deferred MCP tools with its own tool_search, tool_describe and tool_call bridge over a grouped manifest, so the flat view is the right input.',
47
+ },
48
+ ];
49
+ export const clientById = id => AGENT_CLIENTS.find(client => client.id === id) ?? null;
@@ -0,0 +1,43 @@
1
+ export function eventSnapshot(state) {
2
+ const latest = state.latestAttempt || {};
3
+ const occurrences = (latest.occurrences || []).slice(0, 20).map(item => ({
4
+ targetUrl: String(item.targetUrl || '').slice(0, 4096),
5
+ anchor: String(item.anchor || '').slice(0, 256),
6
+ rel: (item.rel || []).slice(0, 20),
7
+ }));
8
+ return {
9
+ schema_version: 1, target_scope: state.target_scope,
10
+ check_state: latest.state ?? 'unknown', link_signature: latest.linkSignature ?? null,
11
+ checker_version: latest.evidence?.checkerVersion ?? null,
12
+ state: state.state || 'unknown', uncertain: state.uncertain ?? true,
13
+ watch_id: state.watch_id, source_url: state.source_url, target_url: state.target_url,
14
+ checked_at: state.checked_at, reason: latest.reason || null,
15
+ http_status: latest.httpStatus ?? null, expectations: latest.expectations || null,
16
+ occurrences, occurrence_count: latest.occurrences?.length || 0,
17
+ occurrences_truncated: (latest.occurrences?.length || 0) > occurrences.length,
18
+ next_check_at: state.nextCheckAt || null,
19
+ };
20
+ }
21
+
22
+ /** Accept v1 flat events and pre-version snapshots. Reject unknown versions explicitly. */
23
+ export function observationFromSnapshot(snapshot) {
24
+ if (snapshot.schema_version !== undefined && snapshot.schema_version !== 1) throw new Error('Unsupported observation event version.');
25
+ if (typeof snapshot.checked_at !== 'string' || !Number.isFinite(Date.parse(snapshot.checked_at))) throw new Error('Invalid observation event timestamp.');
26
+ const latest = snapshot.latestAttempt ?? {};
27
+ const occurrences = snapshot.occurrences ?? latest.occurrences ?? [];
28
+ if (!Array.isArray(occurrences)) throw new Error('Invalid observation event occurrences.');
29
+ return {
30
+ sourceUrl: snapshot.source_url ?? latest.sourceUrl,
31
+ targetUrl: snapshot.target_url ?? latest.targetUrl,
32
+ targetScope: snapshot.target_scope ?? latest.targetScope,
33
+ state: snapshot.uncertain !== false ? 'unknown' : snapshot.state === 'confirmed_missing' ? 'absent' : snapshot.state === 'present' ? 'present' : 'unknown',
34
+ watchState: snapshot.state,
35
+ checkState: snapshot.check_state ?? latest.state ?? null,
36
+ reason: snapshot.reason ?? latest.reason ?? null,
37
+ checkedAt: snapshot.checked_at, occurrences,
38
+ occurrencesTruncated: snapshot.occurrences_truncated ?? false,
39
+ occurrenceCount: snapshot.occurrence_count ?? occurrences.length,
40
+ linkSignature: snapshot.link_signature ?? latest.linkSignature ?? null,
41
+ evidence: {complete: snapshot.uncertain === false, checkerVersion: snapshot.checker_version ?? latest.evidence?.checkerVersion ?? null},
42
+ };
43
+ }
@@ -0,0 +1,59 @@
1
+ import {searchConnectionsResponse,searchRowsResponse,searchSyncResponse,searchPropertiesResponse,searchStartResponse,searchConnection,searchImportResponse} from './search-connection-contract.js';
2
+ import {z} from 'zod';
3
+ export const objectResponse=z.looseObject({});
4
+ export const pageResponse=z.looseObject({items:z.array(objectResponse),next_cursor:z.string().nullable().optional()});
5
+ export const watchResponse=z.looseObject({id:z.string(),source_url:z.string(),target_url:z.string(),state:z.string(),observation_state:objectResponse});
6
+ export const targetResponse=z.looseObject({id:z.string(),url:z.string(),state:z.string(),observation_state:objectResponse});
7
+ export const historyResponse=z.looseObject({items:z.array(z.looseObject({id:z.string(),state:z.string(),checked_at:z.string(),result:objectResponse})),next_cursor:z.string().nullable().optional()});
8
+ export const jobResponse=z.looseObject({id:z.string(),state:z.enum(['queued','running','succeeded','failed','cancelled'])});
9
+ export const observationEvent=z.looseObject({id:z.string(),data:objectResponse});
10
+ export const eventFeedResponse=z.looseObject({events:z.array(observationEvent),next_cursor:z.string(),has_more:z.boolean()});
11
+ export const usageResponse=z.looseObject({
12
+ workspace_id:z.string(),period:z.string().regex(/^\d{4}-\d{2}$/),plan:z.string(),
13
+ check_reservations:z.looseObject({reserved:z.number().nonnegative(),consumed:z.number().nonnegative(),released:z.number().nonnegative()}),
14
+ units:z.record(z.string(),z.looseObject({used:z.number().nonnegative()})),
15
+ at_allowance:z.looseObject({note:z.string()}),checkout:z.looseObject({available:z.boolean(),reason:z.string().nullable().optional()}),
16
+ });
17
+ export const candidateVerificationBatchResponse=z.looseObject({
18
+ schema_version:z.literal(1),id:z.string(),workspace_id:z.string(),project_id:z.string(),run_id:z.string(),
19
+ state:z.enum(['pending','completed','partial','failed']),
20
+ counts:z.object(Object.fromEntries(['rejected','queued','running','succeeded','failed','cancelled'].map(state=>[state,z.number().int().nonnegative()]))),
21
+ items:z.array(z.looseObject({
22
+ ordinal:z.number().int().nonnegative(),candidate_id:z.string(),local_reference:z.string().nullable(),
23
+ job_id:z.string().nullable(),watch_id:z.string().nullable(),
24
+ state:z.enum(['rejected','queued','running','succeeded','failed','cancelled']),error_code:z.string().nullable(),
25
+ observation:z.looseObject({id:z.string(),state:z.string(),reason:z.string().nullable(),checked_at:z.string()}).nullable(),
26
+ usage:z.looseObject({unit:z.literal('source_check'),state:z.string()}).nullable(),
27
+ })).min(1).max(50),
28
+ reservation:z.looseObject({units:z.number().int().nonnegative(),outstanding:z.number().int().nonnegative(),consumed:z.number().int().nonnegative(),released:z.number().int().nonnegative(),period:z.string()}).nullable(),
29
+ replayed:z.boolean().optional(),
30
+ });
31
+ export const candidateMonitoringResponse=z.looseObject({
32
+ watch:z.looseObject({id:z.string(),source_url:z.string(),target_url:z.string(),status:z.enum(['active','paused']),observation_state:objectResponse}),verification_job_id:z.string(),candidate_id:z.string(),run_id:z.string(),
33
+ local_reference:z.string().nullable(),replayed:z.boolean(),recurring_monitoring_created:z.literal(true),
34
+ });
35
+ /** Network boundary shared by the console and command adapters. Unknown fields survive evolution. */
36
+ export function restResponseContract(path,method='GET') {
37
+ const pathname=path.split('?')[0];
38
+ if(method==='POST' && /^\/v1\/discovery\/runs\/[^/]+\/verification-batches$/.test(pathname))return candidateVerificationBatchResponse;
39
+ if(method==='GET' && /^\/v1\/discovery\/verification-batches\/[^/]+$/.test(pathname))return candidateVerificationBatchResponse;
40
+ if(method==='POST' && /^\/v1\/discovery\/verifications\/[^/]+\/monitor$/.test(pathname))return candidateMonitoringResponse;
41
+ if(pathname==='/v1/search-connections'&&method==='GET')return searchConnectionsResponse;
42
+ if(pathname==='/v1/search-connections/start'&&method==='POST')return searchStartResponse;
43
+ if(/^\/v1\/search-connections\/[^/]+\/rows$/.test(pathname)&&method==='GET')return searchRowsResponse;
44
+ if(/^\/v1\/search-connections\/[^/]+\/properties$/.test(pathname)&&method==='GET')return searchPropertiesResponse;
45
+ if(/^\/v1\/search-connections\/[^/]+\/sync$/.test(pathname)&&method==='POST')return searchSyncResponse;
46
+ if(/^\/v1\/search-connections\/[^/]+\/property$/.test(pathname)&&method==='PUT')return z.object({connection:searchConnection});
47
+ if(/^\/v1\/search-connections\/[^/]+\/import$/.test(pathname)&&method==='POST')return searchImportResponse;
48
+ if(/^\/v1\/search-connections\/[^/]+\/disconnect$/.test(pathname)&&method==='POST')return z.object({disconnected:z.literal(true),retainedRows:z.literal(true)});
49
+ if(/^\/v1\/search-connections\/[^/]+$/.test(pathname)&&method==='DELETE')return z.object({forgotten:z.literal(true)});
50
+ if(method!=='GET')return objectResponse;
51
+ if(/^\/v1\/(?:watches|targets)\/[^/]+\/history$/.test(pathname))return historyResponse;
52
+ if(/^\/v1\/watches\/[^/]+$/.test(pathname))return watchResponse;
53
+ if(/^\/v1\/targets\/[^/]+$/.test(pathname))return targetResponse;
54
+ if(['/v1/watches','/v1/targets','/v1/projects','/v1/exports/watches'].includes(pathname))return pageResponse;
55
+ if(pathname==='/v1/usage')return usageResponse;
56
+ if(['/v1/events','/v1/target-events'].includes(pathname))return eventFeedResponse;
57
+ if(/^\/v1\/(?:jobs|target-jobs)\/[^/]+$/.test(pathname))return jobResponse;
58
+ return objectResponse;
59
+ }
@@ -0,0 +1,18 @@
1
+ import {z} from 'zod';
2
+ const text=z.string(),nullable=text.nullable();
3
+ export const searchConnection=z.looseObject({
4
+ id:text,project_id:text,provider:z.enum(['google','bing']),status:text,property:nullable,
5
+ last_sync_at:nullable,next_sync_at:nullable,last_error:nullable,coverage:z.record(text,z.unknown()).nullable(),
6
+ snapshot_id:nullable,capability:z.enum(['search_performance','backlinks']),created_at:text,updated_at:text,
7
+ });
8
+ export const searchConnectionsResponse=z.looseObject({connections:z.array(searchConnection),providers:z.array(z.looseObject({provider:z.enum(['google','bing']),configured:z.boolean(),capability:z.enum(['search_performance','backlinks'])}))});
9
+ const rowMeta={rowId:text,fetched_at:text};
10
+ export const searchProviderRow=z.discriminatedUnion('kind',[
11
+ z.looseObject({...rowMeta,kind:z.literal('search_performance'),page:text,query:text,clicks:z.number().nonnegative(),impressions:z.number().nonnegative(),ctr:z.number().nonnegative(),position:z.number().nonnegative(),startDate:text,endDate:text}),
12
+ z.looseObject({...rowMeta,kind:z.literal('backlinks'),source_url:text,target_url:text,anchor:nullable,supplier:z.literal('bing_webmaster_tools'),verification_status:z.literal('unverified')}),
13
+ ]);
14
+ export const searchRowsResponse=z.looseObject({connection:searchConnection,rows:z.array(searchProviderRow),nextCursor:nullable});
15
+ export const searchSyncResponse=z.looseObject({connection:searchConnection,rowCount:z.number().int().nonnegative()});
16
+ export const searchPropertiesResponse=z.looseObject({properties:z.array(z.looseObject({property:text,permission:text}))});
17
+ export const searchStartResponse=z.looseObject({authorizationUrl:z.url(),expiresAt:text});
18
+ export const searchImportResponse=z.looseObject({id:text,status:text,replayed:z.boolean().optional(),usage:z.looseObject({accepted_candidates:z.number().int().nonnegative()}),rejected:z.array(z.looseObject({row:z.number(),reason:text})),duplicates:z.array(z.looseObject({row:z.number(),first_seen_on_row:z.number()})),rejected_total:z.number().int().nonnegative(),duplicates_total:z.number().int().nonnegative()});
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: agentlinkops-assets
3
+ description: Improve a code-built site's resource, guide or research page for a specific backlink campaign, with sourced claims and useful internal links. Use when a campaign requires a better linkable asset; avoid unrelated site-wide rewrites.
4
+ allowed-tools: Read Grep Glob Edit Bash(npm run *) Bash(agentlinkops call *)
5
+ ---
6
+
7
+ Read the repository's applicable instructions and the campaign's publisher/audience evidence before editing. Define what the target reader needs and what the current page lacks. Prefer an asset with independent utility: a sourced reference, comparison, original dataset, calculator, template or practical guide that fits the product.
8
+
9
+ Preserve the project's rendering and routing conventions. Inspect nearby pages before changing a Next.js or other code-built site. Keep factual claims tied to sources and dates, distinguish original analysis from reported facts, and preserve qualifications that affect accuracy. Do not invent expertise, statistics, citations or endorsements.
10
+
11
+ Write plainly and specifically. Remove repetition and generic filler without deleting necessary evidence. Add internal links only where the destination answers the reader's next relevant question; inspect the actual routes before linking. Check for an existing competing page before creating a new URL.
12
+
13
+ Use the site's existing validation commands appropriate to the change. Record the final target URL, reader benefit and evidence in the AgentLinkOps product/campaign record using [the CRM contract](../../references/cli.md). A completed local edit does not establish that the page is deployed or that a publisher accepted it.
@@ -0,0 +1,5 @@
1
+ interface:
2
+ display_name: AgentLinkOps Assets
3
+ short_description: Improve a code-built site's resource, guide or research page for a specific backlink campaign, with sourced claims and u
4
+ policy:
5
+ allow_implicit_invocation: true