atris 3.63.0 → 3.65.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 (81) hide show
  1. package/README.md +4 -0
  2. package/atris/skills/commerce/SKILL.md +24 -0
  3. package/atris/skills/x-search/SKILL.md +3 -1
  4. package/ax +4 -3
  5. package/bin/atris.js +28 -2
  6. package/cli/atris_code.py +25 -1
  7. package/commands/agent-spawn.js +1 -0
  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 +3 -3
  13. package/commands/deck.js +2 -2
  14. package/commands/engine.js +292 -65
  15. package/commands/feedback.js +2 -2
  16. package/commands/fork.js +2 -2
  17. package/commands/gm.js +2 -1
  18. package/commands/hire.js +189 -0
  19. package/commands/improve.js +142 -8
  20. package/commands/integrations.js +4 -3
  21. package/commands/loops.js +2 -1
  22. package/commands/mail.js +163 -0
  23. package/commands/member.js +47 -9
  24. package/commands/mission.js +4 -3
  25. package/commands/pack-craft.js +2 -2
  26. package/commands/publish.js +2 -2
  27. package/commands/radar.js +3 -2
  28. package/commands/router.js +2 -2
  29. package/commands/stream.js +22 -1
  30. package/commands/task.js +84 -2
  31. package/commands/team.js +355 -67
  32. package/commands/transactions.js +3 -3
  33. package/commands/truth.js +2 -2
  34. package/commands/x-search.js +81 -10
  35. package/commands/youtube.js +2 -1
  36. package/lib/auto-accept-certified.js +83 -9
  37. package/lib/autoland.js +3 -2
  38. package/lib/ax-auto-lane.js +2 -2
  39. package/lib/brief-rules.js +11 -0
  40. package/lib/business-target.js +131 -0
  41. package/lib/chat-log-scan.js +2 -1
  42. package/lib/ci-runner.js +3 -2
  43. package/lib/codex-flight.js +2 -1
  44. package/lib/commerce.js +17 -2
  45. package/lib/deck-history.js +2 -2
  46. package/lib/deck-review.js +3 -3
  47. package/lib/design-api.js +2 -2
  48. package/lib/engine-processes.js +430 -0
  49. package/lib/engine-registry.js +101 -21
  50. package/lib/first-minute.js +2 -2
  51. package/lib/fleet.js +8 -36
  52. package/lib/git-spawn.js +5 -2
  53. package/lib/ignored-cache.js +51 -0
  54. package/lib/known-commands.js +1 -1
  55. package/lib/manifest.js +2 -2
  56. package/lib/map-refs.js +70 -4
  57. package/lib/member-identity.js +56 -0
  58. package/lib/pack-capabilities.js +2 -1
  59. package/lib/permission-grants.js +2 -2
  60. package/lib/pulse.js +5 -5
  61. package/lib/roster-models.js +11 -0
  62. package/lib/roster-runs.js +13 -2
  63. package/lib/roster-stale.js +241 -0
  64. package/lib/runner-command.js +22 -6
  65. package/lib/sandbox-pids.js +113 -0
  66. package/lib/secret-gateway.js +3 -13
  67. package/lib/state-home.js +106 -0
  68. package/lib/task-db.js +4 -3
  69. package/lib/task-prune.js +462 -0
  70. package/lib/team-lineup.js +10 -71
  71. package/lib/team-presence.js +224 -0
  72. package/lib/text-table.js +81 -0
  73. package/lib/x-post-check.js +497 -0
  74. package/package.json +1 -1
  75. package/scripts/det/data/xsearch-answer.json +24 -0
  76. package/scripts/det/data/xsearch-live-2026-09-29.json +93 -0
  77. package/scripts/det/xsearch-bench.js +307 -0
  78. package/scripts/det/ytnotes +61 -15
  79. package/scripts/det/ytrail-bench.js +2 -1
  80. package/utils/auth.js +3 -3
  81. package/utils/update-check.js +2 -2
@@ -4,7 +4,9 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { apiRequestJson } = require('../utils/api');
6
6
  const { ensureBilledCommandAuth } = require('./auth');
7
+ const { spawnSync } = require('child_process');
7
8
  const applyGate = require('../lib/apply-gate');
9
+ const { checkXPosts, annotateContent, formatTally } = require('../lib/x-post-check');
8
10
  const {
9
11
  fileTeachExperiment,
10
12
  extractTeachNumbers,
@@ -23,9 +25,10 @@ const KEEP_RULE = 'keep only if measure.py moves 0→1. scores 1 only when the f
23
25
 
24
26
  function showXSearchHelp(output = console.log, commandName = 'atris x-search') {
25
27
  output('');
26
- output(`Usage: ${commandName} "<query>" [--limit N] [--days N] [--save] [--json]`);
27
- output(` ${commandName} person --name <name> [--handle <h>] [--company <c>] [--context <text>] [--save] [--json]`);
28
+ output(`Usage: ${commandName} "<query>" [--limit N] [--days N] [--save] [--json] [--no-check]`);
29
+ output(` ${commandName} person --name <name> [--handle <h>] [--company <c>] [--context <text>] [--save] [--json] [--no-check]`);
28
30
  output(` ${commandName} unsave <query-or-source>`);
31
+ output(` ${commandName} bench [--quick|--free]`);
29
32
  output('');
30
33
  output(`Search X/Twitter via Atris (${COST_HINT}).`);
31
34
  output('Requires login. Same auth path as atris youtube process.');
@@ -33,13 +36,15 @@ function showXSearchHelp(output = console.log, commandName = 'atris x-search') {
33
36
  output('--save files a brief only when the result is rich.');
34
37
  output('unsave deletes the filed brief, apply stub, and matching experiment pack (no paid calls).');
35
38
  output('Empty or failed search prints credits refunded only when the server marks a refund.');
39
+ output('Each quoted post is looked up on X for free and marked checked, unverified, or unknown.');
36
40
  output('');
37
41
  output('Options:');
38
42
  output(' --limit <n> Max results hint (search only)');
39
43
  output(' --days <n> Only tweets from the last N days (search only)');
40
44
  output(' --save File brief, journal, apply; rich results mint a keep/revert experiment');
41
45
  output(' --unsave Delete filed brief, apply stub, and matching experiment pack (no paid calls)');
42
- output(' --json Print the raw JSON response');
46
+ output(' --json Print the raw JSON response (plus a checks list)');
47
+ output(' --no-check Skip looking up the quoted posts on X');
43
48
  output(' -h, --help This help');
44
49
  output('');
45
50
  output('Person options:');
@@ -55,6 +60,7 @@ function showXSearchHelp(output = console.log, commandName = 'atris x-search') {
55
60
  output(` ${commandName} person --name "Leah Bonvissuto" --handle leahbon`);
56
61
  output(` ${commandName} unsave "MCP agents"`);
57
62
  output(` ${commandName} --unsave "MCP agents"`);
63
+ output(` ${commandName} bench --free`);
58
64
  output('');
59
65
  }
60
66
 
@@ -81,6 +87,7 @@ function parseSearchArgs(argv = []) {
81
87
  json: false,
82
88
  save: false,
83
89
  unsave: false,
90
+ check: true,
84
91
  query: null,
85
92
  limit: null,
86
93
  daysBack: null,
@@ -100,6 +107,8 @@ function parseSearchArgs(argv = []) {
100
107
  options.json = true;
101
108
  } else if (arg === '--save') {
102
109
  options.save = true;
110
+ } else if (arg === '--no-check') {
111
+ options.check = false;
103
112
  } else if (arg === '--unsave') {
104
113
  options.unsave = true;
105
114
  } else if (arg === '--limit') {
@@ -153,6 +162,7 @@ function parsePersonArgs(argv = []) {
153
162
  help: false,
154
163
  json: false,
155
164
  save: false,
165
+ check: true,
156
166
  name: null,
157
167
  handle: null,
158
168
  company: null,
@@ -173,6 +183,8 @@ function parsePersonArgs(argv = []) {
173
183
  options.json = true;
174
184
  } else if (arg === '--save') {
175
185
  options.save = true;
186
+ } else if (arg === '--no-check') {
187
+ options.check = false;
176
188
  } else if (arg === '--name') {
177
189
  options.name = readValue(args, i, arg);
178
190
  i++;
@@ -592,23 +604,26 @@ function ensureXSearchApply({ cwd, source, packRel, now, output } = {}) {
592
604
  });
593
605
  }
594
606
 
595
- function formatXSearchResult(data) {
607
+ // check is the result of checkXPosts. With it, each post carries its own
608
+ // link or warning, and citations no post uses are listed as other sources.
609
+ function formatXSearchResult(data, check = null) {
596
610
  const lines = [];
597
611
  const content = xSearchContent(data);
598
612
  const citations = xSearchCitations(data);
599
613
 
600
614
  if (content) {
601
- lines.push(content);
615
+ lines.push(check ? annotateContent(content, check) : content);
602
616
  } else if (data?.message) {
603
617
  lines.push(String(data.message).trim());
604
618
  } else {
605
619
  lines.push('X search completed');
606
620
  }
607
621
 
608
- if (citations.length) {
622
+ const listed = check ? check.otherSources : citations;
623
+ if (listed.length) {
609
624
  lines.push('');
610
- lines.push('Citations:');
611
- for (const cite of citations) {
625
+ lines.push(check ? 'Other sources:' : 'Citations:');
626
+ for (const cite of listed) {
612
627
  lines.push(` ${cite}`);
613
628
  }
614
629
  }
@@ -619,9 +634,54 @@ function formatXSearchResult(data) {
619
634
  lines.push(...creditLines);
620
635
  }
621
636
 
637
+ if (check) {
638
+ lines.push('');
639
+ lines.push(formatTally(check.tally));
640
+ }
641
+
622
642
  return lines.join('\n');
623
643
  }
624
644
 
645
+ // Look the quoted posts up on X. Free, and never fatal: any failure means no
646
+ // check, and the original output prints unchanged.
647
+ async function checkXSearchPosts(data, options, deps = {}) {
648
+ if (options.check === false) return null;
649
+ try {
650
+ return await (deps.checkXPosts || checkXPosts)({
651
+ content: xSearchContent(data),
652
+ citations: xSearchCitations(data),
653
+ fetchPost: deps.fetchPost,
654
+ fetch: deps.fetch,
655
+ });
656
+ } catch {
657
+ return null;
658
+ }
659
+ }
660
+
661
+ function renderXSearchResult(data, check) {
662
+ if (check) {
663
+ try {
664
+ return formatXSearchResult(data, check);
665
+ } catch {
666
+ // fall through to the original output
667
+ }
668
+ }
669
+ return formatXSearchResult(data);
670
+ }
671
+
672
+ function runXSearchBench(argv, deps = {}) {
673
+ const run = (deps.spawnSync || spawnSync)(
674
+ process.execPath,
675
+ [path.join(__dirname, '..', 'scripts', 'det', 'xsearch-bench.js'), ...argv],
676
+ { stdio: 'inherit' },
677
+ );
678
+ if (run.error) {
679
+ (deps.output || console.error)(`bench did not start: ${run.error.message}`);
680
+ return 1;
681
+ }
682
+ return typeof run.status === 'number' ? run.status : 1;
683
+ }
684
+
625
685
  function formatEmptyXSearchResult(data) {
626
686
  const lines = ['no results'];
627
687
  const creditLines = formatCreditsLines(xSearchCredits(data));
@@ -634,6 +694,11 @@ function formatEmptyXSearchResult(data) {
634
694
 
635
695
  async function xSearchCommand(argv = process.argv.slice(3), deps = {}) {
636
696
  const output = deps.output || ((line = '') => console.log(line));
697
+ if (argv[0] === 'bench') {
698
+ const code = runXSearchBench(argv.slice(1), deps);
699
+ if (!deps.output && !deps.spawnSync) process.exit(code);
700
+ return code;
701
+ }
637
702
  let options;
638
703
  try {
639
704
  options = parseXSearchArgs(argv);
@@ -655,10 +720,14 @@ async function xSearchCommand(argv = process.argv.slice(3), deps = {}) {
655
720
  try {
656
721
  const data = await runXSearch(options, deps);
657
722
  const hasResults = xSearchHasResults(data);
723
+ const check = hasResults ? await checkXSearchPosts(data, options, deps) : null;
658
724
  if (options.json) {
659
- output(JSON.stringify(data, null, 2));
725
+ const withChecks = check && data && typeof data === 'object' && !Array.isArray(data)
726
+ ? { ...data, checks: check.checks }
727
+ : data;
728
+ output(JSON.stringify(withChecks, null, 2));
660
729
  } else {
661
- output(hasResults ? formatXSearchResult(data) : formatEmptyXSearchResult(data));
730
+ output(hasResults ? renderXSearchResult(data, check) : formatEmptyXSearchResult(data));
662
731
  }
663
732
  if (!hasResults) {
664
733
  if (
@@ -734,5 +803,7 @@ module.exports = {
734
803
  xSearchExperimentSlug,
735
804
  xSearchExperimentRel,
736
805
  unsaveXSearch,
806
+ formatXSearchResult,
807
+ runXSearchBench,
737
808
  xSearchCommand,
738
809
  };
@@ -5,6 +5,7 @@ const { spawnSync } = require('child_process');
5
5
  const fs = require('fs');
6
6
  const os = require('os');
7
7
  const path = require('path');
8
+ const { stateHome } = require('../lib/state-home');
8
9
  const https = require('https');
9
10
 
10
11
  const YTNOTES_USAGE = 'usage: ytnotes <youtube-url> [youtube-url-or-playlist...] [auto|gemini|haiku|atris-fast|grok|codex|cursor]';
@@ -2256,7 +2257,7 @@ function searchCacheTtlMs(deps = {}) {
2256
2257
 
2257
2258
  function resolveSearchCachePath(deps = {}) {
2258
2259
  if (deps.searchCachePath) return deps.searchCachePath;
2259
- const homeDir = deps.homeDir || os.homedir();
2260
+ const homeDir = deps.homeDir || stateHome();
2260
2261
  return path.join(homeDir, '.atris', LOCAL_SEARCH_CACHE_FILE);
2261
2262
  }
2262
2263
 
@@ -457,8 +457,61 @@ function parseVerifyCommand(verify) {
457
457
  };
458
458
  }
459
459
 
460
+ // Members record checks as one command or several joined with `&&`
461
+ // ("npm run test:a && node scripts/test-b.mjs"). Each step must pass the
462
+ // same allowlist on its own; `&&` only runs them in order and stops at the
463
+ // first failure, still with shell:false, so a chain can do nothing its steps
464
+ // could not do one at a time. One leading `cd` applies to every step.
465
+ const MAX_VERIFY_CHAIN_STEPS = 8;
466
+
467
+ function splitTopLevelAnd(command) {
468
+ const parts = [];
469
+ let current = '';
470
+ let quote = null;
471
+ for (let i = 0; i < command.length; i += 1) {
472
+ const char = command[i];
473
+ if (quote) {
474
+ if (char === quote) quote = null;
475
+ current += char;
476
+ continue;
477
+ }
478
+ if (char === "'" || char === '"') {
479
+ quote = char;
480
+ current += char;
481
+ continue;
482
+ }
483
+ if (char === '&' && command[i + 1] === '&') {
484
+ parts.push(current.trim());
485
+ current = '';
486
+ i += 1;
487
+ continue;
488
+ }
489
+ current += char;
490
+ }
491
+ parts.push(current.trim());
492
+ return parts;
493
+ }
494
+
495
+ function parseVerifyChain(verify) {
496
+ const cmd = String(verify || '').trim();
497
+ if (!cmd) return { ok: false, reason: 'no_verify_command' };
498
+ const prefixed = parseCdPrefix(cmd);
499
+ if (!prefixed.ok) return prefixed;
500
+ const parts = splitTopLevelAnd(prefixed.command);
501
+ if (parts.length > MAX_VERIFY_CHAIN_STEPS || parts.some((part) => !part || /^cd(\s|$)/.test(part))) {
502
+ return { ok: false, reason: 'verify_command_not_allowed' };
503
+ }
504
+ const steps = [];
505
+ for (const part of parts) {
506
+ const parsed = parseVerifyCommand(part);
507
+ if (!parsed.ok) return parsed;
508
+ steps.push({ ...parsed, ...(prefixed.cwd ? { cwd: prefixed.cwd } : {}) });
509
+ }
510
+ return { ok: true, steps, ...(prefixed.cwd ? { cwd: prefixed.cwd } : {}) };
511
+ }
512
+
460
513
  function isAutoCertifyVerifyCommandAllowed(verify) {
461
- return parseVerifyCommand(verify).ok;
514
+ return parseVerifyChain(verify).ok;
462
515
  }
463
516
 
464
517
  // Re-entrancy guard: every verify child carries ATRIS_VERIFY_IN_PROGRESS=1
@@ -468,12 +521,20 @@ function isAutoCertifyVerifyCommandAllowed(verify) {
468
521
  // `atris task status` and the read path re-ran the verify. Refuse loudly.
469
522
  const VERIFY_IN_PROGRESS_ENV = 'ATRIS_VERIFY_IN_PROGRESS';
470
523
 
471
- // Spawn cap: a single CLI process has no legitimate reason to run dozens of
472
- // verifies. Beyond the cap something is looping; refuse and report instead
473
- // of silently bounding.
474
- const VERIFY_SPAWN_CAP = 25;
524
+ // Spawn cap: bounds a runaway loop inside one process. The recursion that
525
+ // fork-bombed the fleet is stopped by VERIFY_IN_PROGRESS_ENV above; this cap
526
+ // is the backstop. It was 25, which starved the review queue: every hourly
527
+ // tick re-checked the same first 25 rows and the other 141 waiting rows were
528
+ // refused as verify_spawn_cap forever (measured 2026-10-01). Override with
529
+ // ATRIS_VERIFY_SPAWN_CAP.
530
+ const DEFAULT_VERIFY_SPAWN_CAP = 150;
475
531
  let verifySpawnCount = 0;
476
532
 
533
+ function verifySpawnCap() {
534
+ const raw = Number(process.env.ATRIS_VERIFY_SPAWN_CAP);
535
+ return Number.isInteger(raw) && raw > 0 ? Math.min(raw, 1000) : DEFAULT_VERIFY_SPAWN_CAP;
536
+ }
537
+
477
538
  function verifyRefusal(unrunnableCause, extra) {
478
539
  return {
479
540
  ok: false,
@@ -490,14 +551,26 @@ function runVerifyCommand(verify, workspaceRoot) {
490
551
  detail: `${VERIFY_IN_PROGRESS_ENV} is set: a verify is already running up the call chain; refusing to nest another one`,
491
552
  });
492
553
  }
493
- if (verifySpawnCount >= VERIFY_SPAWN_CAP) {
554
+ const chain = parseVerifyChain(verify);
555
+ if (!chain.ok) return chain;
556
+ let result = null;
557
+ for (const step of chain.steps) {
558
+ result = runVerifyStep(step, workspaceRoot);
559
+ if (!result.ok) {
560
+ return chain.steps.length > 1 ? { ...result, failed_step: step.argv.join(' ') } : result;
561
+ }
562
+ }
563
+ return result;
564
+ }
565
+
566
+ function runVerifyStep(parsed, workspaceRoot) {
567
+ const cap = verifySpawnCap();
568
+ if (verifySpawnCount >= cap) {
494
569
  return verifyRefusal('verify_spawn_cap', {
495
- detail: `this process already ran ${verifySpawnCount} verifies (cap ${VERIFY_SPAWN_CAP}); refusing more`,
570
+ detail: `this process already ran ${verifySpawnCount} verifies (cap ${cap}); refusing more`,
496
571
  });
497
572
  }
498
573
  verifySpawnCount += 1;
499
- const parsed = parseVerifyCommand(verify);
500
- if (!parsed.ok) return parsed;
501
574
  const cwdCheck = validateCommandCwd(parsed, workspaceRoot);
502
575
  if (!cwdCheck.ok) return cwdCheck;
503
576
  const gitPathCheck = validateGitWorktreePath(parsed.argv, workspaceRoot, cwdCheck.cwd);
@@ -1250,6 +1323,7 @@ module.exports = {
1250
1323
  sniffedProtectedLane,
1251
1324
  evaluateAutoAccept,
1252
1325
  isAutoCertifyVerifyCommandAllowed,
1326
+ parseVerifyChain,
1253
1327
  isAgentCertified,
1254
1328
  parseVerifyCommand,
1255
1329
  repoHygieneGate,
package/lib/autoland.js CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
+ const { stateHome } = require('./state-home');
10
11
  const os = require('os');
11
12
  const { spawnSync } = require('child_process');
12
13
  const { computeTrustTier } = require('./trust-tiers');
@@ -189,7 +190,7 @@ function buildCronLine(root) {
189
190
  const node = process.execPath;
190
191
  const bin = path.join(root, 'bin', 'atris.js');
191
192
  const cliBin = fs.existsSync(bin) ? bin : 'atris';
192
- const logDir = path.join(os.homedir(), '.atris', 'overnight');
193
+ const logDir = path.join(stateHome(), '.atris', 'overnight');
193
194
  const logFile = path.join(logDir, `autoland-${path.basename(root)}.log`);
194
195
  const invoke = cliBin.endsWith('.js') ? `${node} ${cliBin}` : cliBin;
195
196
  return `14 * * * * cd ${root} && ${invoke} autoland tick >> ${logFile} 2>&1 # ${cronMarker(root)}`;
@@ -210,7 +211,7 @@ function cronInstalled(root) {
210
211
  }
211
212
 
212
213
  function installCron(root) {
213
- fs.mkdirSync(path.join(os.homedir(), '.atris', 'overnight'), { recursive: true });
214
+ fs.mkdirSync(path.join(stateHome(), '.atris', 'overnight'), { recursive: true });
214
215
  const marker = cronMarker(root);
215
216
  const kept = readCrontab().split(/\r?\n/).filter((line) => line.trim() && !line.includes(marker));
216
217
  const next = `${kept.join('\n')}${kept.length ? '\n' : ''}${buildCronLine(root)}\n`;
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('node:fs');
4
- const os = require('node:os');
5
4
  const path = require('node:path');
5
+ const { stateHome } = require('./state-home');
6
6
 
7
7
  const SHORT_LOOKUP_MAX_CHARS = 140;
8
8
  const LONG_INPUT_MIN_CHARS = 1200;
@@ -75,7 +75,7 @@ function overrideMatches(text, override) {
75
75
  function loadOverrides(filePath) {
76
76
  const resolved = filePath
77
77
  || process.env.ATRIS_ROUTER_OVERRIDES_PATH
78
- || path.join(os.homedir(), '.atris', 'router-overrides.json');
78
+ || path.join(stateHome(), '.atris', 'router-overrides.json');
79
79
  try {
80
80
  const parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
81
81
  return Array.isArray(parsed) ? parsed : [];
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+
3
+ // Rule lines every builder brief carries (lib/codex-flight.js buildPrompt and
4
+ // lib/fleet.js buildFleetPrompt). The text lives here once so the two briefs
5
+ // cannot drift apart.
6
+ const SHARED_BRIEF_RULES = [
7
+ '- Keep one concern per PR; split anything larger into separate PRs because git history guides future agents and small PRs are cheap to revert and bisect.',
8
+ '- A guard, limit, or cache must hold on every path to the thing it protects; grep for the other callers, templates, and writers, and test at least one path you were not pointed at.',
9
+ ];
10
+
11
+ module.exports = { SHARED_BRIEF_RULES };
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ // Shared plumbing for business-scoped HTTP commands (`atris hire`, `atris hires`,
4
+ // `atris mail`): parse flags, find which business to act on, get a login token.
5
+ // These commands are HTTP clients of /api/business/{id}/...; no backend code here.
6
+
7
+ const { apiRequestJson } = require('../utils/api');
8
+ const { ensureValidCredentials } = require('../utils/auth');
9
+ const { findBusiness } = require('../commands/feed');
10
+ const { oneLine } = require('./developer-api');
11
+
12
+ const NO_BUSINESS = 'no business here. run this inside a business folder, or add --business <slug>';
13
+
14
+ // --help, --json and --business are allowed on every command.
15
+ const COMMON_SWITCHES = ['help', 'json'];
16
+ const COMMON_VALUES = ['business'];
17
+
18
+ // --key value, --key=value, and yes/no switches that never take a value.
19
+ // `spec` is { switches: [...], values: [...] }: the only flags the command
20
+ // takes, beyond the common ones. Anything else comes back as `error`, and
21
+ // the command must stop before making any request:
22
+ // an unknown flag (--dry-run on a command without it),
23
+ // a value on a switch (--yes=false is refused, never read as yes),
24
+ // a value flag with nothing after it (--business ""),
25
+ // the same value flag twice.
26
+ // A plain array is the old lenient form (switch names only), kept for callers
27
+ // that want it; it never reports errors.
28
+ function parseArgs(argv = [], spec = []) {
29
+ const strict = !Array.isArray(spec);
30
+ const switchNames = strict ? (spec.switches || []) : spec;
31
+ const valueNames = strict ? (spec.values || []) : [];
32
+ const switches = new Set([...switchNames, ...COMMON_SWITCHES, 'yes']);
33
+ const allowed = new Set([...switchNames, ...valueNames, ...COMMON_SWITCHES, ...COMMON_VALUES]);
34
+ const flags = {};
35
+ const pos = [];
36
+ let error = '';
37
+ const fail = (message) => { if (!error) error = message; };
38
+ for (let i = 0; i < argv.length; i += 1) {
39
+ const arg = String(argv[i]);
40
+ if (arg === '-h') { flags.help = true; continue; }
41
+ if (arg === '-y') {
42
+ if (strict && !allowed.has('yes')) fail('unknown option -y');
43
+ else flags.yes = true;
44
+ continue;
45
+ }
46
+ if (arg.startsWith('--')) {
47
+ const eq = arg.indexOf('=');
48
+ const raw = eq > 0 ? arg.slice(0, eq) : arg;
49
+ const key = raw.slice(2).replace(/-/g, '_');
50
+ if (strict && !allowed.has(key)) { fail(`unknown option ${raw}`); continue; }
51
+ if (switches.has(key)) {
52
+ if (eq < 0) flags[key] = true;
53
+ else if (strict) fail(`${raw} is a switch and takes no value: use ${raw} on its own, or leave it off`);
54
+ else flags[key] = arg.slice(eq + 1);
55
+ continue;
56
+ }
57
+ let value = true;
58
+ if (eq > 0) value = arg.slice(eq + 1);
59
+ else if (argv[i + 1] != null && !String(argv[i + 1]).startsWith('--')) { value = String(argv[i + 1]); i += 1; }
60
+ if (strict && (typeof value !== 'string' || !value.trim())) { fail(`${raw} needs a value`); continue; }
61
+ if (strict && Object.prototype.hasOwnProperty.call(flags, key)) { fail(`${raw} was given twice`); continue; }
62
+ flags[key] = value;
63
+ continue;
64
+ }
65
+ if (strict && arg.length > 1 && arg.startsWith('-')) { fail(`unknown option ${arg}`); continue; }
66
+ pos.push(arg);
67
+ }
68
+ return strict ? { flags, pos, error } : { flags, pos };
69
+ }
70
+
71
+ // A --business that was given must name a business. An empty or missing
72
+ // value is an error and never falls back to the current folder's business.
73
+ // Without --business: the nearest .atris/business.json above the current
74
+ // folder. Returns { id, name }, { error }, or null when none is found.
75
+ function resolveBusiness(flags = {}, deps = {}) {
76
+ if (Object.prototype.hasOwnProperty.call(flags, 'business')) {
77
+ const given = typeof flags.business === 'string' ? flags.business.trim() : '';
78
+ if (!given) return { error: '--business needs a business slug or id' };
79
+ return { id: given, name: given };
80
+ }
81
+ const found = (deps.findBusiness || findBusiness)(deps.cwd || process.cwd());
82
+ return found && found.businessId ? { id: found.businessId, name: found.name || 'this business' } : null;
83
+ }
84
+
85
+ // The business to act on, or says why not (through `err`) and returns null.
86
+ function businessOrSay(flags, deps, err) {
87
+ const business = resolveBusiness(flags, deps);
88
+ if (!business) { err(NO_BUSINESS); return null; }
89
+ if (business.error) { err(business.error); return null; }
90
+ return business;
91
+ }
92
+
93
+ async function loginToken(deps = {}) {
94
+ if (deps.token) return deps.token;
95
+ const ensure = deps.ensureCredentials || (() => ensureValidCredentials(apiRequestJson));
96
+ const auth = await ensure();
97
+ return auth && !auth.error && auth.credentials && auth.credentials.token ? auth.credentials.token : '';
98
+ }
99
+
100
+ // FastAPI errors arrive as {"detail": "..."}; keep them to one plain line.
101
+ function errorText(result) {
102
+ if (!result) return 'request failed';
103
+ if (Number(result.status) === 404 && !(result.data && result.data.detail)) {
104
+ return 'backend rolling out, try again shortly';
105
+ }
106
+ return oneLine((result.data && (result.data.detail || result.data.error)) || result.error || `request failed (${result.status})`);
107
+ }
108
+
109
+ // "2026-10-02T09:00:00+00:00" -> "in 9 h" / "6 h ago"
110
+ function relativeTime(iso, now = Date.now()) {
111
+ const at = Date.parse(iso || '');
112
+ if (!Number.isFinite(at)) return '';
113
+ const mins = Math.round((at - now) / 60000);
114
+ const abs = Math.abs(mins);
115
+ const span = abs < 1 ? 'now' : abs < 90 ? `${abs} min` : abs < 48 * 60 ? `${Math.round(abs / 60)} h` : `${Math.round(abs / 1440)} days`;
116
+ if (span === 'now') return 'just now';
117
+ return mins >= 0 ? `in ${span}` : `${span} ago`;
118
+ }
119
+
120
+ function dollars(value) {
121
+ if (value == null || !Number.isFinite(Number(value))) return '-';
122
+ const n = Number(value);
123
+ return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
124
+ }
125
+
126
+ function clip(text, width) {
127
+ const s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
128
+ return s.length > width ? `${s.slice(0, width - 1)}…` : s;
129
+ }
130
+
131
+ module.exports = { NO_BUSINESS, parseArgs, resolveBusiness, businessOrSay, loginToken, errorText, relativeTime, dollars, clip };
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
+ const { stateHome } = require('./state-home');
4
5
 
5
6
  const SCHEMA = 'atris.chat_scan.v1';
6
7
  const PROMPT_LINE = /^(?:max|pro|fast|code-fast)\s*›\s*(.+)$/i;
@@ -73,7 +74,7 @@ function axLogDirs(cwd = process.cwd(), options = {}) {
73
74
  const local = path.join(cwd, 'atris', 'runs');
74
75
  if (fs.existsSync(local)) dirs.push(local);
75
76
  if (options.global) {
76
- const home = path.join(os.homedir(), '.atris', 'runs');
77
+ const home = path.join(stateHome(), '.atris', 'runs');
77
78
  if (fs.existsSync(home) && home !== local) dirs.push(home);
78
79
  }
79
80
  return dirs;
package/lib/ci-runner.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
+ const { stateHome } = require('./state-home');
5
6
  const os = require('node:os');
6
7
  const https = require('node:https');
7
8
  const { spawn, spawnSync } = require('node:child_process');
@@ -9,7 +10,7 @@ const { spawn, spawnSync } = require('node:child_process');
9
10
  const RUNNER_VERSION = '2.336.0';
10
11
  const GITHUB_API_VERSION = '2026-03-10';
11
12
 
12
- function usageFilePath(home = os.homedir()) {
13
+ function usageFilePath(home = stateHome()) {
13
14
  return path.join(home, '.atris', 'ci', 'usage.jsonl');
14
15
  }
15
16
 
@@ -309,7 +310,7 @@ async function ensureRunner(options = {}) {
309
310
  const version = options.version || RUNNER_VERSION;
310
311
  const platform = options.platform || process.platform;
311
312
  const arch = options.arch || process.arch;
312
- const home = options.home || os.homedir();
313
+ const home = options.home || stateHome();
313
314
  const log = options.log || console.log;
314
315
  const asset = runnerAssetName(platform, arch, version);
315
316
  const runnerPlatform = platform === 'darwin' ? 'osx' : platform;
@@ -4,6 +4,7 @@ const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
6
  const { spawn, spawnSync } = require('child_process');
7
+ const { SHARED_BRIEF_RULES } = require('./brief-rules');
7
8
 
8
9
  // Worktree flights need full file access to edit and ship the requested repo.
9
10
  const FLIGHT_SANDBOX = 'danger-full-access';
@@ -59,7 +60,7 @@ function buildPrompt({ worktreePath, branch, brief, verifyCmd }) {
59
60
  '',
60
61
  'Instructions:',
61
62
  `- First run: atris worktree guard`,
62
- '- Keep one concern per PR; split anything larger into separate PRs because git history guides future agents and small PRs are cheap to revert and bisect.',
63
+ ...SHARED_BRIEF_RULES,
63
64
  `- Verify with this bare command and use its real exit code: ${verify}`,
64
65
  '- Stage and commit only files you changed.',
65
66
  `- Commit with a plain-English message and include this trailer: ${COAUTHOR}`,
package/lib/commerce.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // Shared helpers for `atris buy` and `atris transactions`.
4
4
  // Backend: /api/commerce/* and /api/transactions (commerce_router.py).
5
5
 
6
- const { ROLLOUT_MESSAGE, oneLine } = require('./developer-api');
6
+ const { ROLLOUT_MESSAGE, oneLine, tokenFrom } = require('./developer-api');
7
+ const { decodeJwtClaims } = require('../utils/auth');
7
8
 
8
9
  // Flags whose values the backend reads as numbers.
9
10
  const NUMERIC_KEYS = new Set(['quantity', 'dollars', 'limit_price', 'min_each_usd', 'max_each_usd', 'limit']);
@@ -72,4 +73,18 @@ function errorFrom(result) {
72
73
  return oneLine(result && result.error);
73
74
  }
74
75
 
75
- module.exports = { parseFlags, formatCents, untilText, errorFrom };
76
+ // The commerce key (minted with `atris login --agent --commerce`) when it is
77
+ // present, unexpired, and carries the scope this call needs; else the login.
78
+ function commerceToken(creds, scope, now = Date.now()) {
79
+ const key = creds && typeof creds.commerce_agent_token === 'string' ? creds.commerce_agent_token.trim() : '';
80
+ if (key) {
81
+ const claims = decodeJwtClaims(key) || {};
82
+ const scopes = Array.isArray(claims.scopes) ? claims.scopes
83
+ : (Array.isArray(creds.commerce_agent_token_scopes) ? creds.commerce_agent_token_scopes : []);
84
+ const expiry = Number.isFinite(claims.exp) ? claims.exp * 1000 : Date.parse(creds.commerce_agent_token_expires_at || '');
85
+ if (scopes.includes(scope) && Number.isFinite(expiry) && expiry > now) return key;
86
+ }
87
+ return tokenFrom(creds);
88
+ }
89
+
90
+ module.exports = { parseFlags, formatCents, untilText, errorFrom, commerceToken };
@@ -4,11 +4,11 @@
4
4
  // you track iterations of a spec over time.
5
5
 
6
6
  const fs = require('fs');
7
- const os = require('os');
8
7
  const path = require('path');
8
+ const { stateHome } = require('./state-home');
9
9
  const crypto = require('crypto');
10
10
 
11
- const DEFAULT_HISTORY_PATH = path.join(os.homedir(), '.atris', 'deck-history.jsonl');
11
+ const DEFAULT_HISTORY_PATH = path.join(stateHome(), '.atris', 'deck-history.jsonl');
12
12
 
13
13
  // Short, stable content hash of a spec, same spec -> same hash, so you can
14
14
  // tell a no-op rebuild from a real change.
@@ -6,13 +6,13 @@
6
6
 
7
7
  const fs = require('fs');
8
8
  const https = require('https');
9
- const os = require('os');
10
9
  const path = require('path');
10
+ const { stateHome } = require('./state-home');
11
11
 
12
12
  const SCHEMA = 'atris.deck_review.v1';
13
13
  const RECEIPT_SCHEMA = 'atris.deck_receipt.v1';
14
- const DEFAULT_OUT_ROOT = path.join(os.homedir(), '.atris', 'deck-review');
15
- const DEFAULT_RECEIPTS_PATH = path.join(os.homedir(), '.atris', 'deck-receipts.jsonl');
14
+ const DEFAULT_OUT_ROOT = path.join(stateHome(), '.atris', 'deck-review');
15
+ const DEFAULT_RECEIPTS_PATH = path.join(stateHome(), '.atris', 'deck-receipts.jsonl');
16
16
 
17
17
  const AGENT_CHECKLIST = [
18
18
  'Heading and sub do not overlap',