atris 3.64.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.
@@ -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
@@ -923,16 +982,25 @@ function rosterPickFromValue(value, role, options = {}) {
923
982
  let namedModel = false;
924
983
  let namedEffort = false;
925
984
  let prep = '';
985
+ let verified = '';
926
986
  for (const raw of extras) {
927
- // "max: 20 min" and "until: 2026-10-24" read like "max 20 min".
928
- const part = raw.replace(/^(max|until)\s*:\s*/i, '$1 ');
987
+ // "max: 20 min", "until: 2026-10-24", and "verified: 2026-09-30" read
988
+ // like "max 20 min".
989
+ const part = raw.replace(/^(max|until|verified)\s*:\s*/i, '$1 ');
929
990
  const backupMatch = /^backup\s+(.+)$/i.exec(part);
930
991
  const untilMatch = /^until\s+(.+)$/i.exec(part);
931
992
  const maxMatch = /^max\b/i.test(part) && !/^max$/i.test(part);
932
993
  const modelMatch = /^model\s*(?::\s*|\s+)(.+)$/i.exec(part);
933
994
  const effortMatch = /^effort\s*(?::\s*|\s+)(.+)$/i.exec(part);
934
995
  const prepMatch = /^prep\s*(?::\s*|\s+)(.+)$/i.exec(part);
935
- if (prepMatch && !prep) {
996
+ const verifiedMatch = /^verified\s+(.+)$/i.exec(part);
997
+ if (verifiedMatch && !verified) {
998
+ // "verified 2026-09-30": the day a person last saw this model answer.
999
+ // The roster flags a pin whose newest proof is over 30 days old.
1000
+ const words = verifiedMatch[1].trim();
1001
+ verified = parseRosterUntilWords(words);
1002
+ if (!verified) throw new Error(`"verified ${words}" is not a date; use verified 2026-09-30`);
1003
+ } else if (prepMatch && !prep) {
936
1004
  // "prep: search": that job's lead reads the task first and hands this
937
1005
  // worker a trimmed brief. Whether it names this same job is checked
938
1006
  // where the job is known.
@@ -988,6 +1056,7 @@ function rosterPickFromValue(value, role, options = {}) {
988
1056
  until,
989
1057
  ...(until ? {} : { never_expires: true }),
990
1058
  ...(untilYearless ? { until_needs_year: untilYearless } : {}),
1059
+ ...(verified ? { verified } : {}),
991
1060
  };
992
1061
  }
993
1062
 
@@ -1030,6 +1099,7 @@ function rosterWorkerFromValue(value, role, options = {}) {
1030
1099
  until: pick.until,
1031
1100
  ...(pick.never_expires ? { never_expires: true } : {}),
1032
1101
  ...(pick.until_needs_year ? { until_needs_year: pick.until_needs_year } : {}),
1102
+ ...(pick.verified ? { verified: pick.verified } : {}),
1033
1103
  };
1034
1104
  }
1035
1105
 
@@ -1078,6 +1148,7 @@ function pickWorkers(pick) {
1078
1148
  ...(pick.prep ? { prep: pick.prep } : {}),
1079
1149
  until: pick.until,
1080
1150
  ...(pick.never_expires ? { never_expires: true } : {}),
1151
+ ...(pick.verified ? { verified: pick.verified } : {}),
1081
1152
  ...(pick.line ? { line: pick.line } : {}),
1082
1153
  }];
1083
1154
  if (pick.backup) {
@@ -1383,6 +1454,7 @@ function workerLineText(worker) {
1383
1454
  if (Number(worker.max_seconds) > 0) parts.push(`max: ${rosterMaxText(worker.max_seconds).replace(/^max /, '')}`);
1384
1455
  if (worker.prep) parts.push(`prep: ${rosterJobLabel(worker.prep)}`);
1385
1456
  if (worker.until) parts.push(`until ${worker.until}`);
1457
+ if (worker.verified) parts.push(`verified ${worker.verified}`);
1386
1458
  return `- ${parts.join(', ')}`;
1387
1459
  }
1388
1460
 
@@ -1890,38 +1962,48 @@ function resolveEngineForRoleRanked(role, root = process.cwd(), options = {}) {
1890
1962
  const engines = registry.engines
1891
1963
  .filter((engine) => engine.roles.includes(normalizedRole))
1892
1964
  .filter((engine) => !routerSkipsRole(engine, normalizedRole))
1965
+ .filter((engine) => !engineHosted(engine))
1893
1966
  .filter((engine) => engineReadyAt(engine, options.now))
1967
+ .map((engine) => withRoleOrder(engine, normalizedRole))
1894
1968
  .sort((a, b) => {
1895
1969
  const byOrder = Number(a.fallback_order) - Number(b.fallback_order);
1896
1970
  return byOrder || String(a.id).localeCompare(String(b.id));
1897
1971
  });
1898
1972
  // Ranking reads the router's history off disk (task receipts, mission
1899
1973
  // events, dispatch receipts). A valid roster pick decides without it, so
1900
- // 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.
1901
1977
  let rankedCache = null;
1902
1978
  const rank = () => {
1903
1979
  if (!rankedCache) {
1904
- rankedCache = routerBrain.rankEnginesDetailed(engines, {
1980
+ const ranked = routerBrain.rankEnginesDetailed(engines, {
1905
1981
  root,
1906
1982
  taskType: options.taskType || options.task_type || normalizedRole,
1907
1983
  lowStakes: options.lowStakes,
1908
1984
  stakes: options.stakes,
1909
1985
  });
1986
+ rankedCache = { ...ranked, candidates: [...ranked.candidates, ...hostedFallbacks(registry, normalizedRole, options)] };
1910
1987
  }
1911
1988
  return rankedCache;
1912
1989
  };
1913
1990
  const choice = rosterChoice(normalizedRole, withProjectRoster(registry, root, options), options);
1991
+ const label = rosterJobLabel(options.job ? rosterJobKey(options.job) || normalizedRole : normalizedRole);
1914
1992
  if (choice) {
1915
1993
  // The job's own workers go first, in order, then the router's list.
1916
1994
  const after = choice.walk.slice(choice.lead_index + 1).filter((step) => !step.skip).map((step) => step.raw);
1917
1995
  const lead = [choice.engine, ...after].filter((engine, index, list) => list.findIndex((other) => other.id === engine.id) === index);
1918
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 : '';
1919
2000
  const result = {
1920
2001
  engine: choice.engine,
1921
2002
  reason: choice.reason,
1922
2003
  source: choice.source,
1923
2004
  job: choice.job,
1924
2005
  team: choice.team,
2006
+ notice: showSwitchNotice(switchNotice({ label: choice.job, role: normalizedRole, missing, engine: choice.engine }), options),
1925
2007
  };
1926
2008
  let rankedList = null;
1927
2009
  Object.defineProperty(result, 'ranked', {
@@ -1938,7 +2020,122 @@ function resolveEngineForRoleRanked(role, root = process.cwd(), options = {}) {
1938
2020
  return result;
1939
2021
  }
1940
2022
  const ranked = rank();
1941
- 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;
1942
2139
  }
1943
2140
 
1944
2141
  // The ordered team for one job: every worker that can run right now, lead
@@ -2163,6 +2360,7 @@ function engineDoctorReport(root = process.cwd()) {
2163
2360
  let health = engine.health && engine.health.status ? engine.health : { status: installed ? 'ready' : 'not_installed' };
2164
2361
  if (installed && health.status === 'not_installed') health = { status: 'ready' };
2165
2362
  if (!installed && health.status === 'ready') health = { status: 'not_installed' };
2363
+ if (!installed && health.status === 'not_installed') health = { ...health, checked_at: new Date().toISOString() };
2166
2364
  return { ...engine, installed, health };
2167
2365
  });
2168
2366
  const next = { ...registry, updated_at: new Date().toISOString(), engines };
@@ -2187,6 +2385,9 @@ module.exports = {
2187
2385
  setRosterPick,
2188
2386
  confirmRoster,
2189
2387
  normalizeRosterModel,
2388
+ currentClaudeModel,
2389
+ retiredModelsNamed,
2390
+ RETIRED_CLAUDE_MODELS,
2190
2391
  isClaudeFamilyModel,
2191
2392
  rosterModelLabel,
2192
2393
  parseRosterUntil,
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ // Finished work that can never land on its own should not wait forever.
4
+ // Every hourly autoland tick: a review row idle past the grace period whose
5
+ // check this machine cannot rerun from main is closed with a plain reason.
6
+ // Landable rows are left for the landing pass, protected lanes (money,
7
+ // deploys, security, customer, outward) are left for the human, and anything
8
+ // still moving (touched inside the grace period) is left alone.
9
+
10
+ const taskDb = require('./task-db');
11
+ const { evaluateAutoAccept, protectedLaneOf, verifyRunnability } = require('./auto-accept-certified');
12
+ const { plainLandingReason } = require('./voice-gate');
13
+
14
+ const DAY_MS = 24 * 60 * 60 * 1000;
15
+ const DEFAULT_IDLE_CLOSE_DAYS = 7;
16
+
17
+ // Reasons that mean "no machine can ever check this from main as it stands".
18
+ // A human or the builder can still reopen it with a check that runs.
19
+ const UNVERIFIABLE_REASONS = new Set([
20
+ 'verify_command_not_allowed',
21
+ 'verify_only_on_agent_branch',
22
+ 'verify_failed_on_main',
23
+ 'verify_workdir_missing',
24
+ 'verify_worktree_missing',
25
+ 'strict_verify_missing',
26
+ 'no_verify_command',
27
+ ]);
28
+
29
+ function idleCloseMs(policy = {}) {
30
+ const days = Number(policy && policy.idle_close_days);
31
+ return (Number.isFinite(days) && days > 0 ? days : DEFAULT_IDLE_CLOSE_DAYS) * DAY_MS;
32
+ }
33
+
34
+ const CLOSE_WHY = {
35
+ verify_command_not_allowed: 'its check is a kind this machine may not run on its own',
36
+ verify_only_on_agent_branch: "its check runs a file that only exists on the agent's branch, never on main",
37
+ verify_failed_on_main: 'its check fails when rerun from the main project, so the work never reached main',
38
+ verify_workdir_missing: 'the folder its check ran in is gone',
39
+ verify_worktree_missing: 'the agent copy its check ran in is gone',
40
+ strict_verify_missing: 'it has no check to rerun',
41
+ no_verify_command: 'it has no check to rerun',
42
+ };
43
+
44
+ function closeReason(reason, days) {
45
+ const why = CLOSE_WHY[reason] || plainLandingReason(reason) || reason;
46
+ return `Closed after ${days} days waiting, because ${why}, so it could never land on its own. Reopen it with a check that runs from the main project if the work still matters.`;
47
+ }
48
+
49
+ function toMs(value) {
50
+ if (value === null || value === undefined || value === '') return 0;
51
+ const n = Number(value);
52
+ if (Number.isFinite(n)) return n;
53
+ const parsed = Date.parse(value);
54
+ return Number.isFinite(parsed) ? parsed : 0;
55
+ }
56
+
57
+ // Idle means nobody worked on it, not that no machine touched it: the hourly
58
+ // re-check stamps updated_at on every row whose check runs, which would keep
59
+ // a never-landable row "fresh" forever. Count only people and builders.
60
+ function lastActivityAt(task, now = Date.now()) {
61
+ const metadata = task.metadata || {};
62
+ const at = Math.max(
63
+ toMs(task.created_at),
64
+ toMs(task.claimed_at),
65
+ toMs(metadata.agent_reviewed_at),
66
+ toMs(metadata.stage_updated_at),
67
+ toMs(metadata.planned_at),
68
+ toMs(metadata.human_revision_at),
69
+ toMs(metadata.backlogged_at),
70
+ );
71
+ return at > 0 ? at : now;
72
+ }
73
+
74
+ // Pure decision for one projection row. `liveReasons` carries the landing
75
+ // pass's executed verdicts from this tick (verify_failed_on_main can only be
76
+ // known by running the check), keyed by ref.
77
+ function idleDecision(task, { root, policy = {}, now = Date.now(), liveReasons = new Map() } = {}) {
78
+ if (!task || task.status !== 'review') return { action: 'skip', why: 'not_review' };
79
+ const approval = String(task.review?.approval_status || task.metadata?.approval_status || 'pending');
80
+ if (approval !== 'pending' && approval !== 'agent_certified') return { action: 'skip', why: 'decided' };
81
+ const lane = protectedLaneOf(task);
82
+ if (lane) return { action: 'human', lane };
83
+ const idleMs = now - lastActivityAt(task, now);
84
+ if (!(idleMs > idleCloseMs(policy))) return { action: 'wait', why: 'still_in_grace' };
85
+ const ref = task.display_id || task.legacy_ref || task.id;
86
+ let reason = liveReasons.get(ref) || null;
87
+ if (!reason) {
88
+ const evaluation = evaluateAutoAccept(
89
+ { ...task, workspace_root: root },
90
+ { strictVerify: policy.strict_verify !== false, acceptAll: Boolean(policy.accept_all), executeVerify: false },
91
+ );
92
+ if (evaluation.eligible) return { action: 'land', why: 'landing_pass_takes_it' };
93
+ reason = verifyRunnability({ ...task, workspace_root: root }) || evaluation.reason;
94
+ }
95
+ if (!UNVERIFIABLE_REASONS.has(reason)) return { action: 'wait', why: reason };
96
+ return { action: 'close', reason, days: Math.floor(idleMs / DAY_MS) };
97
+ }
98
+
99
+ function sweepIdleReviews(root, { tasks, policy = {}, now = Date.now(), liveReasons = new Map(), dryRun = false, actor = 'autoland' } = {}) {
100
+ const closed = [];
101
+ const humanWaiting = [];
102
+ const due = [];
103
+ for (const task of tasks || []) {
104
+ const decision = idleDecision(task, { root, policy, now, liveReasons });
105
+ const ref = task.display_id || task.legacy_ref || task.id;
106
+ if (decision.action === 'human') humanWaiting.push({ ref, lane: decision.lane });
107
+ if (decision.action === 'close') due.push({ id: task.id, ref, title: task.title || '', reason: decision.reason, text: closeReason(decision.reason, decision.days) });
108
+ }
109
+ if (dryRun || !due.length) return { closed: dryRun ? due : closed, due: due.length, human_waiting: humanWaiting, dry_run: dryRun };
110
+
111
+ const db = taskDb.open();
112
+ db.exec('BEGIN IMMEDIATE');
113
+ try {
114
+ for (const item of due) {
115
+ const result = taskDb.archiveTask(db, { id: item.id, actor, reason: item.text, skipLogs: true });
116
+ if (result.archived) closed.push(item);
117
+ }
118
+ db.exec('COMMIT');
119
+ } catch (error) {
120
+ try { db.exec('ROLLBACK'); } catch { /* surface the original error */ }
121
+ throw error;
122
+ }
123
+ return { closed, due: due.length, human_waiting: humanWaiting, dry_run: false };
124
+ }
125
+
126
+ module.exports = {
127
+ idleDecision,
128
+ lastActivityAt,
129
+ sweepIdleReviews,
130
+ };
@@ -4,8 +4,8 @@ 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', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'sync-checkout',
7
- 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'topup', 'buy', 'transactions', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
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', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi'];
7
+ 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
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'];
9
9
 
10
10
  // Damerau-Levenshtein edit distance between two short strings. Counts an
11
11
  // adjacent transposition (e.g. "taks" -> "task") as a single edit, since
package/lib/land-green.js CHANGED
@@ -23,6 +23,9 @@ const { runGit } = require('./git-spawn');
23
23
 
24
24
  const DEFAULT_VERIFY = 'npm test';
25
25
  const DEFAULT_VERIFY_TIMEOUT_MS = 20 * 60 * 1000;
26
+ // Stop the walk after this many consecutive branches that landed nothing, so
27
+ // a queue of erroring branches does not burn a scratch checkout per branch.
28
+ const DEFAULT_MAX_MISSES = 4;
26
29
  const FAILING_LINE = /^(?:✖|not ok)\s+(.+?)(?:\s+\(\d+(?:\.\d+)?ms\))?\s*$/;
27
30
 
28
31
  function gitOut(args, cwd) {
@@ -291,6 +294,7 @@ function landStaleGreenBranches(root, {
291
294
  ttlDays,
292
295
  base: baseOverride = '',
293
296
  limit = 1,
297
+ maxMisses = DEFAULT_MAX_MISSES,
294
298
  now = Date.now(),
295
299
  verifyCommand = DEFAULT_VERIFY,
296
300
  verifyTimeoutMs = DEFAULT_VERIFY_TIMEOUT_MS,
@@ -307,19 +311,29 @@ function landStaleGreenBranches(root, {
307
311
  .filter((b) => b.state === 'active' && b.stale)
308
312
  .sort((a, b) => b.activityHours - a.activityHours);
309
313
  const verdicts = [];
310
- let attempts = 0;
314
+ // Only landed and red verdicts spend the per-tick budget: an erroring or
315
+ // ineligible branch must not starve the rest of the queue. A bounded run of
316
+ // misses still stops the walk so a fully broken queue does not burn a
317
+ // scratch checkout per branch per tick.
318
+ let spent = 0;
319
+ let misses = 0;
311
320
  for (const candidate of candidates) {
312
- if (attempts >= limit) {
313
- verdicts.push(verdictFor(candidate.name, live.base, { action: 'skipped', reason: 'per_tick_limit' }));
321
+ if (spent >= limit || misses >= maxMisses) {
322
+ verdicts.push(verdictFor(candidate.name, live.base, { action: 'skipped', reason: spent >= limit ? 'per_tick_limit' : 'attempt_cap' }));
314
323
  continue;
315
324
  }
316
- attempts += 1;
317
325
  let verdict;
318
326
  try {
319
327
  verdict = landOneBranch(root, live.base, candidate.name, { verifyCommand, verifyTimeoutMs, dryRun, push, runVerifyFn, env });
320
328
  } catch (err) {
321
329
  verdict = verdictFor(candidate.name, live.base, { action: 'error', reason: 'exception', detail: String((err && err.message) || err).slice(0, 300) });
322
330
  }
331
+ if (verdict.action === 'landed' || verdict.action === 'red') {
332
+ spent += 1;
333
+ misses = 0;
334
+ } else {
335
+ misses += 1;
336
+ }
323
337
  if (verdict.action === 'red' && openFlags) {
324
338
  try {
325
339
  const flagged = openRedFlag(root, verdict, { runCli });
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ // How a launch names the member it runs as, in one place. The writers use
4
+ // these lines and the team view reads them back with patterns built from the
5
+ // same lines, so the wording cannot drift apart.
6
+ //
7
+ // Mission ticks (atris member run, atris mission) say:
8
+ // You are the member "validator".
9
+ // **Objective:** <what the run is for>
10
+ // Dispatch briefs say:
11
+ // You are acting as validator. <the work>
12
+
13
+ const escapeRegExp = require('./escape-regexp');
14
+
15
+ const NAME = '([a-z0-9][a-z0-9_-]*[a-z0-9])';
16
+ const SLOT = '\u0000';
17
+
18
+ function memberIdentityLine(name) {
19
+ return `You are the member "${name}".`;
20
+ }
21
+
22
+ function missionObjectiveLine(objective) {
23
+ return `**Objective:** ${objective}`;
24
+ }
25
+
26
+ function patternFrom(line, name) {
27
+ return new RegExp(escapeRegExp(line).replace(escapeRegExp(SLOT), name), 'i');
28
+ }
29
+
30
+ const MISSION_MEMBER = patternFrom(memberIdentityLine(SLOT), NAME);
31
+ const DISPATCH_MEMBER = /\bacting as (?:the )?([a-z0-9][a-z0-9_-]*[a-z0-9])[.,:;]?/i;
32
+ const OBJECTIVE = patternFrom(missionObjectiveLine(SLOT), '(.+?)(?=\\s\\*\\*[A-Z][^*]*:\\*\\*|\\s#|$)');
33
+
34
+ // One prompt -> { member, doing }. The member is the name the prompt runs
35
+ // as, or null. doing is what the run is for: a mission's objective, or the
36
+ // words after the dispatch line.
37
+ function readMemberLaunch(prompt) {
38
+ const text = String(prompt || '').replace(/\\0?12|\\n/g, ' ').replace(/\s+/g, ' ').trim();
39
+ const mission = MISSION_MEMBER.exec(text);
40
+ if (mission) {
41
+ const objective = OBJECTIVE.exec(text);
42
+ return { member: mission[1].toLowerCase(), doing: objective ? objective[1].trim() : text };
43
+ }
44
+ const dispatch = DISPATCH_MEMBER.exec(text);
45
+ if (dispatch) {
46
+ const after = text.slice(dispatch.index + dispatch[0].length).trim();
47
+ return { member: dispatch[1].toLowerCase(), doing: after || text };
48
+ }
49
+ return { member: null, doing: text };
50
+ }
51
+
52
+ module.exports = {
53
+ memberIdentityLine,
54
+ missionObjectiveLine,
55
+ readMemberLaunch,
56
+ };