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.
- package/README.md +37 -5
- package/atris/features/README.md +8 -0
- package/atris/skills/README.md +1 -0
- package/atris/skills/commerce/SKILL.md +96 -0
- package/atris/skills/engines/SKILL.md +9 -9
- package/bin/atris.js +12 -0
- package/commands/autoland.js +36 -5
- package/commands/autopilot.js +105 -6
- package/commands/buy.js +242 -0
- package/commands/engine.js +96 -21
- package/commands/host.js +56 -4
- package/commands/land.js +129 -8
- package/commands/member.js +68 -14
- package/commands/mission.js +116 -4
- package/commands/one-lap.js +14 -10
- package/commands/stream.js +56 -22
- package/commands/task.js +7 -0
- package/commands/team.js +82 -10
- package/commands/transactions.js +115 -0
- package/commands/worktree.js +39 -0
- package/commands/youtube.js +58 -26
- package/lib/commerce.js +75 -0
- package/lib/engine-ask.js +112 -9
- package/lib/engine-registry.js +300 -39
- package/lib/fleet.js +444 -26
- package/lib/functional-owner.js +15 -6
- package/lib/host.js +296 -170
- package/lib/known-commands.js +1 -1
- package/lib/member-engine.js +25 -1
- package/lib/member-park.js +138 -0
- package/lib/member-scaffold.js +39 -0
- package/lib/member-type.js +27 -0
- package/lib/roster-markdown.js +1 -1
- package/lib/roster-prep.js +289 -0
- package/lib/roster-runs.js +389 -0
- package/lib/roster-suggest.js +145 -0
- package/lib/router-brain.js +111 -3
- package/lib/runner-command.js +37 -2
- package/lib/self-drive.js +26 -2
- package/lib/state-detection.js +3 -3
- package/lib/task-db.js +38 -3
- package/lib/team-lineup.js +7 -2
- package/lib/todo.js +1 -1
- package/lib/wish-audit.js +1 -1
- package/package.json +1 -1
- package/scripts/det/cpu-hotspots.js +216 -0
- package/scripts/det/ytnotes +169 -23
- package/scripts/det/ytquote-repair.js +18 -4
- package/scripts/det/ytrail-bench.js +214 -0
- package/scripts/det/ytrail-eval.js +128 -56
- package/templates/members/host/MEMBER.md +5 -3
- package/templates/members/host/MISSION.md +1 -1
- package/templates/members/host/skills/host/SKILL.md +64 -16
package/commands/buy.js
ADDED
|
@@ -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 };
|
package/commands/engine.js
CHANGED
|
@@ -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
|
|
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
|
|
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}`
|
|
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
|
-
: '
|
|
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 === '
|
|
1152
|
-
:
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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 };
|