atris 3.64.0 → 3.66.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:
@@ -95,7 +95,7 @@ Smoke: `reply with the word OK`, run from `$HOME`, 120s cap, one at a time on a
95
95
  | Cursor | `cursor-agent --trust --model composer-2.5 -p "<prompt>"` (run from the target repo) | `composer-2.5` | 2026-09-23 | 37.5 | `--trust` required for non-interactive. |
96
96
  | Fable | `atris engine fable --timeout 120 --json "<question>"` | `claude-fable-5` | 2026-09-23 | 21.4 | Canonical read-only FABLE ask with receipt; scale `--timeout` to the work. |
97
97
  | Composer | `cursor-agent --trust --model composer-2.5 -p "<prompt>"` | `composer-2.5` via Cursor | 2026-09-23 | 37.5 | Same binary as Cursor. |
98
- | Haiku | `claude -p "<prompt>" --model claude-haiku-4-5 --output-format json --no-session-persistence` | `claude-haiku-4-5` | 2026-09-23 | 19.0 | Fast validation and bounded read-only checks. |
98
+ | Haiku | `claude -p "<prompt>" --model claude-haiku-5-5 --output-format json --no-session-persistence` | `claude-haiku-5-5` | 2026-10-07 | 9.5 | Fast validation and bounded read-only checks. |
99
99
  | Devin (build) | `devin -p --permission-mode dangerous --model swe-2-max --prompt-file <brief>` (run from an isolated worktree) | `swe-2-max` (Free tag in `devin models list`, 2026-09-23) | 2026-09-23 | 14.0 | In `-p` mode the default permission mode rejects shell commands too, so even a read-only audit dies after one step (2026-09-23). Keep the brief outside the worktree. Long runs (5+ min) can print nothing even on success: judge by the worktree diff. `devin cloud` outlives this machine. |
100
100
  | Devin (search) | `devin -p --permission-mode dangerous --model swe-1.7-lightning -- "<prompt>"` | `swe-1.7-lightning` (paid, no Free tag on 2026-09-23) | 2026-09-23 | 13.9 | |
101
101
  | Grok | `grok --always-approve --model grok-4.7 -p "<prompt>"` (run from the target repo) | `grok-4.7` | 2026-09-23 | 11.7 | Unpinned default is now `grok-4.7-build-fast`; `grok models` lists the menu. Uses grok.com login. `--best-of-n <N>` for tricky bounded builds. |
@@ -119,7 +119,7 @@ Headless dispatch permissions (verified 2026-08-11): `codex exec`, `grok`, and `
119
119
 
120
120
  | Engine | Flag | Alternates on the live menu |
121
121
  |--------|------|-----------------------------|
122
- | Claude | `--model <id>` | `claude-opus-5-5`, `claude-haiku-4-5` |
122
+ | Claude | `--model <id>` | `claude-opus-5-5`, `claude-haiku-5-5` |
123
123
  | Devin | `--model <id>` | `swe-2-max`, `swe-2-high`, `swe-2-medium` (Free); `claude-opus-5-5-high`, `gpt-6-sol` (paid) |
124
124
  | Cursor | `--model <id>` | `composer-2.5`, `grok-4.7-xhigh`, `claude-opus-5-thinking-high`; `--list-models` |
125
125
  | Grok | `--model <id>` | `grok-4.7`, `grok-4.7-build-fast` (default), `grok-4.6`, `grok-4.5`; `grok models` |
package/bin/atris.js CHANGED
@@ -632,6 +632,7 @@ function showHelpAll() {
632
632
  console.log(' rsi - Read the Dream-RSI attempt ledger (trees, attempts, policy, dreams)');
633
633
  console.log(` autoland - Approve the policy once; ${require('../lib/autoland').certifiedWorkLandsPhrase(process.cwd())}, you keep irreversible calls`);
634
634
  console.log(' engine - engine registry, answer validation, dispatch flights, and live progress');
635
+ console.log(' link - What two things could become together: one line, why, a first thing to make, who makes it');
635
636
  console.log(' ci - run github actions jobs locally with runs-on: atris');
636
637
  console.log(' router - inspect ax lane outcomes and promote gated reflex overrides');
637
638
  console.log(' sign - Co-author trailer on every commit in an atris workspace (on/off/status)');
@@ -719,6 +720,9 @@ function showHelpAll() {
719
720
  console.log(' topup - Buy credits and print a Stripe checkout URL');
720
721
  console.log(' buy - Quote a purchase or stock order; the person approves before anything is charged');
721
722
  console.log(' transactions - List what you bought, paid in fees, traded, or sent');
723
+ console.log(' hire - Hire an agent in one step: name, job, inbox, allowance, nightly work loop');
724
+ console.log(' hires - Morning check-in: one line per hire (mail, spend, last run)');
725
+ console.log(' mail - Your agents\' own inboxes: list, read, send (not Gmail)');
722
726
  console.log(' design - Extract a site design system, check brand adherence, search brands');
723
727
  console.log(' mcp - Run the atris MCP server (stdio) for Claude Desktop and Cursor');
724
728
  console.log('');
@@ -749,6 +753,7 @@ function showHelpAll() {
749
753
  console.log(' member list - Show all team members');
750
754
  console.log(' member activate <n> - Activate a member (link skills, show context)');
751
755
  console.log(' member upgrade <n> - Convert flat file to directory format');
756
+ console.log(' member result <n> - Newest run of a member: run record, receipt, artifacts, result number');
752
757
  console.log('');
753
758
  console.log('Plugin:');
754
759
  console.log(' plugin build - Package skills as .plugin for Cowork');
@@ -1773,9 +1778,28 @@ function showWelcomeVisualization() {
1773
1778
  // Document health is advisory and must never prevent startup.
1774
1779
  }
1775
1780
 
1781
+ // What the background cleanup did lately, in its own plain words: the
1782
+ // newest line the hourly autoland tick or a project job left in the
1783
+ // journal, plus agent branches parked for a person.
1784
+ try {
1785
+ const lately = require('../lib/cleanup-summary').latestCleanupLine(cwd);
1786
+ if (lately) console.log(row('lately', lately));
1787
+ } catch {
1788
+ // Advisory only; never blocks boot.
1789
+ }
1790
+
1791
+ // When the hourly autoland tick is alive it lands, salvages, and clears
1792
+ // finished branches and old agent copies itself, so boot must not hand the
1793
+ // owner that chore ("39 finished pieces to put away"). Only a dead or
1794
+ // missing heartbeat brings the tidy counts and `land --reap` back.
1795
+ let autolandAlive = false;
1796
+ try { autolandAlive = require('../lib/autoland').heartbeatIsLive(cwd); } catch { autolandAlive = false; }
1797
+
1776
1798
  // landSummary is expensive (git board classification) - compute once per boot.
1777
1799
  let landInfo = null;
1778
- try { landInfo = require('../commands/land').landSummary(cwd); } catch (err) { landInfo = null; }
1800
+ if (!autolandAlive) {
1801
+ try { landInfo = require('../commands/land').landSummary(cwd); } catch (err) { landInfo = null; }
1802
+ }
1779
1803
  let rotInfo = null;
1780
1804
  try {
1781
1805
  const { parseLessons } = require('../lib/memory-view');
@@ -1789,7 +1813,7 @@ function showWelcomeVisualization() {
1789
1813
  worktreeDir = path.join(path.dirname(resolved), '.agent-worktrees', path.basename(resolved));
1790
1814
  }
1791
1815
  let worktrees = 0;
1792
- if (fs.existsSync(worktreeDir)) {
1816
+ if (!autolandAlive && fs.existsSync(worktreeDir)) {
1793
1817
  worktrees = fs.readdirSync(worktreeDir, { withFileTypes: true })
1794
1818
  .filter((entry) => entry.isDirectory()).length;
1795
1819
  }
@@ -2384,6 +2408,20 @@ if (command === 'guide') {
2384
2408
  Promise.resolve(require('../commands/buy').buyCommand(process.argv.slice(3)))
2385
2409
  .then((code) => process.exit(typeof code === 'number' ? code : 0))
2386
2410
  .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2411
+ } else if (command === 'hire') {
2412
+ // One-call hire: member + inbox + allowance + overnight loop (POST /business/{id}/hire).
2413
+ Promise.resolve(require('../commands/hire').hireCommand(process.argv.slice(3)))
2414
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2415
+ .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2416
+ } else if (command === 'hires') {
2417
+ Promise.resolve(require('../commands/hire').hiresCommand(process.argv.slice(3)))
2418
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2419
+ .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2420
+ } else if (command === 'mail') {
2421
+ // Agent inboxes (@atrismail.com), not Gmail. Send asks first unless --yes.
2422
+ Promise.resolve(require('../commands/mail').mailCommand(process.argv.slice(3)))
2423
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2424
+ .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2387
2425
  } else if (command === 'transactions') {
2388
2426
  Promise.resolve(require('../commands/transactions').transactionsCommand(process.argv.slice(3)))
2389
2427
  .then((code) => process.exit(typeof code === 'number' ? code : 0))
@@ -3154,6 +3192,11 @@ if (command === 'guide') {
3154
3192
  Promise.resolve(require('../commands/engine').engineCommand(engineArgs))
3155
3193
  .then((code) => process.exit(typeof code === 'number' ? code : 0))
3156
3194
  .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
3195
+ } else if (command === 'link') {
3196
+ // Link: ask the roster's engine what two things could become together.
3197
+ Promise.resolve(require('../commands/link').linkCommand(process.argv.slice(3)))
3198
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
3199
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
3157
3200
  } else if (command === 'playbook') {
3158
3201
  Promise.resolve()
3159
3202
  .then(() => require('../commands/playbook').playbookCommand(process.argv.slice(3)))
@@ -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,
@@ -555,6 +555,21 @@ function showStatus(root, args) {
555
555
  console.log('');
556
556
  console.log(` waiting on a human right now: ${waiting.length}, oldest ${waiting[0].hours}h`);
557
557
  }
558
+ // What the next tick's idle sweep will do, read-only (no check runs here).
559
+ if (enabled && policy.janitor !== false && policy.idle_sweep !== false) {
560
+ try {
561
+ const idle = require('../lib/idle-review-sweep').sweepIdleReviews(root, { tasks, policy, dryRun: true });
562
+ const days = Number(policy.idle_close_days) > 0 ? Number(policy.idle_close_days) : 7;
563
+ if (idle.closed.length) {
564
+ console.log(` closing on the next tick: ${idle.closed.length} idle over ${days} days that can never land on their own, each with a plain reason.`);
565
+ }
566
+ if (idle.human_waiting.length) {
567
+ console.log(` staying with you: ${idle.human_waiting.length} in money, deploys, security, or customer work; idle time never closes these.`);
568
+ }
569
+ } catch {
570
+ // Preview only.
571
+ }
572
+ }
558
573
  console.log('');
559
574
  if (!enabled) console.log(" flip it on: atris autoland on (one decision; everything after is receipts)");
560
575
  console.log('');
@@ -798,9 +813,11 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
798
813
  .filter((task) => String(task.review?.approval_status || task.metadata?.approval_status || 'pending') === 'pending')
799
814
  .length;
800
815
  const accept = runOwnCli(root, cliArgs, acceptSweepTimeoutMs(pendingReviewRows));
816
+ let acceptResults = [];
801
817
  try {
802
818
  const parsed = JSON.parse(accept.stdout);
803
819
  const results = Array.isArray(parsed.results) ? parsed.results : [];
820
+ acceptResults = results;
804
821
  // A refused sweep (ok:false, a guard tripped, policy race) carries no
805
822
  // summary fields. Name the reason instead of leaving nulls that read as
806
823
  // "no work": a blind heartbeat must say WHY it is blind.
@@ -963,6 +980,51 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
963
980
  recordLandingSweepState(state, null, { error: receipt.reap_error });
964
981
  }
965
982
 
983
+ // 3c2. idle work resolves itself, every tick. Finished work that sat still
984
+ // past the grace period (policy.idle_close_days, default 7) and can never
985
+ // land on its own is closed with a plain reason; done and long-idle rows
986
+ // leave the list. This used to run only when someone opened a session, so
987
+ // nights and trips piled up work nobody would ever land. Protected lanes
988
+ // (money, deploys, security, customer, outward) are never closed by idle
989
+ // time. Off switch: policy.idle_sweep === false or policy.janitor === false.
990
+ if (policy.janitor !== false && policy.idle_sweep !== false) {
991
+ try {
992
+ const liveReasons = new Map(acceptResults
993
+ .filter((r) => r && r.ref && r.reason && r.action !== 'accepted')
994
+ .map((r) => [r.ref, r.reason]));
995
+ const kept = require('../lib/task-list-keeper').keepWorkspaceTaskList(root, { actor: 'autoland', includeDone: false });
996
+ const idle = require('../lib/idle-review-sweep').sweepIdleReviews(root, {
997
+ tasks: readProjection(root),
998
+ policy,
999
+ liveReasons,
1000
+ });
1001
+ const putAway = (kept.put_away || []).length + (kept.reaped || []).length;
1002
+ if (putAway + idle.closed.length > 0) require('./task').refreshKeptTaskList(root);
1003
+ receipt.idle_sweep = {
1004
+ closed: idle.closed.map((c) => ({ ref: c.ref, reason: c.reason })),
1005
+ put_away: putAway,
1006
+ human_waiting: idle.human_waiting.length,
1007
+ };
1008
+ } catch (err) {
1009
+ receipt.idle_sweep_error = String((err && err.message) || err).slice(0, 200);
1010
+ }
1011
+ }
1012
+ try {
1013
+ const { appendOvernightNote, tickSummaryLine } = require('../lib/cleanup-summary');
1014
+ const line = tickSummaryLine({
1015
+ landed: (receipt.landed || []).length,
1016
+ closed: receipt.idle_sweep ? receipt.idle_sweep.closed.length : 0,
1017
+ putAway: receipt.idle_sweep ? receipt.idle_sweep.put_away : 0,
1018
+ needYou: receipt.idle_sweep ? receipt.idle_sweep.human_waiting : protectedWaiting.length,
1019
+ });
1020
+ if (line) {
1021
+ appendOvernightNote(root, line);
1022
+ receipt.summary_line = line;
1023
+ }
1024
+ } catch {
1025
+ // The journal line is a courtesy; it never fails the tick.
1026
+ }
1027
+
966
1028
  // 3d. wish sweep: queued wishes are allowed to wake a mission without a
967
1029
  // foreground `atris wish` session, but they must never endanger landing.
968
1030
  try {
package/commands/brain.js CHANGED
@@ -1140,6 +1140,10 @@ function taskEpisodeScorecard(root, episode, workspace, ts = new Date().toISOStr
1140
1140
  task_title: episode.state && episode.state.title || null,
1141
1141
  task_tag: episode.state && episode.state.tag || null,
1142
1142
  actor: episode.action && episode.action.actor || null,
1143
+ // Who built the work. `actor` is the reviewer; trust is earned by the builder.
1144
+ builder: episode.state && episode.state.metadata && episode.state.metadata.built_by
1145
+ || episode.state && episode.state.claimed_by
1146
+ || null,
1143
1147
  reward: Number.isFinite(reward) ? reward : 0,
1144
1148
  reward_source: episode.reward && episode.reward.source || 'task_review',
1145
1149
  lesson: episode.lesson || '',
@@ -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,
@@ -54,6 +57,8 @@ const {
54
57
  rosterJobNameError,
55
58
  rosterJobRole,
56
59
  readRosterState,
60
+ retiredModelsNamed,
61
+ RETIRED_CLAUDE_MODELS,
57
62
  customRosterJobKeys,
58
63
  setRosterPick,
59
64
  confirmRoster,
@@ -1051,6 +1056,7 @@ function workerRow(step, index, leadIndex, now = new Date()) {
1051
1056
  until: worker.until || null,
1052
1057
  ends_in_days: endsIn,
1053
1058
  expiring_soon: endsSoon(endsIn),
1059
+ verified: worker.verified || null,
1054
1060
  line: worker.line || null,
1055
1061
  ...(worker.error ? { text: worker.text || '' } : {}),
1056
1062
  status,
@@ -1157,6 +1163,7 @@ function rosterReport(root = process.cwd(), now = new Date()) {
1157
1163
  jobs,
1158
1164
  expiring,
1159
1165
  renew_command: expiring.length ? RENEW_COMMAND : null,
1166
+ upgraded: upgradedModels(state),
1160
1167
  team: team.rows,
1161
1168
  warnings: [...state.session.warnings, ...state.project.warnings, ...state.machine.warnings, ...team.warnings],
1162
1169
  files: {
@@ -1168,6 +1175,25 @@ function rosterReport(root = process.cwd(), now = new Date()) {
1168
1175
  };
1169
1176
  }
1170
1177
 
1178
+ // Retired models a roster file still names. Every read runs them as their
1179
+ // replacement, so this is only a heads-up; nobody has to edit the file.
1180
+ function upgradedModels(state) {
1181
+ const found = [];
1182
+ for (const layer of [state.session, state.project, state.machine]) {
1183
+ if (!layer || layer.format === 'none' || !layer.path) continue;
1184
+ let text = '';
1185
+ try { text = fs.readFileSync(layer.path, 'utf8'); } catch { continue; }
1186
+ for (const from of retiredModelsNamed(text)) {
1187
+ if (!found.some((item) => item.from === from)) found.push({ from, to: RETIRED_CLAUDE_MODELS[from] });
1188
+ }
1189
+ }
1190
+ return found;
1191
+ }
1192
+
1193
+ function renderUpgradedLine(upgraded) {
1194
+ return (upgraded || []).map((item) => `note: ${modelLabel(item.from)} is retired, so your roster runs ${modelLabel(item.to)} in its place. no edit needed.`).join('\n');
1195
+ }
1196
+
1171
1197
  // Every worker, in any job, whose end date is today or within the next
1172
1198
  // seven days. Workers already past their date show on their own lines. A
1173
1199
  // job set for this shell only is skipped: roster confirm does not renew it.
@@ -1214,74 +1240,157 @@ function bootExpiringLine(expiring) {
1214
1240
  return `${first.tool} on ${first.job} ${endsInText(first.ends_in_days)}${more}. renew: ${RENEW_COMMAND}`;
1215
1241
  }
1216
1242
 
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
- });
1243
+ // --- the roster as tables -------------------------------------------------
1244
+ //
1245
+ // One row per job, one row per member. Plain columns, a header row, two
1246
+ // spaces between; a cell that needs more than one line (effort, several
1247
+ // backups) carries on underneath in its own column.
1248
+
1249
+ function workerCellText(view) {
1250
+ return view ? engineModelText(view.engine, view.model) : '';
1231
1251
  }
1232
1252
 
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');
1253
+ function maxCellText(seconds) {
1254
+ const text = rosterMaxText(seconds);
1255
+ return text ? text.replace(/^max /, '') : '-';
1256
+ }
1257
+
1258
+ // Workers that name a tool the roster can run, in written order.
1259
+ function goodWorkers(row) {
1260
+ return (row.workers || []).filter((worker) => worker.engine && worker.runs);
1264
1261
  }
1265
1262
 
1266
- function renderTeamRoster(rows) {
1263
+ // Why a backup is passed over, in two or three words.
1264
+ function backupSkipText(worker) {
1265
+ if (worker.status !== 'skipped') return '';
1266
+ if (worker.why === 'expired' && worker.ends_in_days !== null && worker.ends_in_days !== undefined) return `ended ${shortDay(worker.until)}`;
1267
+ return worker.why || 'skipped';
1268
+ }
1269
+
1270
+ // "newer <model> out" or "pin 45 days old": the outdated-model flags,
1271
+ // short enough for the note column. The full line with its fix prints
1272
+ // under the table.
1273
+ function staleNote(worker) {
1274
+ if (worker.stale_model && worker.newer_model) return `newer ${modelLabel(worker.newer_model)} out`;
1275
+ 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`;
1276
+ return '';
1277
+ }
1278
+
1279
+ function jobNoteText(row, lead, backups, mainFrom) {
1280
+ // A tool on its own default model is named alone here, to keep notes short.
1281
+ const nowText = !row.now_runs ? 'no ready engine' : row.now_runs.model ? workerCellText(row.now_runs) : row.now_runs.engine;
1282
+ if (!row.pick) return row.like ? `no pick, same as ${row.like}` : 'no pick, router decides';
1283
+ const fallback = row.lead === 'backup' ? 'using backup'
1284
+ : row.lead === 'later' ? `using ${nowText}`
1285
+ : row.like ? `falls back to ${row.like}` : `router decides: ${nowText}`;
1286
+ const ended = row.ends_in_days !== null && row.ends_in_days !== undefined ? `ended ${shortDay(row.pick.until)}` : 'expired';
1287
+ const parts = [];
1288
+ if (row.status === 'expired') parts.push(`${ended}, ${fallback}`);
1289
+ else if (row.status === 'cooling') parts.push(`${row.cooling}, ${fallback}`);
1290
+ else if (row.status === 'not ready') parts.push(`not ready, ${fallback}`);
1291
+ else if (row.expiring_soon) parts.push(endsInText(row.ends_in_days));
1292
+ if (lead && lead.prep) parts.push(`prep: ${lead.prep}`);
1293
+ if (lead && staleNote(lead)) parts.push(staleNote(lead));
1294
+ for (const worker of backups) if (staleNote(worker)) parts.push(`backup ${staleNote(worker)}`);
1295
+ if (row.from && row.from !== mainFrom) parts.push(`from ${row.from}`);
1296
+ return parts.join(', ');
1297
+ }
1298
+
1299
+ // The roster file most jobs come from; the table heading names it.
1300
+ function mainRosterSource(rows) {
1301
+ const counts = new Map();
1302
+ for (const row of rows) if (row.pick && row.from) counts.set(row.from, (counts.get(row.from) || 0) + 1);
1303
+ const [from] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0] || [];
1304
+ const row = rows.find((item) => item.from === from);
1305
+ return { from: from || null, file: row ? row.file : null };
1306
+ }
1307
+
1308
+ function jobTableRow(row, mainFrom) {
1309
+ const [lead, ...backups] = row.pick ? goodWorkers(row) : [];
1310
+ const leadView = row.pick ? (lead && lead.runs) : row.now_runs;
1311
+ const leads = leadView
1312
+ ? [workerCellText(leadView), leadView.effort ? `(${leadView.effort} effort)` : null]
1313
+ : [row.pick ? 'no ready tool' : 'no ready engine'];
1314
+ const backupCell = backups.length
1315
+ ? backups.map((worker) => {
1316
+ // Effort, max time, and prep for a backup stay in --json; the cell keeps
1317
+ // what changes who runs: a skip reason or an end date.
1318
+ const skip = backupSkipText(worker);
1319
+ const until = !skip && parseRosterUntil(worker.until) ? `until ${shortDay(worker.until)}${worker.expiring_soon ? `, ${endsInText(worker.ends_in_days)}` : ''}` : '';
1320
+ const extras = [skip, until].filter(Boolean);
1321
+ return `${workerCellText(worker.runs)}${extras.length ? ` (${extras.join(', ')})` : ''}`;
1322
+ })
1323
+ : ['none'];
1324
+ const until = lead && parseRosterUntil(lead.until) ? lead.until : '-';
1325
+ return [
1326
+ row.job,
1327
+ leads,
1328
+ backupCell,
1329
+ lead ? maxCellText(lead.max_seconds) : '-',
1330
+ until,
1331
+ jobNoteText(row, lead, backups, mainFrom) || '-',
1332
+ ];
1333
+ }
1334
+
1335
+ function renderJobRoster(rows, { width, heading = true } = {}) {
1267
1336
  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();
1337
+ const { from, file } = mainRosterSource(rows);
1338
+ const columns = [
1339
+ { header: 'JOB' }, { header: 'LEADS' }, { header: 'BACKUP' }, { header: 'MAX' }, { header: 'UNTIL' },
1340
+ { header: 'NOTE', wrap: true, min: 16 },
1341
+ ];
1342
+ const table = renderTable(columns, rows.map((row) => jobTableRow(row, from)), { width });
1343
+ const title = !heading ? '' : file ? `jobs, from ${file}` : 'jobs';
1344
+ const suggestions = rows.filter((row) => row.suggestion).map((row) => `suggestion for ${row.job}: ${row.suggestion.text}`);
1345
+ return [title, table, ...renderWorkerRecords(rows, { width }), ...suggestions].filter(Boolean).join('\n');
1346
+ }
1347
+
1348
+ // Each worker's recent record (last 7 days), only for workers with runs, as
1349
+ // one small table under the jobs.
1350
+ function renderWorkerRecords(rows, { width } = {}) {
1351
+ const records = [];
1352
+ for (const row of rows) {
1353
+ for (const worker of row.workers || []) {
1354
+ if (worker.record) records.push([row.job, worker.runs ? workerCellText(worker.runs) : worker.engine, worker.record.text.replace(/^last \d+ days: /, '')]);
1355
+ }
1356
+ }
1357
+ if (!records.length) return [];
1358
+ return ['\nlast 7 days', renderTable([{ header: 'JOB' }, { header: 'WORKER' }, { header: 'RECORD', clip: true, min: 20 }], records, { width })];
1359
+ }
1360
+
1361
+ // "sep 30, 3 runs" or "quiet 8 to 14 days", from the same evidence atris
1362
+ // team reads. '-' for idle members and when activity was not read.
1363
+ function teamActivityTag(activity) {
1364
+ if (!activity || activity.status === 'idle') return '-';
1365
+ if (activity.status === 'quiet') return 'quiet 8 to 14 days';
1366
+ const runs = Number(activity.runs_7d) || 0;
1367
+ const day = activity.days_since === 0 ? 'today' : activity.days_since === 1 ? 'yesterday' : shortDay(activity.last_active);
1368
+ return `${day}, ${runs} run${runs === 1 ? '' : 's'}`;
1369
+ }
1370
+
1371
+ function renderTeamRoster(rows, { width } = {}) {
1372
+ if (!rows.length) return '';
1373
+ const showActivity = rows.some((row) => row.activity !== undefined);
1374
+ const columns = [{ header: 'MEMBER' }, { header: 'JOB' }, { header: 'ENGINE · MODEL' }, { header: 'SOURCE' }];
1375
+ if (showActivity) columns.push({ header: 'LAST 7 DAYS', clip: true, min: 11 });
1376
+ const cells = rows.map((row) => {
1377
+ const engine = row.engine ? workerCellText(engineRunsView(row.engine, { model: row.model || '', effort: row.effort || '' })) : 'no ready engine';
1378
+ const stale = row.stale_model && row.newer_model ? ` (newer ${modelLabel(row.newer_model)} out)` : '';
1379
+ const cell = [row.member, row.job || '-', `${engine}${stale}`, row.source === 'file' ? (row.file || 'roster file') : 'automatic'];
1380
+ if (showActivity) cell.push(teamActivityTag(row.activity));
1381
+ return cell;
1274
1382
  });
1275
- return ['team', ...lines].join('\n');
1383
+ return ['team', renderTable(columns, cells, { width })].join('\n');
1276
1384
  }
1277
1385
 
1278
1386
  function renderRosterWarnings(warnings) {
1279
1387
  return warnings.map((warning) => `warning: ${warning.file} line ${warning.line} "${warning.text}" ${warning.message}.`).join('\n');
1280
1388
  }
1281
1389
 
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)]
1390
+ function renderRosterReport(report, { width } = {}) {
1391
+ const stale = (report.stale || []).map((item) => item.text);
1392
+ const jobs = [renderJobRoster(report.jobs, { width }), renderExpiringLine(report.expiring), ...stale, renderUpgradedLine(report.upgraded)].filter(Boolean).join('\n');
1393
+ return [jobs, renderTeamRoster(report.team, { width }), renderRosterWarnings(report.warnings)]
1285
1394
  .filter(Boolean)
1286
1395
  .join('\n\n');
1287
1396
  }
@@ -1290,8 +1399,6 @@ function printJobRoster(root, now = new Date()) {
1290
1399
  console.log(renderRosterReport(rosterReport(root, now)));
1291
1400
  }
1292
1401
 
1293
- const AVAILABLE_HINT = 'see which tools and models this machine has: atris engine roster --available';
1294
-
1295
1402
  // Each installed tool and the models it offers, from local files only.
1296
1403
  function availableReport(root) {
1297
1404
  const engines = readEngineRegistry(root, { persist: false }).engines
@@ -1312,7 +1419,7 @@ function renderSessionRoster(report, key) {
1312
1419
  const team = report.team.filter((row) => row.source === 'file' && row.file === report.files.session);
1313
1420
  if (!jobs.length && !team.length) return `this session (${key}) has no roster changes. add one with: atris engine assign <job> <tool> --session`;
1314
1421
  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');
1422
+ return [head, renderJobRoster(jobs, { heading: false }), renderTeamRoster(team)].filter(Boolean).join('\n\n');
1316
1423
  }
1317
1424
 
1318
1425
  function runRosterSessionCommand(rest, json, root, now) {
@@ -1361,6 +1468,38 @@ function attachRunRecords(report, root, now = new Date()) {
1361
1468
  return report;
1362
1469
  }
1363
1470
 
1471
+ // Pins are proven by landed runs; a pin older than 30 days is flagged, so
1472
+ // the look-back reaches well past that.
1473
+ const STALE_PROOF_DAYS = 90;
1474
+
1475
+ // Each team row gets the member's activity (last 7 days, quiet, or idle)
1476
+ // from the same evidence atris team reads: dated logs, commits that name
1477
+ // the member, roster runs, and stream events, read once for the team.
1478
+ function attachTeamActivity(report, root, now = new Date()) {
1479
+ const rows = report.team || [];
1480
+ if (!rows.length) return report;
1481
+ try {
1482
+ const { collectMemberActivity } = require('./team');
1483
+ const activity = collectMemberActivity(root, rows.map((row) => ({ name: row.member })), { nowMs: new Date(now).getTime() });
1484
+ const byName = new Map(activity.map(({ days, ...rest }) => [rest.name, rest]));
1485
+ for (const row of rows) row.activity = byName.get(String(row.member).toLowerCase()) || null;
1486
+ } catch {
1487
+ // Activity is a view; a read that fails leaves the roster as it was.
1488
+ }
1489
+ return report;
1490
+ }
1491
+
1492
+ // Mark workers that run an outdated model or an unproven old pin. Local
1493
+ // files only: the registry, codex's models cache, and the run record.
1494
+ function attachStaleCheck(report, root, now = new Date()) {
1495
+ const engines = readEngineRegistry(root, { persist: false }).engines;
1496
+ return attachStaleModels(report, {
1497
+ catalog: modelCatalog(engines),
1498
+ runs: readRosterRuns(root, { now, days: STALE_PROOF_DAYS }),
1499
+ now,
1500
+ });
1501
+ }
1502
+
1364
1503
  const RUNS_LIST_DAYS = 30;
1365
1504
  const RUNS_LIST_LIMIT = 20;
1366
1505
 
@@ -1407,9 +1546,9 @@ function runRosterCommand(args, root, now = new Date()) {
1407
1546
  }
1408
1547
  if (rest[0] === 'session') return runRosterSessionCommand(rest, json, root, now);
1409
1548
  if (rest[0] === 'confirm') confirmRoster(root, now);
1410
- const report = attachRunRecords(rosterReport(root, now), root, now);
1549
+ const report = attachTeamActivity(attachStaleCheck(attachRunRecords(rosterReport(root, now), root, now), root, now), root, now);
1411
1550
  if (json) console.log(JSON.stringify(report, null, 2));
1412
- else console.log(`${renderRosterReport(report)}\n\n${AVAILABLE_HINT}`);
1551
+ else console.log(`${renderRosterReport(report)}\n\n${TEAM_FOOTER.join('\n')}`);
1413
1552
  return 0;
1414
1553
  }
1415
1554
 
@@ -2267,7 +2406,7 @@ function engineCommand(args = [], deps = {}) {
2267
2406
  if (sub === 'help' || args.includes('--help') || args.includes('-h')) {
2268
2407
  console.log('\n atris engine watch [<id>|latest] [--no-follow]\n follow one live transcript or list running engine work');
2269
2408
  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');
2409
+ 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
2410
  return 0;
2272
2411
  }
2273
2412
  if (isFreshWorkspace(root)) {
@@ -2316,6 +2455,7 @@ module.exports = {
2316
2455
  readSavedEngine,
2317
2456
  roster,
2318
2457
  rosterReport,
2458
+ renderRosterReport,
2319
2459
  jobRosterView,
2320
2460
  expiringWorkers,
2321
2461
  bootExpiringLine,