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,67 @@
1
+ // Signed DP-0004 notifications wake the existing authenticated pull; they are never facts.
2
+ import { createHmac, timingSafeEqual } from 'node:crypto';
3
+ import { readFile, appendFile, mkdir } from 'node:fs/promises';
4
+ import { dirname } from 'node:path';
5
+ import { cloudObservationRows } from './sync.js';
6
+ import { readObservations, appendObservations, writeObservations, observationKey, dedupeObservations } from './mirror.js';
7
+
8
+ export function verifyDelivery({ body, headers, secret, workspaceId, now = Date.now() }) {
9
+ if (typeof secret !== 'string' || !secret || !workspaceId) throw new Error('delivery requires secret and workspace identity');
10
+ if (typeof body !== 'string' || Buffer.byteLength(body) > 262144) throw new Error('invalid delivery body');
11
+ const h = new Headers(headers);
12
+ // DP-0029: deliveries carry AgentLinkOps-* headers, and Linktrail-* alongside them for the
13
+ // compatibility window. Either set verifies; the new one is read first.
14
+ const header = name => h.get(`AgentLinkOps-${name}`) ?? h.get(`Linktrail-${name}`);
15
+ const timestamp = header('Timestamp');
16
+ if (!/^\d+$/.test(timestamp ?? '') || Math.abs(now / 1000 - Number(timestamp)) > 300) throw new Error('expired or invalid delivery timestamp');
17
+ const expected = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest();
18
+ const valid = (header('Signature') ?? '').split(/\s+/).some(part => {
19
+ if (!/^v1=[a-f0-9]{64}$/.test(part)) return false;
20
+ return timingSafeEqual(expected, Buffer.from(part.slice(3), 'hex'));
21
+ });
22
+ if (!valid) throw new Error('invalid delivery signature');
23
+ const payload = JSON.parse(body);
24
+ if (payload.v !== 1 || payload.workspace_id !== workspaceId || !['events', 'target_events'].includes(payload.feed)
25
+ || !payload.id || !Number.isSafeInteger(payload.sequence) || payload.sequence < 1) throw new Error('invalid delivery identity');
26
+ return payload;
27
+ }
28
+
29
+ export async function appendEventHistory(path, events) {
30
+ let text = '';
31
+ try { text = await readFile(path, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
32
+ const key = event => {
33
+ if (!event.id) throw new Error('event missing stable identity');
34
+ // Event IDs have feed-specific prefixes; include explicit feed when present.
35
+ return `${event.feed ?? (event.target_id ? 'target_events' : 'events')}|${event.id}`;
36
+ };
37
+ const seen = new Set(text.split('\n').filter(line => line.trim()).map(line => key(JSON.parse(line))));
38
+ const fresh = events.filter(event => { const id = key(event); if (seen.has(id)) return false; seen.add(id); return true; });
39
+ if (fresh.length) {
40
+ await mkdir(dirname(path), { recursive: true });
41
+ await appendFile(path, (text && !text.endsWith('\n') ? '\n' : '') + fresh.map(row => JSON.stringify(row)).join('\n') + '\n');
42
+ }
43
+ return fresh.length;
44
+ }
45
+
46
+ export async function persistPulledHistory(config, events, targetEvents, index) {
47
+ const existing = await readObservations(config.paths.observations);
48
+ if (existing.problems.length) throw new Error('cannot replay into malformed observation history');
49
+ let repaired=0;
50
+ if(existing.rows.some(row=>row.source==='cloud'&&!row.projection_version)) {
51
+ let raw='';
52
+ try { raw=await readFile(config.paths.events,'utf8'); } catch(error) { if(error.code!=='ENOENT')throw error; }
53
+ const held=raw.split('\n').filter(line=>line.trim()).map(line=>JSON.parse(line));
54
+ const corrected=new Map(cloudObservationRows([...held,...events],index).map(row=>[observationKey(row),row]));
55
+ existing.rows=existing.rows.map(row=>{
56
+ const replacement=corrected.get(observationKey(row));
57
+ if(row.source==='cloud'&&!row.projection_version&&replacement){repaired++;return replacement;}
58
+ return row;
59
+ });
60
+ if(repaired)await writeObservations(config.paths.observations,existing.rows);
61
+ }
62
+ const rows = cloudObservationRows(events, index);
63
+ const { fresh, duplicates } = dedupeObservations(existing.rows, rows);
64
+ await appendObservations(config.paths.observations, fresh);
65
+ await appendEventHistory(config.paths.events, [...events, ...targetEvents]);
66
+ return { fresh, duplicates, repaired };
67
+ }
package/cli/doctor.js ADDED
@@ -0,0 +1,283 @@
1
+ // `agentlinkops doctor` — one command that says whether this installation works.
2
+ //
3
+ // Each check is one plain line with a verdict, and a failing line carries exactly one fix.
4
+ // The command deliberately runs where the ledger cannot yet be trusted: its whole job is to
5
+ // name what is broken, so it dispatches before main.js's ledger requirement and inspects every
6
+ // file itself. Nothing here repairs anything.
7
+ //
8
+ // Two boundaries worth naming. The cloud reachability probe is ANONYMOUS — no Authorization
9
+ // header reaches it, so reachability can never depend on (or leak) a credential. The token
10
+ // probe does send the token, but only to the configured origin, on the same read sync already
11
+ // makes; a token's scope lives server-side, so presence alone cannot answer the scope
12
+ // question. No secret value is ever printed, recorded or measured — not even its length.
13
+ //
14
+ // The observation mirror is deliberately not scanned: it can be megabytes a doctor run would
15
+ // re-parse for no decision, and `status`/`check` already refuse malformed rows loudly.
16
+ import { access } from 'node:fs/promises';
17
+ import assert from 'node:assert/strict';
18
+ import { loadConfig, setNoticeSink } from './config.js';
19
+ import { readLedger } from './ledger.js';
20
+ import { readReceipts } from './receipts.js';
21
+ import { readState, writeState } from './state.js';
22
+ import { createClient, CloudError } from './client.js';
23
+ import { cloudConnection } from './connection.js';
24
+ import { validatePublicUrl, parseLinks, transitionState, checkObservationKind } from '../src/verifier/index.js';
25
+ import { matchesTarget } from '../src/verifier/url.js';
26
+
27
+ const USAGE = 'agentlinkops doctor (no flags; verifies ledger, verifier, cloud and token)';
28
+
29
+ const TOKEN_SHAPE = /^lt_[0-9a-f]{64}$/u;
30
+
31
+ /**
32
+ * Runs the real verifier exports against fixed local expectations — no network, no fixtures
33
+ * directory, nothing metered. This is not a benchmark; it answers "did the verifier arrive in
34
+ * this install intact", the way `ssh -V` answers whether ssh works at all.
35
+ */
36
+ export function verifierSelfTest() {
37
+ const failures = [];
38
+ const step = (name, run) => {
39
+ try { run(); } catch (error) { failures.push(`${name}: ${error?.message ?? String(error)}`); }
40
+ };
41
+
42
+ step('url screen', () => {
43
+ const page = validatePublicUrl('https://example.com/post');
44
+ assert.ok(page.valid, `public URL refused (${page.reason})`);
45
+ const refused = validatePublicUrl('http://192.168.0.10/admin');
46
+ assert.ok(!refused.valid, 'a private IP literal passed the public screen');
47
+ assert.equal(refused.reason, 'ip_literal_not_supported');
48
+ });
49
+
50
+ const document = '<html><body><p>See the <a href="/guide" rel="nofollow ugc">outreach guide</a> for details.</p></body></html>';
51
+ let occurrences = [];
52
+ step('parser', () => {
53
+ occurrences = parseLinks(document, 'https://example.com/post');
54
+ assert.equal(occurrences.length, 1, `expected one occurrence, found ${occurrences.length}`);
55
+ assert.equal(occurrences[0].targetUrl, 'https://example.com/guide');
56
+ assert.equal(occurrences[0].anchor, 'outreach guide');
57
+ assert.deepEqual(occurrences[0].rel, ['nofollow', 'ugc']);
58
+ assert.ok(matchesTarget(occurrences[0].targetUrl, 'https://example.com/guide', 'exact'), 'exact match failed');
59
+ assert.ok(!matchesTarget(occurrences[0].targetUrl, 'https://example.com/other', 'exact'), 'a different path matched');
60
+ });
61
+
62
+ const at = Date.parse('2026-09-12T12:00:00Z');
63
+ let acquired = null;
64
+ step('reducer', () => {
65
+ const present = {
66
+ state: 'present', reason: 'link_found', sourceUrl: 'https://example.com/post',
67
+ finalUrl: 'https://example.com/post', targetUrl: 'https://example.com/guide', targetScope: 'exact',
68
+ httpStatus: 200, checkedAt: new Date(at).toISOString(), occurrences,
69
+ directives: { noindex: false, nofollow: false }, evidence: { complete: true },
70
+ };
71
+ acquired = transitionState(null, present);
72
+ assert.equal(acquired.state, 'present');
73
+ assert.equal(acquired.event?.type, 'placement_acquired', 'first present observation emitted no acquisition event');
74
+ const absent = { ...present, state: 'absent', reason: 'no_matching_link_in_complete_html', occurrences: [],
75
+ checkedAt: new Date(at + 40 * 60 * 1000).toISOString() };
76
+ const missing = transitionState(acquired, absent);
77
+ assert.equal(missing.state, 'suspected_missing', 'one absent observation confirmed loss early');
78
+ assert.ok(missing.nextCheckAt, 'suspected missing scheduled no confirmation check');
79
+ });
80
+
81
+ step('kind check', () => {
82
+ const inside = checkObservationKind({ kind: 'internal', sourceUrl: 'https://example.com/post', targetUrl: 'https://example.com/guide' }, ['example.com']);
83
+ assert.ok(inside.valid, `internal kind refused inside the declared site (${inside.reason})`);
84
+ const outside = checkObservationKind({ kind: 'internal', sourceUrl: 'https://example.com/post', targetUrl: 'https://other.com/guide' }, ['example.com']);
85
+ assert.ok(!outside.valid, 'an internal receipt outside the declared site passed');
86
+ assert.match(outside.reason, /other\.com/u, 'the refusal does not name the offending host');
87
+ });
88
+
89
+ return { ok: failures.length === 0, failures, steps: ['url screen', 'parser', 'reducer', 'kind check'] };
90
+ }
91
+
92
+ /**
93
+ * Anonymous, bounded reachability probe of the configured cloud. No credential is sent, no
94
+ * redirect is followed, and the outcome is meant to be recorded: a doctor run months later
95
+ * should be able to say when the cloud was last reachable from this machine.
96
+ */
97
+ export async function probeCloud({ origin, fetchImpl, timeoutMs = 5000 }) {
98
+ const base = String(origin).replace(/\/$/u, '');
99
+ const started = Date.now();
100
+ try {
101
+ const response = await fetchImpl(`${base}/healthz`, { method: 'GET', redirect: 'error', signal: AbortSignal.timeout(timeoutMs) });
102
+ return { reachable: true, ok: response.ok, status: response.status, ms: Date.now() - started };
103
+ } catch (error) {
104
+ const timedOut = error?.name === 'TimeoutError' || error?.name === 'AbortError';
105
+ return { reachable: false, ok: false, status: null, ms: Date.now() - started,
106
+ error: timedOut ? 'timeout' : 'network error', timedOut };
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Asks the cloud whether the configured token actually works, using the same client sync
112
+ * uses. Scope is a server-side fact, so this read (a plain watch listing, metered as none) is
113
+ * the only honest answer to "is this token good for anything here".
114
+ */
115
+ export async function probeToken({ origin, token, workspaceId, fetchImpl }) {
116
+ try {
117
+ await createClient({ origin, token, workspaceId, fetchImpl }).listWatches({ limit: 1 });
118
+ return { verified: true };
119
+ } catch (error) {
120
+ if (error instanceof CloudError) return { verified: false, code: error.code, status: error.status, scope: error.details?.scope ?? null };
121
+ return { verified: null, error: error?.name ?? 'network error' };
122
+ }
123
+ }
124
+
125
+ /** One line per check, a fix under every failure. Plain words, no symbols to look up. */
126
+ function render(checks, out) {
127
+ let ok = 0, skip = 0, fail = 0;
128
+ for (const check of checks) {
129
+ if (check.status === 'ok') ok++; else if (check.status === 'skip') skip++; else fail++;
130
+ out(`${check.status.padEnd(6)} ${check.name.padEnd(9)} — ${check.detail}`);
131
+ if (check.status === 'fail' && check.fix) out(`${''.padEnd(6)} ${'fix'.padEnd(9)} — ${check.fix}`);
132
+ }
133
+ out('');
134
+ out(fail ? `${fail} of ${checks.length} check(s) failed` : `all checks passed (${ok} ok, ${skip} skipped)`);
135
+ return fail;
136
+ }
137
+
138
+ export async function doctorMain(argv = [], { cwd = process.cwd(), out = console.log, err = console.error,
139
+ env = process.env, fetchImpl = globalThis.fetch, timeoutMs = 5000 } = {}) {
140
+ if (argv.length) { err(USAGE); return 2; }
141
+ setNoticeSink(err);
142
+
143
+ const config = await loadConfig({ cwd });
144
+ const checks = [];
145
+
146
+ const ledger = await readLedger(config.paths.ledger);
147
+ if (ledger.missing) {
148
+ checks.push({ status: 'fail', name: 'ledger', detail: `no ledger at ${config.paths.ledger}`,
149
+ fix: 'run `agentlinkops init` in the repository root' });
150
+ } else if (ledger.problems.length) {
151
+ const first = ledger.problems[0];
152
+ checks.push({ status: 'fail', name: 'ledger',
153
+ detail: `${ledger.problems.length} unreadable line(s); first at line ${first.line}: ${first.reason}`,
154
+ fix: 'repair that line in the ledger file; `agentlinkops fmt` lists every problem without writing' });
155
+ } else {
156
+ const n = ledger.entries.length;
157
+ checks.push({ status: 'ok', name: 'ledger', detail: `${n} ${n === 1 ? 'entry parses' : 'entries parse'}` });
158
+ }
159
+
160
+ // Receipts are optional until the first claim is written; unreadable once written is fatal
161
+ // to every receipt verb, so doctor names it the same way the verbs will.
162
+ const hasReceipts = await access(config.paths.receipts).then(() => true, () => false);
163
+ if (!hasReceipts) {
164
+ checks.push({ status: 'skip', name: 'receipts', detail: 'none written yet' });
165
+ } else {
166
+ try {
167
+ const receipts = await readReceipts(config.paths.receipts);
168
+ checks.push({ status: 'ok', name: 'receipts', detail: `${receipts.length} ${receipts.length === 1 ? 'claim parses' : 'claims parse'}` });
169
+ } catch (error) {
170
+ checks.push({ status: 'fail', name: 'receipts', detail: error.message,
171
+ fix: 'repair or remove the named line in receipts.jsonl; a claim that cannot parse was never verified' });
172
+ }
173
+ }
174
+
175
+ let state = null;
176
+ try {
177
+ state = await readState(config.paths.state);
178
+ checks.push({ status: 'ok', name: 'state', detail: `v${state.v}, ${Object.keys(state.entries).length} entr${Object.keys(state.entries).length === 1 ? 'y' : 'ies'} tracked` });
179
+ } catch (error) {
180
+ checks.push({ status: 'fail', name: 'state', detail: `state.json is not readable: ${error.message}`,
181
+ fix: `restore ${config.dirName}/state.json from version control (cursors and first-present dates live there)` });
182
+ }
183
+
184
+ const selfTest = verifierSelfTest();
185
+ checks.push(selfTest.ok
186
+ ? { status: 'ok', name: 'verifier', detail: `self-test passed (${selfTest.steps.join(', ')})` }
187
+ : { status: 'fail', name: 'verifier', detail: `self-test failed: ${selfTest.failures[0]}`,
188
+ fix: 'the verifier modules are broken in this install; re-install or check local modifications under src/verifier/' });
189
+
190
+ // Cloud reachability: anonymous, bounded, recorded. A repository with no cloud configured is
191
+ // a legitimate local-only install, reported as a skip rather than a failure.
192
+ const connection = cloudConnection(config, env);
193
+ const origin = connection.origin || null;
194
+ const record = { at: new Date().toISOString() };
195
+ let probed = false;
196
+ if (!origin) {
197
+ checks.push({ status: 'skip', name: 'cloud', detail: 'no cloud configured (local-only repository)' });
198
+ } else {
199
+ const cloud = await probeCloud({ origin, fetchImpl, timeoutMs });
200
+ record.cloud = { ok: cloud.ok, status: cloud.status, ms: cloud.ms };
201
+ if (!cloud.reachable) {
202
+ checks.push({ status: 'fail', name: 'cloud',
203
+ detail: `cannot reach ${origin} (${cloud.error}${cloud.timedOut ? ` after ${timeoutMs} ms` : ''})`,
204
+ fix: `check the network and cloud.origin in ${config.dirName}/config.json` });
205
+ } else if (!cloud.ok) {
206
+ checks.push({ status: 'fail', name: 'cloud', detail: `${origin} answered HTTP ${cloud.status}`,
207
+ fix: 'the cloud is reachable but unhealthy; check its deployment before syncing' });
208
+ } else {
209
+ checks.push({ status: 'ok', name: 'cloud', detail: `${origin} answered ${cloud.status} in ${cloud.ms} ms` });
210
+ }
211
+ probed = true;
212
+ }
213
+
214
+ // Token: presence, shape, and — only when the cloud just proved reachable — whether the
215
+ // cloud accepts it. The value itself is never printed, hashed or measured here.
216
+ const token = connection.token || null;
217
+ const source = connection.tokenSource ? `the environment (${connection.tokenSource})` : `${config.dirName}/config.json (cloud.token)`;
218
+ if (!token) {
219
+ if (origin) {
220
+ checks.push({ status: 'fail', name: 'token', detail: 'no token is set; sync and receive need one',
221
+ fix: 'run agentlinkops connect for the app API-key setup path; supply AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY in the environment' });
222
+ } else {
223
+ checks.push({ status: 'skip', name: 'token', detail: 'no token (and no cloud configured)' });
224
+ }
225
+ } else if (!TOKEN_SHAPE.test(token)) {
226
+ checks.push({ status: 'fail', name: 'token', detail: `set in ${source} but it does not look like an AgentLinkOps API key (lt_ plus 64 hex characters)`,
227
+ fix: 'copy the full key without truncation or line breaks; the key itself is never printed here' });
228
+ } else if (!origin) {
229
+ checks.push({ status: 'skip', name: 'token', detail: `set in ${source}; no cloud configured to verify against` });
230
+ } else {
231
+ const cloudLine = checks.find(check => check.name === 'cloud');
232
+ if (cloudLine?.status === 'ok') {
233
+ const probed = await probeToken({ origin, token, workspaceId: config.cloud?.workspaceId, fetchImpl });
234
+ if (probed.verified) {
235
+ record.token = { ok: true };
236
+ checks.push({ status: 'ok', name: 'token', detail: `set in ${source}; the cloud accepted it (watches:read works)` });
237
+ } else if (probed.verified === false) {
238
+ record.token = { ok: false, code: probed.code };
239
+ const fix = probed.code === 'INSUFFICIENT_SCOPE' ? `issue a key carrying ${probed.scope ?? 'watches:read'}`
240
+ : probed.code === 'WORKSPACE_DENIED' ? `check cloud.workspaceId in ${config.dirName}/config.json against the workspace the key belongs to`
241
+ : probed.code === 'UNAUTHORIZED' ? 'issue a fresh key (this one is invalid or expired) and update where it is set'
242
+ : 'sync will fail the same way; act on the code above';
243
+ const detail = probed.code === 'INSUFFICIENT_SCOPE' ? `the cloud accepted it but scope ${probed.scope ?? 'watches:read'} is missing`
244
+ : probed.code === 'WORKSPACE_DENIED' ? 'the credential belongs to a different workspace'
245
+ : probed.code === 'UNAUTHORIZED' ? 'the cloud rejected it (invalid or expired)'
246
+ : `the cloud answered ${probed.code}${probed.status ? ` (HTTP ${probed.status})` : ''}`;
247
+ checks.push({ status: 'fail', name: 'token', detail, fix });
248
+ } else {
249
+ checks.push({ status: 'skip', name: 'token', detail: `set in ${source}; not verified (${probed.error})` });
250
+ }
251
+ } else {
252
+ checks.push({ status: 'skip', name: 'token', detail: `set in ${source}; not verified (cloud unreachable)` });
253
+ }
254
+ }
255
+ // Citation engines: presence only, never the value. A missing credential is a skip,
256
+ // not a failure — the mock engine runs everything, and a repository that never set a
257
+ // live key has not broken anything it asked for.
258
+ if (env.PERPLEXITY_API_KEY) {
259
+ checks.push({ status: 'ok', name: 'citations', detail: 'PERPLEXITY_API_KEY is set; live citation runs available' });
260
+ } else {
261
+ checks.push({ status: 'skip', name: 'citations', detail: 'no engine credential set; mock citation runs work, live runs need PERPLEXITY_API_KEY' });
262
+ }
263
+
264
+ out(`agentlinkops doctor — ${config.dir}`);
265
+
266
+ // Record the probe outcomes beside the cursors they protect — BEFORE rendering, so the
267
+ // "(recorded in state.json)" note on the cloud line is a fact rather than an intention. A
268
+ // state file that cannot be read was already reported above; the note then says so instead
269
+ // of failing doctor twice.
270
+ if (probed) {
271
+ const cloudLine = checks.find(check => check.name === 'cloud');
272
+ try {
273
+ const current = state ?? await readState(config.paths.state);
274
+ await writeState(config.paths.state, { ...current, doctor: record });
275
+ if (cloudLine) cloudLine.detail += ' (recorded in state.json)';
276
+ } catch (error) {
277
+ if (cloudLine) cloudLine.detail += ` (result NOT recorded: ${error.message})`;
278
+ }
279
+ }
280
+
281
+ const failed = render(checks, out);
282
+ return failed ? 1 : 0;
283
+ }
package/cli/env.js ADDED
@@ -0,0 +1,66 @@
1
+ // Environment variable names, and the compatibility window between the two sets of them.
2
+ //
3
+ // The product is AgentLinkOps. "Linktrail" was a working name, and the private pilot's installed
4
+ // CLI and LaunchAgent still export `LINKTRAIL_*` (DP-0029 inventory, 2026-09-15). So every
5
+ // variable has exactly two spellings here: the AgentLinkOps name is preferred, the Linktrail name
6
+ // is an alias, and the rules are the same for every pair:
7
+ //
8
+ // - only the new name set -> used, silently
9
+ // - only the old name set -> used, with ONE warning per process naming the new variable
10
+ // - both set, same value -> used, silently (an operator mid-migration exports both)
11
+ // - both set, different values -> ConfigError, the same conflict rule that already refuses a
12
+ // TOKEN that disagrees with an API_KEY. Guessing between two
13
+ // credentials is how a sync lands in the wrong workspace.
14
+ //
15
+ // No value is ever printed: the warning and the error name variables, never contents.
16
+ import { ConfigError, noticeOnce, resetCompatibilityNotices, setNoticeSink } from './config.js';
17
+
18
+ export { resetCompatibilityNotices, setNoticeSink };
19
+
20
+ export const ENV_PREFIX = 'AGENTLINKOPS';
21
+ export const LEGACY_ENV_PREFIX = 'LINKTRAIL';
22
+
23
+ /** Every suffix the CLI reads from the environment. */
24
+ export const ENV_NAMES = Object.freeze(['TOKEN', 'API_KEY', 'API_URL', 'WEBHOOK_SECRET', 'GSC_TOKEN', 'GA4_TOKEN']);
25
+
26
+ export const envName = suffix => `${ENV_PREFIX}_${suffix}`;
27
+ export const legacyEnvName = suffix => `${LEGACY_ENV_PREFIX}_${suffix}`;
28
+
29
+ export function deprecationNotice(oldName, newName) {
30
+ return `${oldName} is deprecated; set ${newName} instead. The old name keeps working during the pilot compatibility window.`;
31
+ }
32
+
33
+ /**
34
+ * Reads one variable under both names. Returns `{ value, name }` where `name` is the variable
35
+ * the value actually came from (or null), so a caller can say "set in the environment (X)"
36
+ * truthfully.
37
+ */
38
+ export function readEnv(env, suffix, { warn = null } = {}) {
39
+ const preferred = envName(suffix), legacy = legacyEnvName(suffix);
40
+ const fresh = env[preferred], old = env[legacy];
41
+ if (fresh !== undefined && fresh !== '' && old !== undefined && old !== '' && fresh !== old)
42
+ throw new ConfigError(`${preferred} and ${legacy} disagree; select one value.`);
43
+ if (fresh !== undefined && fresh !== '') return { value: fresh, name: preferred };
44
+ if (old !== undefined && old !== '') {
45
+ // Once per process, per variable, so a command that resolves the environment in three
46
+ // places (config, doctor, context) warns once rather than three times.
47
+ if (warn) warn(deprecationNotice(legacy, preferred));
48
+ else noticeOnce(`legacy-env:${legacy}`, deprecationNotice(legacy, preferred));
49
+ return { value: old, name: legacy };
50
+ }
51
+ return { value: undefined, name: null };
52
+ }
53
+
54
+ /**
55
+ * Every variable the CLI reads, resolved through the alias rule. `values` is keyed by suffix
56
+ * (`TOKEN`, `API_KEY`, …); `sources` says which spelling supplied each one.
57
+ */
58
+ export function resolveEnv(env = process.env, options = {}) {
59
+ const values = {}, sources = {};
60
+ for (const suffix of ENV_NAMES) {
61
+ const { value, name } = readEnv(env, suffix, options);
62
+ values[suffix] = value;
63
+ sources[suffix] = name;
64
+ }
65
+ return { values, sources };
66
+ }
package/cli/fleet.js ADDED
@@ -0,0 +1,82 @@
1
+ // `agentlinkops fleet`: one summary over several project ledgers, each read on its own.
2
+ //
3
+ // The fleet question is scale ("how much intent does the whole operation hold?"), and the one
4
+ // rule that makes the command safe is that it NEVER merges intent across repos. A platform
5
+ // that is `expected` for one site and merely `wanted` for another is two entries in two files,
6
+ // and a summary that deduplicated them would silently pick one. So every number below is
7
+ // per project, the only cross-project arithmetic is a sum of counts, and the output says so.
8
+ //
9
+ // "Earned" follows the ledger contract, not a status column: an entry is earned when its
10
+ // latest OBSERVATION says present. A project with no observation file has earned nothing and
11
+ // is reported as never checked rather than as zero links.
12
+ import { readLedger } from './ledger.js';
13
+ import { readObservations, latestByEntry } from './mirror.js';
14
+
15
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
16
+
17
+ /** One project's row. Everything is computed inside the project; nothing crosses the boundary. */
18
+ export async function fleetProject(name, ledgerPath, observationsPath) {
19
+ const ledger = await readLedger(ledgerPath);
20
+ if (ledger.missing) { const error = new Error(`no ledger at ${ledgerPath}`); error.code = 'NO_LEDGER'; throw error; }
21
+ const byIntent = { wanted: 0, expected: 0, retired: 0 };
22
+ for (const entry of ledger.entries) byIntent[entry.intent] = (byIntent[entry.intent] ?? 0) + 1;
23
+
24
+ const { rows } = observationsPath ? await readObservations(observationsPath) : { rows: [] };
25
+ const latest = latestByEntry(rows);
26
+ let earned = 0, observed = 0;
27
+ for (const [id, row] of latest) {
28
+ if (!ledger.entries.some(entry => entry.id === id)) continue; // observations from another ledger's ids
29
+ observed++;
30
+ if (row.state === 'present') earned++;
31
+ }
32
+ const lastCheck = rows.length ? rows.map(row => row.checked_at).sort().at(-1) : null;
33
+ return {
34
+ project: name, ledger: ledgerPath,
35
+ entries: ledger.entries.length, ...byIntent,
36
+ problems: ledger.problems.length,
37
+ observed_entries: observed, earned,
38
+ never_checked: ledger.entries.filter(entry => entry.intent !== 'retired').length - observed,
39
+ last_observation: lastCheck,
40
+ // Due volume, the same arithmetic the dogfood records use to price a fleet.
41
+ implied_weekly_checks: byIntent.wanted + byIntent.expected * 7,
42
+ };
43
+ }
44
+
45
+ /** Summons the fleet. Same numbers per project, one sum of counts, no merged intent. */
46
+ export async function fleetSummary(projects, { observationsOf = {} } = {}) {
47
+ const rows = [];
48
+ for (const { name, ledger } of projects) rows.push(await fleetProject(name, ledger, observationsOf[name] ?? null));
49
+ const totals = rows.reduce((acc, row) => {
50
+ for (const key of ['entries', 'wanted', 'expected', 'retired', 'earned', 'observed_entries', 'never_checked', 'implied_weekly_checks']) {
51
+ acc[key] = (acc[key] ?? 0) + row[key];
52
+ }
53
+ return acc;
54
+ }, {});
55
+ return {
56
+ v: 1, projects: rows, totals,
57
+ // The sum answers "how big is the operation". It is not a merged view: the same platform
58
+ // counted in two projects is two intents, and only a human can say which one they meant.
59
+ intent_merged_across_repos: false,
60
+ };
61
+ }
62
+
63
+ export function renderFleet(summary, out = console.log) {
64
+ out(`${plural(summary.projects.length, 'project')}; counts summed for scale only — intent is never merged across repos`);
65
+ out('');
66
+ out('project entries wanted expected retired earned never-checked last-observation');
67
+ for (const row of summary.projects) {
68
+ out(`${row.project.padEnd(14)} ${String(row.entries).padStart(7)} ${String(row.wanted).padStart(7)} `
69
+ + `${String(row.expected).padStart(9)} ${String(row.retired).padStart(8)} ${String(row.earned).padStart(7)} `
70
+ + `${String(row.never_checked).padStart(13)} ${row.last_observation?.slice(0, 10) ?? 'never checked'}`);
71
+ }
72
+ const t = summary.totals;
73
+ out(`${'TOTAL'.padEnd(14)} ${String(t.entries).padStart(7)} ${String(t.wanted).padStart(7)} `
74
+ + `${String(t.expected).padStart(9)} ${String(t.retired).padStart(8)} ${String(t.earned).padStart(7)} `
75
+ + `${String(t.never_checked).padStart(13)} (sum of counts)`);
76
+ out('');
77
+ out(`implied weekly check volume across the fleet: ${t.implied_weekly_checks}`);
78
+ if (summary.projects.some(row => row.problems)) {
79
+ out(`ledger problems: ${summary.projects.map(row => `${row.project}=${row.problems}`).filter(s => !s.endsWith('=0')).join(', ')}`);
80
+ }
81
+ return 0;
82
+ }