atris 3.34.0 → 3.36.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 (163) hide show
  1. package/AGENTS.md +35 -0
  2. package/FOR_AGENTS.md +5 -3
  3. package/README.md +5 -3
  4. package/atris/GETTING_STARTED.md +1 -1
  5. package/atris/atris.md +3 -0
  6. package/atris/policies/day-loop-voice.md +102 -0
  7. package/atris/policies/outbound-artifact-gate.md +2 -0
  8. package/atris/skills/design/SKILL.md +56 -32
  9. package/atris/skills/endgame/SKILL.md +12 -6
  10. package/atris/skills/engines/SKILL.md +35 -8
  11. package/atris/skills/fable-method/SKILL.md +66 -0
  12. package/atris/skills/improve/SKILL.md +65 -45
  13. package/atris/skills/render-cli/SKILL.md +88 -0
  14. package/atris/skills/youtube/SKILL.md +10 -1
  15. package/atris.md +6 -2
  16. package/ax +617 -31
  17. package/bin/atris.js +719 -266
  18. package/commands/activate.js +194 -88
  19. package/commands/aeo.js +52 -0
  20. package/commands/agents.js +166 -0
  21. package/commands/autoland.js +718 -72
  22. package/commands/autopilot-front.js +20 -2
  23. package/commands/autopilot.js +118 -2
  24. package/commands/avail.js +407 -0
  25. package/commands/bench.js +188 -0
  26. package/commands/brain.js +3 -0
  27. package/commands/brief.js +651 -0
  28. package/commands/business-sync.js +192 -6
  29. package/commands/business.js +91 -8
  30. package/commands/clean.js +50 -24
  31. package/commands/close.js +1083 -0
  32. package/commands/cloud.js +245 -0
  33. package/commands/codex-goal.js +26 -2
  34. package/commands/compile.js +292 -1
  35. package/commands/computer.js +150 -3
  36. package/commands/dream.js +365 -0
  37. package/commands/drill.js +371 -0
  38. package/commands/drive.js +187 -0
  39. package/commands/engine.js +1061 -29
  40. package/commands/experiments.js +28 -0
  41. package/commands/feed.js +202 -0
  42. package/commands/feedback.js +34 -12
  43. package/commands/fleet-report.js +206 -0
  44. package/commands/github.js +38 -0
  45. package/commands/gm.js +285 -3
  46. package/commands/goal.js +247 -0
  47. package/commands/improve.js +642 -26
  48. package/commands/init.js +83 -43
  49. package/commands/integrations.js +39 -11
  50. package/commands/interview.js +209 -0
  51. package/commands/land.js +253 -52
  52. package/commands/lesson.js +112 -1
  53. package/commands/lifecycle.js +39 -3
  54. package/commands/linear.js +38 -0
  55. package/commands/log.js +84 -1
  56. package/commands/loops.js +220 -16
  57. package/commands/meet.js +220 -0
  58. package/commands/member.js +899 -66
  59. package/commands/mission.js +3512 -332
  60. package/commands/next.js +137 -0
  61. package/commands/now.js +240 -21
  62. package/commands/one-lap.js +776 -0
  63. package/commands/orb.js +314 -0
  64. package/commands/pack-craft.js +179 -0
  65. package/commands/pack.js +823 -0
  66. package/commands/play.js +3 -2
  67. package/commands/probe.js +30 -3
  68. package/commands/pulse.js +241 -46
  69. package/commands/push.js +260 -82
  70. package/commands/radar.js +259 -14
  71. package/commands/rainmaker.js +49 -0
  72. package/commands/report.js +415 -0
  73. package/commands/scout.js +147 -0
  74. package/commands/search.js +363 -0
  75. package/commands/serve.js +54 -0
  76. package/commands/skill.js +47 -3
  77. package/commands/slop.js +50 -2
  78. package/commands/soul.js +1 -1
  79. package/commands/status.js +50 -5
  80. package/commands/stream.js +861 -0
  81. package/commands/stripe.js +38 -0
  82. package/commands/study.js +693 -0
  83. package/commands/supabase.js +39 -0
  84. package/commands/sync.js +67 -54
  85. package/commands/task.js +2275 -182
  86. package/commands/team.js +73 -0
  87. package/commands/truth.js +29 -3
  88. package/commands/unknowns.js +627 -0
  89. package/commands/update.js +44 -0
  90. package/commands/vercel.js +38 -0
  91. package/commands/verify.js +96 -0
  92. package/commands/watch.js +303 -0
  93. package/commands/wish.js +500 -0
  94. package/commands/workflow.js +11 -5
  95. package/commands/worktree.js +299 -20
  96. package/commands/write.js +399 -0
  97. package/commands/xp.js +29 -11
  98. package/lib/auto-accept-certified.js +391 -43
  99. package/lib/autoland.js +353 -52
  100. package/lib/ax-auto-lane.js +79 -0
  101. package/lib/bench/context.js +147 -0
  102. package/lib/bench/engines.js +141 -0
  103. package/lib/bench/report.js +140 -0
  104. package/lib/bench/runner.js +512 -0
  105. package/lib/brief-ledger.js +350 -0
  106. package/lib/cloud-mission.js +259 -0
  107. package/lib/codex-flight.js +154 -0
  108. package/lib/default-runner.js +45 -0
  109. package/lib/default-verifier.js +70 -0
  110. package/lib/engine-registry.js +232 -0
  111. package/lib/experiments/daily.js +640 -0
  112. package/lib/fleet.js +2431 -38
  113. package/lib/improve-vitals-html.js +171 -0
  114. package/lib/known-commands.js +58 -0
  115. package/lib/loop-doctor.js +416 -0
  116. package/lib/member-switches.js +144 -0
  117. package/lib/memory-view.js +14 -5
  118. package/lib/mission-room.js +1 -0
  119. package/lib/mission-root.js +52 -0
  120. package/lib/mission-runtime-loop.js +7 -0
  121. package/lib/next-moves.js +327 -10
  122. package/lib/official-cli-integration.js +174 -0
  123. package/lib/one-lap-validator.js +60 -0
  124. package/lib/orb-context.js +477 -0
  125. package/lib/orb-scorecard.js +224 -0
  126. package/lib/outbound-send-gate.js +165 -0
  127. package/lib/permission-grants.js +293 -0
  128. package/lib/policy-lessons.js +52 -1
  129. package/lib/pulse.js +277 -3
  130. package/lib/receipt-block.js +168 -0
  131. package/lib/receipt-evidence.js +65 -4
  132. package/lib/review-integrity.js +147 -0
  133. package/lib/router-brain.js +352 -0
  134. package/lib/runner-command.js +33 -0
  135. package/lib/self-drive.js +258 -0
  136. package/lib/short-name.js +103 -0
  137. package/lib/spawn-env.js +18 -0
  138. package/lib/state-detection.js +56 -1
  139. package/lib/sync-status.js +59 -0
  140. package/lib/task-db.js +319 -27
  141. package/lib/task-proof.js +43 -1
  142. package/lib/task-receipt.js +93 -0
  143. package/lib/team-presence.js +260 -0
  144. package/lib/tool-result-encode.js +7 -0
  145. package/lib/trust-tiers.js +90 -0
  146. package/lib/usage.js +107 -0
  147. package/lib/voice-gate.js +163 -0
  148. package/lib/wish-audit.js +1368 -0
  149. package/lib/wish-delegate.js +1840 -0
  150. package/lib/wish-design.js +110 -0
  151. package/lib/wish-stats.js +183 -0
  152. package/lib/wish-store.js +354 -0
  153. package/lib/zip.js +221 -0
  154. package/package.json +3 -1
  155. package/templates/loops/atris/loops/LOOPS.md +55 -0
  156. package/templates/loops/atris/loops/TICK.md +24 -0
  157. package/templates/loops/atris/loops/feedback.md +22 -0
  158. package/templates/loops/atris/loops/quality.md +22 -0
  159. package/templates/loops/atris/wiki/systems/loops.md +41 -0
  160. package/utils/api.js +5 -1
  161. package/utils/auth.js +57 -21
  162. package/utils/update-check.js +27 -6
  163. package/atris/learnings.jsonl +0 -1
@@ -16,33 +16,66 @@
16
16
  const fs = require('fs');
17
17
  const os = require('os');
18
18
  const path = require('path');
19
+ const readline = require('readline');
19
20
  const { spawnSync } = require('child_process');
21
+ const { Writable } = require('stream');
20
22
  const {
21
23
  RUNNER_PROFILE_DEFS,
22
- RUNNER_PROFILE_ALIASES,
23
24
  RUNNER_PROFILE_NAMES,
24
25
  buildRunnerCommand,
25
26
  } = require('../lib/runner-command');
27
+ const {
28
+ ENGINE_ROLES,
29
+ ENGINE_HEALTH_STATUSES,
30
+ binInstalled,
31
+ canonicalEngineName,
32
+ engineRegistryFile,
33
+ engineRegistryView,
34
+ readEngineRegistry,
35
+ resolveEngineForRole,
36
+ setEngineHealth,
37
+ } = require('../lib/engine-registry');
38
+ const { FLEET_CAPABLE, runDispatchFlight } = require('../lib/fleet');
39
+ const { ensureValidCredentials } = require('../utils/auth');
40
+ const { apiRequestJson } = require('../utils/api');
26
41
 
27
42
  const HOUSE_ENGINE = 'atris-fast';
43
+ const MAX_ENGINE_LOGIN_FILE_BYTES = 64 * 1024;
44
+ const ENGINE_DEVICE_LOGIN_POLL_MS = 3000;
45
+ const ENGINE_DEVICE_LOGIN_TIMEOUT_MS = 16 * 60 * 1000;
46
+ const ENGINE_LOGIN_MANIFESTS = Object.freeze({
47
+ codex: Object.freeze({
48
+ type: 'files',
49
+ files: Object.freeze(['~/.codex/auth.json']),
50
+ missingHint: 'run codex login first',
51
+ }),
52
+ claude: Object.freeze({
53
+ type: 'files',
54
+ files: Object.freeze(['~/.claude/.credentials.json']),
55
+ missingHint: 'run claude login first',
56
+ }),
57
+ cursor: Object.freeze({
58
+ type: 'files',
59
+ files: Object.freeze(['~/.cursor/cli-config.json']),
60
+ missingHint: 'run cursor login first',
61
+ }),
62
+ devin: Object.freeze({
63
+ type: 'api_key',
64
+ missingHint: 'paste a Devin API key',
65
+ }),
66
+ grok: Object.freeze({
67
+ type: 'files',
68
+ files: Object.freeze(['~/.grok/auth.json']),
69
+ missingHint: 'run grok and log in with grok.com',
70
+ }),
71
+ });
28
72
 
29
- function engineFile(root = process.cwd()) {
30
- return path.join(root, '.atris', 'engine.json');
31
- }
32
-
33
- function binInstalled(bin) {
34
- const safe = String(bin || '').replace(/[^A-Za-z0-9_.-]/g, '');
35
- if (!safe) return false;
36
- const probe = spawnSync('sh', ['-c', `command -v ${safe}`], { encoding: 'utf8' });
37
- return probe.status === 0 && Boolean(String(probe.stdout || '').trim());
73
+ function knownLoginProviders() {
74
+ return Object.keys(ENGINE_LOGIN_MANIFESTS);
38
75
  }
39
76
 
40
- function canonicalEngineName(name) {
41
- const trimmed = String(name || '').trim();
42
- if (!trimmed) return '';
43
- if (RUNNER_PROFILE_DEFS[trimmed]) return trimmed;
44
- if (RUNNER_PROFILE_ALIASES[trimmed]) return RUNNER_PROFILE_ALIASES[trimmed];
45
- return '';
77
+ function engineFile(root = process.cwd()) {
78
+ return path.join(root, '.atris', 'engine.json');
46
79
  }
47
80
 
48
81
  function readSavedEngine(root = process.cwd()) {
@@ -69,11 +102,9 @@ function resolveDefaultEngine(root = process.cwd()) {
69
102
 
70
103
  function roster(root = process.cwd()) {
71
104
  const current = resolveDefaultEngine(root);
72
- return RUNNER_PROFILE_NAMES.map((name) => ({
73
- name,
74
- bin: RUNNER_PROFILE_DEFS[name].bin,
75
- installed: binInstalled(RUNNER_PROFILE_DEFS[name].bin),
76
- default: name === current.name,
105
+ return engineRegistryView(root).map((engine) => ({
106
+ ...engine,
107
+ default: engine.id === current.name,
77
108
  }));
78
109
  }
79
110
 
@@ -92,6 +123,764 @@ function resetEngine(root = process.cwd()) {
92
123
  try { fs.unlinkSync(engineFile(root)); return true; } catch { return false; }
93
124
  }
94
125
 
126
+ function expandHomePath(filePath, homeDir = os.homedir()) {
127
+ const value = String(filePath || '');
128
+ if (value === '~') return homeDir;
129
+ if (value.startsWith('~/')) return path.join(homeDir, value.slice(2));
130
+ return value;
131
+ }
132
+
133
+ function normalizeLoginProvider(name) {
134
+ const raw = String(name || '').trim().toLowerCase();
135
+ if (!raw) return '';
136
+ const canonical = canonicalEngineName(raw) || raw;
137
+ return ENGINE_LOGIN_MANIFESTS[canonical] ? canonical : '';
138
+ }
139
+
140
+ function normalizeEngineLoginSeat(name) {
141
+ return String(name || '')
142
+ .trim()
143
+ .toUpperCase()
144
+ .replace(/[\s-]+/g, '_');
145
+ }
146
+
147
+ function validEngineLoginSeat(name) {
148
+ return /^[A-Z0-9][A-Z0-9_]{0,47}$/.test(String(name || ''));
149
+ }
150
+
151
+ function engineLoginSeatError() {
152
+ return 'seat names are letters, numbers, underscores - like personal or work';
153
+ }
154
+
155
+ function byteLength(value) {
156
+ return Buffer.byteLength(String(value || ''), 'utf8');
157
+ }
158
+
159
+ function detectedEmailFromJsonValue(value, depth = 0) {
160
+ if (depth > 6 || value == null) return '';
161
+ if (typeof value === 'string') {
162
+ const match = value.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
163
+ return match ? match[0] : '';
164
+ }
165
+ if (Array.isArray(value)) {
166
+ for (const item of value) {
167
+ const found = detectedEmailFromJsonValue(item, depth + 1);
168
+ if (found) return found;
169
+ }
170
+ return '';
171
+ }
172
+ if (typeof value === 'object') {
173
+ const preferred = ['email', 'account_email', 'user_email'];
174
+ for (const key of preferred) {
175
+ if (Object.prototype.hasOwnProperty.call(value, key)) {
176
+ const found = detectedEmailFromJsonValue(value[key], depth + 1);
177
+ if (found) return found;
178
+ }
179
+ }
180
+ for (const item of Object.values(value)) {
181
+ const found = detectedEmailFromJsonValue(item, depth + 1);
182
+ if (found) return found;
183
+ }
184
+ }
185
+ return '';
186
+ }
187
+
188
+ function detectedEmailFromContent(content) {
189
+ try {
190
+ return detectedEmailFromJsonValue(JSON.parse(content));
191
+ } catch {
192
+ return '';
193
+ }
194
+ }
195
+
196
+ function readEngineLoginFiles(provider, { homeDir = os.homedir(), fsModule = fs } = {}) {
197
+ const manifest = ENGINE_LOGIN_MANIFESTS[provider];
198
+ if (!manifest || manifest.type !== 'files') {
199
+ throw new Error(`No file manifest for ${provider}`);
200
+ }
201
+
202
+ const files = {};
203
+ const summary = [];
204
+ for (const displayPath of manifest.files) {
205
+ const absolutePath = expandHomePath(displayPath, homeDir);
206
+ let stat;
207
+ try {
208
+ stat = fsModule.statSync(absolutePath);
209
+ } catch {
210
+ const err = new Error(`Missing ${displayPath}. ${manifest.missingHint}.`);
211
+ err.code = 'missing_file';
212
+ throw err;
213
+ }
214
+ if (!stat.isFile()) {
215
+ const err = new Error(`Missing ${displayPath}. ${manifest.missingHint}.`);
216
+ err.code = 'missing_file';
217
+ throw err;
218
+ }
219
+ if (stat.size > MAX_ENGINE_LOGIN_FILE_BYTES) {
220
+ const err = new Error(`${displayPath} is ${stat.size} bytes; maximum is ${MAX_ENGINE_LOGIN_FILE_BYTES} bytes.`);
221
+ err.code = 'file_too_large';
222
+ throw err;
223
+ }
224
+ const content = fsModule.readFileSync(absolutePath, 'utf8');
225
+ files[displayPath] = content;
226
+ summary.push({
227
+ path: displayPath,
228
+ bytes: byteLength(content),
229
+ email: detectedEmailFromContent(content),
230
+ });
231
+ }
232
+ return { payload: { files }, summary };
233
+ }
234
+
235
+ function parseEngineLoginArgs(args = []) {
236
+ const options = {
237
+ provider: '',
238
+ list: false,
239
+ remove: '',
240
+ yes: false,
241
+ json: false,
242
+ computer: false,
243
+ business: '',
244
+ businessFlag: false,
245
+ seat: '',
246
+ seatFlag: false,
247
+ help: false,
248
+ };
249
+ for (let i = 0; i < args.length; i += 1) {
250
+ const arg = String(args[i] || '');
251
+ if (arg === '--help' || arg === '-h' || arg === 'help') {
252
+ options.help = true;
253
+ continue;
254
+ }
255
+ if (arg === '--list' || arg === 'list') {
256
+ options.list = true;
257
+ continue;
258
+ }
259
+ if (arg === '--remove' && args[i + 1]) {
260
+ options.remove = String(args[i + 1] || '').trim();
261
+ i += 1;
262
+ continue;
263
+ }
264
+ if (arg.startsWith('--remove=')) {
265
+ options.remove = arg.slice('--remove='.length).trim();
266
+ continue;
267
+ }
268
+ if (arg === '--yes' || arg === '-y') {
269
+ options.yes = true;
270
+ continue;
271
+ }
272
+ if (arg === '--json') {
273
+ options.json = true;
274
+ continue;
275
+ }
276
+ if (arg === '--computer') {
277
+ options.computer = true;
278
+ continue;
279
+ }
280
+ if (arg === '--business' || arg === '-b') {
281
+ options.computer = true;
282
+ options.businessFlag = true;
283
+ const next = args[i + 1] === undefined ? '' : String(args[i + 1] || '');
284
+ if (next && !next.startsWith('--')) {
285
+ options.business = next.trim();
286
+ i += 1;
287
+ }
288
+ continue;
289
+ }
290
+ if (arg.startsWith('--business=')) {
291
+ options.computer = true;
292
+ options.businessFlag = true;
293
+ options.business = arg.slice('--business='.length).trim();
294
+ continue;
295
+ }
296
+ if (arg === '--seat') {
297
+ options.seatFlag = true;
298
+ const next = args[i + 1] === undefined ? '' : String(args[i + 1] || '');
299
+ if (next && !next.startsWith('--')) {
300
+ options.seat = normalizeEngineLoginSeat(next);
301
+ i += 1;
302
+ }
303
+ continue;
304
+ }
305
+ if (arg.startsWith('--seat=')) {
306
+ options.seatFlag = true;
307
+ options.seat = normalizeEngineLoginSeat(arg.slice('--seat='.length));
308
+ continue;
309
+ }
310
+ if (arg.startsWith('--')) continue;
311
+ if (!options.provider) options.provider = arg;
312
+ }
313
+ return options;
314
+ }
315
+
316
+ function parseEngineSeedArgs(args = []) {
317
+ const options = {
318
+ provider: '',
319
+ business: '',
320
+ user: false,
321
+ json: false,
322
+ help: false,
323
+ };
324
+ for (let i = 0; i < args.length; i += 1) {
325
+ const arg = String(args[i] || '');
326
+ if (arg === '--help' || arg === '-h' || arg === 'help') {
327
+ options.help = true;
328
+ continue;
329
+ }
330
+ if ((arg === '--business' || arg === '-b') && args[i + 1]) {
331
+ options.business = String(args[i + 1] || '').trim();
332
+ i += 1;
333
+ continue;
334
+ }
335
+ if (arg.startsWith('--business=')) {
336
+ options.business = arg.slice('--business='.length).trim();
337
+ continue;
338
+ }
339
+ if (arg === '--user') {
340
+ options.user = true;
341
+ continue;
342
+ }
343
+ if (arg === '--json') {
344
+ options.json = true;
345
+ continue;
346
+ }
347
+ if (arg.startsWith('--')) continue;
348
+ if (!options.provider) options.provider = arg;
349
+ }
350
+ return options;
351
+ }
352
+
353
+ function printEngineLoginHelp() {
354
+ console.log('\n atris engine login <provider> --yes\n upload a local whitelisted engine login to the Atris vault\n atris engine login <provider> --computer [--seat <name>]\n sign in on one of your Atris computers by device flow\n atris engine login <provider> --business <id> [--seat <name>]\n sign in on a business Atris computer by device flow\n atris engine login --list\n list vaulted engine logins\n atris engine login --remove <provider>\n remove a vaulted engine login\n providers: codex, claude, cursor, devin, grok\n');
355
+ }
356
+
357
+ function printEngineSeedHelp() {
358
+ console.log('\n atris engine seed <provider> --business <id>\n atris engine seed <provider> --user\n ask the backend to seed a vaulted login onto a computer\n');
359
+ }
360
+
361
+ function redactBackendResponse(value, parentKey = '') {
362
+ if (value == null) return value;
363
+ if (Array.isArray(value)) return value.map((item) => redactBackendResponse(item, parentKey));
364
+ if (typeof value !== 'object') {
365
+ if (/api[_-]?key|token|secret|credential|auth|files?/i.test(parentKey)) return '[redacted]';
366
+ return value;
367
+ }
368
+ const output = {};
369
+ for (const [key, item] of Object.entries(value)) {
370
+ if (/api[_-]?key|token|secret|credential|auth/i.test(key)) {
371
+ output[key] = '[redacted]';
372
+ } else if (key === 'files' && item && typeof item === 'object') {
373
+ output[key] = Object.fromEntries(Object.keys(item).map((filePath) => [filePath, '[redacted]']));
374
+ } else {
375
+ output[key] = redactBackendResponse(item, key);
376
+ }
377
+ }
378
+ return output;
379
+ }
380
+
381
+ function printBackendResult(result, { json = false } = {}) {
382
+ const data = result && result.data !== undefined && result.data !== null
383
+ ? result.data
384
+ : { ok: Boolean(result && result.ok), status: result && result.status };
385
+ const safe = redactBackendResponse(data);
386
+ if (json || typeof safe === 'object') {
387
+ console.log(JSON.stringify(safe, null, 2));
388
+ } else {
389
+ console.log(String(safe));
390
+ }
391
+ }
392
+
393
+ function printLoginSummary(provider, summary) {
394
+ console.log('');
395
+ console.log(` engine login: ${provider}`);
396
+ for (const item of summary) {
397
+ const email = item.email ? ` email: ${item.email}` : '';
398
+ console.log(` ${item.path.padEnd(32)} ${String(item.bytes).padStart(6)} bytes${email}`);
399
+ }
400
+ console.log('');
401
+ }
402
+
403
+ function readLineNoEcho(question) {
404
+ return new Promise((resolve) => {
405
+ if (!process.stdin.isTTY) {
406
+ let data = '';
407
+ process.stdout.write(question);
408
+ process.stdin.setEncoding('utf8');
409
+ process.stdin.on('data', (chunk) => { data += chunk; });
410
+ process.stdin.on('end', () => resolve(data.trim()));
411
+ process.stdin.on('error', () => resolve(''));
412
+ process.stdin.resume();
413
+ return;
414
+ }
415
+
416
+ const muted = new Writable({
417
+ write(chunk, encoding, callback) {
418
+ if (!muted.muted) process.stdout.write(chunk, encoding);
419
+ callback();
420
+ },
421
+ });
422
+ const rl = readline.createInterface({ input: process.stdin, output: muted, terminal: true });
423
+ rl.question(question, (answer) => {
424
+ rl.close();
425
+ process.stdout.write('\n');
426
+ resolve(String(answer || '').trim());
427
+ });
428
+ muted.muted = true;
429
+ });
430
+ }
431
+
432
+ function readLineVisible(question) {
433
+ return new Promise((resolve) => {
434
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
435
+ rl.question(question, (answer) => {
436
+ rl.close();
437
+ resolve(String(answer || '').trim());
438
+ });
439
+ });
440
+ }
441
+
442
+ async function promptApiKey(provider, deps = {}) {
443
+ if (typeof deps.promptSecret === 'function') {
444
+ return String(await deps.promptSecret(provider) || '').trim();
445
+ }
446
+ return readLineNoEcho(`Paste ${provider} API key: `);
447
+ }
448
+
449
+ async function confirmEngineLoginUpload(provider, deps = {}) {
450
+ if (typeof deps.confirmUpload === 'function') {
451
+ return Boolean(await deps.confirmUpload(provider));
452
+ }
453
+ const answer = await readLineVisible('Upload this credential to the Atris backend vault? [y/N] ');
454
+ return /^y(es)?$/i.test(answer);
455
+ }
456
+
457
+ async function authenticatedEngineApi(pathname, options, deps = {}) {
458
+ const apiFn = deps.apiRequestJson || apiRequestJson;
459
+ const ensureFn = deps.ensureValidCredentials || ensureValidCredentials;
460
+ const ensured = await ensureFn(apiFn);
461
+ if (ensured.error) {
462
+ return {
463
+ ok: false,
464
+ status: 0,
465
+ data: null,
466
+ error: ensured.detail || ensured.error || 'not_logged_in',
467
+ authError: true,
468
+ };
469
+ }
470
+ const token = ensured.credentials && ensured.credentials.token;
471
+ return apiFn(pathname, {
472
+ ...options,
473
+ token,
474
+ });
475
+ }
476
+
477
+ async function listEngineLogins(deps = {}) {
478
+ return authenticatedEngineApi('/engines/logins', {
479
+ method: 'GET',
480
+ timeoutMs: 15000,
481
+ retries: 0,
482
+ }, deps);
483
+ }
484
+
485
+ async function listEngineSeats(deps = {}) {
486
+ return authenticatedEngineApi('/engines/logins/seats', {
487
+ method: 'GET',
488
+ timeoutMs: 15000,
489
+ retries: 0,
490
+ }, deps);
491
+ }
492
+
493
+ async function readyCheckEngineLogin(provider, target, deps = {}) {
494
+ return authenticatedEngineApi(`/engines/logins/${encodeURIComponent(provider)}/ready-check`, {
495
+ method: 'POST',
496
+ body: { target },
497
+ timeoutMs: 30000,
498
+ retries: 0,
499
+ }, deps);
500
+ }
501
+
502
+ function formatEngineSeats(data, now = Date.now()) {
503
+ const seats = Array.isArray(data?.seats) ? data.seats : [];
504
+ if (!seats.length) return 'No accounts linked yet. Run: atris computer setup';
505
+
506
+ const nowValue = now instanceof Date ? now.getTime() : Number(now);
507
+ const nowSeconds = Number.isFinite(nowValue) ? nowValue / 1000 : Date.now() / 1000;
508
+ return seats.map((seat) => {
509
+ const engine = String(seat?.engine || 'engine').trim().toLowerCase();
510
+ const name = String(seat?.name || 'account').trim().toLowerCase();
511
+ const coolingUntil = Number(seat?.cooling_until);
512
+ if (!Number.isFinite(coolingUntil) || coolingUntil <= nowSeconds) {
513
+ return `${engine} ${name} - ready`;
514
+ }
515
+ const minutes = Math.ceil((coolingUntil - nowSeconds) / 60);
516
+ const hours = Math.floor(minutes / 60);
517
+ return `${engine} ${name} - cooling down, back ${hours}h ${minutes % 60}m`;
518
+ }).join('\n');
519
+ }
520
+
521
+ async function runEngineSeatsCommand(deps = {}) {
522
+ const result = await listEngineSeats(deps);
523
+ if (!result.ok) {
524
+ console.error(result.authError ? 'Run atris login first.' : `engine seats failed: ${result.error || result.status}`);
525
+ return 1;
526
+ }
527
+ const now = typeof deps.now === 'function' ? deps.now() : Date.now();
528
+ console.log(formatEngineSeats(result.data, now));
529
+ return 0;
530
+ }
531
+
532
+ async function buildEngineLoginPayload(provider, deps = {}) {
533
+ const manifest = ENGINE_LOGIN_MANIFESTS[provider];
534
+ if (!manifest) {
535
+ throw new Error(`Unknown engine login provider "${provider}". Known providers: ${knownLoginProviders().join(', ')}`);
536
+ }
537
+
538
+ if (manifest.type === 'api_key') {
539
+ const apiKey = await promptApiKey(provider, deps);
540
+ if (!apiKey) {
541
+ const err = new Error('No API key provided.');
542
+ err.code = 'missing_api_key';
543
+ throw err;
544
+ }
545
+ if (byteLength(apiKey) > MAX_ENGINE_LOGIN_FILE_BYTES) {
546
+ const err = new Error(`API key is ${byteLength(apiKey)} bytes; maximum is ${MAX_ENGINE_LOGIN_FILE_BYTES} bytes.`);
547
+ err.code = 'api_key_too_large';
548
+ throw err;
549
+ }
550
+ return {
551
+ payload: { api_key: apiKey },
552
+ summary: [{ path: 'api key', bytes: byteLength(apiKey), email: '' }],
553
+ };
554
+ }
555
+
556
+ return readEngineLoginFiles(provider, deps);
557
+ }
558
+
559
+ function engineDeviceLoginTarget(options) {
560
+ if (options.target) return options.target;
561
+ if (!options.computer) return null;
562
+ if (options.businessFlag && !options.business) {
563
+ const err = new Error('usage: atris engine login <provider> --business <id>');
564
+ err.code = 'usage';
565
+ throw err;
566
+ }
567
+ if (options.business) return { type: 'business', id: options.business };
568
+ return { type: 'user' };
569
+ }
570
+
571
+ function normalizeDeviceLoginStatus(data, provider, sessionId, seat = '') {
572
+ const payload = data && typeof data === 'object' ? data : {};
573
+ return {
574
+ ...payload,
575
+ session_id: payload.session_id || sessionId || '',
576
+ provider: payload.provider || provider,
577
+ ...(payload.seat || seat ? { seat: payload.seat || seat } : {}),
578
+ };
579
+ }
580
+
581
+ function printDeviceLoginCode(status, { json = false } = {}) {
582
+ const lines = [
583
+ '',
584
+ `Sign in: ${status.verify_url}`,
585
+ `Code: ${status.code} (expires in 15 minutes; never share this code)`,
586
+ '',
587
+ ];
588
+ for (const line of lines) {
589
+ if (json) console.error(line);
590
+ else console.log(line);
591
+ }
592
+ }
593
+
594
+ function printDeviceLoginCompleted(status, { json = false } = {}) {
595
+ if (json) {
596
+ console.log(JSON.stringify(status, null, 2));
597
+ return;
598
+ }
599
+ const registered = status.registered === true ? 'true' : status.registered === false ? 'false' : String(status.registered ?? '');
600
+ console.log('');
601
+ console.log(`engine login ready: ${status.provider}`);
602
+ if (status.seat) console.log(`seat: ${status.seat}`);
603
+ console.log(`account_email: ${status.account_email || '(none)'}`);
604
+ console.log(`registered: ${registered}`);
605
+ console.log(`ready-check: provider=${status.provider} session_id=${status.session_id || ''} status=${status.status} registered=${registered}`);
606
+ console.log('');
607
+ }
608
+
609
+ function printDeviceLoginFinalJson(status, options) {
610
+ if (options.json) console.log(JSON.stringify(status, null, 2));
611
+ }
612
+
613
+ async function waitForDeviceLoginPoll(ms, deps = {}) {
614
+ if (typeof deps.sleep === 'function') return deps.sleep(ms);
615
+ return new Promise((resolve) => setTimeout(resolve, ms));
616
+ }
617
+
618
+ async function readDeviceLoginPasteBackCode(deps = {}) {
619
+ const readlineDep = deps.readline || readline;
620
+ const input = deps.stdin || process.stdin;
621
+ const output = deps.stdout || process.stdout;
622
+ const rl = readlineDep.createInterface({ input, output });
623
+ try {
624
+ return String(await new Promise((resolve) => rl.question('', resolve)) || '').trim();
625
+ } finally {
626
+ rl.close();
627
+ }
628
+ }
629
+
630
+ async function runEngineDeviceLoginCommand(provider, options, deps = {}) {
631
+ let target;
632
+ let seat;
633
+ try {
634
+ target = engineDeviceLoginTarget(options);
635
+ seat = normalizeEngineLoginSeat(options.seat);
636
+ if ((options.seatFlag || options.seat) && !validEngineLoginSeat(seat)) {
637
+ const err = new Error(engineLoginSeatError());
638
+ err.code = 'usage';
639
+ throw err;
640
+ }
641
+ } catch (err) {
642
+ console.error(err.message);
643
+ return 2;
644
+ }
645
+
646
+ const body = seat ? { target, seat } : { target };
647
+ const started = await authenticatedEngineApi(`/engines/logins/${encodeURIComponent(provider)}/device-login`, {
648
+ method: 'POST',
649
+ body,
650
+ timeoutMs: 30000,
651
+ retries: 0,
652
+ }, deps);
653
+ if (!started.ok) {
654
+ console.error(options.setup
655
+ ? `could not connect the ${provider} account; please try again.`
656
+ : (started.authError ? 'Run atris login first.' : `engine login device flow failed: ${started.error || started.status}`));
657
+ return 1;
658
+ }
659
+
660
+ const startStatus = normalizeDeviceLoginStatus(started.data, provider, '', seat);
661
+ const sessionId = startStatus.session_id;
662
+ if (!sessionId) {
663
+ console.error(options.setup
664
+ ? `could not connect the ${provider} account; please try again.`
665
+ : 'engine login device flow failed: missing session_id');
666
+ return 1;
667
+ }
668
+
669
+ let finalStatus = startStatus;
670
+ let codePrinted = false;
671
+ let pasteBackSubmitted = false;
672
+ const pollMs = Math.max(1, Number(deps.deviceLoginPollMs ?? ENGINE_DEVICE_LOGIN_POLL_MS));
673
+ const timeoutMs = Math.max(0, Number(deps.deviceLoginTimeoutMs ?? ENGINE_DEVICE_LOGIN_TIMEOUT_MS));
674
+ const maxPolls = Math.ceil(timeoutMs / pollMs);
675
+
676
+ for (let attempt = 0; attempt <= maxPolls; attempt += 1) {
677
+ const polled = await authenticatedEngineApi(`/engines/logins/device-login/${encodeURIComponent(sessionId)}`, {
678
+ method: 'GET',
679
+ timeoutMs: 30000,
680
+ retries: 0,
681
+ }, deps);
682
+ if (!polled.ok) {
683
+ console.error(options.setup
684
+ ? `could not connect the ${provider} account; please try again.`
685
+ : (polled.authError ? 'Run atris login first.' : `engine login device poll failed: ${polled.error || polled.status}`));
686
+ return 1;
687
+ }
688
+
689
+ finalStatus = normalizeDeviceLoginStatus(polled.data, provider, sessionId, seat);
690
+ const status = String(finalStatus.status || '').toLowerCase();
691
+ if (!codePrinted && finalStatus.verify_url && finalStatus.code) {
692
+ if (options.setup) {
693
+ console.log(`On your phone, open: ${finalStatus.verify_url} and type the code: ${finalStatus.code}`);
694
+ } else {
695
+ printDeviceLoginCode(finalStatus, { json: options.json });
696
+ }
697
+ codePrinted = true;
698
+ }
699
+
700
+ const pasteBackRequired = !pasteBackSubmitted
701
+ && status === 'pending_user'
702
+ && finalStatus.verify_url
703
+ && !finalStatus.code
704
+ && String(finalStatus.provider || provider).toLowerCase() === 'claude';
705
+ if (pasteBackRequired) {
706
+ const printPrompt = options.json ? console.error : console.log;
707
+ printPrompt(`On your phone, open: ${finalStatus.verify_url}`);
708
+ let submitted = false;
709
+ for (let submitAttempt = 0; submitAttempt < 2; submitAttempt += 1) {
710
+ printPrompt('When the browser shows you a code, paste it here:');
711
+ const pastedCode = await readDeviceLoginPasteBackCode(deps);
712
+ const codeResult = await authenticatedEngineApi(
713
+ `/engines/logins/device-login/${encodeURIComponent(sessionId)}/code`,
714
+ {
715
+ method: 'POST',
716
+ body: { code: pastedCode },
717
+ timeoutMs: 30000,
718
+ retries: 0,
719
+ },
720
+ deps
721
+ );
722
+ if (codeResult.ok) {
723
+ submitted = true;
724
+ pasteBackSubmitted = true;
725
+ break;
726
+ }
727
+ if (submitAttempt === 0) {
728
+ console.error('that code did not work; paste it again.');
729
+ } else {
730
+ console.error('that code did not work; please start the login again.');
731
+ }
732
+ }
733
+ if (!submitted) return 1;
734
+ }
735
+
736
+ if (status === 'completed') {
737
+ if (options.setup) console.log(`${seat} linked.`);
738
+ else printDeviceLoginCompleted(finalStatus, { json: options.json });
739
+ return 0;
740
+ }
741
+ if (status === 'failed' || status === 'expired') {
742
+ if (options.setup) {
743
+ console.error(`could not connect the ${provider} account; please try again.`);
744
+ } else {
745
+ printDeviceLoginFinalJson(finalStatus, options);
746
+ console.error(`engine login device flow ended: ${status}`);
747
+ }
748
+ return 1;
749
+ }
750
+
751
+ if (attempt < maxPolls) await waitForDeviceLoginPoll(pollMs, deps);
752
+ }
753
+
754
+ finalStatus = {
755
+ ...finalStatus,
756
+ status: 'timeout',
757
+ error: `device login did not complete within ${Math.round(timeoutMs / 60000)} minutes`,
758
+ };
759
+ if (options.setup) {
760
+ console.error(`could not connect the ${provider} account; please try again.`);
761
+ } else {
762
+ printDeviceLoginFinalJson(finalStatus, options);
763
+ console.error('engine login device flow timed out');
764
+ }
765
+ return 1;
766
+ }
767
+
768
+ async function runEngineLoginCommand(args, root, deps = {}) {
769
+ const options = parseEngineLoginArgs(args);
770
+ if (options.help) {
771
+ printEngineLoginHelp();
772
+ return 0;
773
+ }
774
+
775
+ if (options.list) {
776
+ const result = await listEngineLogins(deps);
777
+ if (!result.ok) {
778
+ console.error(result.authError ? 'Run atris login first.' : `engine login list failed: ${result.error || result.status}`);
779
+ return 1;
780
+ }
781
+ printBackendResult(result, { json: options.json });
782
+ return 0;
783
+ }
784
+
785
+ if (options.remove) {
786
+ const provider = normalizeLoginProvider(options.remove);
787
+ if (!provider) {
788
+ console.error(`Unknown engine login provider "${options.remove}". Known providers: ${knownLoginProviders().join(', ')}`);
789
+ return 2;
790
+ }
791
+ const result = await authenticatedEngineApi(`/engines/logins/${encodeURIComponent(provider)}`, {
792
+ method: 'DELETE',
793
+ timeoutMs: 15000,
794
+ retries: 0,
795
+ }, deps);
796
+ if (!result.ok) {
797
+ console.error(result.authError ? 'Run atris login first.' : `engine login remove failed: ${result.error || result.status}`);
798
+ return 1;
799
+ }
800
+ printBackendResult(result, { json: options.json });
801
+ return 0;
802
+ }
803
+
804
+ const provider = normalizeLoginProvider(options.provider);
805
+ if (!provider) {
806
+ console.error(`usage: atris engine login <provider> --yes; providers: ${knownLoginProviders().join(', ')}`);
807
+ return 2;
808
+ }
809
+
810
+ if (options.seatFlag && !options.computer) {
811
+ console.error('use --seat with --computer or --business');
812
+ return 2;
813
+ }
814
+
815
+ if (options.computer) {
816
+ return runEngineDeviceLoginCommand(provider, options, deps);
817
+ }
818
+
819
+ let built;
820
+ try {
821
+ built = await buildEngineLoginPayload(provider, deps);
822
+ } catch (err) {
823
+ console.error(err.message);
824
+ return err.code === 'missing_file' || err.code === 'missing_api_key' ? 1 : 2;
825
+ }
826
+
827
+ printLoginSummary(provider, built.summary);
828
+ if (!options.yes) {
829
+ const confirmed = await confirmEngineLoginUpload(provider, deps);
830
+ if (!confirmed) {
831
+ console.error('engine login upload cancelled');
832
+ return 1;
833
+ }
834
+ }
835
+
836
+ const result = await authenticatedEngineApi(`/engines/logins/${encodeURIComponent(provider)}`, {
837
+ method: 'POST',
838
+ body: built.payload,
839
+ timeoutMs: 30000,
840
+ retries: 0,
841
+ }, deps);
842
+ if (!result.ok) {
843
+ console.error(result.authError ? 'Run atris login first.' : `engine login upload failed: ${result.error || result.status}`);
844
+ return 1;
845
+ }
846
+ printBackendResult(result, { json: options.json });
847
+ return 0;
848
+ }
849
+
850
+ async function runEngineSeedCommand(args, root, deps = {}) {
851
+ const options = parseEngineSeedArgs(args);
852
+ if (options.help) {
853
+ printEngineSeedHelp();
854
+ return 0;
855
+ }
856
+
857
+ const provider = normalizeLoginProvider(options.provider);
858
+ if (!provider) {
859
+ console.error(`usage: atris engine seed <provider> --business <id>|--user; providers: ${knownLoginProviders().join(', ')}`);
860
+ return 2;
861
+ }
862
+ if ((options.business && options.user) || (!options.business && !options.user)) {
863
+ console.error('usage: atris engine seed <provider> --business <id>|--user');
864
+ return 2;
865
+ }
866
+
867
+ const body = options.user
868
+ ? { target: { type: 'user' } }
869
+ : { target: { type: 'business', id: options.business } };
870
+ const result = await authenticatedEngineApi(`/engines/logins/${encodeURIComponent(provider)}/seed`, {
871
+ method: 'POST',
872
+ body,
873
+ timeoutMs: 60000,
874
+ retries: 0,
875
+ }, deps);
876
+ if (!result.ok) {
877
+ console.error(result.authError ? 'Run atris login first.' : `engine seed failed: ${result.error || result.status}`);
878
+ return 1;
879
+ }
880
+ printBackendResult(result, { json: options.json });
881
+ return 0;
882
+ }
883
+
95
884
  function printRoster(root) {
96
885
  const list = roster(root);
97
886
  const found = list.filter((e) => e.installed).length;
@@ -101,8 +890,9 @@ function printRoster(root) {
101
890
  console.log('');
102
891
  for (const engine of list) {
103
892
  const mark = engine.default ? '→' : ' ';
104
- const state = engine.installed ? 'ready' : 'not installed';
105
- console.log(` ${mark} ${engine.name.padEnd(12)} ${state}`);
893
+ const state = engine.health.status === 'not_installed' ? 'not installed' : engine.health.status.replace(/_/g, ' ');
894
+ const roles = engine.roles.join(',');
895
+ console.log(` ${mark} ${engine.id.padEnd(12)} ${state.padEnd(13)} ${engine.tier.padEnd(4)} ${roles}`);
106
896
  }
107
897
  console.log('');
108
898
  console.log(` default: ${current.name}${current.source === 'saved' ? ' (set here)' : current.source === 'env' ? ' (this session)' : ''}`);
@@ -110,6 +900,80 @@ function printRoster(root) {
110
900
  console.log('');
111
901
  }
112
902
 
903
+ function registryPayload(root) {
904
+ const current = resolveDefaultEngine(root);
905
+ const registry = readEngineRegistry(root);
906
+ return {
907
+ default: current.name,
908
+ source: current.source,
909
+ engines: registry.engines.map((engine) => ({
910
+ ...engine,
911
+ default: engine.id === current.name,
912
+ })),
913
+ };
914
+ }
915
+
916
+ function parseSetFlag(args) {
917
+ const prefix = '--set=';
918
+ for (let i = 0; i < args.length; i += 1) {
919
+ const arg = String(args[i]);
920
+ if (arg === '--set') return args[i + 1] || '';
921
+ if (arg.startsWith(prefix)) return arg.slice(prefix.length);
922
+ }
923
+ return '';
924
+ }
925
+
926
+ function runResolveCommand(args, root) {
927
+ const json = args.includes('--json');
928
+ const role = args.filter((a) => !String(a).startsWith('--'))[0] || '';
929
+ if (!role) {
930
+ const message = `usage: atris engine resolve <role>; roles: ${ENGINE_ROLES.join(', ')}`;
931
+ if (json) console.log(JSON.stringify({ ok: false, error: message }, null, 2));
932
+ else console.error(message);
933
+ return 2;
934
+ }
935
+ let engine;
936
+ try {
937
+ engine = resolveEngineForRole(role, root);
938
+ } catch (err) {
939
+ if (json) console.log(JSON.stringify({ ok: false, error: err.message }, null, 2));
940
+ else console.error(err.message);
941
+ return 2;
942
+ }
943
+ if (!engine) {
944
+ const message = `No ready installed engine can fill role "${role}".`;
945
+ if (json) console.log(JSON.stringify({ ok: false, role, error: message }, null, 2));
946
+ else console.error(message);
947
+ return 1;
948
+ }
949
+ if (json) console.log(JSON.stringify(engine, null, 2));
950
+ else console.log(engine.id);
951
+ return 0;
952
+ }
953
+
954
+ function runHealthCommand(args, root) {
955
+ const json = args.includes('--json');
956
+ const positional = args.filter((a) => !String(a).startsWith('--'));
957
+ const name = positional[0] || '';
958
+ const status = parseSetFlag(args);
959
+ if (!name || !status) {
960
+ const message = `usage: atris engine health <name> --set <status>; statuses: ${ENGINE_HEALTH_STATUSES.join(', ')}`;
961
+ if (json) console.log(JSON.stringify({ ok: false, error: message }, null, 2));
962
+ else console.error(message);
963
+ return 2;
964
+ }
965
+ try {
966
+ const engine = setEngineHealth(name, status, root);
967
+ if (json) console.log(JSON.stringify(engine, null, 2));
968
+ else console.log(`engine ${engine.id} health: ${engine.health.status}`);
969
+ return 0;
970
+ } catch (err) {
971
+ if (json) console.log(JSON.stringify({ ok: false, error: err.message }, null, 2));
972
+ else console.error(err.message);
973
+ return 2;
974
+ }
975
+ }
976
+
113
977
  // Preflight: run one engine CLI headless with a reply-OK prompt and report
114
978
  // pass/fail. A dead login, missing binary, or hung spawn is a one-command
115
979
  // diagnosis instead of a failed overnight flight. `name` is canonical.
@@ -188,6 +1052,37 @@ function probeEngine(name, { timeout = PROBE_DEFAULT_TIMEOUT_MS } = {}) {
188
1052
  };
189
1053
  }
190
1054
 
1055
+ // A probe is a real engine round trip on the same command shape dispatches
1056
+ // use, so its pass/fail and latency are routing evidence. Record one receipt
1057
+ // per role the engine serves so the router brain (lib/router-brain.js) can
1058
+ // learn from every preflight. Liveness evidence only: real build outcomes
1059
+ // land in the same pool via task receipts and outweigh probes over time.
1060
+ function writeProbeReceipt(result, root = process.cwd()) {
1061
+ const runsDir = path.join(root, 'atris', 'runs');
1062
+ fs.mkdirSync(runsDir, { recursive: true });
1063
+ let roles = [];
1064
+ try {
1065
+ const engine = engineRegistryView(root).find((row) => row.id === result.engine);
1066
+ roles = engine && Array.isArray(engine.roles) ? engine.roles : [];
1067
+ } catch { /* registry unavailable: still record the probe */ }
1068
+ if (!roles.length) roles = ['executor'];
1069
+ const at = new Date().toISOString();
1070
+ const stampMs = Date.now();
1071
+ for (const role of roles) {
1072
+ const receipt = {
1073
+ schema: 'atris.engine_probe_receipt.v1',
1074
+ engine: result.engine,
1075
+ task_type: role,
1076
+ verified_passed: Boolean(result.pass),
1077
+ duration_ms: result.durationMs,
1078
+ reason: result.reason,
1079
+ at,
1080
+ };
1081
+ const file = path.join(runsDir, `engine-probe-task-${result.engine}-${role}-${stampMs}.json`);
1082
+ fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
1083
+ }
1084
+ }
1085
+
191
1086
  function runEngineTest(targets, { json, root } = {}) {
192
1087
  let enginesToTest;
193
1088
  if (targets && targets.length) {
@@ -212,6 +1107,14 @@ function runEngineTest(targets, { json, root } = {}) {
212
1107
  const results = enginesToTest.map((name) => probeEngine(name));
213
1108
  const failures = results.filter((r) => !r.pass);
214
1109
  const passed = results.length - failures.length;
1110
+ // A live probe is the freshest health evidence there is: write it back so
1111
+ // a transient failure does not leave an engine stuck on error forever.
1112
+ for (const r of results) {
1113
+ try { setEngineHealth(r.engine, r.pass ? 'ready' : 'error', root); } catch { /* best-effort */ }
1114
+ if (r.reason !== 'not-installed') {
1115
+ try { writeProbeReceipt(r, root); } catch { /* best-effort */ }
1116
+ }
1117
+ }
215
1118
  if (json) {
216
1119
  console.log(JSON.stringify({
217
1120
  ok: failures.length === 0,
@@ -240,20 +1143,109 @@ function runEngineTest(targets, { json, root } = {}) {
240
1143
  return failures.length ? 1 : 0;
241
1144
  }
242
1145
 
243
- function engineCommand(args = []) {
1146
+ // One-command dispatch: claim -> worktree start -> bounded prompt -> engine
1147
+ // -> re-run Check: -> ship -> task ready, in one call instead of the 6
1148
+ // hand-rolled Bash calls per task the manual version took. Task ids are
1149
+ // positional; --engine/--prompt-file are the only flags, so parse by hand
1150
+ // instead of the generic "anything starting with --" split used above (that
1151
+ // split would swallow an --engine value like "cursor" as a task id).
1152
+ function parseDispatchArgs(args) {
1153
+ const taskIds = [];
1154
+ let engine = '';
1155
+ let promptFile = '';
1156
+ let base = '';
1157
+ let json = false;
1158
+ let yolo = false;
1159
+ for (let i = 0; i < args.length; i += 1) {
1160
+ const a = args[i];
1161
+ if (a === '--engine') { engine = args[i + 1] || ''; i += 1; continue; }
1162
+ if (a.startsWith('--engine=')) { engine = a.slice('--engine='.length); continue; }
1163
+ if (a === '--prompt-file') { promptFile = args[i + 1] || ''; i += 1; continue; }
1164
+ if (a.startsWith('--prompt-file=')) { promptFile = a.slice('--prompt-file='.length); continue; }
1165
+ if (a === '--base') { base = args[i + 1] || ''; i += 1; continue; }
1166
+ if (a.startsWith('--base=')) { base = a.slice('--base='.length); continue; }
1167
+ if (a === '--json') { json = true; continue; }
1168
+ if (a === '--yolo') { yolo = true; continue; }
1169
+ if (a.startsWith('--')) continue;
1170
+ taskIds.push(a);
1171
+ }
1172
+ return { taskIds, engine, promptFile, base, json, yolo };
1173
+ }
1174
+
1175
+ function runDispatchCommand(args, root) {
1176
+ const { taskIds, engine, promptFile, base, json, yolo } = parseDispatchArgs(args);
1177
+ if (!taskIds.length || !engine) {
1178
+ console.error('usage: atris engine dispatch <task-id> [<task-id> ...] --engine cursor|codex [--prompt-file <f>] [--yolo]');
1179
+ return 2;
1180
+ }
1181
+ const canonical = canonicalEngineName(engine);
1182
+ if (!canonical || !FLEET_CAPABLE.includes(canonical)) {
1183
+ console.error(`engine dispatch: --engine must be one of ${FLEET_CAPABLE.join(', ')}`);
1184
+ return 2;
1185
+ }
1186
+ // Argument-shape errors surface before environment errors: --prompt-file
1187
+ // with multiple ids is wrong on any machine, installed CLI or not.
1188
+ let promptOverride = '';
1189
+ if (promptFile) {
1190
+ if (taskIds.length > 1) {
1191
+ console.error('engine dispatch: --prompt-file only supports a single task id');
1192
+ return 2;
1193
+ }
1194
+ try {
1195
+ promptOverride = fs.readFileSync(promptFile, 'utf8');
1196
+ } catch (err) {
1197
+ console.error(`engine dispatch: could not read --prompt-file ${promptFile}: ${err.message}`);
1198
+ return 2;
1199
+ }
1200
+ }
1201
+ const def = RUNNER_PROFILE_DEFS[canonical];
1202
+ if (!binInstalled(def.bin)) {
1203
+ console.error(`engine dispatch: ${canonical} CLI (${def.bin}) is not installed here`);
1204
+ return 2;
1205
+ }
1206
+ return runDispatchFlight({ root, taskIds, engine: canonical, prompt: promptOverride, yolo, ...(base ? { checkoutBase: base } : {}) }).then((flight) => {
1207
+ if (json) console.log(JSON.stringify(flight, null, 2));
1208
+ return flight.paused.length ? 1 : 0;
1209
+ });
1210
+ }
1211
+
1212
+ function engineCommand(args = [], deps = {}) {
1213
+ const root = process.cwd();
1214
+ if ((args[0] || '').trim() === 'dispatch') {
1215
+ return runDispatchCommand(args.slice(1), root);
1216
+ }
1217
+
244
1218
  const json = args.includes('--json');
245
1219
  const positional = args.filter((a) => !a.startsWith('--'));
246
1220
  const sub = (positional[0] || '').trim();
247
- const root = process.cwd();
1221
+
1222
+ if (sub === 'login') {
1223
+ return runEngineLoginCommand(args.slice(args.indexOf('login') + 1), root);
1224
+ }
1225
+
1226
+ if (sub === 'seed') {
1227
+ return runEngineSeedCommand(args.slice(args.indexOf('seed') + 1), root);
1228
+ }
1229
+
1230
+ if (sub === 'seats') {
1231
+ return runEngineSeatsCommand(deps);
1232
+ }
248
1233
 
249
1234
  if (sub === 'test') {
250
1235
  return runEngineTest(positional.slice(1), { json, root });
251
1236
  }
252
1237
 
1238
+ if (sub === 'resolve') {
1239
+ return runResolveCommand(args.slice(args.indexOf('resolve') + 1), root);
1240
+ }
1241
+
1242
+ if (sub === 'health') {
1243
+ return runHealthCommand(args.slice(args.indexOf('health') + 1), root);
1244
+ }
1245
+
253
1246
  if (!sub || sub === 'list' || sub === 'status') {
254
1247
  if (json) {
255
- const current = resolveDefaultEngine(root);
256
- console.log(JSON.stringify({ engines: roster(root), default: current.name, source: current.source }, null, 2));
1248
+ console.log(JSON.stringify(registryPayload(root), null, 2));
257
1249
  return 0;
258
1250
  }
259
1251
  printRoster(root);
@@ -269,11 +1261,23 @@ function engineCommand(args = []) {
269
1261
  }
270
1262
 
271
1263
  if (sub === 'help') {
272
- console.log('\n atris engine roster + current default\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 reset back to the house default\n --engine <name> one run on that engine (mission run / autopilot / run)\n');
1264
+ console.log('\n atris engine roster + current default\n atris engine list --json full registry: default + engines with tier, roles, fallback, health\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 <name> make that engine the default here\n atris engine test [name] preflight: run the engine CLI headless, report pass/fail\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');
273
1265
  return 0;
274
1266
  }
275
1267
 
276
- // atris engine <name> — flip the default.
1268
+ // atris engine <name> — flip the default. An unknown name is a plain user
1269
+ // typo, not a crash: report it cleanly (and as JSON on demand) instead of
1270
+ // letting setEngine throw a raw stack trace at a new person.
1271
+ if (!canonicalEngineName(sub)) {
1272
+ const known = RUNNER_PROFILE_NAMES.join(', ');
1273
+ if (json) {
1274
+ console.log(JSON.stringify({ ok: false, error: `Unknown engine "${sub}"`, known: RUNNER_PROFILE_NAMES }, null, 2));
1275
+ } else {
1276
+ console.error(`\n Unknown engine "${sub}". known engines: ${known}`);
1277
+ console.error(' run "atris engine" to see the roster, or "atris engine help".\n');
1278
+ }
1279
+ return 2;
1280
+ }
277
1281
  const canonical = setEngine(sub, root);
278
1282
  const def = RUNNER_PROFILE_DEFS[canonical];
279
1283
  const installed = binInstalled(def.bin);
@@ -293,7 +1297,35 @@ module.exports = {
293
1297
  setEngine,
294
1298
  resetEngine,
295
1299
  roster,
1300
+ registryPayload,
1301
+ engineRegistryFile,
1302
+ readEngineRegistry,
1303
+ resolveEngineForRole,
1304
+ setEngineHealth,
296
1305
  probeEngine,
297
1306
  runEngineTest,
1307
+ parseDispatchArgs,
1308
+ runDispatchCommand,
1309
+ ENGINE_LOGIN_MANIFESTS,
1310
+ MAX_ENGINE_LOGIN_FILE_BYTES,
1311
+ expandHomePath,
1312
+ normalizeLoginProvider,
1313
+ normalizeEngineLoginSeat,
1314
+ validEngineLoginSeat,
1315
+ engineLoginSeatError,
1316
+ detectedEmailFromContent,
1317
+ readEngineLoginFiles,
1318
+ parseEngineLoginArgs,
1319
+ parseEngineSeedArgs,
1320
+ redactBackendResponse,
1321
+ buildEngineLoginPayload,
1322
+ listEngineLogins,
1323
+ listEngineSeats,
1324
+ readyCheckEngineLogin,
1325
+ formatEngineSeats,
1326
+ runEngineSeatsCommand,
1327
+ runEngineDeviceLoginCommand,
1328
+ runEngineLoginCommand,
1329
+ runEngineSeedCommand,
298
1330
  HOUSE_ENGINE,
299
1331
  };