atris 3.64.0 → 3.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -295,10 +295,14 @@ An engine is the intelligence that builds a mission. Every installed headless co
295
295
 
296
296
  The first ready worker leads and the rest are backups. `~/.atris/ROSTER.md` covers every project on the machine; `atris/ROSTER.md` overrides it for one project. A worker that stalls sits out 30 minutes and the next one takes over.
297
297
 
298
+ The roster warns when a worker runs an old model and a newer one in the same family is on this machine, and prints the command that moves it. It also warns when a pinned model has no proof in 30 days. A landed run is proof; so is `verified 2026-09-30` on the line after a quick smoke test.
299
+
298
300
  ```bash
299
301
  atris engine roster # who does what, with each worker's recent record
300
302
  atris engine assign build claude --model "opus 5.5" # change a job's lead
301
303
  atris engine roster --available # tools and models on this machine
304
+ atris team # who really worked in the last 7 days, then quiet and idle
305
+ atris team --record # also save one row per member per day for the learning loop
302
306
  ```
303
307
 
304
308
  Show every installed engine and the current default:
package/bin/atris.js CHANGED
@@ -719,6 +719,9 @@ function showHelpAll() {
719
719
  console.log(' topup - Buy credits and print a Stripe checkout URL');
720
720
  console.log(' buy - Quote a purchase or stock order; the person approves before anything is charged');
721
721
  console.log(' transactions - List what you bought, paid in fees, traded, or sent');
722
+ console.log(' hire - Hire an agent in one step: name, job, inbox, allowance, nightly work loop');
723
+ console.log(' hires - Morning check-in: one line per hire (mail, spend, last run)');
724
+ console.log(' mail - Your agents\' own inboxes: list, read, send (not Gmail)');
722
725
  console.log(' design - Extract a site design system, check brand adherence, search brands');
723
726
  console.log(' mcp - Run the atris MCP server (stdio) for Claude Desktop and Cursor');
724
727
  console.log('');
@@ -2384,6 +2387,20 @@ if (command === 'guide') {
2384
2387
  Promise.resolve(require('../commands/buy').buyCommand(process.argv.slice(3)))
2385
2388
  .then((code) => process.exit(typeof code === 'number' ? code : 0))
2386
2389
  .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2390
+ } else if (command === 'hire') {
2391
+ // One-call hire: member + inbox + allowance + overnight loop (POST /business/{id}/hire).
2392
+ Promise.resolve(require('../commands/hire').hireCommand(process.argv.slice(3)))
2393
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2394
+ .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2395
+ } else if (command === 'hires') {
2396
+ Promise.resolve(require('../commands/hire').hiresCommand(process.argv.slice(3)))
2397
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2398
+ .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2399
+ } else if (command === 'mail') {
2400
+ // Agent inboxes (@atrismail.com), not Gmail. Send asks first unless --yes.
2401
+ Promise.resolve(require('../commands/mail').mailCommand(process.argv.slice(3)))
2402
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2403
+ .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2387
2404
  } else if (command === 'transactions') {
2388
2405
  Promise.resolve(require('../commands/transactions').transactionsCommand(process.argv.slice(3)))
2389
2406
  .then((code) => process.exit(typeof code === 'number' ? code : 0))
@@ -465,6 +465,7 @@ function agentSpawnStatusCommand(args = [], deps = {}) {
465
465
  }
466
466
 
467
467
  module.exports = {
468
+ ALLOWED_ENGINES,
468
469
  parseSpawnArgs,
469
470
  commandForEngine,
470
471
  createSpawnRequest,
@@ -35,9 +35,12 @@ const {
35
35
  const { parseScopeFlag } = require('../lib/cli-scope');
36
36
  const { isFreshWorkspace, speakFirstMinute } = require('../lib/first-minute');
37
37
  const { teamRosterView } = require('../lib/member-engine');
38
- const { engineRunsView, availableModels } = require('../lib/roster-models');
38
+ const { engineModelText, engineRunsView, availableModels, modelLabel } = require('../lib/roster-models');
39
+ const { renderTable } = require('../lib/text-table');
40
+ const { TEAM_FOOTER } = require('../lib/team-lineup');
39
41
  const { readRosterRuns, workerRuns, summarizeRuns, recentRuns, renderRunLine, DEFAULT_DAYS: RUN_DAYS } = require('../lib/roster-runs');
40
42
  const { attachSuggestions } = require('../lib/roster-suggest');
43
+ const { PIN_MAX_AGE_DAYS: PIN_STALE_DAYS, attachStaleModels, modelCatalog } = require('../lib/roster-stale');
41
44
  const {
42
45
  ENGINE_ROLES,
43
46
  ENGINE_JOBS,
@@ -1051,6 +1054,7 @@ function workerRow(step, index, leadIndex, now = new Date()) {
1051
1054
  until: worker.until || null,
1052
1055
  ends_in_days: endsIn,
1053
1056
  expiring_soon: endsSoon(endsIn),
1057
+ verified: worker.verified || null,
1054
1058
  line: worker.line || null,
1055
1059
  ...(worker.error ? { text: worker.text || '' } : {}),
1056
1060
  status,
@@ -1214,74 +1218,157 @@ function bootExpiringLine(expiring) {
1214
1218
  return `${first.tool} on ${first.job} ${endsInText(first.ends_in_days)}${more}. renew: ${RENEW_COMMAND}`;
1215
1219
  }
1216
1220
 
1217
- // Every worker under its job, numbered in order, when the job has more than
1218
- // the lead and one backup (the job's own line already shows those two).
1219
- function renderWorkerLines(row) {
1220
- if (!row.workers || row.workers.length <= 2) return [];
1221
- const width = Math.max(...row.workers.map((worker) => (worker.runs ? worker.runs.text : worker.text || '').length));
1222
- return row.workers.map((worker, index) => {
1223
- const what = (worker.runs ? worker.runs.text : worker.text || '').padEnd(width);
1224
- const ended = worker.status === 'skipped' && worker.why === 'expired' && worker.ends_in_days !== null;
1225
- const until = !worker.until || ended ? '' : `until ${worker.until}${worker.expiring_soon ? `, ${endsInText(worker.ends_in_days)}` : ''}`;
1226
- const extras = [worker.max_seconds ? rosterMaxText(worker.max_seconds) : '', worker.prep ? `prepped by ${worker.prep}` : '', until].filter(Boolean);
1227
- const state = worker.status === 'leads' ? 'leads now' : worker.status === 'backup' ? 'backup'
1228
- : ended ? `ended ${shortDay(worker.until)}, skipped` : `skipped, ${worker.why}`;
1229
- return ` ${index + 1}. ${what} ${[state, ...extras].join(', ')}`.trimEnd();
1230
- });
1221
+ // --- the roster as tables -------------------------------------------------
1222
+ //
1223
+ // One row per job, one row per member. Plain columns, a header row, two
1224
+ // spaces between; a cell that needs more than one line (effort, several
1225
+ // backups) carries on underneath in its own column.
1226
+
1227
+ function workerCellText(view) {
1228
+ return view ? engineModelText(view.engine, view.model) : '';
1231
1229
  }
1232
1230
 
1233
- // Each worker's recent record under its job, only for workers with runs.
1234
- function renderWorkerRecords(row) {
1235
- return (row.workers || [])
1236
- .filter((worker) => worker.record)
1237
- .map((worker) => ` ${worker.runs ? worker.runs.text : worker.engine}: ${worker.record.text}`);
1238
- }
1239
-
1240
- function renderJobRoster(rows) {
1241
- const width = Math.max(7, ...rows.map((row) => row.job.length));
1242
- const ownerWidth = Math.max(24, ...rows.filter((row) => row.pick && row.runs).map((row) => row.runs.text.length));
1243
- const backupWidth = Math.max(16, ...rows.filter((row) => row.backup_runs).map((row) => row.backup_runs.text.length + 7));
1244
- return rows.map((row) => {
1245
- const label = row.job.padEnd(width);
1246
- const nowText = row.now_runs ? row.now_runs.text : 'no ready engine';
1247
- const fallsTo = `${row.like ? `falls back to ${row.like}` : 'router decides'}: ${nowText}`;
1248
- if (!row.pick) return `${label} no pick, ${fallsTo}`;
1249
- const owner = row.runs.text.padEnd(ownerWidth);
1250
- const backup = row.backup_runs ? `backup ${row.backup_runs.text}${row.backup_prep ? ` prepped by ${row.backup_prep}` : ''}` : 'no backup';
1251
- const cap = `${row.max_seconds ? `${rosterMaxText(row.max_seconds)}, ` : ''}${row.prep ? `prepped by ${row.prep}, ` : ''}`;
1252
- const date = untilText(row.pick, row.ends_in_days);
1253
- const fallback = row.lead === 'backup' ? 'using backup' : row.lead === 'later' ? `using ${nowText}` : fallsTo;
1254
- const ended = row.ends_in_days !== null && row.ends_in_days !== undefined ? `ended ${shortDay(row.pick.until)}, skipped` : 'expired';
1255
- const status = row.status === 'expired' ? `${ended}, ${fallback}`
1256
- : row.status === 'cooling' ? `${row.cooling}, ${fallback}`
1257
- : row.status === 'not ready' ? `not ready, ${fallback}`
1258
- : date;
1259
- const where = row.file ? `${row.from} (${row.file})` : row.from;
1260
- const head = `${label} ${owner} ${backup.padEnd(backupWidth)} ${cap}${status}, ${where}`.trimEnd();
1261
- const suggestion = row.suggestion ? [` suggestion: ${row.suggestion.text}`] : [];
1262
- return [head, ...renderWorkerLines(row), ...renderWorkerRecords(row), ...suggestion].join('\n');
1263
- }).join('\n');
1231
+ function maxCellText(seconds) {
1232
+ const text = rosterMaxText(seconds);
1233
+ return text ? text.replace(/^max /, '') : '-';
1234
+ }
1235
+
1236
+ // Workers that name a tool the roster can run, in written order.
1237
+ function goodWorkers(row) {
1238
+ return (row.workers || []).filter((worker) => worker.engine && worker.runs);
1239
+ }
1240
+
1241
+ // Why a backup is passed over, in two or three words.
1242
+ function backupSkipText(worker) {
1243
+ if (worker.status !== 'skipped') return '';
1244
+ if (worker.why === 'expired' && worker.ends_in_days !== null && worker.ends_in_days !== undefined) return `ended ${shortDay(worker.until)}`;
1245
+ return worker.why || 'skipped';
1246
+ }
1247
+
1248
+ // "newer <model> out" or "pin 45 days old": the outdated-model flags,
1249
+ // short enough for the note column. The full line with its fix prints
1250
+ // under the table.
1251
+ function staleNote(worker) {
1252
+ if (worker.stale_model && worker.newer_model) return `newer ${modelLabel(worker.newer_model)} out`;
1253
+ if (worker.pin_age_days !== null && worker.pin_age_days !== undefined && worker.pin_age_days > PIN_STALE_DAYS) return `pin ${worker.pin_age_days} days old`;
1254
+ return '';
1255
+ }
1256
+
1257
+ function jobNoteText(row, lead, backups, mainFrom) {
1258
+ // A tool on its own default model is named alone here, to keep notes short.
1259
+ const nowText = !row.now_runs ? 'no ready engine' : row.now_runs.model ? workerCellText(row.now_runs) : row.now_runs.engine;
1260
+ if (!row.pick) return row.like ? `no pick, same as ${row.like}` : 'no pick, router decides';
1261
+ const fallback = row.lead === 'backup' ? 'using backup'
1262
+ : row.lead === 'later' ? `using ${nowText}`
1263
+ : row.like ? `falls back to ${row.like}` : `router decides: ${nowText}`;
1264
+ const ended = row.ends_in_days !== null && row.ends_in_days !== undefined ? `ended ${shortDay(row.pick.until)}` : 'expired';
1265
+ const parts = [];
1266
+ if (row.status === 'expired') parts.push(`${ended}, ${fallback}`);
1267
+ else if (row.status === 'cooling') parts.push(`${row.cooling}, ${fallback}`);
1268
+ else if (row.status === 'not ready') parts.push(`not ready, ${fallback}`);
1269
+ else if (row.expiring_soon) parts.push(endsInText(row.ends_in_days));
1270
+ if (lead && lead.prep) parts.push(`prep: ${lead.prep}`);
1271
+ if (lead && staleNote(lead)) parts.push(staleNote(lead));
1272
+ for (const worker of backups) if (staleNote(worker)) parts.push(`backup ${staleNote(worker)}`);
1273
+ if (row.from && row.from !== mainFrom) parts.push(`from ${row.from}`);
1274
+ return parts.join(', ');
1275
+ }
1276
+
1277
+ // The roster file most jobs come from; the table heading names it.
1278
+ function mainRosterSource(rows) {
1279
+ const counts = new Map();
1280
+ for (const row of rows) if (row.pick && row.from) counts.set(row.from, (counts.get(row.from) || 0) + 1);
1281
+ const [from] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0] || [];
1282
+ const row = rows.find((item) => item.from === from);
1283
+ return { from: from || null, file: row ? row.file : null };
1284
+ }
1285
+
1286
+ function jobTableRow(row, mainFrom) {
1287
+ const [lead, ...backups] = row.pick ? goodWorkers(row) : [];
1288
+ const leadView = row.pick ? (lead && lead.runs) : row.now_runs;
1289
+ const leads = leadView
1290
+ ? [workerCellText(leadView), leadView.effort ? `(${leadView.effort} effort)` : null]
1291
+ : [row.pick ? 'no ready tool' : 'no ready engine'];
1292
+ const backupCell = backups.length
1293
+ ? backups.map((worker) => {
1294
+ // Effort, max time, and prep for a backup stay in --json; the cell keeps
1295
+ // what changes who runs: a skip reason or an end date.
1296
+ const skip = backupSkipText(worker);
1297
+ const until = !skip && parseRosterUntil(worker.until) ? `until ${shortDay(worker.until)}${worker.expiring_soon ? `, ${endsInText(worker.ends_in_days)}` : ''}` : '';
1298
+ const extras = [skip, until].filter(Boolean);
1299
+ return `${workerCellText(worker.runs)}${extras.length ? ` (${extras.join(', ')})` : ''}`;
1300
+ })
1301
+ : ['none'];
1302
+ const until = lead && parseRosterUntil(lead.until) ? lead.until : '-';
1303
+ return [
1304
+ row.job,
1305
+ leads,
1306
+ backupCell,
1307
+ lead ? maxCellText(lead.max_seconds) : '-',
1308
+ until,
1309
+ jobNoteText(row, lead, backups, mainFrom) || '-',
1310
+ ];
1264
1311
  }
1265
1312
 
1266
- function renderTeamRoster(rows) {
1313
+ function renderJobRoster(rows, { width, heading = true } = {}) {
1267
1314
  if (!rows.length) return '';
1268
- const width = Math.max(6, ...rows.map((row) => row.member.length));
1269
- const jobWidth = Math.max(6, ...rows.map((row) => String(row.job || '').length));
1270
- const lines = rows.map((row) => {
1271
- const engine = row.engine ? engineRunsView(row.engine, { model: row.model || '', effort: row.effort || '' }).text : 'no ready engine';
1272
- const how = row.source === 'file' ? `from ${row.file}` : 'automatic';
1273
- return `${row.member.padEnd(width)} ${String(row.job || '').padEnd(jobWidth)} ${engine.padEnd(24)} ${how}`.trimEnd();
1315
+ const { from, file } = mainRosterSource(rows);
1316
+ const columns = [
1317
+ { header: 'JOB' }, { header: 'LEADS' }, { header: 'BACKUP' }, { header: 'MAX' }, { header: 'UNTIL' },
1318
+ { header: 'NOTE', wrap: true, min: 16 },
1319
+ ];
1320
+ const table = renderTable(columns, rows.map((row) => jobTableRow(row, from)), { width });
1321
+ const title = !heading ? '' : file ? `jobs, from ${file}` : 'jobs';
1322
+ const suggestions = rows.filter((row) => row.suggestion).map((row) => `suggestion for ${row.job}: ${row.suggestion.text}`);
1323
+ return [title, table, ...renderWorkerRecords(rows, { width }), ...suggestions].filter(Boolean).join('\n');
1324
+ }
1325
+
1326
+ // Each worker's recent record (last 7 days), only for workers with runs, as
1327
+ // one small table under the jobs.
1328
+ function renderWorkerRecords(rows, { width } = {}) {
1329
+ const records = [];
1330
+ for (const row of rows) {
1331
+ for (const worker of row.workers || []) {
1332
+ if (worker.record) records.push([row.job, worker.runs ? workerCellText(worker.runs) : worker.engine, worker.record.text.replace(/^last \d+ days: /, '')]);
1333
+ }
1334
+ }
1335
+ if (!records.length) return [];
1336
+ return ['\nlast 7 days', renderTable([{ header: 'JOB' }, { header: 'WORKER' }, { header: 'RECORD', clip: true, min: 20 }], records, { width })];
1337
+ }
1338
+
1339
+ // "sep 30, 3 runs" or "quiet 8 to 14 days", from the same evidence atris
1340
+ // team reads. '-' for idle members and when activity was not read.
1341
+ function teamActivityTag(activity) {
1342
+ if (!activity || activity.status === 'idle') return '-';
1343
+ if (activity.status === 'quiet') return 'quiet 8 to 14 days';
1344
+ const runs = Number(activity.runs_7d) || 0;
1345
+ const day = activity.days_since === 0 ? 'today' : activity.days_since === 1 ? 'yesterday' : shortDay(activity.last_active);
1346
+ return `${day}, ${runs} run${runs === 1 ? '' : 's'}`;
1347
+ }
1348
+
1349
+ function renderTeamRoster(rows, { width } = {}) {
1350
+ if (!rows.length) return '';
1351
+ const showActivity = rows.some((row) => row.activity !== undefined);
1352
+ const columns = [{ header: 'MEMBER' }, { header: 'JOB' }, { header: 'ENGINE · MODEL' }, { header: 'SOURCE' }];
1353
+ if (showActivity) columns.push({ header: 'LAST 7 DAYS', clip: true, min: 11 });
1354
+ const cells = rows.map((row) => {
1355
+ const engine = row.engine ? workerCellText(engineRunsView(row.engine, { model: row.model || '', effort: row.effort || '' })) : 'no ready engine';
1356
+ const stale = row.stale_model && row.newer_model ? ` (newer ${modelLabel(row.newer_model)} out)` : '';
1357
+ const cell = [row.member, row.job || '-', `${engine}${stale}`, row.source === 'file' ? (row.file || 'roster file') : 'automatic'];
1358
+ if (showActivity) cell.push(teamActivityTag(row.activity));
1359
+ return cell;
1274
1360
  });
1275
- return ['team', ...lines].join('\n');
1361
+ return ['team', renderTable(columns, cells, { width })].join('\n');
1276
1362
  }
1277
1363
 
1278
1364
  function renderRosterWarnings(warnings) {
1279
1365
  return warnings.map((warning) => `warning: ${warning.file} line ${warning.line} "${warning.text}" ${warning.message}.`).join('\n');
1280
1366
  }
1281
1367
 
1282
- function renderRosterReport(report) {
1283
- const jobs = [renderJobRoster(report.jobs), renderExpiringLine(report.expiring)].filter(Boolean).join('\n');
1284
- return [jobs, renderTeamRoster(report.team), renderRosterWarnings(report.warnings)]
1368
+ function renderRosterReport(report, { width } = {}) {
1369
+ const stale = (report.stale || []).map((item) => item.text);
1370
+ const jobs = [renderJobRoster(report.jobs, { width }), renderExpiringLine(report.expiring), ...stale].filter(Boolean).join('\n');
1371
+ return [jobs, renderTeamRoster(report.team, { width }), renderRosterWarnings(report.warnings)]
1285
1372
  .filter(Boolean)
1286
1373
  .join('\n\n');
1287
1374
  }
@@ -1290,8 +1377,6 @@ function printJobRoster(root, now = new Date()) {
1290
1377
  console.log(renderRosterReport(rosterReport(root, now)));
1291
1378
  }
1292
1379
 
1293
- const AVAILABLE_HINT = 'see which tools and models this machine has: atris engine roster --available';
1294
-
1295
1380
  // Each installed tool and the models it offers, from local files only.
1296
1381
  function availableReport(root) {
1297
1382
  const engines = readEngineRegistry(root, { persist: false }).engines
@@ -1312,7 +1397,7 @@ function renderSessionRoster(report, key) {
1312
1397
  const team = report.team.filter((row) => row.source === 'file' && row.file === report.files.session);
1313
1398
  if (!jobs.length && !team.length) return `this session (${key}) has no roster changes. add one with: atris engine assign <job> <tool> --session`;
1314
1399
  const head = `this session (${key}) changes${report.files.session ? `, from ${report.files.session}` : ''}`;
1315
- return [head, renderJobRoster(jobs), renderTeamRoster(team)].filter(Boolean).join('\n\n');
1400
+ return [head, renderJobRoster(jobs, { heading: false }), renderTeamRoster(team)].filter(Boolean).join('\n\n');
1316
1401
  }
1317
1402
 
1318
1403
  function runRosterSessionCommand(rest, json, root, now) {
@@ -1361,6 +1446,38 @@ function attachRunRecords(report, root, now = new Date()) {
1361
1446
  return report;
1362
1447
  }
1363
1448
 
1449
+ // Pins are proven by landed runs; a pin older than 30 days is flagged, so
1450
+ // the look-back reaches well past that.
1451
+ const STALE_PROOF_DAYS = 90;
1452
+
1453
+ // Each team row gets the member's activity (last 7 days, quiet, or idle)
1454
+ // from the same evidence atris team reads: dated logs, commits that name
1455
+ // the member, roster runs, and stream events, read once for the team.
1456
+ function attachTeamActivity(report, root, now = new Date()) {
1457
+ const rows = report.team || [];
1458
+ if (!rows.length) return report;
1459
+ try {
1460
+ const { collectMemberActivity } = require('./team');
1461
+ const activity = collectMemberActivity(root, rows.map((row) => ({ name: row.member })), { nowMs: new Date(now).getTime() });
1462
+ const byName = new Map(activity.map(({ days, ...rest }) => [rest.name, rest]));
1463
+ for (const row of rows) row.activity = byName.get(String(row.member).toLowerCase()) || null;
1464
+ } catch {
1465
+ // Activity is a view; a read that fails leaves the roster as it was.
1466
+ }
1467
+ return report;
1468
+ }
1469
+
1470
+ // Mark workers that run an outdated model or an unproven old pin. Local
1471
+ // files only: the registry, codex's models cache, and the run record.
1472
+ function attachStaleCheck(report, root, now = new Date()) {
1473
+ const engines = readEngineRegistry(root, { persist: false }).engines;
1474
+ return attachStaleModels(report, {
1475
+ catalog: modelCatalog(engines),
1476
+ runs: readRosterRuns(root, { now, days: STALE_PROOF_DAYS }),
1477
+ now,
1478
+ });
1479
+ }
1480
+
1364
1481
  const RUNS_LIST_DAYS = 30;
1365
1482
  const RUNS_LIST_LIMIT = 20;
1366
1483
 
@@ -1407,9 +1524,9 @@ function runRosterCommand(args, root, now = new Date()) {
1407
1524
  }
1408
1525
  if (rest[0] === 'session') return runRosterSessionCommand(rest, json, root, now);
1409
1526
  if (rest[0] === 'confirm') confirmRoster(root, now);
1410
- const report = attachRunRecords(rosterReport(root, now), root, now);
1527
+ const report = attachTeamActivity(attachStaleCheck(attachRunRecords(rosterReport(root, now), root, now), root, now), root, now);
1411
1528
  if (json) console.log(JSON.stringify(report, null, 2));
1412
- else console.log(`${renderRosterReport(report)}\n\n${AVAILABLE_HINT}`);
1529
+ else console.log(`${renderRosterReport(report)}\n\n${TEAM_FOOTER.join('\n')}`);
1413
1530
  return 0;
1414
1531
  }
1415
1532
 
@@ -2267,7 +2384,7 @@ function engineCommand(args = [], deps = {}) {
2267
2384
  if (sub === 'help' || args.includes('--help') || args.includes('-h')) {
2268
2385
  console.log('\n atris engine watch [<id>|latest] [--no-follow]\n follow one live transcript or list running engine work');
2269
2386
  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');
2270
- console.log('\n atris engine roster + current default\n atris engine roster [--json] show every job and its workers in order: search, build, review, and your own\n atris engine roster confirm renew every dated worker for 30 days\n atris engine roster session [clear]\n show or drop the roster changes for this shell only\n atris engine roster --available\n tools on this machine and the models each one offers\n atris engine roster --runs [job]\n the last 20 runs, newest first, with tool, model, time, and outcome\n atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--prep <job>] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]\n atris engine assign <job> --promote "<tool> [model]" | --remove <tool> | --clear [--session | --everywhere]\n sets the lead worker of the job; --promote moves one of its workers to the top as written;\n --add puts one more at the end; jobs: search, build, review,\n or your own like "small build" (--like search|build|review when the name does not say);\n --prep search has that job read the task first and hand this worker a short brief;\n --everywhere is for all projects; --session is this shell only (set ATRIS_ROSTER_SESSION=<name>)\n atris engines --chart show the fleet as an org chart\n atris engine list --json [--all] full registry: default + engines with tier, roles, fallback, health (--all includes hidden engines)\n atris engine set <name> --duty leader|errands|learning [--models "a, b"]\n arrange the fleet and save its model policy\n atris engine resolve <role> or <job> [--json]\n choose the best ready engine for navigator|executor|validator or a roster job;\n notes always answers: its roster pick, else agy then haiku\n atris engine health <name> --set ready|not_installed|credit_out\n flip runtime health, for example when credits run out\n atris engine doctor [--json]\n probe which engine CLIs are installed here and sync that into health policy\n atris engine <name> make that engine the default here\n atris engine test [name] preflight: run the engine CLI headless, report pass/fail\n atris engine bench [names...] [--runs N]\n ranked latency scoreboard of engine passes\n atris engine ask "<question>" --engine <name> [--engine <name> ...]\n ask several engines in parallel without allowing edits\n atris engine ask --jobs <jobs.json>\n ask different read-only questions in parallel\n atris engine validate <receipt-path|latest> [--engine <name>]\n check ask answers with a different read-only referee\n atris engine validate scoreboard\n show pass rates by worker engine\n atris engine dispatch <task-id> [<task-id> ...] --engine cursor|codex [--prompt-file <f>] [--yolo]\n one-command claim, worktree, build, verify, ship, ready\n atris engine login <provider> --yes\n upload a local provider CLI login to the backend vault\n atris engine login <provider> --computer [--seat <name>]\n atris engine login <provider> --business <id> [--seat <name>]\n sign in on an Atris computer by device flow\n atris engine login --list | --remove <provider>\n list or remove vaulted provider logins\n atris engine seats show which named accounts are ready to work\n atris engine seed <provider> --business <id>|--user\n push a vaulted login onto an Atris computer\n atris engine reset back to the house default\n --engine <name> one run on that engine (mission run / autopilot / run)\n');
2387
+ 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 flags a model with a newer version on this machine, or a pin not proven in 30 days\n (a landed run, or "verified 2026-09-30" on its line, proves it)\n atris engine roster confirm renew every dated worker for 30 days\n atris engine roster session [clear]\n show or drop the roster changes for this shell only\n atris engine roster --available\n tools on this machine and the models each one offers\n atris engine roster --runs [job]\n the last 20 runs, newest first, with tool, model, time, and outcome\n atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--prep <job>] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]\n atris engine assign <job> --promote "<tool> [model]" | --remove <tool> | --clear [--session | --everywhere]\n sets the lead worker of the job; --promote moves one of its workers to the top as written;\n --add puts one more at the end; jobs: search, build, review,\n or your own like "small build" (--like search|build|review when the name does not say);\n --prep search has that job read the task first and hand this worker a short brief;\n --everywhere is for all projects; --session is this shell only (set ATRIS_ROSTER_SESSION=<name>)\n atris engines --chart show the fleet as an org chart\n atris engine list --json [--all] full registry: default + engines with tier, roles, fallback, health (--all includes hidden engines)\n atris engine set <name> --duty leader|errands|learning [--models "a, b"]\n arrange the fleet and save its model policy\n atris engine resolve <role> or <job> [--json]\n choose the best ready engine for navigator|executor|validator or a roster job;\n notes always answers: its roster pick, else agy then haiku\n atris engine health <name> --set ready|not_installed|credit_out\n flip runtime health, for example when credits run out\n atris engine doctor [--json]\n probe which engine CLIs are installed here and sync that into health policy\n atris engine <name> make that engine the default here\n atris engine test [name] preflight: run the engine CLI headless, report pass/fail\n atris engine bench [names...] [--runs N]\n ranked latency scoreboard of engine passes\n atris engine ask "<question>" --engine <name> [--engine <name> ...]\n ask several engines in parallel without allowing edits\n atris engine ask --jobs <jobs.json>\n ask different read-only questions in parallel\n atris engine validate <receipt-path|latest> [--engine <name>]\n check ask answers with a different read-only referee\n atris engine validate scoreboard\n show pass rates by worker engine\n atris engine dispatch <task-id> [<task-id> ...] --engine cursor|codex [--prompt-file <f>] [--yolo]\n one-command claim, worktree, build, verify, ship, ready\n atris engine login <provider> --yes\n upload a local provider CLI login to the backend vault\n atris engine login <provider> --computer [--seat <name>]\n atris engine login <provider> --business <id> [--seat <name>]\n sign in on an Atris computer by device flow\n atris engine login --list | --remove <provider>\n list or remove vaulted provider logins\n atris engine seats show which named accounts are ready to work\n atris engine seed <provider> --business <id>|--user\n push a vaulted login onto an Atris computer\n atris engine reset back to the house default\n --engine <name> one run on that engine (mission run / autopilot / run)\n');
2271
2388
  return 0;
2272
2389
  }
2273
2390
  if (isFreshWorkspace(root)) {
@@ -2316,6 +2433,7 @@ module.exports = {
2316
2433
  readSavedEngine,
2317
2434
  roster,
2318
2435
  rosterReport,
2436
+ renderRosterReport,
2319
2437
  jobRosterView,
2320
2438
  expiringWorkers,
2321
2439
  bootExpiringLine,
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+
3
+ // atris hire "<name>" --job "<idea>" one call: member, inbox, allowance, nightly loop
4
+ // atris hires the morning check-in: one line per hire, then its 24h / 7d scoreboard
5
+ // Backend: POST /api/business/{id}/hire, GET /api/business/{id}/hires
6
+
7
+ const { apiRequestJson } = require('../utils/api');
8
+ const { NOT_LOGGED_IN } = require('../lib/developer-api');
9
+ const {
10
+ parseArgs, businessOrSay, loginToken, errorText, relativeTime, dollars, clip,
11
+ } = require('../lib/business-target');
12
+
13
+ const HIRE_FLAGS = { switches: ['outreach'], values: ['job', 'budget', 'schedule', 'timezone'] };
14
+
15
+ function hireHelp(log = console.log) {
16
+ log(`usage: atris hire "<name>" --job "<idea>" [--budget 50] [--outreach] [--schedule nightly] [--business <slug>] [--json]
17
+
18
+ hire an agent in one step. it gets a name, a job, its own email inbox,
19
+ a monthly spending allowance, and a work loop that runs overnight.
20
+
21
+ --job the idea it works on, in plain words (required)
22
+ --budget most it may ask you to approve per month, in dollars (default 0 = no spending)
23
+ you still approve every purchase yourself
24
+ --outreach mark it as doing outreach (recorded; it uses a normal inbox for now)
25
+ --schedule nightly (default, 2 AM your time), hourly, or a cron like "0 3 * * *"
26
+ --timezone defaults to this computer's timezone
27
+ --business which business; defaults to the business folder you are in
28
+
29
+ running it again with the same name is safe: you get the same hire back,
30
+ and anything missing (like the inbox) is filled in. --budget changes the allowance.
31
+
32
+ then each morning: atris hires`);
33
+ }
34
+
35
+ function hiresHelp(log = console.log) {
36
+ log(`usage: atris hires [--business <slug>] [--json]
37
+
38
+ the morning check-in: one line per hire with its email in the last day,
39
+ what it spent this month, and what its last overnight run did. under it,
40
+ its scoreboard for the last 24 hours and 7 days: emails sent, how many got
41
+ a reply from a person, bounces, and spend.`);
42
+ }
43
+
44
+ function localTimezone() {
45
+ try {
46
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
47
+ } catch {
48
+ return '';
49
+ }
50
+ }
51
+
52
+ function renderHire(data = {}, now = Date.now()) {
53
+ const m = data.member || {};
54
+ const lines = [data.created ? `Hired ${m.name}.` : `${m.name} was already hired. Same hire, nothing duplicated.`];
55
+ if (m.job) lines.push(`Job: ${clip(m.job, 200)}`);
56
+ if (data.inbox && data.inbox.address) lines.push(`Inbox: ${data.inbox.address}`);
57
+ const a = data.allowance || {};
58
+ lines.push(Number(a.monthly_usd) > 0
59
+ ? `Allowance: ${dollars(a.monthly_usd)} a month. You still approve every purchase.`
60
+ : 'Allowance: none. It cannot ask to spend money.');
61
+ if (a.note) lines.push(` ${a.note}`);
62
+ const s = data.schedule || {};
63
+ let when = s.label ? `Works ${s.label}.` : 'Works on its schedule.';
64
+ if (s.enabled === false) when += ' Asleep right now.';
65
+ else if (data.next_run_at) when += ` First run ${relativeTime(data.next_run_at, now)}.`;
66
+ lines.push(when);
67
+ if (data.outreach && data.outreach.requested) lines.push(`Outreach: ${data.outreach.note || 'recorded.'}`);
68
+ lines.push('', 'Check in each morning: atris hires');
69
+ return lines.join('\n');
70
+ }
71
+
72
+ function hireLine(h = {}, now = Date.now()) {
73
+ const parts = [];
74
+ if (h.status && h.status !== 'active') {
75
+ const why = { needs_inbox: 'paused, no inbox yet', needs_allowance: 'paused, allowance not set up', setting_up: 'still setting up',
76
+ pending_schedule: 'set up, nightly work not switched on yet (run the same hire again)' };
77
+ parts.push(why[h.status] || `paused (${h.status})`);
78
+ }
79
+ const mail = h.emails_last_24h;
80
+ if (mail) parts.push(`sent ${mail.sent}, got ${mail.received} emails today`);
81
+ else parts.push('no inbox');
82
+ const a = h.allowance || {};
83
+ if (Number(a.monthly_usd) > 0) {
84
+ parts.push(a.spent_this_month_usd == null
85
+ ? `${dollars(a.monthly_usd)} a month`
86
+ : `spent ${dollars(a.spent_this_month_usd)} of ${dollars(a.monthly_usd)}`);
87
+ }
88
+ if (h.last_run_at) {
89
+ const result = h.last_result ? `: ${clip(h.last_result, 80)}` : '';
90
+ parts.push(`last ran ${relativeTime(h.last_run_at, now)}${result}`);
91
+ } else if (h.schedule && h.schedule.enabled === false) {
92
+ parts.push('asleep, has not run');
93
+ } else if (h.next_run_at) {
94
+ parts.push(`first run ${relativeTime(h.next_run_at, now)}`);
95
+ } else {
96
+ parts.push('has not run yet');
97
+ }
98
+ return `${h.name}: ${parts.join(' · ')}`;
99
+ }
100
+
101
+ function windowText(label, w) {
102
+ if (!w || w.sent == null) return '';
103
+ const replies = Number(w.replies) || 0;
104
+ const value = w.reply_rate == null || w.reply_rate === '' ? NaN : Number(w.reply_rate);
105
+ const rate = Number.isFinite(value) ? ` (${Math.round(value * 100)}%)` : '';
106
+ const parts = [`sent ${w.sent}`, `${replies} ${replies === 1 ? 'reply' : 'replies'}${rate}`,
107
+ `${Number(w.bounces) || 0} bounced`];
108
+ if (w.spend_cents != null) parts.push(`spent ${dollars(Number(w.spend_cents) / 100)}`);
109
+ return `${label}: ${parts.join(', ')}`;
110
+ }
111
+
112
+ // The second line under a hire: how its mail is landing. Empty for an older backend.
113
+ function scoreLine(board, now = Date.now()) {
114
+ if (!board) return '';
115
+ const parts = [windowText('last 24h', board.last_24h), windowText('last 7 days', board.last_7d)].filter(Boolean);
116
+ if (!parts.length) return '';
117
+ if (board.last_action_at) parts.push(`last action ${relativeTime(board.last_action_at, now)}`);
118
+ if (board.capped) parts.push('counts cover only the newest mail');
119
+ return ` ${parts.join(' · ')}`;
120
+ }
121
+
122
+ function renderHires(data = {}, now = Date.now()) {
123
+ const rows = Array.isArray(data.hires) ? data.hires : [];
124
+ if (!rows.length) return 'No hires yet. Start one: atris hire "Maya" --job "grow the newsletter"';
125
+ return rows.map((h) => [hireLine(h, now), scoreLine(h.scoreboard, now)].filter(Boolean).join('\n')).join('\n');
126
+ }
127
+
128
+ function io(deps) {
129
+ return { log: deps.log || console.log, err: deps.err || console.error };
130
+ }
131
+
132
+ async function hireCommand(args = [], deps = {}) {
133
+ const out = io(deps);
134
+ const { flags, pos, error } = parseArgs(args, HIRE_FLAGS);
135
+ if (flags.help || pos[0] === 'help') { hireHelp(out.log); return 0; }
136
+ if (error) { out.err(`${error}. see: atris hire --help`); return 1; }
137
+
138
+ const name = pos.join(' ').trim();
139
+ const job = typeof flags.job === 'string' ? flags.job.trim() : '';
140
+ if (!name) { out.err('give the hire a name: atris hire "Maya" --job "<idea>"'); return 1; }
141
+ if (!job) { out.err('give the hire a job: --job "<the idea to grow>"'); return 1; }
142
+ const body = { name, job, outreach: flags.outreach === true };
143
+ if (flags.budget != null) {
144
+ const budget = Number(flags.budget);
145
+ if (!Number.isInteger(budget) || budget < 0 || budget > 1000) {
146
+ out.err('--budget must be whole dollars from 0 to 1000');
147
+ return 1;
148
+ }
149
+ body.monthly_allowance_usd = budget;
150
+ }
151
+ if (typeof flags.schedule === 'string') body.schedule = flags.schedule;
152
+ const tz = typeof flags.timezone === 'string' ? flags.timezone : (deps.timezone || localTimezone());
153
+ if (tz) body.timezone = tz;
154
+
155
+ const business = businessOrSay(flags, deps, out.err);
156
+ if (!business) return 1;
157
+ const token = await loginToken(deps);
158
+ if (!token) { out.err(NOT_LOGGED_IN); return 1; }
159
+
160
+ const request = deps.apiRequestJson || apiRequestJson;
161
+ const result = await request(`/business/${encodeURIComponent(business.id)}/hire`, {
162
+ method: 'POST', token, body, timeoutMs: 60000, retries: 0,
163
+ });
164
+ if (!result || !result.ok) { out.err(`Could not hire ${name}: ${errorText(result)}`); return 1; }
165
+ if (flags.json) out.log(JSON.stringify(result.data));
166
+ else out.log(renderHire(result.data, deps.now ? deps.now() : Date.now()));
167
+ return 0;
168
+ }
169
+
170
+ async function hiresCommand(args = [], deps = {}) {
171
+ const out = io(deps);
172
+ const { flags, pos, error } = parseArgs(args, {});
173
+ if (flags.help || pos[0] === 'help') { hiresHelp(out.log); return 0; }
174
+ if (error) { out.err(`${error}. see: atris hires --help`); return 1; }
175
+ if (pos.length) { out.err(`atris hires takes no other words (got "${pos[0]}"). see: atris hires --help`); return 1; }
176
+ const business = businessOrSay(flags, deps, out.err);
177
+ if (!business) return 1;
178
+ const token = await loginToken(deps);
179
+ if (!token) { out.err(NOT_LOGGED_IN); return 1; }
180
+
181
+ const request = deps.apiRequestJson || apiRequestJson;
182
+ const result = await request(`/business/${encodeURIComponent(business.id)}/hires`, { method: 'GET', token });
183
+ if (!result || !result.ok) { out.err(`Could not load hires: ${errorText(result)}`); return 1; }
184
+ if (flags.json) out.log(JSON.stringify(result.data));
185
+ else out.log(renderHires(result.data, deps.now ? deps.now() : Date.now()));
186
+ return 0;
187
+ }
188
+
189
+ module.exports = { hireCommand, hiresCommand, renderHire, renderHires, hireLine, scoreLine };