atris 3.63.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.
Files changed (81) hide show
  1. package/README.md +4 -0
  2. package/atris/skills/commerce/SKILL.md +24 -0
  3. package/atris/skills/x-search/SKILL.md +3 -1
  4. package/ax +4 -3
  5. package/bin/atris.js +28 -2
  6. package/cli/atris_code.py +25 -1
  7. package/commands/agent-spawn.js +1 -0
  8. package/commands/auth.js +79 -10
  9. package/commands/autoland.js +11 -1
  10. package/commands/browse.js +2 -2
  11. package/commands/business.js +3 -3
  12. package/commands/buy.js +3 -3
  13. package/commands/deck.js +2 -2
  14. package/commands/engine.js +292 -65
  15. package/commands/feedback.js +2 -2
  16. package/commands/fork.js +2 -2
  17. package/commands/gm.js +2 -1
  18. package/commands/hire.js +189 -0
  19. package/commands/improve.js +142 -8
  20. package/commands/integrations.js +4 -3
  21. package/commands/loops.js +2 -1
  22. package/commands/mail.js +163 -0
  23. package/commands/member.js +47 -9
  24. package/commands/mission.js +4 -3
  25. package/commands/pack-craft.js +2 -2
  26. package/commands/publish.js +2 -2
  27. package/commands/radar.js +3 -2
  28. package/commands/router.js +2 -2
  29. package/commands/stream.js +22 -1
  30. package/commands/task.js +84 -2
  31. package/commands/team.js +355 -67
  32. package/commands/transactions.js +3 -3
  33. package/commands/truth.js +2 -2
  34. package/commands/x-search.js +81 -10
  35. package/commands/youtube.js +2 -1
  36. package/lib/auto-accept-certified.js +83 -9
  37. package/lib/autoland.js +3 -2
  38. package/lib/ax-auto-lane.js +2 -2
  39. package/lib/brief-rules.js +11 -0
  40. package/lib/business-target.js +131 -0
  41. package/lib/chat-log-scan.js +2 -1
  42. package/lib/ci-runner.js +3 -2
  43. package/lib/codex-flight.js +2 -1
  44. package/lib/commerce.js +17 -2
  45. package/lib/deck-history.js +2 -2
  46. package/lib/deck-review.js +3 -3
  47. package/lib/design-api.js +2 -2
  48. package/lib/engine-processes.js +430 -0
  49. package/lib/engine-registry.js +101 -21
  50. package/lib/first-minute.js +2 -2
  51. package/lib/fleet.js +8 -36
  52. package/lib/git-spawn.js +5 -2
  53. package/lib/ignored-cache.js +51 -0
  54. package/lib/known-commands.js +1 -1
  55. package/lib/manifest.js +2 -2
  56. package/lib/map-refs.js +70 -4
  57. package/lib/member-identity.js +56 -0
  58. package/lib/pack-capabilities.js +2 -1
  59. package/lib/permission-grants.js +2 -2
  60. package/lib/pulse.js +5 -5
  61. package/lib/roster-models.js +11 -0
  62. package/lib/roster-runs.js +13 -2
  63. package/lib/roster-stale.js +241 -0
  64. package/lib/runner-command.js +22 -6
  65. package/lib/sandbox-pids.js +113 -0
  66. package/lib/secret-gateway.js +3 -13
  67. package/lib/state-home.js +106 -0
  68. package/lib/task-db.js +4 -3
  69. package/lib/task-prune.js +462 -0
  70. package/lib/team-lineup.js +10 -71
  71. package/lib/team-presence.js +224 -0
  72. package/lib/text-table.js +81 -0
  73. package/lib/x-post-check.js +497 -0
  74. package/package.json +1 -1
  75. package/scripts/det/data/xsearch-answer.json +24 -0
  76. package/scripts/det/data/xsearch-live-2026-09-29.json +93 -0
  77. package/scripts/det/xsearch-bench.js +307 -0
  78. package/scripts/det/ytnotes +61 -15
  79. package/scripts/det/ytrail-bench.js +2 -1
  80. package/utils/auth.js +3 -3
  81. package/utils/update-check.js +2 -2
@@ -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,
@@ -69,6 +72,8 @@ const {
69
72
  readEngineRegistry,
70
73
  resolveEngineForRole,
71
74
  resolveEngineForRoleRanked,
75
+ resolveNotesJob,
76
+ jobTakesAnyEngine,
72
77
  requireEngineBin,
73
78
  engineDoctorReport,
74
79
  engineFailureHealthStatus,
@@ -1000,10 +1005,41 @@ function rosterPickEntry(value) {
1000
1005
 
1001
1006
  const WORKER_SKIP_WORDS = Object.freeze({ 'not ready': 'down', expired: 'expired', 'bad model': 'names a model it cannot run' });
1002
1007
 
1008
+ // A worker whose end date is this many days away or fewer gets a heads-up.
1009
+ const ENDS_SOON_DAYS = 7;
1010
+ const RENEW_COMMAND = 'atris engine roster confirm';
1011
+
1012
+ // Whole local days from today to a worker's end date: 0 on its last day,
1013
+ // negative once it has ended, null when it has no valid end date.
1014
+ function endsInDays(until, now = new Date()) {
1015
+ const end = parseRosterUntil(until);
1016
+ if (!end) return null;
1017
+ const date = new Date(now);
1018
+ if (Number.isNaN(date.getTime())) return null;
1019
+ const today = new Date(date.getFullYear(), date.getMonth(), date.getDate());
1020
+ return Math.round((end.getTime() - today.getTime()) / 86400000);
1021
+ }
1022
+
1023
+ function endsSoon(days) {
1024
+ return days !== null && days >= 0 && days <= ENDS_SOON_DAYS;
1025
+ }
1026
+
1027
+ function endsInText(days) {
1028
+ if (days === 0) return 'ends today';
1029
+ if (days === 1) return 'ends tomorrow';
1030
+ return `ends in ${days} days`;
1031
+ }
1032
+
1033
+ function shortDay(until) {
1034
+ const date = parseRosterUntil(until);
1035
+ return date ? date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }).toLowerCase() : '';
1036
+ }
1037
+
1003
1038
  // One worker of a job's team as the view shows it: what it runs, and whether
1004
1039
  // it leads now, backs up, or is skipped and why.
1005
- function workerRow(step, index, leadIndex) {
1040
+ function workerRow(step, index, leadIndex, now = new Date()) {
1006
1041
  const { worker } = step;
1042
+ const endsIn = endsInDays(worker.until, now);
1007
1043
  const runs = worker.error ? null : engineRunsView(worker.engine, { model: worker.model || '', effort: worker.effort || '' });
1008
1044
  const status = index === leadIndex ? 'leads' : step.skip ? 'skipped' : 'backup';
1009
1045
  const why = step.skip === 'bad line' ? `bad line: ${worker.error}`
@@ -1016,6 +1052,9 @@ function workerRow(step, index, leadIndex) {
1016
1052
  max_seconds: Number(worker.max_seconds) > 0 ? Number(worker.max_seconds) : null,
1017
1053
  prep: worker.prep ? rosterJobLabel(worker.prep) : null,
1018
1054
  until: worker.until || null,
1055
+ ends_in_days: endsIn,
1056
+ expiring_soon: endsSoon(endsIn),
1057
+ verified: worker.verified || null,
1019
1058
  line: worker.line || null,
1020
1059
  ...(worker.error ? { text: worker.text || '' } : {}),
1021
1060
  status,
@@ -1041,7 +1080,7 @@ function jobRosterRow(job, role, root, state, registry, now, key = role) {
1041
1080
  const decided = rosterDecided(resolved.source) && (!custom || resolved.job === job);
1042
1081
  const source = decided ? resolved.source : ROSTER_LAYERS.find((scope) => picks[scope]) || null;
1043
1082
  const pick = source ? picks[source] : null;
1044
- const walk = pick ? rosterWorkerWalk(pick, role, registry, now) : [];
1083
+ const walk = pick ? rosterWorkerWalk(pick, role, registry, now, { anyEngine: jobTakesAnyEngine(key) }) : [];
1045
1084
  const leadIndex = decided && resolved.source === source ? walk.findIndex((step) => !step.skip) : -1;
1046
1085
  const good = walk.map((step, index) => ({ step, index })).filter(({ step }) => !step.worker.error);
1047
1086
  const first = good[0] || null;
@@ -1055,6 +1094,7 @@ function jobRosterRow(job, role, root, state, registry, now, key = role) {
1055
1094
  : good[1] && leadIndex === good[1].index ? 'backup'
1056
1095
  : 'later';
1057
1096
  const kind = Object.keys(ENGINE_JOBS).find((name) => ENGINE_JOBS[name] === role);
1097
+ const pickEndsIn = pick ? endsInDays(pick.until, now) : null;
1058
1098
  // What the job really runs: its first two good workers, each with its own
1059
1099
  // model and effort, or the router's engine when no line decides.
1060
1100
  const runsOf = (entry) => (entry ? engineRunsView(entry.step.worker.engine, { model: entry.step.worker.model || '', effort: entry.step.worker.effort || '' }) : null);
@@ -1076,12 +1116,14 @@ function jobRosterRow(job, role, root, state, registry, now, key = role) {
1076
1116
  model: resolved.engine && resolved.engine.roster_model ? resolved.engine.roster_model : null,
1077
1117
  effort: resolved.engine && resolved.engine.roster_effort ? resolved.engine.roster_effort : null,
1078
1118
  max_seconds: pick && Number(pick.max_seconds) > 0 ? Number(pick.max_seconds) : null,
1119
+ ends_in_days: pickEndsIn,
1120
+ expiring_soon: endsSoon(pickEndsIn),
1079
1121
  prep: first && first.step.worker.prep ? rosterJobLabel(first.step.worker.prep) : null,
1080
1122
  backup_prep: good[1] && good[1].step.worker.prep ? rosterJobLabel(good[1].step.worker.prep) : null,
1081
1123
  runs: runs || nowRuns,
1082
1124
  backup_runs: runsOf(good[1]),
1083
1125
  now_runs: nowRuns,
1084
- workers: walk.map((step, index) => workerRow(step, index, leadIndex)),
1126
+ workers: walk.map((step, index) => workerRow(step, index, leadIndex, now)),
1085
1127
  lead,
1086
1128
  status,
1087
1129
  ...(status === 'cooling' ? { cooling: first.step.cooling.text } : {}),
@@ -1114,8 +1156,11 @@ function rosterReport(root = process.cwd(), now = new Date()) {
1114
1156
  const state = readRosterState(root, { now });
1115
1157
  const jobs = jobRosterView(root, now, state);
1116
1158
  const team = teamRosterView(root, { now, rosterState: state });
1159
+ const expiring = expiringWorkers(jobs);
1117
1160
  return {
1118
1161
  jobs,
1162
+ expiring,
1163
+ renew_command: expiring.length ? RENEW_COMMAND : null,
1119
1164
  team: team.rows,
1120
1165
  warnings: [...state.session.warnings, ...state.project.warnings, ...state.machine.warnings, ...team.warnings],
1121
1166
  files: {
@@ -1127,75 +1172,203 @@ function rosterReport(root = process.cwd(), now = new Date()) {
1127
1172
  };
1128
1173
  }
1129
1174
 
1130
- function untilText(worker) {
1175
+ // Every worker, in any job, whose end date is today or within the next
1176
+ // seven days. Workers already past their date show on their own lines. A
1177
+ // job set for this shell only is skipped: roster confirm does not renew it.
1178
+ function expiringWorkers(jobs) {
1179
+ const list = [];
1180
+ for (const row of jobs || []) {
1181
+ if (row.session_pick) continue;
1182
+ for (const worker of row.workers || []) {
1183
+ if (!worker.expiring_soon) continue;
1184
+ list.push({
1185
+ job: row.job,
1186
+ engine: worker.engine,
1187
+ model: worker.model,
1188
+ tool: worker.runs ? worker.runs.text : worker.engine,
1189
+ until: worker.until,
1190
+ ends_in_days: worker.ends_in_days,
1191
+ from: row.from,
1192
+ });
1193
+ }
1194
+ }
1195
+ return list;
1196
+ }
1197
+
1198
+ function untilText(worker, days = null) {
1131
1199
  const until = parseRosterUntil(worker.until);
1132
- if (until) return `until ${until.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }).toLowerCase()}`;
1200
+ if (until) return `until ${shortDay(worker.until)}${endsSoon(days) ? `, ${endsInText(days)}` : ''}`;
1133
1201
  return worker.never_expires ? 'no end date' : 'until no valid date';
1134
1202
  }
1135
1203
 
1136
- // Every worker under its job, numbered in order, when the job has more than
1137
- // the lead and one backup (the job's own line already shows those two).
1138
- function renderWorkerLines(row) {
1139
- if (!row.workers || row.workers.length <= 2) return [];
1140
- const width = Math.max(...row.workers.map((worker) => (worker.runs ? worker.runs.text : worker.text || '').length));
1141
- return row.workers.map((worker, index) => {
1142
- const what = (worker.runs ? worker.runs.text : worker.text || '').padEnd(width);
1143
- const extras = [worker.max_seconds ? rosterMaxText(worker.max_seconds) : '', worker.prep ? `prepped by ${worker.prep}` : '', worker.until ? `until ${worker.until}` : ''].filter(Boolean);
1144
- const state = worker.status === 'leads' ? 'leads now' : worker.status === 'backup' ? 'backup' : `skipped, ${worker.why}`;
1145
- return ` ${index + 1}. ${what} ${[state, ...extras].join(', ')}`.trimEnd();
1146
- });
1204
+ // One line under the jobs when any worker ends within seven days, naming
1205
+ // each one and the command that keeps it going.
1206
+ function renderExpiringLine(expiring) {
1207
+ if (!expiring || !expiring.length) return '';
1208
+ const names = expiring.map((item) => `${item.tool} on ${item.job} ${endsInText(item.ends_in_days)}`);
1209
+ const them = expiring.length === 1 ? 'it' : 'them';
1210
+ return `heads up: ${names.join(', ')}. to keep ${them} 30 more days, run: ${RENEW_COMMAND}`;
1147
1211
  }
1148
1212
 
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
-
1156
- function renderJobRoster(rows) {
1157
- const width = Math.max(7, ...rows.map((row) => row.job.length));
1158
- const ownerWidth = Math.max(24, ...rows.filter((row) => row.pick && row.runs).map((row) => row.runs.text.length));
1159
- const backupWidth = Math.max(16, ...rows.filter((row) => row.backup_runs).map((row) => row.backup_runs.text.length + 7));
1160
- return rows.map((row) => {
1161
- const label = row.job.padEnd(width);
1162
- const nowText = row.now_runs ? row.now_runs.text : 'no ready engine';
1163
- const fallsTo = `${row.like ? `falls back to ${row.like}` : 'router decides'}: ${nowText}`;
1164
- if (!row.pick) return `${label} no pick, ${fallsTo}`;
1165
- const owner = row.runs.text.padEnd(ownerWidth);
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}, ` : ''}`;
1168
- const date = untilText(row.pick);
1169
- const fallback = row.lead === 'backup' ? 'using backup' : row.lead === 'later' ? `using ${nowText}` : fallsTo;
1170
- const status = row.status === 'expired' ? `expired, ${fallback}`
1171
- : row.status === 'cooling' ? `${row.cooling}, ${fallback}`
1172
- : row.status === 'not ready' ? `not ready, ${fallback}`
1173
- : date;
1174
- const where = row.file ? `${row.from} (${row.file})` : row.from;
1175
- const head = `${label} ${owner} ${backup.padEnd(backupWidth)} ${cap}${status}, ${where}`.trimEnd();
1176
- const suggestion = row.suggestion ? [` suggestion: ${row.suggestion.text}`] : [];
1177
- return [head, ...renderWorkerLines(row), ...renderWorkerRecords(row), ...suggestion].join('\n');
1178
- }).join('\n');
1213
+ // The same heads-up, short enough for one boot line.
1214
+ function bootExpiringLine(expiring) {
1215
+ if (!expiring || !expiring.length) return '';
1216
+ const [first] = expiring;
1217
+ const more = expiring.length > 1 ? ` and ${expiring.length - 1} more` : '';
1218
+ return `${first.tool} on ${first.job} ${endsInText(first.ends_in_days)}${more}. renew: ${RENEW_COMMAND}`;
1219
+ }
1220
+
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) : '';
1229
+ }
1230
+
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
+ ];
1179
1311
  }
1180
1312
 
1181
- function renderTeamRoster(rows) {
1313
+ function renderJobRoster(rows, { width, heading = true } = {}) {
1182
1314
  if (!rows.length) return '';
1183
- const width = Math.max(6, ...rows.map((row) => row.member.length));
1184
- const jobWidth = Math.max(6, ...rows.map((row) => String(row.job || '').length));
1185
- const lines = rows.map((row) => {
1186
- const engine = row.engine ? engineRunsView(row.engine, { model: row.model || '', effort: row.effort || '' }).text : 'no ready engine';
1187
- const how = row.source === 'file' ? `from ${row.file}` : 'automatic';
1188
- 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;
1189
1360
  });
1190
- return ['team', ...lines].join('\n');
1361
+ return ['team', renderTable(columns, cells, { width })].join('\n');
1191
1362
  }
1192
1363
 
1193
1364
  function renderRosterWarnings(warnings) {
1194
1365
  return warnings.map((warning) => `warning: ${warning.file} line ${warning.line} "${warning.text}" ${warning.message}.`).join('\n');
1195
1366
  }
1196
1367
 
1197
- function renderRosterReport(report) {
1198
- return [renderJobRoster(report.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)]
1199
1372
  .filter(Boolean)
1200
1373
  .join('\n\n');
1201
1374
  }
@@ -1204,8 +1377,6 @@ function printJobRoster(root, now = new Date()) {
1204
1377
  console.log(renderRosterReport(rosterReport(root, now)));
1205
1378
  }
1206
1379
 
1207
- const AVAILABLE_HINT = 'see which tools and models this machine has: atris engine roster --available';
1208
-
1209
1380
  // Each installed tool and the models it offers, from local files only.
1210
1381
  function availableReport(root) {
1211
1382
  const engines = readEngineRegistry(root, { persist: false }).engines
@@ -1226,7 +1397,7 @@ function renderSessionRoster(report, key) {
1226
1397
  const team = report.team.filter((row) => row.source === 'file' && row.file === report.files.session);
1227
1398
  if (!jobs.length && !team.length) return `this session (${key}) has no roster changes. add one with: atris engine assign <job> <tool> --session`;
1228
1399
  const head = `this session (${key}) changes${report.files.session ? `, from ${report.files.session}` : ''}`;
1229
- 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');
1230
1401
  }
1231
1402
 
1232
1403
  function runRosterSessionCommand(rest, json, root, now) {
@@ -1275,6 +1446,38 @@ function attachRunRecords(report, root, now = new Date()) {
1275
1446
  return report;
1276
1447
  }
1277
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
+
1278
1481
  const RUNS_LIST_DAYS = 30;
1279
1482
  const RUNS_LIST_LIMIT = 20;
1280
1483
 
@@ -1321,9 +1524,9 @@ function runRosterCommand(args, root, now = new Date()) {
1321
1524
  }
1322
1525
  if (rest[0] === 'session') return runRosterSessionCommand(rest, json, root, now);
1323
1526
  if (rest[0] === 'confirm') confirmRoster(root, now);
1324
- const report = attachRunRecords(rosterReport(root, now), root, now);
1527
+ const report = attachTeamActivity(attachStaleCheck(attachRunRecords(rosterReport(root, now), root, now), root, now), root, now);
1325
1528
  if (json) console.log(JSON.stringify(report, null, 2));
1326
- else console.log(`${renderRosterReport(report)}\n\n${AVAILABLE_HINT}`);
1529
+ else console.log(`${renderRosterReport(report)}\n\n${TEAM_FOOTER.join('\n')}`);
1327
1530
  return 0;
1328
1531
  }
1329
1532
 
@@ -1530,9 +1733,30 @@ function parseSetFlag(args) {
1530
1733
  return '';
1531
1734
  }
1532
1735
 
1533
- function runResolveCommand(args, root) {
1736
+ // One worker as a person reads it: the tool, then its model and effort.
1737
+ function notesWorkerText(worker) {
1738
+ const details = [worker.model, worker.effort ? `effort ${worker.effort}` : '', worker.installed ? '' : 'not installed'].filter(Boolean);
1739
+ return details.length ? `${worker.engine} (${details.join(', ')})` : worker.engine;
1740
+ }
1741
+
1742
+ // The notes job: the roster's pick when one is set, else the built-in
1743
+ // default. Always answers, so scripts can call it with a short timeout.
1744
+ function runNotesResolve(json, root, now) {
1745
+ const answer = resolveNotesJob(root, { now });
1746
+ if (json) {
1747
+ console.log(JSON.stringify(answer));
1748
+ return 0;
1749
+ }
1750
+ const backup = answer.backup.length ? `backup ${answer.backup.map(notesWorkerText).join(', then ')}` : 'no backup';
1751
+ const where = answer.source === 'roster' ? `from ${answer.from}` : `built-in default${answer.skipped ? ` (${answer.skipped})` : ''}`;
1752
+ console.log(`notes: ${notesWorkerText(answer)}, ${backup}, ${where}`);
1753
+ return 0;
1754
+ }
1755
+
1756
+ function runResolveCommand(args, root, now) {
1534
1757
  const json = args.includes('--json');
1535
1758
  const role = args.filter((a) => !String(a).startsWith('--'))[0] || '';
1759
+ if (role && rosterJobKey(role) === 'notes') return runNotesResolve(json, root, now);
1536
1760
  if (!role) {
1537
1761
  const message = `usage: atris engine resolve <role> or <job>; roles: ${ENGINE_ROLES.join(', ')}; jobs: search, build, review, or your own`;
1538
1762
  if (json) console.log(JSON.stringify({ ok: false, error: message }, null, 2));
@@ -2133,7 +2357,7 @@ function engineCommand(args = [], deps = {}) {
2133
2357
  }
2134
2358
 
2135
2359
  if (sub === 'resolve') {
2136
- return runResolveCommand(scope.args.slice(scope.args.indexOf('resolve') + 1), root);
2360
+ return runResolveCommand(scope.args.slice(scope.args.indexOf('resolve') + 1), root, deps.now);
2137
2361
  }
2138
2362
 
2139
2363
  if (sub === 'health') {
@@ -2160,7 +2384,7 @@ function engineCommand(args = [], deps = {}) {
2160
2384
  if (sub === 'help' || args.includes('--help') || args.includes('-h')) {
2161
2385
  console.log('\n atris engine watch [<id>|latest] [--no-follow]\n follow one live transcript or list running engine work');
2162
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');
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');
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');
2164
2388
  return 0;
2165
2389
  }
2166
2390
  if (isFreshWorkspace(root)) {
@@ -2209,7 +2433,10 @@ module.exports = {
2209
2433
  readSavedEngine,
2210
2434
  roster,
2211
2435
  rosterReport,
2436
+ renderRosterReport,
2212
2437
  jobRosterView,
2438
+ expiringWorkers,
2439
+ bootExpiringLine,
2213
2440
  engineRegistryFile,
2214
2441
  readEngineRegistry,
2215
2442
  resolveEngineForRole,
@@ -17,6 +17,7 @@
17
17
 
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
+ const { stateHome } = require('../lib/state-home');
20
21
  const { loadCredentials } = require('../utils/auth');
21
22
  const { apiRequestJson } = require('../utils/api');
22
23
  const {
@@ -297,8 +298,7 @@ function resolveBusinessArg(value) {
297
298
  // Full UUID, trust it
298
299
  if (/^[0-9a-f-]{32,}$/i.test(value)) return value;
299
300
  // Otherwise treat as slug and look up in ~/.atris/businesses.json
300
- const home = require('os').homedir();
301
- const file = path.join(home, '.atris', 'businesses.json');
301
+ const file = path.join(stateHome(), '.atris', 'businesses.json');
302
302
  if (!fs.existsSync(file)) return null;
303
303
  try {
304
304
  const map = JSON.parse(fs.readFileSync(file, 'utf8'));
package/commands/fork.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const os = require('os');
3
+ const { stateHome } = require('../lib/state-home');
4
4
  const { loadCredentials } = require('../utils/auth');
5
5
  const { apiRequestJson } = require('../utils/api');
6
6
 
@@ -48,7 +48,7 @@ async function forkAtris() {
48
48
  files = downloadResult.data.files;
49
49
  } else {
50
50
  // Fall back to local template at ~/.atris/templates/{template}/
51
- const localTemplatePath = path.join(os.homedir(), '.atris', 'templates', template);
51
+ const localTemplatePath = path.join(stateHome(), '.atris', 'templates', template);
52
52
  if (fs.existsSync(localTemplatePath)) {
53
53
  console.log(' API unavailable, using local template...');
54
54
  files = readLocalTemplate(localTemplatePath, localTemplatePath);
package/commands/gm.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
+ const { stateHome } = require('../lib/state-home');
6
7
  const { hasFlag: hasExactFlag } = require('../lib/arg-parser');
7
8
 
8
9
  const AGENTXP_LEADERBOARD_URL = 'https://api.atris.ai/api/agentxp/leaderboard';
@@ -452,7 +453,7 @@ function feedPostId(post) {
452
453
  }
453
454
 
454
455
  function credentialsFileExists() {
455
- return fs.existsSync(path.join(os.homedir(), '.atris', 'credentials.json'));
456
+ return fs.existsSync(path.join(stateHome(), '.atris', 'credentials.json'));
456
457
  }
457
458
 
458
459
  async function fetchFeedEvents(workspaceRoot) {