atris 3.65.0 → 3.66.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.
package/commands/xp.js CHANGED
@@ -933,6 +933,8 @@ function receiptFromTaskEpisode(episode, treeHash) {
933
933
  source_episode_id: episodeId,
934
934
  workspace_root: episode.workspace_root || null,
935
935
  actor: episode.action?.actor || null,
936
+ // Who built the work. `actor` is the reviewer; trust is earned by the builder.
937
+ builder: episode.state?.metadata?.built_by || episode.state?.claimed_by || null,
936
938
  outcome: rejected ? 'rejected' : 'accepted',
937
939
  xp: rejected ? 0 : reward,
938
940
  reward: rejected ? 0 : reward,
@@ -1229,7 +1229,7 @@ function buildDigestPrompt(briefs = []) {
1229
1229
 
1230
1230
  function defaultDigestRunner(prompt, deps = {}) {
1231
1231
  const spawn = deps.spawnSync || spawnSync;
1232
- return spawn('claude', ['-p', prompt, '--model', 'claude-haiku-4-5'], {
1232
+ return spawn('claude', ['-p', prompt, '--model', 'claude-haiku-5-5'], {
1233
1233
  encoding: 'utf8',
1234
1234
  timeout: DIGEST_ENGINE_TIMEOUT_MS,
1235
1235
  maxBuffer: 10 * 1024 * 1024,
@@ -6,7 +6,8 @@ const path = require('path');
6
6
  const { taskProofState, taskProofExecutionState } = require('./task-proof');
7
7
  const { extractReceiptEvidence } = require('./receipt-evidence');
8
8
  const reviewIntegrity = require('./review-integrity');
9
- const { computeTrustTier } = require('./trust-tiers');
9
+ const { computeTrustTier, taskTrustSubject } = require('./trust-tiers');
10
+ const { rewriteWorktreeVerify, missingOnMain } = require('./verify-worktree-rewrite');
10
11
  const { policyLessonsForFiles, readPolicyLessons } = require('./policy-lessons');
11
12
  const { skillEvalGate } = require('./skill-eval-gate');
12
13
 
@@ -1091,6 +1092,79 @@ function sniffedProtectedLane(task) {
1091
1092
  return null;
1092
1093
  }
1093
1094
 
1095
+ // The protected lane a task sits in, or null: a denied tag (whole word,
1096
+ // plural-stripped, the same match the landing gate uses), a lane declared in
1097
+ // its text, or, for untagged rows, lane terms in its title or objective.
1098
+ // Every automatic closer must leave these for a human.
1099
+ function deniedTagOf(task) {
1100
+ const tag = String((task && task.tag) || '').trim().toLowerCase();
1101
+ return [...DENIED_TAGS].find((d) =>
1102
+ tag === d || tag.split(/[^a-z0-9]+/).some((w) => w === d || w.replace(/s$/, '') === d)) || null;
1103
+ }
1104
+
1105
+ function protectedLaneOf(task) {
1106
+ if (!task || typeof task !== 'object') return null;
1107
+ const metadata = task.metadata && typeof task.metadata === 'object' ? task.metadata : {};
1108
+ const view = { ...task, objective: task.objective || metadata.objective || '', metadata };
1109
+ const denied = deniedTagOf(view);
1110
+ if (denied) return denied;
1111
+ if (declaredProtectedLane(view)) return 'declared';
1112
+ if (!String(view.tag || '').trim()) {
1113
+ const sniffed = sniffedProtectedLane(view);
1114
+ if (sniffed) return sniffed.lane;
1115
+ }
1116
+ return null;
1117
+ }
1118
+
1119
+ // The check the landing pass actually runs. A check recorded inside an
1120
+ // agent's temporary copy is rewritten to run from the main checkout; when the
1121
+ // file it runs only exists on the agent's branch, that is reported in plain
1122
+ // words instead of a bare "not on the list this machine may run".
1123
+ function resolveTaskVerify(task) {
1124
+ const recorded = String((task.metadata || {}).verify || '').trim();
1125
+ if (!recorded) return { verify: '', recorded: '', rewritten: false, missing: null };
1126
+ const root = task.workspace_root || process.cwd();
1127
+ const rewrite = rewriteWorktreeVerify(recorded, root);
1128
+ const verify = rewrite.command || recorded;
1129
+ let missing = null;
1130
+ if (rewrite.rewritten) {
1131
+ const chain = parseVerifyChain(verify);
1132
+ if (chain.ok) missing = missingOnMain(chain.steps, root);
1133
+ }
1134
+ return { verify, recorded, rewritten: rewrite.rewritten, missing };
1135
+ }
1136
+
1137
+ // Why the landing pass could never rerun this task's check from main, or
1138
+ // null when it can. Independent of the gate's refusal order, so a row held
1139
+ // for another reason still reports a check that can never run.
1140
+ function verifyRunnability(task) {
1141
+ const resolved = resolveTaskVerify(task);
1142
+ if (!resolved.verify) return 'no_verify_command';
1143
+ if (!isAutoCertifyVerifyCommandAllowed(resolved.verify)) return 'verify_command_not_allowed';
1144
+ if (resolved.missing) return 'verify_only_on_agent_branch';
1145
+ return null;
1146
+ }
1147
+
1148
+ function agentBranchOnlyResult(ref, resolved) {
1149
+ return {
1150
+ eligible: false,
1151
+ ref,
1152
+ reason: 'verify_only_on_agent_branch',
1153
+ verify: resolved.verify,
1154
+ recorded_verify: resolved.recorded,
1155
+ missing_path: resolved.missing,
1156
+ next_action: `${resolved.missing} is not on main yet; land the agent branch that has it, then the check reruns from main`,
1157
+ };
1158
+ }
1159
+
1160
+ function runResolvedVerify(resolved, workspaceRoot, cache) {
1161
+ const result = runVerifyCommandCached(resolved.verify, workspaceRoot, cache);
1162
+ if (resolved.rewritten && result.reason === 'verify_failed') {
1163
+ return { ...result, reason: 'verify_failed_on_main', recorded_verify: resolved.recorded };
1164
+ }
1165
+ return resolved.rewritten ? { ...result, recorded_verify: resolved.recorded } : result;
1166
+ }
1167
+
1094
1168
  function evaluateAutoAccept(task, options = {}) {
1095
1169
  const {
1096
1170
  strictVerify = true,
@@ -1103,10 +1177,9 @@ function evaluateAutoAccept(task, options = {}) {
1103
1177
  if (task.status !== 'review') return { eligible: false, ref, reason: 'not_in_review' };
1104
1178
  const metadata = task.metadata || {};
1105
1179
  const review = task.review || {};
1106
- const trustTier = computeTrustTier(
1107
- task.claimed_by || metadata.executed_by,
1108
- task.workspace_root || process.cwd(),
1109
- );
1180
+ // Trust is the builder's, never the approver's: a task built by a member
1181
+ // with a clean record lands without waiting on a human look.
1182
+ const trustTier = computeTrustTier(taskTrustSubject(task), task.workspace_root || process.cwd());
1110
1183
  const tierRequiresStrictVerify = trustTier !== 'trusted';
1111
1184
  const requiresStrictVerify = strictVerify || tierRequiresStrictVerify;
1112
1185
  const approval = String(review.approval_status || metadata.approval_status || 'pending').toLowerCase();
@@ -1180,10 +1253,12 @@ function evaluateAutoAccept(task, options = {}) {
1180
1253
  }
1181
1254
  const evidenceBlock = receiptEvidenceBlock(task, ref);
1182
1255
  if (evidenceBlock) return evidenceBlock;
1183
- const verify = metadata.verify;
1256
+ const resolvedVerify = resolveTaskVerify(task);
1257
+ const verify = resolvedVerify.verify;
1184
1258
  if (tierRequiresStrictVerify && !verify) return strictVerifyMissingResult(ref);
1259
+ if (resolvedVerify.missing) return agentBranchOnlyResult(ref, resolvedVerify);
1185
1260
  if (verify && executeVerify) {
1186
- const verifyResult = runVerifyCommandCached(verify, task.workspace_root || process.cwd(), verifyCache);
1261
+ const verifyResult = runResolvedVerify(resolvedVerify, task.workspace_root || process.cwd(), verifyCache);
1187
1262
  // A check that runs and fails blocks. So does a check whose worktree
1188
1263
  // is gone, otherwise the daily reap converts "has a failing check"
1189
1264
  // into "lands unchecked" the morning after it clears the worktree.
@@ -1194,6 +1269,7 @@ function evaluateAutoAccept(task, options = {}) {
1194
1269
  // "the harness could not run the check" into "landed unchecked".
1195
1270
  if ((tierRequiresStrictVerify && !verifyResult.ok)
1196
1271
  || verifyResult.reason === 'verify_failed'
1272
+ || verifyResult.reason === 'verify_failed_on_main'
1197
1273
  || verifyResult.reason === 'verify_unrunnable'
1198
1274
  || verifyResult.reason === 'verify_worktree_missing'
1199
1275
  || verifyResult.reason === 'verify_workdir_missing') {
@@ -1279,11 +1355,13 @@ function evaluateAutoAccept(task, options = {}) {
1279
1355
  }
1280
1356
 
1281
1357
  if (requiresStrictVerify) {
1282
- const verify = metadata.verify;
1358
+ const resolvedVerify = resolveTaskVerify(task);
1359
+ const verify = resolvedVerify.verify;
1283
1360
  if (!verify) return strictVerifyMissingResult(ref);
1361
+ if (resolvedVerify.missing) return agentBranchOnlyResult(ref, resolvedVerify);
1284
1362
  if (executeVerify) {
1285
1363
  const workspaceRoot = task.workspace_root || process.cwd();
1286
- const verifyResult = runVerifyCommandCached(verify, workspaceRoot, verifyCache);
1364
+ const verifyResult = runResolvedVerify(resolvedVerify, workspaceRoot, verifyCache);
1287
1365
  if (!verifyResult.ok) {
1288
1366
  return { eligible: false, ref, reason: verifyResult.reason, verify, ...verifyResult };
1289
1367
  }
@@ -1321,6 +1399,8 @@ module.exports = {
1321
1399
  candidatePolicyGate,
1322
1400
  declaredProtectedLane,
1323
1401
  sniffedProtectedLane,
1402
+ protectedLaneOf,
1403
+ verifyRunnability,
1324
1404
  evaluateAutoAccept,
1325
1405
  isAutoCertifyVerifyCommandAllowed,
1326
1406
  parseVerifyChain,
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+
3
+ // One plain line per cleanup run, in the daily journal's "## <project>
4
+ // overnight" section: the hourly autoland tick writes its own line there and
5
+ // so do project jobs (project-obelisk's review sweep and state sync). The
6
+ // session start screen reads the latest one back, so the owner sees what the
7
+ // background work did without opening anything.
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const CLEANUP_WORDS = /\b(landed|land on|closed|put away|parked|need you|reran|archived)\b/i;
13
+
14
+ function localDate(now) {
15
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
16
+ }
17
+
18
+ function journalFile(root, now) {
19
+ return path.join(root, 'atris', 'logs', String(now.getFullYear()), `${localDate(now)}.md`);
20
+ }
21
+
22
+ function sectionName(root) {
23
+ return `${path.basename(path.resolve(root)).replace(/^project-/, '')} overnight`;
24
+ }
25
+
26
+ // Same text twice in one day is written once.
27
+ function appendOvernightNote(root, line, now = new Date()) {
28
+ const text = String(line || '').trim();
29
+ if (!text || !fs.existsSync(path.join(root, 'atris'))) return false;
30
+ const file = journalFile(root, now);
31
+ fs.mkdirSync(path.dirname(file), { recursive: true });
32
+ const body = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
33
+ const bullet = `- ${text}`;
34
+ if (body.split('\n').some((l) => l.trim() === bullet)) return false;
35
+ const marker = `## ${sectionName(root)}`;
36
+ const idx = body.split('\n').findIndex((l) => l.trim() === marker);
37
+ let next;
38
+ if (idx === -1) {
39
+ next = `${body}${body && !body.endsWith('\n') ? '\n' : ''}\n${marker}\n${bullet}\n`;
40
+ } else {
41
+ const lines = body.split('\n');
42
+ let end = idx + 1;
43
+ while (end < lines.length && !lines[end].startsWith('## ')) end += 1;
44
+ let insertAt = end;
45
+ while (insertAt > idx + 1 && !lines[insertAt - 1].trim()) insertAt -= 1;
46
+ lines.splice(insertAt, 0, bullet);
47
+ next = lines.join('\n');
48
+ }
49
+ const tmp = `${file}.tmp-${process.pid}`;
50
+ fs.writeFileSync(tmp, next);
51
+ fs.renameSync(tmp, file);
52
+ return true;
53
+ }
54
+
55
+ function overnightBullets(text) {
56
+ const bullets = [];
57
+ let inSection = false;
58
+ for (const line of String(text || '').split('\n')) {
59
+ if (line.startsWith('## ')) {
60
+ inSection = /\bovernight\s*$/i.test(line.trim());
61
+ continue;
62
+ }
63
+ if (inSection && line.trim().startsWith('- ')) bullets.push(line.trim().slice(2).trim());
64
+ }
65
+ return bullets;
66
+ }
67
+
68
+ function parkedBranchCount(root) {
69
+ try {
70
+ return fs.readFileSync(path.join(root, 'atris', 'reports', 'parked-branches.md'), 'utf8')
71
+ .split('\n').filter((l) => /^- \d{4}-\d{2}-\d{2} · /.test(l)).length;
72
+ } catch {
73
+ return 0;
74
+ }
75
+ }
76
+
77
+ function shorten(text, max) {
78
+ if (text.length <= max) return text;
79
+ const cut = text.slice(0, max - 1);
80
+ const stop = Math.max(cut.lastIndexOf('; '), cut.lastIndexOf(', '));
81
+ return `${(stop > max * 0.5 ? cut.slice(0, stop) : cut).replace(/[\s,;.]+$/, '')}...`;
82
+ }
83
+
84
+ // The newest cleanup line from today's journal (yesterday's if today has
85
+ // none), plus the parked-branch count when the line does not already say it.
86
+ // Two small file reads; never throws.
87
+ function latestCleanupLine(root, { now = new Date(), max = 150 } = {}) {
88
+ try {
89
+ let line = '';
90
+ for (const offset of [0, 1]) {
91
+ const day = new Date(now);
92
+ day.setDate(day.getDate() - offset);
93
+ let text = '';
94
+ try { text = fs.readFileSync(journalFile(root, day), 'utf8'); } catch { continue; }
95
+ const bullets = overnightBullets(text).filter((b) => CLEANUP_WORDS.test(b));
96
+ if (bullets.length) { line = bullets[bullets.length - 1]; break; }
97
+ }
98
+ const parked = parkedBranchCount(root);
99
+ if (parked && !/parked/i.test(line)) {
100
+ const note = `${parked} agent branch${parked === 1 ? '' : 'es'} parked for you`;
101
+ line = line ? `${shorten(line.replace(/\.$/, ''), max - note.length - 2)}; ${note}` : `${note}.`;
102
+ }
103
+ return line ? shorten(line, max) : '';
104
+ } catch {
105
+ return '';
106
+ }
107
+ }
108
+
109
+ function plural(n, word, many = `${word}s`) {
110
+ return `${n} ${n === 1 ? word : many}`;
111
+ }
112
+
113
+ // The autoland tick's own line. Null when the tick did nothing worth a line.
114
+ function tickSummaryLine({ landed = 0, closed = 0, putAway = 0, needYou = 0 } = {}) {
115
+ const parts = [];
116
+ if (landed) parts.push(`landed ${plural(landed, 'finished item')}`);
117
+ if (closed) parts.push(`closed ${plural(closed, 'idle item')} that could never land on their own, each with a plain reason`);
118
+ if (putAway) parts.push(`put away ${plural(putAway, 'done or long-idle item')}`);
119
+ if (!parts.length) return null;
120
+ if (needYou) parts.push(`${needYou} need you (money, deploys, security, or customers)`);
121
+ const text = parts.join('; ');
122
+ return `Autoland: ${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
123
+ }
124
+
125
+ module.exports = {
126
+ appendOvernightNote,
127
+ latestCleanupLine,
128
+ tickSummaryLine,
129
+ };
package/lib/engine-ask.js CHANGED
@@ -11,6 +11,7 @@ const {
11
11
  } = require('./runner-command');
12
12
  const {
13
13
  canonicalEngineName,
14
+ currentClaudeModel,
14
15
  engineFailureHealthStatus,
15
16
  setEngineHealth,
16
17
  } = require('./engine-registry');
@@ -241,7 +242,7 @@ function assertAskModelSupported(engine, model) {
241
242
 
242
243
  function resolveAskModel(engine, modelName = '') {
243
244
  const requested = String(modelName || '').trim();
244
- if (requested) return requested;
245
+ if (requested) return currentClaudeModel(requested);
245
246
  if (engine !== 'claude' && engine !== 'fable' && engine !== 'haiku') return '';
246
247
  return RUNNER_PROFILE_DEFS[engine]?.model || DEFAULT_CLAUDE_RUNNER_MODEL;
247
248
  }
@@ -291,7 +292,7 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
291
292
  return {
292
293
  engine,
293
294
  bin: profile.bin,
294
- args: ['exec', ...(model ? ['-m', model] : []), ...effortArgs, '--sandbox', 'read-only', '--ephemeral', '--ignore-user-config', '--ignore-rules', '--color', 'never', request],
295
+ args: ['exec', ...(model ? ['-m', model] : []), ...effortArgs, '--sandbox', 'read-only', '--ephemeral', '--skip-git-repo-check', '--ignore-user-config', '--ignore-rules', '--color', 'never', request],
295
296
  };
296
297
  }
297
298
  if (engine === 'cursor') {
@@ -137,13 +137,20 @@ function engineFailureHealthStatus(result) {
137
137
  return engineStallReason(result) ? 'error' : null;
138
138
  }
139
139
 
140
+ // hosted: the engine runs on Atris's own servers and bills the person's atris
141
+ // tokens. With no roster pick the router never puts a hosted engine ahead of
142
+ // a tool installed here; atris-fast is the last resort for search and review
143
+ // (not build, see hostedFallbacks), and only for someone logged in to atris.
144
+ // role_order: the engine's place for one job when it differs from its
145
+ // fallback_order. Haiku searches first wherever the claude CLI is installed.
140
146
  const ENGINE_SEED_META = Object.freeze({
141
- 'atris-fast': Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator']), models: Object.freeze(['atris fast']), duty: 'learning', fallback_order: 10 }),
147
+ 'atris-fast': Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator']), models: Object.freeze(['atris fast']), duty: 'learning', fallback_order: 10, hosted: true }),
142
148
  // roster_only_roles: jobs a roster pick may give this engine, but the router
143
- // never hands it on its own. Claude and haiku can own search when picked;
144
- // with no pick, search stays with atris-fast, then composer. Codex can own
145
- // review when picked; it stays out of roles, so every review list built
146
- // from roles (the router, one lap, wish audit, the chart) is unchanged.
149
+ // never hands it on its own. Claude can own search when picked; with no
150
+ // pick, search goes to haiku, then other installed tools, then atris-fast.
151
+ // Codex can own review when picked; it stays out of roles, so every review
152
+ // list built from roles (the router, one lap, wish audit, the chart) is
153
+ // unchanged.
147
154
  codex: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), roster_only_roles: Object.freeze(['validator']), models: Object.freeze(['codex']), fallback_order: 10 }),
148
155
  claude: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor', 'navigator']), roster_only_roles: Object.freeze(['navigator']), models: Object.freeze(['opus 5.5', 'opus 5', 'fable', 'haiku']), fallback_order: 20 }),
149
156
  cursor: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['composer 2.5', 'grok 4.6', 'kimi 3']), fallback_order: 30 }),
@@ -151,8 +158,8 @@ const ENGINE_SEED_META = Object.freeze({
151
158
  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 }),
152
159
  grok: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['grok 4.7 fast', 'grok 4.7']), fallback_order: 45 }),
153
160
  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 }),
154
- composer: Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator', 'executor']), models: Object.freeze(['composer 2.5']), fallback_order: 60 }),
155
- haiku: Object.freeze({ tier: 'fast', roles: Object.freeze(['validator', 'navigator']), roster_only_roles: Object.freeze(['navigator']), models: Object.freeze(['haiku']), fallback_order: 70 }),
161
+ composer: Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator', 'executor']), models: Object.freeze(['composer 2.5']), fallback_order: 60, hosted: true }),
162
+ haiku: Object.freeze({ tier: 'fast', roles: Object.freeze(['validator', 'navigator']), role_order: Object.freeze({ navigator: 5 }), models: Object.freeze(['haiku']), fallback_order: 70 }),
156
163
  agy: Object.freeze({
157
164
  tier: 'pro',
158
165
  roles: Object.freeze(['executor']),
@@ -203,6 +210,16 @@ function binInstalled(bin) {
203
210
  return probe.status === 0 && Boolean(String(probe.stdout || '').trim());
204
211
  }
205
212
 
213
+ // A saved not_installed is checked again on the real clock (never a test's
214
+ // routing clock) once its last check, or the run that found it missing, is
215
+ // a minute old. One `command -v` per missing tool per minute, no network.
216
+ const MISSING_RECHECK_MS = 60000;
217
+
218
+ function missingCheckDue(health) {
219
+ const stamp = Date.parse((health && (health.checked_at || health.last_failure_ts)) || '');
220
+ return !Number.isFinite(stamp) || stamp + MISSING_RECHECK_MS <= Date.now();
221
+ }
222
+
206
223
  function canonicalEngineName(name) {
207
224
  const trimmed = String(name || '').trim();
208
225
  if (!trimmed) return '';
@@ -256,17 +273,30 @@ function normalizeEngineEntry(id, saved = {}, now) {
256
273
  // Policy over probes: a saved health status is the routing truth, verbatim.
257
274
  // Only an engine the registry has never seen gets one seeding probe; after
258
275
  // that, installed-state changes flow through `atris engine doctor` (or
259
- // `atris engine health`), never through the resolve path.
276
+ // `atris engine health`), never through the resolve path. One exception:
277
+ // a missing tool is looked for again once its check is a minute old, so a
278
+ // tool installed next week is picked up with no repair step.
260
279
  let status;
261
280
  let installed;
281
+ let checkedAt = '';
262
282
  if (ENGINE_HEALTH_STATUSES.includes(savedStatus)) {
263
283
  status = savedStatus;
264
284
  installed = typeof saved.installed === 'boolean' ? saved.installed : status !== 'not_installed';
285
+ if (status === 'not_installed') {
286
+ checkedAt = String(savedHealth.checked_at || '');
287
+ if (missingCheckDue(savedHealth)) {
288
+ installed = binInstalled(def.bin);
289
+ status = installed ? 'ready' : 'not_installed';
290
+ checkedAt = new Date().toISOString();
291
+ }
292
+ }
265
293
  } else {
266
294
  installed = binInstalled(def.bin);
267
295
  status = installed ? 'ready' : 'not_installed';
296
+ if (!installed) checkedAt = new Date().toISOString();
268
297
  }
269
298
  let health = { status };
299
+ if (status === 'not_installed' && checkedAt) health.checked_at = checkedAt;
270
300
  if (status !== 'ready' && savedHealth.last_failure_ts) health.last_failure_ts = String(savedHealth.last_failure_ts);
271
301
  if (status === 'cooling') {
272
302
  // A cooldown that has passed reads as ready, so the engine comes back
@@ -559,7 +589,12 @@ function rosterPickExpired(pick, now = new Date()) {
559
589
  // read normalizes again, so older saved picks and hand edits work too.
560
590
  const CLAUDE_FAMILY_ENGINES = Object.freeze(['claude', 'fable', 'haiku']);
561
591
  const CLAUDE_MODEL_ALIASES = Object.freeze(['opus', 'sonnet', 'haiku', 'fable', 'opusplan', 'default']);
562
- const CLAUDE_MODEL_DATED_IDS = Object.freeze({ 'claude-haiku-4-5': 'claude-haiku-4-5-20251001' });
592
+ // A retired model runs as its replacement, so a saved pick keeps working
593
+ // after the upgrade. Retiring the next one is one line here.
594
+ const RETIRED_CLAUDE_MODELS = Object.freeze({
595
+ 'claude-haiku-4-5': 'claude-haiku-5-5',
596
+ 'claude-haiku-4-5-20251001': 'claude-haiku-5-5',
597
+ });
563
598
  const CLAUDE_MODEL_SPELLING = /^(opus|sonnet|haiku|fable)[\s_-]*(\d+)(?:[\s._-]+(\d+))?$/;
564
599
  const CLAUDE_LONG_CONTEXT = /\s*\[1m\]$/;
565
600
 
@@ -574,6 +609,30 @@ function normalizeGrokModel(raw) {
574
609
  throw new Error(`grok does not know the model "${raw}". use grok 4.7 fast, grok 4.7, or a full grok- id`);
575
610
  }
576
611
 
612
+ // The model to run for a claude id: its replacement when it is retired,
613
+ // else the id as given. Keeps a "[1m]" tail.
614
+ function currentClaudeModel(value) {
615
+ const raw = String(value || '').trim();
616
+ const suffix = CLAUDE_LONG_CONTEXT.test(raw) ? '[1m]' : '';
617
+ const base = raw.replace(CLAUDE_LONG_CONTEXT, '').trim();
618
+ const next = RETIRED_CLAUDE_MODELS[base.toLowerCase()];
619
+ return next ? `${next}${suffix}` : raw;
620
+ }
621
+
622
+ // The retired models a roster file's text still names, by id or spelled
623
+ // out, each once, so the roster can say they were upgraded.
624
+ function retiredModelsNamed(text) {
625
+ const found = [];
626
+ for (const id of Object.keys(RETIRED_CLAUDE_MODELS)) {
627
+ const [, family, major, minor] = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(id) || [];
628
+ if (!family) continue;
629
+ const spelled = new RegExp(`\\b(?:claude[\\s_-]*)?${family}[\\s_-]*${major}[\\s._-]+${minor}(?![\\d.])`, 'i');
630
+ const base = `claude-${family}-${major}-${minor}`;
631
+ if (spelled.test(String(text || '')) && !found.includes(base)) found.push(base);
632
+ }
633
+ return found;
634
+ }
635
+
577
636
  function normalizeRosterModel(engineId, value) {
578
637
  const raw = String(value || '').trim();
579
638
  if (!raw) return '';
@@ -588,10 +647,10 @@ function normalizeRosterModel(engineId, value) {
588
647
  const match = CLAUDE_MODEL_SPELLING.exec(lower.replace(/^claude[\s_-]*/, ''));
589
648
  if (match) {
590
649
  const id = `claude-${match[1]}-${match[2]}${match[3] ? `-${match[3]}` : ''}`;
591
- return `${CLAUDE_MODEL_DATED_IDS[id] || id}${suffix}`;
650
+ return currentClaudeModel(`${id}${suffix}`);
592
651
  }
593
- if (lower.startsWith('claude-')) return `${base}${suffix}`;
594
- throw new Error(`${engineId} does not know the model "${raw}". use opus, sonnet, haiku, fable, opusplan, default, a version like opus 5.5, sonnet 5, haiku 4.5, or fable 5.1, or a full claude- id; add [1m] for the long context window`);
652
+ if (lower.startsWith('claude-')) return currentClaudeModel(`${base}${suffix}`);
653
+ throw new Error(`${engineId} does not know the model "${raw}". use opus, sonnet, haiku, fable, opusplan, default, a version like opus 5.5, sonnet 5, haiku 5.5, or fable 5.1, or a full claude- id; add [1m] for the long context window`);
595
654
  }
596
655
 
597
656
  // A saved pick's model, normalized the same way assign does. null means the
@@ -1903,38 +1962,48 @@ function resolveEngineForRoleRanked(role, root = process.cwd(), options = {}) {
1903
1962
  const engines = registry.engines
1904
1963
  .filter((engine) => engine.roles.includes(normalizedRole))
1905
1964
  .filter((engine) => !routerSkipsRole(engine, normalizedRole))
1965
+ .filter((engine) => !engineHosted(engine))
1906
1966
  .filter((engine) => engineReadyAt(engine, options.now))
1967
+ .map((engine) => withRoleOrder(engine, normalizedRole))
1907
1968
  .sort((a, b) => {
1908
1969
  const byOrder = Number(a.fallback_order) - Number(b.fallback_order);
1909
1970
  return byOrder || String(a.id).localeCompare(String(b.id));
1910
1971
  });
1911
1972
  // Ranking reads the router's history off disk (task receipts, mission
1912
1973
  // events, dispatch receipts). A valid roster pick decides without it, so
1913
- // rank only when something asks for the full ranked list.
1974
+ // rank only when something asks for the full ranked list. Hosted engines
1975
+ // join after the ranking, so no track record can lift one over a tool
1976
+ // installed here.
1914
1977
  let rankedCache = null;
1915
1978
  const rank = () => {
1916
1979
  if (!rankedCache) {
1917
- rankedCache = routerBrain.rankEnginesDetailed(engines, {
1980
+ const ranked = routerBrain.rankEnginesDetailed(engines, {
1918
1981
  root,
1919
1982
  taskType: options.taskType || options.task_type || normalizedRole,
1920
1983
  lowStakes: options.lowStakes,
1921
1984
  stakes: options.stakes,
1922
1985
  });
1986
+ rankedCache = { ...ranked, candidates: [...ranked.candidates, ...hostedFallbacks(registry, normalizedRole, options)] };
1923
1987
  }
1924
1988
  return rankedCache;
1925
1989
  };
1926
1990
  const choice = rosterChoice(normalizedRole, withProjectRoster(registry, root, options), options);
1991
+ const label = rosterJobLabel(options.job ? rosterJobKey(options.job) || normalizedRole : normalizedRole);
1927
1992
  if (choice) {
1928
1993
  // The job's own workers go first, in order, then the router's list.
1929
1994
  const after = choice.walk.slice(choice.lead_index + 1).filter((step) => !step.skip).map((step) => step.raw);
1930
1995
  const lead = [choice.engine, ...after].filter((engine, index, list) => list.findIndex((other) => other.id === engine.id) === index);
1931
1996
  const leadIds = new Set(lead.map((engine) => engine.id));
1997
+ const first = choice.walk[0];
1998
+ const missing = choice.lead_index > 0 && first.skip === 'not ready' && engineMissing(registry, first.worker.engine)
1999
+ ? first.worker.engine : '';
1932
2000
  const result = {
1933
2001
  engine: choice.engine,
1934
2002
  reason: choice.reason,
1935
2003
  source: choice.source,
1936
2004
  job: choice.job,
1937
2005
  team: choice.team,
2006
+ notice: showSwitchNotice(switchNotice({ label: choice.job, role: normalizedRole, missing, engine: choice.engine }), options),
1938
2007
  };
1939
2008
  let rankedList = null;
1940
2009
  Object.defineProperty(result, 'ranked', {
@@ -1951,7 +2020,122 @@ function resolveEngineForRoleRanked(role, root = process.cwd(), options = {}) {
1951
2020
  return result;
1952
2021
  }
1953
2022
  const ranked = rank();
1954
- return { engine: ranked.candidates[0] || null, reason: routerPickExplanation(ranked), source: 'router', team: [], ranked: ranked.candidates };
2023
+ const engine = ranked.candidates[0] || null;
2024
+ const reason = engine && engineHosted(engine) && !engines.length
2025
+ ? `router picked ${engine.id} because no tool installed here can take ${normalizedRole} work.`
2026
+ : routerPickExplanation(ranked);
2027
+ const notice = switchNotice({
2028
+ label,
2029
+ role: normalizedRole,
2030
+ missing: missingPickedTool(normalizedRole, withProjectRoster(registry, root, options), options),
2031
+ engine,
2032
+ registry,
2033
+ canLogIn: !atrisLoggedIn(options),
2034
+ });
2035
+ return { engine, reason, source: 'router', team: [], ranked: ranked.candidates, notice: showSwitchNotice(notice, options) };
2036
+ }
2037
+
2038
+ // --- When a job runs somewhere other than its pick -----------------------
2039
+
2040
+ function engineHosted(engine) {
2041
+ const seed = engine && ENGINE_SEED_META[engine.id];
2042
+ return Boolean(seed && seed.hosted);
2043
+ }
2044
+
2045
+ // The engine with its place for this one job, when its seed names one.
2046
+ function withRoleOrder(engine, role) {
2047
+ const seed = ENGINE_SEED_META[engine.id];
2048
+ const order = seed && seed.role_order ? seed.role_order[role] : undefined;
2049
+ return Number.isInteger(order) ? { ...engine, fallback_order: order } : engine;
2050
+ }
2051
+
2052
+ // True when this machine is logged in to atris: an ATRIS_TOKEN, or the token
2053
+ // `atris login` saves. One file read, no process and no network, so routing
2054
+ // stays probe-free. options.loggedIn decides in tests.
2055
+ function atrisLoggedIn(options = {}) {
2056
+ if (typeof options.loggedIn === 'boolean') return options.loggedIn;
2057
+ if (String(process.env.ATRIS_TOKEN || '').trim()) return true;
2058
+ try {
2059
+ const saved = JSON.parse(fs.readFileSync(path.join(stateHome(), '.atris', 'credentials.json'), 'utf8'));
2060
+ return Boolean(saved && saved.token);
2061
+ } catch {
2062
+ return false;
2063
+ }
2064
+ }
2065
+
2066
+ // The hosted engines that may take this job, after every installed tool:
2067
+ // atris-fast for search and review, composer for search. None without a
2068
+ // login, since a hosted run bills the person's atris tokens. None for build:
2069
+ // the hosted turn's file relay here is read-only (a 2026-10-07 probe asked
2070
+ // it to write a file and the relay refused every write), so a hosted build
2071
+ // would answer in chat and change nothing.
2072
+ function hostedFallbacks(registry, role, options = {}) {
2073
+ if (role === 'executor' || !atrisLoggedIn(options)) return [];
2074
+ return registry.engines
2075
+ .filter((engine) => engineHosted(engine))
2076
+ .filter((engine) => engine.id === 'atris-fast' || (engine.roles.includes(role) && !routerSkipsRole(engine, role)))
2077
+ .filter((engine) => engineReadyAt(engine, options.now))
2078
+ .sort((a, b) => Number(a.fallback_order) - Number(b.fallback_order));
2079
+ }
2080
+
2081
+ function engineMissing(registry, engineId) {
2082
+ const engine = registry.engines.find((entry) => entry.id === engineId);
2083
+ return Boolean(engine && engine.health && engine.health.status === 'not_installed');
2084
+ }
2085
+
2086
+ // The tool the first roster pick in line names for this job, when that tool
2087
+ // is not installed here. '' when no pick names a missing tool.
2088
+ function missingPickedTool(role, registry, options = {}) {
2089
+ const machine = options.machineRosterPicks || readMachineRoster(options);
2090
+ const session = options.sessionRosterPicks || readSessionRosterLayer(options).picks;
2091
+ for (const key of rosterJobKeysFor(role, options)) {
2092
+ for (const value of [session && session[key], registry.roster && registry.roster[key], machine && machine[key]]) {
2093
+ const pick = rosterPickObject(value);
2094
+ if (!pick || pickRoleForKey(key, pick) !== role) continue;
2095
+ const [first] = pickWorkers(pick);
2096
+ if (!first || first.error || rosterPickExpired(first, options.now)) continue;
2097
+ return engineMissing(registry, first.engine) ? first.engine : '';
2098
+ }
2099
+ }
2100
+ return '';
2101
+ }
2102
+
2103
+ function noticeToolName(engineId) {
2104
+ if (engineId === 'atris-fast') return 'atris 2.5 fast';
2105
+ if (CLAUDE_FAMILY_ENGINES.includes(engineId) && engineId !== 'haiku') return 'claude code';
2106
+ return engineId;
2107
+ }
2108
+
2109
+ // One plain line when a job runs somewhere other than its pick because the
2110
+ // picked tool is missing, or lands on a hosted engine. '' when nothing
2111
+ // switched. With no pick, search's own pick is haiku on claude code.
2112
+ function switchNotice({ label, role, missing = '', engine = null, registry = null, canLogIn = false }) {
2113
+ const missingName = missing ? (CLAUDE_FAMILY_ENGINES.includes(missing) ? 'claude code' : noticeToolName(missing)) : '';
2114
+ if (!engine) {
2115
+ if (!registry) return '';
2116
+ const what = missingName ? `${missingName} is not installed and no other tool here can take it` : 'no tool here can take it';
2117
+ if (role === 'executor') return `${label}: ${what}. install claude code or codex; atris 2.5 fast cannot edit files yet.`;
2118
+ return `${label}: ${what}. install claude code${canLogIn ? ', or run atris login so atris 2.5 fast can take it' : ''}.`;
2119
+ }
2120
+ const hosted = engineHosted(engine);
2121
+ const tokens = hosted ? ' (uses your atris tokens)' : '';
2122
+ if (missingName) return `${label}: ${missingName} is not installed, so ${noticeToolName(engine.id)} took it${tokens}.`;
2123
+ if (!hosted || !registry) return '';
2124
+ const why = role === 'navigator' && engineMissing(registry, 'claude') ? 'claude code is not installed' : 'no tool here can take it';
2125
+ return `${label}: ${why}, so ${noticeToolName(engine.id)} took it${tokens}.`;
2126
+ }
2127
+
2128
+ // Each notice prints once per run, on stderr, however many times a job
2129
+ // resolves. ATRIS_SWITCH_NOTICE=0 silences it.
2130
+ const SHOWN_NOTICES = new Set();
2131
+
2132
+ function showSwitchNotice(notice, options = {}) {
2133
+ if (!notice) return '';
2134
+ if (options.quiet !== true && process.env.ATRIS_SWITCH_NOTICE !== '0' && !SHOWN_NOTICES.has(notice)) {
2135
+ SHOWN_NOTICES.add(notice);
2136
+ console.error(notice);
2137
+ }
2138
+ return notice;
1955
2139
  }
1956
2140
 
1957
2141
  // The ordered team for one job: every worker that can run right now, lead
@@ -2176,6 +2360,7 @@ function engineDoctorReport(root = process.cwd()) {
2176
2360
  let health = engine.health && engine.health.status ? engine.health : { status: installed ? 'ready' : 'not_installed' };
2177
2361
  if (installed && health.status === 'not_installed') health = { status: 'ready' };
2178
2362
  if (!installed && health.status === 'ready') health = { status: 'not_installed' };
2363
+ if (!installed && health.status === 'not_installed') health = { ...health, checked_at: new Date().toISOString() };
2179
2364
  return { ...engine, installed, health };
2180
2365
  });
2181
2366
  const next = { ...registry, updated_at: new Date().toISOString(), engines };
@@ -2200,6 +2385,9 @@ module.exports = {
2200
2385
  setRosterPick,
2201
2386
  confirmRoster,
2202
2387
  normalizeRosterModel,
2388
+ currentClaudeModel,
2389
+ retiredModelsNamed,
2390
+ RETIRED_CLAUDE_MODELS,
2203
2391
  isClaudeFamilyModel,
2204
2392
  rosterModelLabel,
2205
2393
  parseRosterUntil,