atris 3.82.18 → 3.82.20

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.
@@ -8217,6 +8217,9 @@ function detectUnavailableModel(text) {
8217
8217
  // need a resume; a model-unavailable pause is a config error a bare resume won't fix,
8218
8218
  // so name the dead id and the two knobs that change it.
8219
8219
  function missionPauseNextAction(pauseReason, missionId, deadModel = null, lastErrorReason = null) {
8220
+ if (pauseReason === 'member-runs-elsewhere') {
8221
+ return 'left for the machine its owner works on; to run it here, move the owner back: atris engine roster set --member <name> --on local';
8222
+ }
8220
8223
  if (pauseReason === 'protected-lane-review') {
8221
8224
  return `review the protected diff and its receipt before resuming: atris mission run ${missionId}`;
8222
8225
  }
@@ -9577,6 +9580,11 @@ async function executeMissionRunTicksPhase(context) {
9577
9580
  let cachedStep = findCachedMissionStepReceipt(cwd, { missionId: mission.id, tickIndex: tickIdx });
9578
9581
  let cachedVerifierResult = cachedStep ? cachedStep.verifier_result : null;
9579
9582
  const tickSelection = resolveMissionTickRunner(runtimeMission, cwd);
9583
+ if (tickSelection.runs_elsewhere) {
9584
+ console.error(`${mission.owner} runs on ${tickSelection.runs_elsewhere}, so this machine leaves the work for it.`);
9585
+ pauseReason = 'member-runs-elsewhere';
9586
+ break;
9587
+ }
9580
9588
  const tickRuntimeMission = tickSelection.mission;
9581
9589
  const tickRunnerName = String(tickRuntimeMission.runner || '').trim().toLowerCase();
9582
9590
  const tickCallerSessionRunner = runnerUsesCallerSession(tickRunnerName);
@@ -10406,7 +10414,9 @@ function completeMissionRunPhase(context) {
10406
10414
  // 'no-progress' is a clean stop the run diagnosed itself, never route it
10407
10415
  // through handleMissionBlocker (that would file a blocker task and
10408
10416
  // dispatch an engine to "fix" a mission that correctly stopped itself).
10409
- const blockerReason = ['no-progress', 'budget-exhausted'].includes(pauseReason)
10417
+ // 'member-runs-elsewhere' is the same: the owner's own machine does the
10418
+ // work, so nothing here is broken and nothing local should "fix" it.
10419
+ const blockerReason = ['no-progress', 'budget-exhausted', 'member-runs-elsewhere'].includes(pauseReason)
10410
10420
  ? null
10411
10421
  : (pauseReason || mission.stop_reason || (mission.status === 'blocked' ? 'verifier-failed' : null));
10412
10422
  if (selfDrive && blockerReason) {
package/commands/probe.js CHANGED
@@ -59,14 +59,58 @@ function normalizeBash(cmd, label) {
59
59
  return cmd.split(l + '/').join('').split(l).join('.');
60
60
  }
61
61
 
62
- // No write/edit file ops, and the `..` guard only covers args.path. The
63
- // `bash` op still executes whatever command the model sends, verbatim, on
64
- // the remote ai-computer, that is the production relay contract and the
65
- // table must stay in lockstep with it, so this probe is NOT read-only.
62
+ // Every file op the server's local_file_op offers: the enum of LOCAL_FILE_TOOL
63
+ // in the backend (backend/rl/tool_policy_bench.py). The backend test
64
+ // test_local_file_op_probe_lockstep.py pins that enum to this list, so the
65
+ // server cannot add an op without that test failing until this table and
66
+ // SERVER_FILE_OPS are updated together. test/probe-file-ops.test.js fails if
67
+ // any op listed here has no command below.
68
+ const SERVER_FILE_OPS = ['list', 'search', 'glob', 'inspect', 'read', 'write', 'append', 'edit', 'bash'];
69
+
70
+ // Directory names the server hides from glob unless include_generated is set
71
+ // (IGNORED_WORKSPACE_PARTS in the same backend file).
72
+ const IGNORED_PARTS = ['.git', '.next', '.pytest_cache', '.venv', '__pycache__', 'build', 'dist', 'node_modules', 'release', 'venv'];
73
+
74
+ // The `..` guard. It covers args.path and, for the ops that take more than one
75
+ // path, every `paths` entry and the glob pattern. The `bash` op still executes
76
+ // whatever command the model sends, verbatim, on the remote ai-computer, that
77
+ // is the production relay contract and the table must stay in lockstep with
78
+ // it, so this probe is NOT read-only.
79
+ function hasParentRef(value) {
80
+ return String(value).split('/').includes('..');
81
+ }
82
+
83
+ function boundedInt(value, fallback, low, high) {
84
+ const parsed = Number.parseInt(value, 10);
85
+ if (!Number.isFinite(parsed)) return fallback;
86
+ return Math.min(Math.max(parsed, low), high);
87
+ }
88
+
89
+ function b64(value) {
90
+ return Buffer.from(String(value), 'utf8').toString('base64');
91
+ }
92
+
93
+ // Same edit the server runs (_python_edit_command): replace the first match.
94
+ const PY_EDIT = 'import base64,pathlib,sys;'
95
+ + 'p=pathlib.Path(sys.argv[1]);text=p.read_text(encoding=\'utf-8\');'
96
+ + 'old=base64.b64decode(sys.argv[2]).decode(\'utf-8\');'
97
+ + 'new=base64.b64decode(sys.argv[3]).decode(\'utf-8\');'
98
+ + 'old in text or sys.exit(\'find text not found\');'
99
+ + 'p.write_text(text.replace(old,new,1),encoding=\'utf-8\')';
100
+
101
+ // glob like the server's pathlib glob: `**` recurses, hidden files match,
102
+ // directories end in `/`, generated directories are hidden unless asked for.
103
+ const PY_GLOB = 'import glob,os,pathlib,sys\n'
104
+ + 'pat,limit,gen,ign=sys.argv[1],int(sys.argv[2]),sys.argv[3]==\'1\',sys.argv[4].split(\',\')\n'
105
+ + 'raw=map(str,pathlib.Path(\'.\').glob(pat)) if not os.path.isabs(pat) else glob.glob(pat,recursive=True)\n'
106
+ + 'hits=sorted({h+(\'/\' if os.path.isdir(h) else \'\') for h in raw if gen or not any(x in ign for x in h.split(\'/\'))})\n'
107
+ + 'print(\'\\n\'.join(hits[:limit]))\n'
108
+ + 'len(hits)>limit and print(\'[%d more match(es) not shown]\'%(len(hits)-limit))';
109
+
66
110
  function fileOpCommand(args, label) {
67
111
  const op = String(args.type || '').toLowerCase();
68
112
  const raw = normalizePath(String(args.path || '') || '.', label);
69
- if (raw.split('/').includes('..')) return null;
113
+ if (hasParentRef(raw)) return null;
70
114
  const p = shq(raw);
71
115
  if (op === 'bash') return `( ${normalizeBash(String(args.command || '') || 'true', label)} )`;
72
116
  if (op === 'list') return `find ${p} -maxdepth 3 -not -path '*/node_modules/*' -not -path '*/.git/*' | head -200`;
@@ -74,7 +118,42 @@ function fileOpCommand(args, label) {
74
118
  const q = shq(String(args.query || args.pattern || ''));
75
119
  return `grep -rn -m 50 ${q} ${p} 2>/dev/null | head -50`;
76
120
  }
121
+ // The server's read with a wildcard (*, ?, [) is a glob.
122
+ if (op === 'read' && /[*?[]/.test(raw)) return fileOpCommand({ ...args, type: 'glob', pattern: raw }, label);
77
123
  if (op === 'read') return `{ [ -d ${p} ] && ls -p ${p} | head -200 || head -c 12000 ${p}; }`;
124
+ if (op === 'glob') {
125
+ const pattern = normalizePath(String(args.pattern || args.query || args.path || '').trim(), label);
126
+ if (!pattern || hasParentRef(pattern)) return null;
127
+ const limit = boundedInt(args.max_results, 200, 1, 1000);
128
+ return `python3 -c ${shq(PY_GLOB)} ${shq(pattern)} ${limit} ${args.include_generated ? 1 : 0} ${shq(IGNORED_PARTS.join(','))}`;
129
+ }
130
+ if (op === 'inspect') {
131
+ const depth = boundedInt(args.max_depth, 2, 1, 4);
132
+ const limit = boundedInt(args.max_results, 80, 1, 300);
133
+ const rawPaths = args.paths;
134
+ const listed = Array.isArray(rawPaths)
135
+ ? rawPaths.map((item) => String(item).trim()).filter(Boolean)
136
+ : String(rawPaths || '').split(',').map((item) => item.trim()).filter(Boolean);
137
+ const reads = listed.slice(0, 8).map((item) => normalizePath(item, label));
138
+ if (reads.some(hasParentRef)) return null;
139
+ const pieces = [`find ${p} -mindepth 1 -maxdepth ${depth} -print | head -${limit}`];
140
+ for (const rel of reads) pieces.push(`printf '\\n== %s ==\\n' ${shq(rel)}; head -c 12000 ${shq(rel)}`);
141
+ const verify = String(args.command || '').trim();
142
+ if (verify) pieces.push(`( ${normalizeBash(verify, label)} )`);
143
+ return pieces.join('; ');
144
+ }
145
+ if (op === 'write' || op === 'append') {
146
+ // A write needs a real file path and its content. The server writes an
147
+ // empty file when content is missing; a probe call like that is malformed.
148
+ if (!String(args.path || '').trim() || raw === '.' || typeof args.content !== 'string') return null;
149
+ const parent = shq(path.posix.dirname(raw));
150
+ return `mkdir -p -- ${parent} && printf %s ${shq(b64(args.content))} | base64 -d ${op === 'append' ? '>>' : '>'} ${p}`;
151
+ }
152
+ if (op === 'edit') {
153
+ const find = String(args.find || '');
154
+ if (!String(args.path || '').trim() || raw === '.' || !find) return null;
155
+ return `python3 -c ${shq(PY_EDIT)} ${p} ${b64(find)} ${b64(args.replace || '')}`;
156
+ }
78
157
  return null;
79
158
  }
80
159
 
@@ -95,6 +174,16 @@ const SIMPLE_CLI_OPS = {
95
174
  mission_status: ['mission', 'status', '--json'],
96
175
  };
97
176
 
177
+ // Things the server relays or offers that this harness does not run. A model
178
+ // that uses one gets an error back and the probe reports "probe limit", which
179
+ // says the harness could not vouch for that call, not that the product broke.
180
+ // Anything the server never offers (an invented op or tool) stays a failure.
181
+ const NOT_RUN_CLI_OPS = new Set([
182
+ 'apps_list', 'atris_search', 'imessage_doctor', 'imessage_latest',
183
+ 'task_create', 'task_update', 'skill_audit', 'codex_goal_status',
184
+ ]);
185
+ const NOT_RUN_TOOLS = new Set(['dispatch_scout']);
186
+
98
187
  function atrisCliCommand(args) {
99
188
  const op = String(args.type || '').toLowerCase();
100
189
  let argv = SIMPLE_CLI_OPS[op] || null;
@@ -269,6 +358,7 @@ async function runAtris2Turn(opts = {}) {
269
358
  let engine = null;
270
359
  const cliOps = [];
271
360
  const unsupported = [];
361
+ const limits = [];
272
362
 
273
363
  try {
274
364
  await new Promise((resolve, reject) => {
@@ -324,8 +414,8 @@ async function runAtris2Turn(opts = {}) {
324
414
  if (name === 'local_file_op') {
325
415
  const cmd = fileOpCommand(args, label);
326
416
  if (cmd === null) {
327
- out = { status: 'error', error: 'unsupported op or unsafe path' };
328
- unsupported.push(`file_op:${op}`);
417
+ out = { status: 'error', error: SERVER_FILE_OPS.includes(op) ? 'refused: unsafe path or missing argument' : 'unsupported op' };
418
+ unsupported.push(SERVER_FILE_OPS.includes(op) ? `file_op_refused:${op}` : `file_op:${op}`);
329
419
  } else {
330
420
  const term = localCwd
331
421
  ? await runLocalTerminal(cmd, localCwd, localEnv || process.env, localPathPrefix)
@@ -344,7 +434,7 @@ async function runAtris2Turn(opts = {}) {
344
434
  const cmd = atrisCliCommand(args);
345
435
  if (cmd === null) {
346
436
  out = { status: 'error', error: `unsupported atris cli op: ${op || '?'}` };
347
- unsupported.push(`cli_op:${op || '?'}`);
437
+ (NOT_RUN_CLI_OPS.has(op) ? limits : unsupported).push(`cli_op:${op || '?'}`);
348
438
  } else {
349
439
  const term = localCwd
350
440
  ? await runLocalTerminal(cmd, localCwd, localEnv || process.env, localPathPrefix)
@@ -354,7 +444,7 @@ async function runAtris2Turn(opts = {}) {
354
444
  }
355
445
  } else {
356
446
  out = { status: 'error', error: `unsupported relayed tool: ${name || '?'}` };
357
- unsupported.push(`tool:${name || '?'}`);
447
+ (NOT_RUN_TOOLS.has(name) ? limits : unsupported).push(`tool:${name || '?'}`);
358
448
  }
359
449
  await postJson(`${base}/atris2/turn/tool-result`, token,
360
450
  buildToolResultBody(ev.call_id || ev.id, out), 30000);
@@ -399,6 +489,8 @@ async function runAtris2Turn(opts = {}) {
399
489
  out.tools_run = toolsRun;
400
490
  out.cli_ops = cliOps;
401
491
  out.unsupported = unsupported;
492
+ // Only present when the harness skipped something, so the result keeps its old shape otherwise.
493
+ if (limits.length) out.limits = limits;
402
494
  out.error = err;
403
495
  out.ok = err === null;
404
496
  out.duration_ms = Date.now() - t0;
@@ -429,6 +521,7 @@ async function probeCommand(argv) {
429
521
  const err = turn.error;
430
522
  const engine = turn.engine;
431
523
  const unsupported = turn.unsupported;
524
+ const limits = turn.limits || [];
432
525
 
433
526
  // Name the dead-end (first match wins); null = converged.
434
527
  let deadEnd = null;
@@ -451,9 +544,12 @@ async function probeCommand(argv) {
451
544
  const secs = Math.round((Date.now() - t0) / 100) / 10;
452
545
  const ok = deadEnd === null;
453
546
  const stamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
454
- const detail = ok
547
+ const limitNote = limits.length
548
+ ? ` · probe limit (this harness did not run it, not a product failure): ${limits.slice(0, 3).join(',')}`
549
+ : '';
550
+ const detail = (ok
455
551
  ? `answer: ${resultText.slice(0, 80).replace(/\n/g, ' ')}`
456
- : `dead-end: ${deadEnd}`;
552
+ : `dead-end: ${deadEnd}`) + limitNote;
457
553
  const line = buildProbeLine({ stamp, where, model: a.model, member, engine, ok, secs, toolsRun, calendar: a.calendar, cliCalendarOps, detail });
458
554
  console.log(line);
459
555
  return ok ? 0 : 1;
@@ -477,6 +573,7 @@ module.exports = {
477
573
  normalizePath,
478
574
  normalizeBash,
479
575
  fileOpCommand,
576
+ SERVER_FILE_OPS,
480
577
  atrisCliCommand,
481
578
  atrisCliResult,
482
579
  runLocalTerminal,
package/lib/engine-ask.js CHANGED
@@ -34,7 +34,7 @@ const MAX_ASK_PROMPT_BYTES = 16 * 1024;
34
34
  const MAX_ASK_TOTAL_PROMPT_BYTES = 64 * 1024;
35
35
  const MAX_ASK_OUTPUT_BYTES = 1024 * 1024;
36
36
  const ASK_STOP_GRACE_MS = 250;
37
- const ASK_MODEL_ENGINES = new Set(['claude', 'fable', 'haiku', 'codex', 'cursor', 'devin', 'grok', 'agy', 'opencode', 'commandcode']);
37
+ const ASK_MODEL_ENGINES = new Set(['claude', 'fable', 'haiku', 'codex', 'cursor', 'devin', 'grok', 'muse', 'agy', 'opencode', 'commandcode']);
38
38
  const READ_ONLY_PREAMBLE = [
39
39
  'This is a read-only request.',
40
40
  'Do not modify files, create worktrees, start background agents, or run commands with side effects.',
@@ -324,6 +324,15 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
324
324
  args: ['--no-memory', '--no-subagents', '--permission-mode', 'plan', '--sandbox', 'read-only', ...(model ? ['--model', model] : []), ...effortArgs, '-p', request],
325
325
  };
326
326
  }
327
+ if (engine === 'muse') {
328
+ // --disable-write blocks file edits; the read-only preamble covers the
329
+ // shell. Network stays on so a question can read atris memory.
330
+ return {
331
+ engine,
332
+ bin: profile.bin,
333
+ args: ['exec', '--sandbox-network', 'enabled', '--disable-write', ...(model ? ['--model', model] : []), request],
334
+ };
335
+ }
327
336
  if (engine === 'agy') {
328
337
  // agy prompts for tool permission even in plan mode and a headless ask
329
338
  // has no one to answer, so it denies itself `ls` and fails every ask
@@ -54,6 +54,10 @@ const ENGINE_FLAGS = {
54
54
  values: ['--permission-mode', '--sandbox', '--reasoning-effort', '--best-of-n'],
55
55
  switches: ['--always-approve', '--no-memory', '--no-subagents'],
56
56
  },
57
+ muse: {
58
+ values: ['--sandbox-network', '--model', '--approval-mode', '--permission-profile'],
59
+ switches: ['--trust-workspace', '--disable-write', '--disable-approval', '--disable-sandbox', '--yolo'],
60
+ },
57
61
  agy: {
58
62
  values: ['--mode', '--print-timeout'],
59
63
  switches: ['--sandbox', '--dangerously-skip-permissions'],
@@ -105,6 +109,7 @@ const RUN_RULES = {
105
109
  cursor: (split) => printing(split) || split.prompt.length > 0,
106
110
  devin: printing,
107
111
  grok: printing,
112
+ muse: ({ command }) => command[0] === 'exec',
108
113
  agy: printing,
109
114
  opencode: ({ command }) => command[0] === 'run',
110
115
  'atris-fast': ({ flags, prompt }) => !flags.some(([name]) => AX_CONTROL_FLAGS.has(name)) && prompt.length > 0,
@@ -114,7 +119,7 @@ const RUN_RULES = {
114
119
 
115
120
  // Subcommand words ahead of the settings.
116
121
  function subcommandCount(engine, args) {
117
- if (engine === 'codex' || engine === 'droid' || engine === 'opencode') return 1;
122
+ if (engine === 'codex' || engine === 'droid' || engine === 'opencode' || engine === 'muse') return 1;
118
123
  if (engine === 'atris') return args[0] === 'engine' ? 2 : 1;
119
124
  return 0;
120
125
  }
@@ -138,7 +143,7 @@ function canonicalEngine(name) {
138
143
 
139
144
  // The program at the front of a launch -> the engine name the roster uses.
140
145
  const BINARY_ENGINES = Object.freeze({
141
- claude: 'claude', 'cursor-agent': 'cursor', droid: 'droid', devin: 'devin', grok: 'grok', agy: 'agy', opencode: 'opencode',
146
+ claude: 'claude', 'cursor-agent': 'cursor', droid: 'droid', devin: 'devin', grok: 'grok', muse: 'muse', agy: 'agy', opencode: 'opencode',
142
147
  ax: 'atris-fast', cmd: 'commandcode', atris: 'atris',
143
148
  });
144
149
 
@@ -174,6 +174,10 @@ const ENGINE_SEED_META = Object.freeze({
174
174
  // Devin gathers context for search only when a roster picks it.
175
175
  devin: Object.freeze({ tier: 'max', roles: Object.freeze(['executor']), roster_only_roles: Object.freeze(['navigator']), models: Object.freeze(['built-in router']), duty: 'errands', fallback_order: 40 }),
176
176
  grok: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['grok 4.7 fast', 'grok 4.7']), fallback_order: 45 }),
177
+ // Muse runs only Meta's Muse models. The router hands it build work last,
178
+ // after every engine with a longer record here; a roster pick may also give
179
+ // it search or review, so a member seat can sit on Muse whatever its job.
180
+ muse: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), roster_only_roles: Object.freeze(['navigator', 'validator']), models: Object.freeze(['muse-wonder-1.0', 'muse-spark-1.3']), fallback_order: 97 }),
177
181
  fable: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), models: Object.freeze(['opus 5.5', 'opus 5', 'fable', 'haiku']), duty: 'leader', fallback_order: 50 }),
178
182
  composer: Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator', 'executor']), models: Object.freeze(['composer 2.5']), fallback_order: 60, hosted: true }),
179
183
  haiku: Object.freeze({ tier: 'fast', roles: Object.freeze(['validator', 'navigator']), role_order: Object.freeze({ navigator: 5 }), models: Object.freeze(['haiku']), fallback_order: 70 }),
@@ -865,6 +869,7 @@ function resolveEngineModelWords(text) {
865
869
  }
866
870
  if (/^(opus|sonnet|haiku|fable|opusplan)\b/.test(lower)) return { engine: 'claude', model: model('claude', clean) };
867
871
  if (/^grok/.test(lower)) return { engine: 'grok', model: model('grok', clean) };
872
+ if (/^muse-/.test(lower)) return { engine: 'muse', model: model('muse', clean) };
868
873
  if (/^swe-/.test(lower)) return { engine: 'devin', model: clean };
869
874
  if (/^gemini-/.test(lower)) return { engine: 'agy', model: clean };
870
875
  throw new Error(`"${clean}" is not an engine or a model atris knows`);
@@ -933,7 +938,10 @@ function rosterPickFromValue(value, role, options = {}) {
933
938
  const effortMatch = /^effort\s*(?::\s*|\s+)(.+)$/i.exec(part);
934
939
  const prepMatch = /^prep\s*(?::\s*|\s+)(.+)$/i.exec(part);
935
940
  const verifiedMatch = /^verified\s+(.+)$/i.exec(part);
936
- if (verifiedMatch && !verified) {
941
+ if (/^on\s*:/i.test(part)) {
942
+ // "on: atris computer": where the member works. Read with the team
943
+ // line in member-engine.js; it does not change the engine pick.
944
+ } else if (verifiedMatch && !verified) {
937
945
  // "verified 2026-09-30": the day a person last saw this model answer.
938
946
  // The roster flags a pin whose newest proof is over 30 days old.
939
947
  const words = verifiedMatch[1].trim();
@@ -1816,14 +1824,60 @@ function setTeamPick(memberName, engineName, options = {}, root = process.cwd())
1816
1824
  const base = existing !== null ? sectionedRosterText(existing, file, { ...options, root }) : rosterMarkdownFromPicks(layer.picks);
1817
1825
  // a swap changes only the lead of the member's line already in this file
1818
1826
  const line = scanRosterMarkdown(base).entries.find((entry) => entry.section === 'team' && !entry.malformed && entry.name.trim().toLowerCase() === member);
1819
- const value = (line && teamValueWithLead(line.value, engineId, model, role, { root }))
1827
+ // the member's place ("on: grok bot") stays when its engine changes
1828
+ const { splitRunsOn } = require('./runs-on');
1829
+ const kept = line ? splitRunsOn(line.value) : { rest: '', on: '' };
1830
+ // read the line without its place, wherever the on: part sits
1831
+ let value = (kept.rest && teamValueWithLead(kept.rest, engineId, model, role, { root }))
1820
1832
  || workerLineText({ engine: engineId, model }).replace(/^-\s+/, '');
1833
+ if (kept.on && !splitRunsOn(value).on) value = `${value}, on: ${kept.on}`;
1821
1834
  rosterPickFromValue(value, role, { root });
1822
1835
  writeAtomic(file, replaceTeamLine(base, member, value));
1823
1836
  if (target.scope === 'session') sweepStaleSessionRosters(options);
1824
1837
  return { member, engine: engineId, model, value, scope: target.scope, file };
1825
1838
  }
1826
1839
 
1840
+ // Throws the plain sentence setTeamPlace would, before anything is written.
1841
+ function setTeamPlaceCheck(placeWords) {
1842
+ const { readRunsOn } = require('./runs-on');
1843
+ return readRunsOn(placeWords, { isEngine: (words) => canonicalEngineName(words) });
1844
+ }
1845
+
1846
+ // Say where one member works: "atris computer", "local", or a bot on another
1847
+ // machine ("grok bot"). The rest of the member's line stays as written. Local
1848
+ // drops the on: part, since local is what a line with no place means.
1849
+ function setTeamPlace(memberName, placeWords, options = {}, root = process.cwd()) {
1850
+ const { replaceTeamLine, scanRosterMarkdown } = require('./roster-markdown');
1851
+ const { readRunsOn, splitRunsOn } = require('./runs-on');
1852
+ const member = String(memberName || '').trim().toLowerCase();
1853
+ if (!/^[a-z0-9._-]+$/.test(member)) throw new Error(`"${memberName}" is not a member name`);
1854
+ const place = readRunsOn(placeWords, { isEngine: (words) => canonicalEngineName(words) });
1855
+ const target = rosterWriteTarget(options, root);
1856
+ const registry = readEngineRegistry(root, { persist: false });
1857
+ const layer = target.layer || readProjectRoster(root, { ...options, registry });
1858
+ const file = target.file;
1859
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
1860
+ const base = existing !== null ? sectionedRosterText(existing, file, { ...options, root }) : rosterMarkdownFromPicks(layer.picks);
1861
+ const line = scanRosterMarkdown(base).entries.find((entry) => entry.section === 'team' && !entry.malformed && entry.name.trim().toLowerCase() === member);
1862
+ const old = line ? splitRunsOn(line.value) : { rest: '', on: '' };
1863
+ let rest = old.rest;
1864
+ // "on: muse" named the engine through its place; moving keeps that engine
1865
+ if (!rest && old.on) {
1866
+ const before = readRunsOn(old.on, { isEngine: (words) => canonicalEngineName(words) });
1867
+ if (before.engine) rest = rosterToolLabel(before.engine);
1868
+ }
1869
+ const value = place.here
1870
+ ? (rest || (place.engine ? rosterToolLabel(place.engine) : 'on: local'))
1871
+ : [rest, `on: ${place.id}`].filter(Boolean).join(', ');
1872
+ if (place.here && place.engine && rest) {
1873
+ // "on: muse" on a line that already names an engine: the engine changes
1874
+ return { ...setTeamPick(member, place.engine, options, root), place };
1875
+ }
1876
+ writeAtomic(file, replaceTeamLine(base, member, value));
1877
+ if (target.scope === 'session') sweepStaleSessionRosters(options);
1878
+ return { member, value, place, scope: target.scope, file };
1879
+ }
1880
+
1827
1881
  function renewRoster(roster, date) {
1828
1882
  const next = { ...(roster || {}) };
1829
1883
  for (const key of [...ENGINE_ROLES, ...customRosterJobKeys(next)]) {
@@ -2452,6 +2506,8 @@ module.exports = {
2452
2506
  customRosterJobKeys,
2453
2507
  setRosterPick,
2454
2508
  setTeamPick,
2509
+ setTeamPlace,
2510
+ setTeamPlaceCheck,
2455
2511
  confirmRoster,
2456
2512
  engineHasRole,
2457
2513
  engineHosted,
package/lib/fleet.js CHANGED
@@ -1866,7 +1866,7 @@ module.exports = {
1866
1866
  // Engines that can edit a repo headlessly. atris-fast (ax) is a chat lane,
1867
1867
  // not a repo worker, it keeps owning normal mission ticks, not fleet builds.
1868
1868
  const FLEET_CAPABLE = ['claude', 'codex', 'cursor', 'devin', 'grok'];
1869
- const DISPATCH_CAPABLE = [...FLEET_CAPABLE, 'fable', 'commandcode'];
1869
+ const DISPATCH_CAPABLE = [...FLEET_CAPABLE, 'fable', 'commandcode', 'muse'];
1870
1870
 
1871
1871
  let receiptSequence = 0;
1872
1872
  function nowStamp() {
@@ -4,7 +4,7 @@ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'work
4
4
  'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
5
5
  'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
6
6
  'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'sentinel', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'growth', 'scorecard', 'rating', 'sync-checkout', 'zen',
7
- 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'memory', 'balance', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'google', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
7
+ 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'memory', 'balance', 'film', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'google', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
8
  'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi', 'pay', 'trust'];
9
9
 
10
10
  // Damerau-Levenshtein edit distance between two short strings. Counts an
@@ -6,6 +6,8 @@
6
6
  // builds. A line under "## team" in ROSTER.md overrides that, either with a
7
7
  // job ("researcher: search", "fixer: small build") or with engine or model
8
8
  // words ("judge: opus"). The job's pick then comes from the roster.
9
+ // An "on:" part says where the member works ("on: atris computer",
10
+ // "on: grok bot"); only a member that runs here gets an engine here.
9
11
 
10
12
  const fs = require('fs');
11
13
  const { memberRefusalReason } = require('./member-type');
@@ -28,6 +30,20 @@ const {
28
30
  rosterPickFromValue,
29
31
  } = require('./engine-registry');
30
32
  const { isParkedText } = require('./member-park');
33
+ const { readRunsOn, splitRunsOn, machineName } = require('./runs-on');
34
+
35
+ // The engine id words name, or '' when they name none atris knows.
36
+ function engineNamed(words) {
37
+ try {
38
+ return require('./engine-registry').canonicalEngineName(words) || '';
39
+ } catch { return ''; }
40
+ }
41
+
42
+ // The place one team line names, or local when it names none.
43
+ function placeFromOn(on) {
44
+ if (!on) return { id: 'local', label: machineName(), here: true };
45
+ return readRunsOn(on, { isEngine: engineNamed });
46
+ }
31
47
 
32
48
  const SEARCH_WORDS = Object.freeze(['navigator', 'researcher', 'research', 'search', 'scout']);
33
49
  const REVIEW_WORDS = Object.freeze(['validator', 'reviewer', 'review', 'judge', 'critic', 'verifier']);
@@ -111,17 +127,27 @@ function teamLineFor(state, member) {
111
127
  // member's own kind of work. error is a plain sentence for a line that means
112
128
  // neither.
113
129
  function interpretTeamValue(value, state, memberKind, options = {}) {
114
- const text = String(value || '').trim();
115
- if (!text) return { error: 'names no job or engine' };
130
+ let split;
131
+ let place;
132
+ try {
133
+ split = splitRunsOn(value);
134
+ place = placeFromOn(split.on);
135
+ } catch (error) {
136
+ return { error: error.message };
137
+ }
138
+ // "on: muse" with nothing else: that engine, here.
139
+ const text = split.rest || (place.engine || '');
140
+ if (!text) return split.on ? { type: 'place', place } : { error: 'names no job or engine' };
116
141
  const key = rosterJobKey(text);
117
- if (key && ENGINE_ROLES.includes(key)) return { type: 'job', key };
142
+ if (key && ENGINE_ROLES.includes(key)) return { type: 'job', key, place };
118
143
  const saved = (layer) => Boolean(key && layer && layer.picks && layer.picks[key]);
119
- if (saved(state.session) || saved(state.project) || saved(state.machine)) return { type: 'job', key };
144
+ if (saved(state.session) || saved(state.project) || saved(state.machine)) return { type: 'job', key, place };
120
145
  try {
121
- return { type: 'pick', pick: rosterPickFromValue(text, ENGINE_JOBS[memberKind], options) };
146
+ return { type: 'pick', pick: rosterPickFromValue(text, ENGINE_JOBS[memberKind], options), place };
122
147
  } catch (error) {
123
- if (key && inferJobKind(text)) return { type: 'job', key };
124
- return { error: error.message };
148
+ if (key && inferJobKind(text)) return { type: 'job', key, place };
149
+ // the engine part does not read, but the place still counts
150
+ return { error: error.message, place };
125
151
  }
126
152
  }
127
153
 
@@ -143,6 +169,7 @@ function resolveEngineForMember(memberName, root = process.cwd(), options = {})
143
169
  const read = line ? interpretTeamValue(line.value, state, autoKind, options) : null;
144
170
  const override = read && !read.error ? read : null;
145
171
  const yearless = override && override.type === 'pick' && override.pick.until_needs_year;
172
+ const place = read && read.place ? read.place : placeFromOn('');
146
173
  const pickOptions = {
147
174
  ...options,
148
175
  sessionRosterPicks: state.session ? state.session.picks : {},
@@ -156,6 +183,9 @@ function resolveEngineForMember(memberName, root = process.cwd(), options = {})
156
183
  source: override ? 'file' : 'auto',
157
184
  file: override ? line.file : null,
158
185
  line: override ? line.line : null,
186
+ runs_on: place.id,
187
+ runs_on_label: place.label,
188
+ runs_here: place.here,
159
189
  ...(read && read.error ? { warning: read.error } : {}),
160
190
  ...(yearless ? { until_warning: `has no year in its until date, so it counts as expired; write until ${yearless}` } : {}),
161
191
  };
@@ -187,6 +217,7 @@ function resolveEngineForMember(memberName, root = process.cwd(), options = {})
187
217
  const fromRoster = rosterDecided(resolved.source);
188
218
  return {
189
219
  ...base,
220
+ ...(override && override.type === 'place' ? { source: 'file', file: line.file, line: line.line } : {}),
190
221
  job,
191
222
  job_key: jobKey,
192
223
  role,
@@ -214,6 +245,24 @@ function memberRosterEngine(memberName, root = process.cwd(), options = {}) {
214
245
  }
215
246
  }
216
247
 
248
+ // The place a member works when it is not this machine ("Grok bot",
249
+ // "Atris computer"), or '' when it runs here. Never throws: a broken roster
250
+ // must not stop a run.
251
+ function memberRunsElsewhere(memberName, root = process.cwd(), options = {}) {
252
+ const name = String(memberName || '').trim();
253
+ if (!name || name.includes('/') || name.includes('\\')) return '';
254
+ try {
255
+ // only the member's place is read: no engine is resolved, so nothing is
256
+ // printed or probed for a member that works here
257
+ const line = teamLineFor(options.rosterState || readRosterState(root, options), name);
258
+ if (!line) return '';
259
+ const place = placeFromOn(splitRunsOn(line.value).on);
260
+ return place.here ? '' : place.label || place.id;
261
+ } catch {
262
+ return '';
263
+ }
264
+ }
265
+
217
266
  // A person or odd-typed card never runs on an engine.
218
267
  function memberCardRunnable(root, name) {
219
268
  const card = findMemberCard(root, name);
@@ -254,13 +303,19 @@ function teamRosterView(root = process.cwd(), options = {}) {
254
303
  warnings.push({ file: line.file, line: line.line, text: line.text, message: `${picked.warning}, so ${name} picks automatically` });
255
304
  }
256
305
  if (picked.until_warning && line) warnings.push({ file: line.file, line: line.line, text: line.text, message: picked.until_warning });
306
+ // A member working on another machine runs whatever that machine runs,
307
+ // unless its line names the engine; this machine's router has no say.
308
+ const theirs = picked.runs_here === false && picked.pick_source !== 'team';
257
309
  return {
258
310
  member: name,
259
311
  job: picked.job,
260
- engine: picked.engine ? picked.engine.id : null,
261
- model: picked.model || null,
312
+ engine: picked.engine && !theirs ? picked.engine.id : null,
313
+ model: theirs ? null : picked.model || null,
262
314
  effort: picked.engine && picked.engine.roster_effort ? picked.engine.roster_effort : null,
263
315
  max_seconds: picked.engine && picked.engine.roster_max_seconds ? picked.engine.roster_max_seconds : null,
316
+ runs_on: picked.runs_on,
317
+ runs_on_label: picked.runs_on_label,
318
+ runs_here: picked.runs_here,
264
319
  source: picked.source,
265
320
  file: picked.file,
266
321
  reason: picked.reason,
@@ -271,8 +326,10 @@ function teamRosterView(root = process.cwd(), options = {}) {
271
326
 
272
327
  module.exports = {
273
328
  autoJobForMember,
329
+ teamLineFor,
274
330
  readMemberCard,
275
331
  resolveEngineForMember,
276
332
  memberRosterEngine,
333
+ memberRunsElsewhere,
277
334
  teamRosterView,
278
335
  };
@@ -361,6 +361,13 @@ function memberLogTickReason(result) {
361
361
  }
362
362
 
363
363
  function resolveMissionTickRunner(mission, root = process.cwd(), options = {}) {
364
+ // A member whose team line says it works on another machine is left for
365
+ // that machine, whatever engine the mission names; this one never runs
366
+ // its work in its place.
367
+ if (mission && mission.owner) {
368
+ const elsewhere = require('./member-engine').memberRunsElsewhere(mission.owner, root, options);
369
+ if (elsewhere) return { mission, engine_id: null, requested_engine: null, engine_fallback_reason: null, runs_elsewhere: elsewhere };
370
+ }
364
371
  if (String(mission && mission.runner || '').trim().toLowerCase() !== MISSION_AUTO_RUNNER) {
365
372
  // A named engine still gets the roster's time limit for that engine.
366
373
  const runner = String(mission && mission.runner || '').trim().toLowerCase();
@@ -32,6 +32,18 @@ function normalizeGrokModel(raw) {
32
32
  throw new Error(`grok does not know the model "${raw}". use grok 4.7 fast, grok 4.7, or a full grok- id`);
33
33
  }
34
34
 
35
+ // Muse takes friendly names: "muse wonder" is muse-wonder-1.0, "spark 1.3"
36
+ // is muse-spark-1.3. Any full muse- id passes through as typed.
37
+ const MUSE_MODEL_SPELLING = /^(?:muse[\s_-]*)?(wonder|spark)(?:[\s_-]*(\d+(?:\.\d+)?))?$/;
38
+ const MUSE_DEFAULT_VERSIONS = Object.freeze({ wonder: '1.0', spark: '1.3' });
39
+
40
+ function normalizeMuseModel(raw) {
41
+ const match = MUSE_MODEL_SPELLING.exec(raw.toLowerCase());
42
+ if (match) return `muse-${match[1]}-${match[2] || MUSE_DEFAULT_VERSIONS[match[1]]}`;
43
+ if (/^muse-[a-z0-9][a-z0-9._-]*$/i.test(raw)) return raw;
44
+ throw new Error(`muse does not know the model "${raw}". use muse wonder, muse spark, or a full muse- id`);
45
+ }
46
+
35
47
  // The model to run for a claude id: its replacement when it is retired,
36
48
  // else the id as given. Keeps a "[1m]" tail.
37
49
  function currentClaudeModel(value) {
@@ -75,6 +87,7 @@ function normalizeRosterModel(engineId, value) {
75
87
  const raw = String(value || '').trim();
76
88
  if (!raw) return '';
77
89
  if (engineId === 'grok') return normalizeGrokModel(raw);
90
+ if (engineId === 'muse') return normalizeMuseModel(raw);
78
91
  if (!CLAUDE_FAMILY_ENGINES.includes(engineId)) return raw;
79
92
  // "[1m]" asks the claude cli for the long context window; keep it on
80
93
  // whatever name it follows.
@@ -120,6 +120,16 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
120
120
  effortFlag: '--reasoning-effort',
121
121
  efforts: Object.freeze(['low', 'medium', 'high']),
122
122
  }),
123
+ // Meta's Muse Code. Its sandbox sends network through a proxy by default,
124
+ // which blocks atris calls; "enabled" keeps the sandbox and approval judge
125
+ // on and lets the run reach the network. Trusting the workspace loads its
126
+ // AGENTS.md rules. Muse runs only Muse models (verified 2026-11-11).
127
+ muse: Object.freeze({
128
+ bin: 'muse',
129
+ model: '',
130
+ ownModelsOnly: true,
131
+ commandTemplate: '{bin} exec --sandbox-network enabled --trust-workspace {pinnedModelFlag} {prompt}',
132
+ }),
123
133
  agy: Object.freeze({
124
134
  bin: 'agy',
125
135
  // No hard pin: the agy CLI keeps its own session model, so an unpinned