atris 3.61.0 → 3.63.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 (53) hide show
  1. package/README.md +37 -5
  2. package/atris/features/README.md +8 -0
  3. package/atris/skills/README.md +1 -0
  4. package/atris/skills/commerce/SKILL.md +96 -0
  5. package/atris/skills/engines/SKILL.md +9 -9
  6. package/bin/atris.js +12 -0
  7. package/commands/autoland.js +36 -5
  8. package/commands/autopilot.js +105 -6
  9. package/commands/buy.js +242 -0
  10. package/commands/engine.js +96 -21
  11. package/commands/host.js +56 -4
  12. package/commands/land.js +129 -8
  13. package/commands/member.js +68 -14
  14. package/commands/mission.js +116 -4
  15. package/commands/one-lap.js +14 -10
  16. package/commands/stream.js +56 -22
  17. package/commands/task.js +7 -0
  18. package/commands/team.js +82 -10
  19. package/commands/transactions.js +115 -0
  20. package/commands/worktree.js +39 -0
  21. package/commands/youtube.js +58 -26
  22. package/lib/commerce.js +75 -0
  23. package/lib/engine-ask.js +112 -9
  24. package/lib/engine-registry.js +300 -39
  25. package/lib/fleet.js +444 -26
  26. package/lib/functional-owner.js +15 -6
  27. package/lib/host.js +296 -170
  28. package/lib/known-commands.js +1 -1
  29. package/lib/member-engine.js +25 -1
  30. package/lib/member-park.js +138 -0
  31. package/lib/member-scaffold.js +39 -0
  32. package/lib/member-type.js +27 -0
  33. package/lib/roster-markdown.js +1 -1
  34. package/lib/roster-prep.js +289 -0
  35. package/lib/roster-runs.js +389 -0
  36. package/lib/roster-suggest.js +145 -0
  37. package/lib/router-brain.js +111 -3
  38. package/lib/runner-command.js +37 -2
  39. package/lib/self-drive.js +26 -2
  40. package/lib/state-detection.js +3 -3
  41. package/lib/task-db.js +38 -3
  42. package/lib/team-lineup.js +7 -2
  43. package/lib/todo.js +1 -1
  44. package/lib/wish-audit.js +1 -1
  45. package/package.json +1 -1
  46. package/scripts/det/cpu-hotspots.js +216 -0
  47. package/scripts/det/ytnotes +169 -23
  48. package/scripts/det/ytquote-repair.js +18 -4
  49. package/scripts/det/ytrail-bench.js +214 -0
  50. package/scripts/det/ytrail-eval.js +128 -56
  51. package/templates/members/host/MEMBER.md +5 -3
  52. package/templates/members/host/MISSION.md +1 -1
  53. package/templates/members/host/skills/host/SKILL.md +64 -16
@@ -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, tokenFrom, oneLine, printResult } = require('../lib/developer-api');
16
+ const { parseFlags, formatCents, untilText, errorFrom } = 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 = tokenFrom(load());
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 };
@@ -11,8 +11,8 @@
11
11
  // atris engine roster confirm renew every dated worker for 30 days
12
12
  // atris engine roster session [clear] this shell's own changes
13
13
  // atris engine roster --available tools and models on this machine
14
- // atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]
15
- // atris engine assign <job> --remove <tool> | --clear [--session | --everywhere]
14
+ // atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--prep <job>] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]
15
+ // atris engine assign <job> --promote "<tool> [model]" | --remove <tool> | --clear [--session | --everywhere]
16
16
  // jobs are open-ended: search, build, review, or your own ("small build")
17
17
  // atris engine cursor make cursor the default engine here
18
18
  // atris engine reset back to the house default (atris-fast)
@@ -36,6 +36,8 @@ const { parseScopeFlag } = require('../lib/cli-scope');
36
36
  const { isFreshWorkspace, speakFirstMinute } = require('../lib/first-minute');
37
37
  const { teamRosterView } = require('../lib/member-engine');
38
38
  const { engineRunsView, availableModels } = require('../lib/roster-models');
39
+ const { readRosterRuns, workerRuns, summarizeRuns, recentRuns, renderRunLine, DEFAULT_DAYS: RUN_DAYS } = require('../lib/roster-runs');
40
+ const { attachSuggestions } = require('../lib/roster-suggest');
39
41
  const {
40
42
  ENGINE_ROLES,
41
43
  ENGINE_JOBS,
@@ -70,6 +72,8 @@ const {
70
72
  requireEngineBin,
71
73
  engineDoctorReport,
72
74
  engineFailureHealthStatus,
75
+ engineReadyAt,
76
+ coolingView,
73
77
  setEngineOverrides,
74
78
  setEngineHealth,
75
79
  } = require('../lib/engine-registry');
@@ -144,7 +148,7 @@ function resolveDefaultEngine(root = process.cwd()) {
144
148
  if (saved) return { name: saved, source: 'saved' };
145
149
  const ready = new Set(
146
150
  engineRegistryView(root)
147
- .filter((engine) => engine.health && engine.health.status === 'ready')
151
+ .filter((engine) => engineReadyAt(engine))
148
152
  .map((engine) => engine.id)
149
153
  );
150
154
  if (ready.has(HOUSE_ENGINE)) return { name: HOUSE_ENGINE, source: 'house' };
@@ -941,7 +945,7 @@ function printRoster(root, { scope = 'workspace' } = {}) {
941
945
  const list = roster(root);
942
946
  const scoped = scope === 'global'
943
947
  ? list
944
- : list.filter((engine) => engine.installed || engine.default || engine.health.status === 'ready');
948
+ : list.filter((engine) => engine.installed || engine.default || ['ready', 'cooling'].includes(engine.health.status));
945
949
  const found = scoped.filter((e) => e.installed).length;
946
950
  const current = resolveDefaultEngine(root);
947
951
  console.log('');
@@ -954,6 +958,7 @@ function printRoster(root, { scope = 'workspace' } = {}) {
954
958
  console.log(` ${mark} ${engine.id.padEnd(12)} ${state.padEnd(13)} ${engine.tier.padEnd(4)} ${roles}`);
955
959
  const details = [`models: ${engine.models.join(', ')}`];
956
960
  if (engine.duty) details.push(`duty: ${engine.duty}`);
961
+ if (engine.health.status === 'cooling') details.push(coolingView(engine).text);
957
962
  console.log(` ${details.join(' ')}`);
958
963
  }
959
964
  if (scope !== 'global' && list.length > scoped.length) {
@@ -1001,17 +1006,21 @@ function workerRow(step, index, leadIndex) {
1001
1006
  const { worker } = step;
1002
1007
  const runs = worker.error ? null : engineRunsView(worker.engine, { model: worker.model || '', effort: worker.effort || '' });
1003
1008
  const status = index === leadIndex ? 'leads' : step.skip ? 'skipped' : 'backup';
1004
- const why = step.skip === 'bad line' ? `bad line: ${worker.error}` : step.skip ? WORKER_SKIP_WORDS[step.skip] : '';
1009
+ const why = step.skip === 'bad line' ? `bad line: ${worker.error}`
1010
+ : step.skip === 'cooling' && step.cooling ? step.cooling.text
1011
+ : step.skip ? WORKER_SKIP_WORDS[step.skip] : '';
1005
1012
  return {
1006
1013
  engine: worker.engine || null,
1007
1014
  model: worker.model || null,
1008
1015
  effort: worker.effort || null,
1009
1016
  max_seconds: Number(worker.max_seconds) > 0 ? Number(worker.max_seconds) : null,
1017
+ prep: worker.prep ? rosterJobLabel(worker.prep) : null,
1010
1018
  until: worker.until || null,
1011
1019
  line: worker.line || null,
1012
1020
  ...(worker.error ? { text: worker.text || '' } : {}),
1013
1021
  status,
1014
1022
  why,
1023
+ ...(step.cooling ? { cooling_until: step.cooling.until, cooling_reason: step.cooling.reason } : {}),
1015
1024
  runs,
1016
1025
  };
1017
1026
  }
@@ -1039,7 +1048,8 @@ function jobRosterRow(job, role, root, state, registry, now, key = role) {
1039
1048
  const status = !pick ? 'router'
1040
1049
  : first && first.step.skip === 'expired' ? 'expired'
1041
1050
  : first && leadIndex === first.index ? 'picked'
1042
- : 'not ready';
1051
+ : first && first.step.skip === 'cooling' ? 'cooling'
1052
+ : 'not ready';
1043
1053
  const lead = leadIndex === -1 ? 'none'
1044
1054
  : first && leadIndex === first.index ? 'first'
1045
1055
  : good[1] && leadIndex === good[1].index ? 'backup'
@@ -1066,12 +1076,15 @@ function jobRosterRow(job, role, root, state, registry, now, key = role) {
1066
1076
  model: resolved.engine && resolved.engine.roster_model ? resolved.engine.roster_model : null,
1067
1077
  effort: resolved.engine && resolved.engine.roster_effort ? resolved.engine.roster_effort : null,
1068
1078
  max_seconds: pick && Number(pick.max_seconds) > 0 ? Number(pick.max_seconds) : null,
1079
+ prep: first && first.step.worker.prep ? rosterJobLabel(first.step.worker.prep) : null,
1080
+ backup_prep: good[1] && good[1].step.worker.prep ? rosterJobLabel(good[1].step.worker.prep) : null,
1069
1081
  runs: runs || nowRuns,
1070
1082
  backup_runs: runsOf(good[1]),
1071
1083
  now_runs: nowRuns,
1072
1084
  workers: walk.map((step, index) => workerRow(step, index, leadIndex)),
1073
1085
  lead,
1074
1086
  status,
1087
+ ...(status === 'cooling' ? { cooling: first.step.cooling.text } : {}),
1075
1088
  reason: resolved.reason,
1076
1089
  };
1077
1090
  }
@@ -1127,12 +1140,19 @@ function renderWorkerLines(row) {
1127
1140
  const width = Math.max(...row.workers.map((worker) => (worker.runs ? worker.runs.text : worker.text || '').length));
1128
1141
  return row.workers.map((worker, index) => {
1129
1142
  const what = (worker.runs ? worker.runs.text : worker.text || '').padEnd(width);
1130
- const extras = [worker.max_seconds ? rosterMaxText(worker.max_seconds) : '', worker.until ? `until ${worker.until}` : ''].filter(Boolean);
1143
+ const extras = [worker.max_seconds ? rosterMaxText(worker.max_seconds) : '', worker.prep ? `prepped by ${worker.prep}` : '', worker.until ? `until ${worker.until}` : ''].filter(Boolean);
1131
1144
  const state = worker.status === 'leads' ? 'leads now' : worker.status === 'backup' ? 'backup' : `skipped, ${worker.why}`;
1132
1145
  return ` ${index + 1}. ${what} ${[state, ...extras].join(', ')}`.trimEnd();
1133
1146
  });
1134
1147
  }
1135
1148
 
1149
+ // Each worker's recent record under its job, only for workers with runs.
1150
+ function renderWorkerRecords(row) {
1151
+ return (row.workers || [])
1152
+ .filter((worker) => worker.record)
1153
+ .map((worker) => ` ${worker.runs ? worker.runs.text : worker.engine}: ${worker.record.text}`);
1154
+ }
1155
+
1136
1156
  function renderJobRoster(rows) {
1137
1157
  const width = Math.max(7, ...rows.map((row) => row.job.length));
1138
1158
  const ownerWidth = Math.max(24, ...rows.filter((row) => row.pick && row.runs).map((row) => row.runs.text.length));
@@ -1143,16 +1163,18 @@ function renderJobRoster(rows) {
1143
1163
  const fallsTo = `${row.like ? `falls back to ${row.like}` : 'router decides'}: ${nowText}`;
1144
1164
  if (!row.pick) return `${label} no pick, ${fallsTo}`;
1145
1165
  const owner = row.runs.text.padEnd(ownerWidth);
1146
- const backup = row.backup_runs ? `backup ${row.backup_runs.text}` : 'no backup';
1147
- const cap = row.max_seconds ? `${rosterMaxText(row.max_seconds)}, ` : '';
1166
+ const backup = row.backup_runs ? `backup ${row.backup_runs.text}${row.backup_prep ? ` prepped by ${row.backup_prep}` : ''}` : 'no backup';
1167
+ const cap = `${row.max_seconds ? `${rosterMaxText(row.max_seconds)}, ` : ''}${row.prep ? `prepped by ${row.prep}, ` : ''}`;
1148
1168
  const date = untilText(row.pick);
1149
1169
  const fallback = row.lead === 'backup' ? 'using backup' : row.lead === 'later' ? `using ${nowText}` : fallsTo;
1150
1170
  const status = row.status === 'expired' ? `expired, ${fallback}`
1151
- : row.status === 'not ready' ? `not ready, ${fallback}`
1152
- : date;
1171
+ : row.status === 'cooling' ? `${row.cooling}, ${fallback}`
1172
+ : row.status === 'not ready' ? `not ready, ${fallback}`
1173
+ : date;
1153
1174
  const where = row.file ? `${row.from} (${row.file})` : row.from;
1154
1175
  const head = `${label} ${owner} ${backup.padEnd(backupWidth)} ${cap}${status}, ${where}`.trimEnd();
1155
- return [head, ...renderWorkerLines(row)].join('\n');
1176
+ const suggestion = row.suggestion ? [` suggestion: ${row.suggestion.text}`] : [];
1177
+ return [head, ...renderWorkerLines(row), ...renderWorkerRecords(row), ...suggestion].join('\n');
1156
1178
  }).join('\n');
1157
1179
  }
1158
1180
 
@@ -1234,15 +1256,62 @@ function runRosterSessionCommand(rest, json, root, now) {
1234
1256
  return 0;
1235
1257
  }
1236
1258
 
1259
+ // Attach each worker's recent record (last 7 days) to the roster report, and
1260
+ // at most one suggestion per job when that record says the order is wrong.
1261
+ // Only the roster command reads the whole tail of the run file; the boot
1262
+ // line reads a smaller tail just to count suggestions. Nothing here writes
1263
+ // the roster: a suggestion is a line to read and a command a person may run.
1264
+ function attachRunRecords(report, root, now = new Date()) {
1265
+ const runs = readRosterRuns(root, { now, days: RUN_DAYS });
1266
+ if (!runs.length) return report;
1267
+ for (const row of report.jobs || []) {
1268
+ for (const worker of row.workers || []) {
1269
+ if (!worker.engine) continue;
1270
+ const record = summarizeRuns(workerRuns(runs, { job: row.key || row.job, engine: worker.engine, model: worker.model || '' }), { days: RUN_DAYS });
1271
+ if (record) worker.record = record;
1272
+ }
1273
+ }
1274
+ attachSuggestions(report.jobs, runs);
1275
+ return report;
1276
+ }
1277
+
1278
+ const RUNS_LIST_DAYS = 30;
1279
+ const RUNS_LIST_LIMIT = 20;
1280
+
1281
+ // The recent attempts, newest first, one plain line each.
1282
+ function runRosterRunsCommand(job, json, root, now) {
1283
+ const runs = recentRuns(readRosterRuns(root, { now, days: RUNS_LIST_DAYS }), { job, limit: RUNS_LIST_LIMIT });
1284
+ if (json) {
1285
+ console.log(JSON.stringify({ job: job || null, days: RUNS_LIST_DAYS, runs }, null, 2));
1286
+ return 0;
1287
+ }
1288
+ if (!runs.length) {
1289
+ console.log(job ? `no ${job} runs recorded in the last ${RUNS_LIST_DAYS} days` : `no runs recorded in the last ${RUNS_LIST_DAYS} days`);
1290
+ return 0;
1291
+ }
1292
+ console.log([`recent ${job ? `${job} ` : ''}runs, newest first`, ...runs.map(renderRunLine)].join('\n'));
1293
+ return 0;
1294
+ }
1295
+
1237
1296
  function runRosterCommand(args, root, now = new Date()) {
1238
1297
  const json = args.includes('--json');
1239
1298
  const available = args.includes('--available');
1299
+ const runsAt = args.indexOf('--runs');
1300
+ if (runsAt !== -1) {
1301
+ const other = args.slice(0, runsAt).filter((arg) => arg !== '--json');
1302
+ const job = args.slice(runsAt + 1).filter((arg) => !String(arg).startsWith('--')).join(' ').trim();
1303
+ if (other.length || available) {
1304
+ console.error('usage: atris engine roster --runs [job] [--json]');
1305
+ return 2;
1306
+ }
1307
+ return runRosterRunsCommand(job, json, root, now);
1308
+ }
1240
1309
  const rest = args.filter((arg) => arg !== '--json' && arg !== '--available');
1241
1310
  const known = !rest.length
1242
1311
  || (rest.length === 1 && ['confirm', 'session'].includes(rest[0]))
1243
1312
  || (rest.length === 2 && rest[0] === 'session' && rest[1] === 'clear');
1244
1313
  if (!known || (available && rest.length)) {
1245
- console.error('usage: atris engine roster [confirm | session [clear] | --available] [--json]');
1314
+ console.error('usage: atris engine roster [confirm | session [clear] | --available | --runs [job]] [--json]');
1246
1315
  return 2;
1247
1316
  }
1248
1317
  if (available) {
@@ -1252,13 +1321,13 @@ function runRosterCommand(args, root, now = new Date()) {
1252
1321
  }
1253
1322
  if (rest[0] === 'session') return runRosterSessionCommand(rest, json, root, now);
1254
1323
  if (rest[0] === 'confirm') confirmRoster(root, now);
1255
- const report = rosterReport(root, now);
1324
+ const report = attachRunRecords(rosterReport(root, now), root, now);
1256
1325
  if (json) console.log(JSON.stringify(report, null, 2));
1257
1326
  else console.log(`${renderRosterReport(report)}\n\n${AVAILABLE_HINT}`);
1258
1327
  return 0;
1259
1328
  }
1260
1329
 
1261
- const ASSIGN_USAGE = 'usage: atris engine assign <job> <tool> [--model <m>] [--effort low|medium|high|xhigh|max] [--max "20 min"] [--days <n>] [--add] [--backup "<tool> [model]"] [--like search|build|review] [--session | --everywhere], or atris engine assign <job> --remove <tool> | --clear. jobs live in atris/ROSTER.md (~/.atris/ROSTER.md with --everywhere, this shell only with --session) and you can edit that file any time';
1330
+ const ASSIGN_USAGE = 'usage: atris engine assign <job> <tool> [--model <m>] [--effort low|medium|high|xhigh|max] [--max "20 min"] [--prep <job>] [--days <n>] [--add] [--backup "<tool> [model]"] [--like search|build|review] [--session | --everywhere], atris engine assign <job> --promote "<tool> [model]" to move an existing worker to the top unchanged, or atris engine assign <job> --remove <tool> | --clear. jobs live in atris/ROSTER.md (~/.atris/ROSTER.md with --everywhere, this shell only with --session) and you can edit that file any time';
1262
1331
 
1263
1332
  function runAssignCommand(args, root, now = new Date()) {
1264
1333
  const job = args[0] && !String(args[0]).startsWith('--') ? args[0] : '';
@@ -1283,13 +1352,14 @@ function runAssignCommand(args, root, now = new Date()) {
1283
1352
  const flags = {};
1284
1353
  for (let i = 0; i < rest.length; i += 1) {
1285
1354
  const arg = rest[i];
1286
- if (!['--model', '--backup', '--days', '--like', '--effort', '--max', '--remove'].includes(arg) || !rest[i + 1] || rest[i + 1].startsWith('--')) {
1355
+ if (!['--model', '--backup', '--days', '--like', '--effort', '--max', '--prep', '--remove', '--promote'].includes(arg) || !rest[i + 1] || rest[i + 1].startsWith('--')) {
1287
1356
  console.error(`invalid assign option "${arg}"`);
1288
1357
  return 2;
1289
1358
  }
1290
1359
  flags[arg.slice(2)] = rest[++i];
1291
1360
  }
1292
1361
  const removing = flags.remove !== undefined;
1362
+ const promoting = flags.promote !== undefined;
1293
1363
  if (clear && (engine || Object.keys(flags).length || add)) {
1294
1364
  console.error('use --clear without an engine or other options');
1295
1365
  return 2;
@@ -1298,12 +1368,16 @@ function runAssignCommand(args, root, now = new Date()) {
1298
1368
  console.error('use --remove <tool> without other options');
1299
1369
  return 2;
1300
1370
  }
1301
- if (!clear && !removing && !engine) {
1371
+ if (promoting && (engine || Object.keys(flags).length > 1 || add)) {
1372
+ console.error('use --promote "<tool> [model]" without an engine or other options');
1373
+ return 2;
1374
+ }
1375
+ if (!clear && !removing && !promoting && !engine) {
1302
1376
  console.error(ASSIGN_USAGE);
1303
1377
  return 2;
1304
1378
  }
1305
1379
  try {
1306
- setRosterPick(job, engine, { ...flags, clear, add, session, everywhere, now }, root);
1380
+ setRosterPick(job, engine, { ...flags, clear, add, session, everywhere, now, note: (text) => console.log(text) }, root);
1307
1381
  printJobRoster(root, now);
1308
1382
  return 0;
1309
1383
  } catch (err) {
@@ -1942,8 +2016,8 @@ function runDispatchCommand(args, root) {
1942
2016
  : `engine dispatch: ${err.message}`);
1943
2017
  return 2;
1944
2018
  }
1945
- // The build line's model, effort, and time cap ride along when the roster
1946
- // picks this same engine for build.
2019
+ // The build line's model, effort, time cap, and prep job ride along when
2020
+ // the roster picks this same engine for build.
1947
2021
  const pin = rosterPinFor('executor', canonical, root);
1948
2022
  return runDispatchFlight({
1949
2023
  root,
@@ -1955,6 +2029,7 @@ function runDispatchCommand(args, root) {
1955
2029
  ...(pin.roster_model ? { model: pin.roster_model } : {}),
1956
2030
  ...(pin.roster_effort ? { effort: pin.roster_effort } : {}),
1957
2031
  ...(pin.roster_max_seconds ? { maxSeconds: pin.roster_max_seconds } : {}),
2032
+ ...(pin.roster_prep ? { prep: pin.roster_prep } : {}),
1958
2033
  }).then((flight) => {
1959
2034
  if (json) console.log(JSON.stringify(flight, null, 2));
1960
2035
  return flight.paused.length ? 1 : 0;
@@ -2085,7 +2160,7 @@ function engineCommand(args = [], deps = {}) {
2085
2160
  if (sub === 'help' || args.includes('--help') || args.includes('-h')) {
2086
2161
  console.log('\n atris engine watch [<id>|latest] [--no-follow]\n follow one live transcript or list running engine work');
2087
2162
  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');
2088
- 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 assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]\n atris engine assign <job> --remove <tool> | --clear [--session | --everywhere]\n sets the lead worker of the job; --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 --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');
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');
2089
2164
  return 0;
2090
2165
  }
2091
2166
  if (isFreshWorkspace(root)) {
package/commands/host.js CHANGED
@@ -1,14 +1,17 @@
1
1
  'use strict';
2
2
 
3
3
  const { hostAction } = require('../lib/host');
4
+ const { apiRequestJson } = require('../utils/api');
5
+ const { loadCredentials, decodeJwtClaims } = require('../utils/auth');
4
6
 
5
7
  function parse(argv) {
6
8
  const options = {};
7
9
  const positionals = [];
8
- const flags = new Set(['id', 'name', 'team', 'manager', 'door', 'now', 'started', 'question', 'event-id', 'from', 'text', 'reply-to', 'reply-to-ref', 'ref', 'decision', 'patch', 'expected-revision', 'source', 'evidence', 'reason', 'activity', 'as', 'when', 'at']);
10
+ const flags = new Set(['id', 'name', 'team', 'manager', 'door', 'now', 'started', 'question', 'event-id', 'from', 'text', 'reply-to', 'reply-to-ref', 'ref', 'decision', 'patch', 'expected-revision', 'source', 'evidence', 'reason', 'activity', 'as', 'when', 'at', 'room', 'slack-business', 'limit']);
9
11
  for (let i = 0; i < argv.length; i += 1) {
10
12
  const item = argv[i];
11
13
  if (item === '--json') { options.json = true; continue; }
14
+ if (item === '--dry-run') { options.dryRun = true; continue; }
12
15
  if (item.startsWith('--')) {
13
16
  const key = item.slice(2);
14
17
  if (!flags.has(key) || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error(`invalid --${key} value`);
@@ -18,8 +21,49 @@ function parse(argv) {
18
21
  return { options, positionals };
19
22
  }
20
23
 
24
+ async function hostDeliver(root, options = {}, deps = {}) {
25
+ const { room, slack_business_id: businessId, messages } = hostAction(root, 'delivery');
26
+ if (!businessId) throw new Error('slack business id is missing; run atris host setup --slack-business <business id>');
27
+ if (room === 'personal') throw new Error('host delivery is for group rooms only');
28
+ if (options.limit != null && !/^(0|[1-9]\d*)$/.test(options.limit)) throw new Error('limit must be a non-negative integer');
29
+ const eligible = messages.filter((message) => /^slack:[UW][A-Z0-9]+$/.test(message.door || '') && message.draft !== true);
30
+ const selected = eligible.slice(0, options.limit == null ? undefined : Number(options.limit));
31
+ const result = { sent: 0, skipped: messages.length - eligible.length, failed: 0 };
32
+ if (options.dryRun) {
33
+ result.would_send = selected.map((message) => ({ id: message.id, to: message.door.slice(6), kind: message.kind, text: String(message.text).replace(/\s+/g, ' ').slice(0, 80) }));
34
+ return result;
35
+ }
36
+ if (!selected.length) return result;
37
+ const credentials = (deps.loadCredentials || loadCredentials)();
38
+ if (!credentials?.token || credentials.source === 'agent_token_file' || decodeJwtClaims(credentials.token)?.type === 'agent_access') {
39
+ throw new Error('log in as an Atris user with atris login before delivering');
40
+ }
41
+ const request = deps.apiRequestJson || apiRequestJson;
42
+ for (const message of selected) {
43
+ try {
44
+ const response = await request(`/business/${encodeURIComponent(businessId)}/host/slack/send`, {
45
+ method: 'POST', token: credentials.token, retries: 0,
46
+ body: { slack_user_id: message.door.slice(6), text: message.text, host_message_id: message.id },
47
+ });
48
+ if (!response.ok) throw new Error(`${response.status ? `http ${response.status}` : 'network error'}: ${response.error || 'request failed'}`);
49
+ if (typeof response.data?.ts !== 'string' || !response.data.ts.trim()) throw new Error('response is missing a Slack timestamp');
50
+ hostAction(root, 'sent', { id: message.id, ref: response.data.ts });
51
+ result.sent += 1;
52
+ } catch (error) {
53
+ result.failed = 1;
54
+ result.failure = { id: message.id, reason: error.message };
55
+ break;
56
+ }
57
+ }
58
+ return result;
59
+ }
60
+
21
61
  function human(command, result) {
62
+ if (command === 'deliver') return result.would_send
63
+ ? [...result.would_send.map((message) => `${message.to} ${message.kind}: ${message.text}`), `dry run: ${result.would_send.length} would send, ${result.skipped} skipped`].join('\n')
64
+ : `host delivery: ${result.sent} sent, ${result.skipped} skipped, ${result.failure ? `failed ${result.failure.id}: ${result.failure.reason}` : '0 failed'}`;
22
65
  if (command === 'room') return result.text;
66
+ if (command === 'import') return `import complete: ${result.added} added, ${result.skipped} skipped, ${result.refused} refused, ${result.links_added} links added, ${result.links_skipped} links skipped`;
23
67
  if (command === 'due') return result.length ? result.map((entry) => `${entry.name} (${entry.id}) is due for a question.`).join('\n') : 'no questions due';
24
68
  if (command === 'outbox') return result.length ? result.map((entry) => `${entry.id} ${entry.kind} to ${entry.to}: ${entry.text}`).join('\n') : 'outbox empty';
25
69
  if (command === 'schedule') return result.length ? result.map((entry) => `${entry.attempt_id}: ${entry.names.a} (${entry.a}) and ${entry.names.b} (${entry.b}), ${entry.activity}`).join('\n') : 'no introductions need scheduling';
@@ -36,21 +80,29 @@ function human(command, result) {
36
80
  return `${command} complete: ${JSON.stringify(result)}`;
37
81
  }
38
82
 
39
- function hostCommand(argv = process.argv.slice(3), root = process.cwd()) {
83
+ function hostCommand(argv = process.argv.slice(3), root = process.cwd(), deps = {}) {
40
84
  let jsonOutput = argv.includes('--json');
41
85
  try {
42
86
  const [command, ...rest] = argv;
43
87
  if (!command || command === '--help' || command === '-h') {
44
- const usage = 'usage: atris host join|leave|pause|resume|forget|due|ask|receive|card|link|propose|people|outbox|sent|room|view|schedule|scheduled [options]';
88
+ const usage = 'usage: atris host setup|join|import|leave|pause|resume|forget|due|ask|receive|card|link|propose|people|outbox|deliver|sent|room|view|schedule|scheduled [options]';
45
89
  console.log(jsonOutput ? JSON.stringify({ usage }) : usage);
46
90
  return;
47
91
  }
48
92
  const { options, positionals } = parse(rest);
49
93
  jsonOutput = options.json;
50
94
  const args = { ...options };
95
+ if (command === 'import') args.file = positionals[0];
51
96
  if (['leave', 'pause', 'resume', 'forget', 'ask', 'card', 'sent', 'scheduled'].includes(command)) args.id = positionals[0];
52
97
  if (['link', 'propose'].includes(command)) { args.a = positionals[0]; args.b = positionals[1]; }
53
98
  if (command === 'receive') args.eventId = options.eventId;
99
+ if (command === 'deliver') return hostDeliver(root, args, deps).then((result) => {
100
+ console.log(jsonOutput ? JSON.stringify(result) : human(command, result));
101
+ if (result.failed) process.exitCode = 1;
102
+ }).catch((error) => {
103
+ console.error(jsonOutput ? JSON.stringify({ error: error.message }) : `host: ${error.message}`);
104
+ process.exitCode = 1;
105
+ });
54
106
  const result = hostAction(root, command, args);
55
107
  console.log(jsonOutput ? JSON.stringify(result) : human(command, result));
56
108
  } catch (error) {
@@ -59,4 +111,4 @@ function hostCommand(argv = process.argv.slice(3), root = process.cwd()) {
59
111
  }
60
112
  }
61
113
 
62
- module.exports = { hostCommand };
114
+ module.exports = { hostCommand, hostDeliver, parse };