atris 3.58.7 → 3.59.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 (95) hide show
  1. package/AGENTS.md +3 -2
  2. package/GETTING_STARTED.md +2 -2
  3. package/PERSONA.md +11 -11
  4. package/README.md +1 -1
  5. package/atris/GEMINI.md +7 -6
  6. package/atris/PERSONA.md +4 -4
  7. package/atris/features/README.md +77 -6
  8. package/atris/skills/README.md +44 -15
  9. package/atris/skills/aeo/SKILL.md +10 -10
  10. package/atris/skills/atris/SKILL.md +1 -1
  11. package/atris/skills/engines/SKILL.md +69 -50
  12. package/atris/skills/meta/SKILL.md +4 -4
  13. package/bin/atris.js +8 -6
  14. package/commands/aeo.js +9 -12
  15. package/commands/analytics.js +8 -11
  16. package/commands/autopilot.js +79 -8
  17. package/commands/avail.js +1 -1
  18. package/commands/brain.js +3 -1
  19. package/commands/business.js +13 -8
  20. package/commands/clean.js +17 -0
  21. package/commands/close.js +2 -1
  22. package/commands/computer.js +15 -10
  23. package/commands/context-sync.js +3 -3
  24. package/commands/doc-health.js +55 -2
  25. package/commands/engine.js +384 -4
  26. package/commands/experiments.js +13 -4
  27. package/commands/host.js +53 -0
  28. package/commands/improve.js +1 -1
  29. package/commands/integrations.js +2 -5
  30. package/commands/learn.js +1 -1
  31. package/commands/member.js +231 -53
  32. package/commands/mission.js +129 -19
  33. package/commands/one-lap.js +58 -3
  34. package/commands/pack.js +2 -2
  35. package/commands/probe.js +11 -5
  36. package/commands/pull.js +1 -1
  37. package/commands/push.js +1 -1
  38. package/commands/rainmaker.js +3 -16
  39. package/commands/run.js +4 -1
  40. package/commands/scout.js +1 -1
  41. package/commands/search.js +5 -3
  42. package/commands/spaceship.js +1 -1
  43. package/commands/stream.js +27 -2
  44. package/commands/study.js +15 -9
  45. package/commands/sync.js +1 -1
  46. package/commands/task.js +13 -2
  47. package/commands/unknowns.js +5 -0
  48. package/commands/verify.js +12 -4
  49. package/commands/visualize.js +20 -3
  50. package/commands/workflow.js +23 -7
  51. package/lib/codex-flight.js +28 -5
  52. package/lib/dispatch-scout.js +31 -22
  53. package/lib/engine-registry.js +1482 -13
  54. package/lib/feedback-triage.js +2 -0
  55. package/lib/fleet.js +111 -12
  56. package/lib/host.js +336 -0
  57. package/lib/improve-vitals-html.js +3 -2
  58. package/lib/known-commands.js +1 -1
  59. package/lib/lesson-contradiction.js +2 -2
  60. package/lib/map-refs.js +226 -0
  61. package/lib/member-engine.js +253 -0
  62. package/lib/mission-room.js +4 -4
  63. package/lib/noninteractive.js +15 -4
  64. package/lib/policy-lessons.js +1 -1
  65. package/lib/receipt-block.js +137 -28
  66. package/lib/roster-markdown.js +206 -0
  67. package/lib/roster-models.js +179 -0
  68. package/lib/runner-command.js +95 -15
  69. package/lib/scorecard.js +4 -4
  70. package/lib/self-drive.js +9 -1
  71. package/lib/sleep-sync.js +11 -0
  72. package/lib/state-detection.js +28 -9
  73. package/lib/task-db.js +3 -2
  74. package/lib/todo-fallback.js +1 -1
  75. package/lib/wish-audit.js +10 -6
  76. package/lib/wish-delegate.js +29 -5
  77. package/mcp/atris-mcp/index.mjs +77 -24
  78. package/package.json +2 -5
  79. package/scripts/det/det.js +2 -2
  80. package/scripts/det/pr-description.js +2 -2
  81. package/scripts/det/test.js +2 -2
  82. package/templates/members/host/MEMBER.md +23 -0
  83. package/templates/members/host/MISSION.md +11 -0
  84. package/templates/members/host/SOUL.md +7 -0
  85. package/templates/members/host/context/why.md +3 -0
  86. package/templates/members/host/goals.json +5 -0
  87. package/templates/members/host/goals.md +3 -0
  88. package/templates/members/host/skills/host/SKILL.md +13 -0
  89. package/utils/api.js +1 -1
  90. package/utils/auth.js +17 -7
  91. package/utils/backend-root.js +31 -0
  92. package/utils/config.js +5 -4
  93. package/utils/owner-identity.js +61 -0
  94. package/atris/policies/LESSONS.md +0 -13
  95. package/atris/skills/autopilot/atris-autopilot.md +0 -71
@@ -7,6 +7,13 @@
7
7
  // same names ride --engine on mission run / autopilot / run.
8
8
  //
9
9
  // atris engine roster + current default
10
+ // atris engine roster show every job's workers, built-in jobs first
11
+ // atris engine roster confirm renew every dated worker for 30 days
12
+ // atris engine roster session [clear] this shell's own changes
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]
16
+ // jobs are open-ended: search, build, review, or your own ("small build")
10
17
  // atris engine cursor make cursor the default engine here
11
18
  // atris engine reset back to the house default (atris-fast)
12
19
  //
@@ -27,8 +34,30 @@ const {
27
34
  } = require('../lib/runner-command');
28
35
  const { parseScopeFlag } = require('../lib/cli-scope');
29
36
  const { isFreshWorkspace, speakFirstMinute } = require('../lib/first-minute');
37
+ const { teamRosterView } = require('../lib/member-engine');
38
+ const { engineRunsView, availableModels } = require('../lib/roster-models');
30
39
  const {
31
40
  ENGINE_ROLES,
41
+ ENGINE_JOBS,
42
+ ROSTER_SOURCES,
43
+ ROSTER_LAYERS,
44
+ rosterDecided,
45
+ rosterWorkerWalk,
46
+ rosterToolLabel,
47
+ rosterSessionKey,
48
+ clearSessionRoster,
49
+ NO_SESSION_MESSAGE,
50
+ rosterJobKey,
51
+ rosterJobLabel,
52
+ rosterJobNameError,
53
+ rosterJobRole,
54
+ readRosterState,
55
+ customRosterJobKeys,
56
+ setRosterPick,
57
+ confirmRoster,
58
+ rosterMaxText,
59
+ rosterPinFor,
60
+ parseRosterUntil,
32
61
  ENGINE_DUTIES,
33
62
  ENGINE_HEALTH_STATUSES,
34
63
  binInstalled,
@@ -908,6 +937,7 @@ async function runEngineSeedCommand(args, root, deps = {}) {
908
937
 
909
938
  function printRoster(root, { scope = 'workspace' } = {}) {
910
939
  reconcileStaleEngineProbeErrors(root);
940
+ printJobRoster(root);
911
941
  const list = roster(root);
912
942
  const scoped = scope === 'global'
913
943
  ? list
@@ -952,10 +982,336 @@ function registryPayload(root, { scope = 'workspace', includeHidden = false } =
952
982
  scope,
953
983
  default: current.name,
954
984
  source: current.source,
985
+ roster: jobRosterView(root),
955
986
  engines: scoped,
956
987
  };
957
988
  }
958
989
 
990
+ // A hand-edited file can hold a string or a list where a pick belongs. Only
991
+ // an object naming an engine counts; anything else shows as no pick.
992
+ function rosterPickEntry(value) {
993
+ return value && typeof value === 'object' && !Array.isArray(value) && value.engine ? value : null;
994
+ }
995
+
996
+ const WORKER_SKIP_WORDS = Object.freeze({ 'not ready': 'down', expired: 'expired', 'bad model': 'names a model it cannot run' });
997
+
998
+ // One worker of a job's team as the view shows it: what it runs, and whether
999
+ // it leads now, backs up, or is skipped and why.
1000
+ function workerRow(step, index, leadIndex) {
1001
+ const { worker } = step;
1002
+ const runs = worker.error ? null : engineRunsView(worker.engine, { model: worker.model || '', effort: worker.effort || '' });
1003
+ 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] : '';
1005
+ return {
1006
+ engine: worker.engine || null,
1007
+ model: worker.model || null,
1008
+ effort: worker.effort || null,
1009
+ max_seconds: Number(worker.max_seconds) > 0 ? Number(worker.max_seconds) : null,
1010
+ until: worker.until || null,
1011
+ line: worker.line || null,
1012
+ ...(worker.error ? { text: worker.text || '' } : {}),
1013
+ status,
1014
+ why,
1015
+ runs,
1016
+ };
1017
+ }
1018
+
1019
+ function jobRosterRow(job, role, root, state, registry, now, key = role) {
1020
+ const custom = key !== role || !ENGINE_ROLES.includes(key);
1021
+ const layerPicks = Object.fromEntries(ROSTER_LAYERS.map((scope) => [scope, (state[scope] && state[scope].picks) || {}]));
1022
+ const picks = Object.fromEntries(ROSTER_LAYERS.map((scope) => [scope, rosterPickEntry(layerPicks[scope][key])]));
1023
+ const resolved = resolveEngineForRoleRanked(role, root, {
1024
+ now,
1025
+ sessionRosterPicks: layerPicks.session,
1026
+ projectRosterPicks: layerPicks.project,
1027
+ machineRosterPicks: layerPicks.machine,
1028
+ ...(custom ? { job: key } : {}),
1029
+ });
1030
+ // Show the layer that decided; when none did, show the first one set. A
1031
+ // custom job that fell back to its kind's pick did not decide on its own.
1032
+ const decided = rosterDecided(resolved.source) && (!custom || resolved.job === job);
1033
+ const source = decided ? resolved.source : ROSTER_LAYERS.find((scope) => picks[scope]) || null;
1034
+ const pick = source ? picks[source] : null;
1035
+ const walk = pick ? rosterWorkerWalk(pick, role, registry, now) : [];
1036
+ const leadIndex = decided && resolved.source === source ? walk.findIndex((step) => !step.skip) : -1;
1037
+ const good = walk.map((step, index) => ({ step, index })).filter(({ step }) => !step.worker.error);
1038
+ const first = good[0] || null;
1039
+ const status = !pick ? 'router'
1040
+ : first && first.step.skip === 'expired' ? 'expired'
1041
+ : first && leadIndex === first.index ? 'picked'
1042
+ : 'not ready';
1043
+ const lead = leadIndex === -1 ? 'none'
1044
+ : first && leadIndex === first.index ? 'first'
1045
+ : good[1] && leadIndex === good[1].index ? 'backup'
1046
+ : 'later';
1047
+ const kind = Object.keys(ENGINE_JOBS).find((name) => ENGINE_JOBS[name] === role);
1048
+ // What the job really runs: its first two good workers, each with its own
1049
+ // model and effort, or the router's engine when no line decides.
1050
+ const runsOf = (entry) => (entry ? engineRunsView(entry.step.worker.engine, { model: entry.step.worker.model || '', effort: entry.step.worker.effort || '' }) : null);
1051
+ const runs = runsOf(first);
1052
+ const nowRuns = resolved.engine
1053
+ ? engineRunsView(resolved.engine.id, { model: resolved.engine.roster_model || '', effort: resolved.engine.roster_effort || '' })
1054
+ : null;
1055
+ return {
1056
+ job,
1057
+ role,
1058
+ ...(custom ? { key, like: kind } : {}),
1059
+ pick,
1060
+ from: source ? ROSTER_SOURCES[source] : null,
1061
+ file: source ? state[source].file : null,
1062
+ session_pick: picks.session,
1063
+ project_pick: picks.project,
1064
+ machine_pick: picks.machine,
1065
+ engine: resolved.engine ? resolved.engine.id : null,
1066
+ model: resolved.engine && resolved.engine.roster_model ? resolved.engine.roster_model : null,
1067
+ effort: resolved.engine && resolved.engine.roster_effort ? resolved.engine.roster_effort : null,
1068
+ max_seconds: pick && Number(pick.max_seconds) > 0 ? Number(pick.max_seconds) : null,
1069
+ runs: runs || nowRuns,
1070
+ backup_runs: runsOf(good[1]),
1071
+ now_runs: nowRuns,
1072
+ workers: walk.map((step, index) => workerRow(step, index, leadIndex)),
1073
+ lead,
1074
+ status,
1075
+ reason: resolved.reason,
1076
+ };
1077
+ }
1078
+
1079
+ // The three built-in jobs first, then the owner's own jobs from this
1080
+ // session, this project, and the all-projects roster.
1081
+ function jobRosterView(root = process.cwd(), now = new Date(), state = null) {
1082
+ const registry = readEngineRegistry(root);
1083
+ const layers = state || readRosterState(root, { now });
1084
+ const rows = Object.entries(ENGINE_JOBS).map(([job, role]) => jobRosterRow(job, role, root, layers, registry, now));
1085
+ const customKeys = [...new Set(ROSTER_LAYERS.flatMap((scope) => customRosterJobKeys(layers[scope] && layers[scope].picks)))];
1086
+ for (const key of customKeys) {
1087
+ const role = rosterJobRole(key, root, {
1088
+ now,
1089
+ sessionRosterPicks: layers.session.picks,
1090
+ projectRosterPicks: layers.project.picks,
1091
+ machineRosterPicks: layers.machine.picks,
1092
+ });
1093
+ if (!role) continue;
1094
+ rows.push(jobRosterRow(rosterJobLabel(key), role, root, layers, registry, now, key));
1095
+ }
1096
+ return rows;
1097
+ }
1098
+
1099
+ // Jobs, the team, and one warning per roster line that could not be used.
1100
+ function rosterReport(root = process.cwd(), now = new Date()) {
1101
+ const state = readRosterState(root, { now });
1102
+ const jobs = jobRosterView(root, now, state);
1103
+ const team = teamRosterView(root, { now, rosterState: state });
1104
+ return {
1105
+ jobs,
1106
+ team: team.rows,
1107
+ warnings: [...state.session.warnings, ...state.project.warnings, ...state.machine.warnings, ...team.warnings],
1108
+ files: {
1109
+ session: state.session.format === 'none' ? null : state.session.file,
1110
+ project: state.project.format === 'none' ? null : state.project.file,
1111
+ machine: state.machine.format === 'none' ? null : state.machine.file,
1112
+ },
1113
+ session: state.session.key || null,
1114
+ };
1115
+ }
1116
+
1117
+ function untilText(worker) {
1118
+ const until = parseRosterUntil(worker.until);
1119
+ if (until) return `until ${until.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }).toLowerCase()}`;
1120
+ return worker.never_expires ? 'no end date' : 'until no valid date';
1121
+ }
1122
+
1123
+ // Every worker under its job, numbered in order, when the job has more than
1124
+ // the lead and one backup (the job's own line already shows those two).
1125
+ function renderWorkerLines(row) {
1126
+ if (!row.workers || row.workers.length <= 2) return [];
1127
+ const width = Math.max(...row.workers.map((worker) => (worker.runs ? worker.runs.text : worker.text || '').length));
1128
+ return row.workers.map((worker, index) => {
1129
+ 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);
1131
+ const state = worker.status === 'leads' ? 'leads now' : worker.status === 'backup' ? 'backup' : `skipped, ${worker.why}`;
1132
+ return ` ${index + 1}. ${what} ${[state, ...extras].join(', ')}`.trimEnd();
1133
+ });
1134
+ }
1135
+
1136
+ function renderJobRoster(rows) {
1137
+ const width = Math.max(7, ...rows.map((row) => row.job.length));
1138
+ const ownerWidth = Math.max(24, ...rows.filter((row) => row.pick && row.runs).map((row) => row.runs.text.length));
1139
+ const backupWidth = Math.max(16, ...rows.filter((row) => row.backup_runs).map((row) => row.backup_runs.text.length + 7));
1140
+ return rows.map((row) => {
1141
+ const label = row.job.padEnd(width);
1142
+ const nowText = row.now_runs ? row.now_runs.text : 'no ready engine';
1143
+ const fallsTo = `${row.like ? `falls back to ${row.like}` : 'router decides'}: ${nowText}`;
1144
+ if (!row.pick) return `${label} no pick, ${fallsTo}`;
1145
+ 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)}, ` : '';
1148
+ const date = untilText(row.pick);
1149
+ const fallback = row.lead === 'backup' ? 'using backup' : row.lead === 'later' ? `using ${nowText}` : fallsTo;
1150
+ const status = row.status === 'expired' ? `expired, ${fallback}`
1151
+ : row.status === 'not ready' ? `not ready, ${fallback}`
1152
+ : date;
1153
+ const where = row.file ? `${row.from} (${row.file})` : row.from;
1154
+ const head = `${label} ${owner} ${backup.padEnd(backupWidth)} ${cap}${status}, ${where}`.trimEnd();
1155
+ return [head, ...renderWorkerLines(row)].join('\n');
1156
+ }).join('\n');
1157
+ }
1158
+
1159
+ function renderTeamRoster(rows) {
1160
+ if (!rows.length) return '';
1161
+ const width = Math.max(6, ...rows.map((row) => row.member.length));
1162
+ const jobWidth = Math.max(6, ...rows.map((row) => String(row.job || '').length));
1163
+ const lines = rows.map((row) => {
1164
+ const engine = row.engine ? engineRunsView(row.engine, { model: row.model || '', effort: row.effort || '' }).text : 'no ready engine';
1165
+ const how = row.source === 'file' ? `from ${row.file}` : 'automatic';
1166
+ return `${row.member.padEnd(width)} ${String(row.job || '').padEnd(jobWidth)} ${engine.padEnd(24)} ${how}`.trimEnd();
1167
+ });
1168
+ return ['team', ...lines].join('\n');
1169
+ }
1170
+
1171
+ function renderRosterWarnings(warnings) {
1172
+ return warnings.map((warning) => `warning: ${warning.file} line ${warning.line} "${warning.text}" ${warning.message}.`).join('\n');
1173
+ }
1174
+
1175
+ function renderRosterReport(report) {
1176
+ return [renderJobRoster(report.jobs), renderTeamRoster(report.team), renderRosterWarnings(report.warnings)]
1177
+ .filter(Boolean)
1178
+ .join('\n\n');
1179
+ }
1180
+
1181
+ function printJobRoster(root, now = new Date()) {
1182
+ console.log(renderRosterReport(rosterReport(root, now)));
1183
+ }
1184
+
1185
+ const AVAILABLE_HINT = 'see which tools and models this machine has: atris engine roster --available';
1186
+
1187
+ // Each installed tool and the models it offers, from local files only.
1188
+ function availableReport(root) {
1189
+ const engines = readEngineRegistry(root, { persist: false }).engines
1190
+ .filter((engine) => !HIDDEN_PROFILE_NAMES.includes(engine.id))
1191
+ .filter((engine) => engine.installed || (engine.health && engine.health.status === 'ready'));
1192
+ return availableModels(engines).map((row) => ({ ...row, tool: rosterToolLabel(row.engine) }));
1193
+ }
1194
+
1195
+ function renderAvailable(rows) {
1196
+ if (!rows.length) return 'no tools found on this machine. run atris engine doctor to check again';
1197
+ const width = Math.max(...rows.map((row) => row.tool.length));
1198
+ return ['on this machine', ...rows.map((row) => `${row.tool.padEnd(width)} ${row.models.join(', ') || 'its own default'} (${row.source})`)].join('\n');
1199
+ }
1200
+
1201
+ // Only this session's changes, from its own file.
1202
+ function renderSessionRoster(report, key) {
1203
+ const jobs = report.jobs.filter((row) => row.from === ROSTER_SOURCES.session);
1204
+ const team = report.team.filter((row) => row.source === 'file' && row.file === report.files.session);
1205
+ if (!jobs.length && !team.length) return `this session (${key}) has no roster changes. add one with: atris engine assign <job> <tool> --session`;
1206
+ const head = `this session (${key}) changes${report.files.session ? `, from ${report.files.session}` : ''}`;
1207
+ return [head, renderJobRoster(jobs), renderTeamRoster(team)].filter(Boolean).join('\n\n');
1208
+ }
1209
+
1210
+ function runRosterSessionCommand(rest, json, root, now) {
1211
+ const key = rosterSessionKey();
1212
+ if (rest[1] === 'clear') {
1213
+ try {
1214
+ const cleared = clearSessionRoster();
1215
+ console.log(cleared ? 'cleared this session\'s roster changes' : 'this session has no roster changes to clear');
1216
+ return 0;
1217
+ } catch (err) {
1218
+ console.error(err.message);
1219
+ return 2;
1220
+ }
1221
+ }
1222
+ if (!key) {
1223
+ console.error(NO_SESSION_MESSAGE);
1224
+ return 2;
1225
+ }
1226
+ const report = rosterReport(root, now);
1227
+ if (json) {
1228
+ const jobs = report.jobs.filter((row) => row.from === ROSTER_SOURCES.session);
1229
+ const team = report.team.filter((row) => row.source === 'file' && row.file === report.files.session);
1230
+ console.log(JSON.stringify({ session: key, file: report.files.session, jobs, team }, null, 2));
1231
+ } else {
1232
+ console.log(renderSessionRoster(report, key));
1233
+ }
1234
+ return 0;
1235
+ }
1236
+
1237
+ function runRosterCommand(args, root, now = new Date()) {
1238
+ const json = args.includes('--json');
1239
+ const available = args.includes('--available');
1240
+ const rest = args.filter((arg) => arg !== '--json' && arg !== '--available');
1241
+ const known = !rest.length
1242
+ || (rest.length === 1 && ['confirm', 'session'].includes(rest[0]))
1243
+ || (rest.length === 2 && rest[0] === 'session' && rest[1] === 'clear');
1244
+ if (!known || (available && rest.length)) {
1245
+ console.error('usage: atris engine roster [confirm | session [clear] | --available] [--json]');
1246
+ return 2;
1247
+ }
1248
+ if (available) {
1249
+ const rows = availableReport(root);
1250
+ console.log(json ? JSON.stringify({ tools: rows }, null, 2) : renderAvailable(rows));
1251
+ return 0;
1252
+ }
1253
+ if (rest[0] === 'session') return runRosterSessionCommand(rest, json, root, now);
1254
+ if (rest[0] === 'confirm') confirmRoster(root, now);
1255
+ const report = rosterReport(root, now);
1256
+ if (json) console.log(JSON.stringify(report, null, 2));
1257
+ else console.log(`${renderRosterReport(report)}\n\n${AVAILABLE_HINT}`);
1258
+ return 0;
1259
+ }
1260
+
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';
1262
+
1263
+ function runAssignCommand(args, root, now = new Date()) {
1264
+ const job = args[0] && !String(args[0]).startsWith('--') ? args[0] : '';
1265
+ if (!job) {
1266
+ console.error('name the job first: atris engine assign <job> <tool>. jobs: search, build, review, or your own, like "small build"');
1267
+ return 2;
1268
+ }
1269
+ if (!rosterJobKey(job)) {
1270
+ console.error(rosterJobNameError(job));
1271
+ return 2;
1272
+ }
1273
+ const switches = ['--everywhere', '--session', '--clear', '--add'];
1274
+ const everywhere = args.includes('--everywhere');
1275
+ const session = args.includes('--session');
1276
+ const clear = args.includes('--clear');
1277
+ const add = args.includes('--add');
1278
+ const rest = args.slice(1).filter((arg) => !switches.includes(arg));
1279
+ // The tool may be several words: claude code, atris fast.
1280
+ const toolWords = [];
1281
+ while (rest.length && !rest[0].startsWith('--')) toolWords.push(rest.shift());
1282
+ const engine = toolWords.join(' ');
1283
+ const flags = {};
1284
+ for (let i = 0; i < rest.length; i += 1) {
1285
+ const arg = rest[i];
1286
+ if (!['--model', '--backup', '--days', '--like', '--effort', '--max', '--remove'].includes(arg) || !rest[i + 1] || rest[i + 1].startsWith('--')) {
1287
+ console.error(`invalid assign option "${arg}"`);
1288
+ return 2;
1289
+ }
1290
+ flags[arg.slice(2)] = rest[++i];
1291
+ }
1292
+ const removing = flags.remove !== undefined;
1293
+ if (clear && (engine || Object.keys(flags).length || add)) {
1294
+ console.error('use --clear without an engine or other options');
1295
+ return 2;
1296
+ }
1297
+ if (removing && (engine || Object.keys(flags).length > 1 || add)) {
1298
+ console.error('use --remove <tool> without other options');
1299
+ return 2;
1300
+ }
1301
+ if (!clear && !removing && !engine) {
1302
+ console.error(ASSIGN_USAGE);
1303
+ return 2;
1304
+ }
1305
+ try {
1306
+ setRosterPick(job, engine, { ...flags, clear, add, session, everywhere, now }, root);
1307
+ printJobRoster(root, now);
1308
+ return 0;
1309
+ } catch (err) {
1310
+ console.error(err.message);
1311
+ return 2;
1312
+ }
1313
+ }
1314
+
959
1315
  function chartEngineLabel(engine) {
960
1316
  const id = String(engine && (engine.id || engine.name) || '').trim();
961
1317
  const models = Array.isArray(engine && engine.models)
@@ -1104,14 +1460,22 @@ function runResolveCommand(args, root) {
1104
1460
  const json = args.includes('--json');
1105
1461
  const role = args.filter((a) => !String(a).startsWith('--'))[0] || '';
1106
1462
  if (!role) {
1107
- const message = `usage: atris engine resolve <role>; roles: ${ENGINE_ROLES.join(', ')}`;
1463
+ const message = `usage: atris engine resolve <role> or <job>; roles: ${ENGINE_ROLES.join(', ')}; jobs: search, build, review, or your own`;
1108
1464
  if (json) console.log(JSON.stringify({ ok: false, error: message }, null, 2));
1109
1465
  else console.error(message);
1110
1466
  return 2;
1111
1467
  }
1112
1468
  let picked;
1113
1469
  try {
1114
- picked = resolveEngineForRoleRanked(role, root);
1470
+ // A role (navigator, executor, validator) resolves as before; any job
1471
+ // name, built-in or the owner's own, resolves through its roster pick.
1472
+ const jobRole = ENGINE_ROLES.includes(String(role).trim().toLowerCase()) ? '' : rosterJobRole(role, root);
1473
+ if (!ENGINE_ROLES.includes(String(role).trim().toLowerCase()) && !jobRole) {
1474
+ throw new Error(`Unknown role "${role}". Known roles: ${ENGINE_ROLES.join(', ')}; jobs: search, build, review, or one set with atris engine assign`);
1475
+ }
1476
+ picked = jobRole
1477
+ ? resolveEngineForRoleRanked(jobRole, root, { job: role })
1478
+ : resolveEngineForRoleRanked(role, root);
1115
1479
  } catch (err) {
1116
1480
  if (json) console.log(JSON.stringify({ ok: false, error: err.message }, null, 2));
1117
1481
  else console.error(err.message);
@@ -1578,7 +1942,20 @@ function runDispatchCommand(args, root) {
1578
1942
  : `engine dispatch: ${err.message}`);
1579
1943
  return 2;
1580
1944
  }
1581
- return runDispatchFlight({ root, taskIds, engine: canonical, prompt: promptOverride, yolo, ...(base ? { checkoutBase: base } : {}) }).then((flight) => {
1945
+ // The build line's model, effort, and time cap ride along when the roster
1946
+ // picks this same engine for build.
1947
+ const pin = rosterPinFor('executor', canonical, root);
1948
+ return runDispatchFlight({
1949
+ root,
1950
+ taskIds,
1951
+ engine: canonical,
1952
+ prompt: promptOverride,
1953
+ yolo,
1954
+ ...(base ? { checkoutBase: base } : {}),
1955
+ ...(pin.roster_model ? { model: pin.roster_model } : {}),
1956
+ ...(pin.roster_effort ? { effort: pin.roster_effort } : {}),
1957
+ ...(pin.roster_max_seconds ? { maxSeconds: pin.roster_max_seconds } : {}),
1958
+ }).then((flight) => {
1582
1959
  if (json) console.log(JSON.stringify(flight, null, 2));
1583
1960
  return flight.paused.length ? 1 : 0;
1584
1961
  });
@@ -1657,6 +2034,9 @@ function engineCommand(args = [], deps = {}) {
1657
2034
  const positional = scope.args.filter((a) => !a.startsWith('--'));
1658
2035
  const sub = (positional[0] || '').trim();
1659
2036
 
2037
+ if (sub === 'roster') return runRosterCommand(scope.args.slice(scope.args.indexOf('roster') + 1), root, deps.now);
2038
+ if (sub === 'assign') return runAssignCommand(scope.args.slice(scope.args.indexOf('assign') + 1), root, deps.now);
2039
+
1660
2040
  if (sub === 'login') {
1661
2041
  return runEngineLoginCommand(scope.args.slice(scope.args.indexOf('login') + 1), root);
1662
2042
  }
@@ -1705,7 +2085,7 @@ function engineCommand(args = [], deps = {}) {
1705
2085
  if (sub === 'help' || args.includes('--help') || args.includes('-h')) {
1706
2086
  console.log('\n atris engine watch [<id>|latest] [--no-follow]\n follow one live transcript or list running engine work');
1707
2087
  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');
1708
- console.log('\n atris engine roster + current default\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> [--json]\n choose the best ready engine for navigator|executor|validator\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');
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');
1709
2089
  return 0;
1710
2090
  }
1711
2091
  if (isFreshWorkspace(root)) {
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { spawnSync } = require('child_process');
4
+ const { BACKEND_ROOT_HINT, resolveBackendRoot } = require('../utils/backend-root');
4
5
  const { runTaskOnce } = require('./autopilot');
5
6
  const {
6
7
  appendResultsRow,
@@ -391,7 +392,7 @@ function parseRunOptions(args) {
391
392
  const options = {
392
393
  dryRun: false,
393
394
  verbose: false,
394
- backendPath: path.resolve(process.cwd(), '../atrisos-backend'),
395
+ backendPath: null,
395
396
  interventions: [],
396
397
  };
397
398
 
@@ -527,8 +528,10 @@ function buildBenchmarkArtifact(name, packDir, options) {
527
528
  'atris/features/endstate/contract.md',
528
529
  'atris/features/endstate/artifact-schema.json',
529
530
  path.relative(cwd, path.join(packDir, 'program.md')),
530
- '../atrisos-backend/atris/MAP.md',
531
- '../atrisos-backend/atris/TODO.md',
531
+ ...(backendPath ? [
532
+ path.relative(cwd, path.join(backendPath, 'atris', 'MAP.md')),
533
+ path.relative(cwd, path.join(backendPath, 'atris', 'TODO.md')),
534
+ ] : []),
532
535
  ],
533
536
  contextNote: [
534
537
  `Project Endstate track: ${track}`,
@@ -555,7 +558,7 @@ function buildBenchmarkArtifact(name, packDir, options) {
555
558
  ? []
556
559
  : [
557
560
  ...collectChangedFiles(cwd, beforeCli, afterCli),
558
- ...collectChangedFiles(backendPath, beforeBackend, afterBackend, '../atrisos-backend/'),
561
+ ...(backendPath ? collectChangedFiles(backendPath, beforeBackend, afterBackend, `${path.relative(cwd, backendPath)}/`) : []),
559
562
  ];
560
563
  const wikiAfter = readTextIfExists(path.join(cwd, 'atris', 'wiki', 'STATUS.md'));
561
564
 
@@ -637,6 +640,12 @@ function experimentsRun(name, ...args) {
637
640
  return;
638
641
  }
639
642
 
643
+ if (!options.backendPath) options.backendPath = resolveBackendRoot();
644
+ if (!options.backendPath) {
645
+ console.error(BACKEND_ROOT_HINT);
646
+ process.exit(1);
647
+ }
648
+
640
649
  const schemaPath = path.join(process.cwd(), 'atris', 'features', 'endstate', 'artifact-schema.json');
641
650
  const contractPath = path.join(process.cwd(), 'atris', 'features', 'endstate', 'contract.md');
642
651
  if (!fs.existsSync(schemaPath) || !fs.existsSync(contractPath)) {
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ const { hostAction } = require('../lib/host');
4
+
5
+ function parse(argv) {
6
+ const options = {};
7
+ const positionals = [];
8
+ const flags = new Set(['id', 'name', 'team', 'manager', 'door', 'now', 'question', 'event-id', 'from', 'text', 'patch', 'expected-revision', 'source', 'evidence', 'reason', 'activity', 'as']);
9
+ for (let i = 0; i < argv.length; i += 1) {
10
+ const item = argv[i];
11
+ if (item === '--json') { options.json = true; continue; }
12
+ if (item.startsWith('--')) {
13
+ const key = item.slice(2);
14
+ if (!flags.has(key) || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error(`invalid --${key} value`);
15
+ options[key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = argv[++i];
16
+ } else positionals.push(item);
17
+ }
18
+ return { options, positionals };
19
+ }
20
+
21
+ function human(command, result) {
22
+ if (command === 'room') return result.text;
23
+ if (command === 'due') return result.length ? result.map((entry) => `${entry.name} (${entry.id}) is due for a question.`).join('\n') : 'no questions due';
24
+ if (command === 'outbox') return result.length ? result.map((entry) => `${entry.id} ${entry.kind} to ${entry.to}: ${entry.text}`).join('\n') : 'outbox empty';
25
+ if (command === 'view') return [...result.cards.map((entry) => entry.card), result.own ? `Your private record:\n${JSON.stringify(result.own, null, 2)}` : ''].filter(Boolean).join('\n');
26
+ if (command === 'receive') return result.duplicate ? 'event already recorded' : `${result.kind} recorded for ${result.id}`;
27
+ return `${command} complete: ${JSON.stringify(result)}`;
28
+ }
29
+
30
+ function hostCommand(argv = process.argv.slice(3), root = process.cwd()) {
31
+ let jsonOutput = argv.includes('--json');
32
+ try {
33
+ const [command, ...rest] = argv;
34
+ if (!command || command === '--help' || command === '-h') {
35
+ const usage = 'usage: atris host join|leave|pause|resume|forget|due|ask|receive|card|link|propose|outbox|sent|room|view [options]';
36
+ console.log(jsonOutput ? JSON.stringify({ usage }) : usage);
37
+ return;
38
+ }
39
+ const { options, positionals } = parse(rest);
40
+ jsonOutput = options.json;
41
+ const args = { ...options };
42
+ if (['leave', 'pause', 'resume', 'forget', 'ask', 'card', 'sent'].includes(command)) args.id = positionals[0];
43
+ if (['link', 'propose'].includes(command)) { args.a = positionals[0]; args.b = positionals[1]; }
44
+ if (command === 'receive') args.eventId = options.eventId;
45
+ const result = hostAction(root, command, args);
46
+ console.log(jsonOutput ? JSON.stringify(result) : human(command, result));
47
+ } catch (error) {
48
+ console.error(jsonOutput ? JSON.stringify({ error: error.message }) : `host: ${error.message}`);
49
+ process.exitCode = 1;
50
+ }
51
+ }
52
+
53
+ module.exports = { hostCommand };
@@ -37,7 +37,7 @@ const rsi = require('../lib/rsi-record');
37
37
  * writes. The path sent to the backend is left untouched, a remote tick
38
38
  * may target a server-side `~/...` workspace the server expands itself, but
39
39
  * local scorecard/journal writes must never create a literal `~` directory.
40
- * (Surfaced by a live plan tick whose `~/arena/...` arg wrote junk locally.)
40
+ * (Surfaced by a live plan tick whose home-relative path wrote junk locally.)
41
41
  */
42
42
  function expandHome(p) {
43
43
  if (typeof p === 'string' && (p === '~' || p.startsWith('~/'))) {
@@ -35,6 +35,7 @@ const os = require('os');
35
35
  const path = require('path');
36
36
  const readline = require('readline');
37
37
  const { spawnSync } = require('child_process');
38
+ const { sleepSync } = require('../lib/sleep-sync');
38
39
 
39
40
  const CALENDAR_CACHE_PATH = path.join(os.homedir(), '.atris', 'calendar-events-cache.json');
40
41
  const GMAIL_CONNECT_POLL_MS = 3000;
@@ -1812,10 +1813,6 @@ function writeImessageLookupCache(cache) {
1812
1813
  return cachePath;
1813
1814
  }
1814
1815
 
1815
- function sleepMs(ms) {
1816
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1817
- }
1818
-
1819
1816
  function readLatestOutgoingImessage(handle, sinceMs) {
1820
1817
  const chatDb = path.join(os.homedir(), 'Library', 'Messages', 'chat.db');
1821
1818
  const sinceUnix = Math.max(0, Math.floor(Number(sinceMs || Date.now()) / 1000) - 5);
@@ -1873,7 +1870,7 @@ function imessageVerifyLatestOutgoing(handle, sinceMs, options = {}) {
1873
1870
  && !(latest.is_sent || latest.is_delivered || latest.is_finished)
1874
1871
  && Date.now() - startedAt < timeoutMs
1875
1872
  ) {
1876
- sleepMs(intervalMs);
1873
+ sleepSync(intervalMs);
1877
1874
  latest = readLatestOutgoingImessage(handle, sinceMs);
1878
1875
  }
1879
1876
  return {
package/commands/learn.js CHANGED
@@ -460,7 +460,7 @@ function harvestFromJournals(deps = {}) {
460
460
  const lines = notesMatch[1].trim().split('\n').filter(l => l.startsWith('- '));
461
461
  for (const line of lines) {
462
462
  // Strip bullet and optional timestamp prefix
463
- const insight = line.replace(/^- (\d{2}:\d{2} \u2014 )?/, '').trim();
463
+ const insight = line.replace(/^- (\d{2}:\d{2} [-\u2014] )?/, '').trim();
464
464
  if (leftoverClaimableInsight(insight)) continue;
465
465
  if (insight.length > 10) {
466
466
  candidates.push({ insight, source: path.basename(logPath) });