atris 3.61.0 → 3.63.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 (53) hide show
  1. package/README.md +37 -5
  2. package/atris/features/README.md +8 -0
  3. package/atris/skills/README.md +1 -0
  4. package/atris/skills/commerce/SKILL.md +96 -0
  5. package/atris/skills/engines/SKILL.md +9 -9
  6. package/bin/atris.js +12 -0
  7. package/commands/autoland.js +36 -5
  8. package/commands/autopilot.js +105 -6
  9. package/commands/buy.js +242 -0
  10. package/commands/engine.js +96 -21
  11. package/commands/host.js +56 -4
  12. package/commands/land.js +129 -8
  13. package/commands/member.js +68 -14
  14. package/commands/mission.js +116 -4
  15. package/commands/one-lap.js +14 -10
  16. package/commands/stream.js +56 -22
  17. package/commands/task.js +7 -0
  18. package/commands/team.js +82 -10
  19. package/commands/transactions.js +115 -0
  20. package/commands/worktree.js +39 -0
  21. package/commands/youtube.js +58 -26
  22. package/lib/commerce.js +75 -0
  23. package/lib/engine-ask.js +112 -9
  24. package/lib/engine-registry.js +300 -39
  25. package/lib/fleet.js +444 -26
  26. package/lib/functional-owner.js +15 -6
  27. package/lib/host.js +296 -170
  28. package/lib/known-commands.js +1 -1
  29. package/lib/member-engine.js +25 -1
  30. package/lib/member-park.js +138 -0
  31. package/lib/member-scaffold.js +39 -0
  32. package/lib/member-type.js +27 -0
  33. package/lib/roster-markdown.js +1 -1
  34. package/lib/roster-prep.js +289 -0
  35. package/lib/roster-runs.js +389 -0
  36. package/lib/roster-suggest.js +145 -0
  37. package/lib/router-brain.js +111 -3
  38. package/lib/runner-command.js +37 -2
  39. package/lib/self-drive.js +26 -2
  40. package/lib/state-detection.js +3 -3
  41. package/lib/task-db.js +38 -3
  42. package/lib/team-lineup.js +7 -2
  43. package/lib/todo.js +1 -1
  44. package/lib/wish-audit.js +1 -1
  45. package/package.json +1 -1
  46. package/scripts/det/cpu-hotspots.js +216 -0
  47. package/scripts/det/ytnotes +169 -23
  48. package/scripts/det/ytquote-repair.js +18 -4
  49. package/scripts/det/ytrail-bench.js +214 -0
  50. package/scripts/det/ytrail-eval.js +128 -56
  51. package/templates/members/host/MEMBER.md +5 -3
  52. package/templates/members/host/MISSION.md +1 -1
  53. package/templates/members/host/skills/host/SKILL.md +64 -16
@@ -14,7 +14,9 @@ const {
14
14
  engineTakesModel,
15
15
  engineEffortLevels,
16
16
  } = require('./runner-command');
17
- const { rankEnginesDetailed, routerPickExplanation } = require('./router-brain');
17
+ // Through the module object so tests can see when ranking runs.
18
+ const routerBrain = require('./router-brain');
19
+ const { routerPickExplanation } = routerBrain;
18
20
  // Friendly name for a saved model: claude-opus-5-5 reads as "opus 5.5".
19
21
  const { modelLabel: rosterModelLabel } = require('./roster-models');
20
22
 
@@ -22,29 +24,116 @@ const ENGINE_REGISTRY_SCHEMA = 'atris.engine_registry.v2';
22
24
  const ENGINE_TIERS = Object.freeze(['fast', 'pro', 'max']);
23
25
  const ENGINE_ROLES = Object.freeze(['navigator', 'executor', 'validator']);
24
26
  const ENGINE_DUTIES = Object.freeze(['leader', 'errands', 'learning']);
25
- const ENGINE_HEALTH_STATUSES = Object.freeze(['ready', 'not_installed', 'credit_out', 'error']);
27
+ const ENGINE_HEALTH_STATUSES = Object.freeze(['ready', 'not_installed', 'credit_out', 'error', 'cooling']);
28
+ const DEFAULT_ENGINE_COOLDOWN_MINUTES = 30;
29
+
30
+ // How long a stalled engine sits out: ATRIS_ENGINE_COOLDOWN_MINUTES, else 30.
31
+ function engineCooldownMs() {
32
+ const minutes = Number(process.env.ATRIS_ENGINE_COOLDOWN_MINUTES);
33
+ return (Number.isFinite(minutes) && minutes > 0 ? minutes : DEFAULT_ENGINE_COOLDOWN_MINUTES) * 60000;
34
+ }
35
+
36
+ function nowMs(now) {
37
+ if (now === undefined || now === null) return Date.now();
38
+ const ms = now instanceof Date ? now.getTime() : Date.parse(now);
39
+ return Number.isFinite(ms) ? ms : Date.now();
40
+ }
41
+
42
+ // True while an engine sits out after a stall. Once the cooldown passes the
43
+ // engine is ready again with no human step.
44
+ function engineCooling(engine, now) {
45
+ const health = engine && engine.health;
46
+ if (!health || health.status !== 'cooling') return false;
47
+ const until = Date.parse(health.cooling_until || '');
48
+ return Number.isFinite(until) && until > nowMs(now);
49
+ }
50
+
51
+ // Every resolver asks this one question: can this engine take work now?
52
+ function engineReadyAt(engine, now) {
53
+ const health = engine && engine.health;
54
+ const status = health && health.status;
55
+ if (status === 'ready') return true;
56
+ if (status === 'cooling') return !engineCooling(engine, now);
57
+ // 'error' survives from before failed runs stopped benching their engine:
58
+ // it came from any errored run, real task failures included. It sits out
59
+ // the same window as a stall; a missing stamp (older saves) routes at once.
60
+ if (status === 'error') {
61
+ const failedAt = Date.parse(health.last_failure_ts || '');
62
+ return !Number.isFinite(failedAt) || failedAt + engineCooldownMs() <= nowMs(now);
63
+ }
64
+ return false;
65
+ }
66
+
67
+ const TRANSIENT_FAILURE_PATTERN = /\b(?:econnreset|econnrefused|econnaborted|etimedout|enotfound|eai_again|epipe|enetunreach|ehostunreach)\b|socket hang up|connection (?:reset|refused|closed|dropped|lost|error|timed out|interrupted)|lost (?:the )?connection|network (?:error|failure|is unreachable)|stream (?:disconnected|error|closed)|\bdisconnected\b|(?:service|server|model|temporarily|currently)[ _-]unavailable|overloaded|bad gateway|gateway time-?out/;
68
+
69
+ function tailText(value, size = 600) {
70
+ const text = String(value || '');
71
+ return text.length > size ? text.slice(-size) : text;
72
+ }
73
+
74
+ // Why a run stalled, in plain words, or null when it failed for a real task
75
+ // reason. A stall is a time cap (the run hit its max, a spawn timeout, or the
76
+ // codex watchdog's 124/125) or a transient error (a dropped connection, a
77
+ // network error, an unavailable service). Only engine-level signals count:
78
+ // the task's own output (report, stdout, the claude summary and receipt) is
79
+ // the work it produced, and its words must not decide the engine's health.
80
+ function engineStallReason(result) {
81
+ if (!result || typeof result !== 'object') return null;
82
+ const claude = result.claude || {};
83
+ const reason = String(result.reason || '');
84
+ if (/wall-exceeded/.test(reason)) return null;
85
+ const exitCode = Number.isInteger(result.exitCode) ? result.exitCode
86
+ : Number.isInteger(result.exit_code) ? result.exit_code : null;
87
+ const capSecondsValue = Math.floor(Number(result.max_seconds || result.maxSeconds)) || 0;
88
+ const capText = capSecondsValue >= 60 ? `stalled at ${Math.round(capSecondsValue / 60)} min`
89
+ : capSecondsValue > 0 ? `stalled at ${capSecondsValue}s`
90
+ : 'hit its time limit';
91
+ if (exitCode === 124) return { kind: 'time_cap', text: 'stalled at startup' };
92
+ if (result.timed_out === true || claude.timed_out === true || exitCode === 125
93
+ || /\bclaude-timeout\b|(?:^|\n)timeout(?:\n|$)/.test(reason)) {
94
+ return { kind: 'time_cap', text: capText };
95
+ }
96
+ const text = [
97
+ reason,
98
+ result.error && (result.error.message || result.error.code || result.error),
99
+ result.model_unavailable,
100
+ tailText(result.stderr),
101
+ tailText(claude.stderr),
102
+ ].filter(Boolean).map(String).join('\n').toLowerCase();
103
+ if (/spawn[^\n]*etimedout/.test(text)) return { kind: 'time_cap', text: 'timed out starting up' };
104
+ if (TRANSIENT_FAILURE_PATTERN.test(text)) return { kind: 'transient', text: 'lost its connection' };
105
+ return null;
106
+ }
26
107
 
27
108
  function engineFailureHealthStatus(result) {
28
109
  if (!result || result.status !== 'errored') return null;
110
+ // Same rule as engineStallReason: only engine-level signals decide engine
111
+ // health. The task's own output (report, stdout, the claude summary and
112
+ // receipt) is the work product, never the engine's vital signs.
113
+ const rateLimitInfo = result.rate_limit_info;
114
+ const rateLimitStatus = String(
115
+ rateLimitInfo && typeof rateLimitInfo === 'object' ? rateLimitInfo.status || '' : rateLimitInfo || '',
116
+ ).trim().toLowerCase();
117
+ // The engine's own rate-limit event is a credit wall only when the window
118
+ // is actually closed: claude reports resetsAt with status 'allowed' on
119
+ // healthy turns too.
120
+ if (rateLimitStatus && rateLimitStatus !== 'allowed') return 'credit_out';
29
121
  const signalText = [
30
122
  result.reason,
31
123
  result.model_unavailable,
32
- result.report,
33
- result.stdout,
34
124
  result.stderr,
35
- result.error,
36
- result.claude && result.claude.summary,
37
- result.claude && result.claude.receipt_text,
125
+ result.error && (result.error.message || result.error.code || result.error),
38
126
  result.claude && result.claude.stderr,
39
- result.rate_limit_info && JSON.stringify(result.rate_limit_info),
127
+ rateLimitStatus,
40
128
  ].filter(Boolean).join('\n').toLowerCase();
41
129
  if (/usage[ _-]?limit|purchase more credits|insufficient credits|credit(?:s)?[ _-]?(?:out|limit)|rate[ _-]?limit|not authenticated|please log in|login required|auth(?:entication)?[ _-]?expired|payment required|subscription/.test(signalText)) {
42
130
  return 'credit_out';
43
131
  }
44
132
  if (/not installed|command not found|\benoent\b/.test(signalText)) return 'not_installed';
45
- // Timeouts and unavailable models are transient: 'error' keeps the engine
46
- // routable, while 'not_installed' would drop it from routing until a doctor run.
47
- return 'error';
133
+ // A real task failure (its tests failed, its report says why) says nothing
134
+ // about the engine, so health stays. 'error' survives only as the stall
135
+ // marker: the caller walks it through the cooling path.
136
+ return engineStallReason(result) ? 'error' : null;
48
137
  }
49
138
 
50
139
  const ENGINE_SEED_META = Object.freeze({
@@ -55,12 +144,12 @@ const ENGINE_SEED_META = Object.freeze({
55
144
  // review when picked; it stays out of roles, so every review list built
56
145
  // from roles (the router, one lap, wish audit, the chart) is unchanged.
57
146
  codex: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), roster_only_roles: Object.freeze(['validator']), models: Object.freeze(['codex']), fallback_order: 10 }),
58
- 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', 'opus 4.8', 'fable', 'haiku']), fallback_order: 20 }),
147
+ 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 }),
59
148
  cursor: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['composer 2.5', 'grok 4.6', 'kimi 3']), fallback_order: 30 }),
60
149
  // Devin gathers context for search only when a roster picks it.
61
150
  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 }),
62
151
  grok: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['grok 4.7 fast', 'grok 4.7']), fallback_order: 45 }),
63
- fable: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), models: Object.freeze(['opus 5.5', 'opus 5', 'opus 4.8', 'fable', 'haiku']), duty: 'leader', fallback_order: 50 }),
152
+ 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 }),
64
153
  composer: Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator', 'executor']), models: Object.freeze(['composer 2.5']), fallback_order: 60 }),
65
154
  haiku: Object.freeze({ tier: 'fast', roles: Object.freeze(['validator', 'navigator']), roster_only_roles: Object.freeze(['navigator']), models: Object.freeze(['haiku']), fallback_order: 70 }),
66
155
  agy: Object.freeze({
@@ -77,7 +166,7 @@ const ENGINE_SEED_META = Object.freeze({
77
166
  duty: 'errands',
78
167
  fallback_order: 90,
79
168
  }),
80
- opencode: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['opencode/big-pickle', 'opencode/claude-opus-4-8', 'opencode/gpt-5.2']), fallback_order: 95 }),
169
+ opencode: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['opencode/big-pickle', 'opencode/claude-opus-5-5', 'opencode/gpt-5.2']), fallback_order: 95 }),
81
170
  commandcode: Object.freeze({
82
171
  tier: 'max',
83
172
  roles: Object.freeze(['validator', 'executor']),
@@ -155,7 +244,7 @@ function normalizeFallbackOrder(value, fallback) {
155
244
  return Number.isInteger(order) ? order : fallback;
156
245
  }
157
246
 
158
- function normalizeEngineEntry(id, saved = {}) {
247
+ function normalizeEngineEntry(id, saved = {}, now) {
159
248
  const def = RUNNER_PROFILE_DEFS[id];
160
249
  const seed = ENGINE_SEED_META[id] || { tier: 'pro', roles: ['executor'], models: [id], fallback_order: 100 };
161
250
  const savedHealth = saved && saved.health && typeof saved.health === 'object' ? saved.health : {};
@@ -173,8 +262,18 @@ function normalizeEngineEntry(id, saved = {}) {
173
262
  installed = binInstalled(def.bin);
174
263
  status = installed ? 'ready' : 'not_installed';
175
264
  }
176
- const health = { status };
265
+ let health = { status };
177
266
  if (status !== 'ready' && savedHealth.last_failure_ts) health.last_failure_ts = String(savedHealth.last_failure_ts);
267
+ if (status === 'cooling') {
268
+ // A cooldown that has passed reads as ready, so the engine comes back
269
+ // with no human step.
270
+ if (engineCooling({ health: savedHealth }, now)) {
271
+ health.cooling_until = String(savedHealth.cooling_until);
272
+ if (savedHealth.cooling_reason) health.cooling_reason = String(savedHealth.cooling_reason);
273
+ } else {
274
+ health = { status: 'ready' };
275
+ }
276
+ }
178
277
  return {
179
278
  ...saved,
180
279
  id,
@@ -182,7 +281,13 @@ function normalizeEngineEntry(id, saved = {}) {
182
281
  bin: def.bin,
183
282
  tier: normalizeTier(saved.tier, seed.tier),
184
283
  roles: normalizeRoles(saved.roles, Array.from(seed.roles)),
185
- models: normalizeModels(saved.models, Array.from(seed.models)),
284
+ // The seed model list is the live catalog and always wins on read, unless
285
+ // the owner set models on purpose: `atris engine set --models` saves
286
+ // models_set_by_owner next to the list. A list copied in at seed time has
287
+ // no mark, so it thaws to the current seed instead of pinning stale names.
288
+ models: saved.models_set_by_owner === true
289
+ ? normalizeModels(saved.models, Array.from(seed.models))
290
+ : Array.from(seed.models),
186
291
  duty: normalizeDuty(saved.duty, seed.duty),
187
292
  fallback_order: normalizeFallbackOrder(saved.fallback_order, seed.fallback_order),
188
293
  installed,
@@ -190,7 +295,7 @@ function normalizeEngineEntry(id, saved = {}) {
190
295
  };
191
296
  }
192
297
 
193
- function seededRegistry(root = process.cwd(), preloadedRaw = null) {
298
+ function seededRegistry(root = process.cwd(), preloadedRaw = null, now) {
194
299
  const file = engineRegistryFile(root);
195
300
  const raw = preloadedRaw || readRawRegistry(file);
196
301
  const savedById = new Map();
@@ -204,7 +309,7 @@ function seededRegistry(root = process.cwd(), preloadedRaw = null) {
204
309
  updated_at: new Date().toISOString(),
205
310
  // Hidden profiles are registered engines too: they seed, route, and take
206
311
  // health policy exactly like visible ones.
207
- engines: Object.keys(RUNNER_PROFILE_DEFS).map((id) => normalizeEngineEntry(id, savedById.get(id) || {})),
312
+ engines: Object.keys(RUNNER_PROFILE_DEFS).map((id) => normalizeEngineEntry(id, savedById.get(id) || {}, now)),
208
313
  };
209
314
  }
210
315
 
@@ -237,6 +342,7 @@ function setEngineOverrides(name, overrides = {}, root = process.cwd()) {
237
342
  const models = normalizeModels(overrides.models, []);
238
343
  if (!models.length) throw new Error('models must include at least one name');
239
344
  nextOverrides.models = models;
345
+ nextOverrides.models_set_by_owner = true;
240
346
  }
241
347
  if (!Object.keys(nextOverrides).length) throw new Error('set requires --duty or --models');
242
348
 
@@ -282,7 +388,7 @@ function setEngineOverrides(name, overrides = {}, root = process.cwd()) {
282
388
  // one read, one decision.
283
389
  function readEngineRegistry(root = process.cwd(), options = {}) {
284
390
  const raw = readRawRegistry(engineRegistryFile(root));
285
- const registry = seededRegistry(root, raw);
391
+ const registry = seededRegistry(root, raw, options.now);
286
392
  const needsPersist = JSON.stringify(raw.engines || []) !== JSON.stringify(registry.engines);
287
393
  if (options.persist !== false && needsPersist && canPersistEngineRegistry(root)) {
288
394
  writeEngineRegistry(root, registry);
@@ -754,6 +860,22 @@ function resolveEngineModelWords(text) {
754
860
  throw new Error(`"${clean}" is not an engine or a model atris knows`);
755
861
  }
756
862
 
863
+ // The engine a model name belongs to, read the same way a roster line is,
864
+ // or '' when the name is not one atris can place. The claude cli's
865
+ // own aliases ("default", "opusplan", with or without [1m]) count as claude.
866
+ function modelEngineName(model) {
867
+ const base = String(model || '').trim().replace(CLAUDE_LONG_CONTEXT, '').trim();
868
+ try {
869
+ return resolveEngineModelWords(base).engine || '';
870
+ } catch {
871
+ return CLAUDE_MODEL_ALIASES.includes(base.toLowerCase()) ? 'claude' : '';
872
+ }
873
+ }
874
+
875
+ function isClaudeFamilyModel(model) {
876
+ return CLAUDE_FAMILY_ENGINES.includes(modelEngineName(model));
877
+ }
878
+
757
879
  // What an engine can do by its seed, used to check hand-written lines before
758
880
  // any registry is read.
759
881
  function engineCanTake(engineId, role) {
@@ -787,6 +909,7 @@ function rosterPickFromValue(value, role, options = {}) {
787
909
  let maxSeconds = null;
788
910
  let namedModel = false;
789
911
  let namedEffort = false;
912
+ let prep = '';
790
913
  for (const raw of extras) {
791
914
  // "max: 20 min" and "until: 2026-10-24" read like "max 20 min".
792
915
  const part = raw.replace(/^(max|until)\s*:\s*/i, '$1 ');
@@ -795,7 +918,15 @@ function rosterPickFromValue(value, role, options = {}) {
795
918
  const maxMatch = /^max\b/i.test(part) && !/^max$/i.test(part);
796
919
  const modelMatch = /^model\s*(?::\s*|\s+)(.+)$/i.exec(part);
797
920
  const effortMatch = /^effort\s*(?::\s*|\s+)(.+)$/i.exec(part);
798
- if (modelMatch && !namedModel) {
921
+ const prepMatch = /^prep\s*(?::\s*|\s+)(.+)$/i.exec(part);
922
+ if (prepMatch && !prep) {
923
+ // "prep: search": that job's lead reads the task first and hands this
924
+ // worker a trimmed brief. Whether it names this same job is checked
925
+ // where the job is known.
926
+ const words = prepMatch[1].trim();
927
+ prep = rosterJobKey(splitJobName(words).name);
928
+ if (!prep) throw new Error(`"prep: ${words}" does not name a job; use prep: search or another job name`);
929
+ } else if (modelMatch && !namedModel) {
799
930
  if (primary.model) throw new Error(`names two models for ${primary.engine}`);
800
931
  const words = modelMatch[1].trim();
801
932
  if (!engineTakesModel(primary.engine)) throw new Error(`${primary.engine} runs one fixed model and cannot take "${words}"`);
@@ -840,12 +971,21 @@ function rosterPickFromValue(value, role, options = {}) {
840
971
  ...(backup && backup.model ? { backup_model: backup.model } : {}),
841
972
  ...(backup && backup.effort ? { backup_effort: backup.effort } : {}),
842
973
  ...(maxSeconds ? { max_seconds: maxSeconds } : {}),
974
+ ...(prep ? { prep } : {}),
843
975
  until,
844
976
  ...(until ? {} : { never_expires: true }),
845
977
  ...(untilYearless ? { until_needs_year: untilYearless } : {}),
846
978
  };
847
979
  }
848
980
 
981
+ // A worker that names its own job as prep would wait on itself. The line
982
+ // still counts; only the prep is dropped, with a plain warning.
983
+ function selfPrepWarning(worker, key, label) {
984
+ if (!worker || !worker.prep || worker.prep !== key) return '';
985
+ delete worker.prep;
986
+ return `asks ${label} to prep for itself, so this worker runs without prep`;
987
+ }
988
+
849
989
  // A job line's name part, "small build (like build)", split into the name
850
990
  // and the kind it says it is like.
851
991
  function splitJobName(text) {
@@ -873,6 +1013,7 @@ function rosterWorkerFromValue(value, role, options = {}) {
873
1013
  model: pick.model,
874
1014
  ...(pick.effort ? { effort: pick.effort } : {}),
875
1015
  ...(pick.max_seconds ? { max_seconds: pick.max_seconds } : {}),
1016
+ ...(pick.prep ? { prep: pick.prep } : {}),
876
1017
  until: pick.until,
877
1018
  ...(pick.never_expires ? { never_expires: true } : {}),
878
1019
  ...(pick.until_needs_year ? { until_needs_year: pick.until_needs_year } : {}),
@@ -921,6 +1062,7 @@ function pickWorkers(pick) {
921
1062
  model: pick.model || '',
922
1063
  ...(pick.effort ? { effort: pick.effort } : {}),
923
1064
  ...cap,
1065
+ ...(pick.prep ? { prep: pick.prep } : {}),
924
1066
  until: pick.until,
925
1067
  ...(pick.never_expires ? { never_expires: true } : {}),
926
1068
  ...(pick.line ? { line: pick.line } : {}),
@@ -971,6 +1113,8 @@ function parseRosterMarkdown(text, file, options = {}) {
971
1113
  for (const entry of lines) {
972
1114
  try {
973
1115
  const worker = rosterWorkerFromValue(entry.value, ENGINE_JOBS[kind], options);
1116
+ const selfPrep = selfPrepWarning(worker, key, label);
1117
+ if (selfPrep) warn(entry, selfPrep);
974
1118
  workers.push({ ...worker, line: entry.lineNumber, text: entry.raw });
975
1119
  if (worker.until_needs_year) warn(entry, `has no year in its until date, so this ${label} worker counts as expired; write until ${worker.until_needs_year}`);
976
1120
  } catch (error) {
@@ -988,6 +1132,7 @@ function parseRosterMarkdown(text, file, options = {}) {
988
1132
  backup: second ? second.engine : '',
989
1133
  ...(second && second.model ? { backup_model: second.model } : {}),
990
1134
  ...(first.max_seconds ? { max_seconds: first.max_seconds } : {}),
1135
+ ...(first.prep ? { prep: first.prep } : {}),
991
1136
  until: first.until,
992
1137
  ...(first.never_expires ? { never_expires: true } : {}),
993
1138
  ...(builtIn ? {} : { like: kind }),
@@ -1040,6 +1185,8 @@ function parseRosterMarkdown(text, file, options = {}) {
1040
1185
  }
1041
1186
  try {
1042
1187
  const pick = rosterPickFromValue(entry.value, ENGINE_JOBS[kind], options);
1188
+ const selfPrep = selfPrepWarning(pick, key, label);
1189
+ if (selfPrep) warn(entry, selfPrep);
1043
1190
  picks[key] = { ...pick, ...(builtIn ? {} : { like: kind }), file: display, line: entry.lineNumber };
1044
1191
  if (pick.until_needs_year) {
1045
1192
  warn(entry, `has no year in its until date, so ${label} counts as expired; write until ${pick.until_needs_year}`);
@@ -1215,12 +1362,13 @@ function workerModelText(engine, model) {
1215
1362
  return model;
1216
1363
  }
1217
1364
 
1218
- // One worker line: "- claude code, model: opus 5.5, effort: high, max: 20 min, until 2026-10-24".
1365
+ // One worker line: "- claude code, model: opus 5.5, effort: high, max: 20 min, prep: search, until 2026-10-24".
1219
1366
  function workerLineText(worker) {
1220
1367
  const parts = [rosterToolLabel(worker.engine)];
1221
1368
  if (worker.model) parts.push(`model: ${workerModelText(worker.engine, worker.model)}`);
1222
1369
  if (worker.effort) parts.push(`effort: ${worker.effort}`);
1223
1370
  if (Number(worker.max_seconds) > 0) parts.push(`max: ${rosterMaxText(worker.max_seconds).replace(/^max /, '')}`);
1371
+ if (worker.prep) parts.push(`prep: ${rosterJobLabel(worker.prep)}`);
1224
1372
  if (worker.until) parts.push(`until ${worker.until}`);
1225
1373
  return `- ${parts.join(', ')}`;
1226
1374
  }
@@ -1360,6 +1508,24 @@ function assignedWorkerLines(key, engineName, options, context) {
1360
1508
  if (keep.length === currentLines.length) throw new Error(`${label} has no ${tool} worker to remove`);
1361
1509
  return { lines: keep.length ? keep : null };
1362
1510
  }
1511
+ // --promote moves the first line naming exactly the tool and model (and
1512
+ // effort, if given) to the top, every line exactly as written. Unlike an
1513
+ // assign, it rewrites nothing: the demoted lead keeps its own cap, prep,
1514
+ // and notes. With no model the words match only a line with no model, so
1515
+ // promoting "claude" can never move "claude, model: ..." by accident.
1516
+ if (options.promote !== undefined) {
1517
+ const words = String(options.promote).trim();
1518
+ const want = resolveRosterWords(words);
1519
+ const matches = currentWorkers
1520
+ .map((worker, index) => index)
1521
+ .filter((index) => currentWorkers[index].engine === want.engine
1522
+ && (currentWorkers[index].model || '') === (want.model || '')
1523
+ && (currentWorkers[index].effort || '') === (want.effort || ''));
1524
+ if (!matches.length) throw new Error(`${label} has no ${words} worker to move up`);
1525
+ const index = matches[0];
1526
+ const note = matches.length > 1 ? `${label} has ${matches.length} workers named ${words}; the first moved up` : '';
1527
+ return { lines: [currentLines[index], ...currentLines.filter((line, i) => i !== index)], ...(note ? { note } : {}) };
1528
+ }
1363
1529
  const likeName = String(options.like || '').trim();
1364
1530
  const likeKind = likeName ? rosterJob(likeName) : '';
1365
1531
  if (likeName && !likeKind) throw new Error(`unknown kind "${likeName}". use --like search, build, or review`);
@@ -1397,12 +1563,17 @@ function assignedWorkerLines(key, engineName, options, context) {
1397
1563
  if (effort && !engineEffortLevels(engine.id).includes(effort)) throw new Error(effortRefusal(engine.id, effort));
1398
1564
  const maxSeconds = options.max === undefined ? null : parseRosterMax(`max ${String(options.max).replace(/^max\s*:?\s*/i, '')}`);
1399
1565
  if (options.max !== undefined && !maxSeconds) throw new Error(`"${options.max}" is not a time cap; use --max "20 min", --max 90s, or --max 2h`);
1566
+ const prepWords = options.prep === undefined ? '' : String(options.prep).trim();
1567
+ const prep = prepWords ? rosterJobKey(splitJobName(prepWords).name) : '';
1568
+ if (options.prep !== undefined && !prep) throw new Error(`"${options.prep}" is not a job name; use --prep search or another job`);
1569
+ if (prep && prep === key) throw new Error(`${label} cannot prep for itself; pick another job, like --prep search`);
1400
1570
  const now = rosterDate(options.now);
1401
1571
  const line = workerLineText({
1402
1572
  engine: engine.id,
1403
1573
  model,
1404
1574
  ...(effort ? { effort } : {}),
1405
1575
  ...(maxSeconds ? { max_seconds: maxSeconds } : {}),
1576
+ ...(prep ? { prep } : {}),
1406
1577
  until: days ? rosterUntil(now, days) : '',
1407
1578
  });
1408
1579
  const heading = jobHeadingText(key, builtIn ? '' : kind);
@@ -1470,13 +1641,14 @@ function setRosterPick(jobName, engineName, options = {}, root = process.cwd())
1470
1641
  const worker = savedPick && savedPick.workers ? savedPick.workers.find((item) => item.line === entry.lineNumber) : null;
1471
1642
  return worker || { engine: rosterToolName(String(entry.value).split(',')[0]), model: '' };
1472
1643
  });
1473
- const { lines, heading } = assignedWorkerLines(key, engineName, options, {
1644
+ const { lines, heading, note } = assignedWorkerLines(key, engineName, options, {
1474
1645
  registry,
1475
1646
  savedPick,
1476
1647
  currentLines: workerEntries.map((entry) => entry.raw),
1477
1648
  currentWorkers,
1478
1649
  jobName,
1479
1650
  });
1651
+ if (note && typeof options.note === 'function') options.note(note);
1480
1652
  const next = replaceJobSection(base, {
1481
1653
  matches: (title) => rosterJobHeading(title).key === key,
1482
1654
  // Only an assign names a heading, and only when the job's kind changes;
@@ -1576,7 +1748,7 @@ const ROSTER_LAYERS = Object.freeze(['session', 'project', 'machine']);
1576
1748
  // or not ready (down, or it cannot do this kind of work).
1577
1749
  function rosterWorkerWalk(pick, role, registry, now) {
1578
1750
  const ready = registry.engines
1579
- .filter((engine) => engine.health && engine.health.status === 'ready')
1751
+ .filter((engine) => engineReadyAt(engine, now))
1580
1752
  .filter((engine) => engineHasRole(engine, role));
1581
1753
  return pickWorkers(pick).map((worker) => {
1582
1754
  if (worker.error) return { worker, skip: 'bad line', engine: null, raw: null };
@@ -1584,17 +1756,24 @@ function rosterWorkerWalk(pick, role, registry, now) {
1584
1756
  const saved = savedRosterModel(worker.engine, worker.model);
1585
1757
  if (saved === null && !worker.lenient_model) return { worker, skip: 'bad model', engine: null, raw: null };
1586
1758
  const raw = ready.find((engine) => engine.id === worker.engine) || null;
1587
- if (!raw) return { worker, skip: 'not ready', engine: null, raw: null };
1759
+ if (!raw) {
1760
+ // A worker benched after a stall says so and when it comes back.
1761
+ const benched = registry.engines.find((engine) => engine.id === worker.engine && engineCooling(engine, now));
1762
+ if (benched) return { worker, skip: 'cooling', cooling: coolingView(benched), engine: null, raw: null };
1763
+ return { worker, skip: 'not ready', engine: null, raw: null };
1764
+ }
1588
1765
  // A backup runs its own model and effort, never the lead's.
1589
1766
  const model = saved || '';
1590
1767
  const effort = savedRosterEffort(raw.id, worker.effort);
1591
1768
  const maxSeconds = Number(worker.max_seconds) > 0 ? Number(worker.max_seconds) : 0;
1592
- const engine = model || effort || maxSeconds
1769
+ const prep = worker.prep || '';
1770
+ const engine = model || effort || maxSeconds || prep
1593
1771
  ? {
1594
1772
  ...raw,
1595
1773
  ...(model ? { roster_model: model } : {}),
1596
1774
  ...(effort ? { roster_effort: effort } : {}),
1597
1775
  ...(maxSeconds ? { roster_max_seconds: maxSeconds } : {}),
1776
+ ...(prep ? { roster_prep: prep } : {}),
1598
1777
  }
1599
1778
  : raw;
1600
1779
  return { worker, skip: '', engine, raw };
@@ -1615,6 +1794,7 @@ function rosterChoiceForPick(pick, role, registry, { key, label, source, scopeTe
1615
1794
  const after = walk.slice(leadIndex + 1).filter((step) => !step.skip);
1616
1795
  const first = walk[0];
1617
1796
  const why = first.skip === 'expired' ? 'expired'
1797
+ : first.skip === 'cooling' ? `is ${first.cooling.text}`
1618
1798
  : first.skip === 'bad model' ? `names a model ${first.worker.engine} cannot run`
1619
1799
  : first.skip === 'bad line' ? 'has a bad first line'
1620
1800
  : 'is not ready';
@@ -1633,6 +1813,8 @@ function rosterChoiceForPick(pick, role, registry, { key, label, source, scopeTe
1633
1813
  };
1634
1814
  }
1635
1815
 
1816
+ const ROSTER_PIN_FIELDS = Object.freeze(['roster_model', 'roster_effort', 'roster_max_seconds', 'roster_prep']);
1817
+
1636
1818
  const ROSTER_SCOPE_TEXT = Object.freeze({ session: ' (this session)', project: '', machine: ' (all projects)' });
1637
1819
 
1638
1820
  // Walk one job's roster layers in order: this session, this project, all
@@ -1689,36 +1871,58 @@ function resolveEngineForRoleRanked(role, root = process.cwd(), options = {}) {
1689
1871
  if (!ENGINE_ROLES.includes(normalizedRole)) {
1690
1872
  throw new Error(`Unknown role "${role}". Known roles: ${ENGINE_ROLES.join(', ')}`);
1691
1873
  }
1692
- const registry = readEngineRegistry(root);
1874
+ const registry = readEngineRegistry(root, { now: options.now });
1693
1875
  const engines = registry.engines
1694
1876
  .filter((engine) => engine.roles.includes(normalizedRole))
1695
1877
  .filter((engine) => !routerSkipsRole(engine, normalizedRole))
1696
- .filter((engine) => engine.health && engine.health.status === 'ready')
1878
+ .filter((engine) => engineReadyAt(engine, options.now))
1697
1879
  .sort((a, b) => {
1698
1880
  const byOrder = Number(a.fallback_order) - Number(b.fallback_order);
1699
1881
  return byOrder || String(a.id).localeCompare(String(b.id));
1700
1882
  });
1701
- const ranked = rankEnginesDetailed(engines, {
1702
- root,
1703
- taskType: options.taskType || options.task_type || normalizedRole,
1704
- lowStakes: options.lowStakes,
1705
- stakes: options.stakes,
1706
- });
1883
+ // Ranking reads the router's history off disk (task receipts, mission
1884
+ // events, dispatch receipts). A valid roster pick decides without it, so
1885
+ // rank only when something asks for the full ranked list.
1886
+ let rankedCache = null;
1887
+ const rank = () => {
1888
+ if (!rankedCache) {
1889
+ rankedCache = routerBrain.rankEnginesDetailed(engines, {
1890
+ root,
1891
+ taskType: options.taskType || options.task_type || normalizedRole,
1892
+ lowStakes: options.lowStakes,
1893
+ stakes: options.stakes,
1894
+ });
1895
+ }
1896
+ return rankedCache;
1897
+ };
1707
1898
  const choice = rosterChoice(normalizedRole, withProjectRoster(registry, root, options), options);
1708
1899
  if (choice) {
1709
1900
  // The job's own workers go first, in order, then the router's list.
1710
1901
  const after = choice.walk.slice(choice.lead_index + 1).filter((step) => !step.skip).map((step) => step.raw);
1711
1902
  const lead = [choice.engine, ...after].filter((engine, index, list) => list.findIndex((other) => other.id === engine.id) === index);
1712
1903
  const leadIds = new Set(lead.map((engine) => engine.id));
1713
- return {
1904
+ const result = {
1714
1905
  engine: choice.engine,
1715
1906
  reason: choice.reason,
1716
1907
  source: choice.source,
1717
1908
  job: choice.job,
1718
1909
  team: choice.team,
1719
- ranked: [...lead, ...ranked.candidates.filter((candidate) => !leadIds.has(candidate.id))],
1720
1910
  };
1911
+ let rankedList = null;
1912
+ Object.defineProperty(result, 'ranked', {
1913
+ enumerable: true,
1914
+ configurable: true,
1915
+ get() {
1916
+ if (!rankedList) rankedList = [...lead, ...rank().candidates.filter((candidate) => !leadIds.has(candidate.id))];
1917
+ return rankedList;
1918
+ },
1919
+ set(value) {
1920
+ rankedList = value;
1921
+ },
1922
+ });
1923
+ return result;
1721
1924
  }
1925
+ const ranked = rank();
1722
1926
  return { engine: ranked.candidates[0] || null, reason: routerPickExplanation(ranked), source: 'router', team: [], ranked: ranked.candidates };
1723
1927
  }
1724
1928
 
@@ -1754,7 +1958,8 @@ function resolveEngineForRole(role, root = process.cwd(), options = {}) {
1754
1958
  }
1755
1959
 
1756
1960
  // Everything the roster pins on this job's live pick when that pick is this
1757
- // exact engine: roster_model, roster_effort, roster_max_seconds. {} otherwise.
1961
+ // exact engine: roster_model, roster_effort, roster_max_seconds, roster_prep.
1962
+ // {} otherwise.
1758
1963
  // A caller that already chose an engine (a mission's preferred engine,
1759
1964
  // --engine) still gets the pins.
1760
1965
  function rosterPinFor(role, engineId, root = process.cwd(), options = {}) {
@@ -1763,7 +1968,7 @@ function rosterPinFor(role, engineId, root = process.cwd(), options = {}) {
1763
1968
  const choice = rosterChoice(normalizedRole, withProjectRoster(readEngineRegistry(root, { persist: false }), root, options), options);
1764
1969
  if (!choice || choice.engine.id !== engineId) return {};
1765
1970
  const pin = {};
1766
- for (const field of ['roster_model', 'roster_effort', 'roster_max_seconds']) {
1971
+ for (const field of ROSTER_PIN_FIELDS) {
1767
1972
  if (choice.engine[field]) pin[field] = choice.engine[field];
1768
1973
  }
1769
1974
  return pin;
@@ -1779,7 +1984,7 @@ function resolveEngineForRoleWithPreference(role, root = process.cwd(), preferre
1779
1984
  };
1780
1985
  }
1781
1986
  const preferred = resolveRegisteredEngine(requested, root);
1782
- if (preferred.health && preferred.health.status === 'ready') {
1987
+ if (engineReadyAt(preferred, options.now)) {
1783
1988
  const pin = rosterPinFor(role, preferred.id, root, options);
1784
1989
  return {
1785
1990
  engine: Object.keys(pin).length ? { ...preferred, ...pin } : preferred,
@@ -1805,6 +2010,7 @@ function setEngineHealth(name, status, root = process.cwd()) {
1805
2010
  if (!ENGINE_HEALTH_STATUSES.includes(normalizedStatus)) {
1806
2011
  throw new Error(`Unknown health "${status}". Known health statuses: ${ENGINE_HEALTH_STATUSES.join(', ')}`);
1807
2012
  }
2013
+ if (normalizedStatus === 'cooling') return setEngineCooling(id, { root });
1808
2014
  const registry = readEngineRegistry(root, { persist: false });
1809
2015
  const engines = registry.engines.map((engine) => {
1810
2016
  if (engine.id !== id) return engine;
@@ -1817,6 +2023,55 @@ function setEngineHealth(name, status, root = process.cwd()) {
1817
2023
  return engines.find((engine) => engine.id === id);
1818
2024
  }
1819
2025
 
2026
+ // Bench an engine after a stall: it is not ready until the cooldown passes,
2027
+ // so every resolver hands its jobs to the roster's next worker meanwhile.
2028
+ function setEngineCooling(name, { root = process.cwd(), now, reason = 'stalled', minutes } = {}) {
2029
+ const id = canonicalEngineName(name);
2030
+ if (!id) throw new Error(`Unknown engine "${name}". Known engines: ${RUNNER_PROFILE_NAMES.join(', ')}`);
2031
+ const at = nowMs(now);
2032
+ const ms = Number(minutes) > 0 ? Number(minutes) * 60000 : engineCooldownMs();
2033
+ const registry = readEngineRegistry(root, { persist: false, now });
2034
+ const engines = registry.engines.map((engine) => {
2035
+ if (engine.id !== id) return engine;
2036
+ return {
2037
+ ...engine,
2038
+ health: {
2039
+ status: 'cooling',
2040
+ last_failure_ts: new Date(at).toISOString(),
2041
+ cooling_until: new Date(at + ms).toISOString(),
2042
+ cooling_reason: String(reason || 'stalled'),
2043
+ },
2044
+ };
2045
+ });
2046
+ writeEngineRegistry(root, { ...registry, updated_at: new Date(at).toISOString(), engines });
2047
+ return engines.find((engine) => engine.id === id);
2048
+ }
2049
+
2050
+ // "cooling until 14:32 (stalled)" for the roster and engine views.
2051
+ function coolingView(engine) {
2052
+ const health = (engine && engine.health) || {};
2053
+ const until = new Date(health.cooling_until);
2054
+ const clock = Number.isFinite(until.getTime())
2055
+ ? `${String(until.getHours()).padStart(2, '0')}:${String(until.getMinutes()).padStart(2, '0')}`
2056
+ : 'later';
2057
+ const reason = String(health.cooling_reason || 'stalled');
2058
+ return { until: health.cooling_until || null, reason, text: `cooling until ${clock} (${reason})` };
2059
+ }
2060
+
2061
+ // The one health write for a finished run: a stall benches the engine for
2062
+ // the cooldown, a credit wall or a missing binary writes its own status, and
2063
+ // a real task failure changes nothing. Returns the updated engine, or null.
2064
+ function recordEngineRunHealth(engineId, result, root = process.cwd(), { ok = false, now } = {}) {
2065
+ if (!engineId) return null;
2066
+ if (ok) return setEngineHealth(engineId, 'ready', root);
2067
+ const status = engineFailureHealthStatus({ ...result, status: 'errored' });
2068
+ if (status === 'error') {
2069
+ const stall = engineStallReason(result);
2070
+ if (stall) return setEngineCooling(engineId, { root, now, reason: stall.kind === 'transient' ? 'connection dropped' : 'stalled' });
2071
+ }
2072
+ return status ? setEngineHealth(engineId, status, root) : null;
2073
+ }
2074
+
1820
2075
  // Execution-stage guard. Routing hands out engines from policy without ever
1821
2076
  // touching the machine, so the moment we are about to spawn one is where a
1822
2077
  // missing binary has to fail loudly, in one plain sentence naming the binary.
@@ -1868,10 +2123,12 @@ module.exports = {
1868
2123
  setRosterPick,
1869
2124
  confirmRoster,
1870
2125
  normalizeRosterModel,
2126
+ isClaudeFamilyModel,
1871
2127
  rosterModelLabel,
1872
2128
  parseRosterUntil,
1873
2129
  rosterPickExpired,
1874
2130
  rosterPinFor,
2131
+ ROSTER_PIN_FIELDS,
1875
2132
  rosterMaxText,
1876
2133
  mainCheckoutRoot,
1877
2134
  ROSTER_LAYERS,
@@ -1896,6 +2153,10 @@ module.exports = {
1896
2153
  resolveEngineForRole,
1897
2154
  resolveEngineForRoleWithPreference,
1898
2155
  engineFailureHealthStatus,
2156
+ engineStallReason,
2157
+ engineReadyAt,
2158
+ coolingView,
2159
+ recordEngineRunHealth,
1899
2160
  setEngineOverrides,
1900
2161
  setEngineHealth,
1901
2162
  };