agentlinkops 0.6.1 → 0.6.3

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 (201) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/.mcp.json +0 -0
  4. package/CHANGELOG.md +17 -0
  5. package/LICENSE +0 -0
  6. package/NOTICE +0 -0
  7. package/README.md +0 -0
  8. package/bin/agentlinkops.mjs +0 -0
  9. package/bin/linktrail.mjs +0 -0
  10. package/cli/adapters/csv.js +0 -0
  11. package/cli/adapters/sqlite-crm.js +0 -0
  12. package/cli/adapters/suppliers.js +0 -0
  13. package/cli/admission.js +130 -0
  14. package/cli/agent-setup.js +1014 -101
  15. package/cli/agentlinkops.mjs +0 -0
  16. package/cli/args.js +0 -0
  17. package/cli/catalog.js +12837 -4694
  18. package/cli/check.js +0 -0
  19. package/cli/citation.js +181 -32
  20. package/cli/client.js +1 -1
  21. package/cli/commands.js +3 -3
  22. package/cli/config.js +2 -1
  23. package/cli/connection.js +0 -0
  24. package/cli/context-mcp.js +0 -0
  25. package/cli/context.js +0 -0
  26. package/cli/delivery.js +0 -0
  27. package/cli/disavow.js +54 -0
  28. package/cli/doctor.js +7 -8
  29. package/cli/env.js +0 -0
  30. package/cli/fleet.js +0 -0
  31. package/cli/gsc-browser.js +0 -0
  32. package/cli/import.js +0 -0
  33. package/cli/index-observations.js +89 -0
  34. package/cli/ledger.js +7 -1
  35. package/cli/lifecycle.js +40 -0
  36. package/cli/linktrail.mjs +0 -0
  37. package/cli/local-result.js +0 -0
  38. package/cli/main.js +99 -7
  39. package/cli/mirror.js +0 -0
  40. package/cli/mix.js +0 -0
  41. package/cli/platforms.js +0 -0
  42. package/cli/receipt-performance.js +0 -0
  43. package/cli/receipts.js +0 -0
  44. package/cli/report.js +79 -6
  45. package/cli/setup-plan.js +0 -0
  46. package/cli/skill.js +41 -4
  47. package/cli/state.js +0 -0
  48. package/cli/status.js +0 -0
  49. package/cli/sync.js +23 -1
  50. package/package.json +3 -2
  51. package/references/citation-panels.md +26 -0
  52. package/references/cli.md +0 -0
  53. package/references/disavow.md +0 -0
  54. package/references/discovery-handoff.md +32 -0
  55. package/references/recipes/catalog.json +148 -0
  56. package/references/recipes/command-notes.md +9 -0
  57. package/references/recipes/placement-reconciliation.md +41 -0
  58. package/references/recipes/qualified-campaign-handoff.md +56 -0
  59. package/references/recipes/site-context-brief.md +25 -0
  60. package/references/recipes/sourced-linkable-asset.md +25 -0
  61. package/references/reports.md +0 -0
  62. package/scripts/agentlinkops.py +0 -0
  63. package/scripts/disavow.py +0 -0
  64. package/scripts/linktrail.py +0 -0
  65. package/scripts/migrations/001_crm.sql +0 -0
  66. package/scripts/migrations/002_cloud_events.sql +0 -0
  67. package/scripts/migrations/003_disavow.sql +0 -0
  68. package/scripts/migrations/004_placement_costs.sql +0 -0
  69. package/scripts/report.py +0 -0
  70. package/shared/admission-contract.js +16 -0
  71. package/shared/admission-report-contract.js +8 -0
  72. package/shared/agent-clients.js +0 -0
  73. package/shared/citation-evidence-contract.js +27 -0
  74. package/shared/disavow-contract.js +18 -0
  75. package/shared/index-observation-contract.js +14 -0
  76. package/shared/lifecycle-contract.js +22 -0
  77. package/shared/lifecycle-money.js +24 -0
  78. package/shared/observation-event.js +12 -1
  79. package/shared/opportunity-record.js +259 -0
  80. package/shared/response-contract.js +2 -0
  81. package/shared/search-connection-contract.js +0 -0
  82. package/skills/agentlinkops-assets/SKILL.md +2 -0
  83. package/skills/agentlinkops-assets/agents/openai.yaml +0 -0
  84. package/skills/agentlinkops-campaigns/SKILL.md +5 -1
  85. package/skills/agentlinkops-campaigns/agents/openai.yaml +0 -0
  86. package/skills/agentlinkops-connect/SKILL.md +8 -0
  87. package/skills/agentlinkops-connect/agents/openai.yaml +0 -0
  88. package/skills/agentlinkops-connect/references/agentlinkops.md +9 -5
  89. package/skills/agentlinkops-crm/SKILL.md +2 -0
  90. package/skills/agentlinkops-crm/agents/openai.yaml +0 -0
  91. package/skills/agentlinkops-discovery/SKILL.md +17 -0
  92. package/skills/content-defingerprinting/SKILL.md +0 -0
  93. package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -0
  94. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -0
  95. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -0
  96. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -0
  97. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -0
  98. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -0
  99. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -0
  100. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -0
  101. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -0
  102. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -0
  103. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -0
  104. package/skills/internal-linking-optimizer/SKILL.md +0 -0
  105. package/skills/internal-linking-optimizer/references/source-prompt.md +0 -0
  106. package/skills/pre-publish-review/SKILL.md +0 -0
  107. package/skills/source-cited-content-builder/SKILL.md +0 -0
  108. package/skills/source-cited-content-builder/references/source-prompt.md +0 -0
  109. package/skills/source-cited-humanizer/SKILL.md +0 -0
  110. package/src/citations/adapter.js +102 -21
  111. package/src/citations/alert.js +38 -13
  112. package/src/citations/browser/engine.js +145 -50
  113. package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +0 -0
  114. package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +0 -0
  115. package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +0 -0
  116. package/src/citations/browser/gen/core/providers/ai-overview/index.js +0 -0
  117. package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +0 -0
  118. package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +0 -0
  119. package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +0 -0
  120. package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +0 -0
  121. package/src/citations/browser/gen/core/providers/chatgpt/index.js +0 -0
  122. package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +0 -0
  123. package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +0 -0
  124. package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +0 -0
  125. package/src/citations/browser/gen/core/providers/claude/index.js +0 -0
  126. package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +0 -0
  127. package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +0 -0
  128. package/src/citations/browser/gen/core/providers/gemini/index.js +0 -0
  129. package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +0 -0
  130. package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +0 -0
  131. package/src/citations/browser/gen/core/providers/gemini/lib/session.js +0 -0
  132. package/src/citations/browser/gen/core/providers/index.js +0 -0
  133. package/src/citations/browser/gen/core/providers/perplexity/index.js +0 -0
  134. package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +0 -0
  135. package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +0 -0
  136. package/src/citations/browser/gen/lib/browser/camoufox.js +0 -0
  137. package/src/citations/browser/gen/lib/browser/display.js +0 -0
  138. package/src/citations/browser/gen/lib/browser/domOps.js +0 -0
  139. package/src/citations/browser/gen/lib/browser/humanBehavior.js +0 -0
  140. package/src/citations/browser/gen/lib/browser/navigate.js +0 -0
  141. package/src/citations/browser/gen/lib/extraction/sourceUtils.js +0 -0
  142. package/src/citations/browser/gen/lib/input/editor/clearInput.js +0 -0
  143. package/src/citations/browser/gen/lib/input/editor/findEditor.js +0 -0
  144. package/src/citations/browser/gen/lib/input/editor/promptInput.js +0 -0
  145. package/src/citations/browser/gen/lib/input/markdown/converter.js +0 -0
  146. package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +0 -0
  147. package/src/citations/browser/gen/lib/input/response/detectBotPage.js +0 -0
  148. package/src/citations/browser/gen/lib/input/response/isGenerating.js +0 -0
  149. package/src/citations/browser/gen/lib/input/response/waitForFinish.js +0 -0
  150. package/src/citations/browser/gen/lib/input/sources/findButton.js +0 -0
  151. package/src/citations/browser/gen/lib/input/sources/openPanel.js +0 -0
  152. package/src/citations/browser/gen/utils/agent-constants.js +0 -0
  153. package/src/citations/browser/providers.js +3 -1
  154. package/src/citations/browser/shims/errors.js +0 -0
  155. package/src/citations/browser/shims/types.js +0 -0
  156. package/src/citations/browser/shims/utils.js +1 -0
  157. package/src/citations/contract.js +55 -9
  158. package/src/citations/inventory.js +106 -0
  159. package/src/citations/login.js +122 -0
  160. package/src/citations/match.js +12 -0
  161. package/src/citations/panel-builder.js +44 -39
  162. package/src/citations/report.js +88 -21
  163. package/src/citations/runner.js +59 -31
  164. package/src/citations/stats.js +133 -61
  165. package/src/citations/sync.js +131 -0
  166. package/src/citations/watch.js +57 -77
  167. package/src/competitors/contract.js +0 -0
  168. package/src/competitors/domain-mix.js +0 -0
  169. package/src/context/ga4.js +0 -0
  170. package/src/context/gsc.js +0 -0
  171. package/src/context/site-profile.js +0 -0
  172. package/src/context/util.js +0 -0
  173. package/src/context-tool-registry.js +0 -0
  174. package/src/corpus/acquire.js +47 -8
  175. package/src/corpus/seeds.js +132 -19
  176. package/src/discovery/contract.js +0 -0
  177. package/src/discovery/import.js +0 -0
  178. package/src/evidence-reference.js +112 -0
  179. package/src/index-observation.js +161 -0
  180. package/src/locator.js +112 -0
  181. package/src/tool-views.js +6 -2
  182. package/src/verifier/fetch.js +42 -6
  183. package/src/verifier/index.js +0 -0
  184. package/src/verifier/observation-kind.js +0 -0
  185. package/src/verifier/parser.js +0 -0
  186. package/src/verifier/readiness.js +0 -0
  187. package/src/verifier/render-evidence.js +0 -0
  188. package/src/verifier/robots.js +0 -0
  189. package/src/verifier/transition.js +0 -0
  190. package/src/verifier/url.js +0 -0
  191. package/templates/SITE.template.md +0 -0
  192. package/templates/campaigns.v1.json +0 -0
  193. package/src/citations/browser/gen/core/providers/_shared/google.js +0 -4
  194. package/src/citations/browser/gen/core/providers/types.js +0 -0
  195. package/src/citations/browser/gen/lib/browser/playwrightCompat.js +0 -321
  196. package/src/citations/browser/gen/lib/browser/proxy/forwarder.js +0 -251
  197. package/src/citations/browser/gen/lib/browser/runtimeTypes.js +0 -0
  198. package/src/citations/browser/gen/lib/input/editor/assertNotBlocked.js +0 -54
  199. package/src/citations/browser/gen/lib/input/editor/findSendButton.js +0 -21
  200. package/src/citations/browser/gen/lib/input/response/getText.js +0 -10
  201. package/src/citations/fixtures/dataforseo-aio.json +0 -82
package/cli/check.js CHANGED
File without changes
package/cli/citation.js CHANGED
@@ -12,12 +12,17 @@ import { resolve, join } from 'node:path';
12
12
 
13
13
  import { parseArgs } from './args.js';
14
14
  import { loadPanel, runEpoch } from '../src/citations/runner.js';
15
- import { engineIdentity } from '../src/citations/contract.js';
15
+ import { engineIdentity, panelLocaleContext } from '../src/citations/contract.js';
16
16
  import { createMockEngine, createPerplexityEngine, EngineError } from '../src/citations/adapter.js';
17
17
  import { describeChange } from '../src/citations/stats.js';
18
18
 
19
19
  const USAGE = `agentlinkops citation — AI citation watches in this repository
20
20
 
21
+ agentlinkops citation panel --domain HOST --brand NAME [--topic TOPIC]
22
+ [--prompt QUESTION | --research FILE --limit N] [--competitors FILE]
23
+ [--engine mock|chatgpt:web-own-browser|google-aio]
24
+ [--locale en-US] [--country US] [--out PANEL.json]
25
+ write editable prompt suggestions; mock is the default
21
26
  agentlinkops citation run PANEL.json [--dir DIR] [--samples N] [--max-usd USD] [--json]
22
27
  run one fixed-n epoch over a panel; evidence and
23
28
  tallies land under .agentlinkops/citations/
@@ -25,52 +30,137 @@ const USAGE = `agentlinkops citation — AI citation watches in this repository
25
30
  measure the consumer UI through Camoufox
26
31
  (dedicated account, personal cadence; evidence
27
32
  includes a screenshot of every answer)
28
- agentlinkops citation report PANEL.json [--out FILE] [--dir DIR]
33
+ agentlinkops citation sweep PANEL.json --max-usd USD --out INVENTORY.json [--dir DIR]
34
+ run a bounded epoch and freeze its retained evidence
35
+ agentlinkops citation inventory PANEL.json --out INVENTORY.json [--epoch ID] [--dir DIR]
36
+ freeze an existing panel epoch without new calls
37
+ agentlinkops citation gap INVENTORY.json --out GAP.json
38
+ compare mentions and citations within this dataset
39
+ agentlinkops citation export-sources INVENTORY.json --niche ID --niche-label TEXT
40
+ --consent-ref REF --attest-owned --out SOURCES.json
41
+ export held library candidates; no publication
42
+ agentlinkops citation report PANEL.json [--out FILE] [--dir DIR] [--last N]
29
43
  render the latest epoch as a self-contained
30
44
  HTML report: trends, screenshots inline,
31
45
  Wilson intervals, honest classifications
32
46
  agentlinkops citation watch PANEL.json --cadence weekly|daily|hourly|monthly
47
+ [--webhook URL] [--out REPORT.html]
33
48
  register a cron entry that runs the epoch
34
49
  and generates the report on schedule
35
50
  agentlinkops citation watch --list list registered citation watches
36
51
  agentlinkops citation watch --remove NAME remove a registered watch
37
- agentlinkops citation alert PANEL.json check the latest epoch for declines;
52
+ agentlinkops citation alert PANEL.json [--webhook URL --report REPORT.html]
53
+ check the latest epoch for declines;
38
54
  logs an alert line if any cell declined
55
+ agentlinkops citation login ENGINE open a browser, log in once, save the session
56
+ (the engine adapter uses it on every run)
57
+ agentlinkops citation logout ENGINE remove a saved session
58
+ agentlinkops citation sessions list saved engine sessions
59
+ agentlinkops citation sync [--dir DIR] [--evidence] push local epoch rows to the hosted
60
+ API (requires agentlinkops connect first)
61
+ --evidence also uploads retained answer text;
62
+ screenshot bytes remain local
63
+ agentlinkops citation doctor check local installation and browser dependencies
39
64
  agentlinkops citation help this help
40
65
 
41
66
  Panel (JSON): targets (domain or url, brand, aliases), prompts, engines
42
67
  ([{ "engine": "mock" | "perplexity", "model": "…" }]), optional samples and maxUsd.
43
- Live engines need the matching environment credential (PERPLEXITY_API_KEY); the mock
68
+ Live engines need their configured credentials or a usable browser session; the mock
44
69
  engine runs everything at zero cost. Every run states its estimated spend, and the
45
- cap is checked BEFORE each call, so it can be approached but never exceeded.
70
+ cap reserves estimated cost before each call. A supplier charge above that estimate
71
+ is recorded as an overrun and stops subsequent calls.
46
72
 
47
73
  Output language: citation RATE with a Wilson interval and n, classified against the
48
74
  previous epoch as declined, grown or not_distinguishable — never "removed", never
49
75
  "stable", and insufficient_data when n is too thin to interpret.`;
50
76
 
51
77
  export async function citationMain(argv = [], { cwd = process.cwd(), out = console.log, err = console.error, env = process.env } = {}) {
52
- const args = parseArgs(argv);
78
+ const args = parseArgs(argv, ['prompt', 'alias']);
53
79
  const command = args._[0] ?? 'help';
54
80
  if (command === 'help' || args.help) { out(USAGE); return 0; }
81
+ if (command === 'doctor') {
82
+ const { doctorMain } = await import('./doctor.js');
83
+ return doctorMain(args._.slice(1), { cwd, out, err, env });
84
+ }
85
+ if (command === 'panel') {
86
+ const { buildStarterPanel, selectResearchPrompts } = await import('../src/citations/panel-builder.js');
87
+ const { readFile, writeFile } = await import('node:fs/promises');
88
+ try {
89
+ if (args.research && args.prompt) throw new Error('Use either --research or --prompt');
90
+ const researched = args.research ? selectResearchPrompts(JSON.parse(await readFile(resolve(cwd, args.research), 'utf8')), { limit: args.limit === undefined ? 50 : Number(args.limit) }) : undefined;
91
+ const competitors = args.competitors ? JSON.parse(await readFile(resolve(cwd, args.competitors), 'utf8')) : [];
92
+ const selected = args.engine ?? 'mock';
93
+ const [engine, via] = selected.split(':');
94
+ if (!['mock', 'chatgpt', 'grok', 'duck', 'google-aio'].includes(engine) || (via && via !== 'web-own-browser')) throw new Error('unsupported panel engine');
95
+ if (['chatgpt', 'grok'].includes(engine) && via !== 'web-own-browser') throw new Error('browser engines require :web-own-browser');
96
+ const panel = buildStarterPanel({ domain: args.domain, brand: args.brand, topic: args.topic, aliases: args.alias ?? [],
97
+ competitors, engines: [{ engine, via: via ? 'browser' : 'api' }], locale: args.locale ?? 'en-US', country: args.country,
98
+ samples: args.samples === undefined ? 10 : Number(args.samples), maxUsd: args['max-usd'] === undefined ? 1 : Number(args['max-usd']),
99
+ prompts: researched ?? args.prompt?.map((text, index) => ({ id: `p${index + 1}`, text })),
100
+ });
101
+ const json = JSON.stringify(panel, null, 2) + '\n';
102
+ if (args.out) { await writeFile(resolve(cwd, args.out), json, { flag: 'wx' }); out(`panel written: ${args.out}`); }
103
+ else out(json);
104
+ err('Prompts are editable suggestions, not observed search demand. Review them before using a live engine.');
105
+ return 0;
106
+ } catch (cause) { err(cause.code === 'EEXIST' ? 'panel output already exists; choose another path' : cause.message); return 2; }
107
+ }
108
+ if (['sweep', 'inventory', 'gap', 'export-sources'].includes(command)) {
109
+ const { readFile, open, unlink } = await import('node:fs/promises');
110
+ const { freezeCitationInventory, citationGap, exportCitationSources } = await import('../src/citations/inventory.js');
111
+ let output;
112
+ try {
113
+ if (!args._[1] || typeof args.out !== 'string') throw new Error(`${command} requires an input file and --out FILE`);
114
+ if (command === 'sweep' && (args['max-usd'] === undefined || !Number.isFinite(Number(args['max-usd'])) || Number(args['max-usd']) <= 0)) throw new Error('sweep requires an explicit positive --max-usd ceiling');
115
+ // Reserve an exclusive output before a sweep can incur any measurement cost.
116
+ output = await open(resolve(cwd, args.out), 'wx', 0o600);
117
+ let result; let code = 0;
118
+ if (command === 'gap' || command === 'export-sources') {
119
+ const inventory = JSON.parse(await readFile(resolve(cwd, args._[1]), 'utf8'));
120
+ result = command === 'gap' ? citationGap(inventory) : await exportCitationSources(inventory, {
121
+ niche: { id: args.niche, label: args['niche-label'] }, consentRef: args['consent-ref'], excludedInputsAttested: args['attest-owned'] === true,
122
+ });
123
+ } else {
124
+ const panel = await loadPanel(resolve(cwd, args._[1]));
125
+ let epochId = args.epoch;
126
+ if (command === 'sweep') {
127
+ panel.maxUsd = Number(args['max-usd']);
128
+ let run;
129
+ code = await citationMain(['run', args._[1], '--dir', args.dir ?? '.agentlinkops', '--max-usd', String(args['max-usd']), '--json'], { cwd, env, err, out: value => { run = JSON.parse(value); } });
130
+ if (!run?.epochId) throw new Error('Sweep did not produce an epoch');
131
+ epochId = run.epochId;
132
+ }
133
+ result = await freezeCitationInventory({ dir: resolve(cwd, args.dir ?? '.agentlinkops'), panel, epochId });
134
+ }
135
+ await output.writeFile(JSON.stringify(result, null, 2) + '\n'); await output.close(); output = null;
136
+ out(`${command} written: ${args.out}`); return code;
137
+ } catch (cause) {
138
+ if (output) { await output.close(); await unlink(resolve(cwd, args.out)).catch(() => {}); }
139
+ err(cause.code === 'EEXIST' ? 'Output already exists; choose another path' : cause.message); return 2;
140
+ }
141
+ }
55
142
  if (command === 'alert') {
56
143
  const panelPath = args._[1];
57
144
  if (!panelPath) { err('citation alert needs a panel file'); return 2; }
58
145
  const { readFile } = await import('node:fs/promises');
59
146
  const dir = resolve(cwd, args.dir ?? '.agentlinkops');
60
- let latestEpochId = null;
61
- try {
62
- const epochsText = await readFile(join(dir, 'citations-epochs.jsonl'), 'utf8');
63
- const rows = epochsText.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
64
- if (rows.length > 0) latestEpochId = rows[rows.length - 1].epoch_id;
65
- } catch { /* no epochs */ }
66
- if (!latestEpochId) { out('no epochs to check'); return 0; }
147
+ const { latestPanelEpoch } = await import('../src/citations/report.js');
148
+ const panel = await loadPanel(resolve(cwd, panelPath));
149
+ const cells = await latestPanelEpoch({ dir, panel });
150
+ if (!cells) { out('no epochs to check for this panel'); return 0; }
67
151
  const { shouldAlert } = await import('../src/citations/alert.js');
68
- const epochsText = await readFile(join(dir, 'citations-epochs.jsonl'), 'utf8');
69
- const cells = epochsText.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l)).filter((r) => r.epoch_id === latestEpochId);
70
152
  if (shouldAlert(cells)) {
71
153
  const declined = cells.filter((c) => c.classification === 'declined');
72
154
  err(`ALERT: ${declined.length} cell${declined.length === 1 ? '' : 's'} declined:`);
73
- for (const c of declined) err(` ${c.cell_id}: rate ${c.rate.toFixed(2)} (was ${c.baseline?.rate?.toFixed(2) ?? 'n/a'})`);
155
+ for (const c of declined) err(` ${c.cell_id}: rate ${c.rate?.toFixed(2) ?? 'unknown'} (was ${c.baseline?.rate?.toFixed(2) ?? 'n/a'})`);
156
+ if (args.webhook) {
157
+ const { sendAlertWebhook } = await import('../src/citations/alert.js');
158
+ try {
159
+ const htmlPath = resolve(cwd, args.report ?? join(dir, 'citations', 'report.html'));
160
+ await sendAlertWebhook({ endpoint: args.webhook, epochId: cells[0].epoch_id, cells, htmlPath });
161
+ out('citation decline report delivered');
162
+ } catch { err('citation webhook delivery failed'); return 2; }
163
+ }
74
164
  return 1; // exit 1 = actionable, same convention as link checks
75
165
  }
76
166
  out('no declines detected');
@@ -93,7 +183,7 @@ export async function citationMain(argv = [], { cwd = process.cwd(), out = conso
93
183
  if (!panelPath) { err('citation watch needs a panel file (or --list / --remove)'); return 2; }
94
184
  const cadence = args.cadence ?? 'weekly';
95
185
  try {
96
- const result = await registerWatch({ panelPath, cadence });
186
+ const result = await registerWatch({ panelPath: resolve(cwd, panelPath), cadence, dir: resolve(cwd, args.dir ?? '.agentlinkops'), out: args.out ? resolve(cwd, args.out) : undefined, webhook: args.webhook });
97
187
  out(`citation watch registered: ${result.panelPath}`);
98
188
  out(`cadence: ${result.cadence} (${result.next})`);
99
189
  out(`cron: ${result.cronSpec}`);
@@ -103,22 +193,81 @@ export async function citationMain(argv = [], { cwd = process.cwd(), out = conso
103
193
  return 2;
104
194
  }
105
195
  }
196
+ if (command === 'login') {
197
+ const engineName = args._[1];
198
+ if (!engineName) { err('citation login needs an engine name (e.g. chatgpt)'); return 2; }
199
+ const { loginEngine } = await import('../src/citations/login.js');
200
+ try {
201
+ const result = await loginEngine({ engineName });
202
+ out(`session saved: ${result.sessionPath}`);
203
+ out(`cookies: ${result.cookies}, origins: ${result.origins}, login detected: ${result.loggedIn}`);
204
+ return 0;
205
+ } catch (cause) { err(cause.message); return 2; }
206
+ }
207
+ if (command === 'logout') {
208
+ const engineName = args._[1];
209
+ if (!engineName) { err('citation logout needs an engine name'); return 2; }
210
+ const { logoutEngine } = await import('../src/citations/login.js');
211
+ const result = await logoutEngine({ engineName });
212
+ out(result.removed ? `session removed: ${engineName}` : `no session found for ${engineName}`);
213
+ return 0;
214
+ }
215
+ if (command === 'sessions') {
216
+ const { listSessions } = await import('../src/citations/login.js');
217
+ const sessions = await listSessions();
218
+ if (sessions.length === 0) { out('no saved sessions'); return 0; }
219
+ for (const s of sessions) out(s);
220
+ return 0;
221
+ }
222
+ if (command === 'sync') {
223
+ const dir = resolve(cwd, args.dir ?? '.agentlinkops');
224
+ const { createClient } = await import('./client.js');
225
+ const { cloudConnection } = await import('./connection.js');
226
+ const { readFile } = await import('node:fs/promises');
227
+ const { join } = await import('node:path');
228
+ try {
229
+ const { loadConfig } = await import('./config.js');
230
+ const config = await loadConfig({ cwd });
231
+ const connection = cloudConnection(config, env);
232
+ if (!connection.token) { err('citation sync needs a cloud connection (run agentlinkops connect)'); return 2; }
233
+ const { syncEpochs } = await import('../src/citations/sync.js');
234
+ const client = createClient(connection);
235
+ const result = await syncEpochs({
236
+ dir,
237
+ api: async (path, init) => client.callCommand(path.replace('/v1/commands/', ''), init.body),
238
+ workspaceId: connection.workspaceId,
239
+ projectId: connection.projectId,
240
+ });
241
+ out(`synced: ${result.synced}/${result.total} epoch rows${result.errors ? `, ${result.errors} errors` : ''}`);
242
+ if (result.normalized_rates) out(`${result.normalized_rates} legacy rates normalized: ${result.normalization}`);
243
+ if (args.evidence) {
244
+ const { syncEvidence } = await import('../src/citations/sync.js');
245
+ const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
246
+ const evidence = await syncEvidence({ dir, projectId: connection.projectId, cliVersion: manifest.version,
247
+ api: async (path, init) => client.callCommand(path.replace('/v1/commands/', ''), init.body) });
248
+ out(`evidence synced: ${evidence.synced}/${evidence.total}, ${evidence.skipped} skipped, ${evidence.errors} errors; screenshot bytes remain local`);
249
+ for (const [reason, count] of Object.entries(evidence.reasons)) out(`${reason}: ${count}`);
250
+ if (evidence.errors || evidence.skipped) return 2;
251
+ }
252
+ return result.errors ? 1 : 0;
253
+ } catch (cause) { err(cause.message); return 2; }
254
+ }
106
255
  if (command === 'report') {
107
256
  const panelPath = args._[1];
108
257
  if (!panelPath) { err('citation report needs a panel file'); return 2; }
109
258
  const dir = resolve(cwd, args.dir ?? '.agentlinkops');
110
259
  const { generateReport } = await import('../src/citations/report.js');
111
260
  const { readFile, writeFile: wf } = await import('node:fs/promises');
112
- // Find the latest epoch for this panel by reading the epochs file
113
- let latestEpochId = null;
114
- try {
115
- const epochsText = await readFile(join(dir, 'citations-epochs.jsonl'), 'utf8');
116
- const rows = epochsText.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
117
- if (rows.length > 0) latestEpochId = rows[rows.length - 1].epoch_id;
118
- } catch { /* no epochs yet */ }
119
- if (!latestEpochId) { err('no epochs found — run an epoch first'); return 2; }
120
- const panel = JSON.parse(await readFile(resolve(cwd, panelPath), 'utf8'));
121
- const html = await generateReport({ dir, epochId: latestEpochId, panel });
261
+ const panel = await loadPanel(resolve(cwd, panelPath));
262
+ const last = args.last === undefined ? 1 : Number(args.last);
263
+ if (args.last === true || !Number.isSafeInteger(last) || last < 1 || last > 30) {
264
+ err('citation report --last must be an integer from 1 to 30'); return 2;
265
+ }
266
+ const { panelEpochs } = await import('../src/citations/report.js');
267
+ const history = (await panelEpochs({ dir, panel })).slice(0, last);
268
+ if (!history.length) { err('no epochs found for this panel — run an epoch first'); return 2; }
269
+ const latestEpochId = history[0][0].epoch_id;
270
+ const html = await generateReport({ dir, epochId: latestEpochId, panel, history });
122
271
  const outPath = args.out ? resolve(cwd, args.out) : join(dir, 'citations', `report-${latestEpochId.slice(0, 15)}.html`);
123
272
  const { dirname, join: j } = await import('node:path');
124
273
  const { mkdir } = await import('node:fs/promises');
@@ -127,7 +276,7 @@ export async function citationMain(argv = [], { cwd = process.cwd(), out = conso
127
276
  out(`report written: ${outPath} (${Math.round(html.length / 1024)} KB, self-contained)`);
128
277
  return 0;
129
278
  }
130
- if (command !== 'run') { err(`unknown citation command: ${command}`); err(USAGE); return 2; }
279
+ if (!['run', 'report', 'watch', 'alert', 'login', 'logout', 'sessions', 'sync'].includes(command)) { err(`unknown citation command: ${command}`); err(USAGE); return 2; }
131
280
 
132
281
  const panelPath = args._[1];
133
282
  if (!panelPath) { err('citation run needs a panel file'); return 2; }
@@ -170,14 +319,14 @@ export async function citationMain(argv = [], { cwd = process.cwd(), out = conso
170
319
  if (spec.engine === 'mock') {
171
320
  engines.set(identity, createMockEngine({ fixtures: panel.mockFixtures ?? {} }));
172
321
  } else if (spec.engine === 'google-aio') {
173
- // Fixture-first: runs against the recorded response with no credentials.
174
- // Activates live the moment DATAFORSEO_LOGIN + DATAFORSEO_PASSWORD exist.
322
+ // Live observations require explicit provider configuration; offline runs use mock.
175
323
  const { createGoogleAioEngine } = await import('../src/citations/adapter.js');
176
324
  const creds = env.DATAFORSEO_LOGIN && env.DATAFORSEO_PASSWORD
177
325
  ? { login: env.DATAFORSEO_LOGIN, password: env.DATAFORSEO_PASSWORD }
178
326
  : null;
179
- engines.set(identity, createGoogleAioEngine({ credentials: creds }));
180
- if (!creds) err(`google-aio: fixture mode (set DATAFORSEO_LOGIN + DATAFORSEO_PASSWORD for live)`);
327
+ if (!creds) { err('google-aio requires DATAFORSEO_LOGIN and DATAFORSEO_PASSWORD; select mock for offline runs'); return 2; }
328
+ const context = panelLocaleContext(panel, spec);
329
+ engines.set(identity, createGoogleAioEngine({ credentials: creds, country: context.country, languageCode: context.locale.split('-')[0] }));
181
330
  } else if (spec.engine === 'duck') {
182
331
  // Self-hosted Duck2api on the VPS: the whole Claude family plus GPT-5.6
183
332
  // anonymously through duck.ai. Localhost endpoint, no key.
package/cli/client.js CHANGED
@@ -2,7 +2,7 @@ import {restResponseContract} from '../shared/response-contract.js';
2
2
  // A small HTTP client for the cloud. Scoped keys only; no human session ever reaches here.
3
3
  // DP-0036-T13: the CLI names itself so the usage counters can tell it from other REST callers.
4
4
  // The version follows the plugin manifests; bump it with them.
5
- export const CLI_VERSION = '0.5.1';
5
+ export const CLI_VERSION = '0.6.3';
6
6
  export const CLI_USER_AGENT = `agentlinkops-cli/${CLI_VERSION}`;
7
7
  export class CloudError extends Error {
8
8
  constructor(code, status, details = null) {
package/cli/commands.js CHANGED
@@ -33,13 +33,13 @@ export const resolveName = (commands, name) => commands.find(c => c.name === nam
33
33
  // The index: grouped by toolset, one line per command, core and write and admission marked.
34
34
  export function renderIndex(commands, { toolset = null, json = false } = {}) {
35
35
  const selected = toolset ? commands.filter(c => c.toolset === toolset) : commands;
36
- if (json) return JSON.stringify({ toolsets: toolset ? { [toolset]: CATALOG_TOOLSETS[toolset] } : CATALOG_TOOLSETS, commands: selected.map(c => ({ name: c.name, toolset: c.toolset, tier: c.tier, readOnly: c.readOnly, summary: oneLiner(c.description, 96), ...(c.admissionGated ? { admission: 'gated' } : {}), ...(c.aliases?.length ? { aliases: c.aliases } : {}) })), next: 'Run `agentlinkops describe NAME` for a schema, then `agentlinkops call NAME --args JSON`.' }, null, 2);
36
+ if (json) return JSON.stringify({ toolsets: toolset ? { [toolset]: CATALOG_TOOLSETS[toolset] } : CATALOG_TOOLSETS, commands: selected.map(c => ({ name: c.name, toolset: c.toolset, tier: c.tier, readOnly: c.readOnly, summary: oneLiner(c.description, 64), ...(c.admissionGated ? { admission: 'gated' } : {}), ...(c.aliases?.length ? { aliases: c.aliases } : {}) })), next: 'Run `agentlinkops describe NAME` for a schema, then `agentlinkops call NAME --args JSON`.' });
37
37
  const groups = new Map();
38
38
  for (const c of selected) (groups.get(c.toolset) ?? groups.set(c.toolset, []).get(c.toolset)).push(c);
39
39
  const lines = [];
40
40
  for (const [group, members] of [...groups].sort()) {
41
- lines.push(CATALOG_TOOLSETS[group] ? `${group}: ${CATALOG_TOOLSETS[group]}` : `${group}:`);
42
- for (const c of members.sort((a, b) => a.name.localeCompare(b.name))) lines.push(` ${c.name}${c.tier === 'core' ? ' (core)' : ''}${c.readOnly ? '' : ' [write]'}${c.admissionGated ? ' [admission-gated]' : ''} ${oneLiner(c.description, 48)}`);
41
+ lines.push(CATALOG_TOOLSETS[group] ? `${group}: ${oneLiner(CATALOG_TOOLSETS[group], 44)}` : `${group}:`);
42
+ for (const c of members.sort((a, b) => a.name.localeCompare(b.name))) lines.push(` ${c.name}${c.tier === 'core' ? ' (core)' : ''}${c.readOnly ? '' : ' [write]'}${c.admissionGated ? ' [admission-gated]' : ''} ${oneLiner(c.description, 22)}`);
43
43
  lines.push('');
44
44
  }
45
45
  lines.push(`${selected.length} commands${toolset ? ` in ${toolset}` : ''}. describe NAME shows the schema; call NAME --args JSON runs it.`);
package/cli/config.js CHANGED
@@ -19,6 +19,7 @@ export const DEFAULTS = Object.freeze({
19
19
  ledger: 'links.jsonl',
20
20
  receipts: 'receipts.jsonl',
21
21
  observations: 'observations.jsonl',
22
+ indexObservations: 'index-observations.jsonl',
22
23
  events: 'events.jsonl',
23
24
  candidates: 'candidates.jsonl',
24
25
  state: 'state.json',
@@ -98,7 +99,7 @@ export async function loadConfig({ cwd = process.cwd(), root = null } = {}) {
98
99
  defaults: { ...DEFAULTS, ...file.defaults },
99
100
  paths: {
100
101
  ledger: at('ledger'), observations: at('observations'), events: at('events'),
101
- candidates: at('candidates'), state: at('state'), receipts: at('receipts'),
102
+ candidates: at('candidates'), state: at('state'), receipts: at('receipts'), indexObservations: at('indexObservations'),
102
103
  },
103
104
  };
104
105
  }
package/cli/connection.js CHANGED
File without changes
File without changes
package/cli/context.js CHANGED
File without changes
package/cli/delivery.js CHANGED
File without changes
package/cli/disavow.js ADDED
@@ -0,0 +1,54 @@
1
+ import {open,unlink} from 'node:fs/promises';
2
+ import {resolve} from 'node:path';
3
+ import {createHash} from 'node:crypto';
4
+ import {loadConfig,ConfigError} from './config.js';
5
+ import {cloudConnection} from './connection.js';
6
+ import {createClient,CloudError} from './client.js';
7
+ import * as C from '../shared/disavow-contract.js';
8
+
9
+ export const DISAVOW_USAGE='disavow list|history|propose --kind domain|url --value VALUE|import FILE|export [--out FILE]|approve|reject|delete --rule-id ID --revision N [--project-id ID]';
10
+ const bad=message=>{throw new ConfigError(message);};
11
+ export async function disavowMain(args,{cwd=process.cwd(),env=process.env,fetchImpl=globalThis.fetch,out=console.log}={}){
12
+ const action=args._[1];if(!action||action==='help'){out(DISAVOW_USAGE);return 0;}
13
+ const schemas={list:C.disavowListInput,history:C.disavowHistoryInput,propose:C.disavowProposeInput,import:C.disavowImportInput,export:C.disavowProjectInput,approve:C.disavowReviewInput,reject:C.disavowReviewInput,delete:C.disavowDeleteInput};
14
+ const names={list:'list_disavow_rules',history:'list_disavow_exports',propose:'propose_disavow_rule',import:'import_disavow_rules',export:'export_disavow_rules',approve:'review_disavow_rule',reject:'review_disavow_rule',delete:'delete_disavow_rule'};
15
+ if(!schemas[action])bad(DISAVOW_USAGE);
16
+ const options={list:['limit','cursor','status'],history:['limit','cursor'],propose:['kind','value','comment'],import:[],export:['out'],approve:['rule-id','revision'],reject:['rule-id','revision'],delete:['rule-id','revision']}[action];
17
+ if(Object.keys(args).some(key=>!['_','tag','json','project-id',...options].includes(key))||args.tag?.length||args._.length!==(action==='import'?3:2))bad(DISAVOW_USAGE);
18
+ const config=await loadConfig({cwd}),connection=cloudConnection(config,env),projectId=args['project-id']??connection.projectId;
19
+ let input={projectId};
20
+ if(action==='list'||action==='history')input={...input,...(args.limit===undefined?{}:{limit:Number(args.limit)}),...(args.cursor===undefined?{}:{cursor:args.cursor}),...(args.status===undefined?{}:{status:args.status})};
21
+ if(action==='propose')input={...input,kind:args.kind,value:args.value,source:'agent_proposal',status:'proposed',...(args.comment===undefined?{}:{comments:[`# ${args.comment}`]})};
22
+ if(['approve','reject','delete'].includes(action))input={...input,ruleId:args['rule-id'],revision:Number(args.revision),...(action==='delete'?{}:{decision:action})};
23
+ if(action==='import'){
24
+ const file=await open(resolve(cwd,args._[2]),'r');try{if((await file.stat()).size>C.DISAVOW_LIMITS.importBytes)bad('Disavow import exceeds 160 KiB.');input.text=await file.readFile('utf8');}finally{await file.close();}
25
+ }
26
+ const parsed=schemas[action].safeParse(input);if(!parsed.success)bad(`Invalid ${action} arguments. ${DISAVOW_USAGE}`);
27
+ const client=createClient({...connection,fetchImpl});
28
+ // Reserve both local artifacts before creating a remote export receipt. Existing
29
+ // customer files are never replaced and a path error cannot consume an export.
30
+ let target,receiptFile,targetPath,receiptPath,received=false;
31
+ if(action==='export'&&args.out!==undefined){
32
+ if(typeof args.out!=='string'||!args.out)bad('--out requires a file path.');
33
+ targetPath=resolve(cwd,args.out);receiptPath=`${targetPath}.receipt.json`;
34
+ target=await open(targetPath,'wx');try{receiptFile=await open(receiptPath,'wx');}catch(error){await target.close();await unlink(targetPath);throw error;}
35
+ }
36
+ try{
37
+ const result=await client.callCommand(names[action],parsed.data);
38
+ if(action==='export'){
39
+ const checked=C.disavowExportOutput.parse(result);
40
+ if(createHash('sha256').update(checked.text).digest('hex')!==checked.receipt.sha256||Buffer.byteLength(checked.text)!==checked.receipt.byte_length)throw new CloudError('INVALID_RESPONSE',502);
41
+ received=true;
42
+ if(target){await target.writeFile(checked.text,'utf8');await receiptFile.writeFile(JSON.stringify(checked.receipt,null,2)+'\n','utf8');out(JSON.stringify({file:targetPath,receiptFile:receiptPath,receipt:checked.receipt}));}
43
+ else out(JSON.stringify(checked));
44
+ }else out(JSON.stringify(result));
45
+ return 0;
46
+ }catch(error){
47
+ if(error.code==='HUMAN_ADMIN_REQUIRED')throw new ConfigError('Approve, reject or delete this rule in Disavow using the signed-in owner/admin console. Agent credentials cannot approve rules.');
48
+ if(received)out(JSON.stringify({error:{code:'DISAVOW_ARTIFACT_WRITE_FAILED',message:'The hosted export receipt exists. Inspect export history before retrying; local output may be incomplete.'}}));
49
+ throw error;
50
+ }finally{
51
+ await target?.close();await receiptFile?.close();
52
+ if(target&&!received){await unlink(targetPath).catch(()=>{});await unlink(receiptPath).catch(()=>{});}
53
+ }
54
+ }
package/cli/doctor.js CHANGED
@@ -252,14 +252,13 @@ export async function doctorMain(argv = [], { cwd = process.cwd(), out = console
252
252
  checks.push({ status: 'skip', name: 'token', detail: `set in ${source}; not verified (cloud unreachable)` });
253
253
  }
254
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
- }
255
+ // Doctor does not contact citation providers or validate saved browser sessions.
256
+ // Environment presence must not imply an engine can run successfully.
257
+ const aioCredentials = Boolean(env.DATAFORSEO_LOGIN && env.DATAFORSEO_PASSWORD);
258
+ checks.push({
259
+ status: 'skip', name: 'citations',
260
+ detail: `${aioCredentials ? 'google-aio credentials are set but unverified; ' : ''}live engines were not checked; configure the selected engine credentials or browser session. Mock runs work offline`,
261
+ });
263
262
 
264
263
  // Browser engines: the Camoufox stack. Each missing piece names its fix; none
265
264
  // of it fails doctor, because API and mock engines do not need it.
package/cli/env.js CHANGED
File without changes
package/cli/fleet.js CHANGED
File without changes
File without changes
package/cli/import.js CHANGED
File without changes
@@ -0,0 +1,89 @@
1
+ import {readFile,writeFile,open,rename,unlink,mkdir,stat} from 'node:fs/promises';
2
+ import {dirname,resolve} from 'node:path';
3
+ import {createHash} from 'node:crypto';
4
+ import {loadConfig,ConfigError} from './config.js';
5
+ import {cloudConnection} from './connection.js';
6
+ import {createClient} from './client.js';
7
+ import {parseCsv} from './adapters/csv.js';
8
+ import {indexEvidenceReference,describeEvidenceReference} from '../src/evidence-reference.js';
9
+ import {indexReceiptResponse} from '../shared/index-observation-contract.js';
10
+ const sha=value=>createHash('sha256').update(value).digest('hex');
11
+ const fail=message=>{throw new ConfigError(message);};
12
+ export const INDEX_CLI_HELP='agentlinkops index pull --watch-id ID [--limit 50] [--before ID] | import RECEIPTS.json | export [--out FILE] | csv URLS.csv [--out FILE] | check-csv URLS.csv --connection-id ID --request-id ID --out FILE';
13
+ function validate(row){
14
+ if(row?.v!==1||! /^[\w-]{1,128}$/.test(row.project_id??'')||! /^[\w-]{1,128}$/.test(row.watch_id??''))fail('Invalid local index receipt scope');
15
+ const receipt=indexReceiptResponse.parse(row.receipt);
16
+ if(receipt.observation.checked_at!==receipt.observation.checkedAt||receipt.observation.observation_id!==receipt.observation.observationId||!Number.isFinite(Date.parse(receipt.observation.checkedAt)))fail('Index receipt observation identity mismatch');
17
+ if(receipt.evidenceAvailable&&receipt.rawResponse==null)fail('Available index evidence requires an actual raw response');
18
+ if(!receipt.evidenceAvailable&&receipt.rawResponse!==null)fail('Unavailable index evidence must not contain a raw body');
19
+ if(receipt.evidenceAvailable&&sha(JSON.stringify(receipt.rawResponse))!==receipt.observation.evidenceSha256)fail('Index receipt payload hash mismatch');
20
+ return {...row,receipt};
21
+ }
22
+ async function atomicOutput(path,text){await mkdir(dirname(path),{recursive:true});const temporary=`${path}.${crypto.randomUUID()}.tmp`;try{await writeFile(temporary,text,{flag:'wx'});await rename(temporary,path);}finally{await unlink(temporary).catch(()=>{});}}
23
+ async function boundedText(path){const info=await stat(path);if(info.size>32*1024*1024)fail('Index input exceeds 32 MiB; split the export');return readFile(path,'utf8');}
24
+ export async function readIndexReceipts(path){
25
+ let text;try{text=await boundedText(path);}catch(error){if(error.code==='ENOENT')return [];throw error;}
26
+ return text.split('\n').filter(line=>line.trim()).map(line=>validate(JSON.parse(line)));
27
+ }
28
+ export async function saveIndexReceipts(path,rows){
29
+ const checked=rows.map(validate);await mkdir(dirname(path),{recursive:true});let lock;
30
+ try{lock=await open(`${path}.lock`,'wx');}catch(error){if(error.code==='EEXIST')fail('Index receipt writer already locked');throw error;}
31
+ const temporary=`${path}.${crypto.randomUUID()}.tmp`;
32
+ try{
33
+ const existing=await readIndexReceipts(path),byId=new Map(existing.map(r=>[r.receipt.observation.id,r]));
34
+ for(const row of checked){const id=row.receipt.observation.id,previous=byId.get(id);if(previous){
35
+ if(previous.project_id!==row.project_id||previous.watch_id!==row.watch_id||JSON.stringify(previous.receipt.observation)!==JSON.stringify(row.receipt.observation))fail('Index receipt identity conflict');
36
+ if(previous.receipt.evidenceAvailable&&!row.receipt.evidenceAvailable)continue;
37
+ }byId.set(id,row);}
38
+ const text=[...byId.values()].sort((a,b)=>a.receipt.observation.id.localeCompare(b.receipt.observation.id)).map(r=>JSON.stringify(r)).join('\n')+'\n';if(Buffer.byteLength(text)>32*1024*1024)fail('Local index receipts exceed 32 MiB; archive before importing more');
39
+ await writeFile(temporary,text,{flag:'wx'});await rename(temporary,path);return {saved:checked.length,total:byId.size};
40
+ }finally{await unlink(temporary).catch(()=>{});await lock.close();await unlink(`${path}.lock`);}
41
+ }
42
+ export function locateIndexReceipt(rows,receiptId){
43
+ const row=rows.find(r=>r.receipt.observation.id===receiptId);if(!row)fail('No local index receipt matches this reference');
44
+ const reference=indexEvidenceReference({receiptId});
45
+ return {...row.receipt,projectId:row.project_id,watchId:row.watch_id,evidence_reference:reference,reference_resolution:describeEvidenceReference(reference),notes:['Retained index evidence describes the provider snapshot at its check date, not a live crawl.','This explicit local export survives hosted cancellation; hosted raw retention does not remove customer-owned copies.']};
46
+ }
47
+ export function indexRowsForUrl(rows,url){
48
+ const latest=new Map();for(const row of rows){const o=row.receipt.observation;if(o.url!==url)continue;const previous=latest.get(o.source_key);if(!previous||o.checked_at>previous.checked_at||(o.checked_at===previous.checked_at&&o.id>previous.id))latest.set(o.source_key,{...o,locator:indexEvidenceReference({receiptId:o.id}),hosted_locator:indexEvidenceReference({receiptId:o.id,projectId:row.project_id,watchId:row.watch_id,storage:'hosted'})});}
49
+ return [...latest.values()].sort((a,b)=>a.source_key.localeCompare(b.source_key));
50
+ }
51
+ const cell=value=>{const raw=String(value??''),safe=/^[\s]*[=+\-@]|^[\t\r]/.test(raw)?`'${raw}`:raw;return `"${safe.replaceAll('"','""')}"`;};
52
+ export function indexCsv(urls,receipts){
53
+ const output=[['URL','Verdict','Reason','Check Date','Confidence','Backend','Evidence Reference']];
54
+ for(const url of urls){const rows=indexRowsForUrl(receipts,url);if(!rows.length)output.push([url,'unknown','not_checked','','unknown','','']);else for(const row of rows)output.push([url,row.tier,row.reason,row.checked_at,row.confidence,row.backend,row.locator]);}
55
+ return output.map(row=>row.map(cell).join(',')).join('\r\n')+'\r\n';
56
+ }
57
+ function csvInput(text){
58
+ const [header,...rows]=parseCsv(text),urlAt=header?.findIndex(v=>['url','source_url'].includes(v.trim().toLowerCase())),watchAt=header?.findIndex(v=>v.trim().toLowerCase()==='watch_id');
59
+ if(urlAt==null||urlAt<0||!rows.length||rows.length>100)fail('Index CSV requires URL or source_url and 1–100 rows');
60
+ return rows.map(row=>{let url;try{url=new URL(row[urlAt]);}catch{fail('Invalid index CSV URL');}if(!['https:','http:'].includes(url.protocol)||url.username||url.password||url.hash)fail('Invalid index CSV URL');return {url:url.href,watchId:watchAt>=0?row[watchAt]:null};});
61
+ }
62
+ export async function indexMain(args,{cwd=process.cwd(),env=process.env,out=console.log,fetchImpl=globalThis.fetch}={}){
63
+ const command=args._[1],config=await loadConfig({cwd}),path=config.paths.indexObservations;
64
+ const allowed=new Set(['_','tag','out','watch-id','limit','before','connection-id','request-id']);if(args.tag?.length||Object.keys(args).some(key=>!allowed.has(key)))fail(INDEX_CLI_HELP);
65
+ if(command==='import'){
66
+ if(!args._[2])fail(INDEX_CLI_HELP);const parsed=JSON.parse(await boundedText(resolve(cwd,args._[2])));if(!Array.isArray(parsed)||parsed.length>100)fail('Import requires an array of at most 100 scoped index receipts');out(JSON.stringify(await saveIndexReceipts(path,parsed)));return 0;
67
+ }
68
+ if(command==='export'){const text=JSON.stringify(await readIndexReceipts(path),null,2);if(args.out){if(typeof args.out!=='string')fail('--out requires a file');await atomicOutput(resolve(cwd,args.out),text);}else out(text);return 0;}
69
+ if(command==='csv'||command==='check-csv'){
70
+ if(!args._[2])fail(INDEX_CLI_HELP);const input=csvInput(await boundedText(resolve(cwd,args._[2])));
71
+ if(command==='check-csv'){
72
+ if(typeof args['connection-id']!=='string'||typeof args.out!=='string'||typeof args['request-id']!=='string'||! /^[\x21-\x7e]{1,128}$/.test(args['request-id'])||input.some(r=>!r.watchId))fail('check-csv requires watch_id for every row, --connection-id, --request-id and --out');
73
+ const connection=cloudConnection(config,env);if(!connection.projectId)fail('Connect this repository to a project first');const client=createClient({...connection,fetchImpl});
74
+ // Validate every supplied URL/watch pairing before the first inspection.
75
+ for(const row of input){const watch=await client.callCommand('get_link_watch',{watchId:row.watchId});if(watch.project_id!==connection.projectId||watch.source_url!==row.url)fail('CSV URL/watch scope mismatch; no inspection was started');}
76
+ const batchKey=sha(JSON.stringify([connection.projectId,args['connection-id'],args['request-id'],input]));
77
+ for(const row of input){const result=await client.callCommand('inspect_index_status',{connectionId:args['connection-id'],watchId:row.watchId,idempotencyKey:`csv-${sha(JSON.stringify([batchKey,row.watchId,row.url]))}`});await saveIndexReceipts(path,[{v:1,project_id:connection.projectId,watch_id:row.watchId,receipt:{observation:result.observation,rawResponse:result.rawResponse,evidenceAvailable:result.evidenceAvailable}}]);}
78
+ }
79
+ const csv=indexCsv(input.map(r=>r.url),await readIndexReceipts(path));if(args.out){if(typeof args.out!=='string')fail('--out requires a file');const target=resolve(cwd,args.out);await atomicOutput(target,csv);}else out(csv);return 0;
80
+ }
81
+ if(command==='pull'){
82
+ if(typeof args['watch-id']!=='string')fail(INDEX_CLI_HELP);const limit=Number(args.limit??50);if(!Number.isInteger(limit)||limit<1||limit>100)fail('Index pull limit must be 1–100');
83
+ const connection=cloudConnection(config,env);if(!connection.projectId)fail('Connect this repository to a project first');const client=createClient({...connection,fetchImpl});
84
+ const page=await client.callCommand('list_index_observations',{projectId:connection.projectId,watchId:args['watch-id'],limit,...(args.before?{beforeId:args.before}:{})});const rows=[];
85
+ for(const observation of page.observations)rows.push({v:1,project_id:connection.projectId,watch_id:args['watch-id'],receipt:await client.callCommand('get_index_observation',{projectId:connection.projectId,watchId:args['watch-id'],receiptId:observation.id})});
86
+ out(JSON.stringify({...await saveIndexReceipts(path,rows),nextCursor:page.nextCursor}));return 0;
87
+ }
88
+ fail(INDEX_CLI_HELP);
89
+ }
package/cli/ledger.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // number that is wrong in a way they discover months later.
7
7
  import { readFile, writeFile, mkdir, rename, open, unlink } from 'node:fs/promises';
8
8
  import { dirname, join } from 'node:path';
9
+ import {lifecycleDeal,emptyDeal} from '../shared/lifecycle-contract.js';
9
10
 
10
11
  export const INTENTS = Object.freeze(['wanted', 'expected', 'retired']);
11
12
  export const SCOPES = Object.freeze(['exact', 'domain', 'subdomain', 'path']);
@@ -17,7 +18,7 @@ export const DEFAULT_CADENCE = Object.freeze({ wanted: 'weekly', expected: 'dail
17
18
 
18
19
  // Stable key order, so `fmt` produces a diff that names the entry that changed rather than
19
20
  // reordering every line.
20
- const KEY_ORDER = ['id', 'intent', 'source', 'target', 'scope', 'expect', 'cadence', 'ref', 'origin', 'tags', 'added', 'note'];
21
+ const KEY_ORDER = ['id', 'intent', 'source', 'target', 'scope', 'expect', 'cadence', 'ref', 'origin', 'tags', 'added', 'note', 'deal'];
21
22
 
22
23
  // Where an entry came from, when it was not typed by hand. `placement_run` is adopt-side
23
24
  // lineage, not import-side: a run's queue holds decisions already made and its output reports
@@ -115,6 +116,11 @@ export function normalizeEntry(input, { assignId = false } = {}) {
115
116
  else entry[field] = input[field];
116
117
  }
117
118
  }
119
+ if ('deal' in input) {
120
+ const parsed=lifecycleDeal.safeParse(input.deal===null?emptyDeal():input.deal);
121
+ if(!parsed.success)problems.push('deal must contain valid lifecycle fields with paired cost/currency');
122
+ else entry.deal=parsed.data;
123
+ }
118
124
  // An unknown key is kept rather than discarded: it is the customer's file, and a future
119
125
  // version of this tool may well mean something by it.
120
126
  for (const [key, value] of Object.entries(input)) if (!KEY_ORDER.includes(key)) entry[key] = value;