atris 3.63.0 → 3.64.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 (65) hide show
  1. package/atris/skills/commerce/SKILL.md +24 -0
  2. package/atris/skills/x-search/SKILL.md +3 -1
  3. package/ax +4 -3
  4. package/bin/atris.js +11 -2
  5. package/cli/atris_code.py +25 -1
  6. package/commands/auth.js +79 -10
  7. package/commands/autoland.js +11 -1
  8. package/commands/browse.js +2 -2
  9. package/commands/business.js +3 -3
  10. package/commands/buy.js +3 -3
  11. package/commands/deck.js +2 -2
  12. package/commands/engine.js +122 -13
  13. package/commands/feedback.js +2 -2
  14. package/commands/fork.js +2 -2
  15. package/commands/gm.js +2 -1
  16. package/commands/improve.js +142 -8
  17. package/commands/integrations.js +4 -3
  18. package/commands/loops.js +2 -1
  19. package/commands/member.js +47 -9
  20. package/commands/mission.js +1 -1
  21. package/commands/pack-craft.js +2 -2
  22. package/commands/publish.js +2 -2
  23. package/commands/radar.js +3 -2
  24. package/commands/router.js +2 -2
  25. package/commands/task.js +84 -2
  26. package/commands/transactions.js +3 -3
  27. package/commands/truth.js +2 -2
  28. package/commands/x-search.js +81 -10
  29. package/commands/youtube.js +2 -1
  30. package/lib/autoland.js +3 -2
  31. package/lib/ax-auto-lane.js +2 -2
  32. package/lib/brief-rules.js +11 -0
  33. package/lib/chat-log-scan.js +2 -1
  34. package/lib/ci-runner.js +3 -2
  35. package/lib/codex-flight.js +2 -1
  36. package/lib/commerce.js +17 -2
  37. package/lib/deck-history.js +2 -2
  38. package/lib/deck-review.js +3 -3
  39. package/lib/design-api.js +2 -2
  40. package/lib/engine-registry.js +85 -18
  41. package/lib/first-minute.js +2 -2
  42. package/lib/fleet.js +8 -36
  43. package/lib/git-spawn.js +5 -2
  44. package/lib/ignored-cache.js +51 -0
  45. package/lib/manifest.js +2 -2
  46. package/lib/map-refs.js +70 -4
  47. package/lib/pack-capabilities.js +2 -1
  48. package/lib/permission-grants.js +2 -2
  49. package/lib/pulse.js +5 -5
  50. package/lib/roster-runs.js +13 -2
  51. package/lib/runner-command.js +22 -6
  52. package/lib/sandbox-pids.js +113 -0
  53. package/lib/secret-gateway.js +3 -13
  54. package/lib/state-home.js +106 -0
  55. package/lib/task-db.js +4 -3
  56. package/lib/task-prune.js +462 -0
  57. package/lib/x-post-check.js +497 -0
  58. package/package.json +1 -1
  59. package/scripts/det/data/xsearch-answer.json +24 -0
  60. package/scripts/det/data/xsearch-live-2026-09-29.json +93 -0
  61. package/scripts/det/xsearch-bench.js +307 -0
  62. package/scripts/det/ytnotes +61 -15
  63. package/scripts/det/ytrail-bench.js +2 -1
  64. package/utils/auth.js +3 -3
  65. package/utils/update-check.js +2 -2
@@ -69,6 +69,8 @@ const {
69
69
  readEngineRegistry,
70
70
  resolveEngineForRole,
71
71
  resolveEngineForRoleRanked,
72
+ resolveNotesJob,
73
+ jobTakesAnyEngine,
72
74
  requireEngineBin,
73
75
  engineDoctorReport,
74
76
  engineFailureHealthStatus,
@@ -1000,10 +1002,41 @@ function rosterPickEntry(value) {
1000
1002
 
1001
1003
  const WORKER_SKIP_WORDS = Object.freeze({ 'not ready': 'down', expired: 'expired', 'bad model': 'names a model it cannot run' });
1002
1004
 
1005
+ // A worker whose end date is this many days away or fewer gets a heads-up.
1006
+ const ENDS_SOON_DAYS = 7;
1007
+ const RENEW_COMMAND = 'atris engine roster confirm';
1008
+
1009
+ // Whole local days from today to a worker's end date: 0 on its last day,
1010
+ // negative once it has ended, null when it has no valid end date.
1011
+ function endsInDays(until, now = new Date()) {
1012
+ const end = parseRosterUntil(until);
1013
+ if (!end) return null;
1014
+ const date = new Date(now);
1015
+ if (Number.isNaN(date.getTime())) return null;
1016
+ const today = new Date(date.getFullYear(), date.getMonth(), date.getDate());
1017
+ return Math.round((end.getTime() - today.getTime()) / 86400000);
1018
+ }
1019
+
1020
+ function endsSoon(days) {
1021
+ return days !== null && days >= 0 && days <= ENDS_SOON_DAYS;
1022
+ }
1023
+
1024
+ function endsInText(days) {
1025
+ if (days === 0) return 'ends today';
1026
+ if (days === 1) return 'ends tomorrow';
1027
+ return `ends in ${days} days`;
1028
+ }
1029
+
1030
+ function shortDay(until) {
1031
+ const date = parseRosterUntil(until);
1032
+ return date ? date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }).toLowerCase() : '';
1033
+ }
1034
+
1003
1035
  // One worker of a job's team as the view shows it: what it runs, and whether
1004
1036
  // it leads now, backs up, or is skipped and why.
1005
- function workerRow(step, index, leadIndex) {
1037
+ function workerRow(step, index, leadIndex, now = new Date()) {
1006
1038
  const { worker } = step;
1039
+ const endsIn = endsInDays(worker.until, now);
1007
1040
  const runs = worker.error ? null : engineRunsView(worker.engine, { model: worker.model || '', effort: worker.effort || '' });
1008
1041
  const status = index === leadIndex ? 'leads' : step.skip ? 'skipped' : 'backup';
1009
1042
  const why = step.skip === 'bad line' ? `bad line: ${worker.error}`
@@ -1016,6 +1049,8 @@ function workerRow(step, index, leadIndex) {
1016
1049
  max_seconds: Number(worker.max_seconds) > 0 ? Number(worker.max_seconds) : null,
1017
1050
  prep: worker.prep ? rosterJobLabel(worker.prep) : null,
1018
1051
  until: worker.until || null,
1052
+ ends_in_days: endsIn,
1053
+ expiring_soon: endsSoon(endsIn),
1019
1054
  line: worker.line || null,
1020
1055
  ...(worker.error ? { text: worker.text || '' } : {}),
1021
1056
  status,
@@ -1041,7 +1076,7 @@ function jobRosterRow(job, role, root, state, registry, now, key = role) {
1041
1076
  const decided = rosterDecided(resolved.source) && (!custom || resolved.job === job);
1042
1077
  const source = decided ? resolved.source : ROSTER_LAYERS.find((scope) => picks[scope]) || null;
1043
1078
  const pick = source ? picks[source] : null;
1044
- const walk = pick ? rosterWorkerWalk(pick, role, registry, now) : [];
1079
+ const walk = pick ? rosterWorkerWalk(pick, role, registry, now, { anyEngine: jobTakesAnyEngine(key) }) : [];
1045
1080
  const leadIndex = decided && resolved.source === source ? walk.findIndex((step) => !step.skip) : -1;
1046
1081
  const good = walk.map((step, index) => ({ step, index })).filter(({ step }) => !step.worker.error);
1047
1082
  const first = good[0] || null;
@@ -1055,6 +1090,7 @@ function jobRosterRow(job, role, root, state, registry, now, key = role) {
1055
1090
  : good[1] && leadIndex === good[1].index ? 'backup'
1056
1091
  : 'later';
1057
1092
  const kind = Object.keys(ENGINE_JOBS).find((name) => ENGINE_JOBS[name] === role);
1093
+ const pickEndsIn = pick ? endsInDays(pick.until, now) : null;
1058
1094
  // What the job really runs: its first two good workers, each with its own
1059
1095
  // model and effort, or the router's engine when no line decides.
1060
1096
  const runsOf = (entry) => (entry ? engineRunsView(entry.step.worker.engine, { model: entry.step.worker.model || '', effort: entry.step.worker.effort || '' }) : null);
@@ -1076,12 +1112,14 @@ function jobRosterRow(job, role, root, state, registry, now, key = role) {
1076
1112
  model: resolved.engine && resolved.engine.roster_model ? resolved.engine.roster_model : null,
1077
1113
  effort: resolved.engine && resolved.engine.roster_effort ? resolved.engine.roster_effort : null,
1078
1114
  max_seconds: pick && Number(pick.max_seconds) > 0 ? Number(pick.max_seconds) : null,
1115
+ ends_in_days: pickEndsIn,
1116
+ expiring_soon: endsSoon(pickEndsIn),
1079
1117
  prep: first && first.step.worker.prep ? rosterJobLabel(first.step.worker.prep) : null,
1080
1118
  backup_prep: good[1] && good[1].step.worker.prep ? rosterJobLabel(good[1].step.worker.prep) : null,
1081
1119
  runs: runs || nowRuns,
1082
1120
  backup_runs: runsOf(good[1]),
1083
1121
  now_runs: nowRuns,
1084
- workers: walk.map((step, index) => workerRow(step, index, leadIndex)),
1122
+ workers: walk.map((step, index) => workerRow(step, index, leadIndex, now)),
1085
1123
  lead,
1086
1124
  status,
1087
1125
  ...(status === 'cooling' ? { cooling: first.step.cooling.text } : {}),
@@ -1114,8 +1152,11 @@ function rosterReport(root = process.cwd(), now = new Date()) {
1114
1152
  const state = readRosterState(root, { now });
1115
1153
  const jobs = jobRosterView(root, now, state);
1116
1154
  const team = teamRosterView(root, { now, rosterState: state });
1155
+ const expiring = expiringWorkers(jobs);
1117
1156
  return {
1118
1157
  jobs,
1158
+ expiring,
1159
+ renew_command: expiring.length ? RENEW_COMMAND : null,
1119
1160
  team: team.rows,
1120
1161
  warnings: [...state.session.warnings, ...state.project.warnings, ...state.machine.warnings, ...team.warnings],
1121
1162
  files: {
@@ -1127,12 +1168,52 @@ function rosterReport(root = process.cwd(), now = new Date()) {
1127
1168
  };
1128
1169
  }
1129
1170
 
1130
- function untilText(worker) {
1171
+ // Every worker, in any job, whose end date is today or within the next
1172
+ // seven days. Workers already past their date show on their own lines. A
1173
+ // job set for this shell only is skipped: roster confirm does not renew it.
1174
+ function expiringWorkers(jobs) {
1175
+ const list = [];
1176
+ for (const row of jobs || []) {
1177
+ if (row.session_pick) continue;
1178
+ for (const worker of row.workers || []) {
1179
+ if (!worker.expiring_soon) continue;
1180
+ list.push({
1181
+ job: row.job,
1182
+ engine: worker.engine,
1183
+ model: worker.model,
1184
+ tool: worker.runs ? worker.runs.text : worker.engine,
1185
+ until: worker.until,
1186
+ ends_in_days: worker.ends_in_days,
1187
+ from: row.from,
1188
+ });
1189
+ }
1190
+ }
1191
+ return list;
1192
+ }
1193
+
1194
+ function untilText(worker, days = null) {
1131
1195
  const until = parseRosterUntil(worker.until);
1132
- if (until) return `until ${until.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }).toLowerCase()}`;
1196
+ if (until) return `until ${shortDay(worker.until)}${endsSoon(days) ? `, ${endsInText(days)}` : ''}`;
1133
1197
  return worker.never_expires ? 'no end date' : 'until no valid date';
1134
1198
  }
1135
1199
 
1200
+ // One line under the jobs when any worker ends within seven days, naming
1201
+ // each one and the command that keeps it going.
1202
+ function renderExpiringLine(expiring) {
1203
+ if (!expiring || !expiring.length) return '';
1204
+ const names = expiring.map((item) => `${item.tool} on ${item.job} ${endsInText(item.ends_in_days)}`);
1205
+ const them = expiring.length === 1 ? 'it' : 'them';
1206
+ return `heads up: ${names.join(', ')}. to keep ${them} 30 more days, run: ${RENEW_COMMAND}`;
1207
+ }
1208
+
1209
+ // The same heads-up, short enough for one boot line.
1210
+ function bootExpiringLine(expiring) {
1211
+ if (!expiring || !expiring.length) return '';
1212
+ const [first] = expiring;
1213
+ const more = expiring.length > 1 ? ` and ${expiring.length - 1} more` : '';
1214
+ return `${first.tool} on ${first.job} ${endsInText(first.ends_in_days)}${more}. renew: ${RENEW_COMMAND}`;
1215
+ }
1216
+
1136
1217
  // Every worker under its job, numbered in order, when the job has more than
1137
1218
  // the lead and one backup (the job's own line already shows those two).
1138
1219
  function renderWorkerLines(row) {
@@ -1140,8 +1221,11 @@ function renderWorkerLines(row) {
1140
1221
  const width = Math.max(...row.workers.map((worker) => (worker.runs ? worker.runs.text : worker.text || '').length));
1141
1222
  return row.workers.map((worker, index) => {
1142
1223
  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}`;
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}`;
1145
1229
  return ` ${index + 1}. ${what} ${[state, ...extras].join(', ')}`.trimEnd();
1146
1230
  });
1147
1231
  }
@@ -1165,9 +1249,10 @@ function renderJobRoster(rows) {
1165
1249
  const owner = row.runs.text.padEnd(ownerWidth);
1166
1250
  const backup = row.backup_runs ? `backup ${row.backup_runs.text}${row.backup_prep ? ` prepped by ${row.backup_prep}` : ''}` : 'no backup';
1167
1251
  const cap = `${row.max_seconds ? `${rosterMaxText(row.max_seconds)}, ` : ''}${row.prep ? `prepped by ${row.prep}, ` : ''}`;
1168
- const date = untilText(row.pick);
1252
+ const date = untilText(row.pick, row.ends_in_days);
1169
1253
  const fallback = row.lead === 'backup' ? 'using backup' : row.lead === 'later' ? `using ${nowText}` : fallsTo;
1170
- const status = row.status === 'expired' ? `expired, ${fallback}`
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}`
1171
1256
  : row.status === 'cooling' ? `${row.cooling}, ${fallback}`
1172
1257
  : row.status === 'not ready' ? `not ready, ${fallback}`
1173
1258
  : date;
@@ -1195,7 +1280,8 @@ function renderRosterWarnings(warnings) {
1195
1280
  }
1196
1281
 
1197
1282
  function renderRosterReport(report) {
1198
- return [renderJobRoster(report.jobs), renderTeamRoster(report.team), renderRosterWarnings(report.warnings)]
1283
+ const jobs = [renderJobRoster(report.jobs), renderExpiringLine(report.expiring)].filter(Boolean).join('\n');
1284
+ return [jobs, renderTeamRoster(report.team), renderRosterWarnings(report.warnings)]
1199
1285
  .filter(Boolean)
1200
1286
  .join('\n\n');
1201
1287
  }
@@ -1530,9 +1616,30 @@ function parseSetFlag(args) {
1530
1616
  return '';
1531
1617
  }
1532
1618
 
1533
- function runResolveCommand(args, root) {
1619
+ // One worker as a person reads it: the tool, then its model and effort.
1620
+ function notesWorkerText(worker) {
1621
+ const details = [worker.model, worker.effort ? `effort ${worker.effort}` : '', worker.installed ? '' : 'not installed'].filter(Boolean);
1622
+ return details.length ? `${worker.engine} (${details.join(', ')})` : worker.engine;
1623
+ }
1624
+
1625
+ // The notes job: the roster's pick when one is set, else the built-in
1626
+ // default. Always answers, so scripts can call it with a short timeout.
1627
+ function runNotesResolve(json, root, now) {
1628
+ const answer = resolveNotesJob(root, { now });
1629
+ if (json) {
1630
+ console.log(JSON.stringify(answer));
1631
+ return 0;
1632
+ }
1633
+ const backup = answer.backup.length ? `backup ${answer.backup.map(notesWorkerText).join(', then ')}` : 'no backup';
1634
+ const where = answer.source === 'roster' ? `from ${answer.from}` : `built-in default${answer.skipped ? ` (${answer.skipped})` : ''}`;
1635
+ console.log(`notes: ${notesWorkerText(answer)}, ${backup}, ${where}`);
1636
+ return 0;
1637
+ }
1638
+
1639
+ function runResolveCommand(args, root, now) {
1534
1640
  const json = args.includes('--json');
1535
1641
  const role = args.filter((a) => !String(a).startsWith('--'))[0] || '';
1642
+ if (role && rosterJobKey(role) === 'notes') return runNotesResolve(json, root, now);
1536
1643
  if (!role) {
1537
1644
  const message = `usage: atris engine resolve <role> or <job>; roles: ${ENGINE_ROLES.join(', ')}; jobs: search, build, review, or your own`;
1538
1645
  if (json) console.log(JSON.stringify({ ok: false, error: message }, null, 2));
@@ -2133,7 +2240,7 @@ function engineCommand(args = [], deps = {}) {
2133
2240
  }
2134
2241
 
2135
2242
  if (sub === 'resolve') {
2136
- return runResolveCommand(scope.args.slice(scope.args.indexOf('resolve') + 1), root);
2243
+ return runResolveCommand(scope.args.slice(scope.args.indexOf('resolve') + 1), root, deps.now);
2137
2244
  }
2138
2245
 
2139
2246
  if (sub === 'health') {
@@ -2160,7 +2267,7 @@ function engineCommand(args = [], deps = {}) {
2160
2267
  if (sub === 'help' || args.includes('--help') || args.includes('-h')) {
2161
2268
  console.log('\n atris engine watch [<id>|latest] [--no-follow]\n follow one live transcript or list running engine work');
2162
2269
  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');
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');
2164
2271
  return 0;
2165
2272
  }
2166
2273
  if (isFreshWorkspace(root)) {
@@ -2210,6 +2317,8 @@ module.exports = {
2210
2317
  roster,
2211
2318
  rosterReport,
2212
2319
  jobRosterView,
2320
+ expiringWorkers,
2321
+ bootExpiringLine,
2213
2322
  engineRegistryFile,
2214
2323
  readEngineRegistry,
2215
2324
  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) {
@@ -18,10 +18,13 @@
18
18
  * fallback, and scorecard writes can all be faked in tests.
19
19
  */
20
20
 
21
+ const crypto = require('crypto');
21
22
  const fs = require('fs');
22
23
  const os = require('os');
23
24
  const path = require('path');
24
25
  const { spawnSync } = require('child_process');
26
+ const gitSpawn = require('../lib/git-spawn');
27
+ const ignoredCache = require('../lib/ignored-cache');
25
28
  const { apiRequestJson, getApiBaseUrl } = require('../utils/api');
26
29
  const { loadCredentials } = require('../utils/auth');
27
30
  const pulse = require('../lib/pulse');
@@ -1085,8 +1088,9 @@ function parseRevisionsArgs(argv = []) {
1085
1088
  return opts;
1086
1089
  }
1087
1090
 
1088
- function gitLines(cwdRoot, gitArgs) {
1089
- const r = spawnSync('git', gitArgs, { cwd: cwdRoot, encoding: 'utf8' });
1091
+ // Through the module object so tests can count git spawns with a spy.
1092
+ function gitLines(cwdRoot, gitArgs, { input, maxBuffer } = {}) {
1093
+ const r = gitSpawn.runGit(gitArgs, { cwd: cwdRoot, check: false, input, maxBuffer });
1090
1094
  if (r.status !== 0) {
1091
1095
  const err = new Error(String(r.stderr || `git ${gitArgs[0]} exited ${r.status}`).trim());
1092
1096
  err.gitFailed = true;
@@ -1095,17 +1099,129 @@ function gitLines(cwdRoot, gitArgs) {
1095
1099
  return String(r.stdout || '');
1096
1100
  }
1097
1101
 
1102
+ // File names come back NUL-separated and never quoted, whatever core.quotePath
1103
+ // says, so a name reads the same fresh from git and from the saved cache.
1104
+ const NAME_ARGS = ['-c', 'core.quotePath=false'];
1105
+
1106
+ function splitNames(out) {
1107
+ return String(out || '').split('\0').filter(Boolean);
1108
+ }
1109
+
1098
1110
  /**
1099
1111
  * Files changed by one commit. Merge commits are attributed by their
1100
1112
  * first-parent diff (what the merge actually brought onto the mainline);
1101
1113
  * plain commits use diff-tree. Root commits list their initial files.
1114
+ * The rename choice is spelled out so a user's diff.renames setting cannot
1115
+ * change the lists: merges find renames (git diff's default), plain commits
1116
+ * do not (diff-tree's default).
1102
1117
  */
1103
1118
  function commitFiles(cwdRoot, commit) {
1104
1119
  const parents = commit.parents;
1105
1120
  const out = parents.length >= 2
1106
- ? gitLines(cwdRoot, ['diff', '--name-only', `${commit.hash}^1`, commit.hash])
1107
- : gitLines(cwdRoot, ['diff-tree', '--no-commit-id', '--name-only', '-r', '--root', commit.hash]);
1108
- return out.split('\n').map((line) => line.trim()).filter(Boolean);
1121
+ ? gitLines(cwdRoot, [...NAME_ARGS, 'diff', '--name-only', '--find-renames', '-z', `${commit.hash}^1`, commit.hash])
1122
+ : gitLines(cwdRoot, [...NAME_ARGS, 'diff-tree', '--no-commit-id', '--name-only', '--no-renames', '-z', '-r', '--root', commit.hash]);
1123
+ return splitNames(out);
1124
+ }
1125
+
1126
+ /**
1127
+ * Files changed by many plain commits in one git process: the same diff-tree
1128
+ * per-commit call above, fed every hash on stdin. --always prints the hash
1129
+ * even for an empty commit. Raw output frames every file as a ':'-led status
1130
+ * field followed by its path, so a path is never mistaken for a commit hash,
1131
+ * even a path spelled exactly like the next one.
1132
+ */
1133
+ function plainCommitFiles(cwdRoot, commits) {
1134
+ const found = new Map(commits.map((commit) => [commit.hash, []]));
1135
+ if (!commits.length) return found;
1136
+ const hashes = commits.map((commit) => commit.hash);
1137
+ const out = gitLines(cwdRoot, [...NAME_ARGS, 'diff-tree', '--stdin', '--always', '--raw', '--no-renames', '-z', '-r', '--root'], {
1138
+ input: `${hashes.join('\n')}\n`,
1139
+ // Many commits' lists in one reply; the per-commit calls each had 1MB.
1140
+ maxBuffer: Math.max(1, hashes.length) * 1024 * 1024,
1141
+ });
1142
+ const fields = splitNames(out);
1143
+ let current = null;
1144
+ for (let i = 0; i < fields.length; i++) {
1145
+ const field = fields[i];
1146
+ if (field.startsWith(':')) {
1147
+ // Status field; with --no-renames exactly one path follows it.
1148
+ if (current && i + 1 < fields.length) current.push(fields[i + 1]);
1149
+ i += 1;
1150
+ } else {
1151
+ current = found.get(field) || null;
1152
+ }
1153
+ }
1154
+ return found;
1155
+ }
1156
+
1157
+ // Where git keeps state shared by every checkout of this repo (a linked
1158
+ // worktree points at its main repo). null when it cannot be found on disk.
1159
+ function gitCommonDir(root) {
1160
+ if (process.env.GIT_DIR || process.env.GIT_COMMON_DIR) return null;
1161
+ let dir = path.resolve(root);
1162
+ for (;;) {
1163
+ const dotGit = path.join(dir, '.git');
1164
+ let stat = null;
1165
+ try { stat = fs.statSync(dotGit); } catch { stat = null; }
1166
+ if (stat && stat.isDirectory()) return dotGit;
1167
+ if (stat && stat.isFile()) {
1168
+ const match = fs.readFileSync(dotGit, 'utf8').match(/^gitdir:\s*(.+)$/m);
1169
+ if (!match) return null;
1170
+ const gitDir = path.resolve(dir, match[1].trim());
1171
+ try {
1172
+ return path.resolve(gitDir, fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim());
1173
+ } catch {
1174
+ return gitDir;
1175
+ }
1176
+ }
1177
+ const parent = path.dirname(dir);
1178
+ if (parent === dir) return null;
1179
+ dir = parent;
1180
+ }
1181
+ }
1182
+
1183
+ // A shallow clone cuts history at boundary commits, and those commits list
1184
+ // different files once the history is fetched. The saved lists are only good
1185
+ // for the shallow state they were read under; null means do not cache.
1186
+ function shallowStamp(root) {
1187
+ const common = gitCommonDir(root);
1188
+ if (!common) return null;
1189
+ try {
1190
+ return crypto.createHash('sha1').update(fs.readFileSync(path.join(common, 'shallow'))).digest('hex');
1191
+ } catch (error) {
1192
+ return error.code === 'ENOENT' ? 'full' : null;
1193
+ }
1194
+ }
1195
+
1196
+ // A commit's file list never changes, so it is saved by hash in the ignored
1197
+ // .atris/cache and the gauge (shown on every boot) only asks git about commits
1198
+ // it has not seen. Hashes this read used come first; older ones stay until the
1199
+ // cap, so a 7-day boot read and a 14-day report read do not evict each other.
1200
+ const REVISION_FILES_CACHE = 'revision-files.json';
1201
+ const REVISION_FILES_CACHE_MAX = 5000;
1202
+
1203
+ function loadRevisionFilesCache(root) {
1204
+ const shallow = ignoredCache.cacheEnabled(root) ? shallowStamp(root) : null;
1205
+ const cache = { root, shallow, entries: {}, used: {}, dirty: false, writable: Boolean(shallow) };
1206
+ if (!cache.writable) return cache;
1207
+ const parsed = ignoredCache.readCacheJson(root, REVISION_FILES_CACHE);
1208
+ if (parsed && parsed.version === 1 && parsed.shallow === shallow && parsed.entries && typeof parsed.entries === 'object') {
1209
+ cache.entries = parsed.entries;
1210
+ }
1211
+ return cache;
1212
+ }
1213
+
1214
+ function saveRevisionFilesCache(cache) {
1215
+ if (!cache.writable || !cache.dirty) return;
1216
+ const entries = { ...cache.used };
1217
+ let count = Object.keys(entries).length;
1218
+ for (const [hash, files] of Object.entries(cache.entries)) {
1219
+ if (count >= REVISION_FILES_CACHE_MAX) break;
1220
+ if (entries[hash] || !Array.isArray(files)) continue;
1221
+ entries[hash] = files;
1222
+ count += 1;
1223
+ }
1224
+ ignoredCache.writeCacheJson(cache.root, REVISION_FILES_CACHE, { version: 1, shallow: cache.shallow, entries });
1109
1225
  }
1110
1226
 
1111
1227
  /**
@@ -1150,10 +1266,27 @@ function collectRevisionSignals(root, options = {}) {
1150
1266
  // commits count as revision signals.
1151
1267
  const humans = commits.filter((c) => !c.isAgent && c.parents.length < 2);
1152
1268
 
1153
- const filesCache = new Map();
1269
+ // Every landing's files are needed, and a human commit's only when it falls
1270
+ // inside some landing's window. Plain commits missing from the cache are
1271
+ // read in one batch; merges keep their own first-parent diff.
1272
+ const cache = loadRevisionFilesCache(root);
1273
+ const needed = [...landings, ...humans.filter((human) => landings.some((landing) => human.ms > landing.ms && human.ms <= landing.ms + REVISION_WINDOW_MS))];
1274
+ const missing = [];
1275
+ for (const commit of needed) {
1276
+ const hit = cache.entries[commit.hash];
1277
+ if (Array.isArray(hit)) cache.used[commit.hash] = hit;
1278
+ else if (commit.parents.length < 2) missing.push(commit);
1279
+ }
1280
+ for (const [hash, files] of plainCommitFiles(root, missing)) {
1281
+ cache.used[hash] = files;
1282
+ cache.dirty = true;
1283
+ }
1154
1284
  const filesOf = (commit) => {
1155
- if (!filesCache.has(commit.hash)) filesCache.set(commit.hash, commitFiles(root, commit));
1156
- return filesCache.get(commit.hash);
1285
+ if (!cache.used[commit.hash]) {
1286
+ cache.used[commit.hash] = commitFiles(root, commit);
1287
+ cache.dirty = true;
1288
+ }
1289
+ return cache.used[commit.hash];
1157
1290
  };
1158
1291
 
1159
1292
  const revisions = [];
@@ -1177,6 +1310,7 @@ function collectRevisionSignals(root, options = {}) {
1177
1310
  });
1178
1311
  }
1179
1312
  }
1313
+ saveRevisionFilesCache(cache);
1180
1314
 
1181
1315
  return {
1182
1316
  schema: REVISIONS_SCHEMA,
@@ -33,11 +33,12 @@ const {
33
33
  const fs = require('fs');
34
34
  const os = require('os');
35
35
  const path = require('path');
36
+ const { stateHome } = require('../lib/state-home');
36
37
  const readline = require('readline');
37
38
  const { spawnSync } = require('child_process');
38
39
  const { sleepSync } = require('../lib/sleep-sync');
39
40
 
40
- const CALENDAR_CACHE_PATH = path.join(os.homedir(), '.atris', 'calendar-events-cache.json');
41
+ const CALENDAR_CACHE_PATH = path.join(stateHome(), '.atris', 'calendar-events-cache.json');
41
42
  const GMAIL_CONNECT_POLL_MS = 3000;
42
43
  const GMAIL_CONNECT_TIMEOUT_MS = 3 * 60 * 1000;
43
44
  const GMAIL_SEND_USAGE = 'usage: atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>]';
@@ -182,7 +183,7 @@ function parseGmailVerdictsArgs(args = []) {
182
183
 
183
184
  function gmailAccountStatePath() {
184
185
  return process.env.ATRIS_GMAIL_ACCOUNT_FILE
185
- || path.join(os.homedir(), '.atris', 'gmail-account.json');
186
+ || path.join(stateHome(), '.atris', 'gmail-account.json');
186
187
  }
187
188
 
188
189
  function readGmailStickyAccount() {
@@ -1794,7 +1795,7 @@ function normalizeContactLabel(value) {
1794
1795
  }
1795
1796
 
1796
1797
  function imessageLookupCachePath() {
1797
- return path.join(os.homedir(), '.atris', 'cache', 'imessage-contacts.json');
1798
+ return path.join(stateHome(), '.atris', 'cache', 'imessage-contacts.json');
1798
1799
  }
1799
1800
 
1800
1801
  function readImessageLookupCache() {
package/commands/loops.js CHANGED
@@ -13,6 +13,7 @@
13
13
  const fs = require('fs');
14
14
  const os = require('os');
15
15
  const path = require('path');
16
+ const { stateHome } = require('../lib/state-home');
16
17
  const { execSync, spawnSync } = require('child_process');
17
18
 
18
19
  const MAX_LOG_AGE_DAYS = 14;
@@ -26,7 +27,7 @@ const SCAFFOLD_FILES = [
26
27
  ];
27
28
 
28
29
  function heartbeatDir() {
29
- return process.env.ATRIS_HEARTBEAT_DIR || path.join(os.homedir(), '.atris', 'heartbeat');
30
+ return process.env.ATRIS_HEARTBEAT_DIR || path.join(stateHome(), '.atris', 'heartbeat');
30
31
  }
31
32
 
32
33
  function readJson(filePath, fallback) {