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
package/cli/main.js
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import { commandsMain } from './commands.js';
|
|
2
|
+
import { agentMain } from './agent-setup.js';
|
|
3
|
+
import { setupPlanMain } from './setup-plan.js';
|
|
4
|
+
import {parseArgs as parseArguments} from './args.js';
|
|
5
|
+
// The `agentlinkops` command surface (`linktrail` remains an alias for the pilot compatibility
|
|
6
|
+
// window). Local commands need no account and no network beyond the
|
|
7
|
+
// pages being checked.
|
|
8
|
+
import { readFile, writeFile, mkdir, appendFile, open, unlink, rename, readdir, stat } from 'node:fs/promises';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
import { loadConfig, ConfigError, DEFAULTS, LEGACY_DIR, findRoot, resolveDirName, setNoticeSink } from './config.js';
|
|
11
|
+
import { readEnv } from './env.js';
|
|
12
|
+
import { readLedger, mutateLedger, normalizeEntry, newId, serializeLedger } from './ledger.js';
|
|
13
|
+
import { readObservations, appendObservations, writeObservations, compactionPlan, dedupeObservations } from './mirror.js';
|
|
14
|
+
import { readState, writeState, lastCheckedMap, selectChanged, applyRun } from './state.js';
|
|
15
|
+
import { runCheck, selectEntries, dueEntries, summarize } from './check.js';
|
|
16
|
+
import { checkToFile, adoptLocalResult } from './local-result.js';
|
|
17
|
+
import { disagreements, transitions } from './status.js';
|
|
18
|
+
import { readImport, parseMap } from './import.js';
|
|
19
|
+
import { freezeDataset, renderReport, datasetDigest } from './report.js';
|
|
20
|
+
import { createClient, CloudError } from './client.js';
|
|
21
|
+
import { cloudConnection, connectMain, keySetup } from './connection.js';
|
|
22
|
+
import { syncPlan, pushExpectations, pullEvents, cloudObservationRows, watchIndex, pullTargetEvents } from './sync.js';
|
|
23
|
+
import { SUPPLIER_NAMES } from './adapters/suppliers.js';
|
|
24
|
+
import { fleetSummary, renderFleet } from './fleet.js';
|
|
25
|
+
import { platformAggregate, classifyObservation, renderPlatforms, attachToCandidates, PLATFORM_CLASSES } from './platforms.js';
|
|
26
|
+
import { contextMain } from './context.js';
|
|
27
|
+
import { verifyDelivery, persistPulledHistory } from './delivery.js';
|
|
28
|
+
import { writeReceipt, readReceipts, receiptHistory } from './receipts.js';
|
|
29
|
+
import { attachReceiptPerformance } from './receipt-performance.js';
|
|
30
|
+
import { contextPaths, loadContextConfig, readGscRows } from '../src/context/gsc.js';
|
|
31
|
+
import { readGa4Rows } from '../src/context/ga4.js';
|
|
32
|
+
import { mixMain } from './mix.js';
|
|
33
|
+
import { doctorMain } from './doctor.js';
|
|
34
|
+
import { gscLinksMain } from './gsc-browser.js';
|
|
35
|
+
import { skillMain, NUDGE } from './skill.js';
|
|
36
|
+
import { citationMain } from './citation.js';
|
|
37
|
+
|
|
38
|
+
const USAGE = `agentlinkops — a backlink ledger that lives in your repository
|
|
39
|
+
|
|
40
|
+
agentlinkops skill [--url] print the agent reference; read it in full once per session
|
|
41
|
+
before the first AgentLinkOps call
|
|
42
|
+
agentlinkops init create .agentlinkops/ here
|
|
43
|
+
agentlinkops migrate rename an existing .linktrail/ to .agentlinkops/ (receipt; never merges)
|
|
44
|
+
agentlinkops setup --plan --goal verify-links|prepare-campaign|build-content
|
|
45
|
+
[--mode local|hosted|external] read-only setup plan; no account required
|
|
46
|
+
agentlinkops add --source URL --target URL [--intent wanted|expected] [--scope …]
|
|
47
|
+
[--anchor TEXT] [--rel a,b] [--ref TEXT] [--tag t --tag t] [--note TEXT]
|
|
48
|
+
agentlinkops import FILE --target DOMAIN [--from SUPPLIER] [--map source=COL,target=COL]
|
|
49
|
+
[--exact-url] [--no-subdomains] [--generated-at ISO] [--json]
|
|
50
|
+
agentlinkops adopt CRM.sqlite [--scope exact|domain] [--write]
|
|
51
|
+
agentlinkops fleet --project NAME=LEDGER [--project …] [--observations-of NAME=FILE] [--json]
|
|
52
|
+
agentlinkops platforms [HOST] [--observations FILE]… [--benchmark FILE]… [--seed FILE]…
|
|
53
|
+
[--for-candidates FILE] [--json]
|
|
54
|
+
agentlinkops context COMMAND… first-party search context and site profile
|
|
55
|
+
(status/manual/focus/gsc/profile; no account needed —
|
|
56
|
+
run "agentlinkops context help" for every subcommand)
|
|
57
|
+
agentlinkops gsc-links --property PROPERTY --session SESSION --out FILE.csv
|
|
58
|
+
export GSC links through your signed-in browser
|
|
59
|
+
agentlinkops mix [--lane-file FILE]… [--save FILE] [--against FILE] [--json]
|
|
60
|
+
referring-domain mix over this ledger (no account)
|
|
61
|
+
agentlinkops citation run PANEL.json [--dir DIR] [--samples N] [--max-usd USD] [--json]
|
|
62
|
+
one fixed-n AI-citation epoch; evidence under
|
|
63
|
+
.agentlinkops/citations/; live engines need
|
|
64
|
+
PERPLEXITY_API_KEY, the mock engine costs nothing
|
|
65
|
+
agentlinkops receive --body FILE --headers FILE signed notification; authenticated pull
|
|
66
|
+
agentlinkops connect --workspace ID --project-id ID [--origin URL] [--selection FILE]
|
|
67
|
+
agentlinkops tools [TOOLSET] [--json] [--refresh] cloud commands by toolset (offline snapshot;
|
|
68
|
+
--refresh reads the live catalog)
|
|
69
|
+
agentlinkops describe NAME [--examples] [--output-schema] [--json] one command's exact schema
|
|
70
|
+
agentlinkops call NAME [--args JSON | --file FILE | --set PATH=VALUE ...] [--dry-run] [-y]
|
|
71
|
+
run a cloud command (retired names still resolve)
|
|
72
|
+
agentlinkops agent setup [--client ID ...] [--scope project|user] [--origin URL] [--dry-run]
|
|
73
|
+
install the skill pack and the right MCP view for each
|
|
74
|
+
detected agent client (Claude Code, Codex, Cursor,
|
|
75
|
+
Gemini CLI, Hermes); stores no credential
|
|
76
|
+
agentlinkops agent status what is installed where
|
|
77
|
+
agentlinkops sync [--push-only] [--pull-only] [--dry-run] [--include-wanted]
|
|
78
|
+
AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY
|
|
79
|
+
agentlinkops check [--filter TEXT] [--all] [--json] [--fail-on-unknown]
|
|
80
|
+
agentlinkops doctor verify ledger, verifier, cloud reachability, token
|
|
81
|
+
agentlinkops check --source URL --target URL [--scope exact] [--json] [--out FILE]
|
|
82
|
+
one local result; no ledger or account
|
|
83
|
+
agentlinkops adopt-result FILE [--intent wanted|expected] save result to local ledger
|
|
84
|
+
agentlinkops receipt add --file CLAIM.json project.site declares public site hosts
|
|
85
|
+
agentlinkops receipt history [--performance FILE.json] claims, history and optional dated context
|
|
86
|
+
agentlinkops status [--json]
|
|
87
|
+
agentlinkops diff [--json]
|
|
88
|
+
agentlinkops report [--out FILE] [--title T] [--brand B] [--as-of ISO]
|
|
89
|
+
[--include-notes] [--include-retired] [--digest-only]
|
|
90
|
+
agentlinkops fmt
|
|
91
|
+
agentlinkops compact [--apply]
|
|
92
|
+
|
|
93
|
+
Suppliers: ${SUPPLIER_NAMES.join(', ')}
|
|
94
|
+
A preset is a convenience over --map, never a requirement.
|
|
95
|
+
|
|
96
|
+
Exit codes: 0 every expected link present · 1 an expected link observed absent with complete
|
|
97
|
+
evidence · 2 usage, configuration or ledger error. An unknown never fails.
|
|
98
|
+
|
|
99
|
+
Names: \`linktrail\` still runs this CLI, LINKTRAIL_* variables are still read, and an existing
|
|
100
|
+
.linktrail/ directory is still used, each with a one-line notice, until the DP-0029 cutover.`;
|
|
101
|
+
|
|
102
|
+
const parseArgs = argv => parseArguments(argv,["tag", "project", "observations-of", "observations", "benchmark", "seed"],{tag:[]});
|
|
103
|
+
|
|
104
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
105
|
+
|
|
106
|
+
const readJsonl = async path => (await readFile(path, 'utf8')).split('\n').filter(line => line.trim()).map(line => JSON.parse(line));
|
|
107
|
+
|
|
108
|
+
function reportProblems(problems, what, out) {
|
|
109
|
+
if (!problems.length) return;
|
|
110
|
+
out(`\n${problems.length} ${what} could not be read, and none of them was dropped silently:`);
|
|
111
|
+
for (const problem of problems.slice(0, 20)) out(` line ${problem.line}${problem.id ? ` (${problem.id})` : ''}: ${problem.reason}`);
|
|
112
|
+
if (problems.length > 20) out(` … and ${problems.length - 20} more`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function main(argv = process.argv.slice(2), { cwd = process.cwd(), out = console.log, err = console.error } = {}) {
|
|
116
|
+
if (argv[0] === 'gsc-links') return await gscLinksMain(argv, { cwd, out });
|
|
117
|
+
// Citation watches keep their own files under .agentlinkops/citations/ and never
|
|
118
|
+
// touch the link ledger, so they dispatch before its requirement like context does.
|
|
119
|
+
if (argv[0] === 'citation') return await citationMain(argv.slice(1), { cwd, out, err });
|
|
120
|
+
const args = parseArgs(argv);
|
|
121
|
+
const command = args._[0] ?? 'help';
|
|
122
|
+
if (command === 'help' || args.help) { out(USAGE); return 0; }
|
|
123
|
+
// Compatibility notices (old variable names, old directory name) share the error stream, so a
|
|
124
|
+
// `--json` reader on stdout never sees them.
|
|
125
|
+
setNoticeSink(err);
|
|
126
|
+
|
|
127
|
+
let syncLock = null;
|
|
128
|
+
try {
|
|
129
|
+
if (command === 'setup') return await setupPlanMain(argv, { cwd, out });
|
|
130
|
+
if (command === 'tools' || command === 'describe' || command === 'call') return await commandsMain(argv, { cwd, out, err });
|
|
131
|
+
if (command === 'skill') return await skillMain(argv, { out });
|
|
132
|
+
if (command === 'agent') return await agentMain(argv, { cwd, out, err });
|
|
133
|
+
if (command === 'connect') {
|
|
134
|
+
if (args._.length !== 1 || args.tag.length || Object.keys(args).some(key => !['_', 'tag', 'origin', 'workspace', 'project-id', 'selection'].includes(key))) throw new ConfigError('connect --workspace ID --project-id ID [--origin URL] [--selection FILE]');
|
|
135
|
+
return await connectMain(args, { cwd, out });
|
|
136
|
+
}
|
|
137
|
+
if (command === 'check' && (args.source !== undefined || args.target !== undefined)) {
|
|
138
|
+
if (typeof args.source !== 'string' || typeof args.target !== 'string') throw new ConfigError('check requires both --source URL and --target URL');
|
|
139
|
+
const allowed = new Set(['_', 'source', 'target', 'scope', 'json', 'out']);
|
|
140
|
+
if (Object.keys(args).some(key => key !== 'tag' && !allowed.has(key)) || args.tag.length || args._.length !== 1) throw new ConfigError('unsupported option for a single local check');
|
|
141
|
+
if (args.out !== undefined && (typeof args.out !== 'string' || !args.out.trim())) throw new ConfigError('--out requires a file path');
|
|
142
|
+
const result = await checkToFile({ source: args.source, target: args.target, scope: args.scope }, args.out ? resolve(cwd, args.out) : null);
|
|
143
|
+
out(args.json ? JSON.stringify(result, null, 2) : `${result.observation.state} ${result.observation.reason} ${result.checked_at}${args.out ? ` saved ${args.out}` : ''}`);
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
if (command === 'init') {
|
|
147
|
+
// A repository that already has the old directory gets a migrate, not a second directory:
|
|
148
|
+
// creating `.agentlinkops/` beside `.linktrail/` would shadow the existing ledger.
|
|
149
|
+
const existing = await resolveDirName(cwd);
|
|
150
|
+
if (existing.legacy) throw new ConfigError(`${join(cwd, LEGACY_DIR)} already exists; run \`agentlinkops migrate\` to rename it to ${DEFAULTS.dir}/ instead of creating a second directory.`);
|
|
151
|
+
const config = await loadConfig({ cwd, root: cwd });
|
|
152
|
+
await mkdir(config.dir, { recursive: true });
|
|
153
|
+
await writeFile(`${config.dir}/config.json`, `${JSON.stringify({ project: null, cloud: null, paths: {}, defaults: {} }, null, 2)}\n`, 'utf8');
|
|
154
|
+
await mkdir(dirname(config.paths.ledger), { recursive: true });
|
|
155
|
+
await writeFile(config.paths.ledger, '', { flag: 'wx' }).catch(() => {});
|
|
156
|
+
out(`created ${config.dir}`);
|
|
157
|
+
out(NUDGE);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (command === 'migrate') {
|
|
162
|
+
// Renames `.linktrail/` to `.agentlinkops/`, and does nothing else: no merge, no delete, no
|
|
163
|
+
// rewrite of the files inside. A customer's repository is not ours to reorganise beyond the
|
|
164
|
+
// one move they asked for, and the receipt names what moved so the commit can say so too.
|
|
165
|
+
if (args._.length !== 1 || Object.keys(args).some(key => !['_', 'tag', 'json'].includes(key)) || args.tag.length) throw new ConfigError('migrate [--json]');
|
|
166
|
+
const root = await findRoot(cwd, LEGACY_DIR) ?? cwd;
|
|
167
|
+
const from = join(root, LEGACY_DIR), to = join(root, DEFAULTS.dir);
|
|
168
|
+
const isDir = async path => stat(path).then(info => info.isDirectory(), () => false);
|
|
169
|
+
if (!await isDir(from)) {
|
|
170
|
+
if (await isDir(to)) { out(args.json ? JSON.stringify({ migrated: false, reason: 'already_migrated', dir: to }) : `nothing to migrate: ${to} is already in use`); return 0; }
|
|
171
|
+
throw new ConfigError(`no ${LEGACY_DIR}/ directory found at or above ${cwd}; nothing to migrate.`);
|
|
172
|
+
}
|
|
173
|
+
if (await isDir(to)) throw new ConfigError(`${to} already exists; nothing was moved. Reconcile the two directories by hand — migrate never merges.`);
|
|
174
|
+
if (await stat(join(from, 'sync.lock')).then(() => true, () => false)) throw new ConfigError(`${from} is locked by a running sync; retry after it finishes.`);
|
|
175
|
+
const files = (await readdir(from, { recursive: true })).filter(name => !/(^|\/)\._/u.test(name)).sort();
|
|
176
|
+
await rename(from, to);
|
|
177
|
+
const receipt = { migrated: true, from, to, files, at: new Date().toISOString(),
|
|
178
|
+
next: `commit the rename; update .gitignore, CI and any script that names ${LEGACY_DIR}/` };
|
|
179
|
+
if (args.json) { out(JSON.stringify(receipt, null, 2)); return 0; }
|
|
180
|
+
out(`moved ${from} -> ${to} (${plural(files.length, 'file')}; nothing merged or deleted)`);
|
|
181
|
+
for (const file of files.slice(0, 20)) out(` ${file}`);
|
|
182
|
+
if (files.length > 20) out(` … and ${files.length - 20} more`);
|
|
183
|
+
out(receipt.next);
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (command === 'import') {
|
|
188
|
+
const path = args._[1];
|
|
189
|
+
if (!path || !args.target) { err('agentlinkops import FILE --target DOMAIN'); return 2; }
|
|
190
|
+
const result = await readImport(path, {
|
|
191
|
+
supplier: args.from === true ? 'csv' : (args.from ?? 'csv'),
|
|
192
|
+
map: parseMap(args.map), target: args.target,
|
|
193
|
+
targetKind: args['exact-url'] ? 'exact_url' : 'domain',
|
|
194
|
+
includeSubdomains: !args['no-subdomains'],
|
|
195
|
+
supplierGeneratedAt: args['generated-at'] === true ? null : (args['generated-at'] ?? null),
|
|
196
|
+
});
|
|
197
|
+
if (result.error) { err(result.error); return 2; }
|
|
198
|
+
if (args.json) { out(JSON.stringify(result.accepted.map(candidate => JSON.stringify(candidate)).join('\n'))); return 0; }
|
|
199
|
+
out(`mapped ${Object.entries(result.mapping).map(([field, column]) => `${field}="${column}"`).join(', ')}`);
|
|
200
|
+
if (result.unclaimed.length) out(`kept as supplier metrics: ${result.unclaimed.join(', ')}`);
|
|
201
|
+
out(`\n${JSON.stringify(result.counts)}`);
|
|
202
|
+
// One reason accounting for nearly every row means the MAPPING is wrong, not the data,
|
|
203
|
+
// and saying so beats making the reader infer it from two thousand identical lines.
|
|
204
|
+
if (result.counts.read && result.counts.accepted === 0) {
|
|
205
|
+
out(`\nNOTHING imported. When every row fails the same way the column mapping is usually wrong, not the file.`);
|
|
206
|
+
out(` columns present: ${result.header.join(' | ')}`);
|
|
207
|
+
out(` name them explicitly with --map source=<column>,target=<column>`);
|
|
208
|
+
}
|
|
209
|
+
for (const group of result.reasons.slice(0, 10)) out(` ${String(group.count).padStart(6)} ${group.reason} (first at row ${group.example_row})`);
|
|
210
|
+
if (result.reasons.length > 10) out(` … and ${result.reasons.length - 10} more kinds`);
|
|
211
|
+
// Writing candidates into the repository mirror is `sync`'s job (T05): a preview that
|
|
212
|
+
// wrote files would make a dry run indistinguishable from a real one.
|
|
213
|
+
out('\npreview only — nothing was written');
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (command === 'adopt') {
|
|
218
|
+
const path = args._[1];
|
|
219
|
+
if (!path) { err('agentlinkops adopt CRM.sqlite'); return 2; }
|
|
220
|
+
// node:sqlite is loaded only here: importing it prints Node's experimental warning on stderr,
|
|
221
|
+
// and every other command (including the ones an agent pipes) must stay quiet.
|
|
222
|
+
const { readCrm } = await import('./adapters/sqlite-crm.js');
|
|
223
|
+
const result = readCrm(path, { defaultScope: args.scope === true ? 'exact' : (args.scope ?? 'exact') });
|
|
224
|
+
const byIntent = result.entries.reduce((acc, entry) => { acc[entry.intent] = (acc[entry.intent] ?? 0) + 1; return acc; }, {});
|
|
225
|
+
out(`${result.entries.length} entr(ies): ${JSON.stringify(byIntent)}`);
|
|
226
|
+
// What was assumed, named. A status vocabulary this adapter does not know is treated as
|
|
227
|
+
// active, and the operator should see the list rather than find out from a watch.
|
|
228
|
+
for (const [table, list] of Object.entries(result.assumed_active)) {
|
|
229
|
+
if (list.length) out(` ${table} statuses treated as active: ${list.join(', ')}`);
|
|
230
|
+
}
|
|
231
|
+
for (const entry of result.unmapped.slice(0, 20)) out(` ${entry.table}/${entry.id}: ${entry.reason}`);
|
|
232
|
+
if (!args.write) { out('\npreview only — pass --write to append to the ledger'); return 0; }
|
|
233
|
+
const ledgerPath = (await loadConfig({ cwd })).paths.ledger;
|
|
234
|
+
const existing = await readLedger(ledgerPath);
|
|
235
|
+
if (existing.missing) { err('no ledger here — run `agentlinkops init` first'); return 2; }
|
|
236
|
+
const known = new Set(existing.entries.map(entry => entry.id));
|
|
237
|
+
const added = result.entries.filter(entry => !known.has(entry.id));
|
|
238
|
+
await mutateLedger(ledgerPath, rows => {
|
|
239
|
+
const ids = new Set(rows.map(row => row.id));
|
|
240
|
+
return [...rows, ...result.entries.filter(row => !ids.has(row.id))];
|
|
241
|
+
});
|
|
242
|
+
out(`\nappended ${added.length}, skipped ${result.entries.length - added.length} already present`);
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (command === 'platforms') {
|
|
247
|
+
// Explicit inputs, no ledger required: a placement agent asks this BEFORE anything is
|
|
248
|
+
// pursued, from whatever verification results exist — ours, a customer's, or the seeds.
|
|
249
|
+
const query = args._[1] ? String(args._[1]) : null;
|
|
250
|
+
const aggregate = await platformAggregate({
|
|
251
|
+
observations: args.observations ?? [],
|
|
252
|
+
benchmark: args.benchmark ?? [],
|
|
253
|
+
seed: args.seed ?? [],
|
|
254
|
+
});
|
|
255
|
+
if (args['for-candidates'] && args['for-candidates'] !== true) {
|
|
256
|
+
const candidates = await readJsonl(String(args['for-candidates']));
|
|
257
|
+
for (const row of attachToCandidates(candidates, aggregate)) out(JSON.stringify(row));
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
if (args.json) { out(JSON.stringify(query ? aggregate.platforms.find(row => row.platform.includes(query.replace(/^www\./u, ''))) ?? { platform: query, observations: 0, majority_class: null, claim: 'no_observations_unknown' } : aggregate, null, 1)); return 0; }
|
|
261
|
+
return renderPlatforms(aggregate, query, out);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (command === 'fleet') {
|
|
265
|
+
// No config needed: the fleet is many repos, so every ledger path is named explicitly
|
|
266
|
+
// and nothing is discovered from a cwd that only knows about one of them.
|
|
267
|
+
const projects = (args.project ?? []).length ? args.project : (args._.slice(1).map(value => value));
|
|
268
|
+
const pairs = projects.map(value => {
|
|
269
|
+
const [name, path] = String(value).split('=');
|
|
270
|
+
if (!name || !path) throw new ConfigError(`--project expects NAME=LEDGER_PATH, got "${value}"`);
|
|
271
|
+
return { name, ledger: path };
|
|
272
|
+
});
|
|
273
|
+
if (!pairs.length) { err('agentlinkops fleet --project NAME=LEDGER [--project …]'); return 2; }
|
|
274
|
+
const observationsOf = {};
|
|
275
|
+
for (const value of [].concat(args['observations-of'] ?? [])) {
|
|
276
|
+
const [name, path] = String(value).split('=');
|
|
277
|
+
if (!name || !path) throw new ConfigError(`--observations-of expects NAME=FILE, got "${value}"`);
|
|
278
|
+
observationsOf[name] = path;
|
|
279
|
+
}
|
|
280
|
+
const summary = await fleetSummary(pairs, { observationsOf });
|
|
281
|
+
if (args.json) { out(JSON.stringify(summary, null, 1)); return 0; }
|
|
282
|
+
return renderFleet(summary, out);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (command === 'mix') {
|
|
286
|
+
// The local referring-domain mix (DP-0003-T10): same ledger lanes the cloud report
|
|
287
|
+
// accepts, no account. Flags after the command word, like context.
|
|
288
|
+
return mixMain(argv.slice(argv.indexOf(command) + 1), { cwd, out, err });
|
|
289
|
+
}
|
|
290
|
+
if (command === 'context') {
|
|
291
|
+
// DP-0017's context surface owns its config and files and needs no ledger, so it runs
|
|
292
|
+
// before the ledger check below. contextMain parses its own argv; hand it everything
|
|
293
|
+
// after the command word (found positionally, because flags may precede it).
|
|
294
|
+
return await contextMain(argv.slice(argv.indexOf(command) + 1), { cwd, out, err });
|
|
295
|
+
}
|
|
296
|
+
if (command === 'doctor') {
|
|
297
|
+
// Doctor must run exactly where the ledger cannot be trusted yet: its job is to name
|
|
298
|
+
// what is broken, so it dispatches before the ledger requirement and inspects each
|
|
299
|
+
// file itself. Takes no flags and repairs nothing. Awaited on purpose: a promise returned
|
|
300
|
+
// from inside the try escapes the ConfigError handler below, and a disagreeing pair of
|
|
301
|
+
// token spellings would surface as a stack trace instead of the exit-2 message.
|
|
302
|
+
return await doctorMain(argv.slice(argv.indexOf(command) + 1), { cwd, out, err });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const config = await loadConfig({ cwd });
|
|
306
|
+
if (['sync','receive','check','compact','adopt-result'].includes(command) && !(command === 'sync' && args['dry-run'])) {
|
|
307
|
+
const lockPath = join(config.dir, 'sync.lock');
|
|
308
|
+
try { syncLock = { handle: await open(lockPath, 'wx'), path: lockPath }; }
|
|
309
|
+
catch (error) { if (error.code === 'ENOENT') throw new ConfigError('no ledger here — run `agentlinkops init` first'); if (error.code === 'EEXIST') throw new ConfigError('sync writer already locked; retry delivery after it finishes'); throw error; }
|
|
310
|
+
}
|
|
311
|
+
if (command === 'adopt-result') {
|
|
312
|
+
if (args._.length !== 2 || Object.keys(args).some(key => !['_', 'intent', 'tag'].includes(key)) || args.tag.length) throw new ConfigError('adopt-result FILE [--intent wanted|expected]');
|
|
313
|
+
const result = await adoptLocalResult(config, JSON.parse(await readFile(resolve(cwd, args._[1]), 'utf8')), { intent: args.intent ?? 'wanted' });
|
|
314
|
+
out(JSON.stringify(result));
|
|
315
|
+
return 0;
|
|
316
|
+
}
|
|
317
|
+
const ledger = await readLedger(config.paths.ledger);
|
|
318
|
+
if (ledger.missing) { err(`no ledger at ${config.paths.ledger} — run \`agentlinkops init\``); return 2; }
|
|
319
|
+
const observations = await readObservations(config.paths.observations);
|
|
320
|
+
|
|
321
|
+
if (command === 'receipt') {
|
|
322
|
+
if (args._[1] === 'history') {
|
|
323
|
+
let history = receiptHistory(await readReceipts(config.paths.receipts), ledger.entries, observations.rows, await readState(config.paths.state));
|
|
324
|
+
if (args.performance) {
|
|
325
|
+
// Opt-in dated context beside a claim. It reads the saved GSC/GA4 context rows and
|
|
326
|
+
// never edits the receipt, the ledger or the verification history.
|
|
327
|
+
if (args.performance === true) throw new ConfigError('performance requires a JSON file');
|
|
328
|
+
const spec = JSON.parse(await readFile(resolve(cwd, String(args.performance)), 'utf8'));
|
|
329
|
+
const paths = contextPaths({ dir: config.dir, file: await loadContextConfig(config.dir) });
|
|
330
|
+
const [gsc, ga4] = await Promise.all([readGscRows(paths.gsc), spec.ga4_property ? readGa4Rows(paths.ga4) : { rows: [], problems: [] }]);
|
|
331
|
+
if (gsc.problems.length || ga4.problems.length) throw new ConfigError('performance context contains malformed rows');
|
|
332
|
+
history = attachReceiptPerformance(history, spec, { gscRows: gsc.rows, ga4Rows: ga4.rows });
|
|
333
|
+
}
|
|
334
|
+
out(JSON.stringify(history, null, 2));
|
|
335
|
+
return 0;
|
|
336
|
+
}
|
|
337
|
+
if (args._[1] !== 'add' || !args.file || args.file === true) throw new ConfigError('receipt add --file CLAIM.json | receipt history');
|
|
338
|
+
const result = await writeReceipt(config, JSON.parse(await readFile(String(args.file), 'utf8')));
|
|
339
|
+
out(JSON.stringify(result));
|
|
340
|
+
return 0;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (command === 'add') {
|
|
344
|
+
const entry = normalizeEntry({
|
|
345
|
+
intent: args.intent ?? 'wanted', source: args.source, target: args.target, scope: args.scope,
|
|
346
|
+
expect: (args.anchor || args.rel) ? { anchor: args.anchor, rel: args.rel ? String(args.rel).split(',') : undefined } : undefined,
|
|
347
|
+
cadence: args.cadence, ref: args.ref, tags: args.tag.length ? args.tag : undefined,
|
|
348
|
+
note: args.note, added: new Date().toISOString().slice(0, 10),
|
|
349
|
+
}, { assignId: true });
|
|
350
|
+
if (ledger.entries.some(existing => existing.source === entry.source && existing.target === entry.target && existing.scope === entry.scope)) {
|
|
351
|
+
err('an entry with the same source, target and scope is already in the ledger');
|
|
352
|
+
return 2;
|
|
353
|
+
}
|
|
354
|
+
await mutateLedger(config.paths.ledger, rows => {
|
|
355
|
+
if (rows.some(row => row.id === entry.id)) throw new Error('Duplicate ledger id.');
|
|
356
|
+
return [...rows, entry];
|
|
357
|
+
});
|
|
358
|
+
out(entry.id);
|
|
359
|
+
return 0;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (command === 'report') {
|
|
363
|
+
// `--as-of` is what makes a report reproducible: without it the current date is used and
|
|
364
|
+
// the same ledger produces a different document tomorrow. The digest is over the DATASET,
|
|
365
|
+
// so it is unchanged by a cosmetic edit to the template and changes the moment a figure does.
|
|
366
|
+
const asOf = args['as-of'] === true ? null : (args['as-of'] ?? null);
|
|
367
|
+
const dataset = freezeDataset(ledger.entries, observations.rows, {
|
|
368
|
+
asOf: asOf ?? new Date().toISOString().slice(0, 10),
|
|
369
|
+
title: args.title === true ? 'Backlink report' : (args.title ?? 'Backlink report'),
|
|
370
|
+
includeNotes: args['include-notes'] === true,
|
|
371
|
+
includeRetired: args['include-retired'] === true,
|
|
372
|
+
});
|
|
373
|
+
if (args['digest-only']) { out(datasetDigest(dataset)); return 0; }
|
|
374
|
+
const html = renderReport(dataset, { brand: args.brand === true ? null : (args.brand ?? null) });
|
|
375
|
+
if (args.out && args.out !== true) {
|
|
376
|
+
await mkdir(dirname(String(args.out)), { recursive: true });
|
|
377
|
+
await writeFile(String(args.out), html, 'utf8');
|
|
378
|
+
out(`${dataset.totals.entries} link(s), digest ${datasetDigest(dataset)}`);
|
|
379
|
+
out(`wrote ${args.out}`);
|
|
380
|
+
if (!asOf) out('NOT reproducible: no --as-of, so the prepared date is today and tomorrow differs.');
|
|
381
|
+
if (dataset.include_notes) out('Private notes are IN this document.');
|
|
382
|
+
return 0;
|
|
383
|
+
}
|
|
384
|
+
out(html);
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (command === 'fmt') {
|
|
389
|
+
reportProblems(ledger.problems, 'ledger line(s)', out);
|
|
390
|
+
if (ledger.problems.length) return 2;
|
|
391
|
+
const before = await readFile(config.paths.ledger, 'utf8');
|
|
392
|
+
const after = serializeLedger(ledger.entries);
|
|
393
|
+
if (before !== after) { await mutateLedger(config.paths.ledger, rows => rows); out(`formatted ${plural(ledger.entries.length, 'entry', 'entries')}`); }
|
|
394
|
+
else out('already formatted');
|
|
395
|
+
return ledger.problems.length ? 2 : 0;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (command === 'compact') {
|
|
399
|
+
if (observations.problems.length) throw new ConfigError('repair malformed observation history before compacting');
|
|
400
|
+
const plan = compactionPlan(observations.rows);
|
|
401
|
+
out(`${plural(plan.dropped.length, 'repeated observation')} would be collapsed, ${plan.keep.length} kept`);
|
|
402
|
+
if (!args.apply) { out('nothing was written — pass --apply'); return 0; }
|
|
403
|
+
await writeObservations(config.paths.observations, plan.keep);
|
|
404
|
+
out(`wrote ${plan.keep.length}`);
|
|
405
|
+
return 0;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (command === 'status' || command === 'diff') {
|
|
409
|
+
const found = command === 'status' ? disagreements(ledger.entries, observations.rows) : transitions(observations.rows);
|
|
410
|
+
if (args.json) { out(JSON.stringify(found, null, 1)); return 0; }
|
|
411
|
+
reportProblems(ledger.problems, 'ledger line(s)', out);
|
|
412
|
+
if (command === 'diff') {
|
|
413
|
+
if (!found.length) out('no state changes recorded');
|
|
414
|
+
for (const change of found.slice(0, 100)) out(` ${change.at.slice(0, 10)} ${change.from} -> ${change.to} ${change.id}`);
|
|
415
|
+
return 0;
|
|
416
|
+
}
|
|
417
|
+
const groups = { appeared: 'appeared', lost: 'LOST', cannot_say: 'cannot say', never_checked: 'never checked' };
|
|
418
|
+
for (const [kind, label] of Object.entries(groups)) {
|
|
419
|
+
const rows = found.filter(item => item.kind === kind);
|
|
420
|
+
if (!rows.length) continue;
|
|
421
|
+
out(`\n${label} (${rows.length})`);
|
|
422
|
+
for (const item of rows.slice(0, 50)) {
|
|
423
|
+
out(` ${item.entry.id} ${item.entry.source}${item.row ? ` [${item.row.reason}]` : ''}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (!found.length) out('intent and observations agree on every entry');
|
|
427
|
+
return 0;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (command === 'sync' || command === 'receive') {
|
|
431
|
+
if (ledger.problems.length) { reportProblems(ledger.problems, 'ledger line(s)', err); return 2; }
|
|
432
|
+
if (command === 'receive') {
|
|
433
|
+
if (!args.body || !args.headers) throw new ConfigError('receive --body RAW_BODY_FILE --headers HEADERS_JSON_FILE');
|
|
434
|
+
verifyDelivery({ body: await readFile(String(args.body), 'utf8'), headers: JSON.parse(await readFile(String(args.headers), 'utf8')),
|
|
435
|
+
secret: readEnv(process.env, 'WEBHOOK_SECRET').value, workspaceId: config.cloud?.workspaceId });
|
|
436
|
+
args['pull-only'] = true;
|
|
437
|
+
args['push-only'] = false;
|
|
438
|
+
}
|
|
439
|
+
const state = await readState(config.paths.state);
|
|
440
|
+
if (args['pull-only'] && args['push-only']) throw new ConfigError('Choose --pull-only or --push-only.');
|
|
441
|
+
const selection = { includeWanted: args['include-wanted'] === true, ledgerIds: config.cloud?.ledgerIds ?? null };
|
|
442
|
+
if (args['dry-run']) { out(JSON.stringify({ network: false, rows: args['pull-only'] ? [] : syncPlan(ledger.entries, state, selection) }, null, 2)); return 0; }
|
|
443
|
+
const connection = cloudConnection(config);
|
|
444
|
+
if (!connection.origin) { err('no cloud origin — run agentlinkops connect'); return 2; }
|
|
445
|
+
if (!connection.token) { err(keySetup(connection.origin)); return 2; }
|
|
446
|
+
if (!connection.projectId) { err('no project id — run agentlinkops connect'); return 2; }
|
|
447
|
+
const client = createClient(connection);
|
|
448
|
+
let next = { ...state };
|
|
449
|
+
let pushFailed = false;
|
|
450
|
+
if (!args['pull-only']) {
|
|
451
|
+
const pushed = await pushExpectations(client, ledger.entries, state, { projectId: connection.projectId, ...selection });
|
|
452
|
+
next.watches = pushed.watches;
|
|
453
|
+
out(`pushed ${pushed.pushed} (${pushed.created} new, ${pushed.retired} retired, ${pushed.local} kept local)`);
|
|
454
|
+
for (const failure of pushed.failed.slice(0, 20)) out(` ${failure.id}: ${failure.code} ${failure.message}`);
|
|
455
|
+
if (pushed.failed.length) out(` ${pushed.failed.length} entr(ies) the cloud refused, listed above`);
|
|
456
|
+
pushFailed = pushed.failed.length > 0;
|
|
457
|
+
}
|
|
458
|
+
if (!args['push-only']) {
|
|
459
|
+
const pulled = await pullEvents(client, next, {
|
|
460
|
+
onSnapshot: async snapshot => {
|
|
461
|
+
// A resync writes the snapshot beside the mirror rather than into the ledger: the
|
|
462
|
+
// cloud is a mirror of the customer's file and never the other way round.
|
|
463
|
+
await writeFile(join(config.dir, `snapshot-${Date.now()}.json`), `${JSON.stringify(snapshot, null, 1)}\n`, 'utf8');
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
// Two feeds, two cursors. They are pulled separately and written separately, so an
|
|
467
|
+
// expiry on one cannot move the other.
|
|
468
|
+
const targets = await pullTargetEvents(client, next);
|
|
469
|
+
const index = await watchIndex(client, next);
|
|
470
|
+
const { fresh, duplicates } = await persistPulledHistory(config, pulled.events, targets.events, index);
|
|
471
|
+
next.cursors = { ...(next.cursors ?? {}), events: pulled.cursor, target_events: targets.cursor };
|
|
472
|
+
next.gaps = [...(next.gaps ?? []), ...pulled.gaps, ...targets.gaps];
|
|
473
|
+
out(`pulled ${pulled.events.length} event(s) over ${pulled.pages} page(s), ${fresh.length} observation(s)${duplicates.length ? `, ${duplicates.length} already held` : ''}`);
|
|
474
|
+
if (targets.events.length) out(`pulled ${targets.events.length} target event(s) over ${targets.pages} page(s)`);
|
|
475
|
+
// A gap is stated, never inferred. An empty feed means nothing happened; an expired
|
|
476
|
+
// cursor means something happened and is gone.
|
|
477
|
+
for (const gap of [...pulled.gaps, ...targets.gaps]) out(` RESYNC ${gap.at}: ${gap.reason}; a snapshot was written to ${config.dir}`);
|
|
478
|
+
}
|
|
479
|
+
await writeState(config.paths.state, next);
|
|
480
|
+
return pushFailed ? 2 : 0;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (command === 'check') {
|
|
484
|
+
const state = await readState(config.paths.state);
|
|
485
|
+
const selected = selectEntries(ledger.entries, { filter: args.filter === true ? null : args.filter });
|
|
486
|
+
const entries = args.all ? selected : dueEntries(selected, lastCheckedMap(state));
|
|
487
|
+
if (!entries.length) { out(`nothing due (${plural(selected.length, 'entry', 'entries')} in scope; pass --all to recheck)`); return 0; }
|
|
488
|
+
if (!args.json) err(`checking ${plural(entries.length, 'entry', 'entries')}…`);
|
|
489
|
+
const rows = await runCheck(entries, {
|
|
490
|
+
concurrency: Number(args.concurrency ?? config.defaults.concurrency ?? DEFAULTS.concurrency),
|
|
491
|
+
timeoutMs: Number(args.timeout ?? config.defaults.timeoutMs ?? DEFAULTS.timeoutMs),
|
|
492
|
+
hostDelayMs: Number(args['host-delay'] ?? config.defaults.hostDelayMs ?? DEFAULTS.hostDelayMs),
|
|
493
|
+
});
|
|
494
|
+
// The mirror records changes; `state.json` records activity. Appending a row that says
|
|
495
|
+
// the same thing as the one above it is 2 KB of git churn per link per run, and the only
|
|
496
|
+
// thing it adds is work for a later compaction.
|
|
497
|
+
const { changed, repeated } = selectChanged(rows, state);
|
|
498
|
+
await appendObservations(config.paths.observations, changed);
|
|
499
|
+
await writeState(config.paths.state, applyRun(state, rows));
|
|
500
|
+
const summary = summarize(entries, rows);
|
|
501
|
+
if (args.json) { out(rows.map(row => JSON.stringify(row)).join('\n')); return summary.exitCode; }
|
|
502
|
+
reportProblems(ledger.problems, 'ledger line(s)', out);
|
|
503
|
+
for (const { entry, row } of summary.appeared) out(`APPEARED ${entry.id} ${entry.source} (${row.occurrences} occurrence(s))`);
|
|
504
|
+
for (const { entry, row } of summary.failures) out(`LOST ${entry.id} ${entry.source} [${row.reason}]`);
|
|
505
|
+
out(`\n${JSON.stringify(summary.counts)}`);
|
|
506
|
+
out(`${plural(changed.length, 'observation')} recorded, ${repeated.length} repeated an answer already on file`);
|
|
507
|
+
if (summary.counts.unknown) {
|
|
508
|
+
out(`${plural(summary.counts.unknown, 'page')} could not be concluded. An unknown is not a lost link; run \`agentlinkops status\` for the reasons.`);
|
|
509
|
+
}
|
|
510
|
+
if (args['fail-on-unknown'] && summary.counts.unknown) return 1;
|
|
511
|
+
return summary.exitCode;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
err(`unknown command: ${command}\n\n${USAGE}`);
|
|
515
|
+
return 2;
|
|
516
|
+
} catch (error) {
|
|
517
|
+
if (error instanceof ConfigError) { err(error.message); return 2; }
|
|
518
|
+
err(`agentlinkops: ${error?.message ?? error}`);
|
|
519
|
+
return 2;
|
|
520
|
+
} finally {
|
|
521
|
+
if (syncLock) { await syncLock.handle.close(); await unlink(syncLock.path); }
|
|
522
|
+
}
|
|
523
|
+
}
|
package/cli/mirror.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The fact mirror: append-only observations, tool-owned, never hand-edited.
|
|
2
|
+
//
|
|
3
|
+
// A row carries the verifier's own result object with the HTML removed — byte for byte what
|
|
4
|
+
// the cloud stores in `observations.result_json`. That is what makes "the same shape as the
|
|
5
|
+
// cloud" testable rather than aspirational.
|
|
6
|
+
//
|
|
7
|
+
// Raw evidence is never written here. A repository is the wrong place for a publisher's HTML,
|
|
8
|
+
// and it is not ours to redistribute; cloud rows carry a key and the bytes stay in private R2
|
|
9
|
+
// under their own lifecycle.
|
|
10
|
+
import { appendFile, readFile, mkdir, writeFile, rename } from 'node:fs/promises';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
/** Strips the bytes and keeps the receipt. The same shape the cloud persists. */
|
|
14
|
+
export function observationRow(entryId, result, { source = 'local', evidenceKey = null } = {}) {
|
|
15
|
+
const { html, ...top } = result;
|
|
16
|
+
const { html: rawHtml, body, ...evidence } = top.evidence ?? {};
|
|
17
|
+
const stripped = { ...top, evidence };
|
|
18
|
+
return {
|
|
19
|
+
id: entryId,
|
|
20
|
+
checked_at: result.checkedAt,
|
|
21
|
+
state: result.state,
|
|
22
|
+
reason: result.reason,
|
|
23
|
+
occurrences: result.occurrences?.length ?? 0,
|
|
24
|
+
complete: result.evidence?.complete === true,
|
|
25
|
+
checker_version: result.evidence?.checkerVersion ?? null,
|
|
26
|
+
source,
|
|
27
|
+
evidence_key: evidenceKey,
|
|
28
|
+
result: stripped,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The natural key of an observation: which entry, when it was checked, and who looked.
|
|
34
|
+
*
|
|
35
|
+
* `checked_at` is the cloud's own timestamp for that observation rather than a local clock, so a
|
|
36
|
+
* replayed event produces a byte-identical key. `source` is in the key because a local check and
|
|
37
|
+
* a cloud check of the same entry at the same instant are two real observations, not a duplicate.
|
|
38
|
+
*/
|
|
39
|
+
export function observationKey(row) {
|
|
40
|
+
return `${row.id}|${row.checked_at}|${row.source ?? 'local'}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Drops incoming rows the mirror already holds.
|
|
45
|
+
*
|
|
46
|
+
* This exists because of the ORDER sync must write in. Observations are appended first and the
|
|
47
|
+
* cursor advances second, so a crash between them replays that page — which is correct, and is
|
|
48
|
+
* the only ordering that cannot lose an event. Without a dedupe the correct ordering produces
|
|
49
|
+
* duplicate history, and with one it produces none.
|
|
50
|
+
*/
|
|
51
|
+
export function dedupeObservations(existingRows, incoming) {
|
|
52
|
+
const seen = new Set(existingRows.map(observationKey));
|
|
53
|
+
const fresh = [], duplicates = [];
|
|
54
|
+
for (const row of incoming) {
|
|
55
|
+
const key = observationKey(row);
|
|
56
|
+
if (seen.has(key)) { duplicates.push(row); continue; }
|
|
57
|
+
seen.add(key);
|
|
58
|
+
fresh.push(row);
|
|
59
|
+
}
|
|
60
|
+
return { fresh, duplicates };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function appendObservations(path, rows) {
|
|
64
|
+
if (!rows.length) return 0;
|
|
65
|
+
await mkdir(dirname(path), { recursive: true });
|
|
66
|
+
await appendFile(path, rows.map(row => JSON.stringify(row)).join('\n') + '\n', 'utf8');
|
|
67
|
+
return rows.length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function readObservations(path) {
|
|
71
|
+
let text = '';
|
|
72
|
+
try { text = await readFile(path, 'utf8'); }
|
|
73
|
+
catch (error) { if (error.code === 'ENOENT') return { rows: [], problems: [] }; throw error; }
|
|
74
|
+
const rows = [], problems = [];
|
|
75
|
+
text.split('\n').forEach((line, index) => {
|
|
76
|
+
if (!line.trim()) return;
|
|
77
|
+
try { rows.push(JSON.parse(line)); }
|
|
78
|
+
catch (error) { problems.push({ line: index + 1, reason: error.message }); }
|
|
79
|
+
});
|
|
80
|
+
return { rows, problems };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The most recent observation per ledger entry, by checked_at then by file order. */
|
|
84
|
+
export function latestByEntry(rows) {
|
|
85
|
+
const latest = new Map();
|
|
86
|
+
for (const row of rows) {
|
|
87
|
+
const held = latest.get(row.id);
|
|
88
|
+
if (!held || String(row.checked_at) >= String(held.checked_at)) latest.set(row.id, row);
|
|
89
|
+
}
|
|
90
|
+
return latest;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Collapses runs of identical observations to the first and last of each run, and records the
|
|
95
|
+
* window collapsed.
|
|
96
|
+
*
|
|
97
|
+
* Never automatic. Silently shrinking a customer's file is not maintenance, and a gap that a
|
|
98
|
+
* reader cannot see reads exactly like a period with no checks — which is the mistake the
|
|
99
|
+
* cloud's own retention had to be built to avoid.
|
|
100
|
+
*/
|
|
101
|
+
export function compactionPlan(rows) {
|
|
102
|
+
const byEntry = new Map();
|
|
103
|
+
for (const row of rows) {
|
|
104
|
+
if (!byEntry.has(row.id)) byEntry.set(row.id, []);
|
|
105
|
+
byEntry.get(row.id).push(row);
|
|
106
|
+
}
|
|
107
|
+
const keep = new Set(), dropped = [];
|
|
108
|
+
for (const [, entryRows] of byEntry) {
|
|
109
|
+
const ordered = [...entryRows].sort((a, b) => (a.checked_at < b.checked_at ? -1 : 1));
|
|
110
|
+
let previous = null;
|
|
111
|
+
ordered.forEach((row, index) => {
|
|
112
|
+
const boundary = row.state !== previous || index === ordered.length - 1
|
|
113
|
+
|| ordered[index + 1]?.state !== row.state;
|
|
114
|
+
if (boundary) keep.add(row); else dropped.push(row);
|
|
115
|
+
previous = row.state;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return { keep: rows.filter(row => keep.has(row)), dropped };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function writeObservations(path, rows) {
|
|
122
|
+
await mkdir(dirname(path), { recursive: true });
|
|
123
|
+
const temporary = join(dirname(path), `.${crypto.randomUUID()}.obs.tmp`);
|
|
124
|
+
await writeFile(temporary, rows.map(row => JSON.stringify(row)).join('\n') + (rows.length ? '\n' : ''), 'utf8');
|
|
125
|
+
await rename(temporary, path);
|
|
126
|
+
}
|