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,198 @@
1
+ # Local CRM contract
2
+
3
+ Version 0.2.0; SQLite schema version 4. Python 3.10+ is the only prerequisite.
4
+ Examples assume the working directory is this plugin root. Resolve paths from
5
+ the installed plugin location when running from a skill. Choose a database
6
+ path in the user's working directory, outside the replaceable plugin cache.
7
+
8
+ ```sh
9
+ python3 scripts/agentlinkops.py --db ./agentlinkops.sqlite init --workspace local-example
10
+ python3 scripts/agentlinkops.py --db ./agentlinkops.sqlite status
11
+ python3 scripts/agentlinkops.py templates
12
+ python3 scripts/agentlinkops.py --db ./agentlinkops.sqlite product upsert --file product.json
13
+ python3 scripts/agentlinkops.py --db ./agentlinkops.sqlite opportunity list
14
+ python3 scripts/agentlinkops.py --db ./agentlinkops.sqlite export --format json --output crm-export.json
15
+ python3 scripts/agentlinkops.py --db ./agentlinkops.sqlite export --format csv --entity placement --output placements.csv
16
+ ```
17
+
18
+ `init` binds this database to exactly one workspace ID. Use an actual cloud
19
+ workspace ID if cloud sync is planned. For a purely offline CRM, choose a
20
+ stable local name. Workspace and cloud-origin binding cannot be silently
21
+ changed. A separate local database is required for a different workspace.
22
+
23
+ `migrate` creates a SQLite backup before applying pending migrations. It
24
+ refuses unrelated databases, newer schema versions and changed migration
25
+ checksums. Initialization is idempotent for the same workspace. The CLI has
26
+ no destructive reset command. SQLite is not encrypted; rely on the user's
27
+ device security and store the file in an appropriate location.
28
+
29
+ `upsert` reads one object or an atomic array of objects from a file or stdin
30
+ (`--file -`, the default). IDs are explicit, stable strings; updating an
31
+ existing record changes only supplied fields. References must already exist;
32
+ insert product, campaign/contact, opportunity, then activity/placement.
33
+
34
+ | Entity | Required for a new record, plus `id` | Optional editable fields |
35
+ |---|---|---|
36
+ | product | `name`, `url` | `audience`, `description`, `target_pages` (URL array), `notes` |
37
+ | campaign | `name`, `type` | `product_id`, `template_version`, `status`, `goal`, `target_url`, `notes` |
38
+ | contact | `publisher_url` | `name`, `email`, `contact_url`, `source_url`, `observed_at`, `confidence`, `notes` |
39
+ | opportunity | `source_url`, `target_url` | `campaign_id`, `contact_id`, `type`, `status`, `evidence` (object), `notes` |
40
+ | activity | `kind`, `occurred_at` | `opportunity_id`, `contact_id`, `campaign_id`, `channel`, `external_message_id`, `outcome`, `notes` |
41
+ | placement | `source_url`, `target_url` | `opportunity_id`, `project_id`, `watch_id`, `status`, `notes`, `cost_amount`, `cost_currency` |
42
+
43
+ Timestamps require ISO 8601 with a timezone. Link fields require HTTP(S) URLs.
44
+ Null clears an optional relationship ID. Passwords, API keys, tokens,
45
+ mailbox credentials and email/message body fields are rejected. Never put
46
+ those values into free-text notes either. Contact confidence describes the
47
+ evidence, not a promise of email delivery.
48
+
49
+ Example `product.json`:
50
+
51
+ ```json
52
+ {"id":"product-example","name":"Example Resource","url":"https://example.com/","audience":"Resource-page readers","target_pages":["https://example.com/guide"]}
53
+ ```
54
+
55
+ Example opportunity evidence:
56
+
57
+ ```json
58
+ {"id":"opportunity-example","source_url":"https://publisher.example/resources","target_url":"https://example.com/guide","type":"resource_addition","evidence":{"source_url":"https://publisher.example/resources","observed_at":"2026-09-09T12:00:00Z","context":"Relevant resources section; candidate requires browser review."}}
59
+ ```
60
+
61
+ Use `entity list --id ID` to retrieve a specific record. JSON exports preserve
62
+ all fields, raw events, the sync cursor, disavow rules and their source provenance. They are portable data exports,
63
+ not an automatic full-database restore format. CSV exports select one entity
64
+ and prefix possible spreadsheet formulas with `'`; JSON is lossless.
65
+ Export files are created with restrictive permissions and never overwritten.
66
+ For a restorable database backup, use SQLite's backup API or the SQLite CLI
67
+ `.backup` command; do not copy a live database file while a write is running.
68
+
69
+ ## Disavow lists and placement reports
70
+
71
+ For user-selected disavow files, read [the disavow contract](disavow.md).
72
+ The `disavow import/upsert/list/export` commands work offline, require an
73
+ explicit URL-prefix property and preserve source provenance. No command
74
+ uploads to Google or chooses rules from automated risk labels.
75
+
76
+ For branded HTML output, read [the report contract](reports.md). The `report`
77
+ command supports a project filter and optional local notes/cost fields. Notes
78
+ and costs are excluded unless explicitly included. Record a placement cost
79
+ as a decimal string paired with an uppercase currency label, for example
80
+ `"cost_amount":"12.50","cost_currency":"USD"`. Clear both fields with null.
81
+ The CLI validates decimal precision and reports each currency separately.
82
+ It does not verify currency labels against a current exchange-rate service.
83
+
84
+ Existing CRM databases require `migrate` before using version 0.2.0. Schema
85
+ versions 3 and 4 add disavow provenance tables and optional placement cost
86
+ fields. Existing placements, notes, event history and workspace bindings are
87
+ preserved; the helper creates a restorable database backup before upgrading.
88
+
89
+ ## Connect the ledger CLI to an existing project
90
+
91
+ Local ledger commands work without an account. Cloud sync uses a separate scoped
92
+ API key. MCP OAuth credentials stay in the MCP client and cannot authorize REST.
93
+
94
+ 1. Open the invited account's workspace at `https://app.agentlinkops.com/app`.
95
+ An owner or admin selects **Agent access > Create API key**. Choose the
96
+ existing project and the scopes `projects:read`, `watches:read`,
97
+ `watches:write`, `events:read`, and `exports:create`.
98
+ 2. Save the once-shown credential in the user's secret store. Supply it through
99
+ `AGENTLINKOPS_API_KEY` in the CLI process environment. The ledger CLI also accepts
100
+ `AGENTLINKOPS_TOKEN`; if both are set, they must agree. The `LINKTRAIL_` spellings are
101
+ still read, with a one-line warning, during the pilot compatibility window. Never paste credentials
102
+ into chat, command arguments, or ledger files.
103
+ 3. Run the installed CLI from the directory containing the existing `.agentlinkops/` (or `.linktrail/`)
104
+ ledger. Replace the script path and IDs below with the installed package path
105
+ and IDs returned by `get_workspace` and `list_projects`.
106
+
107
+ ```sh
108
+ node /path/to/installed/plugin/cli/agentlinkops.mjs connect --origin https://app.agentlinkops.com --workspace WORKSPACE_ID --project-id PROJECT_ID --selection selected-links.json
109
+ node /path/to/installed/plugin/cli/agentlinkops.mjs sync --dry-run
110
+ node /path/to/installed/plugin/cli/agentlinkops.mjs sync --pull-only
111
+ ```
112
+
113
+ `selected-links.json` contains an array of existing ledger IDs, such as
114
+ `["local-placement-1", "local-placement-2"]`. A mapping file with
115
+ `entries: [{"local_id": "local-placement-1"}]` also works. Save an explicit
116
+ selection when only part of an existing ledger belongs in cloud monitoring.
117
+
118
+ `connect` verifies workspace identity, project access and required scopes through
119
+ read-only REST calls. It saves connection metadata and the selection in
120
+ `.agentlinkops/config.json`, without saving the credential or creating cloud records.
121
+ `sync --dry-run` reads local files only and previews uploads. `sync --pull-only`
122
+ retrieves cloud history without creating watches. After reviewing the plan, run
123
+ `sync` when the user has authorized its proposed monitoring. A saved selection
124
+ limits uploads; without one, sync selects expected or earned entries and pauses mapped retired
125
+ entries. Wanted entries require `--include-wanted`.
126
+
127
+ The cloud stores normalized source URLs: `https://example.com` becomes
128
+ `https://example.com/`. Send the ledger ID as `localReference` and join returned
129
+ rows using `local_reference`. Raw URL string equality can miss an existing watch.
130
+ Copy watch IDs from tool results without shortening them.
131
+
132
+ The Python CRM helper has its own environment settings and workspace binding,
133
+ described in [optional cloud sync](#optional-cloud-sync). Ledger `connect` does
134
+ not configure that helper. If account access or permission to create a credential
135
+ is unavailable, use the existing MCP connection for cloud work and keep local
136
+ records intact.
137
+
138
+ ## Optional cloud sync
139
+
140
+ Set `AGENTLINKOPS_API_URL` to the actual service origin and
141
+ `AGENTLINKOPS_API_KEY` using the shell's existing secure environment mechanism.
142
+ Create the scoped credential in **Agent access > Create API key** as described
143
+ [above](#connect-the-ledger-cli-to-an-existing-project). For this read-only event
144
+ sync, select `events:read` and the projects whose events the CRM should receive.
145
+ This toolkit does not bundle credentials or create an account. Never paste keys into chat or command-line arguments.
146
+
147
+ ```sh
148
+ python3 scripts/agentlinkops.py --db ./agentlinkops.sqlite sync --limit 100 --max-pages 100
149
+ ```
150
+
151
+ The client requests `GET /v1/events?limit=100&cursor=...`. Production origins
152
+ require HTTPS. Loopback HTTP is supported for local development. Redirects
153
+ are rejected so bearer credentials never follow an endpoint redirect.
154
+ Requests time out after 30 seconds and responses are limited to 4 MiB.
155
+
156
+ Expected response:
157
+
158
+ ```json
159
+ {
160
+ "workspace_id": "workspace-example",
161
+ "events": [{
162
+ "id": "event-example", "workspace_id": "workspace-example",
163
+ "cursor": "opaque-cursor", "type": "watch.state_changed",
164
+ "project_id": "project-example", "watch_id": "watch-example",
165
+ "created_at": "2026-09-09T12:00:00Z",
166
+ "data": {
167
+ "before": {"state": "unknown"},
168
+ "after": {"state": "present", "source_url": "https://publisher.example/resources", "target_url": "https://example.com/guide"},
169
+ "observation_id": "observation-example", "evidence_key": null
170
+ }
171
+ }],
172
+ "next_cursor": "opaque-cursor", "has_more": false
173
+ }
174
+ ```
175
+
176
+ Each page commits new events, placement overlays and its cursor in one
177
+ transaction. Duplicate event IDs are ignored only when the payload is
178
+ identical. A changed payload, malformed event or mismatched workspace rolls
179
+ back the entire page. Earlier successful pages remain committed if a later
180
+ page fails; rerunning resumes at the last committed cursor. Two concurrent
181
+ syncs detect a cursor conflict and require a retry.
182
+
183
+ Events update placement `cloud_state`, `cloud_observed_at` and
184
+ `cloud_event_id`. Existing local `status`, `notes` and opportunity links are
185
+ preserved. URLs follow the cloud watch snapshot. A previously unseen watch
186
+ creates `id: cloud:<watch_id>` only when the snapshot supplies source and
187
+ destination URLs. Partial snapshots without a URL pair and unknown event types are retained in the event
188
+ feed for later interpretation; they do not fabricate a placement.
189
+
190
+ HTTP 410 reports `CURSOR_EXPIRED` and does not advance the cursor. Preserve
191
+ the database and export first; obtain a current watch snapshot and recover
192
+ into a separate database before replaying retained events. Automated snapshot
193
+ recovery is not implemented in this version. Removing the cursor alone would
194
+ hide a history gap and is not a supported recovery procedure.
195
+
196
+ All success output is JSON except requested CSV export. Errors are JSON on
197
+ stderr with nonzero exit status. HTTP response bodies and credential values
198
+ are never included in errors. Local operation has no telemetry.
@@ -0,0 +1,73 @@
1
+ # User-managed disavow lists
2
+
3
+ This is an offline record/file tool. It does not evaluate whether a link
4
+ should be disavowed, connect to Search Console, or upload a file. Record the
5
+ user's explicit choices and their provenance.
6
+
7
+ Google's documented file format uses one page URL or `domain:hostname` per
8
+ line, UTF-8 or ASCII `.txt` files, and `#` comment lines. The limits are 2,048
9
+ characters per URL, 100,000 lines including comments/blanks, and 2 MB. Domain
10
+ and subdomain rules are supported; a path prefix is not a domain-wide rule.
11
+ The tool does not support Search Console Domain properties. Uploading a new
12
+ list replaces the list for the selected property; local merging here does
13
+ not establish what Google currently holds. Google says most sites do not
14
+ need disavowals. [Official format and usage guidance](https://support.google.com/webmasters/answer/2648487?hl=en),
15
+ checked September 9, 2026.
16
+
17
+ ## Commands
18
+
19
+ Run from the installed plugin root and keep the database outside its cache:
20
+
21
+ ```sh
22
+ python3 scripts/agentlinkops.py --db ./agentlinkops.sqlite migrate
23
+ python3 scripts/agentlinkops.py --db ./agentlinkops.sqlite disavow import --property https://my-product.com/ --source "User-reviewed existing list" --file existing-list.txt
24
+ python3 scripts/agentlinkops.py --db ./agentlinkops.sqlite disavow list --property https://my-product.com/
25
+ python3 scripts/agentlinkops.py --db ./agentlinkops.sqlite disavow export --property https://my-product.com/ --output reviewed-disavow.txt
26
+ ```
27
+
28
+ Every command requires the exact URL-prefix property. HTTP and HTTPS remain
29
+ separate; a subdomain or path prefix remains separate from its parent. The
30
+ helper records this local scope; it does not verify property ownership.
31
+
32
+ Imports merge rules transactionally. Any invalid line rejects the entire
33
+ file with no new rules or source record. Duplicates reuse stable rule IDs.
34
+ Each source retains its label, original text and SHA-256; provenance records
35
+ the original line, line number and associated comments. Reimporting identical
36
+ content under the same source label is idempotent. A new source label adds
37
+ provenance without duplicating the rule. Imports never overwrite local notes
38
+ or reactivate a rule the user disabled.
39
+
40
+ To add or explicitly change a rule, prepare `rule.json` and run:
41
+
42
+ ```sh
43
+ python3 scripts/agentlinkops.py --db ./agentlinkops.sqlite disavow upsert --property https://my-product.com/ --source "User decision after review" --file rule.json
44
+ ```
45
+
46
+ ```json
47
+ {"kind":"domain","value":"publisher.example","active":false,"notes":"User removed this rule after reviewing the source."}
48
+ ```
49
+
50
+ `kind` is `domain` or `url`. `active` defaults to the existing value, or true
51
+ for a new rule. Omitted notes are preserved. Deactivation is reversible and
52
+ retains provenance; there is no destructive delete command.
53
+
54
+ Domain normalization changes case and a terminal DNS dot only. Hostnames
55
+ must be ASCII or already Punycode; the helper does not guess IDN conversion.
56
+ URLs preserve path case, query strings and fragments. A path ending in `/`
57
+ is still one exact page URL; wildcard patterns are rejected. URL credentials
58
+ and malformed domains are rejected. UTF-8 paths, LF/CRLF and an optional UTF-8
59
+ BOM are accepted. Original source text remains available in the full JSON
60
+ CRM export.
61
+
62
+ `.txt` exports contain active rules for one property, sorted by rule kind and
63
+ value with stable header comments. They omit timestamps and private notes,
64
+ so unchanged rules produce identical bytes. The export is checked against
65
+ the documented line limit and a conservative 2,000,000-byte ceiling, and never
66
+ overwrites an existing file. The same byte ceiling applies to imports.
67
+ Without `--output`, it writes the same text to stdout. The local list can be
68
+ empty; an empty export is not evidence that a Google-side list was cleared.
69
+
70
+ `disavow list --active-only` filters the view. The general `export` command
71
+ includes all disavow rules, sources and provenance in the local JSON backup.
72
+ No monitoring result, authority metric or contact inspection automatically
73
+ creates a rule.
@@ -0,0 +1,53 @@
1
+ # Branded HTML placement reports
2
+
3
+ Reports are generated entirely from the local CRM. They make no network
4
+ requests, run no additional checks, and do not imply broader backlink-index
5
+ coverage. The output is standalone HTML with embedded styles and print
6
+ styling; it is not a generated PDF.
7
+
8
+ ```sh
9
+ python3 scripts/agentlinkops.py --db ./agentlinkops.sqlite report --project project-id --brand "My Agency" --title "Placement monitoring report" --output placements.html
10
+ ```
11
+
12
+ Omit `--project` only when the requested report covers every local placement.
13
+ By default, a report includes source/destination URLs, cloud-observed state,
14
+ observation timestamp, report timestamp and aggregate placement counts.
15
+ It excludes local notes, local costs, contacts, outreach activity, credentials,
16
+ disavow rules, workspace IDs and raw event payloads. URLs are the selected
17
+ records' actual URLs; review the selection before sharing the file.
18
+
19
+ `--include-notes` and `--include-costs` explicitly add those private local
20
+ fields. Use these only when the user requests that content. Included cost
21
+ totals are calculated separately per currency without exchange rates.
22
+ Local CRM status is not substituted for a verified cloud observation.
23
+
24
+ Record a cost with an ordinary partial placement upsert:
25
+
26
+ ```json
27
+ {"id":"placement-id","cost_amount":"12.50","cost_currency":"USD"}
28
+ ```
29
+
30
+ Amounts are nonnegative decimal strings with at most six decimal places,
31
+ not JSON floating-point numbers. Both amount and currency are required for
32
+ a newly recorded cost. The currency is an uppercase three-letter label;
33
+ the helper does not perform a current currency-directory lookup. Updating
34
+ other fields or synchronizing cloud events preserves local costs. Set both
35
+ cost fields to null to clear a cost explicitly.
36
+
37
+ The generator escapes all text/attributes and creates clickable links only
38
+ for HTTP(S) URLs without credentials. It loads no external scripts, fonts,
39
+ logos or tracking pixels. The title and brand are user-selected text labels.
40
+ Local notes remain escaped text, even if they contain HTML.
41
+
42
+ Rows have a stable ordering. Supply a fixed `--generated-at` ISO 8601
43
+ timestamp to reproduce identical output from the same data and options:
44
+
45
+ ```sh
46
+ python3 scripts/agentlinkops.py --db ./agentlinkops.sqlite report --title "Placement report" --generated-at 2026-09-09T14:00:00Z --output reproducible.html
47
+ ```
48
+
49
+ Otherwise the timestamp is the current time. Coverage reports the range of
50
+ stored observation times separately from report generation. Unknown and
51
+ unobserved states remain distinct from confirmed loss. Existing output files
52
+ are never overwritten. Browser printing, PDF generation and automated report
53
+ delivery are separate capabilities and are not performed by this helper.