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
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ // atris buy: quote a purchase or trade, start the person's approval, check status.
4
+ //
5
+ // atris buy quote --kind ticket --event-url <vividseats url> --quantity 2
6
+ // atris buy approve ord_<id> --pick 1 --delivery-email sam@example.com
7
+ // atris buy status ord_<id>
8
+ //
9
+ // Nothing is ever charged by this command. Approve only starts the human step:
10
+ // a tap in their Link app, a payment page, or a reply to the confirm code Atris
11
+ // texts to their own phone. The code never comes back to this command.
12
+
13
+ const { apiRequestJson } = require('../utils/api');
14
+ const { loadCredentials } = require('../utils/auth');
15
+ const { NOT_LOGGED_IN, oneLine, printResult } = require('../lib/developer-api');
16
+ const { parseFlags, formatCents, untilText, errorFrom, commerceToken } = require('../lib/commerce');
17
+
18
+ const KINDS = ['ticket', 'shop', 'trade', 'flight'];
19
+ const NOTHING_CHARGED = 'Nothing is charged until the person approves it themselves.';
20
+ const CONTROL_FLAGS = new Set(['json', 'help', 'intent', 'details']);
21
+ const TRADE_CONFIRM = 'We texted you a confirm code. Reply to that text to place the trade.';
22
+
23
+ function showBuyHelp(log = console.log) {
24
+ log(`usage: atris buy <quote|approve|status> [flags] [--json]
25
+
26
+ get a real quote, then hand the person the approval step. nothing is charged
27
+ until the person approves it themselves (Link app, payment page, or a text
28
+ from their own phone).
29
+
30
+ atris buy quote --kind ticket|shop|trade|flight [fields] [--intent '<json>']
31
+ atris buy approve <id> [fields] [--details '<json>']
32
+ atris buy status <id>
33
+
34
+ quote fields (flags, kebab-case is fine):
35
+ ticket --event-url <vividseats.com url> --quantity N [--min-each-usd N] [--max-each-usd N]
36
+ shop --query "<item>" | --url <product url> [--quantity N]
37
+ trade --side buy|sell --symbol VTI (--quantity N | --dollars N) [--order-type market|limit --limit-price N]
38
+ flight not wired yet, quotes are refused
39
+
40
+ approve fields:
41
+ ticket --pick 1 --delivery-email you@example.com
42
+ shop --delivery-email you@example.com [--options '<json>'] [--address '<json>'] [--accept-extras]
43
+ trade none. Atris texts the person a confirm code; they reply to that text.
44
+
45
+ examples:
46
+ atris buy quote --kind trade --side buy --symbol VTI --dollars 25
47
+ atris buy quote --intent '{"kind":"shop","query":"uniqlo supima crew tee navy"}'
48
+ atris buy approve ord_1234 --pick 1 --delivery-email sam@example.com
49
+ atris buy status ord_1234 --json`);
50
+ }
51
+
52
+ function fieldsFrom(flags) {
53
+ const out = {};
54
+ for (const [key, value] of Object.entries(flags)) {
55
+ if (!CONTROL_FLAGS.has(key)) out[key] = value;
56
+ }
57
+ return out;
58
+ }
59
+
60
+ function jsonFlag(flags, name) {
61
+ if (flags[name] == null) return { ok: true, value: {} };
62
+ const value = flags[name];
63
+ if (value && typeof value === 'object' && !Array.isArray(value)) return { ok: true, value };
64
+ return { ok: false, error: `--${name} must be a JSON object, like --${name} '{"kind":"shop","query":"navy tee"}'` };
65
+ }
66
+
67
+ function approvalLines(approval) {
68
+ if (!approval || typeof approval !== 'object') return [];
69
+ const lines = [];
70
+ // The backend sends trade confirm codes only to the person's own texts.
71
+ if (approval.how === 'text_confirm') return [TRADE_CONFIRM];
72
+ if (approval.url) {
73
+ const label = approval.how === 'connect_link' ? 'Connect Link first'
74
+ : approval.how === 'payment_page' ? 'Pay on this page'
75
+ : 'Approve in Link';
76
+ lines.push(`${label}: ${approval.url}`);
77
+ }
78
+ if (approval.text) lines.push(approval.text);
79
+ return lines;
80
+ }
81
+
82
+ function moneyLines(data) {
83
+ const lines = [];
84
+ if (data.total_cents != null) lines.push(`Total: ${formatCents(data.total_cents, data.currency)}`);
85
+ if (data.fee_cents != null) lines.push(`Atris fee: ${formatCents(data.fee_cents, data.currency)}`);
86
+ return lines;
87
+ }
88
+
89
+ function renderQuote(data = {}, now = Date.now()) {
90
+ const lines = [];
91
+ if (data.status !== 'quoted') return renderOutcome(data, now);
92
+ lines.push(data.summary || 'Quote ready.');
93
+ lines.push('');
94
+ lines.push(...moneyLines(data));
95
+ if (data.expires_at) lines.push(`Expires: ${data.expires_at} (${untilText(data.expires_at, now)})`);
96
+ if (data.id) lines.push(`Quote id: ${data.id}`);
97
+ if (data.product_url) lines.push(`Product: ${data.product_url}`);
98
+ const choices = data.options && typeof data.options === 'object' && !Array.isArray(data.options)
99
+ ? Object.entries(data.options) : [];
100
+ if (choices.length) {
101
+ lines.push('Choices:');
102
+ for (const [name, values] of choices) {
103
+ lines.push(` ${name}: ${Array.isArray(values) ? values.join(', ') : oneLine(values)}`);
104
+ }
105
+ }
106
+ if (data.note) lines.push('', String(data.note));
107
+ lines.push('');
108
+ lines.push(NOTHING_CHARGED);
109
+ const approval = data.approval && data.approval.text ? data.approval.text : '';
110
+ if (approval) lines.push(approval);
111
+ if (data.id) {
112
+ const hints = data.approve_with && typeof data.approve_with === 'object'
113
+ ? Object.keys(data.approve_with).map((key) => `--${key.replace(/_/g, '-')} <${key}>`).join(' ')
114
+ : '';
115
+ lines.push(`Once they say yes: atris buy approve ${data.id}${hints ? ` ${hints}` : ''}`);
116
+ }
117
+ return lines.join('\n');
118
+ }
119
+
120
+ function renderOutcome(data = {}, now = Date.now()) {
121
+ const lines = [];
122
+ const status = String(data.status || '');
123
+ if (status === 'pending_approval') {
124
+ lines.push(data.message || 'Waiting for the person to approve.');
125
+ lines.push('');
126
+ lines.push(...moneyLines(data));
127
+ if (data.expires_at) lines.push(`Expires: ${data.expires_at} (${untilText(data.expires_at, now)})`);
128
+ if (data.id) lines.push(`Id: ${data.id}`);
129
+ lines.push('');
130
+ lines.push('Next, the person does this themselves:');
131
+ for (const line of approvalLines(data.approval)) lines.push(` ${line}`);
132
+ if (data.fee_card && data.fee_card.url) lines.push(` Fee card: ${data.fee_card.url}`);
133
+ lines.push('');
134
+ lines.push('Nothing has been charged yet. Money moves only after the person acts themselves.');
135
+ if (data.id) lines.push(`Check it: atris buy status ${data.id}`);
136
+ return lines.join('\n');
137
+ }
138
+ if (status === 'needs_setup') {
139
+ lines.push(data.message || 'Setup needed first.');
140
+ for (const line of approvalLines(data.approval)) lines.push(` ${line}`);
141
+ lines.push('Nothing was charged.');
142
+ return lines.join('\n');
143
+ }
144
+ if (status === 'needs_input') {
145
+ lines.push(data.message || 'More details needed.');
146
+ if (Array.isArray(data.fields) && data.fields.length) lines.push(`Needed: ${data.fields.join(', ')}`);
147
+ if (data.extra_usd != null) lines.push(`Extra: $${data.extra_usd}`);
148
+ if (data.total_usd != null) lines.push(`New total: $${data.total_usd}`);
149
+ lines.push('Nothing was charged.');
150
+ return lines.join('\n');
151
+ }
152
+ if (status === 'refused') {
153
+ lines.push(data.message || 'That did not go through.');
154
+ if (!/nothing was/i.test(String(data.message || ''))) lines.push('Nothing was charged.');
155
+ return lines.join('\n');
156
+ }
157
+ return data.message || oneLine(data);
158
+ }
159
+
160
+ function renderStatus(data = {}) {
161
+ const lines = [];
162
+ lines.push(`${data.id || ''} ${data.kind || ''} ${String(data.state || 'unknown').toUpperCase()}`.trim());
163
+ if (data.what) lines.push(`What: ${data.what}`);
164
+ if (data.amount_cents != null) lines.push(`Amount: ${formatCents(data.amount_cents, data.currency)}`);
165
+ if (data.when) lines.push(`When: ${data.when}`);
166
+ if (data.detail) lines.push(`Detail: ${data.detail}`);
167
+ if (data.counterparty) lines.push(`With: ${data.counterparty}`);
168
+ if (data.receipt_url) lines.push(`Receipt: ${data.receipt_url}`);
169
+ if (data.fee_payment_url) lines.push(`Pay the Atris fee: ${data.fee_payment_url}`);
170
+ const approval = approvalLines(data.approval);
171
+ if (approval.length) {
172
+ lines.push('Waiting on the person:');
173
+ for (const line of approval) lines.push(` ${line}`);
174
+ }
175
+ if (data.state === 'unknown') lines.push('Money may have moved. Check the store or Robinhood app. Do not buy again to fix it.');
176
+ return lines.join('\n');
177
+ }
178
+
179
+ function buildRequest(sub, pos, flags) {
180
+ if (sub === 'quote') {
181
+ const base = jsonFlag(flags, 'intent');
182
+ if (!base.ok) return base;
183
+ const body = { ...base.value, ...fieldsFrom(flags) };
184
+ const kind = String(body.kind || '').toLowerCase();
185
+ if (!KINDS.includes(kind)) return { ok: false, error: `--kind must be one of ${KINDS.join(', ')}` };
186
+ body.kind = kind;
187
+ return { ok: true, path: '/commerce/quote', method: 'POST', body };
188
+ }
189
+ if (sub === 'approve' || sub === 'status') {
190
+ const id = String(pos[1] || '').trim();
191
+ if (!id) return { ok: false, error: `usage: atris buy ${sub} <id>` };
192
+ const path = `/commerce/${encodeURIComponent(id)}`;
193
+ if (sub === 'status') return { ok: true, path, method: 'GET' };
194
+ const base = jsonFlag(flags, 'details');
195
+ if (!base.ok) return base;
196
+ return { ok: true, path: `${path}/approve`, method: 'POST', body: { ...base.value, ...fieldsFrom(flags) } };
197
+ }
198
+ return { ok: false, error: 'usage: atris buy <quote|approve|status>. run: atris buy --help' };
199
+ }
200
+
201
+ async function buyCommand(args = [], deps = {}) {
202
+ const io = { log: deps.log || console.log, err: deps.err || console.error };
203
+ const { flags, pos } = parseFlags(args);
204
+ const sub = String(pos[0] || '').toLowerCase();
205
+ if (!sub || sub === 'help' || flags.help) {
206
+ showBuyHelp(io.log);
207
+ return 0;
208
+ }
209
+
210
+ const json = flags.json === true;
211
+ const fail = (error, status) => {
212
+ printResult({ json, ok: false, error, payload: { ok: false, error, ...(status ? { status } : {}) } }, io);
213
+ return 1;
214
+ };
215
+
216
+ const req = buildRequest(sub, pos, flags);
217
+ if (!req.ok) return fail(req.error);
218
+
219
+ const load = deps.loadCredentials || loadCredentials;
220
+ const request = deps.apiRequestJson || apiRequestJson;
221
+ const token = commerceToken(load(), 'commerce:quote', deps.now ? deps.now() : Date.now());
222
+ if (!token) return fail(NOT_LOGGED_IN);
223
+
224
+ try {
225
+ // No retries: a quote or approval must never be sent twice by accident.
226
+ const options = { method: req.method, token, retries: 0 };
227
+ if (req.body) options.body = req.body;
228
+ const result = await request(req.path, options);
229
+ if (!result || !result.ok) return fail(errorFrom(result), result && result.status);
230
+ const data = result.data || {};
231
+ const now = deps.now ? deps.now() : Date.now();
232
+ const text = sub === 'quote' ? renderQuote(data, now)
233
+ : sub === 'approve' ? renderOutcome(data, now)
234
+ : renderStatus(data);
235
+ printResult({ json, ok: true, text, payload: { ok: true, ...data } }, io);
236
+ return 0;
237
+ } catch (error) {
238
+ return fail(oneLine(error && error.message));
239
+ }
240
+ }
241
+
242
+ module.exports = { buyCommand };
package/commands/deck.js CHANGED
@@ -15,8 +15,8 @@
15
15
 
16
16
  const fs = require('fs');
17
17
  const https = require('https');
18
- const os = require('os');
19
18
  const path = require('path');
19
+ const { stateHome } = require('../lib/state-home');
20
20
  const { hasFlag } = require('../lib/arg-parser');
21
21
  const { buildDeck, THEMES, notesRequests } = require('../lib/slides-deck');
22
22
  const {
@@ -34,7 +34,7 @@ const BASE = 'api.atris.ai';
34
34
  const PFX = '/api/integrations/google-slides';
35
35
 
36
36
  function token() {
37
- try { return require(path.join(os.homedir(), '.atris/credentials.json')).token; }
37
+ try { return require(path.join(stateHome(), '.atris/credentials.json')).token; }
38
38
  catch { return null; }
39
39
  }
40
40
 
@@ -69,6 +69,8 @@ const {
69
69
  readEngineRegistry,
70
70
  resolveEngineForRole,
71
71
  resolveEngineForRoleRanked,
72
+ resolveNotesJob,
73
+ jobTakesAnyEngine,
72
74
  requireEngineBin,
73
75
  engineDoctorReport,
74
76
  engineFailureHealthStatus,
@@ -1000,10 +1002,41 @@ function rosterPickEntry(value) {
1000
1002
 
1001
1003
  const WORKER_SKIP_WORDS = Object.freeze({ 'not ready': 'down', expired: 'expired', 'bad model': 'names a model it cannot run' });
1002
1004
 
1005
+ // A worker whose end date is this many days away or fewer gets a heads-up.
1006
+ const ENDS_SOON_DAYS = 7;
1007
+ const RENEW_COMMAND = 'atris engine roster confirm';
1008
+
1009
+ // Whole local days from today to a worker's end date: 0 on its last day,
1010
+ // negative once it has ended, null when it has no valid end date.
1011
+ function endsInDays(until, now = new Date()) {
1012
+ const end = parseRosterUntil(until);
1013
+ if (!end) return null;
1014
+ const date = new Date(now);
1015
+ if (Number.isNaN(date.getTime())) return null;
1016
+ const today = new Date(date.getFullYear(), date.getMonth(), date.getDate());
1017
+ return Math.round((end.getTime() - today.getTime()) / 86400000);
1018
+ }
1019
+
1020
+ function endsSoon(days) {
1021
+ return days !== null && days >= 0 && days <= ENDS_SOON_DAYS;
1022
+ }
1023
+
1024
+ function endsInText(days) {
1025
+ if (days === 0) return 'ends today';
1026
+ if (days === 1) return 'ends tomorrow';
1027
+ return `ends in ${days} days`;
1028
+ }
1029
+
1030
+ function shortDay(until) {
1031
+ const date = parseRosterUntil(until);
1032
+ return date ? date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }).toLowerCase() : '';
1033
+ }
1034
+
1003
1035
  // One worker of a job's team as the view shows it: what it runs, and whether
1004
1036
  // it leads now, backs up, or is skipped and why.
1005
- function workerRow(step, index, leadIndex) {
1037
+ function workerRow(step, index, leadIndex, now = new Date()) {
1006
1038
  const { worker } = step;
1039
+ const endsIn = endsInDays(worker.until, now);
1007
1040
  const runs = worker.error ? null : engineRunsView(worker.engine, { model: worker.model || '', effort: worker.effort || '' });
1008
1041
  const status = index === leadIndex ? 'leads' : step.skip ? 'skipped' : 'backup';
1009
1042
  const why = step.skip === 'bad line' ? `bad line: ${worker.error}`
@@ -1016,6 +1049,8 @@ function workerRow(step, index, leadIndex) {
1016
1049
  max_seconds: Number(worker.max_seconds) > 0 ? Number(worker.max_seconds) : null,
1017
1050
  prep: worker.prep ? rosterJobLabel(worker.prep) : null,
1018
1051
  until: worker.until || null,
1052
+ ends_in_days: endsIn,
1053
+ expiring_soon: endsSoon(endsIn),
1019
1054
  line: worker.line || null,
1020
1055
  ...(worker.error ? { text: worker.text || '' } : {}),
1021
1056
  status,
@@ -1041,7 +1076,7 @@ function jobRosterRow(job, role, root, state, registry, now, key = role) {
1041
1076
  const decided = rosterDecided(resolved.source) && (!custom || resolved.job === job);
1042
1077
  const source = decided ? resolved.source : ROSTER_LAYERS.find((scope) => picks[scope]) || null;
1043
1078
  const pick = source ? picks[source] : null;
1044
- const walk = pick ? rosterWorkerWalk(pick, role, registry, now) : [];
1079
+ const walk = pick ? rosterWorkerWalk(pick, role, registry, now, { anyEngine: jobTakesAnyEngine(key) }) : [];
1045
1080
  const leadIndex = decided && resolved.source === source ? walk.findIndex((step) => !step.skip) : -1;
1046
1081
  const good = walk.map((step, index) => ({ step, index })).filter(({ step }) => !step.worker.error);
1047
1082
  const first = good[0] || null;
@@ -1055,6 +1090,7 @@ function jobRosterRow(job, role, root, state, registry, now, key = role) {
1055
1090
  : good[1] && leadIndex === good[1].index ? 'backup'
1056
1091
  : 'later';
1057
1092
  const kind = Object.keys(ENGINE_JOBS).find((name) => ENGINE_JOBS[name] === role);
1093
+ const pickEndsIn = pick ? endsInDays(pick.until, now) : null;
1058
1094
  // What the job really runs: its first two good workers, each with its own
1059
1095
  // model and effort, or the router's engine when no line decides.
1060
1096
  const runsOf = (entry) => (entry ? engineRunsView(entry.step.worker.engine, { model: entry.step.worker.model || '', effort: entry.step.worker.effort || '' }) : null);
@@ -1076,12 +1112,14 @@ function jobRosterRow(job, role, root, state, registry, now, key = role) {
1076
1112
  model: resolved.engine && resolved.engine.roster_model ? resolved.engine.roster_model : null,
1077
1113
  effort: resolved.engine && resolved.engine.roster_effort ? resolved.engine.roster_effort : null,
1078
1114
  max_seconds: pick && Number(pick.max_seconds) > 0 ? Number(pick.max_seconds) : null,
1115
+ ends_in_days: pickEndsIn,
1116
+ expiring_soon: endsSoon(pickEndsIn),
1079
1117
  prep: first && first.step.worker.prep ? rosterJobLabel(first.step.worker.prep) : null,
1080
1118
  backup_prep: good[1] && good[1].step.worker.prep ? rosterJobLabel(good[1].step.worker.prep) : null,
1081
1119
  runs: runs || nowRuns,
1082
1120
  backup_runs: runsOf(good[1]),
1083
1121
  now_runs: nowRuns,
1084
- workers: walk.map((step, index) => workerRow(step, index, leadIndex)),
1122
+ workers: walk.map((step, index) => workerRow(step, index, leadIndex, now)),
1085
1123
  lead,
1086
1124
  status,
1087
1125
  ...(status === 'cooling' ? { cooling: first.step.cooling.text } : {}),
@@ -1114,8 +1152,11 @@ function rosterReport(root = process.cwd(), now = new Date()) {
1114
1152
  const state = readRosterState(root, { now });
1115
1153
  const jobs = jobRosterView(root, now, state);
1116
1154
  const team = teamRosterView(root, { now, rosterState: state });
1155
+ const expiring = expiringWorkers(jobs);
1117
1156
  return {
1118
1157
  jobs,
1158
+ expiring,
1159
+ renew_command: expiring.length ? RENEW_COMMAND : null,
1119
1160
  team: team.rows,
1120
1161
  warnings: [...state.session.warnings, ...state.project.warnings, ...state.machine.warnings, ...team.warnings],
1121
1162
  files: {
@@ -1127,12 +1168,52 @@ function rosterReport(root = process.cwd(), now = new Date()) {
1127
1168
  };
1128
1169
  }
1129
1170
 
1130
- function untilText(worker) {
1171
+ // Every worker, in any job, whose end date is today or within the next
1172
+ // seven days. Workers already past their date show on their own lines. A
1173
+ // job set for this shell only is skipped: roster confirm does not renew it.
1174
+ function expiringWorkers(jobs) {
1175
+ const list = [];
1176
+ for (const row of jobs || []) {
1177
+ if (row.session_pick) continue;
1178
+ for (const worker of row.workers || []) {
1179
+ if (!worker.expiring_soon) continue;
1180
+ list.push({
1181
+ job: row.job,
1182
+ engine: worker.engine,
1183
+ model: worker.model,
1184
+ tool: worker.runs ? worker.runs.text : worker.engine,
1185
+ until: worker.until,
1186
+ ends_in_days: worker.ends_in_days,
1187
+ from: row.from,
1188
+ });
1189
+ }
1190
+ }
1191
+ return list;
1192
+ }
1193
+
1194
+ function untilText(worker, days = null) {
1131
1195
  const until = parseRosterUntil(worker.until);
1132
- if (until) return `until ${until.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }).toLowerCase()}`;
1196
+ if (until) return `until ${shortDay(worker.until)}${endsSoon(days) ? `, ${endsInText(days)}` : ''}`;
1133
1197
  return worker.never_expires ? 'no end date' : 'until no valid date';
1134
1198
  }
1135
1199
 
1200
+ // One line under the jobs when any worker ends within seven days, naming
1201
+ // each one and the command that keeps it going.
1202
+ function renderExpiringLine(expiring) {
1203
+ if (!expiring || !expiring.length) return '';
1204
+ const names = expiring.map((item) => `${item.tool} on ${item.job} ${endsInText(item.ends_in_days)}`);
1205
+ const them = expiring.length === 1 ? 'it' : 'them';
1206
+ return `heads up: ${names.join(', ')}. to keep ${them} 30 more days, run: ${RENEW_COMMAND}`;
1207
+ }
1208
+
1209
+ // The same heads-up, short enough for one boot line.
1210
+ function bootExpiringLine(expiring) {
1211
+ if (!expiring || !expiring.length) return '';
1212
+ const [first] = expiring;
1213
+ const more = expiring.length > 1 ? ` and ${expiring.length - 1} more` : '';
1214
+ return `${first.tool} on ${first.job} ${endsInText(first.ends_in_days)}${more}. renew: ${RENEW_COMMAND}`;
1215
+ }
1216
+
1136
1217
  // Every worker under its job, numbered in order, when the job has more than
1137
1218
  // the lead and one backup (the job's own line already shows those two).
1138
1219
  function renderWorkerLines(row) {
@@ -1140,8 +1221,11 @@ function renderWorkerLines(row) {
1140
1221
  const width = Math.max(...row.workers.map((worker) => (worker.runs ? worker.runs.text : worker.text || '').length));
1141
1222
  return row.workers.map((worker, index) => {
1142
1223
  const what = (worker.runs ? worker.runs.text : worker.text || '').padEnd(width);
1143
- const extras = [worker.max_seconds ? rosterMaxText(worker.max_seconds) : '', worker.prep ? `prepped by ${worker.prep}` : '', worker.until ? `until ${worker.until}` : ''].filter(Boolean);
1144
- const state = worker.status === 'leads' ? 'leads now' : worker.status === 'backup' ? 'backup' : `skipped, ${worker.why}`;
1224
+ const ended = worker.status === 'skipped' && worker.why === 'expired' && worker.ends_in_days !== null;
1225
+ const until = !worker.until || ended ? '' : `until ${worker.until}${worker.expiring_soon ? `, ${endsInText(worker.ends_in_days)}` : ''}`;
1226
+ const extras = [worker.max_seconds ? rosterMaxText(worker.max_seconds) : '', worker.prep ? `prepped by ${worker.prep}` : '', until].filter(Boolean);
1227
+ const state = worker.status === 'leads' ? 'leads now' : worker.status === 'backup' ? 'backup'
1228
+ : ended ? `ended ${shortDay(worker.until)}, skipped` : `skipped, ${worker.why}`;
1145
1229
  return ` ${index + 1}. ${what} ${[state, ...extras].join(', ')}`.trimEnd();
1146
1230
  });
1147
1231
  }
@@ -1165,9 +1249,10 @@ function renderJobRoster(rows) {
1165
1249
  const owner = row.runs.text.padEnd(ownerWidth);
1166
1250
  const backup = row.backup_runs ? `backup ${row.backup_runs.text}${row.backup_prep ? ` prepped by ${row.backup_prep}` : ''}` : 'no backup';
1167
1251
  const cap = `${row.max_seconds ? `${rosterMaxText(row.max_seconds)}, ` : ''}${row.prep ? `prepped by ${row.prep}, ` : ''}`;
1168
- const date = untilText(row.pick);
1252
+ const date = untilText(row.pick, row.ends_in_days);
1169
1253
  const fallback = row.lead === 'backup' ? 'using backup' : row.lead === 'later' ? `using ${nowText}` : fallsTo;
1170
- const status = row.status === 'expired' ? `expired, ${fallback}`
1254
+ const ended = row.ends_in_days !== null && row.ends_in_days !== undefined ? `ended ${shortDay(row.pick.until)}, skipped` : 'expired';
1255
+ const status = row.status === 'expired' ? `${ended}, ${fallback}`
1171
1256
  : row.status === 'cooling' ? `${row.cooling}, ${fallback}`
1172
1257
  : row.status === 'not ready' ? `not ready, ${fallback}`
1173
1258
  : date;
@@ -1195,7 +1280,8 @@ function renderRosterWarnings(warnings) {
1195
1280
  }
1196
1281
 
1197
1282
  function renderRosterReport(report) {
1198
- return [renderJobRoster(report.jobs), renderTeamRoster(report.team), renderRosterWarnings(report.warnings)]
1283
+ const jobs = [renderJobRoster(report.jobs), renderExpiringLine(report.expiring)].filter(Boolean).join('\n');
1284
+ return [jobs, renderTeamRoster(report.team), renderRosterWarnings(report.warnings)]
1199
1285
  .filter(Boolean)
1200
1286
  .join('\n\n');
1201
1287
  }
@@ -1530,9 +1616,30 @@ function parseSetFlag(args) {
1530
1616
  return '';
1531
1617
  }
1532
1618
 
1533
- function runResolveCommand(args, root) {
1619
+ // One worker as a person reads it: the tool, then its model and effort.
1620
+ function notesWorkerText(worker) {
1621
+ const details = [worker.model, worker.effort ? `effort ${worker.effort}` : '', worker.installed ? '' : 'not installed'].filter(Boolean);
1622
+ return details.length ? `${worker.engine} (${details.join(', ')})` : worker.engine;
1623
+ }
1624
+
1625
+ // The notes job: the roster's pick when one is set, else the built-in
1626
+ // default. Always answers, so scripts can call it with a short timeout.
1627
+ function runNotesResolve(json, root, now) {
1628
+ const answer = resolveNotesJob(root, { now });
1629
+ if (json) {
1630
+ console.log(JSON.stringify(answer));
1631
+ return 0;
1632
+ }
1633
+ const backup = answer.backup.length ? `backup ${answer.backup.map(notesWorkerText).join(', then ')}` : 'no backup';
1634
+ const where = answer.source === 'roster' ? `from ${answer.from}` : `built-in default${answer.skipped ? ` (${answer.skipped})` : ''}`;
1635
+ console.log(`notes: ${notesWorkerText(answer)}, ${backup}, ${where}`);
1636
+ return 0;
1637
+ }
1638
+
1639
+ function runResolveCommand(args, root, now) {
1534
1640
  const json = args.includes('--json');
1535
1641
  const role = args.filter((a) => !String(a).startsWith('--'))[0] || '';
1642
+ if (role && rosterJobKey(role) === 'notes') return runNotesResolve(json, root, now);
1536
1643
  if (!role) {
1537
1644
  const message = `usage: atris engine resolve <role> or <job>; roles: ${ENGINE_ROLES.join(', ')}; jobs: search, build, review, or your own`;
1538
1645
  if (json) console.log(JSON.stringify({ ok: false, error: message }, null, 2));
@@ -2133,7 +2240,7 @@ function engineCommand(args = [], deps = {}) {
2133
2240
  }
2134
2241
 
2135
2242
  if (sub === 'resolve') {
2136
- return runResolveCommand(scope.args.slice(scope.args.indexOf('resolve') + 1), root);
2243
+ return runResolveCommand(scope.args.slice(scope.args.indexOf('resolve') + 1), root, deps.now);
2137
2244
  }
2138
2245
 
2139
2246
  if (sub === 'health') {
@@ -2160,7 +2267,7 @@ function engineCommand(args = [], deps = {}) {
2160
2267
  if (sub === 'help' || args.includes('--help') || args.includes('-h')) {
2161
2268
  console.log('\n atris engine watch [<id>|latest] [--no-follow]\n follow one live transcript or list running engine work');
2162
2269
  console.log('\n long read-only asks: atris engine ask "..." --engine agy --timeout <seconds>\n quick asks default to 120 seconds; explicit jobs allow up to 3600\n follow live work with atris engine watch latest');
2163
- console.log('\n atris engine roster + current default\n atris engine roster [--json] show every job and its workers in order: search, build, review, and your own\n atris engine roster confirm renew every dated worker for 30 days\n atris engine roster session [clear]\n show or drop the roster changes for this shell only\n atris engine roster --available\n tools on this machine and the models each one offers\n atris engine roster --runs [job]\n the last 20 runs, newest first, with tool, model, time, and outcome\n atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--prep <job>] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]\n atris engine assign <job> --promote "<tool> [model]" | --remove <tool> | --clear [--session | --everywhere]\n sets the lead worker of the job; --promote moves one of its workers to the top as written;\n --add puts one more at the end; jobs: search, build, review,\n or your own like "small build" (--like search|build|review when the name does not say);\n --prep search has that job read the task first and hand this worker a short brief;\n --everywhere is for all projects; --session is this shell only (set ATRIS_ROSTER_SESSION=<name>)\n atris engines --chart show the fleet as an org chart\n atris engine list --json [--all] full registry: default + engines with tier, roles, fallback, health (--all includes hidden engines)\n atris engine set <name> --duty leader|errands|learning [--models "a, b"]\n arrange the fleet and save its model policy\n atris engine resolve <role> or <job> [--json]\n choose the best ready engine for navigator|executor|validator or a roster job\n atris engine health <name> --set ready|not_installed|credit_out\n flip runtime health, for example when credits run out\n atris engine doctor [--json]\n probe which engine CLIs are installed here and sync that into health policy\n atris engine <name> make that engine the default here\n atris engine test [name] preflight: run the engine CLI headless, report pass/fail\n atris engine bench [names...] [--runs N]\n ranked latency scoreboard of engine passes\n atris engine ask "<question>" --engine <name> [--engine <name> ...]\n ask several engines in parallel without allowing edits\n atris engine ask --jobs <jobs.json>\n ask different read-only questions in parallel\n atris engine validate <receipt-path|latest> [--engine <name>]\n check ask answers with a different read-only referee\n atris engine validate scoreboard\n show pass rates by worker engine\n atris engine dispatch <task-id> [<task-id> ...] --engine cursor|codex [--prompt-file <f>] [--yolo]\n one-command claim, worktree, build, verify, ship, ready\n atris engine login <provider> --yes\n upload a local provider CLI login to the backend vault\n atris engine login <provider> --computer [--seat <name>]\n atris engine login <provider> --business <id> [--seat <name>]\n sign in on an Atris computer by device flow\n atris engine login --list | --remove <provider>\n list or remove vaulted provider logins\n atris engine seats show which named accounts are ready to work\n atris engine seed <provider> --business <id>|--user\n push a vaulted login onto an Atris computer\n atris engine reset back to the house default\n --engine <name> one run on that engine (mission run / autopilot / run)\n');
2270
+ console.log('\n atris engine roster + current default\n atris engine roster [--json] show every job and its workers in order: search, build, review, and your own\n atris engine roster confirm renew every dated worker for 30 days\n atris engine roster session [clear]\n show or drop the roster changes for this shell only\n atris engine roster --available\n tools on this machine and the models each one offers\n atris engine roster --runs [job]\n the last 20 runs, newest first, with tool, model, time, and outcome\n atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--prep <job>] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]\n atris engine assign <job> --promote "<tool> [model]" | --remove <tool> | --clear [--session | --everywhere]\n sets the lead worker of the job; --promote moves one of its workers to the top as written;\n --add puts one more at the end; jobs: search, build, review,\n or your own like "small build" (--like search|build|review when the name does not say);\n --prep search has that job read the task first and hand this worker a short brief;\n --everywhere is for all projects; --session is this shell only (set ATRIS_ROSTER_SESSION=<name>)\n atris engines --chart show the fleet as an org chart\n atris engine list --json [--all] full registry: default + engines with tier, roles, fallback, health (--all includes hidden engines)\n atris engine set <name> --duty leader|errands|learning [--models "a, b"]\n arrange the fleet and save its model policy\n atris engine resolve <role> or <job> [--json]\n choose the best ready engine for navigator|executor|validator or a roster job;\n notes always answers: its roster pick, else agy then haiku\n atris engine health <name> --set ready|not_installed|credit_out\n flip runtime health, for example when credits run out\n atris engine doctor [--json]\n probe which engine CLIs are installed here and sync that into health policy\n atris engine <name> make that engine the default here\n atris engine test [name] preflight: run the engine CLI headless, report pass/fail\n atris engine bench [names...] [--runs N]\n ranked latency scoreboard of engine passes\n atris engine ask "<question>" --engine <name> [--engine <name> ...]\n ask several engines in parallel without allowing edits\n atris engine ask --jobs <jobs.json>\n ask different read-only questions in parallel\n atris engine validate <receipt-path|latest> [--engine <name>]\n check ask answers with a different read-only referee\n atris engine validate scoreboard\n show pass rates by worker engine\n atris engine dispatch <task-id> [<task-id> ...] --engine cursor|codex [--prompt-file <f>] [--yolo]\n one-command claim, worktree, build, verify, ship, ready\n atris engine login <provider> --yes\n upload a local provider CLI login to the backend vault\n atris engine login <provider> --computer [--seat <name>]\n atris engine login <provider> --business <id> [--seat <name>]\n sign in on an Atris computer by device flow\n atris engine login --list | --remove <provider>\n list or remove vaulted provider logins\n atris engine seats show which named accounts are ready to work\n atris engine seed <provider> --business <id>|--user\n push a vaulted login onto an Atris computer\n atris engine reset back to the house default\n --engine <name> one run on that engine (mission run / autopilot / run)\n');
2164
2271
  return 0;
2165
2272
  }
2166
2273
  if (isFreshWorkspace(root)) {
@@ -2210,6 +2317,8 @@ module.exports = {
2210
2317
  roster,
2211
2318
  rosterReport,
2212
2319
  jobRosterView,
2320
+ expiringWorkers,
2321
+ bootExpiringLine,
2213
2322
  engineRegistryFile,
2214
2323
  readEngineRegistry,
2215
2324
  resolveEngineForRole,
@@ -17,6 +17,7 @@
17
17
 
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
+ const { stateHome } = require('../lib/state-home');
20
21
  const { loadCredentials } = require('../utils/auth');
21
22
  const { apiRequestJson } = require('../utils/api');
22
23
  const {
@@ -297,8 +298,7 @@ function resolveBusinessArg(value) {
297
298
  // Full UUID, trust it
298
299
  if (/^[0-9a-f-]{32,}$/i.test(value)) return value;
299
300
  // Otherwise treat as slug and look up in ~/.atris/businesses.json
300
- const home = require('os').homedir();
301
- const file = path.join(home, '.atris', 'businesses.json');
301
+ const file = path.join(stateHome(), '.atris', 'businesses.json');
302
302
  if (!fs.existsSync(file)) return null;
303
303
  try {
304
304
  const map = JSON.parse(fs.readFileSync(file, 'utf8'));
package/commands/fork.js CHANGED
@@ -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
 
@@ -48,7 +48,7 @@ async function forkAtris() {
48
48
  files = downloadResult.data.files;
49
49
  } else {
50
50
  // Fall back to local template at ~/.atris/templates/{template}/
51
- const localTemplatePath = path.join(os.homedir(), '.atris', 'templates', template);
51
+ const localTemplatePath = path.join(stateHome(), '.atris', 'templates', template);
52
52
  if (fs.existsSync(localTemplatePath)) {
53
53
  console.log(' API unavailable, using local template...');
54
54
  files = readLocalTemplate(localTemplatePath, localTemplatePath);
package/commands/gm.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
+ const { stateHome } = require('../lib/state-home');
6
7
  const { hasFlag: hasExactFlag } = require('../lib/arg-parser');
7
8
 
8
9
  const AGENTXP_LEADERBOARD_URL = 'https://api.atris.ai/api/agentxp/leaderboard';
@@ -452,7 +453,7 @@ function feedPostId(post) {
452
453
  }
453
454
 
454
455
  function credentialsFileExists() {
455
- return fs.existsSync(path.join(os.homedir(), '.atris', 'credentials.json'));
456
+ return fs.existsSync(path.join(stateHome(), '.atris', 'credentials.json'));
456
457
  }
457
458
 
458
459
  async function fetchFeedEvents(workspaceRoot) {