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.
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +24 -0
- package/CHANGELOG.md +52 -0
- package/LICENSE +190 -0
- package/NOTICE +17 -0
- package/README.md +202 -0
- package/bin/agentlinkops.mjs +4 -0
- package/bin/linktrail.mjs +6 -0
- package/cli/adapters/csv.js +68 -0
- package/cli/adapters/sqlite-crm.js +79 -0
- package/cli/adapters/suppliers.js +180 -0
- package/cli/agent-setup.js +203 -0
- package/cli/agentlinkops.mjs +4 -0
- package/cli/args.js +18 -0
- package/cli/catalog.js +7483 -0
- package/cli/check.js +119 -0
- package/cli/citation.js +109 -0
- package/cli/client.js +60 -0
- package/cli/commands.js +183 -0
- package/cli/config.js +104 -0
- package/cli/connection.js +90 -0
- package/cli/context-mcp.js +193 -0
- package/cli/context.js +340 -0
- package/cli/delivery.js +67 -0
- package/cli/doctor.js +283 -0
- package/cli/env.js +66 -0
- package/cli/fleet.js +82 -0
- package/cli/gsc-browser.js +239 -0
- package/cli/import.js +108 -0
- package/cli/ledger.js +232 -0
- package/cli/linktrail.mjs +6 -0
- package/cli/local-result.js +161 -0
- package/cli/main.js +523 -0
- package/cli/mirror.js +126 -0
- package/cli/mix.js +123 -0
- package/cli/platforms.js +161 -0
- package/cli/receipt-performance.js +64 -0
- package/cli/receipts.js +122 -0
- package/cli/report.js +158 -0
- package/cli/setup-plan.js +211 -0
- package/cli/skill.js +39 -0
- package/cli/state.js +106 -0
- package/cli/status.js +51 -0
- package/cli/sync.js +179 -0
- package/package.json +59 -0
- package/references/cli.md +198 -0
- package/references/disavow.md +73 -0
- package/references/reports.md +53 -0
- package/scripts/agentlinkops.py +651 -0
- package/scripts/disavow.py +207 -0
- package/scripts/linktrail.py +10 -0
- package/scripts/migrations/001_crm.sql +45 -0
- package/scripts/migrations/002_cloud_events.sql +13 -0
- package/scripts/migrations/003_disavow.sql +20 -0
- package/scripts/migrations/004_placement_costs.sql +2 -0
- package/scripts/report.py +113 -0
- package/shared/agent-clients.js +49 -0
- package/shared/observation-event.js +43 -0
- package/shared/response-contract.js +59 -0
- package/shared/search-connection-contract.js +18 -0
- package/skills/agentlinkops-assets/SKILL.md +13 -0
- package/skills/agentlinkops-assets/agents/openai.yaml +5 -0
- package/skills/agentlinkops-campaigns/SKILL.md +17 -0
- package/skills/agentlinkops-campaigns/agents/openai.yaml +12 -0
- package/skills/agentlinkops-connect/SKILL.md +33 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +12 -0
- package/skills/agentlinkops-connect/references/agentlinkops.md +266 -0
- package/skills/agentlinkops-crm/SKILL.md +27 -0
- package/skills/agentlinkops-crm/agents/openai.yaml +12 -0
- package/skills/content-defingerprinting/SKILL.md +29 -0
- package/skills/content-defingerprinting/scripts/watermarks/README.md +102 -0
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +26 -0
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +52 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +21 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +96 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +432 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +86 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +460 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +651 -0
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +27 -0
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +212 -0
- package/skills/internal-linking-optimizer/SKILL.md +56 -0
- package/skills/internal-linking-optimizer/references/source-prompt.md +76 -0
- package/skills/pre-publish-review/SKILL.md +179 -0
- package/skills/source-cited-content-builder/SKILL.md +56 -0
- package/skills/source-cited-content-builder/references/source-prompt.md +19 -0
- package/skills/source-cited-humanizer/SKILL.md +20 -0
- package/src/citations/adapter.js +141 -0
- package/src/citations/contract.js +171 -0
- package/src/citations/match.js +120 -0
- package/src/citations/runner.js +151 -0
- package/src/citations/stats.js +74 -0
- package/src/competitors/contract.js +157 -0
- package/src/competitors/domain-mix.js +199 -0
- package/src/context/ga4.js +652 -0
- package/src/context/gsc.js +925 -0
- package/src/context/site-profile.js +445 -0
- package/src/context/util.js +71 -0
- package/src/context-tool-registry.js +27 -0
- package/src/corpus/acquire.js +135 -0
- package/src/corpus/seeds.js +182 -0
- package/src/discovery/contract.js +237 -0
- package/src/discovery/import.js +156 -0
- package/src/tool-views.js +117 -0
- package/src/verifier/fetch.js +303 -0
- package/src/verifier/index.js +311 -0
- package/src/verifier/observation-kind.js +91 -0
- package/src/verifier/parser.js +241 -0
- package/src/verifier/readiness.js +90 -0
- package/src/verifier/render-evidence.js +57 -0
- package/src/verifier/robots.js +85 -0
- package/src/verifier/transition.js +129 -0
- package/src/verifier/url.js +56 -0
- package/templates/SITE.template.md +59 -0
- package/templates/campaigns.v1.json +65 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agentlinkops",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Connect an agent to AgentLinkOps and run backlink work with it: the connect skill (views, the three discovery verbs, evidence rules), local ledger and campaign skills, a portable CRM, the source-cited content pipeline, user-managed disavow lists, HTML reports and monitoring-event sync.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "AgentLinkOps"
|
|
7
|
+
},
|
|
8
|
+
"skills": "./skills/",
|
|
9
|
+
"mcpServers": "./.mcp.json"
|
|
10
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agentlinkops",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Connect an agent to AgentLinkOps and run backlink work with it: the connect skill (views, the three discovery verbs, evidence rules), local ledger and campaign skills, a portable CRM, the source-cited content pipeline, user-managed disavow lists, HTML reports and monitoring-event sync.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "AgentLinkOps"
|
|
7
|
+
},
|
|
8
|
+
"skills": "./skills/",
|
|
9
|
+
"interface": {
|
|
10
|
+
"displayName": "AgentLinkOps",
|
|
11
|
+
"shortDescription": "Run backlink campaigns with your own agent.",
|
|
12
|
+
"longDescription": "Plan campaigns and preserve contacts, outreach references and placements in a local SQLite CRM. Manage property-scoped disavow lists and generate branded HTML reports offline. Optional account-connected event sync preserves local notes and costs.",
|
|
13
|
+
"developerName": "AgentLinkOps",
|
|
14
|
+
"category": "Productivity",
|
|
15
|
+
"capabilities": [
|
|
16
|
+
"Write"
|
|
17
|
+
],
|
|
18
|
+
"defaultPrompt": [
|
|
19
|
+
"Plan a backlink campaign for my product.",
|
|
20
|
+
"Set up my local link and outreach CRM.",
|
|
21
|
+
"Review changes to my monitored placements."
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
}
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the `agentlinkops` package are recorded here. The version follows the
|
|
4
|
+
plugin manifests in `.claude-plugin/` and `.codex-plugin/`.
|
|
5
|
+
|
|
6
|
+
## 0.5.0 (unreleased; DP-0036 agent surface)
|
|
7
|
+
|
|
8
|
+
- `agentlinkops tools [TOOLSET]`, `agentlinkops describe NAME` and `agentlinkops call NAME`:
|
|
9
|
+
the three discovery verbs, answered offline from the bundled catalog snapshot (`--refresh`
|
|
10
|
+
reads the live one). The index prints each toolset's summary; `call` adds `--set`,
|
|
11
|
+
`--dry-run` and `-y`, and retired command names resolve to their canonical command.
|
|
12
|
+
- `agentlinkops agent setup` and `agentlinkops agent status`: install the skill pack and the
|
|
13
|
+
right MCP view for each detected client (Claude Code, Codex, Cursor, Gemini CLI, Hermes)
|
|
14
|
+
without storing a credential.
|
|
15
|
+
- Skill pack v2: the new `agentlinkops-connect` skill (views, verbs, evidence rules), per-client
|
|
16
|
+
`allowed-tools` and Codex `agents/openai.yaml` metadata, the plugin's `.mcp.json`, and a
|
|
17
|
+
discovery index with deterministic per-skill archives served at
|
|
18
|
+
`/.well-known/agent-skills/index.json`.
|
|
19
|
+
- Skill-first onboarding (DP-0037): `agentlinkops skill [--url]` prints the agent reference
|
|
20
|
+
that ships inside the connect skill (`references/agentlinkops.md`); the connect skill is a
|
|
21
|
+
stub that has the agent read it once per session; `init`, a terminal `tools` and
|
|
22
|
+
`agent setup` point at it once. The discovery index now follows the Agent Skills discovery
|
|
23
|
+
specification (`$schema`, `type: archive`) so `npx skills add https://agentlinkops.com`
|
|
24
|
+
installs the four product skills; the content pipeline sits behind
|
|
25
|
+
`https://agentlinkops.com/content`.
|
|
26
|
+
- `agentlinkops check --source URL --target URL [--out FILE]`: one accountless local check that
|
|
27
|
+
produces a portable result artifact, and `agentlinkops adopt-result FILE` to save that artifact
|
|
28
|
+
into a ledger once, validated and under the ledger writer lock.
|
|
29
|
+
- `agentlinkops setup --plan --goal verify-links|prepare-campaign|build-content [--mode …]`: a
|
|
30
|
+
read-only setup plan from metadata-only workspace inspection; no writes, network or account.
|
|
31
|
+
- `agentlinkops receipt history --performance FILE.json`: attaches dated Search Console and
|
|
32
|
+
optional GA4 context from the local context files beside a claim, without claiming causation.
|
|
33
|
+
- `node cli/context-mcp.js --root DIR`: the repository-local stdio MCP host for the eight
|
|
34
|
+
context tools. Adds the runtime dependency `@modelcontextprotocol/server` 2.0.0.
|
|
35
|
+
|
|
36
|
+
## 0.4.0 (2026-09-15)
|
|
37
|
+
|
|
38
|
+
First public release under the Apache License 2.0.
|
|
39
|
+
|
|
40
|
+
- `agentlinkops` command: `init`, `add`, `check`, `status`, `diff`, `report`, `import`, `adopt`,
|
|
41
|
+
`sync`, `connect`, `doctor`, `migrate`, `tools`, `call`, `fleet`, `platforms`, `context`,
|
|
42
|
+
`mix`, `receive`, `receipt`, `fmt` and `compact`.
|
|
43
|
+
- `linktrail` alias: runs the same command with a one-line notice on stderr. The alias, the
|
|
44
|
+
`LINKTRAIL_*` environment variables and the `.linktrail/` ledger directory stop working on
|
|
45
|
+
2026-10-15; `agentlinkops migrate` renames the directory.
|
|
46
|
+
- Shared verifier (`src/verifier/`): the module the hosted service runs, unchanged.
|
|
47
|
+
- Import adapters for Ahrefs, Semrush, Majestic, Moz, DataForSEO, Linkody, Google Search Console
|
|
48
|
+
and generic CSV, with an explicit `--map` for any other layout.
|
|
49
|
+
- Agent plugin manifests and skills (`agentlinkops-crm`, `agentlinkops-campaigns`,
|
|
50
|
+
`agentlinkops-assets` and the content-pipeline skills), campaign templates and the Python
|
|
51
|
+
CRM helper.
|
|
52
|
+
- Dependencies declared instead of vendored: parse5 8.0.1 and zod 4.6.0.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
Copyright 2026 AgentLinkOps
|
|
179
|
+
|
|
180
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
181
|
+
you may not use this file except in compliance with the License.
|
|
182
|
+
You may obtain a copy of the License at
|
|
183
|
+
|
|
184
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
185
|
+
|
|
186
|
+
Unless required by applicable law or agreed to in writing, software
|
|
187
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
188
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
189
|
+
See the License for the specific language governing permissions and
|
|
190
|
+
limitations under the License.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
AgentLinkOps CLI
|
|
2
|
+
Copyright 2026 AgentLinkOps
|
|
3
|
+
|
|
4
|
+
This product includes software developed by AgentLinkOps (https://agentlinkops.com/).
|
|
5
|
+
|
|
6
|
+
Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
7
|
+
|
|
8
|
+
Third-party components redistributed with this package:
|
|
9
|
+
|
|
10
|
+
- skills/content-defingerprinting/scripts/watermarks/vendor/: files from
|
|
11
|
+
watermarks-remover (https://github.com/guillaumemeyer/watermarks-remover),
|
|
12
|
+
MIT License, Copyright (c) 2026 watermarks-remover contributors. The files are
|
|
13
|
+
byte-identical to the pinned upstream commit named in the sibling UPSTREAM.json;
|
|
14
|
+
the upstream license text is at vendor/LICENSE.upstream.
|
|
15
|
+
|
|
16
|
+
Runtime dependencies installed by npm and not redistributed here: parse5 (MIT),
|
|
17
|
+
zod (MIT).
|
package/README.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# agentlinkops
|
|
2
|
+
|
|
3
|
+
A backlink ledger that lives in your repository. `agentlinkops check` fetches a source page,
|
|
4
|
+
looks for the link you declared and records an observation with the HTTP status, redirect
|
|
5
|
+
chain, robots posture and a SHA-256 of the fetched bytes. Page HTML never enters the repository.
|
|
6
|
+
A check that cannot conclude says `unknown` with a named reason, and unknown never means the
|
|
7
|
+
link was removed.
|
|
8
|
+
|
|
9
|
+
The CLI imports the same verifier module the hosted AgentLinkOps service runs, and its tests
|
|
10
|
+
assert both paths return the same shape. The hosted checks, evidence store and MCP server are a
|
|
11
|
+
separate service; this package is the local half and needs no account.
|
|
12
|
+
|
|
13
|
+
## Give your agent AgentLinkOps
|
|
14
|
+
|
|
15
|
+
Four steps, the same ones the docs and the landing page show. Node.js 22.13 or later.
|
|
16
|
+
|
|
17
|
+
1. **Install the skill.** Detects the agent clients on this machine (Claude Code, Codex,
|
|
18
|
+
Cursor, Gemini CLI, Hermes), installs the skill pack at each one's skills path and writes
|
|
19
|
+
its MCP entry; stores no credential and prints what it changed.
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npx -y agentlinkops agent setup
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
With the `skills` CLI instead: `npx -y skills add https://agentlinkops.com`, then add
|
|
26
|
+
`https://app.agentlinkops.com/mcp` as a remote HTTP MCP server in your client.
|
|
27
|
+
2. **Sign in when your agent asks.** The client opens the AgentLinkOps sign-in; you pick the
|
|
28
|
+
account, workspace and scopes. Nothing is pasted into a chat.
|
|
29
|
+
3. **Verify.** Ask your agent to list your AgentLinkOps projects, or run `agentlinkops doctor`.
|
|
30
|
+
4. **Check a placement.** Ask: "check whether [publisher page] links to [my page], show the
|
|
31
|
+
dated observation and any uncertainty." No account yet? The same verifier runs locally:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npx -y agentlinkops check --source https://publisher.example/resources --target https://your-site.example/guide
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The skill is a short door: the first time your agent uses AgentLinkOps in a session it reads
|
|
38
|
+
the full agent reference, which `agentlinkops skill` prints and
|
|
39
|
+
https://agentlinkops.com/SKILL.md serves. Hosted monitoring is an invitation-only pilot; the
|
|
40
|
+
local commands below need no account.
|
|
41
|
+
|
|
42
|
+
## Install the CLI on its own
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
npm install -g agentlinkops
|
|
46
|
+
agentlinkops --help
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or run it once without installing:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
npx agentlinkops check
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Check one link with no account
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
cd your-repository
|
|
59
|
+
agentlinkops init
|
|
60
|
+
agentlinkops add --source https://publisher.example.com/resources --target https://example.com/guide --intent expected
|
|
61
|
+
agentlinkops check
|
|
62
|
+
agentlinkops status
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`init` creates `.agentlinkops/` with an empty ledger. `add` writes one entry. `check` fetches
|
|
66
|
+
the source page and appends an observation to the mirror. `status` names every entry whose
|
|
67
|
+
observations disagree with its intent and proposes the edit; applying it is a commit you make.
|
|
68
|
+
|
|
69
|
+
Exit codes: `0` when every expected link is present, `1` only when an expected link is observed
|
|
70
|
+
absent with complete evidence, `2` for a usage, configuration or ledger error. An unknown never
|
|
71
|
+
fails the run. Pass `--fail-on-unknown` when your CI wants it to.
|
|
72
|
+
|
|
73
|
+
`agentlinkops doctor` verifies the ledger, the verifier self-test, cloud reachability and the
|
|
74
|
+
token, and prints a fix line for anything that fails.
|
|
75
|
+
|
|
76
|
+
## The ledger
|
|
77
|
+
|
|
78
|
+
Intent and fact never share a file. You write intent; the verifier writes fact.
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
.agentlinkops/
|
|
82
|
+
config.json project identity, cloud origin, defaults. Yours, committed
|
|
83
|
+
links.jsonl the ledger: intent. Yours, committed
|
|
84
|
+
observations.jsonl fact mirror, tool-owned, committed
|
|
85
|
+
events.jsonl cloud event mirror by cursor, tool-owned, committed
|
|
86
|
+
candidates.jsonl imported and discovered rows, tool-owned, committed
|
|
87
|
+
state.json cursors and last-check marks, tool-owned, committed
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
One ledger entry:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{"id":"lk_9f3ac1","intent":"expected","source":"https://publisher.example.com/resources","target":"https://example.com/guide","scope":"exact","expect":{"anchor":"guide","rel":["nofollow"]},"cadence":"daily","ref":"q3-resource-pages/441","tags":["resource-page"],"added":"2026-09-11","note":"editor confirmed"}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Three intents: `wanted` (we want this publisher to link to us; present is the news),
|
|
97
|
+
`expected` (it should be there now; absent is the alarm) and `retired` (stop checking, keep
|
|
98
|
+
the history). Nothing promotes an entry automatically. `id` is random and permanent, so a
|
|
99
|
+
corrected URL keeps its history. Everything is plain JSON Lines, one entry per line, that you
|
|
100
|
+
can read without this tool.
|
|
101
|
+
|
|
102
|
+
## Imports and adoption
|
|
103
|
+
|
|
104
|
+
Supplier exports import through presets or an explicit column map, and a preview writes nothing:
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
agentlinkops import ahrefs-export.csv --target example.com --from ahrefs
|
|
108
|
+
agentlinkops import any-export.csv --target example.com --map "source=Referring page URL,target=Target URL"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Presets: Ahrefs, Semrush, Majestic, Moz, DataForSEO, Linkody, Google Search Console and generic
|
|
112
|
+
CSV. A preset is a convenience over `--map`, never a requirement. Every rejected row is reported
|
|
113
|
+
with its line and reason. Count-per-site Search Console exports are refused, because importing
|
|
114
|
+
them would invent placements.
|
|
115
|
+
|
|
116
|
+
An existing SQLite CRM (the reference adapter in `scripts/agentlinkops.py`) adopts into ledger
|
|
117
|
+
entries read-only with `agentlinkops adopt crm.sqlite`; there is no reverse direction.
|
|
118
|
+
|
|
119
|
+
One page can be checked without a ledger or an account: `agentlinkops check --source URL
|
|
120
|
+
--target URL [--scope exact] [--json] [--out result.json]` runs the same verifier and prints one
|
|
121
|
+
result. `--out` reserves the file before the fetch and refuses to overwrite. A saved result later
|
|
122
|
+
joins a ledger with `agentlinkops adopt-result result.json [--intent wanted|expected]`, which
|
|
123
|
+
validates the artifact (no raw HTML, a checksum that must match, a state consistent with its
|
|
124
|
+
evidence) and appends the entry and observation once under a writer lock.
|
|
125
|
+
|
|
126
|
+
## Sync with the hosted service
|
|
127
|
+
|
|
128
|
+
Local commands need no account. Sync pushes expectations and pulls events and evidence
|
|
129
|
+
references into the mirror. It never writes into `links.jsonl`.
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
export AGENTLINKOPS_TOKEN=... # from Agent access > Create API key in the hosted workspace
|
|
133
|
+
agentlinkops connect --workspace WORKSPACE_ID --project-id PROJECT_ID --origin https://app.agentlinkops.com
|
|
134
|
+
agentlinkops sync --dry-run
|
|
135
|
+
agentlinkops sync
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Hosted access is an invitation-only pilot. The hosted checks, evidence store and MCP server are a
|
|
139
|
+
separate service and are not part of this package.
|
|
140
|
+
|
|
141
|
+
## Citation watches
|
|
142
|
+
|
|
143
|
+
An AI citation is a placement: a reference to your asset on a surface you do not control,
|
|
144
|
+
re-decided by the model on every ask. So the CLI watches it the way it watches a link — a prompt
|
|
145
|
+
panel, run against engine APIs on cadence, with dated evidence and honest statistics. A citation
|
|
146
|
+
is never reported as "removed": each epoch states the citation rate with a Wilson interval and
|
|
147
|
+
its sample size, classified against the previous epoch as declined, grown or
|
|
148
|
+
not_distinguishable, or `insufficient_data` when n is too thin to interpret.
|
|
149
|
+
|
|
150
|
+
```sh
|
|
151
|
+
export PERPLEXITY_API_KEY=... # live engine; omit it and the mock engine runs at $0
|
|
152
|
+
agentlinkops citation run panel.json --max-usd 1.00
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The panel is JSON: `targets` (domain or url, brand, aliases), `prompts`, `engines`
|
|
156
|
+
(`mock` or `perplexity`), optional `samples` and `maxUsd`. Every run writes immutable
|
|
157
|
+
content-addressed answer snapshots plus append-only observation and epoch rows under
|
|
158
|
+
`.agentlinkops/citations/`. The USD cap is checked before each call, so it can be approached
|
|
159
|
+
but never exceeded, and a budget abort leaves a partial-epoch receipt and exit code 2. A cell
|
|
160
|
+
that declined with complete evidence exits 1, like an expected link observed absent.
|
|
161
|
+
|
|
162
|
+
## Environment variables
|
|
163
|
+
|
|
164
|
+
| Variable | Purpose |
|
|
165
|
+
| --- | --- |
|
|
166
|
+
| `AGENTLINKOPS_TOKEN` or `AGENTLINKOPS_API_KEY` | Scoped credential for `connect`, `sync`, `tools`, `call` and the token probe in `doctor`. If both are set they must agree. |
|
|
167
|
+
| `AGENTLINKOPS_API_URL` | Hosted origin when it is not in `config.json`. |
|
|
168
|
+
| `AGENTLINKOPS_WEBHOOK_SECRET` | Verifies signed deliveries in `agentlinkops receive`. |
|
|
169
|
+
| `AGENTLINKOPS_GSC_TOKEN`, `AGENTLINKOPS_GA4_TOKEN` | First-party context commands under `agentlinkops context`. |
|
|
170
|
+
| `PERPLEXITY_API_KEY` | Live engine credential for `agentlinkops citation`. Presence is reported by `doctor`; the value is never printed. |
|
|
171
|
+
|
|
172
|
+
No value is ever printed. The `LINKTRAIL_*` spellings, the `linktrail` command and a `.linktrail/`
|
|
173
|
+
ledger directory are still accepted with a one-line notice on stderr until 2026-10-15; run
|
|
174
|
+
`agentlinkops migrate` once to rename the directory. It refuses to merge or delete anything.
|
|
175
|
+
|
|
176
|
+
## Agent plugin
|
|
177
|
+
|
|
178
|
+
The package doubles as a plugin: `.claude-plugin/plugin.json` and `.codex-plugin/plugin.json`
|
|
179
|
+
point at `skills/`, which holds the connect, CRM, campaign and asset skills plus the
|
|
180
|
+
content-pipeline skills, with templates in `templates/` and contracts in `references/`.
|
|
181
|
+
`agentlinkops agent setup` installs the same skills for any detected client, and the discovery
|
|
182
|
+
index at `https://agentlinkops.com/.well-known/agent-skills/index.json` serves them to
|
|
183
|
+
`npx skills add` (the content pipeline sits behind `https://agentlinkops.com/content`). Node.js
|
|
184
|
+
runs the ledger CLI; Python 3.10 or later runs the optional CRM helper and the watermark
|
|
185
|
+
scanner.
|
|
186
|
+
|
|
187
|
+
## What it does not do
|
|
188
|
+
|
|
189
|
+
It holds no backlink index, sends no email, verifies no contact address and runs no JavaScript:
|
|
190
|
+
observations are static HTML only, and each one says so. Literal IP addresses, reserved hostnames
|
|
191
|
+
and non-default ports are refused before any fetch.
|
|
192
|
+
|
|
193
|
+
## Documentation and source
|
|
194
|
+
|
|
195
|
+
- Guides: https://docs.agentlinkops.com/guides/first-result and https://docs.agentlinkops.com/guides/connect-cli
|
|
196
|
+
- Product: https://agentlinkops.com/
|
|
197
|
+
- Source: https://github.com/sunlover7/agentlinkops-cli
|
|
198
|
+
- Contract references shipped in this package: `references/cli.md`, `references/disavow.md`, `references/reports.md`
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
Apache License 2.0. See `LICENSE` and `NOTICE`.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Compatibility alias for the rename window: the executable is `agentlinkops`. The alias stops
|
|
3
|
+
// working on 2026-10-15. One line to stderr, then the same main; stdout stays clean for --json readers.
|
|
4
|
+
import { main } from '../cli/main.js';
|
|
5
|
+
process.stderr.write('linktrail: this command is now `agentlinkops`; the old name keeps working during the pilot compatibility window.\n');
|
|
6
|
+
process.exitCode = await main();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// A dependency-free RFC 4180 reader.
|
|
2
|
+
//
|
|
3
|
+
// Written rather than depended on because the CLI is meant to run anywhere with no install, and
|
|
4
|
+
// because the failure that matters here is silent: a parser that mishandles a quoted field
|
|
5
|
+
// containing a comma does not throw, it shifts every column right and imports nonsense.
|
|
6
|
+
export class CsvError extends Error {
|
|
7
|
+
constructor(message, line) { super(message); this.name = 'CsvError'; this.line = line; this.exitCode = 2; }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Rows of raw string cells. Quoted fields may contain commas, quotes and newlines. */
|
|
11
|
+
export function parseCsv(text, { delimiter = ',' } = {}) {
|
|
12
|
+
// A BOM at the start of a spreadsheet export becomes part of the first header name, and then
|
|
13
|
+
// the first column never matches anything. Excel writes one by default.
|
|
14
|
+
const input = text.replace(/^/u, '');
|
|
15
|
+
const rows = [];
|
|
16
|
+
let row = [], field = '', quoted = false, line = 1, started = false;
|
|
17
|
+
for (let i = 0; i < input.length; i++) {
|
|
18
|
+
const char = input[i];
|
|
19
|
+
if (quoted) {
|
|
20
|
+
if (char === '"') {
|
|
21
|
+
if (input[i + 1] === '"') { field += '"'; i++; continue; }
|
|
22
|
+
quoted = false; continue;
|
|
23
|
+
}
|
|
24
|
+
if (char === '\n') line++;
|
|
25
|
+
field += char;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (char === '"' && field === '') { quoted = true; started = true; continue; }
|
|
29
|
+
if (char === delimiter) { row.push(field); field = ''; started = false; continue; }
|
|
30
|
+
if (char === '\r') continue;
|
|
31
|
+
if (char === '\n') {
|
|
32
|
+
line++;
|
|
33
|
+
if (row.length || field !== '' || started) { row.push(field); rows.push(row); }
|
|
34
|
+
row = []; field = ''; started = false;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
field += char; started = true;
|
|
38
|
+
}
|
|
39
|
+
if (quoted) throw new CsvError('the file ends inside a quoted field', line);
|
|
40
|
+
if (row.length || field !== '' || started) { row.push(field); rows.push(row); }
|
|
41
|
+
return rows;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Normalised for matching: case, punctuation and spacing are not information here. */
|
|
45
|
+
export const normalizeHeader = name => String(name).toLowerCase().replace(/[\s_\-.()/]+/gu, '').trim();
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Header row plus objects keyed by the ORIGINAL header, so an unmapped column keeps the name
|
|
49
|
+
* the customer's tool gave it.
|
|
50
|
+
*/
|
|
51
|
+
export function readTable(text, options = {}) {
|
|
52
|
+
const rows = parseCsv(text, options);
|
|
53
|
+
if (!rows.length) throw new CsvError('the file is empty', 1);
|
|
54
|
+
const header = rows[0].map(cell => cell.trim());
|
|
55
|
+
if (!header.some(Boolean)) throw new CsvError('the first row is blank, so there are no column names', 1);
|
|
56
|
+
const records = [];
|
|
57
|
+
rows.slice(1).forEach((cells, index) => {
|
|
58
|
+
// A short or long row is a REPORT, not a silent pad or truncate: it usually means the file
|
|
59
|
+
// is not the delimiter we think it is, and padding hides that until the numbers are wrong.
|
|
60
|
+
if (cells.length !== header.length && cells.some(Boolean)) {
|
|
61
|
+
records.push({ line: index + 2, error: `${cells.length} value(s) for ${header.length} column(s)` });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!cells.some(Boolean)) return;
|
|
65
|
+
records.push({ line: index + 2, values: Object.fromEntries(header.map((name, position) => [name, cells[position] ?? ''])) });
|
|
66
|
+
});
|
|
67
|
+
return { header, records };
|
|
68
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The SQLite CRM as one adapter, not the local surface.
|
|
2
|
+
//
|
|
3
|
+
// The toolkit's six-table CRM works, is tested, and fits an agency that wants contacts and
|
|
4
|
+
// outreach activity in the same place. What it is no longer is the ONLY way to keep links
|
|
5
|
+
// locally — the ledger file is — so this reads a CRM into ledger entries and leaves the CRM
|
|
6
|
+
// untouched. **Read-only, and no new required schema**: the adapter adapts to what is there.
|
|
7
|
+
//
|
|
8
|
+
// Adopting an existing CRM is a MIGRATION rather than a discovery import, which is why it is
|
|
9
|
+
// `adopt` and not `import`. An import brings in a supplier's index of backlinks the customer has
|
|
10
|
+
// not decided about; a CRM holds decisions they already made.
|
|
11
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { normalizeEntry } from '../ledger.js';
|
|
14
|
+
|
|
15
|
+
export class CrmError extends Error {
|
|
16
|
+
constructor(message) { super(message); this.name = 'CrmError'; this.exitCode = 2; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Words that mean "stop watching this". Everything else keeps the default for its table,
|
|
20
|
+
// because `status` in this CRM is free text and refusing every unrecognised value would refuse
|
|
21
|
+
// nearly every row. What the adapter does instead is REPORT the statuses it saw.
|
|
22
|
+
const RETIRING = new Set(['rejected', 'declined', 'dead', 'lost', 'removed', 'abandoned', 'closed-lost', 'disavowed']);
|
|
23
|
+
|
|
24
|
+
const idFor = (table, value) => `lk_${createHash('sha256').update(`crm:${table}:${value}`).digest('hex').slice(0, 10)}`;
|
|
25
|
+
|
|
26
|
+
export function readCrm(path, { defaultScope = 'exact' } = {}) {
|
|
27
|
+
let db;
|
|
28
|
+
try { db = new DatabaseSync(path, { readOnly: true }); }
|
|
29
|
+
catch (error) { throw new CrmError(`could not open ${path}: ${error.message}`); }
|
|
30
|
+
try {
|
|
31
|
+
const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(row => row.name));
|
|
32
|
+
const missing = ['placements', 'opportunities'].filter(name => !tables.has(name));
|
|
33
|
+
if (missing.length === 2) throw new CrmError(`${path} has no placements or opportunities table, so it is not an AgentLinkOps CRM`);
|
|
34
|
+
|
|
35
|
+
const entries = [], unmapped = [], statuses = { placement: {}, opportunity: {} };
|
|
36
|
+
const placementSources = new Set();
|
|
37
|
+
|
|
38
|
+
const add = (table, row, intent) => {
|
|
39
|
+
try {
|
|
40
|
+
entries.push(normalizeEntry({
|
|
41
|
+
id: idFor(table, row.id), intent,
|
|
42
|
+
source: row.source_url, target: row.target_url, scope: defaultScope,
|
|
43
|
+
ref: `crm/${table}/${row.id}`,
|
|
44
|
+
...(row.notes ? { note: String(row.notes).slice(0, 200) } : {}),
|
|
45
|
+
added: (row.created_at ?? '').slice(0, 10) || undefined,
|
|
46
|
+
}, { assignId: false }));
|
|
47
|
+
} catch (error) { unmapped.push({ table, id: row.id, reason: error.message }); }
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (tables.has('placements')) {
|
|
51
|
+
for (const row of db.prepare('SELECT * FROM placements').all()) {
|
|
52
|
+
const status = String(row.status ?? '').trim().toLowerCase();
|
|
53
|
+
statuses.placement[status || '(blank)'] = (statuses.placement[status || '(blank)'] ?? 0) + 1;
|
|
54
|
+
// A placement is a link the customer believes exists, so it is EXPECTED — absence is the
|
|
55
|
+
// alarm. That is the whole difference between the two intents.
|
|
56
|
+
add('placements', row, RETIRING.has(status) ? 'retired' : 'expected');
|
|
57
|
+
if (row.opportunity_id) placementSources.add(row.opportunity_id);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (tables.has('opportunities')) {
|
|
61
|
+
for (const row of db.prepare('SELECT * FROM opportunities').all()) {
|
|
62
|
+
// An opportunity that became a placement is superseded by it; carrying both would watch
|
|
63
|
+
// one link twice and report it twice.
|
|
64
|
+
if (placementSources.has(row.id)) continue;
|
|
65
|
+
const status = String(row.status ?? '').trim().toLowerCase();
|
|
66
|
+
statuses.opportunity[status || '(blank)'] = (statuses.opportunity[status || '(blank)'] ?? 0) + 1;
|
|
67
|
+
add('opportunities', row, RETIRING.has(status) ? 'retired' : 'wanted');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
entries, unmapped, statuses,
|
|
72
|
+
// Named so an operator can see what was ASSUMED active rather than discovering it later.
|
|
73
|
+
assumed_active: {
|
|
74
|
+
placement: Object.keys(statuses.placement).filter(status => !RETIRING.has(status)),
|
|
75
|
+
opportunity: Object.keys(statuses.opportunity).filter(status => !RETIRING.has(status)),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
} finally { db?.close(); }
|
|
79
|
+
}
|