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,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,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,121 @@
1
+ #!/usr/bin/env python3
2
+ """Save/read back an explicitly mapped draft in a customer-selected local Outreach CRM.
3
+ No network, send, sequence activation, or contact/authority-state writes.
4
+ """
5
+ import argparse
6
+ import hashlib
7
+ import importlib.util
8
+ import json
9
+ from pathlib import Path
10
+ import re
11
+ from urllib.parse import urlsplit
12
+
13
+ spec = importlib.util.spec_from_file_location('portable_handoff', Path(__file__).with_name('portable-handoff.py'))
14
+ portable = importlib.util.module_from_spec(spec)
15
+ spec.loader.exec_module(portable)
16
+
17
+
18
+ def text(value, label):
19
+ if not isinstance(value, str) or not value.strip():
20
+ raise ValueError(label + ' must be explicit')
21
+ return value
22
+
23
+
24
+ def host(value):
25
+ parsed = urlsplit(text(value, 'site'))
26
+ if parsed.scheme not in ('https', 'http') or not parsed.hostname or parsed.username or parsed.password:
27
+ raise ValueError('site must be an explicit URL without credentials')
28
+ return parsed.hostname.lower()
29
+
30
+
31
+ def prepare(root, request, body):
32
+ fields = {'version', 'crm_project', 'workspace_id', 'project_id', 'site', 'operation_id',
33
+ 'original_request', 'local_id', 'external_id', 'observation', 'reply',
34
+ 'suppression', 'subject', 'body_sha256'}
35
+ if not isinstance(request, dict) or set(request) != fields or type(request['version']) is not int or request['version'] != 1:
36
+ raise ValueError('explicit version 1 draft request required; see local CRM handoff reference')
37
+ for field in fields - {'version', 'original_request'}:
38
+ text(request[field], field)
39
+ project = request['crm_project']
40
+ if not re.fullmatch(r'[a-z0-9][a-z0-9_-]*', project):
41
+ raise ValueError('crm_project must be one project directory name')
42
+ root = Path(root).resolve(strict=True)
43
+ project_dir = root / 'projects' / project
44
+ # Refuse symlink redirection in destination directories, including an existing
45
+ # operation directory. This is a cooperating local writer, not an OS sandbox.
46
+ for path in (root / 'projects', project_dir, project_dir / 'config.json', project_dir / 'drafts'):
47
+ if path.is_symlink():
48
+ raise ValueError('destination symlink refused: ' + str(path))
49
+ config = json.loads((project_dir / 'config.json').read_text(encoding='utf-8'))
50
+ if not isinstance(config, dict) or config.get('project') != project or host(config.get('site')) != host(request['site']):
51
+ raise ValueError('CRM config identity/site mismatch; preserve original mapping')
52
+ if not (project_dir / 'drafts').is_dir():
53
+ raise ValueError('selected CRM drafts directory missing; initialize that project first')
54
+ original = request['original_request']
55
+ if not isinstance(original, dict) or set(original) != {'destination', 'workspace_id', 'project_id', 'command', 'arguments', 'idempotency_key'}:
56
+ raise ValueError('complete original non-secret request tuple required')
57
+ for field in ('destination', 'workspace_id', 'project_id', 'command', 'idempotency_key'):
58
+ text(original[field], 'original_request.' + field)
59
+ if not isinstance(original['arguments'], dict):
60
+ raise ValueError('original request arguments must be an object')
61
+ if any(original[field] != request[field] for field in ('workspace_id', 'project_id')):
62
+ raise ValueError('original request scope mismatch; preserve original mapping')
63
+ if request['observation'] not in ('unknown', 'present', 'provisional_loss', 'confirmed_loss'):
64
+ raise ValueError('explicit supported observation required')
65
+ if request['reply'] not in ('unknown', 'none', 'replied') or request['suppression'] not in ('unknown', 'clear', 'suppressed'):
66
+ raise ValueError('explicit reply/suppression observation required')
67
+ if '\n' in request['subject'] or '\r' in request['subject']:
68
+ raise ValueError('subject must be one line')
69
+ body.decode('utf-8')
70
+ if not body or hashlib.sha256(body).hexdigest() != request['body_sha256']:
71
+ raise ValueError('body bytes do not match the original draft hash')
72
+ identity = {field: request[field] for field in ('crm_project', 'workspace_id', 'project_id', 'operation_id', 'local_id', 'external_id')}
73
+ directory = project_dir / 'drafts' / ('agentlinkops-' + portable.digest(identity))
74
+ if directory.is_symlink():
75
+ raise ValueError('destination operation symlink refused')
76
+ manifest = {'version': 1, 'destination': 'local-outreach-crm-drafts', 'request': request,
77
+ 'body_file': 'body.txt', 'send_eligible': False, 'messages_sent': 0,
78
+ 'native_vendor_acceptance': False, 'hosted_scope_verification': 'customer_supplied_unverified'}
79
+ return directory, (portable.canonical(manifest) + '\n').encode('utf-8')
80
+
81
+
82
+ def save(root, request, body, readback=False):
83
+ directory, manifest = prepare(root, request, body)
84
+ artifacts = [(directory / 'body.txt', body), (directory / 'manifest.json', manifest)]
85
+ if readback and not directory.is_dir():
86
+ raise ValueError('draft readback missing; resume the original save')
87
+ if not readback:
88
+ directory.mkdir(exist_ok=True)
89
+ with portable._publication_lock(directory / 'draft-transaction'):
90
+ for path, expected in artifacts:
91
+ if path.is_symlink() or (path.exists() and (not path.is_file() or path.read_bytes() != expected)):
92
+ raise ValueError('saved draft differs; retain original operation and inspect ' + str(path))
93
+ if readback and not path.is_file():
94
+ raise ValueError('draft readback incomplete; resume the original save')
95
+ outcomes = [] if readback else [portable._publish_bytes(path, expected) for path, expected in artifacts]
96
+ if any(path.read_bytes() != expected for path, expected in artifacts):
97
+ raise ValueError('draft readback mismatch; preserve original operation')
98
+ return {'operation_id': request['operation_id'], 'crm_project': request['crm_project'],
99
+ 'local_id': request['local_id'], 'external_id': request['external_id'],
100
+ 'directory': str(directory), 'body_sha256': request['body_sha256'],
101
+ 'manifest_sha256': hashlib.sha256(manifest).hexdigest(),
102
+ 'outcome': 'saved_and_read_back' if 'saved_and_read_back' in outcomes else 'identical_readback',
103
+ 'messages_sent': 0, 'send_eligible': False, 'native_vendor_acceptance': False}
104
+
105
+
106
+ def main():
107
+ parser = argparse.ArgumentParser(description=__doc__)
108
+ parser.add_argument('--crm-root', required=True)
109
+ parser.add_argument('--request', required=True)
110
+ parser.add_argument('--body-file', required=True)
111
+ parser.add_argument('--readback', action='store_true')
112
+ args = parser.parse_args()
113
+ request = json.loads(Path(args.request).read_text(encoding='utf-8'))
114
+ print(portable.canonical(save(args.crm_root, request, Path(args.body_file).read_bytes(), args.readback)))
115
+
116
+
117
+ if __name__ == '__main__':
118
+ try:
119
+ main()
120
+ except (ValueError, OSError, KeyError, TypeError) as error:
121
+ raise SystemExit('local CRM draft refused: ' + str(error))
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env python3
2
+ """Local draft-only handoff from a frozen report and explicit external-ID mapping.
3
+ No network, mailbox, sender or mutation of the input ledger/report. No send action.
4
+ """
5
+ import argparse
6
+ from contextlib import contextmanager
7
+ import errno
8
+ import hashlib
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ import tempfile
13
+ import stat
14
+ import time
15
+ from urllib.parse import urlsplit
16
+
17
+
18
+ def canonical(value):
19
+ return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
20
+
21
+
22
+ def digest(value):
23
+ return hashlib.sha256(canonical(value).encode()).hexdigest()
24
+
25
+
26
+ def prepare(report, mapping):
27
+ if not isinstance(report, dict) or not isinstance(mapping, dict) or type(report.get('v')) is not int or report['v'] not in (2, 3):
28
+ raise ValueError('supported frozen report v2 or v3 and mapping objects required')
29
+ if set(mapping) != {'version', 'workspace_id', 'project_id', 'site', 'destination', 'records', 'source_report_sha256'} or type(mapping['version']) is not int or mapping['version'] != 1:
30
+ raise ValueError('explicit versioned workspace/project/site/destination mapping required')
31
+ if any(not isinstance(mapping[k], str) or not mapping[k] or len(mapping[k]) > 2048 for k in ('workspace_id', 'project_id', 'site', 'destination')):
32
+ raise ValueError('mapping identity missing')
33
+ if mapping['destination'] != 'portable-file':
34
+ raise ValueError('native vendor adapter unsupported; use portable-file')
35
+ if mapping['source_report_sha256'] != digest(report):
36
+ raise ValueError('mapping belongs to another frozen report; scope refused')
37
+ site = urlsplit(mapping['site'])
38
+ if site.scheme not in ('http', 'https') or not site.hostname or site.username or site.password:
39
+ raise ValueError('explicit site URL required')
40
+ if not isinstance(report.get('rows'), list) or not isinstance(mapping['records'], dict):
41
+ raise ValueError('frozen report entries and explicit record mappings required')
42
+ rows, seen, external = [], set(), set()
43
+ for entry in report['rows']:
44
+ if not isinstance(entry, dict) or not {'id','source','target','state','reason','checked_at','evidence'}.issubset(entry):
45
+ raise ValueError('incomplete frozen observation row')
46
+ for field in ('source', 'target'):
47
+ url = urlsplit(entry[field])
48
+ if url.scheme not in ('http', 'https') or not url.hostname or url.username or url.password:
49
+ raise ValueError('public source and target URLs without credentials required')
50
+ local_id = entry.get('id')
51
+ if not isinstance(local_id, str) or not local_id or local_id in seen:
52
+ raise ValueError('missing or duplicate local identity')
53
+ if urlsplit(entry.get('target') or '').hostname != site.hostname:
54
+ raise ValueError('target belongs to another site; scope refused')
55
+ seen.add(local_id)
56
+ record = mapping['records'].get(local_id)
57
+ if not record or set(record) != {'external_id', 'reply', 'suppression'}:
58
+ raise ValueError('explicit external identity/reply/suppression required for every row')
59
+ if not isinstance(record['external_id'], str) or not record['external_id'] or record['external_id'] in external:
60
+ raise ValueError('missing or duplicate external identity')
61
+ external.add(record['external_id'])
62
+ if record['reply'] not in ('unknown', 'none', 'replied') or record['suppression'] not in ('unknown', 'suppressed', 'clear'):
63
+ raise ValueError('unknown/replied/suppressed facts must remain explicit')
64
+ # Keep the original observation payload and dates. Unknown never becomes lost.
65
+ row = {'local_id': local_id, 'external_id': record['external_id'],
66
+ 'source': entry.get('source'), 'target': entry.get('target'),
67
+ 'state': entry['state'], 'reason': entry['reason'], 'checked_at': entry['checked_at'], 'evidence': entry['evidence'],
68
+ 'checked_by': entry.get('checked_by'), 'occurrences': entry.get('occurrences'),
69
+ 'reply': record['reply'], 'suppression': record['suppression'],
70
+ 'send_eligible': False}
71
+ if report['v'] == 3:
72
+ # Activity and retained receipts are different facts. Preserve both;
73
+ # a later unknown attempt must not disappear behind an older receipt.
74
+ activity = entry.get('activity')
75
+ activity_fields = {'available', 'identity_status', 'latest_attempt',
76
+ 'last_successful_observation', 'last_link_verification', 'first_present'}
77
+ if entry.get('current_state') not in ('present', 'unknown', 'source_unavailable',
78
+ 'suspected_missing', 'confirmed_missing', 'unchecked') \
79
+ or type(entry.get('uncertain')) is not bool or not isinstance(activity, dict) \
80
+ or not activity_fields.issubset(activity):
81
+ raise ValueError('incomplete frozen report v3 activity row')
82
+ row.update(current_state=entry['current_state'], uncertain=entry['uncertain'], activity=activity)
83
+ rows.append(row)
84
+ if set(mapping['records']) != seen:
85
+ raise ValueError('mapping contains records outside this frozen report')
86
+ payload = {'version': 1, 'mode': 'draft_only', 'workspace_id': mapping['workspace_id'],
87
+ 'project_id': mapping['project_id'], 'site': mapping['site'], 'destination': mapping['destination'],
88
+ 'scope_verification': 'customer_supplied_mapping_unverified_hosted',
89
+ 'source_report_sha256': digest(report), 'mapping_sha256': digest(mapping),
90
+ 'rows': sorted(rows, key=lambda row: row['local_id'])}
91
+ return {**payload, 'operation_id': 'portable_' + digest(payload), 'messages_sent': 0,
92
+ 'destination_outcome': 'not_attempted', 'native_vendor_acceptance': False}
93
+
94
+
95
+ @contextmanager
96
+ def _publication_lock(output):
97
+ # Unsupported filesystems cannot publish by exclusive link. Serialize this
98
+ # helper's writers in a host-local kernel lock; never infer lock ownership
99
+ # from the existence or age of the cache file.
100
+ try:
101
+ import fcntl
102
+ except ImportError as error:
103
+ raise ValueError('filesystem lacks exclusive publication and native writer locking; preserve the checkpoint and use a supported local filesystem') from error
104
+ root = Path('/tmp') / ('agentlinkops-handoff-locks-' + str(os.getuid()))
105
+ root.mkdir(mode=0o700, exist_ok=True)
106
+ info = root.lstat()
107
+ if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
108
+ raise ValueError('publication lock directory is not private; preserve the checkpoint')
109
+ parent = output.parent.resolve()
110
+ parent_info = parent.stat()
111
+ identity = canonical([parent_info.st_dev, parent_info.st_ino, output.name.casefold()])
112
+ lock_path = root / (hashlib.sha256(identity.encode('utf-8')).hexdigest() + '.lock')
113
+ fd = os.open(lock_path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
114
+ try:
115
+ info = os.fstat(fd)
116
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
117
+ raise ValueError('publication lock file is not private; preserve the checkpoint')
118
+ deadline = time.monotonic() + 5
119
+ while True:
120
+ try:
121
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
122
+ break
123
+ except BlockingIOError:
124
+ if time.monotonic() >= deadline:
125
+ raise ValueError('draft publication is busy; preserve the original operation and read back after its writer finishes')
126
+ time.sleep(0.02)
127
+ try:
128
+ yield
129
+ finally:
130
+ fcntl.flock(fd, fcntl.LOCK_UN)
131
+ finally:
132
+ os.close(fd)
133
+
134
+
135
+ def _publish_bytes(output, content):
136
+ output = Path(output)
137
+ if output.exists():
138
+ if output.read_bytes() != content:
139
+ raise ValueError('existing handoff differs; preserve checkpoint and choose a new output')
140
+ return 'identical_readback'
141
+ output.parent.mkdir(parents=True, exist_ok=True)
142
+ fd, temporary = tempfile.mkstemp(prefix='.handoff-', dir=output.parent)
143
+ try:
144
+ with os.fdopen(fd, 'wb') as f:
145
+ f.write(content); f.flush(); os.fsync(f.fileno())
146
+ try:
147
+ os.link(temporary, output)
148
+ except FileExistsError:
149
+ if output.read_bytes() != content:
150
+ raise ValueError('concurrent handoff differs; preserve checkpoint')
151
+ return 'identical_readback'
152
+ except OSError as error:
153
+ if error.errno not in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.ENOSYS}:
154
+ raise
155
+ # ExFAT has neither hard links nor exclusive rename. All fallback
156
+ # writers take the same kernel lock and re-read before publication.
157
+ # This protects cooperating helper writers, not arbitrary external
158
+ # applications writing the same filename outside this protocol.
159
+ with _publication_lock(output):
160
+ if output.exists():
161
+ if output.read_bytes() != content:
162
+ raise ValueError('concurrent handoff differs; preserve checkpoint')
163
+ return 'identical_readback'
164
+ os.rename(temporary, output)
165
+ if output.read_bytes() != content:
166
+ raise ValueError('artifact readback mismatch; preserve operation identity')
167
+ if output.read_bytes() != content:
168
+ raise ValueError('artifact readback mismatch; preserve operation identity')
169
+ finally:
170
+ try:
171
+ os.unlink(temporary)
172
+ except FileNotFoundError:
173
+ pass # A successful rename consumed only this writer's temporary.
174
+ return 'saved_and_read_back'
175
+
176
+
177
+ def save(bundle, output):
178
+ return _publish_bytes(output, (canonical(bundle) + '\n').encode('utf-8'))
179
+
180
+
181
+ def main():
182
+ parser = argparse.ArgumentParser(description=__doc__)
183
+ parser.add_argument('--report', required=True)
184
+ parser.add_argument('--mapping', required=True)
185
+ parser.add_argument('--out', required=True)
186
+ args = parser.parse_args()
187
+ bundle = prepare(json.loads(Path(args.report).read_text(encoding='utf-8')), json.loads(Path(args.mapping).read_text(encoding='utf-8')))
188
+ receipt = save(bundle, args.out)
189
+ print(canonical({'operation_id': bundle['operation_id'], 'artifact': receipt, 'rows': len(bundle['rows']),
190
+ 'messages_sent': 0, 'destination_outcome': 'not_attempted'}))
191
+
192
+
193
+ if __name__ == '__main__':
194
+ try:
195
+ main()
196
+ except (ValueError, OSError, KeyError, TypeError) as error:
197
+ raise SystemExit('portable handoff refused: ' + str(error))