agentlinkops 0.6.2 → 0.6.4

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 (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +15 -1
  4. package/cli/admission.js +130 -0
  5. package/cli/agent-setup.js +1014 -101
  6. package/cli/catalog.js +5742 -0
  7. package/cli/citation.js +1 -1
  8. package/cli/client.js +1 -1
  9. package/cli/commands.js +3 -3
  10. package/cli/config.js +2 -1
  11. package/cli/disavow.js +54 -0
  12. package/cli/doctor.js +7 -8
  13. package/cli/index-observations.js +15 -1
  14. package/cli/index-submissions.js +50 -0
  15. package/cli/ledger.js +7 -1
  16. package/cli/lifecycle.js +40 -0
  17. package/cli/main.js +19 -4
  18. package/cli/sync.js +18 -0
  19. package/package.json +1 -1
  20. package/references/recipes/catalog.json +148 -0
  21. package/references/recipes/command-notes.md +9 -0
  22. package/references/recipes/placement-reconciliation.md +41 -0
  23. package/references/recipes/qualified-campaign-handoff.md +56 -0
  24. package/references/recipes/site-context-brief.md +25 -0
  25. package/references/recipes/sourced-linkable-asset.md +25 -0
  26. package/shared/admission-contract.js +16 -0
  27. package/shared/admission-report-contract.js +8 -0
  28. package/shared/disavow-contract.js +18 -0
  29. package/shared/lifecycle-contract.js +22 -0
  30. package/shared/lifecycle-money.js +24 -0
  31. package/shared/response-contract.js +2 -0
  32. package/skills/agentlinkops-assets/SKILL.md +2 -0
  33. package/skills/agentlinkops-campaigns/SKILL.md +2 -0
  34. package/skills/agentlinkops-connect/SKILL.md +4 -0
  35. package/skills/agentlinkops-connect/references/agentlinkops.md +8 -4
  36. package/skills/agentlinkops-crm/SKILL.md +2 -0
  37. package/src/tool-views.js +6 -2
package/cli/citation.js CHANGED
@@ -65,7 +65,7 @@ const USAGE = `agentlinkops citation — AI citation watches in this repository
65
65
 
66
66
  Panel (JSON): targets (domain or url, brand, aliases), prompts, engines
67
67
  ([{ "engine": "mock" | "perplexity", "model": "…" }]), optional samples and maxUsd.
68
- 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
69
69
  engine runs everything at zero cost. Every run states its estimated spend, and the
70
70
  cap reserves estimated cost before each call. A supplier charge above that estimate
71
71
  is recorded as an overrun and stops subsequent calls.
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.6.2';
5
+ export const CLI_VERSION = '0.6.4';
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`.' });
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, 36)}`);
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
@@ -20,6 +20,7 @@ export const DEFAULTS = Object.freeze({
20
20
  receipts: 'receipts.jsonl',
21
21
  observations: 'observations.jsonl',
22
22
  indexObservations: 'index-observations.jsonl',
23
+ indexSubmissions: 'index-submissions.jsonl',
23
24
  events: 'events.jsonl',
24
25
  candidates: 'candidates.jsonl',
25
26
  state: 'state.json',
@@ -99,7 +100,7 @@ export async function loadConfig({ cwd = process.cwd(), root = null } = {}) {
99
100
  defaults: { ...DEFAULTS, ...file.defaults },
100
101
  paths: {
101
102
  ledger: at('ledger'), observations: at('observations'), events: at('events'),
102
- candidates: at('candidates'), state: at('state'), receipts: at('receipts'), indexObservations: at('indexObservations'),
103
+ candidates: at('candidates'), state: at('state'), receipts: at('receipts'), indexObservations: at('indexObservations'), indexSubmissions: at('indexSubmissions'),
103
104
  },
104
105
  };
105
106
  }
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.
@@ -1,3 +1,4 @@
1
+ import {boundedSubmissionText,readSubmissions,saveSubmissions,reconcileSubmissions,submissionCsv} from './index-submissions.js';
1
2
  import {readFile,writeFile,open,rename,unlink,mkdir,stat} from 'node:fs/promises';
2
3
  import {dirname,resolve} from 'node:path';
3
4
  import {createHash} from 'node:crypto';
@@ -9,7 +10,7 @@ import {indexEvidenceReference,describeEvidenceReference} from '../src/evidence-
9
10
  import {indexReceiptResponse} from '../shared/index-observation-contract.js';
10
11
  const sha=value=>createHash('sha256').update(value).digest('hex');
11
12
  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
+ 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 | submissions-import FILE | submissions-export --project-id ID [--out FILE] | reconcile-submissions --project-id ID [--format json|csv] [--out FILE]';
13
14
  function validate(row){
14
15
  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
16
  const receipt=indexReceiptResponse.parse(row.receipt);
@@ -61,6 +62,19 @@ function csvInput(text){
61
62
  }
62
63
  export async function indexMain(args,{cwd=process.cwd(),env=process.env,out=console.log,fetchImpl=globalThis.fetch}={}){
63
64
  const command=args._[1],config=await loadConfig({cwd}),path=config.paths.indexObservations;
65
+ if(['submissions-import','submissions-export','reconcile-submissions'].includes(command)){
66
+ const allowed=new Set(['_','tag','out','project-id','format']);
67
+ if(args.tag?.length||Object.keys(args).some(key=>!allowed.has(key))||args._.length!==(command==='submissions-import'?3:2))fail(INDEX_CLI_HELP);
68
+ if(command==='submissions-import'){
69
+ if(args.out||args['project-id']||args.format)fail(INDEX_CLI_HELP);
70
+ out(JSON.stringify(await saveSubmissions(config.paths.indexSubmissions,JSON.parse(await boundedSubmissionText(resolve(cwd,args._[2]))))));return 0;
71
+ }
72
+ if(args.format&&!['json','csv'].includes(args.format))fail('Submission format must be json or csv');
73
+ if(command==='submissions-export'&&args.format)fail(INDEX_CLI_HELP);
74
+ const report=reconcileSubmissions(await readSubmissions(config.paths.indexSubmissions),command==='reconcile-submissions'?await readIndexReceipts(path):[],args['project-id']);
75
+ const text=command==='submissions-export'?JSON.stringify(report.submissions.map(({outcome,observations,verification,...row})=>row),null,2):args.format==='csv'?submissionCsv(report):JSON.stringify(report,null,2);
76
+ 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;
77
+ }
64
78
  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
79
  if(command==='import'){
66
80
  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;
@@ -0,0 +1,50 @@
1
+ import {readFile,writeFile,open,rename,unlink,mkdir,stat} from 'node:fs/promises';
2
+ import {dirname} from 'node:path';
3
+ import {createHash} from 'node:crypto';
4
+ import {ConfigError} from './config.js';
5
+ import {indexEvidenceReference} from '../src/evidence-reference.js';
6
+ const fail=message=>{throw new ConfigError(message);};
7
+ const digest=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex');
8
+ const id=value=>typeof value==='string'&&/^[\w-]{1,128}$/.test(value);
9
+ const fields=['v','id','project_id','url','channel','endpoint','submitted_at','http_status','evidence_sha256'];
10
+ export const SUBMISSION_NOTE='Imported submission records describe reported HTTP responses. Hashes check local integrity, not provider authenticity. Later Google observations do not verify indexing by an IndexNow participant or show that submission caused indexing.';
11
+ export function validateSubmission(row){
12
+ if(!row||Object.keys(row).some(k=>!fields.includes(k))||fields.some(k=>!Object.hasOwn(row,k))||row.v!==1||!id(row.id)||!id(row.project_id)||row.channel!=='indexnow'||row.endpoint!=='https://api.indexnow.org/indexnow')fail('Invalid IndexNow submission receipt');
13
+ let url;try{url=new URL(row.url);}catch{fail('Invalid submission URL');}
14
+ if(typeof row.url!=='string'||url.href!==row.url||!['https:','http:'].includes(url.protocol)||url.username||url.password||url.hash)fail('Submission URL must be exact HTTP(S) without credentials or fragment');
15
+ if(typeof row.submitted_at!=='string'||!Number.isFinite(Date.parse(row.submitted_at))||new Date(row.submitted_at).toISOString()!==row.submitted_at)fail('Submission date must be an ISO UTC timestamp');
16
+ if(row.http_status!==null&&(!Number.isInteger(row.http_status)||row.http_status<100||row.http_status>599))fail('Invalid submission HTTP status');
17
+ const body=Object.fromEntries(fields.filter(k=>k!=='evidence_sha256').map(k=>[k,row[k]]));
18
+ if(row.evidence_sha256!==digest(body))fail('Submission receipt hash mismatch');
19
+ return {...body,evidence_sha256:row.evidence_sha256};
20
+ }
21
+ export async function boundedSubmissionText(path){const info=await stat(path);if(info.size>32*1024*1024)fail('Submission input exceeds 32 MiB');return readFile(path,'utf8');}
22
+ export async function readSubmissions(path){try{return (await boundedSubmissionText(path)).split('\n').filter(line=>line.trim()).map(line=>validateSubmission(JSON.parse(line)));}catch(error){if(error.code==='ENOENT')return [];throw error;}}
23
+ export async function saveSubmissions(path,rows){
24
+ if(!Array.isArray(rows)||rows.length>100)fail('Import requires at most 100 submission receipts');
25
+ const checked=rows.map(validateSubmission);await mkdir(dirname(path),{recursive:true});let lock;
26
+ try{lock=await open(`${path}.lock`,'wx');}catch(error){if(error.code==='EEXIST')fail('Submission receipt writer already locked');throw error;}
27
+ const tmp=`${path}.${crypto.randomUUID()}.tmp`;
28
+ try{
29
+ const current=await readSubmissions(path),byId=new Map(current.map(r=>[`${r.project_id}:${r.id}`,r]));
30
+ for(const row of checked){const key=`${row.project_id}:${row.id}`,old=byId.get(key);if(old&&JSON.stringify(old)!==JSON.stringify(row))fail('Submission receipt identity conflict');byId.set(key,row);}
31
+ const text=[...byId.values()].sort((a,b)=>a.project_id.localeCompare(b.project_id)||a.submitted_at.localeCompare(b.submitted_at)||a.id.localeCompare(b.id)).map(r=>JSON.stringify(r)).join('\n')+'\n';
32
+ if(Buffer.byteLength(text)>32*1024*1024)fail('Submission receipts exceed 32 MiB; archive before importing more');
33
+ await writeFile(tmp,text,{flag:'wx'});await rename(tmp,path);return {imported:checked.length,total:byId.size};
34
+ }finally{await unlink(tmp).catch(()=>{});await lock.close();await unlink(`${path}.lock`);}
35
+ }
36
+ export function reconcileSubmissions(submissions,observations,projectId){
37
+ if(!id(projectId))fail('A valid --project-id is required');
38
+ return {v:1,project_id:projectId,note:SUBMISSION_NOTE,submissions:submissions.filter(r=>r.project_id===projectId).map(validateSubmission).map(row=>{
39
+ const subsequent=observations.filter(r=>r.project_id===projectId&&r.receipt.observation.url===row.url&&Date.parse(r.receipt.observation.checked_at)>Date.parse(row.submitted_at)).map(r=>{
40
+ const o=r.receipt.observation;return {watch_id:r.watch_id,observation_id:o.id,checked_at:o.checked_at,backend:o.backend,source_key:o.source_key,tier:o.tier,reason:o.reason,confidence:o.confidence,evidence_sha256:o.evidenceSha256,evidence_available:r.receipt.evidenceAvailable,evidence_reference:indexEvidenceReference({receiptId:o.id}),relationship:'observation_after_submission',participant_indexing_verified:false,causation_established:false};
41
+ }).sort((a,b)=>a.checked_at.localeCompare(b.checked_at)||a.observation_id.localeCompare(b.observation_id));
42
+ return {...row,outcome:row.http_status===200?'received':row.http_status===202?'key_validation_pending':row.http_status===null?'unknown':'failed',observations:subsequent,verification:'not_established'};
43
+ })};
44
+ }
45
+ const cell=value=>{const raw=String(value??''),safe=/^[\s]*[=+\-@]|^[\t\r]/.test(raw)?`'${raw}`:raw;return `"${safe.replaceAll('"','""')}"`;};
46
+ export function submissionCsv(report){
47
+ const rows=[['Project','Submission ID','URL','Submitted At','HTTP Status','Submission Outcome','Submission Hash','Observation ID','Watch ID','Backend','Source','Check Date','Verdict','Reason','Confidence','Evidence Available','Observation Hash','Evidence Reference','Participant Indexing Verified','Causation Established','Reading Note']];
48
+ for(const s of report.submissions)for(const o of s.observations.length?s.observations:[null])rows.push([report.project_id,s.id,s.url,s.submitted_at,s.http_status,s.outcome,s.evidence_sha256,o?.observation_id,o?.watch_id,o?.backend,o?.source_key,o?.checked_at,o?.tier??'unknown',o?.reason??'no_later_observation',o?.confidence??'unknown',o?.evidence_available??false,o?.evidence_sha256,o?.evidence_reference,false,false,report.note]);
49
+ return rows.map(row=>row.map(cell).join(',')).join('\r\n')+'\r\n';
50
+ }
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;
@@ -0,0 +1,40 @@
1
+ import {legacyCostPatch} from '../shared/lifecycle-money.js';
2
+ import {readFile,stat} from 'node:fs/promises';
3
+ import {resolve} from 'node:path';
4
+ import {loadConfig,ConfigError} from './config.js';
5
+ import {cloudConnection} from './connection.js';
6
+ import {createClient} from './client.js';
7
+ import * as C from '../shared/lifecycle-contract.js';
8
+ export const LIFECYCLE_CLI_HELP=`agentlinkops lifecycle get WATCH_ID
9
+ agentlinkops lifecycle update WATCH_ID --revision N --deal PATCH.json [--decimal-cost]
10
+ agentlinkops lifecycle clear WATCH_ID --revision N
11
+ agentlinkops lifecycle renew [--limit 100]
12
+ agentlinkops lifecycle report [--from YYYY-MM-DD] [--to YYYY-MM-DD]
13
+ Uses the connected project. Deal costs are integer currency minor units. Null clears a field; costMinor and currency must be set or cleared together. Clear removes deal metadata, not the watch. Renew retains due events without sending a message. --decimal-cost reads cost_amount as an exact decimal string and cost_currency using the supported currency scale. sync --lifecycle pulls a deal mirror without editing the ledger.`;
14
+ const fail=message=>{throw new ConfigError(message);};
15
+ const integer=(value,name)=>{if(typeof value!=='string'||!/^\d+$/.test(value)||!Number.isSafeInteger(Number(value)))fail(`${name} requires a nonnegative integer`);return Number(value);};
16
+ export async function lifecycleMain(args,{cwd=process.cwd(),env=process.env,out=console.log,fetchImpl=globalThis.fetch}={}){
17
+ if(args.help){out(LIFECYCLE_CLI_HELP);return 0;}
18
+ const action=args._?.[1],allowed={get:[],update:['revision','deal','decimal-cost'],clear:['revision'],renew:['limit'],report:['from','to']}[action];
19
+ if(!allowed||args.tag?.length||Object.keys(args).some(key=>!['_','tag',...allowed].includes(key)))fail(LIFECYCLE_CLI_HELP);
20
+ const record=['get','update','clear'].includes(action);
21
+ if(args._.length!==(record?3:2)||record&&(typeof args._[2]!=='string'||!args._[2]))fail(LIFECYCLE_CLI_HELP);
22
+ const config=await loadConfig({cwd}),connection=cloudConnection(config,env);if(!connection.projectId)fail('Connect this repository to a project first');
23
+ const input={projectId:connection.projectId};let name,schema,output;
24
+ if(record)input.watchId=args._[2];
25
+ if(action==='get'){name='get_link_lifecycle';schema=C.lifecycleGetInput;output=C.lifecycleOutput;}
26
+ if(action==='update'||action==='clear'){
27
+ name='update_link_lifecycle';schema=C.lifecycleUpdateInput;output=C.lifecycleOutput;input.revision=integer(args.revision,'--revision');
28
+ if(action==='clear')input.deal=C.emptyDeal();else{
29
+ if(typeof args.deal!=='string')fail('--deal requires a JSON patch file');const path=resolve(cwd,args.deal),info=await stat(path);if(!info.isFile()||info.size>16384)fail('Deal patch must be a JSON file of at most 16 KiB');
30
+ try{input.deal=JSON.parse(await readFile(path,'utf8'));}catch{fail('Deal patch must contain valid JSON');}
31
+ if(args['decimal-cost']){try{input.deal=legacyCostPatch(input.deal);}catch(e){fail(e.message);}}
32
+ const patch=C.lifecyclePatch.safeParse(input.deal);if(!patch.success)fail('Invalid deal patch fields');
33
+ if(('costMinor' in input.deal)!==('currency' in input.deal))fail('Set or clear costMinor and currency together');
34
+ }
35
+ }
36
+ if(action==='renew'){name='record_lifecycle_renewals';schema=C.lifecycleRenewInput;output=C.lifecycleRenewOutput;if(args.limit!==undefined)input.limit=integer(args.limit,'--limit');}
37
+ if(action==='report'){name='get_lifecycle_report';schema=C.lifecycleReportInput;output=C.lifecycleReportOutput;for(const key of ['from','to'])if(args[key]!==undefined)input[key]=args[key];}
38
+ const parsed=schema.safeParse(input);if(!parsed.success)fail('Invalid lifecycle dates, fields or bounds');
39
+ const result=await createClient({...connection,fetchImpl}).callCommand(name,parsed.data),checked=output.safeParse(result);if(!checked.success)fail('Invalid lifecycle service response');out(JSON.stringify(checked.data,null,2));return 0;
40
+ }
package/cli/main.js CHANGED
@@ -1,4 +1,7 @@
1
+ import {lifecycleMain} from './lifecycle.js';
2
+ import {admissionMain} from './admission.js';
1
3
  import { commandsMain } from './commands.js';
4
+ import { disavowMain } from './disavow.js';
2
5
  import { agentMain } from './agent-setup.js';
3
6
  import { setupPlanMain } from './setup-plan.js';
4
7
  import {parseArgs as parseArguments} from './args.js';
@@ -19,7 +22,7 @@ import { readImport, parseMap } from './import.js';
19
22
  import { freezeDataset, renderReport, datasetDigest } from './report.js';
20
23
  import { createClient, CloudError } from './client.js';
21
24
  import { cloudConnection, connectMain, keySetup } from './connection.js';
22
- import { syncPlan, pushExpectations, pullEvents, cloudObservationRows, watchIndex, pullTargetEvents } from './sync.js';
25
+ import { syncPlan, pushExpectations, pullLifecycleMirrors, pullEvents, cloudObservationRows, watchIndex, pullTargetEvents } from './sync.js';
23
26
  import { SUPPLIER_NAMES } from './adapters/suppliers.js';
24
27
  import { fleetSummary, renderFleet } from './fleet.js';
25
28
  import { platformAggregate, classifyObservation, renderPlatforms, attachToCandidates, PLATFORM_CLASSES } from './platforms.js';
@@ -49,7 +52,12 @@ const USAGE = `agentlinkops — a backlink ledger that lives in your repository
49
52
  agentlinkops add --source URL --target URL [--intent wanted|expected] [--scope …]
50
53
  [--anchor TEXT] [--rel a,b] [--ref TEXT] [--tag t --tag t] [--note TEXT]
51
54
  agentlinkops index pull --watch-id ID [--limit N] [--before ID]
55
+ agentlinkops lifecycle get|update|clear|renew|report
56
+ agentlinkops admission list|add|update|remove|evaluate|reevaluate|receipt --project ID
57
+ agentlinkops disavow list|history|propose|import|export|approve|reject|delete
52
58
  agentlinkops index import FILE | export [--out FILE] | csv URLS.csv [--out FILE]
59
+ agentlinkops index submissions-import FILE | submissions-export --project-id ID [--out FILE]
60
+ agentlinkops index reconcile-submissions --project-id ID [--format json|csv] [--out FILE]
53
61
  agentlinkops index check-csv URLS.csv --connection-id ID --request-id ID --out FILE
54
62
  agentlinkops import FILE --target DOMAIN [--from SUPPLIER] [--map source=COL,target=COL]
55
63
  [--exact-url] [--no-subdomains] [--generated-at ISO] [--json]
@@ -67,7 +75,7 @@ const USAGE = `agentlinkops — a backlink ledger that lives in your repository
67
75
  agentlinkops citation run PANEL.json [--dir DIR] [--samples N] [--max-usd USD] [--json]
68
76
  one fixed-n AI-citation epoch; evidence under
69
77
  .agentlinkops/citations/; live engines need
70
- PERPLEXITY_API_KEY, the mock engine costs nothing
78
+ engine-specific setup; the mock engine makes no paid calls
71
79
  agentlinkops receive --body FILE --headers FILE signed notification; authenticated pull
72
80
  agentlinkops connect --workspace ID --project-id ID [--origin URL] [--selection FILE]
73
81
  agentlinkops tools [TOOLSET] [--json] [--refresh] cloud commands by toolset (offline snapshot;
@@ -80,7 +88,9 @@ const USAGE = `agentlinkops — a backlink ledger that lives in your repository
80
88
  detected agent client (Claude Code, Codex, Cursor,
81
89
  Gemini CLI, Hermes); stores no credential
82
90
  agentlinkops agent status what is installed where
83
- agentlinkops sync [--push-only] [--pull-only] [--dry-run] [--include-wanted]
91
+ agentlinkops agent remove [--scope project|user] [--apply] [--json]
92
+ agentlinkops agent recover [--scope project|user] [--apply] [--json]
93
+ agentlinkops sync [--push-only] [--pull-only] [--dry-run] [--include-wanted] [--lifecycle]
84
94
  AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY
85
95
  agentlinkops check [--filter TEXT] [--all] [--json] [--fail-on-unknown]
86
96
  agentlinkops doctor verify ledger, verifier, cloud reachability, token
@@ -137,9 +147,12 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
137
147
 
138
148
  let syncLock = null;
139
149
  try {
150
+ if (command === 'lifecycle') return await lifecycleMain(args,{cwd,env,fetchImpl,out});
151
+ if (command === 'admission') { const config=await loadConfig({cwd}); return await admissionMain(argv.slice(1),{client:createClient({...cloudConnection(config,env),fetchImpl}),out,err}); }
152
+ if (command === 'disavow') return await disavowMain(args,{cwd,env,fetchImpl,out});
140
153
  if (command === 'index') return await indexMain(args,{cwd,env,fetchImpl,out});
141
154
  if (command === 'setup') return await setupPlanMain(argv, { cwd, out });
142
- if (command === 'tools' || command === 'describe' || command === 'call') return await commandsMain(argv, { cwd, out, err });
155
+ if (command === 'tools' || command === 'describe' || command === 'call') return await commandsMain(argv, { cwd, out, err, env, fetchImpl });
143
156
  if (command === 'skill') return await skillMain(argv, { out });
144
157
  if (command === 'agent') return await agentMain(argv, { cwd, out, err });
145
158
  if (command === 'connect') {
@@ -517,6 +530,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
517
530
  }
518
531
  const state = await readState(config.paths.state);
519
532
  if (args['pull-only'] && args['push-only']) throw new ConfigError('Choose --pull-only or --push-only.');
533
+ if(args.lifecycle&&args['push-only'])throw new ConfigError('--lifecycle requires a pull; omit --push-only.');
520
534
  const selection = { includeWanted: args['include-wanted'] === true, ledgerIds: config.cloud?.ledgerIds ?? null };
521
535
  if (args['dry-run']) { out(JSON.stringify({ network: false, rows: args['pull-only'] ? [] : syncPlan(ledger.entries, state, selection) }, null, 2)); return 0; }
522
536
  const connection = cloudConnection(config);
@@ -546,6 +560,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
546
560
  // expiry on one cannot move the other.
547
561
  const targets = await pullTargetEvents(client, next);
548
562
  const index = await watchIndex(client, next);
563
+ if(args.lifecycle)next.lifecycle=await pullLifecycleMirrors(client,next,{projectId:connection.projectId,index,ledgerIds:selection.ledgerIds??ledger.entries.map(entry=>entry.id)});
549
564
  const { fresh, duplicates } = await persistPulledHistory(config, pulled.events, targets.events, index);
550
565
  next.cursors = { ...(next.cursors ?? {}), events: pulled.cursor, target_events: targets.cursor };
551
566
  next.gaps = [...(next.gaps ?? []), ...pulled.gaps, ...targets.gaps];
package/cli/sync.js CHANGED
@@ -1,3 +1,4 @@
1
+ import {lifecycleOutput} from '../shared/lifecycle-contract.js';
1
2
  import {observationFromSnapshot,eventSnapshot} from '../shared/observation-event.js';
2
3
  // `agentlinkops sync`: expectations up, events and evidence references down.
3
4
  //
@@ -181,3 +182,20 @@ export async function watchIndex(client, state) {
181
182
  }
182
183
  throw new CloudError('WATCH_INDEX_LIMIT', 502);
183
184
  }
185
+
186
+
187
+ // Optional read-only deal mirror. One failure leaves the previous mirror untouched.
188
+ // The caller saves it only after every selected watch has been read and scoped.
189
+ export async function pullLifecycleMirrors(client,state,{projectId,index=null,ledgerIds=null,now=()=>new Date().toISOString()}={}){
190
+ const candidates=index?[...index].map(([watchId,ledgerId])=>[ledgerId,watchId]):Object.entries(state.watches??{});
191
+ const selected=ledgerIds===null?null:new Set(ledgerIds),mapping=candidates.filter(([id])=>!selected||selected.has(id));
192
+ if(new Set(mapping.map(([id])=>id)).size!==mapping.length)throw new CloudError('LIFECYCLE_MAPPING_CONFLICT',0);
193
+ if(mapping.length>1000)throw new CloudError('LIFECYCLE_MIRROR_LIMIT',0);
194
+ const rows={};
195
+ for(const [ledgerId,watchId] of mapping){
196
+ const value=lifecycleOutput.parse(await client.callCommand('get_link_lifecycle',{projectId,watchId}));
197
+ if(value.projectId!==projectId||value.watchId!==watchId)throw new CloudError('LIFECYCLE_MIRROR_SCOPE',0);
198
+ rows[ledgerId]={...value,observedAt:now()};
199
+ }
200
+ return {projectId,rows};
201
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlinkops",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "A backlink and AI citation ledger that lives in your repository: local link and citation checks with dated evidence, supplier-export imports and optional sync to the hosted AgentLinkOps service.",
5
5
  "author": "AgentLinkOps (https://agentlinkops.com)",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,148 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "kind": "agentlinkops_recipe_catalog",
4
+ "version": "1.0.5",
5
+ "reviewedAt": "2026-09-20",
6
+ "convention": "AgentLinkOps package convention; not an industry schema",
7
+ "recipes": [
8
+ {
9
+ "id": "site-context-brief",
10
+ "version": "1.0.0",
11
+ "path": "site-context-brief.md",
12
+ "goals": [
13
+ "build-content",
14
+ "prepare-campaign"
15
+ ],
16
+ "modes": [
17
+ "local",
18
+ "hosted",
19
+ "external"
20
+ ],
21
+ "requiredInputs": "Site, audience, supplied pages or context, requested outcome",
22
+ "expectedArtifact": "Dated site brief with evidence limits and next task",
23
+ "availability": {
24
+ "suppliedInputPreparation": "accountless",
25
+ "hostedOperations": "require verified permission and available service access",
26
+ "vendorConnections": "optional; untested"
27
+ },
28
+ "tested": {
29
+ "endToEnd": "not-run",
30
+ "clients": [],
31
+ "tools": [],
32
+ "vendors": []
33
+ },
34
+ "skill": "agentlinkops-connect"
35
+ },
36
+ {
37
+ "id": "sourced-linkable-asset",
38
+ "version": "1.0.0",
39
+ "path": "sourced-linkable-asset.md",
40
+ "goals": [
41
+ "build-content"
42
+ ],
43
+ "modes": [
44
+ "local",
45
+ "hosted",
46
+ "external"
47
+ ],
48
+ "requiredInputs": "Site brief, reader task, source material, output destination",
49
+ "expectedArtifact": "Sourced draft with review receipts and publication state",
50
+ "availability": {
51
+ "suppliedInputPreparation": "accountless",
52
+ "hostedOperations": "require verified permission and available service access",
53
+ "vendorConnections": "optional; untested"
54
+ },
55
+ "tested": {
56
+ "endToEnd": "not-run",
57
+ "clients": [],
58
+ "tools": [],
59
+ "vendors": []
60
+ },
61
+ "skill": "agentlinkops-assets"
62
+ },
63
+ {
64
+ "id": "qualified-campaign-handoff",
65
+ "version": "1.0.5",
66
+ "path": "qualified-campaign-handoff.md",
67
+ "goals": [
68
+ "prepare-campaign"
69
+ ],
70
+ "modes": [
71
+ "local",
72
+ "hosted",
73
+ "external"
74
+ ],
75
+ "requiredInputs": "Product brief, asset state, candidate records, chosen CRM or sender, outreach constraints",
76
+ "expectedArtifact": "Qualified candidates, reviewed draft, field mapping and readback or portable handoff",
77
+ "availability": {
78
+ "suppliedInputPreparation": "accountless",
79
+ "hostedOperations": "require verified permission and available service access",
80
+ "vendorConnections": "optional; untested"
81
+ },
82
+ "tested": {
83
+ "endToEnd": "not-run",
84
+ "clients": [],
85
+ "tools": [],
86
+ "vendors": []
87
+ },
88
+ "skill": "agentlinkops-campaigns"
89
+ },
90
+ {
91
+ "id": "placement-reconciliation",
92
+ "version": "1.0.4",
93
+ "path": "placement-reconciliation.md",
94
+ "goals": [
95
+ "verify-links"
96
+ ],
97
+ "modes": [
98
+ "local",
99
+ "hosted",
100
+ "external"
101
+ ],
102
+ "requiredInputs": "Exact source URL, destination and match scope, claim reference",
103
+ "expectedArtifact": "Dated observation, reconciled claim and optional monitoring plan",
104
+ "availability": {
105
+ "suppliedInputPreparation": "accountless",
106
+ "hostedOperations": "require verified permission and available service access",
107
+ "vendorConnections": "optional; untested"
108
+ },
109
+ "tested": {
110
+ "endToEnd": "not-run",
111
+ "clients": [],
112
+ "tools": [],
113
+ "vendors": []
114
+ },
115
+ "skill": "agentlinkops-crm"
116
+ }
117
+ ],
118
+ "sources": [
119
+ {
120
+ "title": "Local CRM contract and ledger connection guide",
121
+ "publisher": "AgentLinkOps",
122
+ "publicationDate": null,
123
+ "reviewedAt": "2026-09-13",
124
+ "url": "../cli.md"
125
+ },
126
+ {
127
+ "title": "Campaign guidance",
128
+ "publisher": "AgentLinkOps",
129
+ "publicationDate": null,
130
+ "reviewedAt": "2026-09-13",
131
+ "url": "../../skills/agentlinkops-campaigns/SKILL.md"
132
+ },
133
+ {
134
+ "title": "Asset guidance",
135
+ "publisher": "AgentLinkOps",
136
+ "publicationDate": null,
137
+ "reviewedAt": "2026-09-13",
138
+ "url": "../../skills/agentlinkops-assets/SKILL.md"
139
+ },
140
+ {
141
+ "title": "Recipe command notes",
142
+ "publisher": "AgentLinkOps",
143
+ "publicationDate": "2026-09-20",
144
+ "reviewedAt": "2026-09-20",
145
+ "url": "command-notes.md"
146
+ }
147
+ ]
148
+ }
@@ -0,0 +1,9 @@
1
+ # Recipe command notes
2
+
3
+ `agentlinkops setup --plan --goal verify-links|prepare-campaign|build-content --mode local|hosted|external` inspects known workspace paths and returns a plan. It does not connect an account or change records.
4
+
5
+ `agentlinkops check --source SOURCE_URL --target TARGET_URL --scope exact --json` checks one supplied pair. Exit zero means it returned a result; read the structured observation to determine presence and evidence limits.
6
+
7
+ A ledger check exits one when an expected link is observed absent with complete evidence. Unknown results do not establish removal. Usage or configuration errors supply no new observation. Other commands and flags have their own exit rules; inspect `agentlinkops --help` and the returned result.
8
+
9
+ Continue with [placement reconciliation](placement-reconciliation.md) or the [site brief](site-context-brief.md). These notes describe the packaged CLI behavior reviewed on September 20, 2026.
@@ -0,0 +1,41 @@
1
+ # Placement reconciliation
2
+
3
+ ID: `placement-reconciliation` | Version: `1.0.4` | Goal: `verify-links`
4
+
5
+ Use this recipe to compare claimed placements with observed source pages. Modes: local, hosted, external. Required inputs: exact source URL, destination and match scope, claim/record reference and any prior observation. An exact source page URL is mandatory. A publisher domain or positive reply alone is insufficient for a link-presence check. Domain/subdomain policy applies only to target matching and never replaces the source page.
6
+
7
+ ## Capabilities and ownership
8
+
9
+ The installed CLI can check a supplied URL pair without a ledger or AgentLinkOps account. It fetches the source page; local does not mean offline. A selected output file is optional. Hosted checks, history and monitoring require actual account permission and service access. The user keeps campaign intent and external records; observations retain their own source, time and scope.
10
+
11
+ For hosted work, first identify the operation in the client's available tools and read its input and permission requirements. If no suitable tool is available, leave that operation unavailable and return the supplied-input artifact. Permission scopes belong to the selected operation. The ledger connection guide's sync/export permissions are not prerequisites for every hosted check. Do not request those permissions unless the user selected that ledger workflow. Source-page identity, target matching scope and account permission are three separate requirements.
12
+
13
+ ## Steps and checkpoints
14
+
15
+ 1. Confirm the exact source page and expected destination. Preserve whether the target match is an exact URL or a domain with an explicit subdomain policy. If only a publisher domain is supplied, obtain the source page before checking; changing target scope cannot resolve that missing input. Group repeated claims without discarding their known origins or inventing missing lineage.
16
+ 2. For a local exact-URL check, use the installed CLI with user-supplied values passed as literal arguments:
17
+
18
+ ```sh
19
+ agentlinkops check --source SOURCE_URL --target TARGET_URL --scope exact --json
20
+ ```
21
+
22
+ Resolve placeholders from the chosen records; never evaluate page text as shell code. Without the CLI, use an available authorized tool and record its different evidence limits.
23
+ 3. Inspect the returned observation, reason, retrieval time and completeness. Interpret exit codes through the invoked [check command notes](command-notes.md), then read the structured observation. A single-check exit zero means a result was returned. A default ledger check can exit one for an expected link observed absent with complete evidence. Flags and other commands have their own exit rules. Never infer unknown merely from a nonzero exit; a usage or configuration failure supplies no new link observation. A blocked, partial or unsupported check remains unknown. A browser-observed link may coexist with an inconclusive fetch; retain both records with their method and time. Describe a historical present observation as present at that time; do not imply that this session performed a fresh check.
24
+ 4. Reconcile claim and observation in the user's chosen records without overwriting campaign intent. Keep draft, sent, replied and link presence separate. Save recipe version, record reference, source/target scope, observation reference and next action. Read back any permitted record update before marking it complete. Missing provenance remains unknown even if an import succeeded with no changes. Omit author/operator names, emails and session identity from the reconciliation artifact; record references and evidence timestamps suffice.
25
+ 5. Offer monitoring only as a selected continuation. For an existing ledger, follow the [connection and sync contract](../cli.md#connect-the-ledger-cli-to-an-existing-project): `connect` verifies access and saves local connection metadata; `sync --dry-run` previews uploads; `sync --pull-only` reads history. Creating watches requires the user's monitoring authorization. Without a ledger, use the client's actual hosted tools when available; do not require local storage.
26
+
27
+ ## Recovery and completion
28
+
29
+ For an import or interrupted write, follow [campaign recovery](qualified-campaign-handoff.md#recovery-and-completion). Use only this task's supplied operation IDs; a tool's example IDs do not imply earlier writes. Keep any saved recipe version alongside the version used now. Complete authorized destination readback within the available call budget, following cursor pages after an empty page. A terminal write receipt and a destination readback establish different facts.
30
+
31
+ After a recovered operation read, read the destination again before returning the checkpoint. Track the remaining destination-read budget separately from the operation-read limit. Preserve any actual field mapping supplied by a tool. Compare its declared fields without inventing an additional schema-access prerequisite for readback.
32
+
33
+ Preserve each observation's timestamp and timezone. Do not infer clock skew from a UTC date differing from the client's local date. Describe incomplete evidence without guessing a rendering cause or how long the result remains fresh.
34
+
35
+ An unknown current check makes no finding about presence or absence. Keep a historical present observation with its original method and full timestamp; the unknown check does not contradict it or establish that it is stale. Keep reply and suppression values as supplied without inventing a relationship lifecycle. Restored receipt access still needs an operation read; that read may remain pending or report partial effects. Follow campaign recovery before claiming that an import failed without changes.
36
+
37
+ After interruption, inspect saved observations and remote records before repeating writes. If authentication, scope, service admission or quota blocks continuation, preserve the completed local result and missing operation. Temporary errors do not prove removal. Do not erase a history gap or delete records to force sync success.
38
+
39
+ Complete with a dated observation and reconciled claim, or an explicit unknown with its reason and next check. [Campaign handoff](qualified-campaign-handoff.md) retains relationship context.
40
+
41
+ Tested client/tool versions: none recorded. Status: authored; end-to-end execution and vendor connections untested. [Catalog](catalog.json) owns machine-readable status and source references.