atris 3.61.0 → 3.62.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 (41) hide show
  1. package/README.md +37 -5
  2. package/atris/skills/engines/SKILL.md +9 -9
  3. package/bin/atris.js +1 -0
  4. package/commands/autoland.js +36 -5
  5. package/commands/autopilot.js +105 -6
  6. package/commands/engine.js +96 -21
  7. package/commands/host.js +56 -4
  8. package/commands/land.js +129 -8
  9. package/commands/member.js +33 -8
  10. package/commands/mission.js +112 -3
  11. package/commands/one-lap.js +14 -10
  12. package/commands/stream.js +56 -22
  13. package/commands/team.js +82 -10
  14. package/commands/worktree.js +39 -0
  15. package/commands/youtube.js +36 -26
  16. package/lib/engine-ask.js +112 -9
  17. package/lib/engine-registry.js +283 -39
  18. package/lib/fleet.js +444 -26
  19. package/lib/functional-owner.js +15 -6
  20. package/lib/host.js +296 -170
  21. package/lib/member-engine.js +11 -1
  22. package/lib/member-park.js +138 -0
  23. package/lib/member-scaffold.js +37 -0
  24. package/lib/roster-markdown.js +1 -1
  25. package/lib/roster-prep.js +289 -0
  26. package/lib/roster-runs.js +389 -0
  27. package/lib/roster-suggest.js +145 -0
  28. package/lib/router-brain.js +111 -3
  29. package/lib/self-drive.js +26 -2
  30. package/lib/state-detection.js +3 -3
  31. package/lib/task-db.js +38 -3
  32. package/lib/team-lineup.js +7 -2
  33. package/lib/todo.js +1 -1
  34. package/lib/wish-audit.js +1 -1
  35. package/package.json +1 -1
  36. package/scripts/det/cpu-hotspots.js +216 -0
  37. package/scripts/det/ytnotes +105 -19
  38. package/scripts/det/ytquote-repair.js +18 -4
  39. package/templates/members/host/MEMBER.md +5 -3
  40. package/templates/members/host/MISSION.md +1 -1
  41. package/templates/members/host/skills/host/SKILL.md +64 -16
@@ -11,8 +11,8 @@
11
11
  // atris engine roster confirm renew every dated worker for 30 days
12
12
  // atris engine roster session [clear] this shell's own changes
13
13
  // atris engine roster --available tools and models on this machine
14
- // atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]
15
- // atris engine assign <job> --remove <tool> | --clear [--session | --everywhere]
14
+ // atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--prep <job>] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]
15
+ // atris engine assign <job> --promote "<tool> [model]" | --remove <tool> | --clear [--session | --everywhere]
16
16
  // jobs are open-ended: search, build, review, or your own ("small build")
17
17
  // atris engine cursor make cursor the default engine here
18
18
  // atris engine reset back to the house default (atris-fast)
@@ -36,6 +36,8 @@ const { parseScopeFlag } = require('../lib/cli-scope');
36
36
  const { isFreshWorkspace, speakFirstMinute } = require('../lib/first-minute');
37
37
  const { teamRosterView } = require('../lib/member-engine');
38
38
  const { engineRunsView, availableModels } = require('../lib/roster-models');
39
+ const { readRosterRuns, workerRuns, summarizeRuns, recentRuns, renderRunLine, DEFAULT_DAYS: RUN_DAYS } = require('../lib/roster-runs');
40
+ const { attachSuggestions } = require('../lib/roster-suggest');
39
41
  const {
40
42
  ENGINE_ROLES,
41
43
  ENGINE_JOBS,
@@ -70,6 +72,8 @@ const {
70
72
  requireEngineBin,
71
73
  engineDoctorReport,
72
74
  engineFailureHealthStatus,
75
+ engineReadyAt,
76
+ coolingView,
73
77
  setEngineOverrides,
74
78
  setEngineHealth,
75
79
  } = require('../lib/engine-registry');
@@ -144,7 +148,7 @@ function resolveDefaultEngine(root = process.cwd()) {
144
148
  if (saved) return { name: saved, source: 'saved' };
145
149
  const ready = new Set(
146
150
  engineRegistryView(root)
147
- .filter((engine) => engine.health && engine.health.status === 'ready')
151
+ .filter((engine) => engineReadyAt(engine))
148
152
  .map((engine) => engine.id)
149
153
  );
150
154
  if (ready.has(HOUSE_ENGINE)) return { name: HOUSE_ENGINE, source: 'house' };
@@ -941,7 +945,7 @@ function printRoster(root, { scope = 'workspace' } = {}) {
941
945
  const list = roster(root);
942
946
  const scoped = scope === 'global'
943
947
  ? list
944
- : list.filter((engine) => engine.installed || engine.default || engine.health.status === 'ready');
948
+ : list.filter((engine) => engine.installed || engine.default || ['ready', 'cooling'].includes(engine.health.status));
945
949
  const found = scoped.filter((e) => e.installed).length;
946
950
  const current = resolveDefaultEngine(root);
947
951
  console.log('');
@@ -954,6 +958,7 @@ function printRoster(root, { scope = 'workspace' } = {}) {
954
958
  console.log(` ${mark} ${engine.id.padEnd(12)} ${state.padEnd(13)} ${engine.tier.padEnd(4)} ${roles}`);
955
959
  const details = [`models: ${engine.models.join(', ')}`];
956
960
  if (engine.duty) details.push(`duty: ${engine.duty}`);
961
+ if (engine.health.status === 'cooling') details.push(coolingView(engine).text);
957
962
  console.log(` ${details.join(' ')}`);
958
963
  }
959
964
  if (scope !== 'global' && list.length > scoped.length) {
@@ -1001,17 +1006,21 @@ function workerRow(step, index, leadIndex) {
1001
1006
  const { worker } = step;
1002
1007
  const runs = worker.error ? null : engineRunsView(worker.engine, { model: worker.model || '', effort: worker.effort || '' });
1003
1008
  const status = index === leadIndex ? 'leads' : step.skip ? 'skipped' : 'backup';
1004
- const why = step.skip === 'bad line' ? `bad line: ${worker.error}` : step.skip ? WORKER_SKIP_WORDS[step.skip] : '';
1009
+ const why = step.skip === 'bad line' ? `bad line: ${worker.error}`
1010
+ : step.skip === 'cooling' && step.cooling ? step.cooling.text
1011
+ : step.skip ? WORKER_SKIP_WORDS[step.skip] : '';
1005
1012
  return {
1006
1013
  engine: worker.engine || null,
1007
1014
  model: worker.model || null,
1008
1015
  effort: worker.effort || null,
1009
1016
  max_seconds: Number(worker.max_seconds) > 0 ? Number(worker.max_seconds) : null,
1017
+ prep: worker.prep ? rosterJobLabel(worker.prep) : null,
1010
1018
  until: worker.until || null,
1011
1019
  line: worker.line || null,
1012
1020
  ...(worker.error ? { text: worker.text || '' } : {}),
1013
1021
  status,
1014
1022
  why,
1023
+ ...(step.cooling ? { cooling_until: step.cooling.until, cooling_reason: step.cooling.reason } : {}),
1015
1024
  runs,
1016
1025
  };
1017
1026
  }
@@ -1039,7 +1048,8 @@ function jobRosterRow(job, role, root, state, registry, now, key = role) {
1039
1048
  const status = !pick ? 'router'
1040
1049
  : first && first.step.skip === 'expired' ? 'expired'
1041
1050
  : first && leadIndex === first.index ? 'picked'
1042
- : 'not ready';
1051
+ : first && first.step.skip === 'cooling' ? 'cooling'
1052
+ : 'not ready';
1043
1053
  const lead = leadIndex === -1 ? 'none'
1044
1054
  : first && leadIndex === first.index ? 'first'
1045
1055
  : good[1] && leadIndex === good[1].index ? 'backup'
@@ -1066,12 +1076,15 @@ function jobRosterRow(job, role, root, state, registry, now, key = role) {
1066
1076
  model: resolved.engine && resolved.engine.roster_model ? resolved.engine.roster_model : null,
1067
1077
  effort: resolved.engine && resolved.engine.roster_effort ? resolved.engine.roster_effort : null,
1068
1078
  max_seconds: pick && Number(pick.max_seconds) > 0 ? Number(pick.max_seconds) : null,
1079
+ prep: first && first.step.worker.prep ? rosterJobLabel(first.step.worker.prep) : null,
1080
+ backup_prep: good[1] && good[1].step.worker.prep ? rosterJobLabel(good[1].step.worker.prep) : null,
1069
1081
  runs: runs || nowRuns,
1070
1082
  backup_runs: runsOf(good[1]),
1071
1083
  now_runs: nowRuns,
1072
1084
  workers: walk.map((step, index) => workerRow(step, index, leadIndex)),
1073
1085
  lead,
1074
1086
  status,
1087
+ ...(status === 'cooling' ? { cooling: first.step.cooling.text } : {}),
1075
1088
  reason: resolved.reason,
1076
1089
  };
1077
1090
  }
@@ -1127,12 +1140,19 @@ function renderWorkerLines(row) {
1127
1140
  const width = Math.max(...row.workers.map((worker) => (worker.runs ? worker.runs.text : worker.text || '').length));
1128
1141
  return row.workers.map((worker, index) => {
1129
1142
  const what = (worker.runs ? worker.runs.text : worker.text || '').padEnd(width);
1130
- const extras = [worker.max_seconds ? rosterMaxText(worker.max_seconds) : '', worker.until ? `until ${worker.until}` : ''].filter(Boolean);
1143
+ const extras = [worker.max_seconds ? rosterMaxText(worker.max_seconds) : '', worker.prep ? `prepped by ${worker.prep}` : '', worker.until ? `until ${worker.until}` : ''].filter(Boolean);
1131
1144
  const state = worker.status === 'leads' ? 'leads now' : worker.status === 'backup' ? 'backup' : `skipped, ${worker.why}`;
1132
1145
  return ` ${index + 1}. ${what} ${[state, ...extras].join(', ')}`.trimEnd();
1133
1146
  });
1134
1147
  }
1135
1148
 
1149
+ // Each worker's recent record under its job, only for workers with runs.
1150
+ function renderWorkerRecords(row) {
1151
+ return (row.workers || [])
1152
+ .filter((worker) => worker.record)
1153
+ .map((worker) => ` ${worker.runs ? worker.runs.text : worker.engine}: ${worker.record.text}`);
1154
+ }
1155
+
1136
1156
  function renderJobRoster(rows) {
1137
1157
  const width = Math.max(7, ...rows.map((row) => row.job.length));
1138
1158
  const ownerWidth = Math.max(24, ...rows.filter((row) => row.pick && row.runs).map((row) => row.runs.text.length));
@@ -1143,16 +1163,18 @@ function renderJobRoster(rows) {
1143
1163
  const fallsTo = `${row.like ? `falls back to ${row.like}` : 'router decides'}: ${nowText}`;
1144
1164
  if (!row.pick) return `${label} no pick, ${fallsTo}`;
1145
1165
  const owner = row.runs.text.padEnd(ownerWidth);
1146
- const backup = row.backup_runs ? `backup ${row.backup_runs.text}` : 'no backup';
1147
- const cap = row.max_seconds ? `${rosterMaxText(row.max_seconds)}, ` : '';
1166
+ const backup = row.backup_runs ? `backup ${row.backup_runs.text}${row.backup_prep ? ` prepped by ${row.backup_prep}` : ''}` : 'no backup';
1167
+ const cap = `${row.max_seconds ? `${rosterMaxText(row.max_seconds)}, ` : ''}${row.prep ? `prepped by ${row.prep}, ` : ''}`;
1148
1168
  const date = untilText(row.pick);
1149
1169
  const fallback = row.lead === 'backup' ? 'using backup' : row.lead === 'later' ? `using ${nowText}` : fallsTo;
1150
1170
  const status = row.status === 'expired' ? `expired, ${fallback}`
1151
- : row.status === 'not ready' ? `not ready, ${fallback}`
1152
- : date;
1171
+ : row.status === 'cooling' ? `${row.cooling}, ${fallback}`
1172
+ : row.status === 'not ready' ? `not ready, ${fallback}`
1173
+ : date;
1153
1174
  const where = row.file ? `${row.from} (${row.file})` : row.from;
1154
1175
  const head = `${label} ${owner} ${backup.padEnd(backupWidth)} ${cap}${status}, ${where}`.trimEnd();
1155
- return [head, ...renderWorkerLines(row)].join('\n');
1176
+ const suggestion = row.suggestion ? [` suggestion: ${row.suggestion.text}`] : [];
1177
+ return [head, ...renderWorkerLines(row), ...renderWorkerRecords(row), ...suggestion].join('\n');
1156
1178
  }).join('\n');
1157
1179
  }
1158
1180
 
@@ -1234,15 +1256,62 @@ function runRosterSessionCommand(rest, json, root, now) {
1234
1256
  return 0;
1235
1257
  }
1236
1258
 
1259
+ // Attach each worker's recent record (last 7 days) to the roster report, and
1260
+ // at most one suggestion per job when that record says the order is wrong.
1261
+ // Only the roster command reads the whole tail of the run file; the boot
1262
+ // line reads a smaller tail just to count suggestions. Nothing here writes
1263
+ // the roster: a suggestion is a line to read and a command a person may run.
1264
+ function attachRunRecords(report, root, now = new Date()) {
1265
+ const runs = readRosterRuns(root, { now, days: RUN_DAYS });
1266
+ if (!runs.length) return report;
1267
+ for (const row of report.jobs || []) {
1268
+ for (const worker of row.workers || []) {
1269
+ if (!worker.engine) continue;
1270
+ const record = summarizeRuns(workerRuns(runs, { job: row.key || row.job, engine: worker.engine, model: worker.model || '' }), { days: RUN_DAYS });
1271
+ if (record) worker.record = record;
1272
+ }
1273
+ }
1274
+ attachSuggestions(report.jobs, runs);
1275
+ return report;
1276
+ }
1277
+
1278
+ const RUNS_LIST_DAYS = 30;
1279
+ const RUNS_LIST_LIMIT = 20;
1280
+
1281
+ // The recent attempts, newest first, one plain line each.
1282
+ function runRosterRunsCommand(job, json, root, now) {
1283
+ const runs = recentRuns(readRosterRuns(root, { now, days: RUNS_LIST_DAYS }), { job, limit: RUNS_LIST_LIMIT });
1284
+ if (json) {
1285
+ console.log(JSON.stringify({ job: job || null, days: RUNS_LIST_DAYS, runs }, null, 2));
1286
+ return 0;
1287
+ }
1288
+ if (!runs.length) {
1289
+ console.log(job ? `no ${job} runs recorded in the last ${RUNS_LIST_DAYS} days` : `no runs recorded in the last ${RUNS_LIST_DAYS} days`);
1290
+ return 0;
1291
+ }
1292
+ console.log([`recent ${job ? `${job} ` : ''}runs, newest first`, ...runs.map(renderRunLine)].join('\n'));
1293
+ return 0;
1294
+ }
1295
+
1237
1296
  function runRosterCommand(args, root, now = new Date()) {
1238
1297
  const json = args.includes('--json');
1239
1298
  const available = args.includes('--available');
1299
+ const runsAt = args.indexOf('--runs');
1300
+ if (runsAt !== -1) {
1301
+ const other = args.slice(0, runsAt).filter((arg) => arg !== '--json');
1302
+ const job = args.slice(runsAt + 1).filter((arg) => !String(arg).startsWith('--')).join(' ').trim();
1303
+ if (other.length || available) {
1304
+ console.error('usage: atris engine roster --runs [job] [--json]');
1305
+ return 2;
1306
+ }
1307
+ return runRosterRunsCommand(job, json, root, now);
1308
+ }
1240
1309
  const rest = args.filter((arg) => arg !== '--json' && arg !== '--available');
1241
1310
  const known = !rest.length
1242
1311
  || (rest.length === 1 && ['confirm', 'session'].includes(rest[0]))
1243
1312
  || (rest.length === 2 && rest[0] === 'session' && rest[1] === 'clear');
1244
1313
  if (!known || (available && rest.length)) {
1245
- console.error('usage: atris engine roster [confirm | session [clear] | --available] [--json]');
1314
+ console.error('usage: atris engine roster [confirm | session [clear] | --available | --runs [job]] [--json]');
1246
1315
  return 2;
1247
1316
  }
1248
1317
  if (available) {
@@ -1252,13 +1321,13 @@ function runRosterCommand(args, root, now = new Date()) {
1252
1321
  }
1253
1322
  if (rest[0] === 'session') return runRosterSessionCommand(rest, json, root, now);
1254
1323
  if (rest[0] === 'confirm') confirmRoster(root, now);
1255
- const report = rosterReport(root, now);
1324
+ const report = attachRunRecords(rosterReport(root, now), root, now);
1256
1325
  if (json) console.log(JSON.stringify(report, null, 2));
1257
1326
  else console.log(`${renderRosterReport(report)}\n\n${AVAILABLE_HINT}`);
1258
1327
  return 0;
1259
1328
  }
1260
1329
 
1261
- const ASSIGN_USAGE = 'usage: atris engine assign <job> <tool> [--model <m>] [--effort low|medium|high|xhigh|max] [--max "20 min"] [--days <n>] [--add] [--backup "<tool> [model]"] [--like search|build|review] [--session | --everywhere], or atris engine assign <job> --remove <tool> | --clear. jobs live in atris/ROSTER.md (~/.atris/ROSTER.md with --everywhere, this shell only with --session) and you can edit that file any time';
1330
+ const ASSIGN_USAGE = 'usage: atris engine assign <job> <tool> [--model <m>] [--effort low|medium|high|xhigh|max] [--max "20 min"] [--prep <job>] [--days <n>] [--add] [--backup "<tool> [model]"] [--like search|build|review] [--session | --everywhere], atris engine assign <job> --promote "<tool> [model]" to move an existing worker to the top unchanged, or atris engine assign <job> --remove <tool> | --clear. jobs live in atris/ROSTER.md (~/.atris/ROSTER.md with --everywhere, this shell only with --session) and you can edit that file any time';
1262
1331
 
1263
1332
  function runAssignCommand(args, root, now = new Date()) {
1264
1333
  const job = args[0] && !String(args[0]).startsWith('--') ? args[0] : '';
@@ -1283,13 +1352,14 @@ function runAssignCommand(args, root, now = new Date()) {
1283
1352
  const flags = {};
1284
1353
  for (let i = 0; i < rest.length; i += 1) {
1285
1354
  const arg = rest[i];
1286
- if (!['--model', '--backup', '--days', '--like', '--effort', '--max', '--remove'].includes(arg) || !rest[i + 1] || rest[i + 1].startsWith('--')) {
1355
+ if (!['--model', '--backup', '--days', '--like', '--effort', '--max', '--prep', '--remove', '--promote'].includes(arg) || !rest[i + 1] || rest[i + 1].startsWith('--')) {
1287
1356
  console.error(`invalid assign option "${arg}"`);
1288
1357
  return 2;
1289
1358
  }
1290
1359
  flags[arg.slice(2)] = rest[++i];
1291
1360
  }
1292
1361
  const removing = flags.remove !== undefined;
1362
+ const promoting = flags.promote !== undefined;
1293
1363
  if (clear && (engine || Object.keys(flags).length || add)) {
1294
1364
  console.error('use --clear without an engine or other options');
1295
1365
  return 2;
@@ -1298,12 +1368,16 @@ function runAssignCommand(args, root, now = new Date()) {
1298
1368
  console.error('use --remove <tool> without other options');
1299
1369
  return 2;
1300
1370
  }
1301
- if (!clear && !removing && !engine) {
1371
+ if (promoting && (engine || Object.keys(flags).length > 1 || add)) {
1372
+ console.error('use --promote "<tool> [model]" without an engine or other options');
1373
+ return 2;
1374
+ }
1375
+ if (!clear && !removing && !promoting && !engine) {
1302
1376
  console.error(ASSIGN_USAGE);
1303
1377
  return 2;
1304
1378
  }
1305
1379
  try {
1306
- setRosterPick(job, engine, { ...flags, clear, add, session, everywhere, now }, root);
1380
+ setRosterPick(job, engine, { ...flags, clear, add, session, everywhere, now, note: (text) => console.log(text) }, root);
1307
1381
  printJobRoster(root, now);
1308
1382
  return 0;
1309
1383
  } catch (err) {
@@ -1942,8 +2016,8 @@ function runDispatchCommand(args, root) {
1942
2016
  : `engine dispatch: ${err.message}`);
1943
2017
  return 2;
1944
2018
  }
1945
- // The build line's model, effort, and time cap ride along when the roster
1946
- // picks this same engine for build.
2019
+ // The build line's model, effort, time cap, and prep job ride along when
2020
+ // the roster picks this same engine for build.
1947
2021
  const pin = rosterPinFor('executor', canonical, root);
1948
2022
  return runDispatchFlight({
1949
2023
  root,
@@ -1955,6 +2029,7 @@ function runDispatchCommand(args, root) {
1955
2029
  ...(pin.roster_model ? { model: pin.roster_model } : {}),
1956
2030
  ...(pin.roster_effort ? { effort: pin.roster_effort } : {}),
1957
2031
  ...(pin.roster_max_seconds ? { maxSeconds: pin.roster_max_seconds } : {}),
2032
+ ...(pin.roster_prep ? { prep: pin.roster_prep } : {}),
1958
2033
  }).then((flight) => {
1959
2034
  if (json) console.log(JSON.stringify(flight, null, 2));
1960
2035
  return flight.paused.length ? 1 : 0;
@@ -2085,7 +2160,7 @@ function engineCommand(args = [], deps = {}) {
2085
2160
  if (sub === 'help' || args.includes('--help') || args.includes('-h')) {
2086
2161
  console.log('\n atris engine watch [<id>|latest] [--no-follow]\n follow one live transcript or list running engine work');
2087
2162
  console.log('\n long read-only asks: atris engine ask "..." --engine agy --timeout <seconds>\n quick asks default to 120 seconds; explicit jobs allow up to 3600\n follow live work with atris engine watch latest');
2088
- console.log('\n atris engine roster + current default\n atris engine roster [--json] show every job and its workers in order: search, build, review, and your own\n atris engine roster confirm renew every dated worker for 30 days\n atris engine roster session [clear]\n show or drop the roster changes for this shell only\n atris engine roster --available\n tools on this machine and the models each one offers\n atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]\n atris engine assign <job> --remove <tool> | --clear [--session | --everywhere]\n sets the lead worker of the job; --add puts one more at the end; jobs: search, build, review,\n or your own like "small build" (--like search|build|review when the name does not say);\n --everywhere is for all projects; --session is this shell only (set ATRIS_ROSTER_SESSION=<name>)\n atris engines --chart show the fleet as an org chart\n atris engine list --json [--all] full registry: default + engines with tier, roles, fallback, health (--all includes hidden engines)\n atris engine set <name> --duty leader|errands|learning [--models "a, b"]\n arrange the fleet and save its model policy\n atris engine resolve <role> or <job> [--json]\n choose the best ready engine for navigator|executor|validator or a roster job\n atris engine health <name> --set ready|not_installed|credit_out\n flip runtime health, for example when credits run out\n atris engine doctor [--json]\n probe which engine CLIs are installed here and sync that into health policy\n atris engine <name> make that engine the default here\n atris engine test [name] preflight: run the engine CLI headless, report pass/fail\n atris engine bench [names...] [--runs N]\n ranked latency scoreboard of engine passes\n atris engine ask "<question>" --engine <name> [--engine <name> ...]\n ask several engines in parallel without allowing edits\n atris engine ask --jobs <jobs.json>\n ask different read-only questions in parallel\n atris engine validate <receipt-path|latest> [--engine <name>]\n check ask answers with a different read-only referee\n atris engine validate scoreboard\n show pass rates by worker engine\n atris engine dispatch <task-id> [<task-id> ...] --engine cursor|codex [--prompt-file <f>] [--yolo]\n one-command claim, worktree, build, verify, ship, ready\n atris engine login <provider> --yes\n upload a local provider CLI login to the backend vault\n atris engine login <provider> --computer [--seat <name>]\n atris engine login <provider> --business <id> [--seat <name>]\n sign in on an Atris computer by device flow\n atris engine login --list | --remove <provider>\n list or remove vaulted provider logins\n atris engine seats show which named accounts are ready to work\n atris engine seed <provider> --business <id>|--user\n push a vaulted login onto an Atris computer\n atris engine reset back to the house default\n --engine <name> one run on that engine (mission run / autopilot / run)\n');
2163
+ console.log('\n atris engine roster + current default\n atris engine roster [--json] show every job and its workers in order: search, build, review, and your own\n atris engine roster confirm renew every dated worker for 30 days\n atris engine roster session [clear]\n show or drop the roster changes for this shell only\n atris engine roster --available\n tools on this machine and the models each one offers\n atris engine roster --runs [job]\n the last 20 runs, newest first, with tool, model, time, and outcome\n atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--prep <job>] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]\n atris engine assign <job> --promote "<tool> [model]" | --remove <tool> | --clear [--session | --everywhere]\n sets the lead worker of the job; --promote moves one of its workers to the top as written;\n --add puts one more at the end; jobs: search, build, review,\n or your own like "small build" (--like search|build|review when the name does not say);\n --prep search has that job read the task first and hand this worker a short brief;\n --everywhere is for all projects; --session is this shell only (set ATRIS_ROSTER_SESSION=<name>)\n atris engines --chart show the fleet as an org chart\n atris engine list --json [--all] full registry: default + engines with tier, roles, fallback, health (--all includes hidden engines)\n atris engine set <name> --duty leader|errands|learning [--models "a, b"]\n arrange the fleet and save its model policy\n atris engine resolve <role> or <job> [--json]\n choose the best ready engine for navigator|executor|validator or a roster job\n atris engine health <name> --set ready|not_installed|credit_out\n flip runtime health, for example when credits run out\n atris engine doctor [--json]\n probe which engine CLIs are installed here and sync that into health policy\n atris engine <name> make that engine the default here\n atris engine test [name] preflight: run the engine CLI headless, report pass/fail\n atris engine bench [names...] [--runs N]\n ranked latency scoreboard of engine passes\n atris engine ask "<question>" --engine <name> [--engine <name> ...]\n ask several engines in parallel without allowing edits\n atris engine ask --jobs <jobs.json>\n ask different read-only questions in parallel\n atris engine validate <receipt-path|latest> [--engine <name>]\n check ask answers with a different read-only referee\n atris engine validate scoreboard\n show pass rates by worker engine\n atris engine dispatch <task-id> [<task-id> ...] --engine cursor|codex [--prompt-file <f>] [--yolo]\n one-command claim, worktree, build, verify, ship, ready\n atris engine login <provider> --yes\n upload a local provider CLI login to the backend vault\n atris engine login <provider> --computer [--seat <name>]\n atris engine login <provider> --business <id> [--seat <name>]\n sign in on an Atris computer by device flow\n atris engine login --list | --remove <provider>\n list or remove vaulted provider logins\n atris engine seats show which named accounts are ready to work\n atris engine seed <provider> --business <id>|--user\n push a vaulted login onto an Atris computer\n atris engine reset back to the house default\n --engine <name> one run on that engine (mission run / autopilot / run)\n');
2089
2164
  return 0;
2090
2165
  }
2091
2166
  if (isFreshWorkspace(root)) {
package/commands/host.js CHANGED
@@ -1,14 +1,17 @@
1
1
  'use strict';
2
2
 
3
3
  const { hostAction } = require('../lib/host');
4
+ const { apiRequestJson } = require('../utils/api');
5
+ const { loadCredentials, decodeJwtClaims } = require('../utils/auth');
4
6
 
5
7
  function parse(argv) {
6
8
  const options = {};
7
9
  const positionals = [];
8
- const flags = new Set(['id', 'name', 'team', 'manager', 'door', 'now', 'started', 'question', 'event-id', 'from', 'text', 'reply-to', 'reply-to-ref', 'ref', 'decision', 'patch', 'expected-revision', 'source', 'evidence', 'reason', 'activity', 'as', 'when', 'at']);
10
+ const flags = new Set(['id', 'name', 'team', 'manager', 'door', 'now', 'started', 'question', 'event-id', 'from', 'text', 'reply-to', 'reply-to-ref', 'ref', 'decision', 'patch', 'expected-revision', 'source', 'evidence', 'reason', 'activity', 'as', 'when', 'at', 'room', 'slack-business', 'limit']);
9
11
  for (let i = 0; i < argv.length; i += 1) {
10
12
  const item = argv[i];
11
13
  if (item === '--json') { options.json = true; continue; }
14
+ if (item === '--dry-run') { options.dryRun = true; continue; }
12
15
  if (item.startsWith('--')) {
13
16
  const key = item.slice(2);
14
17
  if (!flags.has(key) || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error(`invalid --${key} value`);
@@ -18,8 +21,49 @@ function parse(argv) {
18
21
  return { options, positionals };
19
22
  }
20
23
 
24
+ async function hostDeliver(root, options = {}, deps = {}) {
25
+ const { room, slack_business_id: businessId, messages } = hostAction(root, 'delivery');
26
+ if (!businessId) throw new Error('slack business id is missing; run atris host setup --slack-business <business id>');
27
+ if (room === 'personal') throw new Error('host delivery is for group rooms only');
28
+ if (options.limit != null && !/^(0|[1-9]\d*)$/.test(options.limit)) throw new Error('limit must be a non-negative integer');
29
+ const eligible = messages.filter((message) => /^slack:[UW][A-Z0-9]+$/.test(message.door || '') && message.draft !== true);
30
+ const selected = eligible.slice(0, options.limit == null ? undefined : Number(options.limit));
31
+ const result = { sent: 0, skipped: messages.length - eligible.length, failed: 0 };
32
+ if (options.dryRun) {
33
+ result.would_send = selected.map((message) => ({ id: message.id, to: message.door.slice(6), kind: message.kind, text: String(message.text).replace(/\s+/g, ' ').slice(0, 80) }));
34
+ return result;
35
+ }
36
+ if (!selected.length) return result;
37
+ const credentials = (deps.loadCredentials || loadCredentials)();
38
+ if (!credentials?.token || credentials.source === 'agent_token_file' || decodeJwtClaims(credentials.token)?.type === 'agent_access') {
39
+ throw new Error('log in as an Atris user with atris login before delivering');
40
+ }
41
+ const request = deps.apiRequestJson || apiRequestJson;
42
+ for (const message of selected) {
43
+ try {
44
+ const response = await request(`/business/${encodeURIComponent(businessId)}/host/slack/send`, {
45
+ method: 'POST', token: credentials.token, retries: 0,
46
+ body: { slack_user_id: message.door.slice(6), text: message.text, host_message_id: message.id },
47
+ });
48
+ if (!response.ok) throw new Error(`${response.status ? `http ${response.status}` : 'network error'}: ${response.error || 'request failed'}`);
49
+ if (typeof response.data?.ts !== 'string' || !response.data.ts.trim()) throw new Error('response is missing a Slack timestamp');
50
+ hostAction(root, 'sent', { id: message.id, ref: response.data.ts });
51
+ result.sent += 1;
52
+ } catch (error) {
53
+ result.failed = 1;
54
+ result.failure = { id: message.id, reason: error.message };
55
+ break;
56
+ }
57
+ }
58
+ return result;
59
+ }
60
+
21
61
  function human(command, result) {
62
+ if (command === 'deliver') return result.would_send
63
+ ? [...result.would_send.map((message) => `${message.to} ${message.kind}: ${message.text}`), `dry run: ${result.would_send.length} would send, ${result.skipped} skipped`].join('\n')
64
+ : `host delivery: ${result.sent} sent, ${result.skipped} skipped, ${result.failure ? `failed ${result.failure.id}: ${result.failure.reason}` : '0 failed'}`;
22
65
  if (command === 'room') return result.text;
66
+ if (command === 'import') return `import complete: ${result.added} added, ${result.skipped} skipped, ${result.refused} refused, ${result.links_added} links added, ${result.links_skipped} links skipped`;
23
67
  if (command === 'due') return result.length ? result.map((entry) => `${entry.name} (${entry.id}) is due for a question.`).join('\n') : 'no questions due';
24
68
  if (command === 'outbox') return result.length ? result.map((entry) => `${entry.id} ${entry.kind} to ${entry.to}: ${entry.text}`).join('\n') : 'outbox empty';
25
69
  if (command === 'schedule') return result.length ? result.map((entry) => `${entry.attempt_id}: ${entry.names.a} (${entry.a}) and ${entry.names.b} (${entry.b}), ${entry.activity}`).join('\n') : 'no introductions need scheduling';
@@ -36,21 +80,29 @@ function human(command, result) {
36
80
  return `${command} complete: ${JSON.stringify(result)}`;
37
81
  }
38
82
 
39
- function hostCommand(argv = process.argv.slice(3), root = process.cwd()) {
83
+ function hostCommand(argv = process.argv.slice(3), root = process.cwd(), deps = {}) {
40
84
  let jsonOutput = argv.includes('--json');
41
85
  try {
42
86
  const [command, ...rest] = argv;
43
87
  if (!command || command === '--help' || command === '-h') {
44
- const usage = 'usage: atris host join|leave|pause|resume|forget|due|ask|receive|card|link|propose|people|outbox|sent|room|view|schedule|scheduled [options]';
88
+ const usage = 'usage: atris host setup|join|import|leave|pause|resume|forget|due|ask|receive|card|link|propose|people|outbox|deliver|sent|room|view|schedule|scheduled [options]';
45
89
  console.log(jsonOutput ? JSON.stringify({ usage }) : usage);
46
90
  return;
47
91
  }
48
92
  const { options, positionals } = parse(rest);
49
93
  jsonOutput = options.json;
50
94
  const args = { ...options };
95
+ if (command === 'import') args.file = positionals[0];
51
96
  if (['leave', 'pause', 'resume', 'forget', 'ask', 'card', 'sent', 'scheduled'].includes(command)) args.id = positionals[0];
52
97
  if (['link', 'propose'].includes(command)) { args.a = positionals[0]; args.b = positionals[1]; }
53
98
  if (command === 'receive') args.eventId = options.eventId;
99
+ if (command === 'deliver') return hostDeliver(root, args, deps).then((result) => {
100
+ console.log(jsonOutput ? JSON.stringify(result) : human(command, result));
101
+ if (result.failed) process.exitCode = 1;
102
+ }).catch((error) => {
103
+ console.error(jsonOutput ? JSON.stringify({ error: error.message }) : `host: ${error.message}`);
104
+ process.exitCode = 1;
105
+ });
54
106
  const result = hostAction(root, command, args);
55
107
  console.log(jsonOutput ? JSON.stringify(result) : human(command, result));
56
108
  } catch (error) {
@@ -59,4 +111,4 @@ function hostCommand(argv = process.argv.slice(3), root = process.cwd()) {
59
111
  }
60
112
  }
61
113
 
62
- module.exports = { hostCommand };
114
+ module.exports = { hostCommand, hostDeliver, parse };
package/commands/land.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { runGit } = require('../lib/git-spawn');
3
+ const gitSpawn = require('../lib/git-spawn');
4
4
  const { compactErrorPayload, printCliJson } = require('../lib/cli-json');
5
5
  const { listWorktrees, statusCounts } = require('./worktree');
6
6
 
@@ -9,6 +9,14 @@ const DEFAULT_STALE_HOURS = 48;
9
9
  const WORKTREE_REAP_GRACE_MS = 60 * 60 * 1000;
10
10
  const PROTECTED_BRANCHES = new Set(['main', 'master']);
11
11
 
12
+ // Through the module object so tests can count git spawns with a spy.
13
+ function runGit(args, opts) {
14
+ return gitSpawn.runGit(args, opts);
15
+ }
16
+
17
+ const CHERRY_CACHE_FILE = path.join('.atris', 'state', 'cherry-cache.json');
18
+ const CHERRY_CACHE_MAX = 2000;
19
+
12
20
  // Pluralize a count + noun ("1 change", "2 changes").
13
21
  function countLabel(n, word) {
14
22
  return `${n} ${word}${n === 1 ? '' : 's'}`;
@@ -157,10 +165,11 @@ function checkoutKeepLine(name, bound, now) {
157
165
  function listBranches(root, base = '') {
158
166
  // With a base, ask git for ahead counts in the same single spawn
159
167
  // (%(ahead-behind:) needs git >= 2.41; on failure we retry without it and
160
- // collectBoard falls back to per-branch aheadCount).
168
+ // collectBoard falls back to per-branch aheadCount). The branch sha comes
169
+ // along in the same spawn so the cherry cache needs no per-branch lookup.
161
170
  const format = base
162
- ? `%(refname:short)%09%(committerdate:unix)%09%(ahead-behind:${base})`
163
- : '%(refname:short)%09%(committerdate:unix)';
171
+ ? `%(refname:short)%09%(committerdate:unix)%09%(objectname)%09%(ahead-behind:${base})`
172
+ : '%(refname:short)%09%(committerdate:unix)%09%(objectname)';
164
173
  const result = runGit(['for-each-ref', 'refs/heads', `--format=${format}`], { cwd: root, check: false });
165
174
  if (result.status !== 0) {
166
175
  if (base) return listBranches(root);
@@ -170,8 +179,9 @@ function listBranches(root, base = '') {
170
179
  .split(/\r?\n/)
171
180
  .filter(Boolean)
172
181
  .map((line) => {
173
- const [name, date, aheadBehind] = line.split('\t');
182
+ const [name, date, sha, aheadBehind] = line.split('\t');
174
183
  const entry = { name, lastCommitUnix: Number(date) };
184
+ if (sha) entry.sha = sha;
175
185
  if (aheadBehind !== undefined) {
176
186
  const ahead = Number(aheadBehind.split(' ')[0]);
177
187
  if (Number.isFinite(ahead)) entry.ahead = ahead;
@@ -206,6 +216,82 @@ function cherryStats(root, base, ref) {
206
216
  return { landedElsewhere, unique };
207
217
  }
208
218
 
219
+ // `git cherry <base> <ref>` depends only on the two commit shas, so its
220
+ // counts are cached per (base sha, ref sha) under .atris/state. The cache is
221
+ // only written where .atris already exists; a missing or corrupt file means
222
+ // recompute. Entries for refs not seen this run are dropped on save.
223
+ function cherryCachePath(root) {
224
+ return path.join(root, CHERRY_CACHE_FILE);
225
+ }
226
+
227
+ function loadCherryCache(root) {
228
+ const cache = { file: cherryCachePath(root), entries: {}, used: {}, dirty: false, writable: false };
229
+ try {
230
+ cache.writable = fs.statSync(path.join(root, '.atris')).isDirectory();
231
+ } catch {
232
+ cache.writable = false;
233
+ }
234
+ if (!cache.writable) return cache;
235
+ try {
236
+ const parsed = JSON.parse(fs.readFileSync(cache.file, 'utf8'));
237
+ if (parsed && parsed.version === 1 && parsed.entries && typeof parsed.entries === 'object') {
238
+ cache.entries = parsed.entries;
239
+ }
240
+ } catch {
241
+ cache.entries = {};
242
+ }
243
+ return cache;
244
+ }
245
+
246
+ function validCherryEntry(entry) {
247
+ return entry && Number.isInteger(entry.landedElsewhere) && Number.isInteger(entry.unique)
248
+ && entry.landedElsewhere >= 0 && entry.unique >= 0;
249
+ }
250
+
251
+ function cachedCherryStats(cache, cwd, base, baseSha, ref, refSha) {
252
+ if (!cache || !baseSha || !refSha) return cherryStats(cwd, base, ref);
253
+ const key = `${baseSha}:${refSha}`;
254
+ const hit = cache.entries[key];
255
+ if (validCherryEntry(hit)) {
256
+ cache.used[key] = hit;
257
+ return { landedElsewhere: hit.landedElsewhere, unique: hit.unique };
258
+ }
259
+ // Cherry the exact commits the key names, so a ref that moves mid-run
260
+ // can never file one pair's result under another pair's key.
261
+ const result = runGit(['cherry', baseSha, refSha], { cwd, check: false });
262
+ if (result.status !== 0) return { landedElsewhere: 0, unique: 0 };
263
+ const stats = { landedElsewhere: 0, unique: 0 };
264
+ for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
265
+ if (line.startsWith('- ')) stats.landedElsewhere += 1;
266
+ else if (line.startsWith('+ ')) stats.unique += 1;
267
+ }
268
+ cache.used[key] = { ...stats };
269
+ cache.dirty = true;
270
+ return stats;
271
+ }
272
+
273
+ function saveCherryCache(cache) {
274
+ if (!cache || !cache.writable) return;
275
+ const usedKeys = Object.keys(cache.used);
276
+ // Rewrite when something new was computed or something stale can go.
277
+ if (!cache.dirty && usedKeys.length === Object.keys(cache.entries).length) return;
278
+ const entries = {};
279
+ for (const key of usedKeys.slice(-CHERRY_CACHE_MAX)) entries[key] = cache.used[key];
280
+ try {
281
+ fs.mkdirSync(path.dirname(cache.file), { recursive: true });
282
+ const tmp = `${cache.file}.${process.pid}.tmp`;
283
+ fs.writeFileSync(tmp, `${JSON.stringify({ version: 1, entries })}\n`);
284
+ fs.renameSync(tmp, cache.file);
285
+ } catch {
286
+ // The cache is only a speedup; a failed write costs a recompute next time.
287
+ }
288
+ }
289
+
290
+ function resolveSha(root, ref) {
291
+ const result = runGit(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { cwd: root, check: false });
292
+ return result.status === 0 ? result.stdout.trim() : '';
293
+ }
294
+
209
295
  // Board: every branch and worktree with unlanded state, classified.
210
296
  // landed, no commits ahead of base; the branch pointer is residue
211
297
  // active, has unlanded commits, younger than TTL
@@ -220,6 +306,8 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
220
306
 
221
307
  const branches = [];
222
308
  const lastCommitMsByBranch = new Map();
309
+ const cherryCache = loadCherryCache(root);
310
+ const baseSha = resolveSha(root, base);
223
311
  for (const branch of listBranches(root, base)) {
224
312
  if (PROTECTED_BRANCHES.has(branch.name)) continue;
225
313
  const lastCommitMs = Number(branch.lastCommitUnix) * 1000;
@@ -227,7 +315,9 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
227
315
  const ahead = branch.ahead !== undefined ? branch.ahead : aheadCount(root, base, branch.name);
228
316
  const age = ageDays(branch.lastCommitUnix, now);
229
317
  const hours = ageHours(branch.lastCommitUnix, now);
230
- const cherry = ahead > 0 ? cherryStats(root, base, branch.name) : { landedElsewhere: 0, unique: 0 };
318
+ const cherry = ahead > 0
319
+ ? cachedCherryStats(cherryCache, root, base, baseSha, branch.name, branch.sha)
320
+ : { landedElsewhere: 0, unique: 0 };
231
321
  let state = 'active';
232
322
  if (ahead === 0 || (cherry.landedElsewhere > 0 && cherry.unique === 0)) state = 'landed';
233
323
  else if (age > ttlDays) state = 'due';
@@ -271,7 +361,7 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
271
361
  const cnt = runGit(['rev-list', '--count', `${base}..HEAD`], { cwd: wt.path, check: false });
272
362
  ahead = cnt.status === 0 ? Number(cnt.stdout.trim()) || 0 : 0;
273
363
  if (ahead > 0) {
274
- unlandedCommits = cherryStats(wt.path, base, 'HEAD').unique;
364
+ unlandedCommits = cachedCherryStats(cherryCache, wt.path, base, baseSha, 'HEAD', wt.head).unique;
275
365
  const ts = runGit(['log', '-1', '--format=%ct', 'HEAD'], { cwd: wt.path, check: false });
276
366
  if (ts.status === 0) ageDaysVal = ageDays(Number(ts.stdout.trim()), now);
277
367
  if (!light) subjects = commitSubjects(wt.path, base, 'HEAD');
@@ -309,6 +399,8 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
309
399
  branch.stale = branch.state === 'active' && branch.activityHours >= staleHours;
310
400
  }
311
401
 
402
+ saveCherryCache(cherryCache);
403
+
312
404
  const due = branches.filter((b) => b.state === 'due');
313
405
  const landed = branches.filter((b) => b.state === 'landed');
314
406
  const active = branches.filter((b) => b.state === 'active');
@@ -333,6 +425,33 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
333
425
  };
334
426
  }
335
427
 
428
+ // One landing board per process per repo. Boot, stream, and the team view
429
+ // each ask for the board more than once in a single command; the first call
430
+ // pays for git, later calls within BOARD_MEMO_MAX_AGE_MS share the result.
431
+ // A full board also answers light requests (light is a subset). Long-lived
432
+ // callers (stream follow) clear the memo before each poll.
433
+ const BOARD_MEMO_MAX_AGE_MS = 10000;
434
+ const boardMemo = new Map();
435
+
436
+ function boardMemoKey(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOURS, base = '' } = {}) {
437
+ return JSON.stringify([canonicalPath(root), ttlDays, staleHours, base]);
438
+ }
439
+
440
+ function sharedBoard(root, opts = {}) {
441
+ const key = boardMemoKey(root, opts);
442
+ const light = Boolean(opts.light);
443
+ const nowMs = Date.now();
444
+ const hit = boardMemo.get(key);
445
+ if (hit && nowMs - hit.at < BOARD_MEMO_MAX_AGE_MS && (light || !hit.light)) return hit.board;
446
+ const board = collectBoard(root, { ...opts, light });
447
+ boardMemo.set(key, { board, light, at: Date.now() });
448
+ return board;
449
+ }
450
+
451
+ function clearBoardMemo() {
452
+ boardMemo.clear();
453
+ }
454
+
336
455
  // Counts for the boot banner and digest. This intentionally pays the same
337
456
  // classification cost as the landing board so "in the air" never includes
338
457
  // merged branch residue.
@@ -340,7 +459,7 @@ function landSummary(cwd = process.cwd(), ttlDays = DEFAULT_TTL_DAYS) {
340
459
  const root = repoRoot(cwd);
341
460
  if (!root || !hasCommits(root)) return null;
342
461
  try {
343
- const board = collectBoard(root, { ttlDays, light: true });
462
+ const board = sharedBoard(root, { ttlDays, light: true });
344
463
  return {
345
464
  branches: board.summary.active + board.summary.due,
346
465
  due: board.summary.due,
@@ -918,9 +1037,11 @@ function landCommand(args = []) {
918
1037
  }
919
1038
 
920
1039
  module.exports = {
1040
+ clearBoardMemo,
921
1041
  collectBoard,
922
1042
  countLabel,
923
1043
  landCommand,
924
1044
  landSummary,
925
1045
  reap,
1046
+ sharedBoard,
926
1047
  };