atris 3.37.1 → 3.40.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 (164) hide show
  1. package/AGENTS.md +25 -6
  2. package/README.md +2 -0
  3. package/atris/PERSONA.md +11 -11
  4. package/atris.md +17 -2
  5. package/ax +160 -34
  6. package/bin/atris.js +78 -6
  7. package/commands/activate.js +16 -3
  8. package/commands/agent-spawn.js +13 -18
  9. package/commands/autoland.js +76 -47
  10. package/commands/autopilot-front.js +0 -4
  11. package/commands/autopilot.js +0 -12
  12. package/commands/bench.js +10 -12
  13. package/commands/brain.js +0 -7
  14. package/commands/brief.js +0 -2
  15. package/commands/business-sync.js +0 -1
  16. package/commands/business.js +452 -12
  17. package/commands/chat-scan.js +5 -7
  18. package/commands/clean.js +31 -2
  19. package/commands/close.js +46 -11
  20. package/commands/cloud.js +0 -1
  21. package/commands/codex-goal.js +8 -12
  22. package/commands/computer.js +6 -5
  23. package/commands/console.js +64 -11
  24. package/commands/decide.js +166 -0
  25. package/commands/deck.js +30 -12
  26. package/commands/dream.js +0 -7
  27. package/commands/drill.js +14 -25
  28. package/commands/engine.js +206 -26
  29. package/commands/experiments.js +0 -4
  30. package/commands/fleet-report.js +12 -4
  31. package/commands/game.js +261 -0
  32. package/commands/gm.js +17 -7
  33. package/commands/goal.js +0 -6
  34. package/commands/harvest.js +1 -5
  35. package/commands/improve.js +29 -8
  36. package/commands/init.js +41 -37
  37. package/commands/integrations.js +0 -7
  38. package/commands/land.js +77 -20
  39. package/commands/launchpad.js +1 -16
  40. package/commands/lesson.js +17 -7
  41. package/commands/lifecycle.js +5 -5
  42. package/commands/log.js +55 -5
  43. package/commands/loop-front.js +0 -2
  44. package/commands/loops.js +6 -5
  45. package/commands/meet.js +0 -2
  46. package/commands/member.js +622 -573
  47. package/commands/mission.js +790 -314
  48. package/commands/next.js +0 -1
  49. package/commands/now.js +0 -12
  50. package/commands/one-lap.js +8 -8
  51. package/commands/orb.js +0 -4
  52. package/commands/pack-craft.js +0 -1
  53. package/commands/pack.js +3599 -424
  54. package/commands/play.js +6 -6
  55. package/commands/probe.js +12 -6
  56. package/commands/pulse.js +88 -64
  57. package/commands/push.js +0 -4
  58. package/commands/radar.js +14 -11
  59. package/commands/recover.js +340 -0
  60. package/commands/release.js +10 -9
  61. package/commands/report.js +0 -1
  62. package/commands/router.js +320 -0
  63. package/commands/run-front.js +0 -2
  64. package/commands/search.js +0 -3
  65. package/commands/site-deploy.js +870 -0
  66. package/commands/site.js +11 -2
  67. package/commands/skill.js +7 -2
  68. package/commands/slop.js +237 -7
  69. package/commands/status.js +6 -0
  70. package/commands/stream.js +4 -20
  71. package/commands/study.js +0 -3
  72. package/commands/sync-checkout.js +51 -0
  73. package/commands/sync.js +11 -40
  74. package/commands/task.js +999 -595
  75. package/commands/taste.js +101 -0
  76. package/commands/teach.js +86 -0
  77. package/commands/team.js +83 -3
  78. package/commands/unknowns.js +0 -10
  79. package/commands/update.js +0 -1
  80. package/commands/vercel.js +4 -2
  81. package/commands/visualize.js +0 -5
  82. package/commands/voice.js +195 -0
  83. package/commands/watch.js +1 -28
  84. package/commands/wiki.js +42 -15
  85. package/commands/wish.js +0 -1
  86. package/commands/workflow.js +2 -2
  87. package/commands/worktree.js +88 -28
  88. package/commands/xp.js +28 -30
  89. package/commands/youtube.js +0 -2
  90. package/lib/accept-verify-gate.js +126 -0
  91. package/lib/activity-stream.js +0 -2
  92. package/lib/arg-parser.js +41 -0
  93. package/lib/auto-accept-certified.js +261 -3
  94. package/lib/autoland.js +66 -3
  95. package/lib/ax-auto-lane.js +68 -7
  96. package/lib/bench/context.js +0 -2
  97. package/lib/bench/report.js +0 -1
  98. package/lib/bench/runner.js +18 -11
  99. package/lib/brief-ledger.js +0 -6
  100. package/lib/business-simulate.js +382 -0
  101. package/lib/chat-log-scan.js +0 -5
  102. package/lib/checkout-sync.js +52 -0
  103. package/lib/claude-boot-block.js +48 -0
  104. package/lib/cloud-mission.js +0 -6
  105. package/lib/codex-flight.js +0 -1
  106. package/lib/conductor-artifacts.js +25 -0
  107. package/lib/context-gatherer.js +7 -2
  108. package/lib/deck-compose.js +0 -1
  109. package/lib/deck-history.js +0 -1
  110. package/lib/deck-layout.js +0 -5
  111. package/lib/deck-review.js +0 -7
  112. package/lib/deck-schema.js +0 -1
  113. package/lib/default-verifier.js +0 -2
  114. package/lib/engine-registry.js +157 -28
  115. package/lib/experiments/daily.js +0 -6
  116. package/lib/falsifier-probe.js +84 -0
  117. package/lib/fleet.js +275 -42
  118. package/lib/functional-owner.js +0 -3
  119. package/lib/git-spawn.js +15 -0
  120. package/lib/inspect-fields.js +0 -1
  121. package/lib/json-file.js +37 -0
  122. package/lib/known-commands.js +2 -2
  123. package/lib/learnings.js +0 -2
  124. package/lib/lesson-preflight.js +146 -0
  125. package/lib/loop-doctor.js +11 -19
  126. package/lib/manifest.js +0 -2
  127. package/lib/member-alive.js +0 -1
  128. package/lib/member-switches.js +0 -5
  129. package/lib/mission-artifact.js +0 -1
  130. package/lib/mission-human-asks.js +28 -0
  131. package/lib/mission-protected-lane.js +296 -0
  132. package/lib/mission-room.js +0 -2
  133. package/lib/mission-runtime-loop.js +0 -1
  134. package/lib/next-moves.js +0 -18
  135. package/lib/official-cli-integration.js +47 -6
  136. package/lib/orb-context.js +14 -4
  137. package/lib/orb-scorecard.js +0 -1
  138. package/lib/outbound-send-gate.js +0 -6
  139. package/lib/pack-capabilities.js +685 -0
  140. package/lib/permission-grants.js +0 -6
  141. package/lib/policy-lessons.js +0 -2
  142. package/lib/pulse.js +68 -15
  143. package/lib/review-integrity.js +0 -2
  144. package/lib/router-brain.js +51 -3
  145. package/lib/runner-command.js +0 -10
  146. package/lib/runtime-bootstrap.js +0 -1
  147. package/lib/scorecard.js +0 -1
  148. package/lib/self-drive.js +45 -14
  149. package/lib/state-detection.js +0 -6
  150. package/lib/task-db.js +137 -7
  151. package/lib/task-decision.js +50 -0
  152. package/lib/taste-lessons.js +153 -0
  153. package/lib/team-presence.js +0 -1
  154. package/lib/tool-result-encode.js +17 -1
  155. package/lib/voice-gate.js +129 -2
  156. package/lib/wiki.js +152 -9
  157. package/lib/wish-audit.js +21 -7
  158. package/lib/wish-delegate.js +27 -21
  159. package/lib/wish-stats.js +0 -5
  160. package/lib/wish-store.js +0 -1
  161. package/lib/workspace-safety.js +0 -2
  162. package/lib/zip.js +95 -8
  163. package/package.json +2 -1
  164. package/templates/business-starter/persona.md +9 -0
package/commands/deck.js CHANGED
@@ -17,6 +17,7 @@ const fs = require('fs');
17
17
  const https = require('https');
18
18
  const os = require('os');
19
19
  const path = require('path');
20
+ const { hasFlag } = require('../lib/arg-parser');
20
21
  const { buildDeck, THEMES, notesRequests } = require('../lib/slides-deck');
21
22
  const {
22
23
  lintSpec,
@@ -118,10 +119,6 @@ function flag(argv, name) {
118
119
  return i !== -1 ? argv[i + 1] : null;
119
120
  }
120
121
 
121
- function hasFlag(argv, name) {
122
- return argv.includes(name);
123
- }
124
-
125
122
  // Flags that take a value, so their following token is consumed (not a path).
126
123
  const VALUE_FLAGS = new Set(['--theme', '--title', '--update', '--out', '--style', '--url', '--md']);
127
124
 
@@ -141,6 +138,23 @@ function readSpec(specPath) {
141
138
  return JSON.parse(fs.readFileSync(specPath, 'utf8'));
142
139
  }
143
140
 
141
+ // Pluralize a count + noun ("1 error", "2 errors").
142
+ function countLabel(n, word) {
143
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
144
+ }
145
+
146
+ // True when the args carry an explicit help flag.
147
+ function wantsHelp(argv) {
148
+ return argv.includes('--help') || argv.includes('-h');
149
+ }
150
+
151
+ // One-line lint tally with correct singular/plural nouns.
152
+ function lintSummary(findings) {
153
+ const errs = findings.filter((f) => f.severity === 'error').length;
154
+ const warns = findings.filter((f) => f.severity === 'warn').length;
155
+ return `${countLabel(errs, 'error')}, ${countLabel(warns, 'warning')}`;
156
+ }
157
+
144
158
  function printLint(findings) {
145
159
  if (!findings.length) {
146
160
  console.log('\n ✓ spec lint clean\n');
@@ -369,15 +383,13 @@ async function run(argv) {
369
383
  const findings = checkSpec(spec, lintSpec);
370
384
  if (out) {
371
385
  fs.writeFileSync(out, `${JSON.stringify(spec, null, 2)}\n`);
372
- console.error(`\n ✓ composed ${spec.slides.length} slides (${spec.theme}) -> ${out}`);
386
+ console.error(`\n ✓ composed ${countLabel(spec.slides.length, 'slide')} (${spec.theme}) -> ${out}`);
373
387
  printLint(findings);
374
388
  console.error(` next: atris deck build ${out} --review\n`);
375
389
  } else {
376
390
  // stdout stays clean JSON for piping; lint goes to stderr
377
391
  console.log(JSON.stringify(spec, null, 2));
378
- const errs = findings.filter((f) => f.severity === 'error').length;
379
- const warns = findings.filter((f) => f.severity === 'warn').length;
380
- console.error(` composed ${spec.slides.length} slides (${spec.theme}) · lint: ${errs} errors, ${warns} warnings`);
392
+ console.error(` composed ${countLabel(spec.slides.length, 'slide')} (${spec.theme}) · lint: ${lintSummary(findings)}`);
381
393
  }
382
394
  return hasErrors(findings) ? 1 : 0;
383
395
  }
@@ -385,8 +397,11 @@ async function run(argv) {
385
397
  if (sub === 'lint') {
386
398
  const specPath = argv.slice(1).find((a) => !a.startsWith('-'));
387
399
  if (!specPath) {
388
- console.error(' usage: atris deck lint <spec.json>');
389
- return 2;
400
+ // Explicit -h/--help is a help request (stdout, exit 0); a genuinely
401
+ // missing spec path is a usage error (stderr, exit 2).
402
+ const help = wantsHelp(argv);
403
+ console[help ? 'log' : 'error'](' usage: atris deck lint <spec.json>');
404
+ return help ? 0 : 2;
390
405
  }
391
406
  let spec;
392
407
  try { spec = readSpec(specPath); }
@@ -488,8 +503,9 @@ async function run(argv) {
488
503
  }
489
504
  const specPath = specPaths[0];
490
505
  if (!specPath) {
491
- console.error(' usage: atris deck build <spec.json> [<spec2.json> ...] [--title T] [--theme x] [--review]');
492
- return 2;
506
+ const help = wantsHelp(argv);
507
+ console[help ? 'log' : 'error'](' usage: atris deck build <spec.json> [<spec2.json> ...] [--title T] [--theme x] [--review]');
508
+ return help ? 0 : 2;
493
509
  }
494
510
  let spec;
495
511
  try { spec = readSpec(specPath); }
@@ -576,6 +592,8 @@ module.exports = {
576
592
  lintSpec,
577
593
  validateSpec,
578
594
  checkSpec,
595
+ countLabel,
596
+ lintSummary,
579
597
  planBatch,
580
598
  publishDeck,
581
599
  videoToDeck,
package/commands/dream.js CHANGED
@@ -353,13 +353,6 @@ async function dreamCommand(args = [], root = process.cwd(), options = {}) {
353
353
  }
354
354
 
355
355
  module.exports = {
356
- DREAM_TIMEOUT_MS,
357
356
  dreamsPath,
358
- collectDreamMaterial,
359
- buildDreamPrompt,
360
- parseDreamCards,
361
- dreamModels,
362
- defaultDreamRunner,
363
- runDream,
364
357
  dreamCommand,
365
358
  };
package/commands/drill.js CHANGED
@@ -3,22 +3,12 @@
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
- const { spawnSync } = require('child_process');
6
+ const { hasFlag } = require('../lib/arg-parser');
7
+ const { runGit } = require('../lib/git-spawn');
7
8
 
8
9
  const VERIFY_COMMAND = 'node -e "process.exit(0)"';
9
10
  const COAUTHOR = 'Co-authored-by: Atris <299057014+atris-builder[bot]@users.noreply.github.com>';
10
11
 
11
- function hasFlag(args, name) {
12
- return args.includes(name);
13
- }
14
-
15
- function runGit(args, cwd, options = {}) {
16
- const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
17
- if (options.check === false || result.status === 0) return result;
18
- const output = (result.stderr || result.stdout || `git ${args.join(' ')} failed`).trim();
19
- throw new Error(output);
20
- }
21
-
22
12
  function parseJsonOutput(text) {
23
13
  const raw = String(text || '').trim();
24
14
  if (!raw) throw new Error('expected JSON output, got empty output');
@@ -97,9 +87,9 @@ async function createSandbox(ctx) {
97
87
  ctx.tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-drill-'));
98
88
  ctx.sandboxPath = path.join(ctx.tmpRoot, 'sandbox');
99
89
  fs.mkdirSync(ctx.sandboxPath, { recursive: true });
100
- runGit(['init', '-b', 'master'], ctx.sandboxPath);
101
- runGit(['config', 'user.email', 'drill@example.invalid'], ctx.sandboxPath);
102
- runGit(['config', 'user.name', 'Atris Drill'], ctx.sandboxPath);
90
+ runGit(['init', '-b', 'master'], { cwd: ctx.sandboxPath });
91
+ runGit(['config', 'user.email', 'drill@example.invalid'], { cwd: ctx.sandboxPath });
92
+ runGit(['config', 'user.name', 'Atris Drill'], { cwd: ctx.sandboxPath });
103
93
  fs.writeFileSync(path.join(ctx.sandboxPath, 'README.md'), '# Atris drill sandbox\n', 'utf8');
104
94
  await withSandboxProcessState(ctx.sandboxPath, {
105
95
  ATRIS_SKIP_UPDATE_CHECK: '1',
@@ -108,8 +98,8 @@ async function createSandbox(ctx) {
108
98
  }, async () => {
109
99
  await callInitAtris(ctx.sandboxPath);
110
100
  });
111
- runGit(['add', '-A'], ctx.sandboxPath);
112
- runGit(['commit', '-m', 'initial sandbox'], ctx.sandboxPath);
101
+ runGit(['add', '-A'], { cwd: ctx.sandboxPath });
102
+ runGit(['commit', '-m', 'initial sandbox'], { cwd: ctx.sandboxPath });
113
103
  }
114
104
 
115
105
  async function wishCaptured(ctx) {
@@ -199,10 +189,10 @@ async function landingShippedLocal(ctx) {
199
189
  try {
200
190
  require('../lib/task-db').close();
201
191
  } catch {}
202
- const dirty = runGit(['status', '--porcelain'], ctx.sandboxPath).stdout.trim();
192
+ const dirty = runGit(['status', '--porcelain'], { cwd: ctx.sandboxPath }).stdout.trim();
203
193
  if (dirty) {
204
- runGit(['add', '-A'], ctx.sandboxPath);
205
- runGit(['commit', '-m', 'record drill pipeline state'], ctx.sandboxPath);
194
+ runGit(['add', '-A'], { cwd: ctx.sandboxPath });
195
+ runGit(['commit', '-m', 'record drill pipeline state'], { cwd: ctx.sandboxPath });
206
196
  }
207
197
  ctx.landingWorktree = path.join(ctx.tmpRoot, 'landing-worktree');
208
198
  const created = worktree.createAgentWorktree({
@@ -229,9 +219,9 @@ async function landingShippedLocal(ctx) {
229
219
  assertOk(shipped.value === 0, `worktree ship exited ${shipped.value}`);
230
220
  assertOk(/local mode/.test(`${shipped.stdout}\n${shipped.stderr}`), 'worktree ship did not report local mode');
231
221
  });
232
- runGit(['worktree', 'remove', ctx.landingWorktree, '--force'], ctx.sandboxPath);
233
- runGit(['branch', '-d', ctx.landingBranch], ctx.sandboxPath);
234
- const landed = runGit(['show', 'master:drill-landing.txt'], ctx.sandboxPath).stdout;
222
+ runGit(['worktree', 'remove', ctx.landingWorktree, '--force'], { cwd: ctx.sandboxPath });
223
+ runGit(['branch', '-d', ctx.landingBranch], { cwd: ctx.sandboxPath });
224
+ const landed = runGit(['show', 'master:drill-landing.txt'], { cwd: ctx.sandboxPath }).stdout;
235
225
  assertOk(landed.trim() === 'local landing ok', 'landing file was not merged into sandbox master');
236
226
  }
237
227
 
@@ -244,7 +234,7 @@ async function ledgerChecked(ctx) {
244
234
  assertOk(currentMission, `mission not found: ${ctx.missionId}`);
245
235
  assertOk(currentMission.verifier_result && currentMission.verifier_result.passed === true, 'mission verifier result is not passing');
246
236
  assertOk(currentMission.receipt_path && fs.existsSync(path.join(ctx.sandboxPath, currentMission.receipt_path)), `mission receipt missing: ${currentMission.receipt_path}`);
247
- const unmerged = runGit(['branch', '--no-merged', 'master'], ctx.sandboxPath).stdout
237
+ const unmerged = runGit(['branch', '--no-merged', 'master'], { cwd: ctx.sandboxPath }).stdout
248
238
  .split(/\r?\n/)
249
239
  .map((line) => line.replace(/^\*/, '').trim())
250
240
  .filter(Boolean)
@@ -365,7 +355,6 @@ async function drillCommand(args = []) {
365
355
 
366
356
  module.exports = {
367
357
  COAUTHOR,
368
- VERIFY_COMMAND,
369
358
  drillCommand,
370
359
  runDrill,
371
360
  };
@@ -26,6 +26,7 @@ const {
26
26
  } = require('../lib/runner-command');
27
27
  const {
28
28
  ENGINE_ROLES,
29
+ ENGINE_DUTIES,
29
30
  ENGINE_HEALTH_STATUSES,
30
31
  binInstalled,
31
32
  canonicalEngineName,
@@ -33,6 +34,10 @@ const {
33
34
  engineRegistryView,
34
35
  readEngineRegistry,
35
36
  resolveEngineForRole,
37
+ resolveEngineForRoleRanked,
38
+ requireEngineBin,
39
+ engineDoctorReport,
40
+ setEngineOverrides,
36
41
  setEngineHealth,
37
42
  } = require('../lib/engine-registry');
38
43
  const { FLEET_CAPABLE, runDispatchFlight } = require('../lib/fleet');
@@ -89,14 +94,22 @@ function readSavedEngine(root = process.cwd()) {
89
94
 
90
95
  // The default engine for this workspace, in precedence order:
91
96
  // env (per-run flags land here) -> .atris/engine.json -> house default.
92
- // The house default is our own intelligence when it is installed.
97
+ // When nothing is configured the pick comes from the registry's saved health
98
+ // (seeded once on first sight, refreshed by `atris engine doctor`), never a
99
+ // machine probe: policy over probes. A stale "ready" is the execution stage's
100
+ // problem, where requireEngineBin fails in one plain sentence.
93
101
  function resolveDefaultEngine(root = process.cwd()) {
94
102
  const env = canonicalEngineName(process.env.ATRIS_RUNNER_PROFILE);
95
103
  if (env) return { name: env, source: 'env' };
96
104
  const saved = readSavedEngine(root);
97
105
  if (saved) return { name: saved, source: 'saved' };
98
- if (binInstalled(RUNNER_PROFILE_DEFS[HOUSE_ENGINE].bin)) return { name: HOUSE_ENGINE, source: 'house' };
99
- const fallback = RUNNER_PROFILE_NAMES.find((name) => binInstalled(RUNNER_PROFILE_DEFS[name].bin));
106
+ const ready = new Set(
107
+ engineRegistryView(root)
108
+ .filter((engine) => engine.health && engine.health.status === 'ready')
109
+ .map((engine) => engine.id)
110
+ );
111
+ if (ready.has(HOUSE_ENGINE)) return { name: HOUSE_ENGINE, source: 'house' };
112
+ const fallback = RUNNER_PROFILE_NAMES.find((name) => ready.has(name));
100
113
  return fallback ? { name: fallback, source: 'detected' } : { name: HOUSE_ENGINE, source: 'none' };
101
114
  }
102
115
 
@@ -886,13 +899,16 @@ function printRoster(root) {
886
899
  const found = list.filter((e) => e.installed).length;
887
900
  const current = resolveDefaultEngine(root);
888
901
  console.log('');
889
- console.log(` engines — ${found} intelligence${found === 1 ? '' : 's'} found`);
902
+ console.log(` engines: ${found} intelligence${found === 1 ? '' : 's'} found`);
890
903
  console.log('');
891
904
  for (const engine of list) {
892
905
  const mark = engine.default ? '→' : ' ';
893
906
  const state = engine.health.status === 'not_installed' ? 'not installed' : engine.health.status.replace(/_/g, ' ');
894
907
  const roles = engine.roles.join(',');
895
908
  console.log(` ${mark} ${engine.id.padEnd(12)} ${state.padEnd(13)} ${engine.tier.padEnd(4)} ${roles}`);
909
+ const details = [`models: ${engine.models.join(', ')}`];
910
+ if (engine.duty) details.push(`duty: ${engine.duty}`);
911
+ console.log(` ${details.join(' ')}`);
896
912
  }
897
913
  console.log('');
898
914
  console.log(` default: ${current.name}${current.source === 'saved' ? ' (set here)' : current.source === 'env' ? ' (this session)' : ''}`);
@@ -900,6 +916,139 @@ function printRoster(root) {
900
916
  console.log('');
901
917
  }
902
918
 
919
+ function chartEngineLabel(engine) {
920
+ const id = String(engine && (engine.id || engine.name) || '').trim();
921
+ const models = Array.isArray(engine && engine.models)
922
+ ? engine.models.map((model) => String(model || '').trim()).filter(Boolean)
923
+ : [];
924
+ return models.length ? `${id}: ${models.join(', ')}` : id;
925
+ }
926
+
927
+ function chartBox(title, engines, note = '', emptyLabel = 'unassigned') {
928
+ const labels = engines.length ? engines.map(chartEngineLabel) : (emptyLabel ? [emptyLabel] : []);
929
+ const body = [title, ...labels, ...(note ? [note] : [])];
930
+ const innerWidth = Math.max(...body.map((line) => line.length)) + 2;
931
+ return {
932
+ width: innerWidth + 2,
933
+ lines: [
934
+ `┌${'─'.repeat(innerWidth)}┐`,
935
+ ...body.map((line) => `│ ${line.padEnd(innerWidth - 2)} │`),
936
+ `└${'─'.repeat(innerWidth)}┘`,
937
+ ],
938
+ };
939
+ }
940
+
941
+ function centerChartLine(line, width, center = Math.floor((width - 1) / 2)) {
942
+ const left = Math.max(0, center - Math.floor(line.length / 2));
943
+ return `${' '.repeat(left)}${line}`.padEnd(width);
944
+ }
945
+
946
+ function renderEngineChart(registry) {
947
+ const engines = Array.isArray(registry) ? registry : ((registry && registry.engines) || []);
948
+ const specialDuties = new Set(ENGINE_DUTIES);
949
+ const leaders = engines.filter((entry) => entry.duty === 'leader');
950
+ const rolePool = engines.filter((entry) => !specialDuties.has(entry.duty));
951
+ const builders = rolePool.filter((entry) => Array.isArray(entry.roles) && entry.roles.includes('executor'));
952
+ const checkers = rolePool.filter((entry) => Array.isArray(entry.roles) && entry.roles.includes('validator'));
953
+ const errands = engines.filter((entry) => entry.duty === 'errands');
954
+ const apprentices = engines.filter((entry) => entry.duty === 'learning');
955
+
956
+ const ownerBox = chartBox('owner', [], '', '');
957
+ const leaderBox = chartBox('leader', leaders);
958
+ const columns = [
959
+ chartBox('builders', builders),
960
+ chartBox('checkers', checkers),
961
+ chartBox('errands', errands),
962
+ ];
963
+ const gap = 3;
964
+ const totalWidth = columns.reduce((sum, column) => sum + column.width, 0) + (gap * (columns.length - 1));
965
+ const columnHeight = Math.max(...columns.map((column) => column.lines.length));
966
+ const columnLines = [];
967
+ for (let row = 0; row < columnHeight; row += 1) {
968
+ columnLines.push(columns.map((column) => (column.lines[row] || '').padEnd(column.width)).join(' '.repeat(gap)));
969
+ }
970
+
971
+ const centers = [];
972
+ let offset = 0;
973
+ for (const column of columns) {
974
+ centers.push(offset + Math.floor(column.width / 2));
975
+ offset += column.width + gap;
976
+ }
977
+ const fleetCenter = centers[1];
978
+ const branch = Array(totalWidth).fill(' ');
979
+ for (let i = centers[0]; i <= centers[centers.length - 1]; i += 1) branch[i] = '─';
980
+ branch[centers[0]] = '┌';
981
+ branch[centers[centers.length - 1]] = '┐';
982
+ for (const center of centers.slice(1, -1)) branch[center] = '┬';
983
+ branch[fleetCenter] = centers.includes(fleetCenter) ? '┼' : '┴';
984
+ const stems = Array(totalWidth).fill(' ');
985
+ for (const center of centers) stems[center] = '│';
986
+
987
+ const lines = [
988
+ ...ownerBox.lines.map((line) => centerChartLine(line, totalWidth, fleetCenter)),
989
+ centerChartLine('│', totalWidth, fleetCenter),
990
+ ...leaderBox.lines.map((line) => centerChartLine(line, totalWidth, fleetCenter)),
991
+ centerChartLine('│', totalWidth, fleetCenter),
992
+ branch.join(''),
993
+ stems.join(''),
994
+ ...columnLines,
995
+ ];
996
+ if (apprentices.length) {
997
+ const apprenticeBox = chartBox('apprentice', apprentices, 'learning the system');
998
+ lines.push(centerChartLine('│', totalWidth, fleetCenter));
999
+ lines.push(...apprenticeBox.lines.map((line) => centerChartLine(line, totalWidth, fleetCenter)));
1000
+ }
1001
+ return lines.map((line) => line.replace(/\s+$/, '')).join('\n');
1002
+ }
1003
+
1004
+ function printEngineChart(root) {
1005
+ console.log(renderEngineChart(readEngineRegistry(root)));
1006
+ }
1007
+
1008
+ function flagValue(args, flag) {
1009
+ const equals = `${flag}=`;
1010
+ for (let i = 0; i < args.length; i += 1) {
1011
+ const value = String(args[i] || '');
1012
+ if (value === flag) return { present: true, value: args[i + 1] || '' };
1013
+ if (value.startsWith(equals)) return { present: true, value: value.slice(equals.length) };
1014
+ }
1015
+ return { present: false, value: '' };
1016
+ }
1017
+
1018
+ function runSetEngineCommand(args, root) {
1019
+ const name = String(args[0] || '').trim();
1020
+ const dutyFlag = flagValue(args.slice(1), '--duty');
1021
+ const modelsFlag = flagValue(args.slice(1), '--models');
1022
+ const duty = String(dutyFlag.value || '').trim();
1023
+ const models = String(modelsFlag.value || '').split(',').map((model) => model.trim()).filter(Boolean);
1024
+ if (!name || (!dutyFlag.present && !modelsFlag.present)) {
1025
+ console.error('usage: atris engine set <name> [--duty leader|errands|learning] [--models "a, b"]');
1026
+ return 2;
1027
+ }
1028
+ if (dutyFlag.present && !ENGINE_DUTIES.includes(duty)) {
1029
+ console.error(`unknown duty "${duty}". known duties: ${ENGINE_DUTIES.join(', ')}`);
1030
+ return 2;
1031
+ }
1032
+ if (modelsFlag.present && !models.length) {
1033
+ console.error('models must include at least one name');
1034
+ return 2;
1035
+ }
1036
+ try {
1037
+ const updated = setEngineOverrides(name, {
1038
+ ...(dutyFlag.present ? { duty } : {}),
1039
+ ...(modelsFlag.present ? { models } : {}),
1040
+ }, root);
1041
+ const details = [];
1042
+ if (updated.duty) details.push(`duty ${updated.duty}`);
1043
+ if (updated.models) details.push(`models ${updated.models.join(', ')}`);
1044
+ console.log(`${updated.id} updated: ${details.join('; ')}`);
1045
+ return 0;
1046
+ } catch (err) {
1047
+ console.error(String(err.message || err).replace(/^Unknown/, 'unknown'));
1048
+ return 2;
1049
+ }
1050
+ }
1051
+
903
1052
  function registryPayload(root) {
904
1053
  const current = resolveDefaultEngine(root);
905
1054
  const registry = readEngineRegistry(root);
@@ -932,22 +1081,48 @@ function runResolveCommand(args, root) {
932
1081
  else console.error(message);
933
1082
  return 2;
934
1083
  }
935
- let engine;
1084
+ let picked;
936
1085
  try {
937
- engine = resolveEngineForRole(role, root);
1086
+ picked = resolveEngineForRoleRanked(role, root);
938
1087
  } catch (err) {
939
1088
  if (json) console.log(JSON.stringify({ ok: false, error: err.message }, null, 2));
940
1089
  else console.error(err.message);
941
1090
  return 2;
942
1091
  }
1092
+ const engine = picked.engine;
943
1093
  if (!engine) {
944
1094
  const message = `No ready installed engine can fill role "${role}".`;
945
- if (json) console.log(JSON.stringify({ ok: false, role, error: message }, null, 2));
1095
+ if (json) console.log(JSON.stringify({ ok: false, role, error: message, won_reason: picked.reason }, null, 2));
946
1096
  else console.error(message);
947
1097
  return 1;
948
1098
  }
949
- if (json) console.log(JSON.stringify(engine, null, 2));
950
- else console.log(engine.id);
1099
+ if (json) console.log(JSON.stringify({ ...engine, won_reason: picked.reason }, null, 2));
1100
+ else {
1101
+ console.error(picked.reason);
1102
+ console.log(engine.id);
1103
+ }
1104
+ return 0;
1105
+ }
1106
+
1107
+ // Doctor is the one opt-in place that probes the machine: it checks every
1108
+ // engine binary, reports installed state, and folds ready/not_installed flips
1109
+ // back into the policy file. Routing itself never probes.
1110
+ function runDoctorCommand(args, root) {
1111
+ const json = args.includes('--json');
1112
+ const engines = engineDoctorReport(root);
1113
+ if (json) {
1114
+ console.log(JSON.stringify({ ok: true, engines }, null, 2));
1115
+ return 0;
1116
+ }
1117
+ console.log('');
1118
+ for (const engine of engines) {
1119
+ const state = engine.installed ? 'installed' : 'missing';
1120
+ const health = engine.health.status.replace(/_/g, ' ');
1121
+ console.log(` ${engine.id.padEnd(12)} ${String(engine.bin).padEnd(14)} ${state.padEnd(10)} health: ${health}`);
1122
+ }
1123
+ console.log('');
1124
+ console.log(' probed just now and saved to the registry; routing reads that saved policy, never the machine.');
1125
+ console.log('');
951
1126
  return 0;
952
1127
  }
953
1128
 
@@ -1198,9 +1373,12 @@ function runDispatchCommand(args, root) {
1198
1373
  return 2;
1199
1374
  }
1200
1375
  }
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`);
1376
+ // Execution stage: this is where the binary must exist. Routing above never
1377
+ // probed the machine; a missing CLI fails loudly here instead.
1378
+ try {
1379
+ requireEngineBin(canonical);
1380
+ } catch (err) {
1381
+ console.error(`engine dispatch: ${err.message}`);
1204
1382
  return 2;
1205
1383
  }
1206
1384
  return runDispatchFlight({ root, taskIds, engine: canonical, prompt: promptOverride, yolo, ...(base ? { checkoutBase: base } : {}) }).then((flight) => {
@@ -1243,6 +1421,19 @@ function engineCommand(args = [], deps = {}) {
1243
1421
  return runHealthCommand(args.slice(args.indexOf('health') + 1), root);
1244
1422
  }
1245
1423
 
1424
+ if (sub === 'doctor') {
1425
+ return runDoctorCommand(args.slice(args.indexOf('doctor') + 1), root);
1426
+ }
1427
+
1428
+ if (sub === 'set') {
1429
+ return runSetEngineCommand(args.slice(args.indexOf('set') + 1), root);
1430
+ }
1431
+
1432
+ if (sub === 'chart' || args.includes('--chart')) {
1433
+ printEngineChart(root);
1434
+ return 0;
1435
+ }
1436
+
1246
1437
  if (!sub || sub === 'list' || sub === 'status') {
1247
1438
  if (json) {
1248
1439
  console.log(JSON.stringify(registryPayload(root), null, 2));
@@ -1261,7 +1452,7 @@ function engineCommand(args = [], deps = {}) {
1261
1452
  }
1262
1453
 
1263
1454
  if (sub === 'help') {
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');
1455
+ console.log('\n atris engine roster + current default\n atris engines --chart show the fleet as an org chart\n atris engine list --json full registry: default + engines with tier, roles, fallback, health\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 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');
1265
1456
  return 0;
1266
1457
  }
1267
1458
 
@@ -1294,36 +1485,25 @@ module.exports = {
1294
1485
  resolveDefaultEngine,
1295
1486
  canonicalEngineName,
1296
1487
  readSavedEngine,
1297
- setEngine,
1298
- resetEngine,
1299
1488
  roster,
1300
- registryPayload,
1301
1489
  engineRegistryFile,
1302
1490
  readEngineRegistry,
1303
1491
  resolveEngineForRole,
1304
1492
  setEngineHealth,
1305
- probeEngine,
1306
- runEngineTest,
1493
+ setEngineOverrides,
1494
+ renderEngineChart,
1307
1495
  parseDispatchArgs,
1308
1496
  runDispatchCommand,
1309
1497
  ENGINE_LOGIN_MANIFESTS,
1310
- MAX_ENGINE_LOGIN_FILE_BYTES,
1311
1498
  expandHomePath,
1312
1499
  normalizeLoginProvider,
1313
1500
  normalizeEngineLoginSeat,
1314
1501
  validEngineLoginSeat,
1315
1502
  engineLoginSeatError,
1316
- detectedEmailFromContent,
1317
- readEngineLoginFiles,
1318
1503
  parseEngineLoginArgs,
1319
- parseEngineSeedArgs,
1320
- redactBackendResponse,
1321
- buildEngineLoginPayload,
1322
- listEngineLogins,
1323
1504
  listEngineSeats,
1324
1505
  readyCheckEngineLogin,
1325
1506
  formatEngineSeats,
1326
- runEngineSeatsCommand,
1327
1507
  runEngineDeviceLoginCommand,
1328
1508
  runEngineLoginCommand,
1329
1509
  runEngineSeedCommand,
@@ -588,8 +588,4 @@ function experimentsCommand(subcommand, ...args) {
588
588
  module.exports = {
589
589
  experimentsCommand,
590
590
  ensureExperimentsFramework,
591
- buildBenchmarkArtifact,
592
- parseRunOptions,
593
- experimentsDaily,
594
- experimentsQueue,
595
591
  };
@@ -79,6 +79,16 @@ async function writeReportToComputer(token, businessId, workspaceId, report) {
79
79
  return remotePath;
80
80
  }
81
81
 
82
+ // A missing scoreboard field must never reach the owner as "$NaN" or
83
+ // "$undefined"; coerce every number to 0 before it renders.
84
+ function formatScoreboardPnl(board) {
85
+ if (!board) return '';
86
+ const mrr = Number(board.revenue_mrr_usd) || 0;
87
+ const cost = (Number(board.compute_cost_usd) || 0) + (Number(board.ec2_cost_usd) || 0);
88
+ const profit = Number(board.profit_daily_usd) || 0;
89
+ return ` | mrr $${mrr} cost $${cost.toFixed(2)}/d profit $${profit}/d`;
90
+ }
91
+
82
92
  async function deliverToBusiness(token, biz, { wake, dryRun }) {
83
93
  const label = biz.slug || biz.name || biz.id;
84
94
  const status = await computerStatus(token, biz.id);
@@ -97,9 +107,7 @@ async function deliverToBusiness(token, biz, { wake, dryRun }) {
97
107
 
98
108
  const report = await fetchDailyReport(token, biz.id);
99
109
  const board = report.scoreboard || null;
100
- const pnl = board
101
- ? ` | mrr $${board.revenue_mrr_usd} cost $${(board.compute_cost_usd + (board.ec2_cost_usd || 0)).toFixed(2)}/d profit $${board.profit_daily_usd}/d`
102
- : '';
110
+ const pnl = formatScoreboardPnl(board);
103
111
  if (dryRun) {
104
112
  console.log(` ${label}: dry-run, report ${String(report.markdown || '').length} chars${pnl}`);
105
113
  return { business: label, delivered: false, reason: 'dry-run', scoreboard: board };
@@ -203,4 +211,4 @@ async function fleetReport() {
203
211
  process.exitCode = delivered > 0 || dryRun || results.every((r) => r.reason === 'asleep') ? 0 : 1;
204
212
  }
205
213
 
206
- module.exports = { fleetReport };
214
+ module.exports = { fleetReport, formatScoreboardPnl };