atris 3.58.7 → 3.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/AGENTS.md +3 -2
  2. package/GETTING_STARTED.md +2 -2
  3. package/PERSONA.md +11 -11
  4. package/README.md +1 -1
  5. package/atris/GEMINI.md +7 -6
  6. package/atris/PERSONA.md +4 -4
  7. package/atris/features/README.md +77 -6
  8. package/atris/skills/README.md +44 -15
  9. package/atris/skills/aeo/SKILL.md +10 -10
  10. package/atris/skills/atris/SKILL.md +1 -1
  11. package/atris/skills/engines/SKILL.md +69 -50
  12. package/atris/skills/meta/SKILL.md +4 -4
  13. package/bin/atris.js +8 -6
  14. package/commands/aeo.js +9 -12
  15. package/commands/analytics.js +8 -11
  16. package/commands/autopilot.js +79 -8
  17. package/commands/avail.js +1 -1
  18. package/commands/brain.js +3 -1
  19. package/commands/business.js +13 -8
  20. package/commands/clean.js +17 -0
  21. package/commands/close.js +2 -1
  22. package/commands/computer.js +15 -10
  23. package/commands/context-sync.js +3 -3
  24. package/commands/doc-health.js +55 -2
  25. package/commands/engine.js +384 -4
  26. package/commands/experiments.js +13 -4
  27. package/commands/host.js +53 -0
  28. package/commands/improve.js +1 -1
  29. package/commands/integrations.js +2 -5
  30. package/commands/learn.js +1 -1
  31. package/commands/member.js +231 -53
  32. package/commands/mission.js +129 -19
  33. package/commands/one-lap.js +58 -3
  34. package/commands/pack.js +2 -2
  35. package/commands/probe.js +11 -5
  36. package/commands/pull.js +1 -1
  37. package/commands/push.js +1 -1
  38. package/commands/rainmaker.js +3 -16
  39. package/commands/run.js +4 -1
  40. package/commands/scout.js +1 -1
  41. package/commands/search.js +5 -3
  42. package/commands/spaceship.js +1 -1
  43. package/commands/stream.js +27 -2
  44. package/commands/study.js +15 -9
  45. package/commands/sync.js +1 -1
  46. package/commands/task.js +13 -2
  47. package/commands/unknowns.js +5 -0
  48. package/commands/verify.js +12 -4
  49. package/commands/visualize.js +20 -3
  50. package/commands/workflow.js +23 -7
  51. package/lib/codex-flight.js +28 -5
  52. package/lib/dispatch-scout.js +31 -22
  53. package/lib/engine-registry.js +1482 -13
  54. package/lib/feedback-triage.js +2 -0
  55. package/lib/fleet.js +111 -12
  56. package/lib/host.js +336 -0
  57. package/lib/improve-vitals-html.js +3 -2
  58. package/lib/known-commands.js +1 -1
  59. package/lib/lesson-contradiction.js +2 -2
  60. package/lib/map-refs.js +226 -0
  61. package/lib/member-engine.js +253 -0
  62. package/lib/mission-room.js +4 -4
  63. package/lib/noninteractive.js +15 -4
  64. package/lib/policy-lessons.js +1 -1
  65. package/lib/receipt-block.js +137 -28
  66. package/lib/roster-markdown.js +206 -0
  67. package/lib/roster-models.js +179 -0
  68. package/lib/runner-command.js +95 -15
  69. package/lib/scorecard.js +4 -4
  70. package/lib/self-drive.js +9 -1
  71. package/lib/sleep-sync.js +11 -0
  72. package/lib/state-detection.js +28 -9
  73. package/lib/task-db.js +3 -2
  74. package/lib/todo-fallback.js +1 -1
  75. package/lib/wish-audit.js +10 -6
  76. package/lib/wish-delegate.js +29 -5
  77. package/mcp/atris-mcp/index.mjs +77 -24
  78. package/package.json +2 -5
  79. package/scripts/det/det.js +2 -2
  80. package/scripts/det/pr-description.js +2 -2
  81. package/scripts/det/test.js +2 -2
  82. package/templates/members/host/MEMBER.md +23 -0
  83. package/templates/members/host/MISSION.md +11 -0
  84. package/templates/members/host/SOUL.md +7 -0
  85. package/templates/members/host/context/why.md +3 -0
  86. package/templates/members/host/goals.json +5 -0
  87. package/templates/members/host/goals.md +3 -0
  88. package/templates/members/host/skills/host/SKILL.md +13 -0
  89. package/utils/api.js +1 -1
  90. package/utils/auth.js +17 -7
  91. package/utils/backend-root.js +31 -0
  92. package/utils/config.js +5 -4
  93. package/utils/owner-identity.js +61 -0
  94. package/atris/policies/LESSONS.md +0 -13
  95. package/atris/skills/autopilot/atris-autopilot.md +0 -71
@@ -13,6 +13,7 @@ const {
13
13
  printCliJson,
14
14
  } = require('../lib/cli-json');
15
15
  const escapeRegExp = require('../lib/escape-regexp');
16
+ const { sleepSync } = require('../lib/sleep-sync');
16
17
  const {
17
18
  appendBriefRecord,
18
19
  stampBriefOutcome,
@@ -20,6 +21,7 @@ const {
20
21
  } = require('../lib/brief-ledger');
21
22
  const {
22
23
  resolveClaudeRunnerModel,
24
+ resolvePinnedRunnerModel,
23
25
  resolveClaudeRunnerBin,
24
26
  resolveClaudeRunnerCommandTemplate,
25
27
  buildRunnerCommand,
@@ -384,13 +386,59 @@ function resolveMissionRunnerSelection(value, options = {}) {
384
386
  exitMissionError(`Unknown ${noun} "${raw}". ${knownMissionRunnerText()}.`, 2, asJson);
385
387
  }
386
388
 
387
- function resolveMissionTickRunner(mission, root = process.cwd()) {
389
+ // The roster line's effort and time cap for this tick, when it sets them.
390
+ function rosterTickPins(engine) {
391
+ return {
392
+ ...(engine && engine.roster_effort ? { roster_effort: engine.roster_effort } : {}),
393
+ ...(engine && engine.roster_max_seconds ? { roster_max_seconds: engine.roster_max_seconds } : {}),
394
+ };
395
+ }
396
+
397
+ // A tick's time limit: the time left on the mission, capped by the roster
398
+ // line's "max 20 min" when it sets one, else by the default tick cap.
399
+ function missionTickTimeoutMs(mission, remainingMs) {
400
+ const cap = Number(mission && mission.roster_max_seconds) > 0
401
+ ? Number(mission.roster_max_seconds) * 1000
402
+ : MISSION_RUN_DEFAULTS.claudeTimeoutMs;
403
+ return clampTimeoutMs(remainingMs, cap);
404
+ }
405
+
406
+ function resolveMissionTickRunner(mission, root = process.cwd(), options = {}) {
388
407
  if (String(mission && mission.runner || '').trim().toLowerCase() !== MISSION_AUTO_RUNNER) {
389
408
  return { mission, engine_id: null, requested_engine: null, engine_fallback_reason: null };
390
409
  }
391
- const resolved = resolveEngineForRoleWithPreference('executor', root, mission.preferred_engine);
410
+ // A mission owned by a team member runs on that member's roster pick. An
411
+ // explicit preferred engine (or a run's --engine) still wins, and with no
412
+ // roster line behind the member, routing stays exactly as it was.
413
+ if (!String(mission.preferred_engine || '').trim() && mission.owner) {
414
+ const { memberRosterEngine } = require('../lib/member-engine');
415
+ const member = memberRosterEngine(mission.owner, root, options);
416
+ if (member) {
417
+ if (process.env.ATRIS_ROUTER_EXPLAIN !== '0') console.error(member.reason);
418
+ return {
419
+ mission: {
420
+ ...mission,
421
+ runner: member.engine.id,
422
+ runner_kind: 'engine',
423
+ ...(!mission.model && member.model ? { model: member.model } : {}),
424
+ ...rosterTickPins(member.engine),
425
+ },
426
+ engine_id: member.engine.id,
427
+ requested_engine: null,
428
+ engine_fallback_reason: null,
429
+ member_engine_reason: member.reason,
430
+ };
431
+ }
432
+ }
433
+ const resolved = resolveEngineForRoleWithPreference('executor', root, mission.preferred_engine, options);
392
434
  return {
393
- mission: resolved.engine ? { ...mission, runner: resolved.engine.id, runner_kind: 'engine' } : mission,
435
+ mission: resolved.engine ? {
436
+ ...mission,
437
+ runner: resolved.engine.id,
438
+ runner_kind: 'engine',
439
+ ...(!mission.model && resolved.engine.roster_model ? { model: resolved.engine.roster_model } : {}),
440
+ ...rosterTickPins(resolved.engine),
441
+ } : mission,
394
442
  engine_id: resolved.engine ? resolved.engine.id : null,
395
443
  requested_engine: resolved.requested_engine,
396
444
  engine_fallback_reason: resolved.engine_fallback_reason,
@@ -443,6 +491,20 @@ function applyMissionRunnerProfile(runner) {
443
491
  };
444
492
  }
445
493
 
494
+ // Claude-family engines share the claude CLI, so they keep the resolved
495
+ // default model pin. Every other engine's CLI rejects a claude model name, so
496
+ // it only ever takes an explicit pin (the mission's model or the roster
497
+ // line's); '' lets the engine ride its own default.
498
+ const CLAUDE_FAMILY_RUNNER_ENGINES = new Set(['claude', 'fable', 'haiku']);
499
+
500
+ function resolveMissionTickRunnerModel(mission) {
501
+ const engine = canonicalEngineName(mission && mission.runner);
502
+ if (!engine || CLAUDE_FAMILY_RUNNER_ENGINES.has(engine)) {
503
+ return resolveClaudeRunnerModel(mission);
504
+ }
505
+ return resolvePinnedRunnerModel(mission);
506
+ }
507
+
446
508
  function exitMissionError(message, code = 1, asJson = false) {
447
509
  if (asJson) {
448
510
  printCliJson(
@@ -3568,8 +3630,8 @@ function readTasteReviewHistory(root, limit = 4) {
3568
3630
 
3569
3631
  function missionTasteMemorySignals(text) {
3570
3632
  const definitions = [
3571
- ['plain_language', 'Keshav asks for plain English', /\b(plain|jargon|feynman|understand|simple|clarify|clarity)\b/i],
3572
- ['proof_gate', 'Keshav wants proof before accept', /\b(proof|receipt|verify|verified|checked|tested|accept|approval)\b/i],
3633
+ ['plain_language', 'the operator asks for plain English', /\b(plain|jargon|feynman|understand|simple|clarify|clarity)\b/i],
3634
+ ['proof_gate', 'the operator wants proof before accept', /\b(proof|receipt|verify|verified|checked|tested|accept|approval)\b/i],
3573
3635
  ['runway_revenue', 'runway pushes user or revenue work', /\b(runway|revenue|cash|user|users|customer|demo|adoption|retention)\b/i],
3574
3636
  ['technical_ambition', 'technical advancement still matters', /\b(technical|homerun|research|runtime|architecture|model|benchmark|agent)\b/i],
3575
3637
  ['trust_boundary', 'trust and approval boundaries matter', /\b(security|safe|safety|trust|privacy|approval|permission|gmail|email|connector)\b/i],
@@ -3668,9 +3730,9 @@ function missionPlainTaskPreview(title) {
3668
3730
  function missionTasteMemoryReason(tasteMemory, signals) {
3669
3731
  const candidateIds = new Set((signals || []).map((signal) => signal.id));
3670
3732
  const memoryIds = new Set((tasteMemory?.signals || []).map((signal) => signal.id));
3671
- if (memoryIds.has('runway_revenue') && candidateIds.has('users_revenue')) return 'Keshav has been steering toward user/revenue proof because runway is tight.';
3672
- if (memoryIds.has('plain_language') && candidateIds.has('usability')) return 'Keshav keeps asking for plain-English, understandable work.';
3673
- if (memoryIds.has('proof_gate') && (candidateIds.has('trust') || candidateIds.has('technical'))) return 'Keshav wants proof and receipts before serious work is accepted.';
3733
+ if (memoryIds.has('runway_revenue') && candidateIds.has('users_revenue')) return 'the operator has been steering toward user/revenue proof because runway is tight.';
3734
+ if (memoryIds.has('plain_language') && candidateIds.has('usability')) return 'the operator keeps asking for plain-English, understandable work.';
3735
+ if (memoryIds.has('proof_gate') && (candidateIds.has('trust') || candidateIds.has('technical'))) return 'the operator wants proof and receipts before serious work is accepted.';
3674
3736
  if (memoryIds.has('working_memory')) return 'Recent logs are being used as working memory for what matters now.';
3675
3737
  if (memoryIds.has('technical_ambition') && candidateIds.has('technical')) return 'The member memory still values technical advancement when it has proof.';
3676
3738
  return '';
@@ -4160,14 +4222,61 @@ function startMission(args, options = {}) {
4160
4222
  // checkout's dirt never reaches the mission baseline.
4161
4223
  if (hasFlag(args, '--worktree')) {
4162
4224
  let created;
4225
+ const sourceTop = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: process.cwd(), encoding: 'utf8' });
4226
+ const sourceCheckout = sourceTop.status === 0 ? String(sourceTop.stdout).trim() : process.cwd();
4227
+ let nodeModulesSource = null;
4163
4228
  try {
4164
4229
  const { createAgentWorktree } = require('./worktree');
4165
4230
  const base = readFlag(args, '--base', '') || inheritedWorktreeBase(process.cwd());
4166
4231
  created = createAgentWorktree({ member: mission.owner, task: mission.objective, ...(base ? { base } : {}) });
4232
+ const packageFile = path.join(sourceCheckout, 'package.json');
4233
+ const sourceModules = path.join(sourceCheckout, 'node_modules');
4234
+ const targetModules = path.join(created.path, 'node_modules');
4235
+ try {
4236
+ let targetExists = false;
4237
+ try {
4238
+ fs.lstatSync(targetModules);
4239
+ targetExists = true;
4240
+ } catch (error) {
4241
+ if (error.code !== 'ENOENT') throw error;
4242
+ }
4243
+ if (fs.existsSync(packageFile) && fs.existsSync(sourceModules) && !targetExists) {
4244
+ const manifest = JSON.parse(fs.readFileSync(packageFile, 'utf8'));
4245
+ const hasDependencies = ['dependencies', 'devDependencies', 'optionalDependencies']
4246
+ .some((field) => manifest[field] && Object.keys(manifest[field]).length > 0);
4247
+ if (hasDependencies) {
4248
+ const checkIgnored = () => {
4249
+ const result = spawnSync('git', ['check-ignore', '-q', 'node_modules'], { cwd: created.path, encoding: 'utf8' });
4250
+ if (result.error || (result.status !== 0 && result.status !== 1)) {
4251
+ throw new Error(result.error?.message || String(result.stderr || 'git check-ignore failed').trim());
4252
+ }
4253
+ return result.status === 0;
4254
+ };
4255
+ if (!checkIgnored()) {
4256
+ const commonDir = spawnSync('git', ['rev-parse', '--git-common-dir'], { cwd: created.path, encoding: 'utf8' });
4257
+ if (commonDir.error || commonDir.status !== 0) {
4258
+ throw new Error(commonDir.error?.message || String(commonDir.stderr || 'could not find shared git directory').trim());
4259
+ }
4260
+ const excludeFile = path.resolve(created.path, String(commonDir.stdout).trim(), 'info', 'exclude');
4261
+ fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
4262
+ const exclude = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, 'utf8') : '';
4263
+ if (!exclude.split(/\r?\n/).includes('/node_modules')) {
4264
+ fs.appendFileSync(excludeFile, `${exclude && !exclude.endsWith('\n') ? '\n' : ''}/node_modules\n`);
4265
+ }
4266
+ }
4267
+ if (!checkIgnored()) throw new Error('git still tracks node_modules after updating the shared exclude file');
4268
+ fs.symlinkSync(sourceModules, targetModules, 'dir');
4269
+ nodeModulesSource = sourceModules;
4270
+ }
4271
+ }
4272
+ } catch (error) {
4273
+ console.warn(`warning: could not link node_modules: ${String(error.message).split(/\r?\n/)[0]}`);
4274
+ }
4167
4275
  } catch (e) {
4168
4276
  exitMissionError(`[mission start] worktree creation failed: ${e.message}`, 2, asJson);
4169
4277
  }
4170
4278
  mission.worktree = { path: created.path, branch: created.branch, base: created.base };
4279
+ if (nodeModulesSource) mission.node_modules_source = nodeModulesSource;
4171
4280
  process.chdir(created.path);
4172
4281
  }
4173
4282
  if (mission.xp_task_enabled) {
@@ -4225,6 +4334,8 @@ function startMission(args, options = {}) {
4225
4334
  dirty_count: worktreeBaseline.dirty_count,
4226
4335
  dirty_hash: worktreeBaseline.dirty_hash,
4227
4336
  } : null,
4337
+ node_modules_linked: Boolean(saved.node_modules_source),
4338
+ node_modules_source: saved.node_modules_source || null,
4228
4339
  ...(saved.remap_reason ? { remap_reason: saved.remap_reason } : {}),
4229
4340
  };
4230
4341
  if (!options.silent) {
@@ -4236,6 +4347,7 @@ function startMission(args, options = {}) {
4236
4347
  `State: ${saved.status}`,
4237
4348
  ...(saved.remap_reason ? [`Remap: ${saved.remap_reason}`] : []),
4238
4349
  ...(saved.worktree ? [`Worktree: ${saved.worktree.path}`, `Branch: ${saved.worktree.branch}`] : []),
4350
+ ...(saved.node_modules_source ? [`linked node_modules from ${saved.node_modules_source}`] : []),
4239
4351
  ...warnings.map((warning) => `Warning: ${warning.message}`),
4240
4352
  ...(saved.xp_task ? [`AgentXP task: ${saved.xp_task.ref}`] : []),
4241
4353
  ...(saved.worktree ? [`Next: cd ${saved.worktree.path} && ${nextTickCommand}`] : [`Next: ${nextTickCommand}`]),
@@ -8049,12 +8161,6 @@ function isWithinActiveHours(activeHours, now = new Date()) {
8049
8161
  return cur >= start || cur < end;
8050
8162
  }
8051
8163
 
8052
- function sleepSync(ms) {
8053
- const waitMs = Math.max(0, Number(ms) || 0);
8054
- if (!waitMs) return;
8055
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, waitMs);
8056
- }
8057
-
8058
8164
  function missionLockOwnerIsAlive(pid) {
8059
8165
  const ownerPid = Number(pid);
8060
8166
  if (!Number.isInteger(ownerPid) || ownerPid <= 0) return false;
@@ -8700,7 +8806,7 @@ function spawnGenericRunnerTick(mission, opts) {
8700
8806
  let briefId = null;
8701
8807
  try {
8702
8808
  promptFile = writeRunnerPromptFile(cwd, mission.id, prompt);
8703
- cmd = buildRunnerCommand({ promptFile, model });
8809
+ cmd = buildRunnerCommand({ promptFile, model, ...(mission.roster_effort ? { effort: mission.roster_effort } : {}) });
8704
8810
  const engine = canonicalEngineName(mission.runner);
8705
8811
  if (engine) {
8706
8812
  const record = appendBriefRecord(cwd, {
@@ -8877,6 +8983,7 @@ function spawnClaudeTick(mission, opts) {
8877
8983
  '--include-partial-messages',
8878
8984
  ];
8879
8985
  if (model) args.push('--model', model);
8986
+ if (mission.roster_effort) args.push('--effort', mission.roster_effort);
8880
8987
  if (sessionMode === 'set') args.push('--session-id', sessionId);
8881
8988
  else if (sessionMode === 'resume') args.push('--resume', sessionId);
8882
8989
 
@@ -9043,7 +9150,7 @@ async function runEngineVerifier(mission, options = {}) {
9043
9150
  signal: options.signal,
9044
9151
  timeoutMs: ENGINE_VERIFY_TIMEOUT_MS,
9045
9152
  prompt: buildEngineVerifyPrompt(mission, options.tickIndex || 1),
9046
- model: resolveClaudeRunnerModel(verifyMission),
9153
+ model: resolveMissionTickRunnerModel(verifyMission),
9047
9154
  });
9048
9155
  } catch (error) {
9049
9156
  engineResult = { ok: false, error: error.message };
@@ -9623,12 +9730,12 @@ async function executeMissionRunTicksPhase(context) {
9623
9730
  const runClaudeSession = () => spawnClaudeTick(tickRuntimeMission, {
9624
9731
  sessionMode, sessionId: useId, cwd, signal: controller.signal,
9625
9732
  missionLock: lock,
9626
- timeoutMs: clampTimeoutMs(
9733
+ timeoutMs: missionTickTimeoutMs(
9734
+ tickRuntimeMission,
9627
9735
  (maxWallSeconds - ((Date.now() - startedAt) / 1000)) * 1000,
9628
- MISSION_RUN_DEFAULTS.claudeTimeoutMs,
9629
9736
  ),
9630
9737
  prompt,
9631
- model: resolveClaudeRunnerModel(tickRuntimeMission),
9738
+ model: resolveMissionTickRunnerModel(tickRuntimeMission),
9632
9739
  });
9633
9740
  try {
9634
9741
  claudeResult = await runClaudeSession();
@@ -11629,6 +11736,7 @@ module.exports = {
11629
11736
  missionProtectedLaneHold,
11630
11737
  missionCommand,
11631
11738
  startMission,
11739
+ resolveMission,
11632
11740
  spawnMissionDriver,
11633
11741
  missionRunnerNeedsLiveSession,
11634
11742
  completeMission,
@@ -11680,6 +11788,8 @@ module.exports = {
11680
11788
  missionHumanStatusText,
11681
11789
  resolveMissionRunnerSelection,
11682
11790
  resolveMissionTickRunner,
11791
+ resolveMissionTickRunnerModel,
11792
+ missionTickTimeoutMs,
11683
11793
  engineFailureHealthStatus,
11684
11794
  recordMissionEngineTickOutcome,
11685
11795
  tickMadeProgress,
@@ -9,7 +9,10 @@ const { readWishes } = require('../lib/wish-store');
9
9
  const {
10
10
  engineRegistryView,
11
11
  resolveEngineForRole,
12
+ resolveEngineForRoleRanked,
12
13
  resolveRegisteredEngine,
14
+ rosterPinFor,
15
+ rosterDecided,
13
16
  } = require('../lib/engine-registry');
14
17
  const { parseVerifyCommand } = require('../lib/auto-accept-certified');
15
18
  const fleet = require('../lib/fleet');
@@ -155,7 +158,8 @@ function readyExecutor(root, preferred = '') {
155
158
  if (!selected.roles.includes('executor')) throw new Error(`engine ${selected.id} is not an executor`);
156
159
  if (!selected.health || selected.health.status !== 'ready') throw new Error(`engine ${selected.id} is not ready`);
157
160
  if (!fleet.FLEET_CAPABLE.includes(selected.id)) throw new Error(`engine ${selected.id} cannot build headlessly`);
158
- return selected;
161
+ const pin = rosterPinFor('executor', selected.id, root);
162
+ return Object.keys(pin).length ? { ...selected, ...pin } : selected;
159
163
  }
160
164
  const routed = resolveEngineForRole('executor', root);
161
165
  if (routed && fleet.FLEET_CAPABLE.includes(routed.id)) return routed;
@@ -168,16 +172,30 @@ function readyExecutor(root, preferred = '') {
168
172
 
169
173
  function readyValidators(root, preferred = '', exclude = '') {
170
174
  const blocked = String(exclude || '').trim();
175
+ const rosterRanked = resolveEngineForRoleRanked('validator', root);
176
+ const fromRoster = rosterDecided(rosterRanked.source);
177
+ const rosterOrder = !preferred && fromRoster
178
+ ? rosterRanked.ranked.map((engine) => engine.id)
179
+ : [];
180
+ const pinned = fromRoster && rosterRanked.engine
181
+ && (rosterRanked.engine.roster_model || rosterRanked.engine.roster_effort || rosterRanked.engine.roster_max_seconds)
182
+ ? rosterRanked.engine
183
+ : null;
184
+ // A roster pick can name an engine that reviews only when picked (codex),
185
+ // so the roster's list joins even when an engine lacks the role.
186
+ const rosterPicked = new Set(fromRoster ? rosterRanked.ranked.map((engine) => engine.id) : []);
171
187
  const candidates = engineRegistryView(root)
172
- .filter((engine) => engine.roles.includes('validator'))
188
+ .filter((engine) => engine.roles.includes('validator') || rosterPicked.has(engine.id))
173
189
  .filter((engine) => engine.id !== blocked)
174
190
  .sort((a, b) => {
175
191
  const aPreferred = preferred && a.id === preferred ? 0 : 1;
176
192
  const bPreferred = preferred && b.id === preferred ? 0 : 1;
177
193
  return aPreferred - bPreferred
194
+ || (rosterOrder.length ? (rosterOrder.indexOf(a.id) - rosterOrder.indexOf(b.id)) : 0)
178
195
  || Number(a.fallback_order) - Number(b.fallback_order)
179
196
  || String(a.id).localeCompare(String(b.id));
180
- });
197
+ })
198
+ .map((engine) => (pinned && engine.id === pinned.id ? { ...engine, ...pinFields(pinned) } : engine));
181
199
  const ready = candidates.filter((engine) => engine.health && engine.health.status === 'ready');
182
200
  // Route-time determinism: which validator binaries exist on this machine
183
201
  // must not change the lap's route. When none are ready, dispatch with the
@@ -187,6 +205,39 @@ function readyValidators(root, preferred = '', exclude = '') {
187
205
  return ready.length ? ready : candidates;
188
206
  }
189
207
 
208
+ function pinFields(engine) {
209
+ const pin = {};
210
+ for (const field of ['roster_model', 'roster_effort', 'roster_max_seconds']) {
211
+ if (engine && engine[field]) pin[field] = engine[field];
212
+ }
213
+ return pin;
214
+ }
215
+
216
+ // The roster's pins ride into the flight: the builder's model, effort, and
217
+ // time cap for the builder, and each reviewer's for that reviewer only. A
218
+ // reviewer with only a model keeps the plain model string.
219
+ function lapModelPins(executor, validators = []) {
220
+ const validatorModels = {};
221
+ for (const engine of validators) {
222
+ if (!engine) continue;
223
+ if (engine.roster_effort || engine.roster_max_seconds) {
224
+ validatorModels[engine.id] = {
225
+ model: engine.roster_model || '',
226
+ effort: engine.roster_effort || '',
227
+ max_seconds: engine.roster_max_seconds || 0,
228
+ };
229
+ } else if (engine.roster_model) {
230
+ validatorModels[engine.id] = engine.roster_model;
231
+ }
232
+ }
233
+ return {
234
+ model: executor && executor.roster_model || '',
235
+ ...(executor && executor.roster_effort ? { effort: executor.roster_effort } : {}),
236
+ ...(executor && executor.roster_max_seconds ? { maxSeconds: executor.roster_max_seconds } : {}),
237
+ validatorModels: Object.keys(validatorModels).length ? validatorModels : null,
238
+ };
239
+ }
240
+
190
241
  function taskById(runCli, taskId) {
191
242
  const result = runCli(['task', 'show', taskId, '--json']);
192
243
  if (!result || result.status !== 0) return null;
@@ -656,6 +707,7 @@ async function runOneLap(ask, options = {}) {
656
707
  reviewOnly: true,
657
708
  verifierCommand: verifier,
658
709
  validatorEngines: validators.map((entry) => entry.id),
710
+ ...lapModelPins(executor, validators),
659
711
  actor: mission.owner || 'mission-lead',
660
712
  installedEngines: engineRegistryView(root)
661
713
  .filter((entry) => entry.health && entry.health.status === 'ready')
@@ -773,4 +825,7 @@ async function runOneLap(ask, options = {}) {
773
825
  module.exports = {
774
826
  oneLapSafetyIssue,
775
827
  runOneLap,
828
+ readyExecutor,
829
+ readyValidators,
830
+ lapModelPins,
776
831
  };
package/commands/pack.js CHANGED
@@ -1622,7 +1622,7 @@ async function publishPack(rawArgs, cwd = process.cwd(), options = {}) {
1622
1622
  }
1623
1623
 
1624
1624
  // pack.json belongs to the pack, not to whatever repo happens to contain it
1625
- // (2026-07-27: publishing ~/arena/atrisos-backend/atris wrote a pack.json
1625
+ // (2026-07-27: publishing a backend workspace's atris folder wrote a pack.json
1626
1626
  // into atrisos-backend/ and polluted the source repo).
1627
1627
  const manifestPath = path.join(sourceDir, 'pack.json');
1628
1628
  const existing = readJson(manifestPath);
@@ -2453,7 +2453,7 @@ function printCapabilityTrustCard(policy, trust, receiptPath, userDenyRuleCount
2453
2453
  if (options.operatorInput) {
2454
2454
  console.log(` operator input: ${options.operatorInput.bytes} bytes injected; source path withheld from the pack and receipt`);
2455
2455
  }
2456
- console.log(` host shell: ${policy.grantedCapabilities.includes('host.shell') ? 'GRANTED \u2014 Bash can reach host files and network' : 'denied'}`);
2456
+ console.log(` host shell: ${policy.grantedCapabilities.includes('host.shell') ? 'granted, bash can reach host files and network' : 'denied'}`);
2457
2457
  console.log(' receipt coverage: Atris hook tool events; direct slash-skill invocations and later Claude or policy denials may not appear');
2458
2458
  console.log(` receipt: ${receiptPath}`);
2459
2459
  }
package/commands/probe.js CHANGED
@@ -454,18 +454,24 @@ async function probeCommand(argv) {
454
454
  const detail = ok
455
455
  ? `answer: ${resultText.slice(0, 80).replace(/\n/g, ' ')}`
456
456
  : `dead-end: ${deadEnd}`;
457
- const line = `- **atris-probe** \`${stamp}\` \u2014 ${where} · ${a.model}`
457
+ const line = buildProbeLine({ stamp, where, model: a.model, member, engine, ok, secs, toolsRun, calendar: a.calendar, cliCalendarOps, detail });
458
+ console.log(line);
459
+ return ok ? 0 : 1;
460
+ }
461
+
462
+ function buildProbeLine({ stamp, where, model, member, engine, ok, secs, toolsRun, calendar, cliCalendarOps, detail }) {
463
+ const line = `- **atris-probe** \`${stamp}\` - ${where} · ${model}`
458
464
  + (member ? ` · member=${member}` : '')
459
465
  + (engine ? ` · engine=${engine}` : '')
460
- + ` · ${ok ? 'PASS' : 'FAIL'} · ${secs}s · tools=${toolsRun}`
461
- + (a.calendar ? ` cal_cli=${cliCalendarOps}` : '')
466
+ + ` · ${ok ? 'pass' : 'fail'} · ${secs}s · tools=${toolsRun}`
467
+ + (calendar ? ` cal_cli=${cliCalendarOps}` : '')
462
468
  + ` · ${detail}`;
463
- console.log(line);
464
- return ok ? 0 : 1;
469
+ return line.replace(/\s*\u2014\s*/g, ', ');
465
470
  }
466
471
 
467
472
  module.exports = {
468
473
  probeCommand,
474
+ buildProbeLine,
469
475
  runAtris2Turn,
470
476
  // exported for tests
471
477
  normalizePath,
package/commands/pull.js CHANGED
@@ -722,7 +722,7 @@ async function pullBusiness(slug) {
722
722
  fs.mkdirSync(path.dirname(localPath), { recursive: true });
723
723
  fs.writeFileSync(localPath, content);
724
724
  }
725
- console.log(` \u26A0 ${p.replace(/^\//, '')} CONFLICT \u2014 both you and the computer changed this`);
725
+ console.log(` \u26A0 ${p.replace(/^\//, '')}: conflict, both you and the computer changed this`);
726
726
  console.log(` \u2192 Remote version saved as ${p.replace(/^\//, '')}.remote`);
727
727
  conflictCount++;
728
728
  }
package/commands/push.js CHANGED
@@ -985,7 +985,7 @@ async function pushAtris() {
985
985
  console.log(` ... +${failedToLand.length - shown.length} more`);
986
986
  }
987
987
  console.log('');
988
- console.log(' Common causes: path is outside the workspace (e.g. absolute /Users/... path),');
988
+ console.log(' common causes: path is outside the workspace (for example, an absolute home path),');
989
989
  console.log(' your role lacks write permission for that folder, or warm runner returned an error.');
990
990
  console.log(' These files will appear as drift on your next push so you can retry.');
991
991
  }
@@ -9,22 +9,9 @@
9
9
  */
10
10
 
11
11
  const fs = require('fs');
12
- const os = require('os');
13
12
  const path = require('path');
14
13
  const { spawnSync } = require('child_process');
15
-
16
- function resolveBackendRoot() {
17
- const candidates = [
18
- process.env.ATRIS_BACKEND_DIR,
19
- path.resolve(__dirname, '..', '..', 'atrisos-backend'),
20
- path.resolve(process.cwd(), '..', 'atrisos-backend'),
21
- path.join(os.homedir(), 'arena', 'atrisos-backend'),
22
- ].filter(Boolean);
23
- for (const root of candidates) {
24
- if (fs.existsSync(path.join(root, 'scripts', 'rainmaker.py'))) return root;
25
- }
26
- return null;
27
- }
14
+ const { BACKEND_ROOT_HINT, resolveBackendRoot } = require('../utils/backend-root');
28
15
 
29
16
  function rainmakerCommand(args = []) {
30
17
  if (args[0] === '--help' || args[0] === '-h') {
@@ -32,8 +19,8 @@ function rainmakerCommand(args = []) {
32
19
  return 0;
33
20
  }
34
21
  const root = resolveBackendRoot();
35
- if (!root) {
36
- console.error('Cannot find scripts/rainmaker.py. Set ATRIS_BACKEND_DIR to atrisos-backend.');
22
+ if (!root || !fs.existsSync(path.join(root, 'scripts', 'rainmaker.py'))) {
23
+ console.error(BACKEND_ROOT_HINT);
37
24
  return 1;
38
25
  }
39
26
  const script = path.join(root, 'scripts', 'rainmaker.py');
package/commands/run.js CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
+ const { mapNotesPath } = require('../lib/map-refs');
12
13
  const { execSync, spawnSync } = require('child_process');
13
14
  const { getLogPath, ensureLogDirectory, createLogFile } = require('../lib/journal');
14
15
  const { parseTodo } = require('../lib/todo');
@@ -118,11 +119,12 @@ function execPhaseCommandSync(cmd, opts = {}) {
118
119
  * what the validator found - closing the try → notice → adjust loop.
119
120
  */
120
121
  function buildRunPrompt(phase, context, priorCycleReview) {
121
- const { mapPath, todoPath, personaPath, lessonsPath, journalPath } = context;
122
+ const { mapPath, notesPath, todoPath, personaPath, lessonsPath, journalPath } = context;
122
123
 
123
124
  const readFiles = [
124
125
  personaPath && `- ${personaPath}`,
125
126
  mapPath && `- ${mapPath}`,
127
+ notesPath && `- ${notesPath}`,
126
128
  todoPath && `- ${todoPath}`,
127
129
  lessonsPath && `- ${lessonsPath}`,
128
130
  journalPath && `- ${journalPath}`,
@@ -413,6 +415,7 @@ async function runAtris(options = {}) {
413
415
  // Build context paths
414
416
  const context = {
415
417
  mapPath: fs.existsSync(path.join(atrisDir, 'MAP.md')) ? 'atris/MAP.md' : null,
418
+ notesPath: mapNotesPath(path.dirname(atrisDir)),
416
419
  todoPath: fs.existsSync(path.join(atrisDir, 'TODO.md')) ? 'atris/TODO.md' : null,
417
420
  personaPath: fs.existsSync(path.join(atrisDir, 'PERSONA.md')) ? 'atris/PERSONA.md' : null,
418
421
  lessonsPath: fs.existsSync(path.join(atrisDir, 'lessons.md')) ? 'atris/lessons.md' : null,
package/commands/scout.js CHANGED
@@ -53,7 +53,7 @@ const ENGINES = {
53
53
  function scoutPrompt(question, dir) {
54
54
  return [
55
55
  `You are a read-only scout in ${dir}. You NEVER write, edit, or run mutating commands.`,
56
- `Search order: index files first (atris/MAP.md, atris/TODO.md, README) via targeted reads, then grep. Cheap reads only.`,
56
+ `Search order: index files first (atris/MAP.md, atris/refs/MAP-NOTES.md if present for exact file:line refs, atris/TODO.md, README) via targeted reads, then grep. Cheap reads only.`,
57
57
  ``,
58
58
  `Question: ${question}`,
59
59
  ``,
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { existingMapDocs } = require('../lib/map-refs');
5
6
 
6
7
  const MEMORY_LAYERS = [
7
8
  { key: 'features', dir: path.join('atris', 'features') },
@@ -226,10 +227,11 @@ function scanLayer(root, layer, terms) {
226
227
  return scanFiles(root, layer.key, layer.dir.split(path.sep).join('/'), listMarkdownFiles(dir), terms);
227
228
  }
228
229
 
230
+ // The map layer covers the boot map and the notes file that holds its deep refs.
229
231
  function scanMap(root, terms) {
230
- const mapPath = path.join(root, 'atris', 'MAP.md');
231
- if (!fs.existsSync(mapPath)) return emptyLayerResult('map', 'atris/MAP.md');
232
- return scanFiles(root, 'map', 'atris/MAP.md', [mapPath], terms);
232
+ const files = existingMapDocs(root).map((doc) => path.join(root, doc));
233
+ if (!files.length) return emptyLayerResult('map', 'atris/MAP.md');
234
+ return scanFiles(root, 'map', 'atris/MAP.md', files, terms);
233
235
  }
234
236
 
235
237
  function scanSource(root, terms) {
@@ -4,7 +4,7 @@
4
4
  * atris spaceship, bounded, self-reporting overnight runner.
5
5
  *
6
6
  * Thin wrapper over scripts/spaceship.sh. The script is the implementation
7
- * (a supervised loop that survives bad ticks and emails Keshav on every
7
+ * (a supervised loop that survives bad ticks and emails the operator on every
8
8
  * meaningful state change); this module just makes it reachable as
9
9
  * `atris spaceship ...` and integrates with the CLI's command dispatch.
10
10
  *
@@ -293,6 +293,31 @@ function pushEvent(events, input) {
293
293
  if (event) events.push({ ...event, key: eventKey(event) });
294
294
  }
295
295
 
296
+ // When timestamps match, state snapshots precede receipts, then landing summaries.
297
+ const STREAM_SOURCE_RANK = {
298
+ task: 20,
299
+ task_episode: 30,
300
+ scorecard: 40,
301
+ mission_event: 50,
302
+ mission_state: 60,
303
+ mission_receipt: 70,
304
+ mission_landing: 80,
305
+ worktree: 85,
306
+ landing: 90,
307
+ state: 10,
308
+ };
309
+
310
+ function compareStreamEvents(a, b) {
311
+ const byMs = a.ms - b.ms;
312
+ if (byMs) return byMs;
313
+ const rankA = STREAM_SOURCE_RANK[a.source] ?? 55;
314
+ const rankB = STREAM_SOURCE_RANK[b.source] ?? 55;
315
+ if (rankA !== rankB) return rankA - rankB;
316
+ const byKey = String(a.key || '').localeCompare(String(b.key || ''));
317
+ if (byKey) return byKey;
318
+ return String(a.event || '').localeCompare(String(b.event || ''));
319
+ }
320
+
296
321
  function statePaths(root) {
297
322
  const stateDir = path.join(root, '.atris', 'state');
298
323
  return {
@@ -455,7 +480,6 @@ function collectRunReceiptEvents(root, deps, events) {
455
480
 
456
481
  function candidateWorktreeDirs(root, deps) {
457
482
  const dirs = new Set([
458
- path.resolve(root, '..', '.agent-worktrees'),
459
483
  path.join(path.dirname(root), '.agent-worktrees', path.basename(root)),
460
484
  ]);
461
485
  const sidecar = readJson(statePaths(root).sidecar, deps, null);
@@ -606,7 +630,7 @@ function collectStreamEvents({ root = process.cwd(), sinceMs = 0, agent = '', no
606
630
  if (wantedAgent && event.agent.toLowerCase() !== wantedAgent) continue;
607
631
  byKey.set(event.key, event);
608
632
  }
609
- return [...byKey.values()].sort((a, b) => (a.ms - b.ms) || a.event.localeCompare(b.event));
633
+ return [...byKey.values()].sort(compareStreamEvents);
610
634
  }
611
635
 
612
636
  function waitingOnOperator(tasks) {
@@ -841,6 +865,7 @@ function streamCommand(args = [], deps = defaultDeps()) {
841
865
  module.exports = {
842
866
  collectSnapshot,
843
867
  collectStreamEvents,
868
+ compareStreamEvents,
844
869
  createStreamState,
845
870
  parseArgs,
846
871
  parseSince,
package/commands/study.js CHANGED
@@ -15,6 +15,7 @@ const fs = require('fs');
15
15
  const path = require('path');
16
16
  const http = require('http');
17
17
  const { spawn, spawnSync } = require('child_process');
18
+ const { resolveBackendRoot } = require('../utils/backend-root');
18
19
 
19
20
  const FEED_URL = 'http://localhost:8777';
20
21
  const FEED_STATS_URL = `${FEED_URL}/api/stats`;
@@ -91,15 +92,19 @@ function parseArgs(argv = []) {
91
92
  }
92
93
 
93
94
  function resolveStudyRoot(cwd = process.cwd()) {
94
- const candidates = [
95
- process.env.ATRIS_STUDY_ROOT,
96
- process.env.ATRIS_BACKEND_ROOT,
97
- path.join(path.resolve(cwd), 'atris', 'study'),
98
- ].filter(Boolean);
99
- for (const root of candidates) {
100
- const resolved = path.resolve(String(root));
101
- if (fs.existsSync(resolved)) return resolved;
102
- }
95
+ if (process.env.ATRIS_STUDY_ROOT) {
96
+ const explicit = path.resolve(process.env.ATRIS_STUDY_ROOT);
97
+ if (fs.existsSync(explicit) && fs.statSync(explicit).isDirectory()) return explicit;
98
+ }
99
+ // Same order as before: an explicitly set backend root wins over the local folder.
100
+ if (process.env.ATRIS_BACKEND_ROOT) {
101
+ const envBackend = path.resolve(process.env.ATRIS_BACKEND_ROOT);
102
+ if (fs.existsSync(envBackend)) return envBackend;
103
+ }
104
+ const local = path.join(path.resolve(cwd), 'atris', 'study');
105
+ if (fs.existsSync(local) && fs.statSync(local).isDirectory()) return local;
106
+ const backend = resolveBackendRoot(cwd);
107
+ if (backend) return backend;
103
108
  return null;
104
109
  }
105
110
 
@@ -697,6 +702,7 @@ async function run(argv = []) {
697
702
 
698
703
  module.exports = {
699
704
  run,
705
+ resolveStudyRoot,
700
706
  parseArgs,
701
707
  showHelp,
702
708
  wrapText,