atris 3.62.0 → 3.64.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.
Files changed (72) hide show
  1. package/atris/features/README.md +8 -0
  2. package/atris/skills/README.md +1 -0
  3. package/atris/skills/commerce/SKILL.md +120 -0
  4. package/atris/skills/x-search/SKILL.md +3 -1
  5. package/ax +4 -3
  6. package/bin/atris.js +22 -2
  7. package/cli/atris_code.py +25 -1
  8. package/commands/auth.js +79 -10
  9. package/commands/autoland.js +11 -1
  10. package/commands/browse.js +2 -2
  11. package/commands/business.js +3 -3
  12. package/commands/buy.js +242 -0
  13. package/commands/deck.js +2 -2
  14. package/commands/engine.js +122 -13
  15. package/commands/feedback.js +2 -2
  16. package/commands/fork.js +2 -2
  17. package/commands/gm.js +2 -1
  18. package/commands/improve.js +142 -8
  19. package/commands/integrations.js +4 -3
  20. package/commands/loops.js +2 -1
  21. package/commands/member.js +82 -15
  22. package/commands/mission.js +4 -1
  23. package/commands/pack-craft.js +2 -2
  24. package/commands/publish.js +2 -2
  25. package/commands/radar.js +3 -2
  26. package/commands/router.js +2 -2
  27. package/commands/task.js +91 -2
  28. package/commands/transactions.js +115 -0
  29. package/commands/truth.js +2 -2
  30. package/commands/x-search.js +81 -10
  31. package/commands/youtube.js +24 -1
  32. package/lib/autoland.js +3 -2
  33. package/lib/ax-auto-lane.js +2 -2
  34. package/lib/brief-rules.js +11 -0
  35. package/lib/chat-log-scan.js +2 -1
  36. package/lib/ci-runner.js +3 -2
  37. package/lib/codex-flight.js +2 -1
  38. package/lib/commerce.js +90 -0
  39. package/lib/deck-history.js +2 -2
  40. package/lib/deck-review.js +3 -3
  41. package/lib/design-api.js +2 -2
  42. package/lib/engine-registry.js +102 -18
  43. package/lib/first-minute.js +2 -2
  44. package/lib/fleet.js +8 -36
  45. package/lib/git-spawn.js +5 -2
  46. package/lib/ignored-cache.js +51 -0
  47. package/lib/known-commands.js +1 -1
  48. package/lib/manifest.js +2 -2
  49. package/lib/map-refs.js +70 -4
  50. package/lib/member-engine.js +15 -1
  51. package/lib/member-scaffold.js +2 -0
  52. package/lib/member-type.js +27 -0
  53. package/lib/pack-capabilities.js +2 -1
  54. package/lib/permission-grants.js +2 -2
  55. package/lib/pulse.js +5 -5
  56. package/lib/roster-runs.js +13 -2
  57. package/lib/runner-command.js +54 -3
  58. package/lib/sandbox-pids.js +113 -0
  59. package/lib/secret-gateway.js +3 -13
  60. package/lib/state-home.js +106 -0
  61. package/lib/task-db.js +4 -3
  62. package/lib/task-prune.js +462 -0
  63. package/lib/x-post-check.js +497 -0
  64. package/package.json +1 -1
  65. package/scripts/det/data/xsearch-answer.json +24 -0
  66. package/scripts/det/data/xsearch-live-2026-09-29.json +93 -0
  67. package/scripts/det/xsearch-bench.js +307 -0
  68. package/scripts/det/ytnotes +122 -16
  69. package/scripts/det/ytrail-bench.js +215 -0
  70. package/scripts/det/ytrail-eval.js +128 -56
  71. package/utils/auth.js +3 -3
  72. package/utils/update-check.js +2 -2
@@ -86,6 +86,14 @@ None.
86
86
 
87
87
  ### Completed Features
88
88
 
89
+ #### youtube-notes
90
+ Free, fast, checked notes from any YouTube link
91
+ - **Files:** commands/youtube.js, scripts/det/ytnotes, scripts/det/ytquote-repair.js, test/ytnotes.test.js, test/ytquote-repair.test.js, test/youtube.test.js, atris/features/youtube-notes/*
92
+ - **Status:** complete; the `atris youtube bench` scorecard is in review
93
+ - **Keywords:** youtube, notes, captions, transcript, whisper, quotes
94
+ - **What:** `atris youtube notes <url>` reads the real English captions, transcribes on an Apple silicon Mac when there are none, writes with the fastest installed AI, and checks every quote against the transcript
95
+ - **Completed:** 2026-09-28
96
+
89
97
  #### audit-gaps
90
98
  Close remaining audit gaps from self-audit
91
99
  - **Files:** atris/team/*/MEMBER.md, atris/features/README.md, atris/features/audit-gaps/*
@@ -34,6 +34,7 @@ cp -r atris/skills/[name] ~/.codex/skills/
34
34
  | backend | Backend architecture policy | `policies/atris-backend.md` |
35
35
  | blocks | Author Atris block documents: docs, decks, reports | - |
36
36
  | calendar | Google Calendar via AtrisOS API | - |
37
+ | commerce | Quote a purchase or stock order; the person approves before anything is charged | - |
37
38
  | copy-editor | Detects and fixes AI writing patterns | - |
38
39
  | create-app | Build and deploy an Atris app from a description | - |
39
40
  | create-member | Create and manage MEMBER.md team members | - |
@@ -0,0 +1,120 @@
1
+ ---
2
+ name: commerce
3
+ description: "Buy or trade through Atris for a person: get a quote, show it, the person approves, check status, read their transactions. Use when asked to buy tickets, buy an item from a store, place a stock order, or list what someone bought or traded. Triggers on buy, purchase, order, trade, stock order, transactions, what did I spend."
4
+ version: 1.0.0
5
+ tags:
6
+ - commerce
7
+ - money
8
+ ---
9
+
10
+ # Commerce: buy or trade through Atris
11
+
12
+ You can quote and ask. Only the human can pay. Every dollar leaves only after
13
+ the person acts themselves: a tap in their Link app, a payment page they
14
+ fill in, or a text from their own phone.
15
+
16
+ ## Get a key
17
+
18
+ The person mints a commerce key from their own signed-in CLI. It can ask for
19
+ prices and start their approval. It can never pay.
20
+
21
+ ```bash
22
+ atris login --agent --commerce --commerce-max-usd 150
23
+ # scopes: commerce:quote, transactions:read
24
+ # largest single quote: $150 (1 to 500, default 200)
25
+ # this key can ask for prices and start your approval, it can never pay.
26
+ ```
27
+
28
+ - Same machine: nothing else to do. The key is stored in its own slot
29
+ (`commerce_agent_token` in `~/.atris/credentials.json`), and `atris buy` and
30
+ `atris transactions` use it. Minting an x-search or YouTube key later does not
31
+ replace it.
32
+ - Agent on another machine: add `--print-key` to show the key once. The person
33
+ hands it to the agent as `ATRIS_TOKEN` (CLI) or `ATRIS_API_KEY` (curl below).
34
+ Never paste it into a chat.
35
+ - Narrower key: `--scopes commerce:quote` (quote, approve, status) or
36
+ `--scopes transactions:read` (history only).
37
+ - Quotes over the key's cap are refused, and each key gets 50 quotes a day (UTC). A developer key (`atris api-key`) or the
38
+ person's own login also works, without those per-key limits.
39
+
40
+ ## The loop
41
+
42
+ 1. Quote. `atris buy quote --kind <kind> [fields]`.
43
+ 2. Show the person the summary, total, fee and approval text. Word for word on money.
44
+ 3. Only after they say yes, approve. `atris buy approve <id> [fields]`.
45
+ 4. Hand the person the approval link (Link or payment page). For trades, tell them to check their texts: Atris sends the one-time code only to their own text thread, never to you. Nothing else pays.
46
+ 5. Check `atris buy status <id>` until the state is `done`, `failed`, `refunded` or `unknown`.
47
+ 6. `atris transactions` lists everything they bought, paid in fees, traded or sent.
48
+
49
+ Add `--json` to any command for the raw response.
50
+
51
+ ## Kinds
52
+
53
+ | kind | quote flags | approve flags | human approves by |
54
+ |---|---|---|---|
55
+ | `ticket` | `--event-url` (vividseats.com), `--quantity`, `--min-each-usd`, `--max-each-usd` | `--pick` (1-3 or ticket_id), `--delivery-email` | Link app, or payment page |
56
+ | `shop` | `--query` or `--url`, `--quantity` | `--options '<json>'`, `--delivery-email`, `--address '<json>'` (US, once), `--accept-extras` | Link app |
57
+ | `trade` | `--side` buy/sell, `--symbol`, `--quantity` or `--dollars`, `--order-type` market/limit, `--limit-price` | none | replying to Atris's text with the exact phrase from their own phone, within 5 minutes |
58
+ | `flight` | not wired yet, quotes are refused | | |
59
+
60
+ Any field can also go in as JSON: `--intent '{"kind":"shop","query":"navy tee"}'` for quotes,
61
+ `--details '{"pick":"1"}'` for approve. Flags win over the JSON.
62
+
63
+ ## Rails (enforced in code, not by you)
64
+
65
+ - Approval is always human. An API key cannot confirm a trade or approve a Link spend.
66
+ - Caps: purchases up to `TEXT_TICKETS_MAX_USD` (default $400) and $500 per Link spend. Stocks: per-order and per-day caps, the Robinhood risk policy, market hours, no shorting, stocks only.
67
+ - Quotes expire: 30 minutes for purchases, 10 for trades. A trade quote stages at most one order. Ask again after that.
68
+ - A trade already waiting for the person's yes blocks a new one. Let them confirm or let it expire.
69
+ - No retries. If a result is `unknown`, money may have moved. Tell the person to check the store or Robinhood app. Never buy again to "fix" it.
70
+ - No advice on trades. Carry out only the person's own instruction.
71
+ - For Link-mode orders, status may carry `fee_payment_url`: the page where the person pays the Atris fee. The CLI prints it as "Pay the Atris fee".
72
+ - Status values from quote and approve: `quoted`, `pending_approval`, `needs_input` (ask the person for the named field), `needs_setup` (send them the connect link), `refused` (read the message to them).
73
+
74
+ ## With the CLI
75
+
76
+ ```bash
77
+ # Tickets
78
+ atris buy quote --kind ticket --event-url "https://www.vividseats.com/..." --quantity 2
79
+ atris buy approve ord_<uuid> --pick 1 --delivery-email sam@example.com
80
+ # -> Approve in Link: https://... Nothing has been charged yet.
81
+
82
+ # A store item
83
+ atris buy quote --kind shop --query "uniqlo supima crew tee navy" --quantity 1
84
+
85
+ # A stock order
86
+ atris buy quote --kind trade --side buy --symbol VTI --dollars 25
87
+ atris buy approve trq_<token>
88
+ # -> We texted you a confirm code. Reply to that text to place the trade.
89
+
90
+ # Status and history
91
+ atris buy status ord_<uuid>
92
+ atris transactions --since 2026-09-01 --kind ticket,shop,trade --limit 50
93
+ # Quotes nobody approved are left out; add --include-quotes to see them.
94
+ ```
95
+
96
+ ## With curl
97
+
98
+ ```bash
99
+ KEY="Authorization: Bearer $ATRIS_API_KEY"
100
+ API=https://api.atris.ai/api
101
+
102
+ curl -s -X POST $API/commerce/quote -H "$KEY" -H 'content-type: application/json' \
103
+ -d '{"kind":"ticket","event_url":"https://www.vividseats.com/...","quantity":2}'
104
+ curl -s -X POST $API/commerce/ord_<uuid>/approve -H "$KEY" -H 'content-type: application/json' \
105
+ -d '{"pick":"1","delivery_email":"sam@example.com"}'
106
+ # -> {"status":"pending_approval","approval":{"how":"link_app","url":"https://..."}}
107
+
108
+ curl -s -X POST $API/commerce/quote -H "$KEY" -H 'content-type: application/json' \
109
+ -d '{"kind":"trade","side":"buy","symbol":"VTI","dollars":25}'
110
+ curl -s -X POST $API/commerce/trq_<token>/approve -H "$KEY"
111
+ # -> {"status":"pending_approval","approval":{"how":"text_confirm","text":"Check your texts. ..."}}
112
+
113
+ curl -s $API/commerce/ord_<uuid> -H "$KEY"
114
+ curl -s "$API/transactions?since=2026-09-01&kinds=ticket,shop,trade&limit=50" -H "$KEY"
115
+ # Quotes nobody approved are left out; add &include_quotes=true to see them.
116
+ ```
117
+
118
+ Each transaction: `id, kind, when, what, amount_cents, currency, state, counterparty, receipt_url`.
119
+ `sources` says which ledgers loaded; if one says `failed`, the list is partial. Say so.
120
+ The CLI prints that as "Partial list: could not load ...".
@@ -49,7 +49,9 @@ atris x-search person --name "Leah Bonvissuto" --handle leahbon
49
49
  atris x-search --help
50
50
  ```
51
51
 
52
- Add `--json` for the raw API payload. Curl below is the raw API for debugging only.
52
+ Every cited post is looked up on X for free (the public embed endpoint, 5s cap), whatever layout the answer uses. A cited post whose words appear in the answer is checked: its `https://x.com/<handle>/status/<id> (checked)` link prints under that post, or in a `Checked posts:` list when the answer is a table or prose. A quote of 40+ characters that matches no real post says `(could not find this post on X, treat as unverified)`; if X could not be reached it says so and is never called fake. Citations no post uses print as `Other sources:`. The last line is `posts: N checked, N unverified, N unknown`. `--no-check` skips the lookup. Bench it with `atris x-search bench --free` (0 credits) or `--quick` (5 credits).
53
+
54
+ Add `--json` for the raw API payload (plus a `checks` list). Curl below is the raw API for debugging only.
53
55
 
54
56
  ## Bootstrap (ALWAYS Run First)
55
57
 
package/ax CHANGED
@@ -5,6 +5,7 @@ const http = require('http');
5
5
  const https = require('https');
6
6
  const os = require('os');
7
7
  const path = require('path');
8
+ const { stateHome } = require('./lib/state-home');
8
9
  const readline = require('readline');
9
10
  const crypto = require('crypto');
10
11
  const { Readable } = require('stream');
@@ -230,7 +231,7 @@ function createRunLogger({ cwd = process.cwd(), mode = 'pro', kind = 'play', out
230
231
 
231
232
  const baseDir = fs.existsSync(path.join(cwd, 'atris'))
232
233
  ? path.join(cwd, 'atris', 'runs')
233
- : path.join(os.homedir(), '.atris', 'runs');
234
+ : path.join(stateHome(), '.atris', 'runs');
234
235
  fs.mkdirSync(baseDir, { recursive: true });
235
236
  const logPath = path.join(baseDir, `ax-${kind}-${timestampForFile()}.log`);
236
237
  fs.appendFileSync(logPath, [
@@ -995,7 +996,7 @@ function normalizeMode(mode) {
995
996
  function autoPickLogPath(options = {}) {
996
997
  return options.pickLogPath
997
998
  || process.env.ATRIS_ROUTER_PICKS_PATH
998
- || path.join(os.homedir(), '.atris', 'ax-auto-picks.jsonl');
999
+ || path.join(stateHome(), '.atris', 'ax-auto-picks.jsonl');
999
1000
  }
1000
1001
 
1001
1002
  function autoMessageWords(message) {
@@ -2328,7 +2329,7 @@ function formatTaskPreviewRows(receipt, options = {}) {
2328
2329
  }
2329
2330
 
2330
2331
  function approvalStorePath(options = {}) {
2331
- return options.storePath || process.env.AX_APPROVAL_STORE || path.join(os.homedir(), '.atris', 'ax-approvals.json');
2332
+ return options.storePath || process.env.AX_APPROVAL_STORE || path.join(stateHome(), '.atris', 'ax-approvals.json');
2332
2333
  }
2333
2334
 
2334
2335
  function approvalMaxAgeMs(options = {}) {
package/bin/atris.js CHANGED
@@ -717,6 +717,8 @@ function showHelpAll() {
717
717
  console.log(' usage - Show developer API usage');
718
718
  console.log(' api-key - Create, list, rotate, or revoke a developer API key');
719
719
  console.log(' topup - Buy credits and print a Stripe checkout URL');
720
+ console.log(' buy - Quote a purchase or stock order; the person approves before anything is charged');
721
+ console.log(' transactions - List what you bought, paid in fees, traded, or sent');
720
722
  console.log(' design - Extract a site design system, check brand adherence, search brands');
721
723
  console.log(' mcp - Run the atris MCP server (stdio) for Claude Desktop and Cursor');
722
724
  console.log('');
@@ -1009,6 +1011,9 @@ function showAuthHelp(commandName) {
1009
1011
  console.log(' --agent Mint a scoped agent token from the stored login.');
1010
1012
  console.log(' --scopes <list> Comma-separated scopes (default: x-search,youtube).');
1011
1013
  console.log(' --daily-credit-cap <n> Daily credit cap (default: 50).');
1014
+ console.log(' --commerce Commerce key: scopes commerce:quote,transactions:read. Can quote, never pay.');
1015
+ console.log(' --commerce-max-usd <n> Largest single quote a commerce key may ask for, 1 to 500 (default: 200).');
1016
+ console.log(' --print-key Print the minted key once, for an agent on another machine.');
1012
1017
  } else if (commandName === 'switch') {
1013
1018
  console.log(' --global, -g Switch the account for all terminals.');
1014
1019
  }
@@ -1854,9 +1859,15 @@ function showWelcomeVisualization() {
1854
1859
  // Every new chat sees which tool and model does each job, from the same
1855
1860
  // resolver as atris engine roster. A roster that cannot be read drops the
1856
1861
  // line; it never stops the boot.
1862
+ // A roster worker that ends within seven days gets one line, so the job
1863
+ // does not quietly fall to its backup when the date passes.
1857
1864
  try {
1858
- const teamLine = require('../lib/team-lineup').bootTeamLine(cwd, { width: 80 - 11 });
1865
+ const engineView = require('../commands/engine');
1866
+ const jobs = engineView.jobRosterView(cwd);
1867
+ const teamLine = require('../lib/team-lineup').bootTeamLine(cwd, { width: 80 - 11, jobs });
1859
1868
  if (teamLine) console.log(row('team', teamLine));
1869
+ const endsLine = engineView.bootExpiringLine(engineView.expiringWorkers(jobs));
1870
+ if (endsLine) console.log(row('roster', endsLine));
1860
1871
  } catch {
1861
1872
  // Silent: the lineup is a hint, not a gate.
1862
1873
  }
@@ -2368,6 +2379,15 @@ if (command === 'guide') {
2368
2379
  Promise.resolve(require('../commands/topup').topupCommand(process.argv.slice(3)))
2369
2380
  .then((code) => process.exit(typeof code === 'number' ? code : 0))
2370
2381
  .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2382
+ } else if (command === 'buy') {
2383
+ // Buy: quote, then hand the person the approval step. Never charges.
2384
+ Promise.resolve(require('../commands/buy').buyCommand(process.argv.slice(3)))
2385
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2386
+ .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2387
+ } else if (command === 'transactions') {
2388
+ Promise.resolve(require('../commands/transactions').transactionsCommand(process.argv.slice(3)))
2389
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2390
+ .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2371
2391
  } else if (command === '_resolve') {
2372
2392
  // Hidden: resolve a profile name query → print exact profile name
2373
2393
  require('../commands/auth').resolveProfile();
@@ -2970,7 +2990,7 @@ if (command === 'guide') {
2970
2990
  const subcommand = process.argv[3];
2971
2991
  const args = process.argv.slice(4);
2972
2992
  Promise.resolve(require('../commands/member').memberCommand(subcommand, ...args))
2973
- .then(() => process.exit(subcommand === 'run' ? (process.exitCode || 0) : 0))
2993
+ .then(() => process.exit(process.exitCode || 0))
2974
2994
  .catch((err) => { console.error(`✗ Error: ${err.message || err}`); process.exit(1); });
2975
2995
  } else if (command === 'app') {
2976
2996
  const subcommand = process.argv[3];
package/cli/atris_code.py CHANGED
@@ -12,11 +12,13 @@ from __future__ import annotations
12
12
 
13
13
  import argparse
14
14
  import asyncio
15
+ import getpass
15
16
  import json
16
17
  import logging
17
18
  import os
18
19
  import subprocess
19
20
  import sys
21
+ import tempfile
20
22
  import time
21
23
  from dataclasses import dataclass
22
24
  from pathlib import Path
@@ -36,7 +38,29 @@ RED = "\033[91m"
36
38
 
37
39
  PACKAGE_ROOT = Path(__file__).resolve().parent.parent
38
40
  TEAM_DIR = PACKAGE_ROOT / "atris" / "team"
39
- STATE_DIR = Path.home() / ".atris"
41
+
42
+
43
+ def _same_dir(a: str, b: str) -> bool:
44
+ try:
45
+ return os.path.samefile(a, b)
46
+ except OSError:
47
+ return False
48
+
49
+
50
+ def _state_home() -> Path:
51
+ """Mirror lib/state-home.js: a test run never writes the real ~/.atris."""
52
+ env, home = os.environ, Path.home()
53
+ under_test = env.get("NODE_TEST_CONTEXT") or env.get("ATRIS_TEST_STATE_DIR")
54
+ if env.get("ATRIS_TEST_REAL_HOME") == "1" or not under_test:
55
+ return home
56
+ real = [os.path.expanduser("~" + getpass.getuser()), env.get("ATRIS_TEST_PROTECTED_HOME", "")]
57
+ if not any(r and _same_dir(str(home), r) for r in real):
58
+ return home
59
+ user = os.getuid() if hasattr(os, "getuid") else getpass.getuser()
60
+ return Path(env.get("ATRIS_TEST_STATE_DIR") or os.path.join(tempfile.gettempdir(), f"atris-test-home-{user}"))
61
+
62
+
63
+ STATE_DIR = _state_home() / ".atris"
40
64
  SESSION_STATE_FILE = STATE_DIR / "computer_sessions.json"
41
65
  AUDIT_LOG_FILE = STATE_DIR / "computer_audit.jsonl"
42
66
  RESUME_MAX_AGE_SECONDS = 6 * 60 * 60
package/commands/auth.js CHANGED
@@ -8,6 +8,13 @@ const path = require('path');
8
8
  const AGENT_TOKEN_PATH = '/auth/agent-token';
9
9
  const DEFAULT_AGENT_SCOPES = ['x-search', 'youtube'];
10
10
  const DEFAULT_DAILY_CREDIT_CAP = 50;
11
+ // Commerce keys can ask for prices and start the owner's approval. They can
12
+ // never pay: every dollar still needs the owner's own tap or text. They live
13
+ // in their own slot so an x-search or YouTube mint never overwrites them.
14
+ const COMMERCE_QUOTE_SCOPE = 'commerce:quote';
15
+ const COMMERCE_SCOPES = [COMMERCE_QUOTE_SCOPE, 'transactions:read'];
16
+ const COMMERCE_MAX_USD_LIMIT = 500;
17
+ const COMMERCE_NEVER_PAYS = 'this key can ask for prices and start your approval, it can never pay.';
11
18
  const NO_STORED_JWT_MESSAGE = 'not signed in. run atris login first.';
12
19
 
13
20
  function firstNonEmptyString(...values) {
@@ -23,8 +30,12 @@ function wantsAgentToken(args = []) {
23
30
  return first === 'agent-token';
24
31
  }
25
32
 
26
- function parseScopeList(raw) {
27
- if (!raw) return [...DEFAULT_AGENT_SCOPES];
33
+ function isCommerceKey(scopes = []) {
34
+ return Array.isArray(scopes) && scopes.some((scope) => COMMERCE_SCOPES.includes(String(scope)));
35
+ }
36
+
37
+ function parseScopeList(raw, fallback = DEFAULT_AGENT_SCOPES) {
38
+ if (!raw) return [...fallback];
28
39
  const scopes = String(raw).split(',').map((part) => part.trim()).filter(Boolean);
29
40
  if (scopes.length === 0) {
30
41
  throw new Error('scopes must list at least one value');
@@ -43,11 +54,29 @@ function parseDailyCreditCap(args) {
43
54
  return value;
44
55
  }
45
56
 
57
+ function parseCommerceMaxUsd(args, scopes) {
58
+ const present = args.some((arg) => arg === '--commerce-max-usd' || String(arg).startsWith('--commerce-max-usd='));
59
+ if (!present) return null;
60
+ const raw = String(readFlag(args, '--commerce-max-usd', '')).replace(/^\$/, '');
61
+ const value = Number(raw);
62
+ if (!Number.isInteger(value) || value < 1 || value > COMMERCE_MAX_USD_LIMIT) {
63
+ throw new Error(`commerce max must be a whole dollar amount from 1 to ${COMMERCE_MAX_USD_LIMIT}`);
64
+ }
65
+ if (!scopes.includes(COMMERCE_QUOTE_SCOPE)) {
66
+ throw new Error('--commerce-max-usd needs the commerce:quote scope (try --commerce)');
67
+ }
68
+ return value;
69
+ }
70
+
46
71
  function parseAgentTokenArgs(args = []) {
72
+ const commerce = hasFlag(args, '--commerce');
73
+ const scopes = parseScopeList(readFlag(args, '--scopes', ''), commerce ? COMMERCE_SCOPES : DEFAULT_AGENT_SCOPES);
47
74
  return {
48
75
  agent: wantsAgentToken(args),
49
- scopes: parseScopeList(readFlag(args, '--scopes', '')),
76
+ scopes,
50
77
  dailyCreditCap: parseDailyCreditCap(args),
78
+ commerceMaxUsd: parseCommerceMaxUsd(args, scopes),
79
+ printKey: hasFlag(args, '--print-key'),
51
80
  json: wantsJson(args),
52
81
  help: hasFlag(args, '--help') || hasFlag(args, '-h') || args[0] === 'help',
53
82
  };
@@ -82,7 +111,14 @@ function extractAgentTokenMeta(data, requested, token) {
82
111
  const dailyCreditCap = Number.isFinite(Number(rawCap)) ? Number(rawCap) : requested.dailyCreditCap;
83
112
  const expiresAt = expiryFromValue(payload.expires_at ?? payload.expiry ?? payload.expires ?? payload.exp)
84
113
  || expiryFromValue(getTokenExpiryEpochSeconds(token));
85
- return { scopes, dailyCreditCap, expiresAt };
114
+ const rawMax = payload.commerce_max_quote_usd;
115
+ const commerceMaxUsd = Number.isFinite(Number(rawMax)) && rawMax !== null && rawMax !== undefined
116
+ ? Number(rawMax)
117
+ : (requested.commerceMaxUsd || null);
118
+ const commerceDailyQuotes = Number.isFinite(Number(payload.commerce_daily_quotes)) && payload.commerce_daily_quotes != null
119
+ ? Number(payload.commerce_daily_quotes)
120
+ : null;
121
+ return { scopes, dailyCreditCap, expiresAt, commerceMaxUsd, commerceDailyQuotes };
86
122
  }
87
123
 
88
124
  function isAgentAccessToken(token) {
@@ -116,12 +152,22 @@ function persistMintedAgentToken(credentials, token, extras = {}) {
116
152
  if (isAgentAccessToken(credentials.token)) {
117
153
  throw new Error('Refusing to save a scoped agent token as the login token; keep it under agent_token');
118
154
  }
155
+ const slot = isCommerceKey(extras.scopes)
156
+ ? {
157
+ commerce_agent_token: token,
158
+ commerce_agent_token_scopes: extras.scopes || [],
159
+ commerce_agent_token_expires_at: extras.expiresAt || null,
160
+ commerce_max_quote_usd: extras.commerceMaxUsd || null,
161
+ }
162
+ : {
163
+ agent_token: token,
164
+ agent_token_scopes: extras.scopes || [],
165
+ agent_token_expires_at: extras.expiresAt || null,
166
+ };
119
167
  const next = {
120
168
  ...credentials,
121
169
  refresh_token: extras.refresh_token || credentials.refresh_token || null,
122
- agent_token: token,
123
- agent_token_scopes: extras.scopes || [],
124
- agent_token_expires_at: extras.expiresAt || null,
170
+ ...slot,
125
171
  };
126
172
  if (credentials.source_profile) {
127
173
  saveProfile(credentials.source_profile, next);
@@ -132,10 +178,14 @@ function persistMintedAgentToken(credentials, token, extras = {}) {
132
178
  }
133
179
 
134
180
  function printAgentTokenMint(meta, output) {
135
- output('minted scoped agent token');
181
+ const commerce = isCommerceKey(meta.scopes);
182
+ output(commerce ? 'minted commerce agent key' : 'minted scoped agent token');
136
183
  output(`scopes: ${meta.scopes.join(', ')}`);
184
+ if (meta.commerceMaxUsd) output(`largest single quote: $${meta.commerceMaxUsd}`);
185
+ if (meta.commerceDailyQuotes) output(`quotes per day: ${meta.commerceDailyQuotes}`);
137
186
  output(`daily credit cap: ${meta.dailyCreditCap}`);
138
187
  if (meta.expiresAt) output(`expires: ${meta.expiresAt}`);
188
+ if (commerce) output(COMMERCE_NEVER_PAYS);
139
189
  }
140
190
 
141
191
  function redactSecret(text, secret) {
@@ -175,10 +225,16 @@ async function mintScopedAgentToken(requested = {}, deps = {}) {
175
225
  return { ok: false, code: 'not_logged_in', error: NO_STORED_JWT_MESSAGE };
176
226
  }
177
227
 
228
+ const commerceMaxUsd = Number.isInteger(requested.commerceMaxUsd) ? requested.commerceMaxUsd : null;
229
+ if (commerceMaxUsd !== null && !scopes.includes(COMMERCE_QUOTE_SCOPE)) {
230
+ return { ok: false, code: 'invalid_scopes', error: '--commerce-max-usd needs the commerce:quote scope' };
231
+ }
232
+
178
233
  const body = {
179
234
  scopes,
180
235
  daily_credit_cap: dailyCreditCap,
181
236
  };
237
+ if (commerceMaxUsd !== null) body.commerce_max_quote_usd = commerceMaxUsd;
182
238
  let authToken = isAgentAccessToken(accessToken) ? refreshToken : (accessToken || refreshToken);
183
239
  let result = await postAgentToken(api, authToken, body);
184
240
  if (!result.ok && result.status === 401 && refreshToken && authToken !== refreshToken) {
@@ -201,7 +257,7 @@ async function mintScopedAgentToken(requested = {}, deps = {}) {
201
257
  return { ok: false, code: 'missing_token', error: 'backend did not return an agent token' };
202
258
  }
203
259
 
204
- const meta = extractAgentTokenMeta(result.data, { scopes, dailyCreditCap }, minted);
260
+ const meta = extractAgentTokenMeta(result.data, { scopes, dailyCreditCap, commerceMaxUsd }, minted);
205
261
  persist(credentials, minted, {
206
262
  ...meta,
207
263
  refresh_token: firstNonEmptyString(result.data && result.data.refresh_token) || refreshToken,
@@ -212,7 +268,7 @@ async function mintScopedAgentToken(requested = {}, deps = {}) {
212
268
  ok: true,
213
269
  token: minted,
214
270
  meta,
215
- storedIn: credentials.source_profile ? `profile ${credentials.source_profile}, agent_token` : '~/.atris/credentials.json, agent_token',
271
+ storedIn: `${credentials.source_profile ? `profile ${credentials.source_profile}` : '~/.atris/credentials.json'}, ${isCommerceKey(meta.scopes) ? 'commerce_agent_token' : 'agent_token'}`,
216
272
  };
217
273
  }
218
274
 
@@ -276,6 +332,7 @@ async function mintAgentToken(args = [], deps = {}) {
276
332
  const minted = await mintScopedAgentToken({
277
333
  scopes: options.scopes,
278
334
  dailyCreditCap: options.dailyCreditCap,
335
+ commerceMaxUsd: options.commerceMaxUsd,
279
336
  }, deps);
280
337
 
281
338
  if (!minted.ok) {
@@ -302,12 +359,24 @@ async function mintAgentToken(args = [], deps = {}) {
302
359
  scopes: minted.meta.scopes,
303
360
  daily_credit_cap: minted.meta.dailyCreditCap,
304
361
  expires_at: minted.meta.expiresAt,
362
+ ...(isCommerceKey(minted.meta.scopes) ? {
363
+ commerce_max_quote_usd: minted.meta.commerceMaxUsd,
364
+ commerce_daily_quotes: minted.meta.commerceDailyQuotes,
365
+ can_pay: false,
366
+ note: COMMERCE_NEVER_PAYS,
367
+ } : {}),
368
+ ...(options.printKey ? { key: minted.token } : {}),
305
369
  }, null, 2));
306
370
  return 0;
307
371
  }
308
372
 
309
373
  printAgentTokenMint(minted.meta, output);
310
374
  output(`stored in ${minted.storedIn}`);
375
+ if (options.printKey) {
376
+ // Only on request: an outside agent needs the raw key once.
377
+ output('key (shown once, give it to the agent as ATRIS_TOKEN, do not paste it into chat):');
378
+ output(minted.token);
379
+ }
311
380
  return 0;
312
381
  }
313
382
 
@@ -135,7 +135,16 @@ function runOwnCli(root, cliArgs, timeoutMs = 300000) {
135
135
  // target root or a global `atris` breaks whenever the tick runs a project
136
136
  // that isn't the CLI repo on a machine without a global install (CI, cron).
137
137
  const bin = path.resolve(__dirname, '..', 'bin', 'atris.js');
138
- const result = spawnSync(process.execPath, [bin, ...cliArgs], { cwd: root, encoding: 'utf8', timeout: timeoutMs });
138
+ return spawnCapture([bin, ...cliArgs], { cwd: root, timeoutMs });
139
+ }
140
+
141
+ // spawnSync keeps 1 MB of stdout by default and kills the child past it
142
+ // (ENOBUFS). A certify-and-land sweep over 150+ review rows prints more than
143
+ // that, so the accept step read as unreadable on most ticks. Allow 64 MB.
144
+ const OWN_CLI_MAX_BUFFER = 64 * 1024 * 1024;
145
+
146
+ function spawnCapture(nodeArgs, { cwd, timeoutMs = 300000 } = {}) {
147
+ const result = spawnSync(process.execPath, nodeArgs, { cwd, encoding: 'utf8', timeout: timeoutMs, maxBuffer: OWN_CLI_MAX_BUFFER });
139
148
  return {
140
149
  status: result.status,
141
150
  stdout: String(result.stdout || ''),
@@ -1139,6 +1148,7 @@ module.exports = {
1139
1148
  digestNextMoves,
1140
1149
  acceptSweepTimeoutMs,
1141
1150
  describeAcceptKillCause,
1151
+ spawnCapture,
1142
1152
  digestStoryRows,
1143
1153
  digestTickStatus,
1144
1154
  engineAnswerValidationLine,
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const os = require('os');
3
+ const { stateHome } = require('../lib/state-home');
4
4
  const { loadCredentials } = require('../utils/auth');
5
5
  const { apiRequestJson } = require('../utils/api');
6
6
 
@@ -34,7 +34,7 @@ async function browseAtris() {
34
34
  if (!result.ok) {
35
35
  // API failed, try local templates
36
36
  console.log(' API unavailable, checking local templates...');
37
- const localDir = path.join(os.homedir(), '.atris', 'templates');
37
+ const localDir = path.join(stateHome(), '.atris', 'templates');
38
38
  if (!fs.existsSync(localDir)) {
39
39
  console.error(' No templates found locally or from API.');
40
40
  process.exit(1);
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const { stateHome } = require('../lib/state-home');
3
4
  const os = require('os');
4
5
  const { loadCredentials } = require('../utils/auth');
5
6
  const { apiRequestJson } = require('../utils/api');
@@ -18,8 +19,7 @@ function businessWorkspaceBase() {
18
19
  }
19
20
 
20
21
  function getBusinessConfigPath() {
21
- const home = require('os').homedir();
22
- const dir = path.join(home, '.atris');
22
+ const dir = path.join(stateHome(), '.atris');
23
23
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
24
24
  return path.join(dir, 'businesses.json');
25
25
  }
@@ -3139,7 +3139,7 @@ async function connectService(connector, ...flags) {
3139
3139
  }
3140
3140
 
3141
3141
  // Create local secrets directory
3142
- const secretsDir = path.join(require('os').homedir(), '.atris', 'secrets', connector);
3142
+ const secretsDir = path.join(stateHome(), '.atris', 'secrets', connector);
3143
3143
  if (!fs.existsSync(secretsDir)) {
3144
3144
  fs.mkdirSync(secretsDir, { recursive: true });
3145
3145
  console.log(`\n Created secrets dir: ${secretsDir}/`);