callx402 1.0.1 → 1.0.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.
package/core/rail.js ADDED
@@ -0,0 +1,317 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * core/rail.js — lightweight client for the hosted Payload Rail.
5
+ *
6
+ * When the v2.0.0 runtime tree is not available locally (or unless
7
+ * CALLX402_LOCAL=1), subsystem commands route here automatically:
8
+ *
9
+ * invoke -> free quote -> user approves -> payment/authorization -> result
10
+ *
11
+ * Payment paths (no wallet required in the CLI):
12
+ * - x402: user pays USDC on Base from their own wallet, pastes the tx hash.
13
+ * - card: CLI opens the Stripe checkout in a browser; user pastes the
14
+ * single-use credit ID from the success page.
15
+ *
16
+ * The rail verifies payment exactly once, executes read-only diagnostic
17
+ * actions server-side, meters the invocation, and returns the result.
18
+ * Nothing here signs, holds keys, or moves money.
19
+ */
20
+
21
+ const { randomBytes } = require('node:crypto');
22
+ const { buildAuthMessage, actionRequestHash, readSignedAuthorization } = require('./payer-auth.js');
23
+ const RAIL_BASE = process.env.CALLX402_RAIL || 'https://payload-rail.fly.dev';
24
+ const RAIL_ACTIONS = [
25
+ 'diagnose', 'doctor', 'resolve', 'preflight',
26
+ 'evidence', 'explain', 'recover',
27
+ 'settlement_interpretation', 'failure_classification', 'duplicate_payment_risk',
28
+ ];
29
+
30
+ // Map CLI command names to rail action names.
31
+ const ACTION_ALIASES = { doctor: 'diagnose' };
32
+
33
+ function railActionFor(command) {
34
+ return ACTION_ALIASES[command] || command;
35
+ }
36
+
37
+ function isRailCommand(command) {
38
+ return RAIL_ACTIONS.includes(command);
39
+ }
40
+
41
+ /** True when the caller explicitly prefers the local v2 tree. */
42
+ function preferLocal() {
43
+ return process.env.CALLX402_LOCAL === '1';
44
+ }
45
+
46
+ async function railFetch(path, opts = {}) {
47
+ const res = await fetch(`${RAIL_BASE}${path}`, {
48
+ ...opts,
49
+ headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
50
+ });
51
+ const text = await res.text();
52
+ let body = null;
53
+ try { body = text ? JSON.parse(text) : null; } catch { body = { _raw: text }; }
54
+ return { status: res.status, body };
55
+ }
56
+
57
+ async function getQuote(action) {
58
+ const { status, body } = await railFetch(
59
+ `/v1/callx402/quote?action=${encodeURIComponent(action)}&path=x402`,
60
+ );
61
+ if (status !== 200 || !body || !body.quoted_price_usd) {
62
+ throw new Error(`quote unavailable (HTTP ${status})`);
63
+ }
64
+ return body;
65
+ }
66
+
67
+ function prompt(question) {
68
+ return new Promise((resolve) => {
69
+ const rl = require('node:readline').createInterface({
70
+ input: process.stdin,
71
+ output: process.stdout,
72
+ });
73
+ rl.question(question, (answer) => {
74
+ rl.close();
75
+ resolve(answer.trim());
76
+ });
77
+ });
78
+ }
79
+
80
+ function openBrowser(url) {
81
+ const { execSync } = require('node:child_process');
82
+ const platform = process.platform;
83
+ try {
84
+ if (platform === 'darwin') execSync(`open "${url}"`, { stdio: 'ignore' });
85
+ else if (platform === 'win32') execSync(`start "" "${url}"`, { stdio: 'ignore' });
86
+ else execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
87
+ return true;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ function buildEvidence(action, args) {
94
+ // Prefer explicit --evidence JSON (or @file); otherwise assemble from args.
95
+ if (args.evidence) {
96
+ const raw = String(args.evidence);
97
+ if (raw.startsWith('@')) {
98
+ const fs = require('node:fs');
99
+ const path = require('node:path');
100
+ return fs.readFileSync(path.resolve(raw.slice(1)), 'utf8');
101
+ }
102
+ return raw;
103
+ }
104
+ const ev = {};
105
+ if (args.target) ev.target = args.target;
106
+ if (args.incident) ev.incident = args.incident;
107
+ if (args.goal) ev.goal = args.goal;
108
+ if (args.intent) ev.intent = args.intent;
109
+ if (args.query) ev.query = args.query;
110
+ if (args.operationId) ev.operationId = args.operationId;
111
+ if (args.identity) ev.identity = args.identity;
112
+ return ev;
113
+ }
114
+
115
+ /**
116
+ * Invoke a paid action via the hosted rail.
117
+ * Returns { ok, result } for display by the caller.
118
+ */
119
+ async function invokeRail(command, args, opts = {}) {
120
+ const action = railActionFor(command);
121
+ const evidence = buildEvidence(action, args);
122
+ const interactive = process.stdin.isTTY && !opts.yes;
123
+
124
+ // 1. Fresh quote for new actions; previously paid agent redemptions
125
+ // must reuse their ORIGINAL signed quote. A fresh quote has a different
126
+ // issuedAt/quote_id and would invalidate an otherwise authentic signature.
127
+ let quote;
128
+ let signedAuthorization = null;
129
+ try {
130
+ if (args.txHash && args.payerAuth) {
131
+ signedAuthorization = readSignedAuthorization(String(args.payerAuth));
132
+ if (!signedAuthorization.quote_inputs || typeof signedAuthorization.quote_inputs !== 'object') {
133
+ throw new Error('Signed authorization file must include the canonical quote_inputs from the original quote.');
134
+ }
135
+ quote = {
136
+ quote_id: signedAuthorization.quote_id,
137
+ quote_inputs: signedAuthorization.quote_inputs,
138
+ quoted_price_usd: signedAuthorization.quoted_price_usd ?? 'see signed quote',
139
+ };
140
+ } else {
141
+ quote = await getQuote(action);
142
+ }
143
+ } catch (err) {
144
+ return { ok: false, error: `Unable to load a valid payment quote: ${err.message}` };
145
+ }
146
+ const price = quote.quoted_price_usd;
147
+ const quoteId = quote.quote_id;
148
+
149
+ if (!opts.json) {
150
+ console.log(`callx402 (hosted) — action '${action}'`);
151
+ console.log(` Free quote: $${price} (quote ${String(quoteId).slice(0, 8)}…)`);
152
+ }
153
+
154
+ // 2. User approves the spend.
155
+ if (!opts.yes) {
156
+ if (!interactive) {
157
+ return {
158
+ ok: false,
159
+ error: `Payment of $${price} required. Re-run with --yes to approve non-interactively, or run interactively.`,
160
+ quote: { action, price_usd: price, quote_id: quoteId, quote_inputs: quote.quote_inputs },
161
+ };
162
+ }
163
+ const approve = await prompt(` Approve $${price} for '${action}'? [y/N] `);
164
+ if (!/^y(es)?$/i.test(approve)) {
165
+ return { ok: false, error: 'Cancelled by user before payment.' };
166
+ }
167
+ }
168
+
169
+ // 3. Payment: x402 (paste tx hash) or card (browser checkout -> credit ID).
170
+ let payBody = { evidence, quote_id: quoteId, quote: quote.quote_inputs || {} };
171
+ if ((action === 'evidence' || action === 'explain') && args.operationId) {
172
+ payBody.invocation_id = String(args.operationId);
173
+ }
174
+ // Agents can supply a wallet-produced EIP-191 signature through a file;
175
+ // private keys are never requested or handled by callx402.
176
+ if (args.creditId) {
177
+ payBody = { ...payBody, credit_id: String(args.creditId) };
178
+ } else if (args.txHash && args.payerAuth) {
179
+ if (!quote.quote_inputs) return { ok: false, error: 'Rail must return canonical quote_inputs before signed redemption.' };
180
+ const auth = signedAuthorization;
181
+ if (auth.txHash?.toLowerCase() !== String(args.txHash).toLowerCase() ||
182
+ auth.action !== action || auth.quote_id !== quoteId ||
183
+ auth.request_hash !== actionRequestHash(action, payBody)) {
184
+ return { ok: false, error: 'Signed authorization does not match the transaction, action, and current quote.' };
185
+ }
186
+ payBody = { ...payBody, txHash: String(args.txHash).toLowerCase(), payer_auth: auth };
187
+ }
188
+ if (!payBody.credit_id && !payBody.payer_auth && !interactive) {
189
+ return {
190
+ ok: false,
191
+ error: 'Non-interactive payment requires --credit-id or --tx-hash with --payer-auth @file.',
192
+ quote: { action, price_usd: price, quote_id: quoteId },
193
+ payment_instructions: `Use --credit-id or --tx-hash plus --payer-auth @signed-auth.json after an authorized wallet signature; a bare txHash is never enough.`,
194
+ };
195
+ }
196
+ if (!payBody.credit_id && !payBody.payer_auth) {
197
+ const method = await prompt(' Pay with [1] USDC on Base (paste tx hash after paying) or [2] card via browser? [1/2] ');
198
+ if (method === '2') {
199
+ // Stripe is a distinct price path; never imply its fee equals the
200
+ // previously displayed micropayment quote.
201
+ let cardPrice;
202
+ try {
203
+ const catalog = await railFetch('/v1/callx402/actions');
204
+ const item = catalog.body?.actions?.find(a => a.action === action);
205
+ if (catalog.status !== 200 || !Number.isSafeInteger(item?.amount_cents) || item.amount_cents <= 0) {
206
+ throw new Error('card fee not available');
207
+ }
208
+ cardPrice = (item.amount_cents / 100).toFixed(2);
209
+ } catch {
210
+ return { ok: false, error: 'Could not confirm the exact card price. Checkout cancelled before payment.' };
211
+ }
212
+ const cardApproval = await prompt(` Card checkout total $ ${cardPrice} USD for '${action}'. Continue? [y/N] `);
213
+ if (!/^y(es)?$/i.test(cardApproval)) {
214
+ return { ok: false, error: 'Card checkout cancelled before payment.' };
215
+ }
216
+ // Stripe checkout path.
217
+ const { status, body } = await railFetch('/v1/callx402/checkout', {
218
+ method: 'POST',
219
+ body: JSON.stringify({ action }),
220
+ });
221
+ if (status !== 200 && status !== 201) {
222
+ return { ok: false, error: `Checkout creation failed (HTTP ${status}).` };
223
+ }
224
+ const url = body.checkout_url || body.url;
225
+ if (!url) return { ok: false, error: 'Checkout URL not returned by rail.' };
226
+ console.log(` Opening checkout in your browser…`);
227
+ if (!openBrowser(url)) console.log(` Open this URL manually: ${url}`);
228
+ console.log(' Complete the payment, then paste the single-use credit ID from the success page.');
229
+ const creditId = await prompt(' Credit ID: ');
230
+ if (!creditId) return { ok: false, error: 'No credit ID provided.' };
231
+ payBody = { ...payBody, credit_id: creditId };
232
+ } else {
233
+ if (!quote.quote_inputs) return { ok: false, error: 'Rail must return canonical quote_inputs before USDC payment.' };
234
+ // x402 path: get exact payment terms from the 402, then user pays.
235
+ const { status, body } = await railFetch(`/v1/callx402/actions/${action}`, {
236
+ method: 'POST',
237
+ body: JSON.stringify({ evidence, quote_id: quoteId, quote: quote.quote_inputs }),
238
+ });
239
+ if (status !== 402) {
240
+ return { ok: false, error: `Expected a 402 payment challenge, got HTTP ${status}.` };
241
+ }
242
+ const accept = (body.accepts && body.accepts[0]) || {};
243
+ const amountUsdc = accept.amount ? (Number(accept.amount) / 1e6).toFixed(2) : price;
244
+ console.log(` Send ${amountUsdc} USDC on Base (eip155:8453) to:`);
245
+ console.log(` ${accept.payTo || '(see 402 response)'}`);
246
+ console.log(' Then paste the transaction hash:');
247
+ const txHash = await prompt(' Tx hash: ');
248
+ if (!/^0x[0-9a-fA-F]{64}$/.test(txHash)) {
249
+ return { ok: false, error: 'Invalid transaction hash format.' };
250
+ }
251
+ const wallet = await prompt(' Payer wallet address (0x...): ');
252
+ if (!/^0x[0-9a-fA-F]{40}$/.test(wallet)) {
253
+ return { ok: false, error: 'A valid Ethereum payer wallet address is required.' };
254
+ }
255
+ const auth = {
256
+ wallet, action, txHash: txHash.toLowerCase(), quote_id: quoteId,
257
+ request_hash: actionRequestHash(action, payBody),
258
+ network: accept.network || 'eip155:8453',
259
+ recipient: accept.payTo,
260
+ nonce: '0x' + randomBytes(16).toString('hex'),
261
+ expiry: Math.floor(Date.now() / 1000) + 300,
262
+ };
263
+ if (!/^0x[0-9a-fA-F]{40}$/.test(auth.recipient || '')) {
264
+ return { ok: false, error: 'The payment challenge omitted a valid recipient; refusing redemption.' };
265
+ }
266
+ const signingMessage = buildAuthMessage(auth);
267
+ console.log('\n Sign the exact message below using your paying wallet (EIP-191 personal_sign).');
268
+ console.log(' NEVER paste a seed phrase, private key, or recovery code.\n');
269
+ console.log(signingMessage);
270
+ console.log();
271
+ const signature = await prompt(' Paste wallet signature (0x...): ');
272
+ if (!/^0x[0-9a-fA-F]{130}$/.test(signature)) {
273
+ return { ok: false, error: 'Expected a 65-byte Ethereum personal_sign signature.' };
274
+ }
275
+ payBody = { ...payBody, txHash: txHash.toLowerCase(),
276
+ payer_auth: { ...auth, signature } };
277
+ }
278
+ }
279
+
280
+ // 4. Submit payment proof -> verified, executed, metered -> result.
281
+ const { status, body } = await railFetch(`/v1/callx402/actions/${action}`, {
282
+ method: 'POST',
283
+ body: JSON.stringify(payBody),
284
+ });
285
+ if (status === 402) {
286
+ return { ok: false, error: 'Payment not yet recognized. The quote may have expired — re-run to get a fresh quote.' };
287
+ }
288
+ if (status === 401) {
289
+ return { ok: false, error: `Wallet authorization rejected: ${body?.error?.message || 'check signed message and wallet'}` };
290
+ }
291
+ if (status === 422) {
292
+ return { ok: false, error: `Action could not execute: ${body?.error?.message || body?.execution?.error || 'not available'}` };
293
+ }
294
+ if (status === 409) {
295
+ return { ok: false, error: `Already used: ${body?.error?.message || 'this payment was already consumed'}.` };
296
+ }
297
+ if (status !== 200 || !body || !body.ok) {
298
+ return { ok: false, error: `Rail error (HTTP ${status}): ${body?.error?.message || 'unknown'}` };
299
+ }
300
+ return {
301
+ ok: true,
302
+ action,
303
+ invocation_id: body.invocation_id,
304
+ via: body.via,
305
+ execution: body.execution || null,
306
+ notice: body.notice,
307
+ };
308
+ }
309
+
310
+ module.exports = {
311
+ RAIL_BASE,
312
+ isRailCommand,
313
+ preferLocal,
314
+ railActionFor,
315
+ invokeRail,
316
+ getQuote,
317
+ };
@@ -0,0 +1,240 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * core/veyline.js — dispatch handlers for the Veyline Recovery track:
5
+ * callx402 evidence <operationId> [--dir <path>] [--evidence <json|@file>]
6
+ * callx402 explain <operationId> [--dir <path>] [--evidence <json|@file>]
7
+ * callx402 recover <operationId> [--dir <path>] [--evidence <json|@file>]
8
+ * [--identity <id>]
9
+ *
10
+ * ADDITIVE-ONLY: this file is new; no existing command is touched. It
11
+ * resolves the Veyline recovery modules from the recovery dir
12
+ * (CALLX402_VEYLINE_ROOT env, config veylineRoot, or the default relative
13
+ * path) — it does NOT go through core/subsystems.js, whose hard rule
14
+ * limits requires to the v2.0.0 tree.
15
+ *
16
+ * All three commands are read-only. `recover` reports the recovery
17
+ * decision the network would make (RECOVERABLE / SAFE_RETRY /
18
+ * EXECUTED_BUT_UNRECOVERABLE / SETTLEMENT_UNKNOWN); it never charges,
19
+ * never executes, never repays. Performing the approved safe action stays
20
+ * with the operator's own adapters.
21
+ *
22
+ * Gate: PAYLOAD_VEYLINE_LEDGER=1 for evidence/explain,
23
+ * PAYLOAD_VEYLINE_RECOVERY=1 for recover. Default OFF.
24
+ */
25
+
26
+ const path = require('path');
27
+ const { EXIT, ok, fail } = require('./envelope');
28
+
29
+ function veylineRoot(config = {}) {
30
+ return config.veylineRoot
31
+ || process.env.CALLX402_VEYLINE_ROOT
32
+ || path.resolve(__dirname, '..', '..', 'veyline', 'engineering', 'recovery');
33
+ }
34
+
35
+ function loadVeyline(config, deps) {
36
+ if (deps && deps.veyline) {
37
+ return { modules: deps.veyline, flag: '(test override)', overridden: true };
38
+ }
39
+ const root = veylineRoot(config);
40
+ try {
41
+ const modules = {
42
+ ledger: require(path.join(root, 'operation-ledger.js')),
43
+ recovery: require(path.join(root, 'recovery-state.js')),
44
+ planner: require(path.join(root, 'rescue-planner.js')),
45
+ trust: require(path.join(root, 'trust-graph.js')),
46
+ intel: require(path.join(root, 'incident-intel.js')),
47
+ continuity: require(path.join(root, 'continuity.js')),
48
+ root,
49
+ };
50
+ return { modules, flag: null, overridden: false };
51
+ } catch (err) {
52
+ return { modules: null, error: err.message };
53
+ }
54
+ }
55
+
56
+ function readJsonInput(input) {
57
+ if (input === undefined || input === null || input === '') return {};
58
+ if (typeof input !== 'string') return input;
59
+ const s = input.trim();
60
+ if (s.startsWith('@')) {
61
+ const fs = require('fs');
62
+ return JSON.parse(fs.readFileSync(path.resolve(s.slice(1)), 'utf8'));
63
+ }
64
+ return JSON.parse(s);
65
+ }
66
+
67
+ function disabledEnvelope(action, flag) {
68
+ return {
69
+ result: fail(action, {
70
+ subsystem: 'veyline', subsystemStatus: 'disabled',
71
+ code: 'subsystem_disabled',
72
+ message: `veyline recovery subsystem is disabled. Set ${flag}=1 to enable it. Nothing was executed.`,
73
+ disposition: 'veyline disabled: nothing executed.',
74
+ data: { flag },
75
+ }),
76
+ exitCode: EXIT.UNAVAILABLE,
77
+ };
78
+ }
79
+
80
+ function gate(modules, action, flag) {
81
+ if (!modules) {
82
+ return {
83
+ result: fail(action, { subsystem: 'veyline', code: 'subsystem_unreachable', message: 'veyline recovery modules could not be loaded. Nothing was executed.' }),
84
+ exitCode: EXIT.UNAVAILABLE,
85
+ };
86
+ }
87
+ const mod = action === 'recover' ? modules.recovery : modules.ledger;
88
+ const on = typeof mod.enabled === 'function' ? mod.enabled() : false;
89
+ if (!on) return disabledEnvelope(action, flag);
90
+ return null;
91
+ }
92
+
93
+ function ledgerFor(modules, dir) {
94
+ const d = dir || path.join(process.cwd(), '.veyline');
95
+ return modules.ledger.createOperationLedger({ dir: d });
96
+ }
97
+
98
+ async function actEvidence(args, ctx) {
99
+ const { modules, error } = loadVeyline(ctx.config, ctx.deps);
100
+ if (error || !modules) {
101
+ return { result: fail('evidence', { subsystem: 'veyline', code: 'subsystem_unreachable', message: `veyline modules unreachable: ${error || 'unknown'}` }), exitCode: EXIT.UNAVAILABLE };
102
+ }
103
+ const g = gate(modules, 'evidence', modules.ledger.FLAG);
104
+ if (g) return g;
105
+ try {
106
+ const operationId = args.operationId;
107
+ if (!operationId) {
108
+ return { result: fail('evidence', { subsystem: 'veyline', code: 'usage', message: 'operationId is required: callx402 evidence <operationId>' }), exitCode: EXIT.USAGE };
109
+ }
110
+ const ledger = ledgerFor(modules, args.dir);
111
+ const events = ledger.events(operationId);
112
+ const states = ledger.latestStates(operationId);
113
+ const protocols = ledger.protocols(operationId);
114
+ const data = {
115
+ operationId, events, states, protocols,
116
+ eventCount: events.length,
117
+ basis: events.length > 0 ? 'recorded ledger evidence' : 'no_basis: no events recorded for this operation',
118
+ };
119
+ return {
120
+ result: ok('evidence', {
121
+ subsystem: 'veyline',
122
+ disposition: events.length > 0
123
+ ? `Evidence for ${operationId}: ${events.length} event(s) across ${protocols.join(', ') || 'no protocols'}.`
124
+ : `No evidence recorded for ${operationId}.`,
125
+ data,
126
+ }),
127
+ exitCode: EXIT.OK,
128
+ };
129
+ } catch (err) {
130
+ const isMalformed = err instanceof SyntaxError;
131
+ return { result: fail('evidence', { subsystem: 'veyline', code: isMalformed ? 'usage' : 'failed', message: err.message }), exitCode: isMalformed ? EXIT.USAGE : EXIT.FAIL };
132
+ }
133
+ }
134
+
135
+ async function actExplain(args, ctx) {
136
+ const { modules, error } = loadVeyline(ctx.config, ctx.deps);
137
+ if (error || !modules) {
138
+ return { result: fail('explain', { subsystem: 'veyline', code: 'subsystem_unreachable', message: `veyline modules unreachable: ${error || 'unknown'}` }), exitCode: EXIT.UNAVAILABLE };
139
+ }
140
+ const g = gate(modules, 'explain', modules.ledger.FLAG);
141
+ if (g) return g;
142
+ try {
143
+ const operationId = args.operationId;
144
+ if (!operationId) {
145
+ return { result: fail('explain', { subsystem: 'veyline', code: 'usage', message: 'operationId is required: callx402 explain <operationId>' }), exitCode: EXIT.USAGE };
146
+ }
147
+ const ledger = ledgerFor(modules, args.dir);
148
+ const events = ledger.events(operationId);
149
+ const states = ledger.latestStates(operationId);
150
+ let assessment;
151
+ if (events.length === 0) {
152
+ assessment = 'NO_BASIS: nothing recorded; no claim can be made about this operation.';
153
+ } else if (states.payment === 'UNKNOWN' || states.execution === 'UNKNOWN') {
154
+ assessment = 'INCOMPLETE: a plane is UNKNOWN — do not retry, do not repay; gather fresh evidence or escalate to human review.';
155
+ } else if (states.payment === 'SETTLED' && states.execution === 'SUCCEEDED' && (states.delivery === 'DELIVERED' || states.delivery === 'ACKED')) {
156
+ assessment = 'KNOWN_SAFE: paid, executed, delivered.';
157
+ } else if (states.delivery === 'LOST') {
158
+ assessment = 'RECOVERY_CANDIDATE: delivery lost — run `callx402 recover <operationId>` to see the safe recovery decision.';
159
+ } else {
160
+ assessment = 'PARTIAL: some planes resolved, some not — see states; safe action depends on the unresolved plane.';
161
+ }
162
+ const trail = events.map((e) => `[${e.seq}] ${e.at} ${e.kind}${e.protocol ? `/${e.protocol}` : ''}: ${e.summary || ''}`.trim());
163
+ return {
164
+ result: ok('explain', {
165
+ subsystem: 'veyline',
166
+ disposition: `Explanation for ${operationId}: ${assessment}`,
167
+ data: { operationId, states, assessment, trail, eventCount: events.length },
168
+ }),
169
+ exitCode: EXIT.OK,
170
+ };
171
+ } catch (err) {
172
+ const isMalformed = err instanceof SyntaxError;
173
+ return { result: fail('explain', { subsystem: 'veyline', code: isMalformed ? 'usage' : 'failed', message: err.message }), exitCode: isMalformed ? EXIT.USAGE : EXIT.FAIL };
174
+ }
175
+ }
176
+
177
+ async function actRecover(args, ctx) {
178
+ const { modules, error } = loadVeyline(ctx.config, ctx.deps);
179
+ if (error || !modules) {
180
+ return { result: fail('recover', { subsystem: 'veyline', code: 'subsystem_unreachable', message: `veyline modules unreachable: ${error || 'unknown'}` }), exitCode: EXIT.UNAVAILABLE };
181
+ }
182
+ const g = gate(modules, 'recover', modules.recovery.FLAG);
183
+ if (g) return g;
184
+ try {
185
+ const operationId = args.operationId;
186
+ if (!operationId) {
187
+ return { result: fail('recover', { subsystem: 'veyline', code: 'usage', message: 'operationId is required: callx402 recover <operationId>' }), exitCode: EXIT.USAGE };
188
+ }
189
+ const evidence = readJsonInput(args.evidence);
190
+ const dir = args.dir || path.join(process.cwd(), '.veyline');
191
+ const ledger = ledgerFor(modules, args.dir);
192
+ // File-backed cache shared via --dir: the CLI reads the same recovery
193
+ // substrate a server wrote. Without --dir the cache is in-memory.
194
+ const cache = args.dir && typeof modules.recovery.createFileBackedCache === 'function'
195
+ ? modules.recovery.createFileBackedCache({ file: path.join(dir, 'recovery-cache.jsonl') })
196
+ : undefined;
197
+ const network = modules.recovery.createRecoveryNetwork({ ledger, cache });
198
+ let identity = args.identity || evidence.identity || null;
199
+ if (!identity && evidence.toolName && evidence.payer && evidence.paymentAuthFingerprint) {
200
+ identity = network.buildOperationIdentity({
201
+ toolName: evidence.toolName,
202
+ args: evidence.args,
203
+ payer: evidence.payer,
204
+ paymentAuthFingerprint: evidence.paymentAuthFingerprint,
205
+ traceId: evidence.traceId,
206
+ operationId,
207
+ });
208
+ }
209
+ if (!identity) {
210
+ return {
211
+ result: fail('recover', {
212
+ subsystem: 'veyline', code: 'usage',
213
+ message: 'an operation identity is required: pass --identity <id> or --evidence with toolName/payer/paymentAuthFingerprint (+args/traceId).',
214
+ }),
215
+ exitCode: EXIT.USAGE,
216
+ };
217
+ }
218
+ const decision = network.onLostResult({
219
+ operationId,
220
+ identity,
221
+ credentials: { payer: evidence.payer, paymentAuthFingerprint: evidence.paymentAuthFingerprint },
222
+ evidence,
223
+ });
224
+ const terminal = decision.recoveryState === 'RECOVERABLE' || decision.recoveryState === 'SAFE_RETRY'
225
+ ? 'KNOWN_SAFE' : 'HUMAN_REVIEW';
226
+ return {
227
+ result: ok('recover', {
228
+ subsystem: 'veyline',
229
+ disposition: `Recovery decision for ${operationId}: ${decision.recoveryState} -> ${terminal}. Read-only: nothing charged, nothing executed.`,
230
+ data: { operationId, decision, terminal },
231
+ }),
232
+ exitCode: EXIT.OK,
233
+ };
234
+ } catch (err) {
235
+ const isMalformed = err instanceof SyntaxError;
236
+ return { result: fail('recover', { subsystem: 'veyline', code: isMalformed ? 'usage' : 'failed', message: err.message }), exitCode: isMalformed ? EXIT.USAGE : EXIT.FAIL };
237
+ }
238
+ }
239
+
240
+ module.exports = { actEvidence, actExplain, actRecover, veylineRoot, loadVeyline };
package/docs/README.md CHANGED
@@ -4,8 +4,8 @@
4
4
  action and entry-point layer for x402 infrastructure: you state an intent in
5
5
  plain language (or call an HTTP endpoint), and callx402 dispatches into the
6
6
  existing x402 subsystems that do the real work. It dispatches only. It never
7
- moves money by itself. The flagship product keeps a separate, forthcoming
8
- brand; callx402 is how you access it.
7
+ moves money by itself. The flagship product is Veyline; callx402 is how you
8
+ access it. **Positioning: callx402 is the action layer of Veyline.**
9
9
 
10
10
  Status of this docs set: landing index is live. Pages and assets marked
11
11
  "planned" below are not published yet. Nothing here is aspirational: every
@@ -46,18 +46,20 @@ Check subsystem reachability and feature flags:
46
46
  callx402 status
47
47
  ```
48
48
 
49
- Run an intent in plain language:
49
+ Run an intent in plain language (single quotes: in double quotes your shell
50
+ would expand `$1` to nothing):
50
51
 
51
52
  ```sh
52
- callx402 "complete this job for under $1"
53
+ callx402 'complete this job for under $1'
53
54
  ```
54
55
 
55
- Diagnose, rescue, route, resolve:
56
+ Diagnose, rescue, route, resolve (need the v2.0.0 tree plus each
57
+ subsystem's flag; see the main README, "Full subsystem commands"):
56
58
 
57
59
  ```sh
58
- callx402 diagnose --target "https://api.example.com/x402/pay"
60
+ callx402 diagnose --target 'https://api.example.com/x402/pay'
59
61
  callx402 rescue --incident inc_123
60
- callx402 route --goal "fetch 100 product prices for under $0.50"
62
+ callx402 route --goal 'fetch 100 product prices for under 0.50 USD'
61
63
  callx402 resolve --evidence '{"txHash":"0xabc..."}'
62
64
  ```
63
65
 
@@ -123,7 +125,7 @@ Planned. No recipes published yet.
123
125
  The subsystem tree callx402 dispatches into: Sentinel, Rescue, Doctor,
124
126
  Router, Valuator, SpendGuard, Settlement Resolver, Intent Engine, Capability
125
127
  Graph, Economic Planner, MCP fabric, and RevRule. Every dispatch goes
126
- through the real subsystem in the x402 paid API starter kit v2.0.0 tree,
128
+ through the real subsystem in the Veyline Developer Primer v2.0.0 tree (x402-paid-api-starter-kit),
127
129
  which callx402 imports but never modifies. If a subsystem is disabled or
128
130
  unreachable, callx402 reports that plainly and fails closed. It never fakes
129
131
  success.
@@ -288,7 +290,7 @@ guards real money movement is ever force-enabled by callx402.
288
290
 
289
291
  ### Subsystem docs
290
292
 
291
- callx402 dispatches into the x402 paid API starter kit v2.0.0 tree. Its docs
293
+ callx402 dispatches into the Veyline Developer Primer v2.0.0 tree (x402-paid-api-starter-kit). Its docs
292
294
  are the reference for subsystem behavior:
293
295
 
294
296
  - `../x402-paid-api-starter-kit/v2.0.0/README.md` — kit overview
@@ -67,7 +67,7 @@ Shared notes for all actions:
67
67
  - **CAN IT MOVE MONEY?** No.
68
68
  - **CAN IT CREATE OR RETRY AN AUTHORIZATION?** No.
69
69
  - **FAIL-CLOSED CONDITIONS:** Missing `--goal` → exit 2; router disabled or unreachable → exit 3. With no candidates at all, the action is honest ("No candidate tools available... No route selected; nothing executed.") and still exits 0.
70
- - **EXAMPLE:** `callx402 route --goal "fetch 100 product prices for under $0.50" --speed cheap --json`
70
+ - **EXAMPLE:** `callx402 route --goal 'fetch 100 product prices for under 0.50 USD' --speed cheap --json`
71
71
  - **CURRENT LIMITATIONS:** Without registered tools in the capability graph (experimental; requires `PAYLOAD_MCP_FABRIC=1`) there is nothing to rank, so routing returns an honest no-route result. Route selects; it never executes.
72
72
  - **VERIFICATION STATUS:** Tested. `test/cli-dispatch.test.js`: "route: disabled subsystem -> exit 3", "route: enabled, no candidates -> honest no-route, exit 0", "route: missing --goal -> usage error, exit 2", "router dispatch honors the fast speed policy", "router dispatch runs real selectTool/rankTools over candidates".
73
73
 
@@ -97,7 +97,7 @@ Shared notes for all actions:
97
97
  - **CAN IT MOVE MONEY?** It is the only action designed with execution side effects, and it is budget-gated, dry-run capable, and idempotent. In the current build it cannot move money: no live executable route is wired, so execution honestly does not happen. [implementation, tested]
98
98
  - **CAN IT CREATE OR RETRY AN AUTHORIZATION?** No in the current build. `--force-retry` after a stored run whose settlement is UNKNOWN is explicitly refused (`unsafe_retry_refused`, exit 5) to prevent double-spend.
99
99
  - **FAIL-CLOSED CONDITIONS:** Missing `--intent` → exit 2; over budget → exit 4, zero side effects; above approval threshold without `--approve` → exit 4; retry after UNKNOWN → exit 5; individual stages (intent engine, planner, router, kernel) disabled or unreachable are recorded in `data.stages` and planning continues honestly.
100
- - **EXAMPLE:** `callx402 execute --intent "complete this job for under $1" --max-budget 1.00 --dry-run --idempotency-key job-42 --json`
100
+ - **EXAMPLE:** `callx402 execute --intent 'complete this job for under $1' --max-budget 1.00 --dry-run --idempotency-key job-42 --json`
101
101
  - **CURRENT LIMITATIONS:** No live executable route is wired in this build; `execute` currently plans honestly and stops. Effect proofs, settlement resolution, and receipts are only populated on real execution. Live execution would require a wired route plus an enabled execution kernel.
102
102
  - **VERIFICATION STATUS:** Tested. `test/intent-pipeline.test.js` (8 tests, incl. "execute: dry-run intent -> exit 0, honest no-route disposition", "cost above approval threshold without --approve fails closed, exit 4", "explicit --approve passes the threshold gate", "over-budget intent -> budget_refused, exit 4, zero side effects", "duplicate idempotencyKey: second call deduped with ORIGINAL result", "unsafe retry refusal", "intent pipeline walks stages in order", "networks/assets/providers constrain planner, router and spendguard"). `test/safety.test.js` (7 tests) covers the money-safety invariants end to end. Real CLI run verified 2026-10-06: intent-mode dispatch returns the honest "no executable route available" disposition with per-stage detail.
103
103
 
@@ -37,3 +37,39 @@ Options:
37
37
 
38
38
  Exit codes: 0 ok · 1 failure · 2 usage · 3 disabled/unreachable ·
39
39
  4 budget refused · 5 settlement unknown (never auto-retry, never repay).
40
+
41
+ ================================================================================
42
+ ADDENDUM 2026-10-07 — per-command help and rail-only routing hint
43
+ ================================================================================
44
+ Change: `callx402 <command> --help` now prints that command's own usage
45
+ instead of the global help (bin/callx402.js COMMAND_HELP). Bare
46
+ `callx402 --help` output is unchanged (verified identical above). Unknown
47
+ commands matching a rail-only paid action (safe_retry, duplicate_payment_risk,
48
+ failure_classification, settlement_interpretation, hyphenated aliases) now get
49
+ a routing hint to docs/problem-map.md instead of a bare usage error.
50
+
51
+ $ callx402 recover --help (exit 0)
52
+ callx402 recover — report the safe recovery decision (read-only).
53
+
54
+ Usage:
55
+ callx402 recover <operationId> [--identity <id> | --evidence <json>]
56
+
57
+ Evaluates recorded history against the operation identity and returns the
58
+ recovery decision: RECOVERABLE, SAFE_RETRY, EXECUTED_BUT_UNRECOVERABLE, or
59
+ SETTLEMENT_UNKNOWN, mapped to KNOWN_SAFE or HUMAN_REVIEW. Never charges,
60
+ never executes, never repays. Requires PAYLOAD_VEYLINE_RECOVERY=1.
61
+
62
+ This is the read-only answer to "is a retry safe?" — a SAFE_RETRY verdict
63
+ here is advisory; performing the safe action stays with your own adapters.
64
+ For the paid one-off verdict on a specific retry plan, use the 'safe_retry'
65
+ rail action (quote first, then pay; see docs/problem-map.md). The MCP tool
66
+ x402_recover covers the same free diagnostic.
67
+
68
+ Exit codes: 0 ok · 1 failure · 2 usage (missing identity/evidence) ·
69
+ 3 disabled/unreachable · 4 budget refused ·
70
+ 5 settlement unknown (never auto-retry, never repay).
71
+
72
+ $ callx402 safe_retry (exit 2, stderr)
73
+ callx402: unknown command 'safe_retry' — 'safe_retry' is a paid on-demand rail action, not a CLI command. Get the free quote, then pay deliberately: see docs/problem-map.md
74
+ Valid commands: diagnose, rescue, route, resolve, doctor, execute, monitor, status, preflight, inspect, config, evidence, explain, recover
75
+ Run 'callx402 --help' for usage, or 'callx402 <command> --help' for one command.