@thirdfy/agent-cli 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  All notable changes to `@thirdfy/agent-cli` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [0.6.0] - 2026-10-18
6
+
7
+ `thirdfy-agent track-record` proves an agent's trading track record, the CLI side of thirdfy-mcp's `prove_track_record` (thirdfy-mcp 0.1.47). `track actions` and `track events` now send the agent's key, which the API requires. A minor version for the new command.
8
+
9
+ ### Security
10
+
11
+ - **`track actions` and `track events` send the agent's credential** (THI-292; MCP THI-148, thirdfy/thirdfy-mcp#292). They called `GET /api/v1/agent/telemetry/{actions,events}` with no credential, and the API used to answer with every agent's rows, user DIDs included. The API now refuses keyless reads (THI-149) and gives an agent key its own rows only. The CLI sends the agent key (`--agent-api-key`, `THIRDFY_AGENT_API_KEY` or config) in `x-agent-api-key`, never in the URL. The API does not let an OAuth sign-in read these routes, so a sign-in is skipped for the key in config. `--agent-key` is now optional (the key names its agent). With no credential the command refuses `AGENT_API_KEY_REQUIRED` (`check_credentials`) before any call. Scripts that passed only `--agent-key` need the agent's key.
12
+
13
+ ### Added
14
+
15
+ - **`track-record challenge | verify | score | status | public <agentKey>`** (THI-291; thirdfy-api-v2 4.7.0, THI-255/THI-280; MCP thirdfy/thirdfy-mcp#293 and thirdfy/thirdfy-mcp#294). An agent proves it controls a Hyperliquid or Polymarket wallet that Thirdfy never funded and gets a score of the wallet's public venue history, signed by Thirdfy.
16
+ - **Steps:** `challenge --venue --wallet` returns a one-time message; you sign it with the wallet; `verify --challenge-id --signature` binds the wallet to the agent; `score [--venue --wallet]` scores the proven wallets; `status` lists them with their latest scores; `public <agentKey>` reads any agent's published record (no key).
17
+ - **Output:** the API's body plus `step`, `nextStep` (the command to run next), and for scores `summary` (venue, wallet, `eligible`, `reasons` and `reasonNotes`, `windowDays`, `pnlUsd`, `maxDrawdownUsd`, `trades`, `signer`, `digest`) and `verifyNote`. Schema: `track-record.response.schema.json`.
18
+ - **Checking a score:** `verifyNote` gives the 4.7.0 rule: the digest is keccak256 of `thirdfy-track-record/v1`, a newline, then the canonical JSON of `score`; compare the recovered EIP-191 signer with Thirdfy's signer (`GET /api/v1/track-record/signer`), never the `signer` field in the score.
19
+ - **Refusals:** every API code gets the MCP's `nextAction`, `retryable` and a hint with CLI commands: `AGENT_API_KEY_REQUIRED`, `AGENT_API_KEY_INVALID`, `TRACK_RECORD_UNAVAILABLE`, `TRACK_RECORD_DISABLED`, `SCORE_SIGNER_UNAVAILABLE`, `VENUE_HISTORY_UNAVAILABLE`, `VENUE_UNSUPPORTED`, `WALLET_INVALID`, `SIGNATURE_INVALID`, `SIGNATURE_MISMATCH`, `CHALLENGE_NOT_FOUND`, `CHALLENGE_USED`, `CHALLENGE_EXPIRED`, `WALLET_NOT_PROVEN`, `WALLET_BOUND_TO_OTHER_AGENT`, `TRACK_RECORD_NOT_PUBLISHED`. Also the 4.7.0 codes `AGENT_REGISTRY_UNAVAILABLE`, `RPC_UNAVAILABLE` and `NO_WALLET_SCORED` (503: call again or pass `--wallet`; 502: see each wallet's error in `results`).
20
+ - New next actions: `start_new_challenge`, `prove_wallet`, `owner_publish`.
21
+ - A timed-out `challenge` or `score` is sent once more (neither moves anything); `verify` spends its one-use challenge, so a timeout there is `WRITE_OUTCOME_UNKNOWN`: check `track-record status`.
22
+ - **Credential:** the agent API key (`--agent-api-key`, `THIRDFY_AGENT_API_KEY` or config). The track-record routes take only the agent key (they are not on the API's OAuth allowlist), so an OAuth sign-in is skipped for the key in config, and with no key the command refuses `AGENT_API_KEY_REQUIRED` before any call.
23
+ - Never asks for a venue key, never holds the wallet key, moves no funds and places no orders. Publishing stays the owner's step, signed in to Thirdfy.
24
+
25
+ ### Changed
26
+
27
+ - `mcp-tool-map.json` maps `prove_track_record` to `track-record challenge` (68 MCP tools, 66 with a CLI command). `validate:provider-parity` resolves an MCP tool whose `name:` is a string constant in its own file.
28
+
29
+ ## [0.5.1] - 2026-10-16
30
+
31
+ A patch release: `doctor certify execution` works for agents with a narrow allowlist.
32
+
33
+ ### Fixed
34
+
35
+ - **`doctor certify execution` no longer fails on venue writes outside the agent's allowlist** (THI-201). With an agent key, the CLI reads the agent's own action list, so bare certification flagged every venue write the agent may not call (for example `place_hyperliquid_tp_sl` or `update_lighter_leverage`) as `CATALOG_SCHEMA_UNAVAILABLE` and failed. It now checks only the venue writes the agent may call and lists the rest in `skippedVenueActions`. An allowed action with a broken schema, a `--required-actions` entry the agent can't call, and a public catalog missing a venue write all still fail.
36
+
5
37
  ## [0.5.0] - 2026-10-15
6
38
 
7
39
  `thirdfy-agent login` signs in with OAuth in the browser (auth.thirdfy.com, the same sign-in the MCP uses), as one agent with read access, plus trade with `--trade`. A minor version because `login` without a token flag now starts that sign-in instead of failing, and `credits balance` / `pricing quote` drop their credit counts for USD. `login email`, token flags and agent API keys work as before.
package/README.md CHANGED
@@ -39,11 +39,15 @@ Run without global install:
39
39
  npx @thirdfy/agent-cli --help
40
40
  ```
41
41
 
42
- ## What's new in v0.5.0
43
-
44
- - **Sign in with your Thirdfy account:** `thirdfy-agent login` opens your browser at auth.thirdfy.com. You pick one agent and approve read access, and trade access with `thirdfy-agent login --trade`. The CLI then acts as that agent, refreshes the sign-in by itself, and `thirdfy-agent logout` revokes it. Disconnect it any time in Connected apps at auth.thirdfy.com.
45
- - **Same sign-in as the MCP:** the same consent page and the same Connected apps list as Claude, ChatGPT and Cursor.
46
- - **Nothing else changes:** `login email`, `--auth-token`, `--owner-session-token` and agent API keys work as before, and an explicit `--agent-api-key` always wins over the sign-in.
42
+ ## What's new in v0.6.0
43
+
44
+ - **Prove a trading track record: `thirdfy-agent track-record`.** Your agent proves it controls a Hyperliquid or Polymarket wallet and gets a score of that wallet's public history (PnL, drawdown, trades, eligibility reasons), signed by Thirdfy. Same steps and answers as the MCP tool `prove_track_record`.
45
+ - `track-record challenge --venue hyperliquid --wallet 0x…` returns a message; sign it with the wallet yourself.
46
+ - `track-record verify --challenge-id <id> --signature 0x…` binds the wallet to the agent.
47
+ - `track-record score`, `track-record status`, and `track-record public <agentKey>` for any agent's published record.
48
+ - **Nothing moves:** no funds, no orders, no venue key, and the CLI never holds the wallet key. Scores stay private until the agent's owner publishes them.
49
+ - **Clear next steps on every refusal:** each one carries `errorCode`, `retryable`, `nextAction` and a `hint`. See [`docs/command-reference.md`](./docs/command-reference.md#track-records-track-record), including how to check a score's signature.
50
+ - **`track actions` and `track events` need the agent's key** (`--agent-api-key`, env or config; an OAuth sign-in cannot read them) and return only that agent's rows. `--agent-key` alone no longer works.
47
51
  - Older versions: see [CHANGELOG.md](./CHANGELOG.md).
48
52
 
49
53
  ## Quick start
@@ -352,6 +356,7 @@ Provider and venue guides are kept outside `README` so this page stays stable as
352
356
  - Discovery: `catalogs list`, `signals` (data provider apps and their signals: fields with units, freshness, roles, credits, attribution; `--provider`, `--role`), `actions` (optional `--provider`, `--chain-id`, `--data-category`; see [`docs/command-reference.md`](./docs/command-reference.md) for trading vs earn vs prediction providers and case-insensitive matching)
353
357
  - Discovery: `actions`, `chains` (chain ids, CAIP-2 and lanes from the API)
354
358
  - Readiness and credits: `readiness` (can the agent act on a chain), `credits balance`, `credits buy --usd <n>`
359
+ - Track records: `track-record challenge`, `track-record verify`, `track-record score`, `track-record status`, `track-record public <agentKey>` (prove a trading wallet, get a Thirdfy-signed score)
355
360
  - Execution: `preflight`, `preview`, `run`, `intent-status`
356
361
  - Polymarket: `polymarket status`, `polymarket setup`, `polymarket dry-run`
357
362
  - Venue tooling: `venue readiness`, `venue setup`, `venue fund` (`polymarket`, `hyperliquid`, `lighter`, `veranta`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thirdfy/agent-cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Thirdfy Agent CLI for onboarding, governance preflight, execute-intent, and status polling.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,7 @@
23
23
  ],
24
24
  "scripts": {
25
25
  "test": "node --test test/*/*.test.cjs test/*/*/*.test.cjs",
26
- "lint": "node --check ./bin/thirdfy-agent.mjs && node --check ./src/core/*.mjs && node --check ./src/core/execution/*.mjs && node --check ./src/core/oauth/*.mjs && node --check ./src/commands/*.mjs && node --check ./src/commands/venues/*.mjs && node --check ./src/commands/onboarding/*.mjs && node --check ./src/commands/account/*.mjs && node --check ./src/commands/signals/*.mjs && node --check ./src/cli/*.mjs && node --check ./src/cli/options/*.mjs && node --check ./src/cli/options/venues/*.mjs && node --check ./src/cli/options/onboarding/*.mjs && node --check ./src/cli/options/account/*.mjs && node --check ./src/runtime/*.mjs && node --check ./src/runtime/execution/*.mjs && node --check ./src/runtime/execution/runners/*.mjs && node --check ./src/runtime/actions/*.mjs && node --check ./src/runtime/validation/*.mjs && node --check ./src/runtime/certification/*.mjs && node --check ./src/runtime/providerHints/*.mjs && node --check ./src/runtime/risk/*.mjs",
26
+ "lint": "node --check ./bin/thirdfy-agent.mjs && node --check ./src/core/*.mjs && node --check ./src/core/execution/*.mjs && node --check ./src/core/oauth/*.mjs && node --check ./src/commands/*.mjs && node --check ./src/commands/venues/*.mjs && node --check ./src/commands/onboarding/*.mjs && node --check ./src/commands/account/*.mjs && node --check ./src/commands/signals/*.mjs && node --check ./src/commands/trackRecord/*.mjs && node --check ./src/cli/*.mjs && node --check ./src/cli/options/*.mjs && node --check ./src/cli/options/venues/*.mjs && node --check ./src/cli/options/onboarding/*.mjs && node --check ./src/cli/options/account/*.mjs && node --check ./src/runtime/*.mjs && node --check ./src/runtime/execution/*.mjs && node --check ./src/runtime/execution/runners/*.mjs && node --check ./src/runtime/actions/*.mjs && node --check ./src/runtime/validation/*.mjs && node --check ./src/runtime/certification/*.mjs && node --check ./src/runtime/providerHints/*.mjs && node --check ./src/runtime/risk/*.mjs",
27
27
  "validate:cli-manifest-handlers": "node ./scripts/validate-cli-manifest-handlers.mjs",
28
28
  "validate:runtime-boundaries": "node ./scripts/validate-runtime-boundaries.mjs",
29
29
  "validate:npm-pack": "node ./scripts/validate-npm-pack.mjs",
@@ -16,6 +16,7 @@ const REQUIRED_FILES = [
16
16
  'readiness.response.schema.json',
17
17
  'error.response.schema.json',
18
18
  'wallet-profile.response.schema.json',
19
+ 'track-record.response.schema.json',
19
20
  ];
20
21
 
21
22
  const REQUIRED_KEYS = ['$schema', 'title', 'type'];
@@ -647,6 +647,43 @@ export const CLI_MANIFEST = [
647
647
  options: ['signals'],
648
648
  description: 'List data provider apps and the signals they publish (fields, units, freshness, roles, credits)',
649
649
  },
650
+ // track records (thirdfy-mcp prove_track_record): prove a trading wallet, get a Thirdfy-signed score
651
+ {
652
+ path: ['track-record', 'challenge'],
653
+ handler: 'commandTrackRecordChallenge',
654
+ tier: 'offline',
655
+ options: ['trackRecord'],
656
+ description: 'Get a one-time message to sign with a trading wallet: --venue hyperliquid|polymarket --wallet <0x>',
657
+ },
658
+ {
659
+ path: ['track-record', 'verify'],
660
+ handler: 'commandTrackRecordVerify',
661
+ tier: 'offline',
662
+ options: ['trackRecord'],
663
+ description: "Prove the wallet: --challenge-id <id> --signature <the wallet's signature of the message>",
664
+ },
665
+ {
666
+ path: ['track-record', 'score'],
667
+ handler: 'commandTrackRecordScore',
668
+ tier: 'offline',
669
+ options: ['trackRecord'],
670
+ description: 'Score the proven wallets from public venue history, signed by Thirdfy (optional --venue, --wallet)',
671
+ },
672
+ {
673
+ path: ['track-record', 'status'],
674
+ handler: 'commandTrackRecordStatus',
675
+ tier: 'offline',
676
+ options: ['trackRecord'],
677
+ description: "This agent's proven wallets and latest scores, published or not",
678
+ },
679
+ {
680
+ path: ['track-record', 'public'],
681
+ handler: 'commandTrackRecordPublic',
682
+ tier: 'offline',
683
+ options: ['trackRecord'],
684
+ description: "An agent's published track record (no key needed)",
685
+ args: { query: '<agentKey>' },
686
+ },
650
687
  {
651
688
  path: ['readiness'],
652
689
  handler: 'commandReadiness',
@@ -20,6 +20,7 @@ import { attachBrainOptions } from './brain.mjs';
20
20
  import { attachCertificationOptions } from './certification.mjs';
21
21
  import { attachActionListOptions, attachDataCategoryOptions, attachSignalsOptions } from './data.mjs';
22
22
  import { attachWalletControlOptions } from './account/walletControl.mjs';
23
+ import { attachTrackRecordOptions } from './trackRecord.mjs';
23
24
 
24
25
  const BUNDLES = {
25
26
  auth: attachAuthOptions,
@@ -48,6 +49,7 @@ const BUNDLES = {
48
49
  dataCategory: attachDataCategoryOptions,
49
50
  actionList: attachActionListOptions,
50
51
  walletControl: attachWalletControlOptions,
52
+ trackRecord: attachTrackRecordOptions,
51
53
  };
52
54
 
53
55
  /** @param {import('commander').Command} cmd */
@@ -0,0 +1,9 @@
1
+ /** `track-record` steps: the agent API key, and the step's own fields. No venue key and no wallet key. */
2
+ export function attachTrackRecordOptions(cmd) {
3
+ return cmd
4
+ .option('--agent-api-key <key>', 'agent API key (every step but public)')
5
+ .option('--venue <venue>', 'challenge (required) and score (optional filter): the venue the wallet trades on, for example hyperliquid or polymarket')
6
+ .option('--wallet <address>', 'challenge (required) and score (optional filter): the 0x wallet; for Polymarket, the proxy wallet trades settle to')
7
+ .option('--challenge-id <id>', 'verify (required): the challengeId the challenge step returned')
8
+ .option('--signature <hex>', "verify (required): the wallet's signature of the challenge message, exactly as returned");
9
+ }
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from 'module';
2
2
  import { requireFlag } from '../core/args.mjs';
3
3
  import { apiGet, requestWithRouteFallback } from '../core/http.mjs';
4
- import { loadProfileConfig, getProfileConfigPath, getCachedActionsCatalog } from '../core/context.mjs';
4
+ import { loadProfileConfig, getProfileConfigPath, getCachedActionsCatalogSnapshot } from '../core/context.mjs';
5
5
  import { DEFAULT_API_BASE, PROFILE_DEFAULTS } from '../core/constants.mjs';
6
6
  import { DEFAULT_CHAIN_ID, describeChainGas, parseChainIdFlag, pinnedRpcProvider, resolveChainRpcUrl } from '../core/chainRegistry.mjs';
7
7
  import { exitAfterOutput } from '../core/envelope.mjs';
@@ -333,9 +333,12 @@ async function commandManagedSetup(ctx, flags, capabilities) {
333
333
 
334
334
  async function commandDoctorCertifyExecution(ctx, flags) {
335
335
  let liveActions = null;
336
+ let catalogPolicyScoped = false;
336
337
  let catalogError = null;
337
338
  try {
338
- liveActions = await getCachedActionsCatalog(ctx, flags);
339
+ const snapshot = await getCachedActionsCatalogSnapshot(ctx, flags);
340
+ liveActions = snapshot.actions;
341
+ catalogPolicyScoped = snapshot.policyScoped === true;
339
342
  } catch (error) {
340
343
  catalogError = {
341
344
  message: error?.message || 'Actions catalog fetch failed',
@@ -356,6 +359,7 @@ async function commandDoctorCertifyExecution(ctx, flags) {
356
359
  runMode,
357
360
  expectedSchemaDigest,
358
361
  requiredActions,
362
+ catalogPolicyScoped,
359
363
  });
360
364
 
361
365
  printEnvelope({
@@ -369,6 +373,7 @@ async function commandDoctorCertifyExecution(ctx, flags) {
369
373
  catalog: {
370
374
  fetched: Array.isArray(liveActions),
371
375
  actionCount: Array.isArray(liveActions) ? liveActions.length : 0,
376
+ policyScoped: catalogPolicyScoped,
372
377
  error: catalogError,
373
378
  },
374
379
  },
@@ -1,5 +1,7 @@
1
1
  import { requireFlag, parseJsonFlag } from '../core/args.mjs';
2
2
  import { apiGet, apiPost } from '../core/http.mjs';
3
+ import { findAgentApiKeyWithoutOAuth } from '../core/context.mjs';
4
+ import { createCliError } from '../core/envelope.mjs';
3
5
  import { hasVerantaToken, normalizeVenueProviderId } from '../core/venueBrand.mjs';
4
6
  export function createTelemetryCommands({ printEnvelope, resolveAgentApiKey }) {
5
7
  function applySharedPortfolioListingFiltersToQuery(query, flags) {
@@ -80,11 +82,35 @@ async function commandAnalyticsLeaderboard(ctx, flags, capabilities) {
80
82
  });
81
83
  }
82
84
 
85
+ /**
86
+ * `track actions` / `track events`: the agent's own tracked rows. The API answers only an agent
87
+ * key (that agent's rows, without user DIDs) or an operator key (THI-149), so the agent key goes in
88
+ * `x-agent-api-key`, never the URL, as thirdfy-mcp getAgentTrackActions / getAgentTrackEvents
89
+ * (THI-148). The API's OAuth allowlist does not open these routes, so an OAuth sign-in is skipped
90
+ * for the key in config (`findAgentApiKeyWithoutOAuth`). The key names its agent;
91
+ * `--agent-key` is optional and must be that agent. No credential: refused before any call.
92
+ */
83
93
  async function commandTrackList(ctx, flags, capabilities, kind) {
84
- const agentKey = requireFlag(flags, 'agentKey', 'Missing --agent-key');
85
- const query = new URLSearchParams({ agentKey });
94
+ const agentApiKey = findAgentApiKeyWithoutOAuth(flags);
95
+ if (!agentApiKey) {
96
+ const error = createCliError(
97
+ 'AGENT_API_KEY_REQUIRED',
98
+ `track ${kind} reads the agent's own rows and needs its API key (--agent-api-key, THIRDFY_AGENT_API_KEY or config; an OAuth sign-in cannot read them).`,
99
+ { blockedStage: 'auth' },
100
+ );
101
+ error.errorFields = {
102
+ nextAction: 'check_credentials',
103
+ retryable: false,
104
+ hint: "Tracked actions and events belong to an agent: pass --agent-api-key, set THIRDFY_AGENT_API_KEY, or save the key with `thirdfy-agent login --agent-api-key <key>`. An OAuth sign-in cannot read them.",
105
+ };
106
+ throw error;
107
+ }
108
+ const agentKey = String(flags.agentKey || '').trim();
109
+ const query = new URLSearchParams();
110
+ if (agentKey) query.set('agentKey', agentKey);
86
111
  if (flags.limit) query.set('limit', String(flags.limit));
87
- const response = await apiGet(ctx, `/api/v1/agent/telemetry/${kind}?${query.toString()}`);
112
+ const search = query.toString();
113
+ const response = await apiGet(ctx, `/api/v1/agent/telemetry/${kind}${search ? `?${search}` : ''}`, { 'x-agent-api-key': agentApiKey });
88
114
  printEnvelope({
89
115
  success: true,
90
116
  code: kind === 'events' ? 'TRACK_EVENTS_OK' : 'TRACK_ACTIONS_OK',
@@ -92,7 +118,7 @@ async function commandTrackList(ctx, flags, capabilities, kind) {
92
118
  data: response,
93
119
  meta: {
94
120
  apiBase: ctx.apiBase,
95
- agentKey,
121
+ agentKey: agentKey || null,
96
122
  kind,
97
123
  },
98
124
  });
@@ -0,0 +1,187 @@
1
+ import { usageError } from '../../core/args.mjs';
2
+ import { findAgentApiKeyWithoutOAuth } from '../../core/context.mjs';
3
+ import { createCliError } from '../../core/envelope.mjs';
4
+ import { apiGet, apiPost } from '../../core/http.mjs';
5
+ import { TRACK_RECORD_REFUSALS, trackRecordRefusal } from './refusals.mjs';
6
+ import { VERIFY_NOTE, formatScoreLines, scoreSummaries } from './scoreSummary.mjs';
7
+
8
+ /**
9
+ * `thirdfy-agent track-record <step>`: the CLI side of thirdfy-mcp's prove_track_record (THI-256,
10
+ * THI-291). An agent proves it controls a trading wallet and gets a score of that wallet's public
11
+ * venue history, signed by Thirdfy. It calls the Thirdfy API's track-record routes (THI-255,
12
+ * thirdfy-api-v2 `docs-dev/agents/track-record.md`):
13
+ *
14
+ * - `challenge --venue --wallet` -> `POST /api/v1/track-record/challenge`: a one-time message. The
15
+ * agent signs it with the wallet itself; the CLI never holds or asks for the wallet key.
16
+ * - `verify --challenge-id --signature` -> `POST /verify`: the wallet is bound to the agent.
17
+ * - `score [--venue] [--wallet]` -> `POST /score`: signed scores of the proven wallets.
18
+ * - `status` -> `GET /me`: the agent's proven wallets and latest scores, published or not.
19
+ * - `public <agentKey>` -> `GET /public/:agentKey`: what anyone sees once the owner has published.
20
+ *
21
+ * Every step but `public` sends the agent API key (`x-agent-api-key`); the API takes the agent from
22
+ * the key, never from the body. These routes read only that header, so an OAuth sign-in (sent as
23
+ * `Authorization: Bearer`, which the API's OAuth allowlist does not open here) is skipped for the
24
+ * agent key saved in config (`findAgentApiKeyWithoutOAuth`), or the CLI refuses with
25
+ * AGENT_API_KEY_REQUIRED before any call. Publishing is the owner's step (signed in to
26
+ * Thirdfy) and is not offered. Nothing moves funds, places orders or asks for a venue key.
27
+ */
28
+
29
+ const BASE = '/api/v1/track-record';
30
+
31
+ function agentApiKeyRequired() {
32
+ const error = createCliError('AGENT_API_KEY_REQUIRED', 'track-record needs the agent API key (every step but public).', {
33
+ blockedStage: 'auth',
34
+ });
35
+ error.errorFields = TRACK_RECORD_REFUSALS.AGENT_API_KEY_REQUIRED;
36
+ return error;
37
+ }
38
+
39
+ function missingArguments(step, names) {
40
+ return usageError(`track-record ${step} needs ${names.map((name) => `--${name}`).join(' and ')}.`, names[0]);
41
+ }
42
+
43
+ /** A track-record refusal gets its next step (`errorFields`); anything else is left as thrown. */
44
+ function withRefusalFields(error) {
45
+ const code = String(error?.payload?.error || '').trim();
46
+ const refusal = error?.statusCode ? trackRecordRefusal(code, Number(error.statusCode)) : undefined;
47
+ if (refusal) error.errorFields = refusal;
48
+ return error;
49
+ }
50
+
51
+ /** What the agent does after a successful step. */
52
+ function nextStepOf(step, data) {
53
+ if (step === 'challenge') {
54
+ const challenge = data.challenge && typeof data.challenge === 'object' ? data.challenge : {};
55
+ return {
56
+ step: 'verify',
57
+ instruction:
58
+ 'Sign `challenge.message` exactly as returned with the wallet (personal_sign) before `expiresAt`, then run `thirdfy-agent track-record verify --challenge-id <challengeId> --signature <signature>`. Thirdfy never sees the wallet key.',
59
+ challengeId: challenge.challengeId,
60
+ expiresAt: challenge.expiresAt,
61
+ };
62
+ }
63
+ if (step === 'verify') {
64
+ return { step: 'score', instruction: 'Run `thirdfy-agent track-record score` to score the proven wallet.' };
65
+ }
66
+ if (step === 'score' || step === 'status') {
67
+ return {
68
+ step: 'owner_publish',
69
+ instruction:
70
+ "Scores stay private until the agent's owner publishes them, signed in to Thirdfy. After that, `thirdfy-agent track-record public <agentKey>` shows them to anyone.",
71
+ };
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ const MESSAGES = {
77
+ challenge: 'Challenge created: sign the message with the wallet',
78
+ verify: 'Wallet proven',
79
+ score: 'Scores signed',
80
+ status: 'Track record loaded',
81
+ public: 'Published track record loaded',
82
+ };
83
+
84
+ export function createTrackRecordCommands({ printEnvelope }) {
85
+ async function runStep(ctx, step, request) {
86
+ let data;
87
+ try {
88
+ // challenge and score move nothing and are safe to send again, so a timeout there is retried
89
+ // and never WRITE_OUTCOME_UNKNOWN; verify spends its one-use challenge and stays a write.
90
+ const callCtx = request.resendable ? { ...ctx, writeAction: false } : ctx;
91
+ const response =
92
+ request.method === 'GET'
93
+ ? await apiGet(callCtx, request.route, request.headers)
94
+ : await apiPost(callCtx, request.route, request.body, request.headers);
95
+ data = response && typeof response === 'object' && !Array.isArray(response) ? response : {};
96
+ } catch (error) {
97
+ throw withRefusalFields(error);
98
+ }
99
+ const scored = step === 'score' || step === 'status' || step === 'public';
100
+ const summary = scored ? scoreSummaries(data.scores) : [];
101
+ const nextStep = nextStepOf(step, data);
102
+ const lead = [
103
+ scored ? formatScoreLines(summary) : '',
104
+ step === 'challenge' && data.challenge?.message ? `Message to sign:\n${data.challenge.message}` : '',
105
+ nextStep ? `Next: ${nextStep.instruction}` : '',
106
+ ]
107
+ .filter(Boolean)
108
+ .join('\n');
109
+ printEnvelope(
110
+ {
111
+ success: true,
112
+ code: 'OK',
113
+ message: MESSAGES[step],
114
+ data: {
115
+ ...data,
116
+ step,
117
+ ...(scored ? { summary, verifyNote: VERIFY_NOTE } : {}),
118
+ ...(nextStep ? { nextStep } : {}),
119
+ },
120
+ meta: { apiBase: ctx.apiBase, step },
121
+ },
122
+ { humanLead: lead },
123
+ );
124
+ }
125
+
126
+ function agentHeaders(flags) {
127
+ const agentApiKey = findAgentApiKeyWithoutOAuth(flags);
128
+ if (!agentApiKey) throw agentApiKeyRequired();
129
+ return { 'x-agent-api-key': agentApiKey };
130
+ }
131
+
132
+ async function commandTrackRecordChallenge(ctx, flags) {
133
+ const venue = String(flags.venue || '').trim();
134
+ const wallet = String(flags.wallet || '').trim();
135
+ if (!venue || !wallet) throw missingArguments('challenge', ['venue', 'wallet']);
136
+ await runStep(ctx, 'challenge', {
137
+ method: 'POST',
138
+ route: `${BASE}/challenge`,
139
+ resendable: true,
140
+ body: { venue, wallet },
141
+ headers: agentHeaders(flags),
142
+ });
143
+ }
144
+
145
+ async function commandTrackRecordVerify(ctx, flags) {
146
+ const challengeId = String(flags.challengeId || '').trim();
147
+ const signature = String(flags.signature || '').trim();
148
+ if (!challengeId || !signature) throw missingArguments('verify', ['challenge-id', 'signature']);
149
+ await runStep(ctx, 'verify', {
150
+ method: 'POST',
151
+ route: `${BASE}/verify`,
152
+ body: { challengeId, signature },
153
+ headers: agentHeaders(flags),
154
+ });
155
+ }
156
+
157
+ async function commandTrackRecordScore(ctx, flags) {
158
+ const venue = String(flags.venue || '').trim();
159
+ const wallet = String(flags.wallet || '').trim();
160
+ await runStep(ctx, 'score', {
161
+ method: 'POST',
162
+ route: `${BASE}/score`,
163
+ resendable: true,
164
+ body: { ...(venue ? { venue } : {}), ...(wallet ? { wallet } : {}) },
165
+ headers: agentHeaders(flags),
166
+ });
167
+ }
168
+
169
+ async function commandTrackRecordStatus(ctx, flags) {
170
+ await runStep(ctx, 'status', { method: 'GET', route: `${BASE}/me`, headers: agentHeaders(flags) });
171
+ }
172
+
173
+ async function commandTrackRecordPublic(ctx, flags) {
174
+ // Commander requires <agentKey>; it arrives as the positional `query`.
175
+ const positional = Array.isArray(flags.query) ? flags.query[0] : flags.query;
176
+ const agentKey = String(positional || '').trim().toLowerCase();
177
+ await runStep(ctx, 'public', { method: 'GET', route: `${BASE}/public/${encodeURIComponent(agentKey)}`, headers: {} });
178
+ }
179
+
180
+ return {
181
+ commandTrackRecordChallenge,
182
+ commandTrackRecordVerify,
183
+ commandTrackRecordScore,
184
+ commandTrackRecordStatus,
185
+ commandTrackRecordPublic,
186
+ };
187
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * What an agent does after each refusal of the Thirdfy API's track-record routes (THI-255,
3
+ * `/api/v1/track-record/*`). The API sends only `{ success: false, error, message }`; the CLI adds
4
+ * `nextAction`, `retryable` and a `hint` (as `error.errorFields`, read by `describeCliError`), the
5
+ * same as thirdfy-mcp's prove_track_record (`src/tools/trackRecord/trackRecordRefusals.ts`), with
6
+ * CLI commands in the hints instead of the MCP tool's steps.
7
+ * Codes: thirdfy-api-v2 `docs-dev/agents/track-record.md`.
8
+ */
9
+
10
+ const startOver = (why) => ({
11
+ nextAction: 'start_new_challenge',
12
+ retryable: false,
13
+ hint: `${why} Run \`thirdfy-agent track-record challenge\` again and sign the new message.`,
14
+ });
15
+
16
+ export const TRACK_RECORD_REFUSALS = Object.freeze({
17
+ AGENT_API_KEY_REQUIRED: {
18
+ nextAction: 'check_credentials',
19
+ retryable: false,
20
+ hint: "Track records belong to an agent: pass --agent-api-key, set THIRDFY_AGENT_API_KEY, or save the key with `thirdfy-agent login --agent-api-key <key>`. An OAuth sign-in cannot prove a track record from the CLI; use the agent's API key.",
21
+ },
22
+ AGENT_API_KEY_INVALID: {
23
+ nextAction: 'check_credentials',
24
+ retryable: false,
25
+ hint: 'Thirdfy did not accept this agent API key. Use the key of the agent that trades the wallet.',
26
+ },
27
+ AGENT_REGISTRY_UNAVAILABLE: {
28
+ nextAction: 'retry_later',
29
+ retryable: true,
30
+ hint: 'Thirdfy could not check the agent key right now. This is an outage, not a bad key: retry shortly.',
31
+ },
32
+ TRACK_RECORD_UNAVAILABLE: {
33
+ nextAction: 'retry_later',
34
+ retryable: true,
35
+ hint: 'Thirdfy could not check the key or reach its store. This is an outage, not a bad key: retry shortly.',
36
+ },
37
+ TRACK_RECORD_DISABLED: {
38
+ nextAction: 'contact_support',
39
+ retryable: false,
40
+ hint: 'Track records are switched off on this Thirdfy environment.',
41
+ },
42
+ SCORE_SIGNER_UNAVAILABLE: {
43
+ nextAction: 'contact_support',
44
+ retryable: false,
45
+ hint: 'This Thirdfy environment has no score signing key, so it stores no score. The wallet proof is kept.',
46
+ },
47
+ RPC_UNAVAILABLE: {
48
+ nextAction: 'retry_later',
49
+ retryable: true,
50
+ hint: "Thirdfy could not reach the venue chain to check a contract wallet's signature. Nothing was refused: retry the verify shortly with the same challenge, before it expires.",
51
+ },
52
+ VENUE_HISTORY_UNAVAILABLE: {
53
+ nextAction: 'retry_later',
54
+ retryable: true,
55
+ hint: "The venue's public history API did not answer. Retry the score shortly.",
56
+ },
57
+ VENUE_UNSUPPORTED: {
58
+ nextAction: 'fix_request',
59
+ retryable: false,
60
+ hint: 'Thirdfy does not score this venue yet. Use a venue the API supports (the refusal message says which).',
61
+ },
62
+ WALLET_INVALID: {
63
+ nextAction: 'fix_request',
64
+ retryable: false,
65
+ hint: '--wallet must be a 0x EVM address: the Hyperliquid account, or the Polymarket proxy wallet trades settle to.',
66
+ },
67
+ SIGNATURE_INVALID: {
68
+ nextAction: 'fix_request',
69
+ retryable: false,
70
+ hint: '--signature must be the 0x hex signature of the challenge message, signed with the wallet (personal_sign / EIP-191, or EIP-1271 for a contract wallet).',
71
+ },
72
+ SIGNATURE_MISMATCH: {
73
+ nextAction: 'fix_request',
74
+ retryable: false,
75
+ hint: 'The signature is not from the challenged wallet. Sign the exact challenge message, unchanged, with that wallet.',
76
+ },
77
+ CHALLENGE_NOT_FOUND: startOver('This challenge does not exist for this agent.'),
78
+ CHALLENGE_USED: startOver('This challenge was already used.'),
79
+ CHALLENGE_EXPIRED: startOver('The challenge expired (past its expiresAt).'),
80
+ WALLET_NOT_PROVEN: {
81
+ nextAction: 'prove_wallet',
82
+ retryable: false,
83
+ hint: 'This agent has not proven that wallet. Run `thirdfy-agent track-record challenge`, sign the message with the wallet, then `thirdfy-agent track-record verify`.',
84
+ },
85
+ WALLET_BOUND_TO_OTHER_AGENT: {
86
+ nextAction: 'contact_support',
87
+ retryable: false,
88
+ hint: 'Another agent already proved this wallet. A wallet belongs to one agent.',
89
+ },
90
+ TRACK_RECORD_NOT_PUBLISHED: {
91
+ nextAction: 'owner_publish',
92
+ retryable: false,
93
+ hint: "The agent's owner has not published its track record. The owner opts in, signed in to Thirdfy; an agent key cannot publish.",
94
+ },
95
+ });
96
+
97
+ /**
98
+ * `/score` when no wallet scored (thirdfy-api-v2 4.7.0, THI-280): 503 when every wallet only ran out
99
+ * of time or hit the per-call cap, else 502. Both carry the per-wallet `results`.
100
+ */
101
+ const NO_WALLET_SCORED = Object.freeze({
102
+ 503: {
103
+ nextAction: 'retry_later',
104
+ retryable: true,
105
+ hint: 'No wallet scored in this call: each one ran out of time or passed the per-call cap. Call `thirdfy-agent track-record score` again, or pass --venue and --wallet to score one.',
106
+ },
107
+ 502: {
108
+ nextAction: 'retry_later',
109
+ retryable: true,
110
+ hint: "No wallet could be scored; each wallet's error is in `results` (VENUE_HISTORY_UNAVAILABLE: the venue's public API did not answer). Retry the score shortly.",
111
+ },
112
+ });
113
+
114
+ /** The next step for a track-record refusal, or undefined for a code this table does not know. */
115
+ export function trackRecordRefusal(code, status) {
116
+ if (code === 'NO_WALLET_SCORED') return NO_WALLET_SCORED[status === 503 ? 503 : 502];
117
+ return TRACK_RECORD_REFUSALS[code];
118
+ }
119
+
120
+ /** The line a score adds for each reason it is not eligible. */
121
+ export const INELIGIBLE_REASON_NOTES = Object.freeze({
122
+ SHORT_HISTORY: "SHORT_HISTORY: the account history is shorter than the policy's minimum days.",
123
+ FEW_TRADES: "FEW_TRADES: fewer trades in the window than the policy's minimum.",
124
+ SHORT_HOLDS: "SHORT_HOLDS: too many round trips were held for less than the policy's minimum time.",
125
+ SELF_TRADES: 'SELF_TRADES: too many trades were self-trades.',
126
+ HISTORY_TRUNCATED:
127
+ 'HISTORY_TRUNCATED: the venue had more history pages than Thirdfy reads, so trade counts and PnL are lower bounds. The score is signed but not eligible.',
128
+ });
@@ -0,0 +1,57 @@
1
+ import { INELIGIBLE_REASON_NOTES } from './refusals.mjs';
2
+
3
+ const isRecord = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
4
+
5
+ const stringList = (value) => (Array.isArray(value) ? value.filter((item) => typeof item === 'string') : []);
6
+
7
+ /**
8
+ * One line per signed score: venue, wallet, eligible or why not, and the signer and digest. The
9
+ * same fields as thirdfy-mcp prove_track_record's `summary`.
10
+ */
11
+ export function scoreSummaries(scores) {
12
+ return (Array.isArray(scores) ? scores : []).filter(isRecord).map((signed) => {
13
+ const score = isRecord(signed.score) ? signed.score : {};
14
+ const metrics = isRecord(score.metrics) ? score.metrics : {};
15
+ const reasons = stringList(score.reasons);
16
+ return {
17
+ venue: score.venue,
18
+ wallet: score.wallet,
19
+ eligible: score.eligible,
20
+ reasons,
21
+ ...(reasons.length ? { reasonNotes: reasons.map((reason) => INELIGIBLE_REASON_NOTES[reason] ?? reason) } : {}),
22
+ ...(isRecord(score.window) ? { windowDays: score.window.days } : {}),
23
+ pnlUsd: metrics.pnlUsd,
24
+ maxDrawdownUsd: metrics.maxDrawdownUsd,
25
+ trades: metrics.trades,
26
+ signer: signed.signer,
27
+ digest: signed.digest,
28
+ };
29
+ });
30
+ }
31
+
32
+ /**
33
+ * How anyone checks a score. Since thirdfy-api-v2 4.7.0 (THI-280) the digest carries a domain tag,
34
+ * and the signer to trust is Thirdfy's (`GET /api/v1/track-record/signer`), never the `signer`
35
+ * field inside the score, which anyone can set.
36
+ */
37
+ export const VERIFY_NOTE =
38
+ 'Anyone can check a score: the digest is keccak256 of `thirdfy-track-record/v1`, a newline, then the canonical JSON of `score` (keys sorted). Recover the EIP-191 signer from `digest` and `signature` and compare it with Thirdfy\'s signer from `GET /api/v1/track-record/signer`, not with the `signer` field in the score. `score.evidence` lists the public venue requests it read.';
39
+
40
+ const usd = (value) => {
41
+ const n = Number(value);
42
+ if (value === null || value === undefined || !Number.isFinite(n)) return 'n/a';
43
+ const sign = n < 0 ? '-' : '';
44
+ return `${sign}$${Math.abs(n).toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
45
+ };
46
+
47
+ /** Human mode: one line per score, then the next step. */
48
+ export function formatScoreLines(summaries) {
49
+ if (!summaries.length) return 'No scores yet.';
50
+ return summaries
51
+ .map((row) => {
52
+ const status = row.eligible ? 'eligible' : `not eligible (${row.reasons.join(', ') || 'no reason given'})`;
53
+ const window = row.windowDays ? ` over ${row.windowDays}d` : '';
54
+ return `${row.venue} ${row.wallet}: ${status}; PnL ${usd(row.pnlUsd)}${window}, max drawdown ${usd(row.maxDrawdownUsd)}, ${row.trades ?? 'n/a'} trades`;
55
+ })
56
+ .join('\n');
57
+ }
@@ -0,0 +1,83 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "CLI Endpoint: track-record (challenge, verify, score, status, public)",
4
+ "description": "The API's track-record body (thirdfy-api-v2 /api/v1/track-record/*) plus the fields thirdfy-mcp prove_track_record adds: step, summary and verifyNote (score, status, public), nextStep (all but public).",
5
+ "type": "object",
6
+ "required": ["step"],
7
+ "properties": {
8
+ "step": { "enum": ["challenge", "verify", "score", "status", "public"] },
9
+ "challenge": {
10
+ "type": "object",
11
+ "required": ["challengeId", "message", "expiresAt"],
12
+ "properties": {
13
+ "challengeId": { "type": "string" },
14
+ "venue": { "type": "string" },
15
+ "wallet": { "type": "string" },
16
+ "message": { "type": "string" },
17
+ "expiresAt": { "type": "string" }
18
+ }
19
+ },
20
+ "wallet": { "type": "object" },
21
+ "wallets": { "type": "array" },
22
+ "scores": {
23
+ "type": "array",
24
+ "items": {
25
+ "type": "object",
26
+ "required": ["score", "digest", "signature"],
27
+ "properties": {
28
+ "score": { "type": "object" },
29
+ "digest": { "type": "string" },
30
+ "signature": { "type": "string" },
31
+ "signer": { "type": "string" }
32
+ }
33
+ }
34
+ },
35
+ "results": {
36
+ "type": "array",
37
+ "items": {
38
+ "type": "object",
39
+ "required": ["venue", "wallet", "ok"],
40
+ "properties": {
41
+ "venue": { "type": "string" },
42
+ "wallet": { "type": "string" },
43
+ "ok": { "type": "boolean" },
44
+ "reused": { "type": "boolean" },
45
+ "score": { "type": "object" },
46
+ "error": { "type": "string" },
47
+ "message": { "type": "string" }
48
+ }
49
+ }
50
+ },
51
+ "summary": {
52
+ "type": "array",
53
+ "items": {
54
+ "type": "object",
55
+ "required": ["reasons"],
56
+ "properties": {
57
+ "venue": { "type": ["string", "null"] },
58
+ "wallet": { "type": ["string", "null"] },
59
+ "eligible": { "type": ["boolean", "null"] },
60
+ "reasons": { "type": "array", "items": { "type": "string" } },
61
+ "reasonNotes": { "type": "array", "items": { "type": "string" } },
62
+ "windowDays": { "type": ["number", "null"] },
63
+ "pnlUsd": { "type": ["number", "null"] },
64
+ "maxDrawdownUsd": { "type": ["number", "null"] },
65
+ "trades": { "type": ["number", "null"] },
66
+ "signer": { "type": ["string", "null"] },
67
+ "digest": { "type": ["string", "null"] }
68
+ }
69
+ }
70
+ },
71
+ "verifyNote": { "type": "string" },
72
+ "nextStep": {
73
+ "type": "object",
74
+ "required": ["step", "instruction"],
75
+ "properties": {
76
+ "step": { "enum": ["verify", "score", "owner_publish"] },
77
+ "instruction": { "type": "string" },
78
+ "challengeId": { "type": "string" },
79
+ "expiresAt": { "type": "string" }
80
+ }
81
+ }
82
+ }
83
+ }
@@ -4,7 +4,7 @@ import path from 'path';
4
4
  import { DEFAULT_CATALOG_CACHE_TTL_MS, PROFILE_DEFAULTS } from './constants.mjs';
5
5
  import { createCliError } from './envelope.mjs';
6
6
  import { apiGet } from './http.mjs';
7
- import { apiBaseFor, oauthSessionForApi, oauthSessionIsAgentCredential } from './oauth/session.mjs';
7
+ import { apiBaseFor, isOAuthAccessToken, oauthSessionForApi, oauthSessionIsAgentCredential } from './oauth/session.mjs';
8
8
  import {
9
9
  normalizeCustodyMode,
10
10
  normalizeProfileName,
@@ -139,6 +139,19 @@ export function findAgentApiKey(flags = {}) {
139
139
  return String(config?.agent?.apiKey || '').trim();
140
140
  }
141
141
 
142
+ /**
143
+ * The agent API key for a route an OAuth sign-in may not call. The API lets an OAuth bearer reach
144
+ * only the routes in its allowlist (thirdfy-api-v2 `auth/oauth/oauthRouteAllowlist.ts`): not
145
+ * `/track-record/*` or `/telemetry/{actions,events}`. So an OAuth access token from
146
+ * `findAgentApiKey` is skipped for config `agent.apiKey`; empty when there is no real agent key.
147
+ */
148
+ export function findAgentApiKeyWithoutOAuth(flags = {}) {
149
+ const key = findAgentApiKey(flags);
150
+ if (key && !isOAuthAccessToken(key)) return key;
151
+ const saved = String(loadProfileConfig()?.agent?.apiKey || '').trim();
152
+ return saved && !isOAuthAccessToken(saved) ? saved : '';
153
+ }
154
+
142
155
  /**
143
156
  * Whose credits a credits read is about. Same precedence as `wallet profile`: an owner credential
144
157
  * passed as a flag (--owner-session-token or --auth-token), then an explicit --agent-api-key or an
@@ -22,6 +22,9 @@ export const NEXT_ACTIONS = Object.freeze({
22
22
  use_evm_chain: 'This Thirdfy API does not run that chain family (Solana). Use an EVM chain the API lists in `chains`.',
23
23
  reauthenticate: 'The sign-in expired or was revoked. Sign in again: `thirdfy-agent login`.',
24
24
  reauthenticate_with_trade: 'This sign-in is read-only. Sign in again with trade access: `thirdfy-agent login --trade`.',
25
+ start_new_challenge: 'The wallet challenge cannot be used. Run `thirdfy-agent track-record challenge` again and sign the new message.',
26
+ prove_wallet: 'The agent has not proven this wallet. Run `thirdfy-agent track-record challenge`, sign it with the wallet, then `track-record verify`.',
27
+ owner_publish: "The agent's owner has not published its track record. The owner publishes it, signed in to Thirdfy.",
25
28
  });
26
29
 
27
30
  /** Credits refusal codes the API sends (HTTP 402). */
@@ -138,9 +141,20 @@ function withoutUndefined(fields) {
138
141
  /**
139
142
  * `{ errorCode, retryable, retryAfterSeconds?, nextAction, hint? }` for a thrown CLI error.
140
143
  * It reads what `src/core/http.mjs` attaches (`statusCode`, `payload`, `write`, `retryAfterSeconds`)
141
- * and what `createCliError` attaches (`payload.error`, `payload.blockedStage`).
144
+ * and what `createCliError` attaches (`payload.error`, `payload.blockedStage`). A command that knows
145
+ * the next step for a refusal sets `error.errorFields` (`{ retryable, nextAction, hint }`), and those
146
+ * win: `track-record` does, as thirdfy-mcp's prove_track_record does for the same codes.
142
147
  */
143
148
  export function describeCliError(error) {
149
+ const fields = describeCliErrorFromResponse(error);
150
+ if (!isRecord(error?.errorFields)) return fields;
151
+ const merged = { ...fields, ...withoutUndefined(error.errorFields) };
152
+ // retryAfterSeconds goes with a retryable answer only.
153
+ if (merged.retryable === false) delete merged.retryAfterSeconds;
154
+ return merged;
155
+ }
156
+
157
+ function describeCliErrorFromResponse(error) {
144
158
  const payload = isRecord(error?.payload) ? error.payload : {};
145
159
  const status = Number(error?.statusCode || 0);
146
160
  const data = isRecord(payload.data) ? payload.data : {};
@@ -227,6 +227,7 @@ export function buildExecutionCertificationReport({
227
227
  runMode = null,
228
228
  expectedSchemaDigest = null,
229
229
  requiredActions = [],
230
+ catalogPolicyScoped = false,
230
231
  } = {}) {
231
232
  const version = String(cliVersion || PACKAGE_VERSION).trim();
232
233
  const normalizedRequiredActions = parseRequiredActions(requiredActions);
@@ -259,10 +260,25 @@ export function buildExecutionCertificationReport({
259
260
  }
260
261
  }
261
262
 
263
+ // A keyed catalog lists only the agent's allowlist. Bare certification then checks the venue
264
+ // writes this agent may call; the rest are reported as skipped, not as missing schemas.
265
+ // An unscoped (public) catalog that lacks a venue action still fails closed.
266
+ const scopeToAllowlist = bareCertification && catalogFetched && catalogPolicyScoped === true;
267
+ const liveActionKeys = new Set();
268
+ for (const row of liveActions || []) {
269
+ for (const name of [row?.action, ...(Array.isArray(row?.aliases) ? row.aliases : [])]) {
270
+ if (name) liveActionKeys.add(normalizeActionKey(String(name)));
271
+ }
272
+ }
273
+ const skippedVenueActions = [];
262
274
  const venueActionNames =
263
275
  normalizedRequiredActions.length > 0
264
276
  ? normalizedRequiredActions.filter((action) => isWriteAction(action))
265
- : VENUE_WRITE_ACTION_FIXTURES.map((fixture) => fixture.action);
277
+ : VENUE_WRITE_ACTION_FIXTURES.map((fixture) => fixture.action).filter((action) => {
278
+ if (!scopeToAllowlist || liveActionKeys.has(normalizeActionKey(action))) return true;
279
+ skippedVenueActions.push({ action, reason: 'NOT_ON_AGENT_ALLOWLIST' });
280
+ return false;
281
+ });
266
282
 
267
283
  const venueActions = runSchemaCompatibilityChecks(catalogByAction, venueActionNames, {
268
284
  requireLiveCatalog: bareCertification,
@@ -380,6 +396,7 @@ export function buildExecutionCertificationReport({
380
396
  commandAvailability,
381
397
  authorityInputs,
382
398
  venueActions,
399
+ skippedVenueActions,
383
400
  checks,
384
401
  };
385
402
  }
@@ -12,6 +12,7 @@ import { createConsoleMandateCommands } from '../commands/onboarding/consoleMand
12
12
  import { createBootstrapCommands } from '../commands/onboarding/bootstrap.mjs';
13
13
  import { createDiscoveryCommands } from '../commands/discovery.mjs';
14
14
  import { createSignalsCommands } from '../commands/signals/command.mjs';
15
+ import { createTrackRecordCommands } from '../commands/trackRecord/command.mjs';
15
16
  import { createCreditsCommands } from '../commands/account/credits.mjs';
16
17
  import { createChatCommands } from '../commands/chat.mjs';
17
18
  import { createBrainCommands } from '../commands/brain.mjs';
@@ -70,6 +71,8 @@ export function createRuntimeHandlers(deps) {
70
71
 
71
72
  const __signals = createSignalsCommands({ printEnvelope });
72
73
 
74
+ const __trackRecord = createTrackRecordCommands({ printEnvelope });
75
+
73
76
  const __credits = createCreditsCommands({
74
77
  printEnvelope,
75
78
  getAuthToken,
@@ -244,6 +247,11 @@ export function createRuntimeHandlers(deps) {
244
247
  commandCatalogsList: __discovery.commandCatalogsList,
245
248
  commandActions: __discovery.commandActions,
246
249
  commandSignals: __signals.commandSignals,
250
+ commandTrackRecordChallenge: __trackRecord.commandTrackRecordChallenge,
251
+ commandTrackRecordVerify: __trackRecord.commandTrackRecordVerify,
252
+ commandTrackRecordScore: __trackRecord.commandTrackRecordScore,
253
+ commandTrackRecordStatus: __trackRecord.commandTrackRecordStatus,
254
+ commandTrackRecordPublic: __trackRecord.commandTrackRecordPublic,
247
255
  commandCreditsBalance: __discovery.commandCreditsBalance,
248
256
  commandCreditsBuy: __credits.commandCreditsBuy,
249
257
  commandCreditsTransactions: __credits.commandCreditsTransactions,