atris 3.38.0 → 3.41.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 (78) hide show
  1. package/AGENTS.md +25 -6
  2. package/atris/PERSONA.md +8 -4
  3. package/atris.md +7 -0
  4. package/ax +2 -1
  5. package/bin/atris.js +31 -6
  6. package/commands/agent-spawn.js +13 -11
  7. package/commands/autoland.js +28 -77
  8. package/commands/bench.js +10 -12
  9. package/commands/business.js +345 -0
  10. package/commands/chat-scan.js +5 -7
  11. package/commands/codex-goal.js +8 -10
  12. package/commands/computer.js +20 -0
  13. package/commands/console.js +19 -3
  14. package/commands/decide.js +166 -0
  15. package/commands/deck.js +1 -4
  16. package/commands/drill.js +14 -24
  17. package/commands/engine.js +196 -8
  18. package/commands/gm.js +8 -6
  19. package/commands/harvest.js +1 -4
  20. package/commands/init.js +23 -3
  21. package/commands/land.js +8 -14
  22. package/commands/launchpad.js +1 -14
  23. package/commands/lifecycle.js +5 -5
  24. package/commands/log.js +55 -5
  25. package/commands/member.js +558 -574
  26. package/commands/mission.js +456 -237
  27. package/commands/pack.js +2746 -164
  28. package/commands/play.js +6 -4
  29. package/commands/probe.js +2 -2
  30. package/commands/pulse.js +15 -16
  31. package/commands/release.js +10 -9
  32. package/commands/router.js +5 -4
  33. package/commands/site-deploy.js +885 -0
  34. package/commands/site.js +11 -2
  35. package/commands/slop.js +14 -2
  36. package/commands/stream.js +4 -18
  37. package/commands/task.js +880 -558
  38. package/commands/taste.js +101 -0
  39. package/commands/team.js +176 -3
  40. package/commands/vercel.js +4 -2
  41. package/commands/voice.js +195 -0
  42. package/commands/watch.js +1 -22
  43. package/commands/wiki.js +1 -4
  44. package/commands/workflow.js +2 -2
  45. package/commands/worktree.js +1 -14
  46. package/commands/xp.js +27 -24
  47. package/lib/accept-verify-gate.js +5 -1
  48. package/lib/arg-parser.js +41 -0
  49. package/lib/auto-accept-certified.js +116 -1
  50. package/lib/autoland.js +66 -0
  51. package/lib/bench/runner.js +19 -1
  52. package/lib/context-gatherer.js +7 -1
  53. package/lib/engine-registry.js +141 -20
  54. package/lib/falsifier-probe.js +84 -0
  55. package/lib/fleet.js +65 -15
  56. package/lib/git-spawn.js +15 -0
  57. package/lib/json-file.js +37 -0
  58. package/lib/known-commands.js +2 -2
  59. package/lib/lesson-preflight.js +146 -0
  60. package/lib/loop-doctor.js +0 -2
  61. package/lib/mission-human-asks.js +28 -0
  62. package/lib/mission-protected-lane.js +4 -1
  63. package/lib/official-cli-integration.js +47 -2
  64. package/lib/orb-context.js +8 -1
  65. package/lib/pack-capabilities.js +685 -0
  66. package/lib/router-brain.js +51 -1
  67. package/lib/runner-command.js +0 -6
  68. package/lib/self-drive.js +44 -13
  69. package/lib/task-db.js +137 -3
  70. package/lib/task-decision.js +50 -0
  71. package/lib/taste-lessons.js +153 -0
  72. package/lib/tool-result-encode.js +17 -1
  73. package/lib/voice-gate.js +66 -0
  74. package/lib/wish-audit.js +1 -1
  75. package/lib/wish-delegate.js +1 -1
  76. package/lib/zip.js +95 -7
  77. package/package.json +2 -1
  78. package/templates/business-starter/persona.md +9 -0
@@ -0,0 +1,166 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ answerMissionHumanAsk,
5
+ listMissions,
6
+ listWorktreeRollupMissions,
7
+ pingMission,
8
+ } = require('./mission');
9
+ const { openHumanAsks, normalizeHumanAsks } = require('../lib/mission-human-asks');
10
+ const { redirectToWorkspaceRoot } = require('../lib/mission-root');
11
+ const { shortId } = require('../lib/short-name');
12
+
13
+ const TERMINAL_STATUSES = new Set(['stopped', 'complete']);
14
+
15
+ function missionTouchedAt(mission) {
16
+ return String(mission.updated_at || mission.created_at || '');
17
+ }
18
+
19
+ function liveMissions(root = process.cwd()) {
20
+ const seen = new Set();
21
+ return [...listMissions(root), ...listWorktreeRollupMissions(root)]
22
+ .filter((mission) => {
23
+ if (!mission || !mission.id || seen.has(mission.id) || TERMINAL_STATUSES.has(mission.status)) return false;
24
+ seen.add(mission.id);
25
+ return true;
26
+ })
27
+ .sort((left, right) => (
28
+ missionTouchedAt(right).localeCompare(missionTouchedAt(left))
29
+ || String(left.id).localeCompare(String(right.id))
30
+ ));
31
+ }
32
+
33
+ function collectOpenDecisions(root = process.cwd()) {
34
+ const decisions = [];
35
+ for (const mission of liveMissions(root)) {
36
+ const normalized = normalizeHumanAsks(mission.human_asks);
37
+ normalized.forEach((ask, askIndex) => {
38
+ if (!ask.text.trim() || ask.answered_at) return;
39
+ decisions.push({
40
+ number: decisions.length + 1,
41
+ owner: String(mission.owner || 'unowned'),
42
+ mission_id: mission.id,
43
+ mission_short_id: shortId(mission.id),
44
+ mission_status: mission.status,
45
+ mission_updated_at: missionTouchedAt(mission),
46
+ ask_index: askIndex,
47
+ text: ask.text,
48
+ });
49
+ });
50
+ }
51
+ return decisions;
52
+ }
53
+
54
+ function printHelp() {
55
+ console.log('Usage:');
56
+ console.log(' atris decide');
57
+ console.log(' atris decide <n> y|n|yes|no [--note "<text>"]');
58
+ console.log(' atris decide --json');
59
+ console.log(' atris decide <n> y --json');
60
+ }
61
+
62
+ function fail(message, asJson, code = 2) {
63
+ if (asJson) {
64
+ console.log(JSON.stringify({ ok: false, action: 'decide_error', error: message }));
65
+ } else {
66
+ console.error(message);
67
+ }
68
+ process.exitCode = code;
69
+ }
70
+
71
+ function parseArgs(args) {
72
+ const asJson = args.includes('--json');
73
+ const rest = args.filter((arg) => arg !== '--json');
74
+ let note = '';
75
+ const noteIndex = rest.findIndex((arg) => arg === '--note' || String(arg).startsWith('--note='));
76
+ if (noteIndex !== -1) {
77
+ const noteArg = String(rest[noteIndex]);
78
+ if (noteArg === '--note') {
79
+ if (rest[noteIndex + 1] == null) return { asJson, error: '--note requires text' };
80
+ note = String(rest[noteIndex + 1]).trim();
81
+ rest.splice(noteIndex, 2);
82
+ } else {
83
+ note = noteArg.slice('--note='.length).trim();
84
+ rest.splice(noteIndex, 1);
85
+ }
86
+ }
87
+ return { asJson, note, rest };
88
+ }
89
+
90
+ function decideCommand(args = []) {
91
+ redirectToWorkspaceRoot();
92
+ const parsed = parseArgs(args);
93
+ if (parsed.error) return fail(parsed.error, parsed.asJson);
94
+ const { asJson, note, rest } = parsed;
95
+ if (rest.includes('--help') || rest.includes('-h') || rest[0] === 'help') {
96
+ printHelp();
97
+ return;
98
+ }
99
+
100
+ const decisions = collectOpenDecisions();
101
+ if (!rest.length) {
102
+ if (asJson) {
103
+ console.log(JSON.stringify({
104
+ ok: true,
105
+ action: 'decide_list',
106
+ count: decisions.length,
107
+ decisions,
108
+ }, null, 2));
109
+ } else if (!decisions.length) {
110
+ console.log('nothing is waiting for a decision.');
111
+ } else {
112
+ for (const decision of decisions) {
113
+ console.log(`[${decision.number}] ${decision.owner} · ${decision.mission_short_id} · ${decision.text}`);
114
+ }
115
+ console.log('atris decide <n> y|n');
116
+ }
117
+ return;
118
+ }
119
+
120
+ if (rest.length !== 2) {
121
+ return fail('usage: atris decide <n> y|n|yes|no [--note "<text>"]', asJson);
122
+ }
123
+ const number = Number(rest[0]);
124
+ if (!Number.isInteger(number) || number < 1) {
125
+ return fail('decision number must be a positive integer', asJson);
126
+ }
127
+ const answerToken = String(rest[1]).toLowerCase();
128
+ const answer = answerToken === 'y' || answerToken === 'yes'
129
+ ? 'yes'
130
+ : (answerToken === 'n' || answerToken === 'no' ? 'no' : null);
131
+ if (!answer) return fail('answer must be y, n, yes, or no', asJson);
132
+ const decision = decisions[number - 1];
133
+ if (!decision) return fail(`decision ${number} is not open`, asJson, 1);
134
+
135
+ const message = `Decision on "${decision.text}": ${answer.toUpperCase()}${note ? ` — ${note}` : ''}`;
136
+ pingMission([decision.mission_id, message, '--from', 'decide'], { silent: true });
137
+ const mission = answerMissionHumanAsk(decision.mission_id, decision.ask_index, answer, note);
138
+ const remainingOpenAsks = openHumanAsks(mission.human_asks).length;
139
+ const payload = {
140
+ ok: true,
141
+ action: 'decision_answered',
142
+ decision: {
143
+ ...decision,
144
+ answer,
145
+ note,
146
+ message,
147
+ },
148
+ mission: {
149
+ id: mission.id,
150
+ short_id: decision.mission_short_id,
151
+ owner: mission.owner,
152
+ status: mission.status,
153
+ remaining_open_asks: remainingOpenAsks,
154
+ },
155
+ };
156
+ if (asJson) {
157
+ console.log(JSON.stringify(payload, null, 2));
158
+ } else {
159
+ console.log(`sent to ${decision.mission_short_id}: ${message}`);
160
+ console.log(`mission ${decision.mission_short_id} will read it on its next tick.`);
161
+ }
162
+ }
163
+
164
+ module.exports = {
165
+ decideCommand,
166
+ };
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
 
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)
@@ -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,
@@ -34,6 +35,9 @@ const {
34
35
  readEngineRegistry,
35
36
  resolveEngineForRole,
36
37
  resolveEngineForRoleRanked,
38
+ requireEngineBin,
39
+ engineDoctorReport,
40
+ setEngineOverrides,
37
41
  setEngineHealth,
38
42
  } = require('../lib/engine-registry');
39
43
  const { FLEET_CAPABLE, runDispatchFlight } = require('../lib/fleet');
@@ -90,14 +94,22 @@ function readSavedEngine(root = process.cwd()) {
90
94
 
91
95
  // The default engine for this workspace, in precedence order:
92
96
  // env (per-run flags land here) -> .atris/engine.json -> house default.
93
- // 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.
94
101
  function resolveDefaultEngine(root = process.cwd()) {
95
102
  const env = canonicalEngineName(process.env.ATRIS_RUNNER_PROFILE);
96
103
  if (env) return { name: env, source: 'env' };
97
104
  const saved = readSavedEngine(root);
98
105
  if (saved) return { name: saved, source: 'saved' };
99
- if (binInstalled(RUNNER_PROFILE_DEFS[HOUSE_ENGINE].bin)) return { name: HOUSE_ENGINE, source: 'house' };
100
- 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));
101
113
  return fallback ? { name: fallback, source: 'detected' } : { name: HOUSE_ENGINE, source: 'none' };
102
114
  }
103
115
 
@@ -887,13 +899,16 @@ function printRoster(root) {
887
899
  const found = list.filter((e) => e.installed).length;
888
900
  const current = resolveDefaultEngine(root);
889
901
  console.log('');
890
- console.log(` engines — ${found} intelligence${found === 1 ? '' : 's'} found`);
902
+ console.log(` engines: ${found} intelligence${found === 1 ? '' : 's'} found`);
891
903
  console.log('');
892
904
  for (const engine of list) {
893
905
  const mark = engine.default ? '→' : ' ';
894
906
  const state = engine.health.status === 'not_installed' ? 'not installed' : engine.health.status.replace(/_/g, ' ');
895
907
  const roles = engine.roles.join(',');
896
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(' ')}`);
897
912
  }
898
913
  console.log('');
899
914
  console.log(` default: ${current.name}${current.source === 'saved' ? ' (set here)' : current.source === 'env' ? ' (this session)' : ''}`);
@@ -901,6 +916,139 @@ function printRoster(root) {
901
916
  console.log('');
902
917
  }
903
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
+
904
1052
  function registryPayload(root) {
905
1053
  const current = resolveDefaultEngine(root);
906
1054
  const registry = readEngineRegistry(root);
@@ -956,6 +1104,28 @@ function runResolveCommand(args, root) {
956
1104
  return 0;
957
1105
  }
958
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('');
1126
+ return 0;
1127
+ }
1128
+
959
1129
  function runHealthCommand(args, root) {
960
1130
  const json = args.includes('--json');
961
1131
  const positional = args.filter((a) => !String(a).startsWith('--'));
@@ -1203,9 +1373,12 @@ function runDispatchCommand(args, root) {
1203
1373
  return 2;
1204
1374
  }
1205
1375
  }
1206
- const def = RUNNER_PROFILE_DEFS[canonical];
1207
- if (!binInstalled(def.bin)) {
1208
- 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}`);
1209
1382
  return 2;
1210
1383
  }
1211
1384
  return runDispatchFlight({ root, taskIds, engine: canonical, prompt: promptOverride, yolo, ...(base ? { checkoutBase: base } : {}) }).then((flight) => {
@@ -1248,6 +1421,19 @@ function engineCommand(args = [], deps = {}) {
1248
1421
  return runHealthCommand(args.slice(args.indexOf('health') + 1), root);
1249
1422
  }
1250
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
+
1251
1437
  if (!sub || sub === 'list' || sub === 'status') {
1252
1438
  if (json) {
1253
1439
  console.log(JSON.stringify(registryPayload(root), null, 2));
@@ -1266,7 +1452,7 @@ function engineCommand(args = [], deps = {}) {
1266
1452
  }
1267
1453
 
1268
1454
  if (sub === 'help') {
1269
- 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');
1270
1456
  return 0;
1271
1457
  }
1272
1458
 
@@ -1304,6 +1490,8 @@ module.exports = {
1304
1490
  readEngineRegistry,
1305
1491
  resolveEngineForRole,
1306
1492
  setEngineHealth,
1493
+ setEngineOverrides,
1494
+ renderEngineChart,
1307
1495
  parseDispatchArgs,
1308
1496
  runDispatchCommand,
1309
1497
  ENGINE_LOGIN_MANIFESTS,
package/commands/gm.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
+ const { hasFlag: hasExactFlag } = require('../lib/arg-parser');
6
7
 
7
8
  const AGENTXP_LEADERBOARD_URL = 'https://api.atris.ai/api/agentxp/leaderboard';
8
9
  const AGENTXP_GLOBAL_SYNC_RULE = 'Run atris login, then sync. Owner-provided sync tokens are guided-demo fallback only.';
@@ -54,8 +55,9 @@ function flag(args, name) {
54
55
  return null;
55
56
  }
56
57
 
57
- function hasFlag(args, name) {
58
- return args.includes(name) || args.some(arg => arg.startsWith(`${name}=`));
58
+ // This command treats --name=value as flag presence as well as a standalone flag.
59
+ function hasFlagOrValue(args, name) {
60
+ return hasExactFlag(args, name) || args.some(arg => arg.startsWith(`${name}=`));
59
61
  }
60
62
 
61
63
  function positional(args) {
@@ -185,7 +187,7 @@ function pickSeedPlayer(workspaceRoot, tasks, args = []) {
185
187
  }
186
188
 
187
189
  function ensureStarterMission(taskDb, db, workspaceRoot, tasks, args = []) {
188
- if (hasFlag(args, '--no-seed')) return { tasks, seeded: null };
190
+ if (hasFlagOrValue(args, '--no-seed')) return { tasks, seeded: null };
189
191
  if (!fs.existsSync(path.join(workspaceRoot, 'atris'))) return { tasks, seeded: null };
190
192
  if (activeAgentXpTasks(tasks).length) return { tasks, seeded: null };
191
193
 
@@ -658,7 +660,7 @@ async function gmCommand(...args) {
658
660
  return memberCommand('wake', member, ...passthrough);
659
661
  }
660
662
 
661
- if (!hasFlag(args, '--json') && !hasFlag(args, '--watch')) {
663
+ if (!hasFlagOrValue(args, '--json') && !hasFlagOrValue(args, '--watch')) {
662
664
  try {
663
665
  const { gameCommand } = require('./game');
664
666
  const dashboardShown = await gameCommand([], { silentMissing: true });
@@ -669,12 +671,12 @@ async function gmCommand(...args) {
669
671
  }
670
672
 
671
673
  const state = gmState(args);
672
- if (hasFlag(args, '--json')) {
674
+ if (hasFlagOrValue(args, '--json')) {
673
675
  console.log(JSON.stringify(state, null, 2));
674
676
  return;
675
677
  }
676
678
  render(state);
677
- if (hasFlag(args, '--watch')) {
679
+ if (hasFlagOrValue(args, '--watch')) {
678
680
  await gmWatch(args);
679
681
  }
680
682
  }
@@ -1,14 +1,11 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const { hasFlag } = require('../lib/arg-parser');
3
4
  const {
4
5
  isGenericInboxPlaceholder,
5
6
  seedInboxFromMove,
6
7
  } = require('../lib/next-moves');
7
8
 
8
- function hasFlag(args, name) {
9
- return args.includes(name);
10
- }
11
-
12
9
  function safeRead(file) {
13
10
  try { return fs.readFileSync(file, 'utf8'); } catch { return ''; }
14
11
  }