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
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Bench what users get from `atris x-search`, end to end.
5
+ // Usage: node scripts/det/xsearch-bench.js [--quick|--free] [--case <name>]
6
+ // default: every case, 2 paid searches (10 credits)
7
+ // --quick: topic plus the free cases (5 credits)
8
+ // --free: free cases only (0 credits)
9
+ // Prints one line per case and a final "bench: X/Y passed in Ns"; exits 1 if
10
+ // any case fails. Rows (with a `case` field) go to $XSEARCH_OUT_DIR/xsearch.jsonl,
11
+ // default ~/.atris/benchmarks, outside any git repo. A failed paid case also
12
+ // keeps its full output there, since it cost credits to get.
13
+ // bin/atris.js is resolved next to this file, so an installed copy benches itself.
14
+
15
+ const { spawn } = require('node:child_process');
16
+ const fs = require('node:fs');
17
+ const http = require('node:http');
18
+ const os = require('node:os');
19
+ const path = require('node:path');
20
+ const { checkXPosts } = require('../../lib/x-post-check');
21
+
22
+ const ATRIS = path.join(__dirname, '..', '..', 'bin', 'atris.js');
23
+ const FIXTURE = path.join(__dirname, 'data', 'xsearch-answer.json');
24
+ const PAID_TIMEOUT_MS = 180000;
25
+ const STRANGER_TIMEOUT_MS = 60000;
26
+ const CHECKED_MIN = 0.6;
27
+
28
+ const CASES = [
29
+ { name: 'topic', kind: 'paid', args: ['x-search', 'AI agent startups launch', '--limit', '5', '--days', '2'], minPosts: 3, quick: true },
30
+ { name: 'person', kind: 'paid', args: ['x-search', 'person', '--name', 'Garry Tan', '--handle', 'garrytan'], minPosts: 1 },
31
+ { name: 'stranger-logged-out', kind: 'stranger', args: ['x-search', 'AI agent startups launch', '--limit', '5', '--days', '2'], quick: true, free: true },
32
+ { name: 'offline-check', kind: 'offline', quick: true, free: true },
33
+ ];
34
+
35
+ function parseArgs(argv) {
36
+ const opts = { quick: false, free: false, only: null };
37
+ for (let i = 0; i < argv.length; i += 1) {
38
+ if (argv[i] === '--quick') opts.quick = true;
39
+ else if (argv[i] === '--free') opts.free = true;
40
+ else if (argv[i] === '--case') opts.only = argv[++i] || '';
41
+ else if (argv[i].startsWith('--case=')) opts.only = argv[i].slice('--case='.length);
42
+ else throw new Error(`unknown option: ${argv[i]}`);
43
+ }
44
+ return opts;
45
+ }
46
+
47
+ function selectCases(opts, cases = CASES) {
48
+ if (opts.only !== null) {
49
+ const found = cases.filter((c) => c.name === opts.only);
50
+ if (!found.length) {
51
+ throw new Error(`unknown case: ${opts.only} (cases: ${cases.map((c) => c.name).join(', ')})`);
52
+ }
53
+ return found;
54
+ }
55
+ if (opts.free) return cases.filter((c) => c.free);
56
+ return opts.quick ? cases.filter((c) => c.quick) : cases.slice();
57
+ }
58
+
59
+ // x-search prints "posts: N checked, N unverified, N unknown" at the end.
60
+ function parseTally(stdout) {
61
+ const match = String(stdout || '').match(/posts: (\d+) checked, (\d+) unverified, (\d+) unknown/);
62
+ if (!match) return null;
63
+ return { checked: Number(match[1]), unverified: Number(match[2]), unknown: Number(match[3]) };
64
+ }
65
+
66
+ // Pure pass/fail rules. Each takes a finished run and returns { pass, note }.
67
+ // Posts = checked citations plus unverified quotes. Unknown (X could not be
68
+ // reached) counts neither way.
69
+ function postCount(tally) {
70
+ return tally ? tally.checked + tally.unverified : 0;
71
+ }
72
+
73
+ function judgePaid({ status, tally, minPosts }) {
74
+ const posts = postCount(tally);
75
+ const share = posts ? tally.checked / posts : 0;
76
+ if (status !== 0) return { pass: false, posts, note: 'search failed' };
77
+ if (!tally) return { pass: false, posts, note: 'no posts found to check' };
78
+ if (posts < minPosts) return { pass: false, posts, note: `only ${posts} posts, wanted ${minPosts}` };
79
+ if (share < CHECKED_MIN) return { pass: false, posts, note: `only ${Math.round(share * 100)}% checked` };
80
+ return { pass: true, posts, note: `${Math.round(share * 100)}% checked` };
81
+ }
82
+
83
+ function judgeStranger({ status, text, hits }) {
84
+ const told = /not signed in|atris login/i.test(String(text || ''));
85
+ const pass = told && typeof status === 'number' && status !== 0 && hits === 0;
86
+ let note = 'says to log in, nothing charged';
87
+ if (!told) note = 'did not say to log in';
88
+ else if (hits) note = `reached the server ${hits} time(s)`;
89
+ else if (status === 0) note = 'exited 0 without logging in';
90
+ return { pass, note };
91
+ }
92
+
93
+ function fixtureFetch(embeds, downId = null) {
94
+ return async (id) => {
95
+ if (id === downId) return { id, ok: false, error: 'timeout' };
96
+ if (!(id in embeds)) return { id, ok: false, error: 'not in fixture' };
97
+ const post = embeds[id];
98
+ return post ? { id, ok: true, post: { id, handle: post.handle, text: post.text, createdAt: null } } : { id, ok: true, post: null };
99
+ };
100
+ }
101
+
102
+ // Run the checker on the saved answer twice: once with X answering every
103
+ // lookup, once with one lookup timing out. No network.
104
+ async function runOfflineCheck(fixture = JSON.parse(fs.readFileSync(FIXTURE, 'utf8'))) {
105
+ const up = await checkXPosts({ content: fixture.content, citations: fixture.citations, fetchPost: fixtureFetch(fixture.embeds) });
106
+ const down = await checkXPosts({
107
+ content: fixture.content,
108
+ citations: fixture.citations,
109
+ fetchPost: fixtureFetch(fixture.embeds, fixture.expect.one_down.down_id),
110
+ });
111
+ const want = fixture.expect;
112
+ const upOk = Boolean(up)
113
+ && up.tally.checked === want.all_up.checked
114
+ && up.tally.unverified === want.all_up.unverified
115
+ && up.tally.unknown === want.all_up.unknown
116
+ && up.otherSources.length === want.all_up.other_sources;
117
+ const downOk = Boolean(down)
118
+ && down.tally.checked === want.one_down.checked
119
+ && down.tally.unverified === want.one_down.unverified
120
+ && down.tally.unknown === want.one_down.unknown;
121
+ return {
122
+ pass: upOk && downOk,
123
+ tally: up ? up.tally : null,
124
+ note: !upOk ? 'wrong result with X answering' : !downOk ? 'wrong result with X down' : 'checked, unverified, and unknown all land right',
125
+ };
126
+ }
127
+
128
+ // Count every request so the stranger case can prove it never reached the server.
129
+ function startCountingServer() {
130
+ return new Promise((resolve, reject) => {
131
+ const state = { hits: 0 };
132
+ const server = http.createServer((req, res) => {
133
+ state.hits += 1;
134
+ res.writeHead(401, { 'content-type': 'application/json' });
135
+ res.end('{"detail":"bench server"}');
136
+ });
137
+ server.on('error', reject);
138
+ server.listen(0, '127.0.0.1', () => {
139
+ state.url = `http://127.0.0.1:${server.address().port}`;
140
+ state.close = () => new Promise((done) => server.close(() => done()));
141
+ resolve(state);
142
+ });
143
+ });
144
+ }
145
+
146
+ function runAtris(args, { env, cwd, timeoutMs }) {
147
+ return new Promise((resolve) => {
148
+ const started = Date.now();
149
+ const child = spawn(process.execPath, [ATRIS, ...args], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
150
+ let stdout = '';
151
+ let stderr = '';
152
+ child.stdout.on('data', (d) => { stdout += d; });
153
+ child.stderr.on('data', (d) => { stderr += d; });
154
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
155
+ child.on('close', (status) => {
156
+ clearTimeout(timer);
157
+ resolve({ status, stdout, stderr, seconds: Number(((Date.now() - started) / 1000).toFixed(1)) });
158
+ });
159
+ child.on('error', (err) => {
160
+ clearTimeout(timer);
161
+ resolve({ status: null, stdout, stderr: `${stderr}${err.message}`, seconds: Number(((Date.now() - started) / 1000).toFixed(1)) });
162
+ });
163
+ });
164
+ }
165
+
166
+ // Run x-search as a stranger would: a bare HOME with no login, and the backend
167
+ // pointed at a local server that counts requests.
168
+ async function runStranger(args) {
169
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'xsearch-stranger-'));
170
+ const server = await startCountingServer();
171
+ try {
172
+ const run = await runAtris(args, {
173
+ cwd: tmp,
174
+ env: {
175
+ HOME: tmp,
176
+ PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin`,
177
+ TMPDIR: tmp,
178
+ ATRIS_API_URL: `${server.url}/api`,
179
+ ATRIS_BACKEND_URL: server.url,
180
+ ATRIS_APP_URL: server.url,
181
+ CI: '1',
182
+ },
183
+ timeoutMs: STRANGER_TIMEOUT_MS,
184
+ });
185
+ return { ...run, hits: server.hits };
186
+ } finally {
187
+ await server.close();
188
+ fs.rmSync(tmp, { recursive: true, force: true });
189
+ }
190
+ }
191
+
192
+ function lastLines(text, n = 3) {
193
+ return String(text || '').trim().split('\n').filter(Boolean).slice(-n).map((line) => ` ${line}`).join('\n');
194
+ }
195
+
196
+ function tallyText(t) {
197
+ return t ? `posts=${postCount(t)} checked=${t.checked} unverified=${t.unverified} unknown=${t.unknown}` : 'posts=0';
198
+ }
199
+
200
+ // Keep what a failed paid search printed, so the miss can be read later
201
+ // without paying for another search.
202
+ function saveFailedOutput(dir, name, ts, text) {
203
+ try {
204
+ fs.mkdirSync(dir, { recursive: true });
205
+ const file = path.join(dir, `xsearch-${name}-${ts.replace(/[:.]/g, '-')}.txt`);
206
+ fs.writeFileSync(file, text);
207
+ return file;
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
212
+
213
+ async function runCase(c, outDir) {
214
+ const ts = new Date().toISOString();
215
+ if (c.kind === 'offline') {
216
+ const started = Date.now();
217
+ const verdict = await runOfflineCheck();
218
+ const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
219
+ const t = verdict.tally;
220
+ return {
221
+ pass: verdict.pass,
222
+ line: `${c.name} ${verdict.pass ? 'pass' : 'fail'} ${seconds}s (${verdict.note}) ${tallyText(t)}`,
223
+ row: {
224
+ ts, case: c.name, seconds, pass: verdict.pass, exit: null,
225
+ posts: postCount(t),
226
+ checked: t ? t.checked : 0, unverified: t ? t.unverified : 0, unknown: t ? t.unknown : 0,
227
+ },
228
+ };
229
+ }
230
+
231
+ if (c.kind === 'stranger') {
232
+ const run = await runStranger(c.args);
233
+ const verdict = judgeStranger({ status: run.status, text: `${run.stdout}\n${run.stderr}`, hits: run.hits });
234
+ return {
235
+ pass: verdict.pass,
236
+ line: `${c.name} ${verdict.pass ? 'pass' : 'fail'} ${run.seconds}s exit=${run.status === null ? 'none' : run.status} (${verdict.note})`,
237
+ detail: verdict.pass ? '' : lastLines(`${run.stdout}\n${run.stderr}`),
238
+ row: { ts, case: c.name, seconds: run.seconds, pass: verdict.pass, exit: run.status, posts: 0, checked: 0, unverified: 0, unknown: 0, hits: run.hits },
239
+ };
240
+ }
241
+
242
+ const run = await runAtris(c.args, { cwd: os.tmpdir(), env: process.env, timeoutMs: PAID_TIMEOUT_MS });
243
+ const tally = parseTally(run.stdout);
244
+ const verdict = judgePaid({ status: run.status, tally, minPosts: c.minPosts });
245
+ const saved = verdict.pass ? null : saveFailedOutput(outDir, c.name, ts, `${run.stdout}\n${run.stderr}`);
246
+ return {
247
+ pass: verdict.pass,
248
+ line: `${c.name} ${verdict.pass ? 'pass' : 'fail'} ${run.seconds}s exit=${run.status === null ? 'none' : run.status} (${verdict.note}) ${tallyText(tally)}`,
249
+ detail: verdict.pass ? '' : [lastLines(`${run.stdout}\n${run.stderr}`), saved ? ` full output: ${saved}` : null].filter(Boolean).join('\n'),
250
+ row: {
251
+ ts, case: c.name, seconds: run.seconds, pass: verdict.pass, exit: run.status,
252
+ posts: verdict.posts,
253
+ checked: tally ? tally.checked : 0, unverified: tally ? tally.unverified : 0, unknown: tally ? tally.unknown : 0,
254
+ ...(saved ? { output: saved } : {}),
255
+ },
256
+ };
257
+ }
258
+
259
+ function summary(results, seconds) {
260
+ const passed = results.filter((r) => r.pass).length;
261
+ return `bench: ${passed}/${results.length} passed in ${seconds}s`;
262
+ }
263
+
264
+ function appendRow(dir, row) {
265
+ fs.mkdirSync(dir, { recursive: true });
266
+ fs.appendFileSync(path.join(dir, 'xsearch.jsonl'), `${JSON.stringify(row)}\n`);
267
+ }
268
+
269
+ async function main() {
270
+ let cases;
271
+ try {
272
+ cases = selectCases(parseArgs(process.argv.slice(2)));
273
+ } catch (err) {
274
+ console.error(err.message);
275
+ console.error('usage: xsearch-bench [--quick|--free] [--case <name>]');
276
+ process.exit(2);
277
+ }
278
+ const outDir = process.env.XSEARCH_OUT_DIR || path.join(os.homedir(), '.atris', 'benchmarks');
279
+ const started = Date.now();
280
+ const results = [];
281
+ for (const c of cases) {
282
+ const result = await runCase(c, outDir);
283
+ results.push(result);
284
+ console.log(result.line);
285
+ if (result.detail) console.log(result.detail);
286
+ appendRow(outDir, result.row);
287
+ }
288
+ console.log(summary(results, Math.round((Date.now() - started) / 1000)));
289
+ process.exit(results.some((r) => !r.pass) ? 1 : 0);
290
+ }
291
+
292
+ if (require.main === module) main();
293
+
294
+ module.exports = {
295
+ CASES,
296
+ FIXTURE,
297
+ parseArgs,
298
+ selectCases,
299
+ parseTally,
300
+ postCount,
301
+ judgePaid,
302
+ judgeStranger,
303
+ fixtureFetch,
304
+ runOfflineCheck,
305
+ saveFailedOutput,
306
+ summary,
307
+ };
@@ -4,8 +4,9 @@
4
4
  # No captions: if mlx_whisper is installed (uv tool install mlx-whisper, Apple
5
5
  # silicon), the audio is transcribed on this machine instead. Otherwise the
6
6
  # script prints the paid command. ATRIS_YTNOTES_LOCAL_TRANSCRIBE=0 turns it off.
7
- # Engines: auto (default: fastest installed writer that answers: Gemini
8
- # Flash through agy, then Haiku, Codex, Cursor),
7
+ # Engines: auto (default: the team's notes writer from the roster, then the
8
+ # fastest installed writer that answers: Gemini Flash through agy, then Haiku,
9
+ # Codex, Cursor),
9
10
  # gemini, haiku, atris-fast, grok, codex, cursor. ATRIS_YTNOTES_ENGINE sets the default.
10
11
  # Outputs: $WORK/yt_${ID}.clean.txt (transcript), $WORK/yt_${ID}.md (notes).
11
12
  set -euo pipefail
@@ -372,40 +373,85 @@ run_grok() {
372
373
  }
373
374
 
374
375
  run_haiku() {
375
- run_with_timeout 240 claude -p "$PROMPT" --model claude-haiku-4-5
376
+ run_with_timeout 240 claude -p "$PROMPT" --model "${1:-claude-haiku-4-5}"
376
377
  }
377
378
 
378
379
  # Gemini Flash through the Antigravity CLI (agy). On an 85-minute transcript it
379
380
  # writes notes in about 8s; Haiku takes 30-40s. The old gemini CLI is retired.
381
+ # Low effort is the floor for notes: high effort is slower and not sharper here.
380
382
  run_gemini() {
381
383
  command -v agy >/dev/null 2>&1 || { echo "agy is not installed" >&2; return 127; }
382
- run_with_timeout 120 agy -p "$PROMPT" --model "${ATRIS_YTNOTES_GEMINI_MODEL:-gemini-3.8-flash}" \
383
- --effort low --print-timeout 110s </dev/null
384
+ run_with_timeout 120 agy -p "$PROMPT" --model "${ATRIS_YTNOTES_GEMINI_MODEL:-${1:-gemini-3.8-flash}}" \
385
+ --effort "${2:-low}" --print-timeout 110s </dev/null
384
386
  }
385
387
 
386
388
  # Fastest installed writer that answers: Gemini, Haiku, Codex, Cursor.
389
+ # Entries are cmd|model|effort|label; an empty model or effort means the default.
387
390
  # Buffered so a failed or empty answer never reaches the notes file.
388
- AUTO_WRITERS="agy:Gemini claude:Haiku codex:Codex cursor-agent:Cursor"
391
+ AUTO_WRITERS="agy|||Gemini claude|||Haiku codex|||Codex cursor-agent|||Cursor"
392
+
393
+ # The team's "notes" writer, from `atris engine resolve notes --json`, run from
394
+ # the caller's project with this same package. It only reorders: any failure,
395
+ # a slow answer (2s), or a writer that is not installed falls back to the order
396
+ # above. ATRIS_YTNOTES_ROSTER=0 skips it.
397
+ resolve_roster_writers() {
398
+ [ "${ATRIS_YTNOTES_ROSTER:-1}" = "0" ] && return 0
399
+ local json=""
400
+ if [ -n "${ATRIS_YTNOTES_RESOLVE_CMD:-}" ]; then
401
+ json=$(cd "$CALLER_CWD" && run_with_timeout 2 sh -c "$ATRIS_YTNOTES_RESOLVE_CMD" 2>/dev/null) || return 0
402
+ else
403
+ [ -f "$SCRIPT_DIR/../../bin/atris.js" ] || return 0
404
+ json=$(cd "$CALLER_CWD" && ATRIS_AUTO_UPDATE=off run_with_timeout 2 node "$SCRIPT_DIR/../../bin/atris.js" engine resolve notes --json 2>/dev/null) || return 0
405
+ fi
406
+ printf '%s' "$json" | node -e '
407
+ const s = require("fs").readFileSync(0, "utf8");
408
+ const d = JSON.parse(s.slice(s.indexOf("{"), s.lastIndexOf("}") + 1));
409
+ const cmds = { agy: "agy", gemini: "agy", haiku: "claude", claude: "claude", codex: "codex", cursor: "cursor-agent", "cursor-agent": "cursor-agent" };
410
+ const clean = (v) => (v == null ? "" : String(v).replace(/[^A-Za-z0-9._:\/-]/g, ""));
411
+ const label = (cmd, model) => ({ agy: "Gemini", codex: "Codex", "cursor-agent": "Cursor" }[cmd]
412
+ || (/haiku/i.test(model || "haiku") ? "Haiku" : "Claude"));
413
+ const out = [];
414
+ for (const w of [d, ...(Array.isArray(d.backup) ? d.backup : [])]) {
415
+ const cmd = cmds[String((w && w.engine) || "").toLowerCase()];
416
+ if (!cmd) continue;
417
+ const model = clean(w.model);
418
+ out.push([cmd, model, clean(w.effort), label(cmd, model)].join("|"));
419
+ }
420
+ process.stdout.write(out.join(" "));
421
+ ' 2>/dev/null || true
422
+ }
423
+
424
+ # Roster writers first, then the rest of the built-in order, one entry per cmd.
425
+ writer_list() {
426
+ local entry cmd seen=" " list=""
427
+ for entry in $(resolve_roster_writers) $AUTO_WRITERS; do
428
+ cmd="${entry%%|*}"
429
+ case "$seen" in *" $cmd "*) continue ;; esac
430
+ seen="$seen$cmd "
431
+ list="$list $entry"
432
+ done
433
+ printf '%s' "$list"
434
+ }
389
435
 
390
436
  run_writer() {
437
+ local model="${2:-}"
391
438
  case "$1" in
392
- agy) run_gemini ;;
393
- claude) run_haiku ;;
394
- codex) run_with_timeout 240 codex exec --sandbox read-only --skip-git-repo-check "$PROMPT" </dev/null ;;
395
- cursor-agent) run_with_timeout 240 cursor-agent --trust -p "$PROMPT" ;;
439
+ agy) run_gemini "$model" "${3:-}" ;;
440
+ claude) run_haiku "$model" ;;
441
+ codex) run_with_timeout 240 codex exec ${model:+-m "$model"} --sandbox read-only --skip-git-repo-check "$PROMPT" </dev/null ;;
442
+ cursor-agent) run_with_timeout 240 cursor-agent --trust ${model:+--model "$model"} -p "$PROMPT" ;;
396
443
  esac
397
444
  }
398
445
 
399
446
  run_auto() {
400
- local entry cmd label out tried=""
401
- for entry in $AUTO_WRITERS; do
402
- cmd="${entry%%:*}"
403
- label="${entry#*:}"
447
+ local entry cmd model effort label out tried=""
448
+ for entry in $(writer_list); do
449
+ IFS='|' read -r cmd model effort label <<< "$entry"
404
450
  command -v "$cmd" >/dev/null 2>&1 || continue
405
451
  if [ -n "$tried" ]; then
406
452
  echo "$tried did not answer; writing the notes with $label instead." >&2
407
453
  fi
408
- if out=$(run_writer "$cmd" 2>>"$WORK/yt_${ID}.engine.err") && printf '%s' "$out" | grep -q '[[:alnum:]]'; then
454
+ if out=$(run_writer "$cmd" "$model" "$effort" 2>>"$WORK/yt_${ID}.engine.err") && printf '%s' "$out" | grep -q '[[:alnum:]]'; then
409
455
  printf '%s\n' "$out"
410
456
  echo "notes by $label" >&2
411
457
  return 0
@@ -12,6 +12,7 @@ const { spawnSync } = require('node:child_process');
12
12
  const fs = require('node:fs');
13
13
  const os = require('node:os');
14
14
  const path = require('node:path');
15
+ const { stateHome } = require('../../lib/state-home');
15
16
  const { evaluate, appendRow } = require('./ytrail-eval');
16
17
 
17
18
  const YTNOTES = path.join(__dirname, 'ytnotes');
@@ -186,7 +187,7 @@ function main() {
186
187
  console.error('usage: ytrail-bench [--quick] [--case <name>]');
187
188
  process.exit(2);
188
189
  }
189
- const outDir = process.env.YTRAIL_OUT_DIR || path.join(os.homedir(), '.atris', 'benchmarks');
190
+ const outDir = process.env.YTRAIL_OUT_DIR || path.join(stateHome(), '.atris', 'benchmarks');
190
191
  const started = Date.now();
191
192
  const results = [];
192
193
  for (const c of cases) {
package/utils/auth.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const os = require('os');
2
2
  const path = require('path');
3
+ const { stateHome } = require('../lib/state-home');
3
4
  const fs = require('fs');
4
5
  const { exec } = require('child_process');
5
6
  const readline = require('readline');
@@ -171,8 +172,7 @@ function restrictToOwner(filePath) {
171
172
  }
172
173
 
173
174
  function getAtrisDir() {
174
- const homeDir = os.homedir();
175
- return mkPrivateDir(path.join(homeDir, '.atris'));
175
+ return mkPrivateDir(path.join(stateHome(), '.atris'));
176
176
  }
177
177
 
178
178
  function getCredentialsPath() {
@@ -182,7 +182,7 @@ function getCredentialsPath() {
182
182
  function getPlacedAgentTokenPath() {
183
183
  const override = process.env.ATRIS_AGENT_TOKEN_FILE;
184
184
  if (override && override.trim()) return override.trim();
185
- return path.join(os.homedir(), '.atris', 'agent-token.json');
185
+ return path.join(stateHome(), '.atris', 'agent-token.json');
186
186
  }
187
187
 
188
188
  function expiryTimeMs(value) {
@@ -1,13 +1,13 @@
1
1
  const https = require('https');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
- const os = require('os');
4
+ const { stateHome } = require('../lib/state-home');
5
5
  const { spawn, spawnSync } = require('child_process');
6
6
 
7
7
  const PACKAGE_NAME = 'atris';
8
8
  const NPM_SELF_UPDATE_COMMAND = `npm install -g ${PACKAGE_NAME}@latest`;
9
9
  const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
10
- const ATRIS_DIR = path.join(os.homedir(), '.atris');
10
+ const ATRIS_DIR = path.join(stateHome(), '.atris');
11
11
  const CACHE_FILE = path.join(ATRIS_DIR, '.update-check');
12
12
 
13
13
  /**