agentlinkops 0.6.3 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlinkops",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Connect an agent to AgentLinkOps and run backlink work with it: the connect skill (views, the three discovery verbs, evidence rules), local ledger and campaign skills, a portable CRM, the source-cited content pipeline, user-managed disavow lists, HTML reports and monitoring-event sync.",
5
5
  "author": {
6
6
  "name": "AgentLinkOps"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlinkops",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Connect an agent to AgentLinkOps and run backlink work with it: the connect skill (views, the three discovery verbs, evidence rules), local ledger and campaign skills, a portable CRM, the source-cited content pipeline, user-managed disavow lists, HTML reports and monitoring-event sync.",
5
5
  "author": {
6
6
  "name": "AgentLinkOps"
package/CHANGELOG.md CHANGED
@@ -3,6 +3,12 @@
3
3
  All notable changes to the `agentlinkops` package are recorded here. The version follows the
4
4
  plugin manifests in `.claude-plugin/` and `.codex-plugin/`.
5
5
 
6
+ ## 0.6.4 (2026-09-20)
7
+
8
+ - Import and export immutable local IndexNow submission receipts, with hashes and replay checks. These commands send no submissions.
9
+ - Join each submission attempt to later retained index observations for the exact URL and project. JSON and CSV preserve source, date, reason and unknown results.
10
+ - Submission responses remain received, validation pending, failed or unknown. Later Google observations do not verify indexing by an IndexNow participant or establish that submission caused indexing.
11
+
6
12
  ## 0.6.3 (2026-09-20)
7
13
 
8
14
  - Agent installation preserves customer edits and supports previewed removal and interrupted-install recovery.
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.3';
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/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
  }
@@ -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/main.js CHANGED
@@ -56,6 +56,8 @@ const USAGE = `agentlinkops — a backlink ledger that lives in your repository
56
56
  agentlinkops admission list|add|update|remove|evaluate|reevaluate|receipt --project ID
57
57
  agentlinkops disavow list|history|propose|import|export|approve|reject|delete
58
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]
59
61
  agentlinkops index check-csv URLS.csv --connection-id ID --request-id ID --out FILE
60
62
  agentlinkops import FILE --target DOMAIN [--from SUPPLIER] [--map source=COL,target=COL]
61
63
  [--exact-url] [--no-subdomains] [--generated-at ISO] [--json]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlinkops",
3
- "version": "0.6.3",
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",