atris 3.58.8 → 3.60.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 (43) hide show
  1. package/AGENTS.md +3 -2
  2. package/atris/skills/engines/SKILL.md +28 -1
  3. package/bin/atris.js +13 -0
  4. package/commands/autopilot.js +75 -5
  5. package/commands/clean.js +17 -0
  6. package/commands/doc-health.js +11 -6
  7. package/commands/engine.js +386 -4
  8. package/commands/host.js +62 -0
  9. package/commands/member.js +57 -6
  10. package/commands/mission.js +72 -8
  11. package/commands/one-lap.js +58 -3
  12. package/commands/run.js +4 -1
  13. package/commands/scout.js +1 -1
  14. package/commands/search.js +5 -3
  15. package/commands/team.js +20 -3
  16. package/commands/unknowns.js +5 -0
  17. package/commands/verify.js +12 -4
  18. package/commands/visualize.js +20 -3
  19. package/commands/workflow.js +17 -6
  20. package/lib/dispatch-scout.js +31 -22
  21. package/lib/engine-registry.js +1482 -13
  22. package/lib/feedback-triage.js +2 -0
  23. package/lib/fleet.js +107 -11
  24. package/lib/host.js +461 -0
  25. package/lib/intents.js +15 -0
  26. package/lib/known-commands.js +1 -1
  27. package/lib/map-refs.js +44 -1
  28. package/lib/member-engine.js +253 -0
  29. package/lib/roster-markdown.js +206 -0
  30. package/lib/roster-models.js +179 -0
  31. package/lib/runner-command.js +94 -14
  32. package/lib/self-drive.js +9 -1
  33. package/lib/team-lineup.js +167 -0
  34. package/lib/wish-audit.js +10 -6
  35. package/lib/wish-delegate.js +10 -0
  36. package/package.json +1 -1
  37. package/templates/members/host/MEMBER.md +23 -0
  38. package/templates/members/host/MISSION.md +11 -0
  39. package/templates/members/host/SOUL.md +7 -0
  40. package/templates/members/host/context/why.md +3 -0
  41. package/templates/members/host/goals.json +5 -0
  42. package/templates/members/host/goals.md +3 -0
  43. package/templates/members/host/skills/host/SKILL.md +15 -0
@@ -1,15 +1,22 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
+ const os = require('os');
4
5
  const path = require('path');
5
6
  const childProcess = require('child_process');
7
+ const crypto = require('crypto');
6
8
  const {
7
9
  RUNNER_PROFILE_DEFS,
8
10
  RUNNER_PROFILE_ALIASES,
9
11
  RUNNER_PROFILE_NAMES,
10
12
  HIDDEN_PROFILE_NAMES,
13
+ EFFORT_WORDS,
14
+ engineTakesModel,
15
+ engineEffortLevels,
11
16
  } = require('./runner-command');
12
17
  const { rankEnginesDetailed, routerPickExplanation } = require('./router-brain');
18
+ // Friendly name for a saved model: claude-opus-5-5 reads as "opus 5.5".
19
+ const { modelLabel: rosterModelLabel } = require('./roster-models');
13
20
 
14
21
  const ENGINE_REGISTRY_SCHEMA = 'atris.engine_registry.v2';
15
22
  const ENGINE_TIERS = Object.freeze(['fast', 'pro', 'max']);
@@ -42,14 +49,20 @@ function engineFailureHealthStatus(result) {
42
49
 
43
50
  const ENGINE_SEED_META = Object.freeze({
44
51
  'atris-fast': Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator']), models: Object.freeze(['atris fast']), duty: 'learning', fallback_order: 10 }),
45
- codex: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['codex']), fallback_order: 10 }),
46
- claude: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), models: Object.freeze(['opus 5', 'opus 4.8', 'fable', 'haiku']), fallback_order: 20 }),
52
+ // roster_only_roles: jobs a roster pick may give this engine, but the router
53
+ // never hands it on its own. Claude and haiku can own search when picked;
54
+ // with no pick, search stays with atris-fast, then composer. Codex can own
55
+ // review when picked; it stays out of roles, so every review list built
56
+ // from roles (the router, one lap, wish audit, the chart) is unchanged.
57
+ 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 }),
47
59
  cursor: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['composer 2.5', 'grok 4.6', 'kimi 3']), fallback_order: 30 }),
48
- devin: Object.freeze({ tier: 'max', roles: Object.freeze(['executor']), models: Object.freeze(['built-in router']), duty: 'errands', fallback_order: 40 }),
49
- grok: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['grok 4.6', 'grok 4.5']), fallback_order: 45 }),
50
- fable: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), models: Object.freeze(['opus 5', 'opus 4.8', 'fable', 'haiku']), duty: 'leader', fallback_order: 50 }),
60
+ // Devin gathers context for search only when a roster picks it.
61
+ 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
+ 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 }),
51
64
  composer: Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator', 'executor']), models: Object.freeze(['composer 2.5']), fallback_order: 60 }),
52
- haiku: Object.freeze({ tier: 'fast', roles: Object.freeze(['validator']), models: Object.freeze(['haiku']), fallback_order: 70 }),
65
+ haiku: Object.freeze({ tier: 'fast', roles: Object.freeze(['validator', 'navigator']), roster_only_roles: Object.freeze(['navigator']), models: Object.freeze(['haiku']), fallback_order: 70 }),
53
66
  agy: Object.freeze({
54
67
  tier: 'pro',
55
68
  roles: Object.freeze(['executor']),
@@ -163,6 +176,7 @@ function normalizeEngineEntry(id, saved = {}) {
163
176
  const health = { status };
164
177
  if (status !== 'ready' && savedHealth.last_failure_ts) health.last_failure_ts = String(savedHealth.last_failure_ts);
165
178
  return {
179
+ ...saved,
166
180
  id,
167
181
  name: id,
168
182
  bin: def.bin,
@@ -185,6 +199,7 @@ function seededRegistry(root = process.cwd(), preloadedRaw = null) {
185
199
  if (id) savedById.set(id, entry);
186
200
  }
187
201
  return {
202
+ ...raw,
188
203
  schema: ENGINE_REGISTRY_SCHEMA,
189
204
  updated_at: new Date().toISOString(),
190
205
  // Hidden profiles are registered engines too: they seed, route, and take
@@ -275,6 +290,1264 @@ function readEngineRegistry(root = process.cwd(), options = {}) {
275
290
  return registry;
276
291
  }
277
292
 
293
+ const ENGINE_JOBS = Object.freeze({ search: 'navigator', build: 'executor', review: 'validator' });
294
+ const ENGINE_JOB_ALIASES = Object.freeze({ navigator: 'search', builder: 'build', executor: 'build', reviewer: 'review', validator: 'review' });
295
+ const MACHINE_ROSTER_SCHEMA = 'atris.machine_roster.v1';
296
+ // The owner's own job for small, tightly specified builds. A build resolved
297
+ // with lowStakes checks this pick first, then the plain build pick.
298
+ const SMALL_BUILD_JOB = 'small-build';
299
+
300
+ function rosterJob(name) {
301
+ const job = String(name || '').trim().toLowerCase();
302
+ return ENGINE_JOBS[job] ? job : ENGINE_JOB_ALIASES[job] || '';
303
+ }
304
+
305
+ // "Small Build!" and "small build" save under the same key, small-build.
306
+ function rosterJobSlug(name) {
307
+ return String(name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
308
+ }
309
+
310
+ // The roster key for any job name: the three built-in jobs keep their role
311
+ // keys (navigator, executor, validator), so every saved roster still reads
312
+ // the same; any other name is a custom job under its slug. A built-in job
313
+ // only answers to its exact name or alias: "builder!" would slug onto build
314
+ // and quietly replace the real build pick, so it gets no key at all.
315
+ function rosterJobKey(name) {
316
+ const builtIn = rosterJob(name);
317
+ if (builtIn) return ENGINE_JOBS[builtIn];
318
+ const slug = rosterJobSlug(name);
319
+ if (!slug || rosterJob(slug)) return '';
320
+ return slug;
321
+ }
322
+
323
+ // The plain refusal for a job name that has no key.
324
+ function rosterJobNameError(name) {
325
+ const near = rosterJob(rosterJobSlug(name));
326
+ if (near) return `"${String(name || '').trim()}" is too close to the built-in ${near} job. type ${near} to change that job, or pick another name`;
327
+ return 'name the job, for example: build, review, search, or "small build"';
328
+ }
329
+
330
+ function rosterJobLabel(key) {
331
+ const builtIn = Object.keys(ENGINE_JOBS).find((job) => ENGINE_JOBS[job] === key);
332
+ return builtIn || String(key || '').replace(/-/g, ' ');
333
+ }
334
+
335
+ // A name says its kind when exactly one of build, review, or search is a
336
+ // word in it: "small build" is a build, "deep review" is a review.
337
+ function inferJobKind(name) {
338
+ const words = rosterJobSlug(name).split('-');
339
+ const kinds = Object.keys(ENGINE_JOBS).filter((kind) => words.includes(kind));
340
+ return kinds.length === 1 ? kinds[0] : '';
341
+ }
342
+
343
+ // The role a saved custom pick runs as, read from its like field. '' means
344
+ // the pick does not say a kind the roster knows, so it never routes.
345
+ function customPickRole(pick) {
346
+ const kind = rosterJob(pick && pick.like);
347
+ return kind ? ENGINE_JOBS[kind] : '';
348
+ }
349
+
350
+ // Only an object naming an engine counts as a pick; a hand edit that left a
351
+ // string or a list reads as no pick.
352
+ function rosterPickObject(value) {
353
+ return value && typeof value === 'object' && !Array.isArray(value) && value.engine ? value : null;
354
+ }
355
+
356
+ function pickRoleForKey(key, pick) {
357
+ return ENGINE_ROLES.includes(key) ? key : customPickRole(pick);
358
+ }
359
+
360
+ // Custom job keys set in a roster, in saved order.
361
+ function customRosterJobKeys(roster) {
362
+ return Object.keys(roster || {}).filter((key) => !ENGINE_ROLES.includes(key) && rosterPickObject(roster[key]));
363
+ }
364
+
365
+ // A pick that can decide right now: it names a kind, has not expired, names
366
+ // an engine and model that can run, and that engine (or its backup) is ready.
367
+ // A pick that cannot run never sets the job's kind, so it cannot block a
368
+ // ready all-projects pick of another kind.
369
+ function rosterPickLive(pick, now, registry) {
370
+ const role = customPickRole(pick);
371
+ if (!role || !canonicalEngineName(pick.engine)) return false;
372
+ return Boolean(rosterChoiceForPick(pick, role, registry, { label: rosterJobLabel(role), now }));
373
+ }
374
+
375
+ // The role a job name resolves as: a built-in job's own role, else the kind
376
+ // of the first live pick in line (this project, then all projects), then the
377
+ // kind the name says, then any saved kind. An expired or broken project pick
378
+ // never decides the kind, so it cannot hide a live all-projects pick.
379
+ function rosterJobRole(name, root = process.cwd(), options = {}) {
380
+ const key = rosterJobKey(name);
381
+ if (!key) return '';
382
+ if (ENGINE_ROLES.includes(key)) return key;
383
+ const session = options.sessionRosterPicks || readSessionRosterLayer({ ...options, root }).picks;
384
+ const project = options.projectRosterPicks || readProjectRoster(root, options).picks;
385
+ const machine = options.machineRosterPicks || readMachineRoster(options);
386
+ const picks = [session && session[key], project && project[key], machine && machine[key]].map(rosterPickObject).filter(Boolean);
387
+ const registry = options.registry || readEngineRegistry(root, { persist: false });
388
+ const live = picks.find((pick) => rosterPickLive(pick, options.now, registry));
389
+ if (live) return customPickRole(live);
390
+ const kind = inferJobKind(name);
391
+ if (kind) return ENGINE_JOBS[kind];
392
+ return picks.map(customPickRole).find(Boolean) || '';
393
+ }
394
+
395
+ function rosterDate(now = new Date()) {
396
+ const date = new Date(now);
397
+ if (Number.isNaN(date.getTime())) throw new Error('invalid roster date');
398
+ return date;
399
+ }
400
+
401
+ function localDay(date) {
402
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
403
+ }
404
+
405
+ function localDayText(date) {
406
+ const pad = (value) => String(value).padStart(2, '0');
407
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
408
+ }
409
+
410
+ // until dates are local calendar days, inclusive: a pick set until
411
+ // 2026-10-24 still holds all day on the 24th, wherever this machine is.
412
+ function rosterUntil(now, days) {
413
+ const date = localDay(rosterDate(now));
414
+ date.setDate(date.getDate() + days);
415
+ return localDayText(date);
416
+ }
417
+
418
+ // Only a real YYYY-MM-DD calendar day counts. Anything else ("garbage",
419
+ // "2026-9-1", "9999", "2026-02-30") reads as already expired.
420
+ function parseRosterUntil(value) {
421
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || '').trim());
422
+ if (!match) return null;
423
+ const [year, month, day] = match.slice(1).map(Number);
424
+ const date = new Date(year, month - 1, day);
425
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null;
426
+ return date;
427
+ }
428
+
429
+ // A ROSTER.md line with no until never expires; a saved JSON pick always
430
+ // carries its date.
431
+ function rosterPickExpired(pick, now = new Date()) {
432
+ if (pick && pick.never_expires === true && !pick.until) return false;
433
+ const until = parseRosterUntil(pick && pick.until);
434
+ if (!until) return true;
435
+ return localDay(rosterDate(now)).getTime() > until.getTime();
436
+ }
437
+
438
+ // Claude-family engines run through the claude CLI, which rejects loose
439
+ // spellings like "opus-5.5". Assign saves the id the CLI accepts, and every
440
+ // read normalizes again, so older saved picks and hand edits work too.
441
+ const CLAUDE_FAMILY_ENGINES = Object.freeze(['claude', 'fable', 'haiku']);
442
+ const CLAUDE_MODEL_ALIASES = Object.freeze(['opus', 'sonnet', 'haiku', 'fable', 'opusplan', 'default']);
443
+ const CLAUDE_MODEL_DATED_IDS = Object.freeze({ 'claude-haiku-4-5': 'claude-haiku-4-5-20251001' });
444
+ const CLAUDE_MODEL_SPELLING = /^(opus|sonnet|haiku|fable)[\s_-]*(\d+)(?:[\s._-]+(\d+))?$/;
445
+ const CLAUDE_LONG_CONTEXT = /\s*\[1m\]$/;
446
+
447
+ // Grok takes friendly names too: "grok 4.7 fast" is grok-4.7-build-fast and
448
+ // "grok 4.7" is grok-4.7. Any full grok- id passes through as typed.
449
+ const GROK_MODEL_SPELLING = /^grok[\s_-]*(\d+(?:\.\d+)?)(?:[\s_-]+(?:build[\s_-]+)?(fast))?$/;
450
+
451
+ function normalizeGrokModel(raw) {
452
+ const match = GROK_MODEL_SPELLING.exec(raw.toLowerCase());
453
+ if (match) return `grok-${match[1]}${match[2] ? '-build-fast' : ''}`;
454
+ if (/^grok-[a-z0-9][a-z0-9._-]*$/i.test(raw)) return raw;
455
+ throw new Error(`grok does not know the model "${raw}". use grok 4.7 fast, grok 4.7, or a full grok- id`);
456
+ }
457
+
458
+ function normalizeRosterModel(engineId, value) {
459
+ const raw = String(value || '').trim();
460
+ if (!raw) return '';
461
+ if (engineId === 'grok') return normalizeGrokModel(raw);
462
+ if (!CLAUDE_FAMILY_ENGINES.includes(engineId)) return raw;
463
+ // "[1m]" asks the claude cli for the long context window; keep it on
464
+ // whatever name it follows.
465
+ const suffix = CLAUDE_LONG_CONTEXT.test(raw) ? '[1m]' : '';
466
+ const base = raw.replace(CLAUDE_LONG_CONTEXT, '').trim();
467
+ const lower = base.toLowerCase();
468
+ if (CLAUDE_MODEL_ALIASES.includes(lower)) return `${lower}${suffix}`;
469
+ const match = CLAUDE_MODEL_SPELLING.exec(lower.replace(/^claude[\s_-]*/, ''));
470
+ if (match) {
471
+ const id = `claude-${match[1]}-${match[2]}${match[3] ? `-${match[3]}` : ''}`;
472
+ return `${CLAUDE_MODEL_DATED_IDS[id] || id}${suffix}`;
473
+ }
474
+ if (lower.startsWith('claude-')) return `${base}${suffix}`;
475
+ 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`);
476
+ }
477
+
478
+ // A saved pick's model, normalized the same way assign does. null means the
479
+ // saved name is one the claude cli would reject, so the pick cannot run.
480
+ function savedRosterModel(engineId, value) {
481
+ try {
482
+ return normalizeRosterModel(engineId, value);
483
+ } catch {
484
+ return null;
485
+ }
486
+ }
487
+
488
+
489
+ // A registry saved before an engine learned a job keeps its old role list;
490
+ // the roster still honors the job the engine can do today, including a job
491
+ // it only takes by roster pick.
492
+ function engineHasRole(engine, role) {
493
+ if (!engine) return false;
494
+ if (Array.isArray(engine.roles) && engine.roles.includes(role)) return true;
495
+ const seed = ENGINE_SEED_META[engine.id];
496
+ return Boolean(seed && (seed.roles.includes(role) || (seed.roster_only_roles && seed.roster_only_roles.includes(role))));
497
+ }
498
+
499
+ // A job this engine only takes by roster pick. Read from the seed, so a
500
+ // registry saved with the role behaves the same as a fresh one.
501
+ function routerSkipsRole(engine, role) {
502
+ const seed = engine && ENGINE_SEED_META[engine.id];
503
+ return Boolean(seed && seed.roster_only_roles && seed.roster_only_roles.includes(role));
504
+ }
505
+
506
+ function machineRosterFile(options = {}) {
507
+ return options.machineRosterFile
508
+ || process.env.ATRIS_MACHINE_ROSTER_PATH
509
+ || path.join(os.homedir(), '.atris', 'roster.json');
510
+ }
511
+
512
+ // The all-projects ROSTER.md. A scratch roster.json path (tests, sandboxes)
513
+ // keeps its markdown beside it, so pointing one override away from the home
514
+ // folder moves both files.
515
+ function machineRosterMarkdownFile(options = {}) {
516
+ if (options.machineRosterMarkdownFile) return options.machineRosterMarkdownFile;
517
+ if (process.env.ATRIS_MACHINE_ROSTER_MD_PATH) return process.env.ATRIS_MACHINE_ROSTER_MD_PATH;
518
+ if (options.machineRosterFile || process.env.ATRIS_MACHINE_ROSTER_PATH) {
519
+ return path.join(path.dirname(machineRosterFile(options)), 'ROSTER.md');
520
+ }
521
+ return path.join(os.homedir(), '.atris', 'ROSTER.md');
522
+ }
523
+
524
+ function projectRosterFile(root = process.cwd()) {
525
+ return path.join(root, 'atris', 'ROSTER.md');
526
+ }
527
+
528
+ // A git worktree of this project shares its roster: when the worktree has no
529
+ // atris/ROSTER.md of its own (an uncommitted file never reaches it), the main
530
+ // checkout's file applies. Read from the .git pointer file; no git process.
531
+ function projectRosterRoot(root = process.cwd()) {
532
+ if (fs.existsSync(projectRosterFile(root))) return root;
533
+ const main = mainCheckoutRoot(root);
534
+ return main && fs.existsSync(projectRosterFile(main)) ? main : root;
535
+ }
536
+
537
+ // The main checkout behind a git worktree, read from the .git pointer file
538
+ // with no git process. '' when root is not a linked worktree.
539
+ function mainCheckoutRoot(root = process.cwd()) {
540
+ try {
541
+ const pointer = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(path.join(root, '.git'), 'utf8'));
542
+ if (!pointer) return '';
543
+ const gitdir = path.resolve(root, pointer[1].trim());
544
+ if (path.basename(path.dirname(gitdir)) !== 'worktrees') return '';
545
+ return path.dirname(path.dirname(path.dirname(gitdir)));
546
+ } catch {
547
+ return '';
548
+ }
549
+ }
550
+
551
+ // The machine roster is personal policy from the home folder. Test runs never
552
+ // read it unless they point at a file on purpose, so one person's picks
553
+ // cannot change what the suite routes to.
554
+ function machineRosterEnabled(options = {}) {
555
+ if (options.machineRoster === false) return false;
556
+ if (options.machineRosterFile || options.machineRosterMarkdownFile) return true;
557
+ if (process.env.ATRIS_MACHINE_ROSTER_PATH || process.env.ATRIS_MACHINE_ROSTER_MD_PATH) return true;
558
+ return !process.env.NODE_TEST_CONTEXT;
559
+ }
560
+
561
+ function readMachineRosterJson(options = {}) {
562
+ if (!machineRosterEnabled(options)) return {};
563
+ try {
564
+ const parsed = JSON.parse(fs.readFileSync(machineRosterFile(options), 'utf8'));
565
+ return parsed && parsed.roster && typeof parsed.roster === 'object' ? parsed.roster : {};
566
+ } catch {
567
+ return {};
568
+ }
569
+ }
570
+
571
+ // The all-projects picks: ROSTER.md when it exists, else the older JSON file.
572
+ function readMachineRoster(options = {}) {
573
+ return readMachineRosterLayer(options).picks;
574
+ }
575
+
576
+ function writeAtomic(file, body) {
577
+ fs.mkdirSync(path.dirname(file), { recursive: true });
578
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
579
+ fs.writeFileSync(tmp, body, 'utf8');
580
+ fs.renameSync(tmp, file);
581
+ }
582
+
583
+ function writeMachineRoster(roster, options = {}) {
584
+ if (!machineRosterEnabled(options)) throw new Error('the all-projects roster is off in test runs; set ATRIS_MACHINE_ROSTER_PATH to a scratch file');
585
+ const body = { schema: MACHINE_ROSTER_SCHEMA, updated_at: new Date().toISOString(), roster };
586
+ writeAtomic(machineRosterFile(options), `${JSON.stringify(body, null, 2)}\n`);
587
+ }
588
+
589
+ // --- ROSTER.md: words to picks ------------------------------------------
590
+
591
+ const MONTH_NAMES = Object.freeze(['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december']);
592
+
593
+ // Month and day words: "oct 24", "october 24 2026", "24 oct 2026".
594
+ function readRosterDateWords(text) {
595
+ const raw = String(text || '').trim().toLowerCase().replace(/,/g, ' ').replace(/\s+/g, ' ');
596
+ const monthFirst = /^([a-z]+)\.?\s+(\d{1,2})(?:st|nd|rd|th)?(?:\s+(\d{4}))?$/.exec(raw);
597
+ const dayFirst = /^(\d{1,2})(?:st|nd|rd|th)?\s+([a-z]+)\.?(?:\s+(\d{4}))?$/.exec(raw);
598
+ const monthWord = monthFirst ? monthFirst[1] : dayFirst ? dayFirst[2] : '';
599
+ const day = Number(monthFirst ? monthFirst[2] : dayFirst ? dayFirst[1] : NaN);
600
+ const yearText = monthFirst ? monthFirst[3] : dayFirst ? dayFirst[3] : '';
601
+ if (!monthWord || monthWord.length < 3) return null;
602
+ const month = MONTH_NAMES.findIndex((name) => name.startsWith(monthWord) || (monthWord === 'sept' && name === 'september'));
603
+ if (month === -1) return null;
604
+ const build = (year) => {
605
+ const date = new Date(year, month, day);
606
+ return date.getMonth() === month && date.getDate() === day ? date : null;
607
+ };
608
+ return { month, day, year: yearText ? Number(yearText) : null, build };
609
+ }
610
+
611
+ // "2026-10-24", "oct 24 2026", "october 24, 2026", or "24 oct 2026". The year
612
+ // is required: a year-less date would have to guess its year from when the
613
+ // file was saved, and an unrelated edit would then revive a passed pick.
614
+ // null means the words are not a full date.
615
+ function parseRosterUntilWords(text) {
616
+ const raw = String(text || '').trim();
617
+ if (!raw) return null;
618
+ const iso = parseRosterUntil(raw);
619
+ if (iso) return localDayText(iso);
620
+ const words = readRosterDateWords(raw);
621
+ if (!words || words.year === null) return null;
622
+ const date = words.build(words.year);
623
+ return date ? localDayText(date) : null;
624
+ }
625
+
626
+ // The full date a year-less "oct 24" most likely meant (the nearest one not
627
+ // in the past), offered in the warning. null when the words are no date.
628
+ function yearlessRosterDate(text, now = new Date()) {
629
+ const words = readRosterDateWords(text);
630
+ if (!words || words.year !== null) return null;
631
+ const today = localDay(rosterDate(now));
632
+ for (const year of [today.getFullYear(), today.getFullYear() + 1, today.getFullYear() + 4]) {
633
+ const date = words.build(year);
634
+ if (date && date.getTime() >= today.getTime()) return localDayText(date);
635
+ }
636
+ return null;
637
+ }
638
+
639
+ // "max 20 min", "max 90s", "max 2h": the time cap for one line, in seconds.
640
+ // null means the words are not a time cap.
641
+ const MAX_UNITS = Object.freeze({
642
+ s: 1, sec: 1, secs: 1, second: 1, seconds: 1,
643
+ m: 60, min: 60, mins: 60, minute: 60, minutes: 60,
644
+ h: 3600, hr: 3600, hrs: 3600, hour: 3600, hours: 3600,
645
+ });
646
+
647
+ function parseRosterMax(text) {
648
+ const match = /^max\s+(\d+(?:\.\d+)?)\s*([a-z]+)$/i.exec(String(text || '').trim());
649
+ if (!match) return null;
650
+ const unit = MAX_UNITS[match[2].toLowerCase()];
651
+ const seconds = unit ? Math.round(Number(match[1]) * unit) : 0;
652
+ return seconds > 0 ? seconds : null;
653
+ }
654
+
655
+ function rosterMaxText(seconds) {
656
+ const value = Number(seconds);
657
+ if (!Number.isFinite(value) || value <= 0) return '';
658
+ if (value % 3600 === 0) return `max ${value / 3600}h`;
659
+ if (value % 60 === 0) return `max ${value / 60} min`;
660
+ return `max ${value}s`;
661
+ }
662
+
663
+ // Why an engine cannot take an effort word, in one plain sentence.
664
+ function effortRefusal(engineId, effort) {
665
+ const levels = engineEffortLevels(engineId);
666
+ if (!levels.length) return `${engineId} cannot take an effort level`;
667
+ return `${engineId} takes effort ${levels.join(', ')}, not "${effort}"`;
668
+ }
669
+
670
+ // A saved effort word this engine takes, '' for none, null when it cannot.
671
+ function savedRosterEffort(engineId, value) {
672
+ const effort = String(value || '').trim().toLowerCase();
673
+ if (!effort) return '';
674
+ return engineEffortLevels(engineId).includes(effort) ? effort : null;
675
+ }
676
+
677
+ // Words that name an engine, a model, or both, then an optional effort word,
678
+ // turned into one engine, one model, and one effort the engine takes.
679
+ // "devin swe-2-max", "claude haiku", "opus 5.5", "codex gpt-6-astra medium".
680
+ // Throws a plain sentence when the words name nothing or the engine cannot
681
+ // take the model or effort.
682
+ function resolveRosterWords(text) {
683
+ const all = String(text || '').trim().replace(/\s+/g, ' ').split(' ').filter(Boolean);
684
+ const last = all.length > 1 ? all[all.length - 1].toLowerCase() : '';
685
+ const effort = EFFORT_WORDS.includes(last) ? last : '';
686
+ const read = resolveEngineModelWords((effort ? all.slice(0, -1) : all).join(' '));
687
+ if (read.model && !engineTakesModel(read.engine)) throw new Error(`${read.engine} runs one fixed model and cannot take "${read.model}"`);
688
+ if (effort && !engineEffortLevels(read.engine).includes(effort)) throw new Error(effortRefusal(read.engine, effort));
689
+ return { ...read, effort };
690
+ }
691
+
692
+ // Friendly tool names a roster line may use in place of an engine id.
693
+ const ROSTER_TOOL_NAMES = Object.freeze({
694
+ 'claude code': 'claude',
695
+ ccs: 'claude',
696
+ 'atris fast': 'atris-fast',
697
+ gemini: 'agy',
698
+ antigravity: 'agy',
699
+ });
700
+ const ROSTER_TOOL_WORDS = /^(claude code|ccs|atris fast|gemini|antigravity)(?:\s+(.*))?$/i;
701
+
702
+ // The engine id for a tool name a person typed: an engine id, an alias, or a
703
+ // friendly name like "claude code". '' when it names no engine.
704
+ function rosterToolName(name) {
705
+ const lower = String(name || '').trim().replace(/\s+/g, ' ').toLowerCase();
706
+ return ROSTER_TOOL_NAMES[lower] || canonicalEngineName(lower);
707
+ }
708
+
709
+ // How a roster line names an engine: "claude code" for claude, "atris fast"
710
+ // for atris-fast, the id for the rest.
711
+ function rosterToolLabel(engineId) {
712
+ if (engineId === 'claude') return 'claude code';
713
+ if (engineId === 'atris-fast') return 'atris fast';
714
+ return engineId;
715
+ }
716
+
717
+ function resolveEngineModelWords(text) {
718
+ const clean = String(text || '').trim().replace(/\s+/g, ' ');
719
+ if (!clean) throw new Error('names no engine');
720
+ const lower = clean.toLowerCase();
721
+ const words = clean.split(' ');
722
+ const friendly = ROSTER_TOOL_WORDS.exec(clean);
723
+ if (friendly) {
724
+ const engine = ROSTER_TOOL_NAMES[friendly[1].toLowerCase()];
725
+ const rest = String(friendly[2] || '').trim();
726
+ if (!rest) return { engine, model: '' };
727
+ try {
728
+ return { engine, model: normalizeRosterModel(engine, rest) };
729
+ } catch {
730
+ throw new Error(`${engine} cannot take the model "${rest}"`);
731
+ }
732
+ }
733
+ const first = canonicalEngineName(words[0].toLowerCase());
734
+ const rest = words.slice(1).join(' ');
735
+ const model = (engine, value) => {
736
+ try {
737
+ return normalizeRosterModel(engine, value);
738
+ } catch {
739
+ throw new Error(`${engine} cannot take the model "${value}"`);
740
+ }
741
+ };
742
+ if (first && !rest) return { engine: first, model: '' };
743
+ const bare = lower.replace(CLAUDE_LONG_CONTEXT, '').replace(/^claude[\s_-]*/, '');
744
+ if (CLAUDE_MODEL_SPELLING.test(bare) || lower.startsWith('claude-')) return { engine: 'claude', model: model('claude', clean) };
745
+ if (GROK_MODEL_SPELLING.test(lower) || lower.startsWith('grok-')) return { engine: 'grok', model: model('grok', clean) };
746
+ if (first) {
747
+ const value = first === 'grok' && /^\d/.test(rest) ? `grok ${rest}` : rest;
748
+ return { engine: first, model: model(first, value) };
749
+ }
750
+ if (/^(opus|sonnet|haiku|fable|opusplan)\b/.test(lower)) return { engine: 'claude', model: model('claude', clean) };
751
+ if (/^grok/.test(lower)) return { engine: 'grok', model: model('grok', clean) };
752
+ if (/^swe-/.test(lower)) return { engine: 'devin', model: clean };
753
+ if (/^gemini-/.test(lower)) return { engine: 'agy', model: clean };
754
+ throw new Error(`"${clean}" is not an engine or a model atris knows`);
755
+ }
756
+
757
+ // What an engine can do by its seed, used to check hand-written lines before
758
+ // any registry is read.
759
+ function engineCanTake(engineId, role) {
760
+ const seed = ENGINE_SEED_META[engineId];
761
+ return Boolean(seed && engineHasRole({ id: engineId, roles: Array.from(seed.roles) }, role));
762
+ }
763
+
764
+ // One line value, "<words>[, backup <words>][, until <date>]", checked
765
+ // against the kind of job it fills. Throws a plain sentence on anything wrong.
766
+ function rosterPickFromValue(value, role, options = {}) {
767
+ const kind = Object.keys(ENGINE_JOBS).find((name) => ENGINE_JOBS[name] === role) || role;
768
+ const parts = String(value || '').split(',').map((part) => part.trim()).filter(Boolean);
769
+ // "until oct 24, 2026": a lone year belongs to the part before it.
770
+ for (let i = parts.length - 1; i > 0; i -= 1) {
771
+ if (/^\d{4}$/.test(parts[i])) parts.splice(i - 1, 2, `${parts[i - 1]} ${parts[i]}`);
772
+ }
773
+ if (!parts.length) throw new Error('names no engine');
774
+ let [main, ...extras] = parts;
775
+ if (/^(backup|until|max)\b/i.test(main)) throw new Error('names no engine before its backup, until, or max');
776
+ // "codex medium max 20 min" with no comma still reads the cap.
777
+ const inlineMax = /\s+(max\s+\d+(?:\.\d+)?\s*[a-z]+)$/i.exec(main);
778
+ if (inlineMax) {
779
+ main = main.slice(0, inlineMax.index);
780
+ extras = [inlineMax[1], ...extras];
781
+ }
782
+ const primary = resolveRosterWords(main);
783
+ if (!engineCanTake(primary.engine, role)) throw new Error(`${primary.engine} cannot do ${kind} work`);
784
+ let backup = null;
785
+ let until = '';
786
+ let untilYearless = '';
787
+ let maxSeconds = null;
788
+ let namedModel = false;
789
+ let namedEffort = false;
790
+ for (const raw of extras) {
791
+ // "max: 20 min" and "until: 2026-10-24" read like "max 20 min".
792
+ const part = raw.replace(/^(max|until)\s*:\s*/i, '$1 ');
793
+ const backupMatch = /^backup\s+(.+)$/i.exec(part);
794
+ const untilMatch = /^until\s+(.+)$/i.exec(part);
795
+ const maxMatch = /^max\b/i.test(part) && !/^max$/i.test(part);
796
+ const modelMatch = /^model\s*(?::\s*|\s+)(.+)$/i.exec(part);
797
+ const effortMatch = /^effort\s*(?::\s*|\s+)(.+)$/i.exec(part);
798
+ if (modelMatch && !namedModel) {
799
+ if (primary.model) throw new Error(`names two models for ${primary.engine}`);
800
+ const words = modelMatch[1].trim();
801
+ if (!engineTakesModel(primary.engine)) throw new Error(`${primary.engine} runs one fixed model and cannot take "${words}"`);
802
+ try {
803
+ primary.model = normalizeRosterModel(primary.engine, words);
804
+ } catch {
805
+ throw new Error(`${primary.engine} cannot take the model "${words}"`);
806
+ }
807
+ namedModel = true;
808
+ } else if (effortMatch && !namedEffort) {
809
+ if (primary.effort) throw new Error(`names two efforts for ${primary.engine}`);
810
+ const effort = effortMatch[1].trim().toLowerCase();
811
+ if (!EFFORT_WORDS.includes(effort) || !engineEffortLevels(primary.engine).includes(effort)) throw new Error(effortRefusal(primary.engine, effort));
812
+ primary.effort = effort;
813
+ namedEffort = true;
814
+ } else if (backupMatch && options.worker) {
815
+ throw new Error('a worker line takes no backup; put the backup on the next line');
816
+ } else if (backupMatch && !backup) {
817
+ backup = resolveRosterWords(backupMatch[1]);
818
+ if (!engineCanTake(backup.engine, role)) throw new Error(`the backup ${backup.engine} cannot do ${kind} work`);
819
+ } else if (untilMatch && !until) {
820
+ const words = untilMatch[1].trim();
821
+ until = parseRosterUntilWords(words);
822
+ if (!until) {
823
+ untilYearless = yearlessRosterDate(words, options.now);
824
+ if (!untilYearless) throw new Error(`"${words}" is not a date; use 2026-10-24 or oct 24 2026`);
825
+ // Kept as typed, so the pick reads as expired until the year is added.
826
+ until = words;
827
+ }
828
+ } else if (maxMatch && maxSeconds === null) {
829
+ maxSeconds = parseRosterMax(part);
830
+ if (!maxSeconds) throw new Error(`"${part}" is not a time cap; use max 20 min, max 90s, or max 2h`);
831
+ } else {
832
+ throw new Error(`does not understand "${part}"`);
833
+ }
834
+ }
835
+ return {
836
+ engine: primary.engine,
837
+ model: primary.model,
838
+ ...(primary.effort ? { effort: primary.effort } : {}),
839
+ backup: backup ? backup.engine : '',
840
+ ...(backup && backup.model ? { backup_model: backup.model } : {}),
841
+ ...(backup && backup.effort ? { backup_effort: backup.effort } : {}),
842
+ ...(maxSeconds ? { max_seconds: maxSeconds } : {}),
843
+ until,
844
+ ...(until ? {} : { never_expires: true }),
845
+ ...(untilYearless ? { until_needs_year: untilYearless } : {}),
846
+ };
847
+ }
848
+
849
+ // A job line's name part, "small build (like build)", split into the name
850
+ // and the kind it says it is like.
851
+ function splitJobName(text) {
852
+ const match = /^(.*?)\s*\(\s*like\s+([a-z]+)\s*\)\s*$/i.exec(String(text || '').trim());
853
+ return match ? { name: match[1].trim(), like: match[2].trim().toLowerCase() } : { name: String(text || '').trim(), like: '' };
854
+ }
855
+
856
+ function rosterFileLabel(file, root = process.cwd()) {
857
+ const abs = path.resolve(file);
858
+ const rel = path.relative(path.resolve(root), abs);
859
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) return rel;
860
+ const home = os.homedir();
861
+ const fromHome = path.relative(home, abs);
862
+ if (fromHome && !fromHome.startsWith('..') && !path.isAbsolute(fromHome)) return `~/${fromHome}`;
863
+ return abs;
864
+ }
865
+
866
+ // One worker line, "<tool>[, model: <m>][, effort: <e>][, max: <cap>][,
867
+ // until <date>]", checked against the kind of job it serves. Throws a plain
868
+ // sentence on anything wrong.
869
+ function rosterWorkerFromValue(value, role, options = {}) {
870
+ const pick = rosterPickFromValue(value, role, { ...options, worker: true });
871
+ return {
872
+ engine: pick.engine,
873
+ model: pick.model,
874
+ ...(pick.effort ? { effort: pick.effort } : {}),
875
+ ...(pick.max_seconds ? { max_seconds: pick.max_seconds } : {}),
876
+ until: pick.until,
877
+ ...(pick.never_expires ? { never_expires: true } : {}),
878
+ ...(pick.until_needs_year ? { until_needs_year: pick.until_needs_year } : {}),
879
+ };
880
+ }
881
+
882
+ // A job heading, "## small build (like build)", read as a job: its key, its
883
+ // label, and the kind of work it is. error says why it is not one.
884
+ function rosterJobHeading(title) {
885
+ const { name, like } = splitJobName(title);
886
+ const key = rosterJobKey(name);
887
+ if (!key) return { error: rosterJobNameError(name) };
888
+ const builtIn = ENGINE_ROLES.includes(key);
889
+ const label = rosterJobLabel(key);
890
+ const likeKind = like ? rosterJob(like) : '';
891
+ if (like && !likeKind) return { key, label, error: `"like ${like}" is not search, build, or review` };
892
+ if (builtIn && likeKind && ENGINE_JOBS[likeKind] !== key) return { key, label, error: `${label} is a built-in job and cannot be like ${likeKind}` };
893
+ const kind = builtIn ? label : likeKind || inferJobKind(name);
894
+ if (!kind) return { key, label, error: `say what kind of job "${label}" is, for example "## ${label} (like build)"` };
895
+ return { key, label, kind, builtIn };
896
+ }
897
+
898
+ // Does this worker line start with a tool name? Only then does a line under
899
+ // a heading that is not a job deserve a warning; "- remember to renew" under
900
+ // "## notes" is just a note.
901
+ function namesATool(value) {
902
+ const first = String(value || '').split(',')[0].trim();
903
+ if (rosterToolName(first)) return true;
904
+ try {
905
+ resolveEngineModelWords(first);
906
+ return true;
907
+ } catch {
908
+ return false;
909
+ }
910
+ }
911
+
912
+ // A job's workers in order, whichever shape wrote them. A one-line pick reads
913
+ // as its engine, then its backup: the backup never expires and runs even with
914
+ // a model it cannot use, the way a one-line backup always has.
915
+ function pickWorkers(pick) {
916
+ if (!pick) return [];
917
+ if (Array.isArray(pick.workers)) return pick.workers;
918
+ const cap = Number(pick.max_seconds) > 0 ? { max_seconds: Number(pick.max_seconds) } : {};
919
+ const list = [{
920
+ engine: pick.engine,
921
+ model: pick.model || '',
922
+ ...(pick.effort ? { effort: pick.effort } : {}),
923
+ ...cap,
924
+ until: pick.until,
925
+ ...(pick.never_expires ? { never_expires: true } : {}),
926
+ ...(pick.line ? { line: pick.line } : {}),
927
+ }];
928
+ if (pick.backup) {
929
+ list.push({
930
+ engine: pick.backup,
931
+ model: pick.backup_model || '',
932
+ ...(pick.backup_effort ? { effort: pick.backup_effort } : {}),
933
+ ...cap,
934
+ until: '',
935
+ never_expires: true,
936
+ lenient_model: true,
937
+ backup: true,
938
+ ...(pick.line ? { line: pick.line } : {}),
939
+ });
940
+ }
941
+ return list;
942
+ }
943
+
944
+ // Read one ROSTER.md into picks keyed like the JSON roster, the raw team
945
+ // lines, and one warning per line that could not be used. Never throws on
946
+ // what the file says. A sectioned job's pick carries its workers in order;
947
+ // engine, model, and backup mirror its first two good workers.
948
+ function parseRosterMarkdown(text, file, options = {}) {
949
+ const { scanRosterMarkdown, jobSections } = require('./roster-markdown');
950
+ const display = rosterFileLabel(file, options.root || process.cwd());
951
+ const picks = {};
952
+ const team = [];
953
+ const warnings = [];
954
+ const warn = (entry, message) => warnings.push({ file: display, line: entry.lineNumber, text: entry.raw, message });
955
+ const scan = scanRosterMarkdown(text);
956
+ const sections = new Map(jobSections(scan).map((section) => [section.heading.index, section]));
957
+ const done = new Set();
958
+ const readSection = (section) => {
959
+ const { heading, workers: lines } = section;
960
+ const job = rosterJobHeading(heading.title);
961
+ if (job.error) {
962
+ if (lines.some((entry) => namesATool(entry.value))) warn(heading, `${job.error}, so the section is skipped`);
963
+ return;
964
+ }
965
+ const { key, label, kind, builtIn } = job;
966
+ if (picks[key]) {
967
+ warn(heading, `sets ${label} a second time; line ${picks[key].line} wins`);
968
+ return;
969
+ }
970
+ const workers = [];
971
+ for (const entry of lines) {
972
+ try {
973
+ const worker = rosterWorkerFromValue(entry.value, ENGINE_JOBS[kind], options);
974
+ workers.push({ ...worker, line: entry.lineNumber, text: entry.raw });
975
+ 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
+ } catch (error) {
977
+ warn(entry, `${error.message}, so ${label} skips this worker`);
978
+ workers.push({ engine: rosterToolName(String(entry.value).split(',')[0]) || '', model: '', until: '', error: error.message, line: entry.lineNumber, text: entry.raw });
979
+ }
980
+ }
981
+ const good = workers.filter((worker) => !worker.error);
982
+ if (!good.length) return;
983
+ const [first, second] = good;
984
+ picks[key] = {
985
+ engine: first.engine,
986
+ model: first.model,
987
+ ...(first.effort ? { effort: first.effort } : {}),
988
+ backup: second ? second.engine : '',
989
+ ...(second && second.model ? { backup_model: second.model } : {}),
990
+ ...(first.max_seconds ? { max_seconds: first.max_seconds } : {}),
991
+ until: first.until,
992
+ ...(first.never_expires ? { never_expires: true } : {}),
993
+ ...(builtIn ? {} : { like: kind }),
994
+ workers,
995
+ file: display,
996
+ line: heading.lineNumber,
997
+ };
998
+ };
999
+ for (const entry of scan.entries) {
1000
+ if (entry.section === 'job') {
1001
+ if (!done.has(entry.headingIndex)) {
1002
+ done.add(entry.headingIndex);
1003
+ readSection(sections.get(entry.headingIndex));
1004
+ }
1005
+ continue;
1006
+ }
1007
+ if (entry.malformed) {
1008
+ warn(entry, entry.section === 'team' ? 'is not "member: job or engine", so it is skipped' : 'is not "job: engine", so it is skipped');
1009
+ continue;
1010
+ }
1011
+ if (entry.section === 'team') {
1012
+ team.push({ member: entry.name.trim().toLowerCase(), value: entry.value, line: entry.lineNumber, text: entry.raw, file: display });
1013
+ continue;
1014
+ }
1015
+ const { name, like } = splitJobName(entry.name);
1016
+ const key = rosterJobKey(name);
1017
+ if (!key) {
1018
+ warn(entry, `${rosterJobNameError(name)}, so the line is skipped`);
1019
+ continue;
1020
+ }
1021
+ const builtIn = ENGINE_ROLES.includes(key);
1022
+ const label = rosterJobLabel(key);
1023
+ const likeKind = like ? rosterJob(like) : '';
1024
+ if (like && !likeKind) {
1025
+ warn(entry, `"like ${like}" is not search, build, or review, so ${label} uses the next pick in line`);
1026
+ continue;
1027
+ }
1028
+ if (builtIn && likeKind && ENGINE_JOBS[likeKind] !== key) {
1029
+ warn(entry, `${label} is a built-in job and cannot be like ${likeKind}, so the line is skipped`);
1030
+ continue;
1031
+ }
1032
+ const kind = builtIn ? label : likeKind || inferJobKind(name);
1033
+ if (!kind) {
1034
+ warn(entry, `say what kind of job "${label}" is, for example "${label} (like build)", so the line is skipped`);
1035
+ continue;
1036
+ }
1037
+ if (picks[key]) {
1038
+ warn(entry, `sets ${label} a second time; line ${picks[key].line} wins`);
1039
+ continue;
1040
+ }
1041
+ try {
1042
+ const pick = rosterPickFromValue(entry.value, ENGINE_JOBS[kind], options);
1043
+ picks[key] = { ...pick, ...(builtIn ? {} : { like: kind }), file: display, line: entry.lineNumber };
1044
+ if (pick.until_needs_year) {
1045
+ warn(entry, `has no year in its until date, so ${label} counts as expired; write until ${pick.until_needs_year}`);
1046
+ }
1047
+ } catch (error) {
1048
+ warn(entry, `${error.message}, so ${label} uses the next pick in line`);
1049
+ }
1050
+ }
1051
+ return { picks, team, warnings };
1052
+ }
1053
+
1054
+ function readRosterMarkdownLayer(scope, file, options = {}) {
1055
+ let text;
1056
+ try {
1057
+ text = fs.readFileSync(file, 'utf8');
1058
+ } catch {
1059
+ return null;
1060
+ }
1061
+ return { scope, format: 'markdown', path: file, ...parseRosterMarkdown(text, file, options) };
1062
+ }
1063
+
1064
+ function readProjectRoster(root = process.cwd(), options = {}) {
1065
+ const md = readRosterMarkdownLayer('project', projectRosterFile(projectRosterRoot(root)), { ...options, root });
1066
+ if (md) return { ...md, file: rosterFileLabel(md.path, root) };
1067
+ const registry = options.registry || readEngineRegistry(root, { persist: false });
1068
+ const picks = registry.roster && typeof registry.roster === 'object' && !Array.isArray(registry.roster) ? registry.roster : {};
1069
+ const jsonFile = engineRegistryFile(root);
1070
+ return { scope: 'project', format: Object.keys(picks).length ? 'json' : 'none', path: jsonFile, file: rosterFileLabel(jsonFile, root), picks, team: [], warnings: [] };
1071
+ }
1072
+
1073
+ function readMachineRosterLayer(options = {}) {
1074
+ const root = options.root || process.cwd();
1075
+ if (!machineRosterEnabled(options)) return { scope: 'machine', format: 'none', path: '', file: '', picks: {}, team: [], warnings: [] };
1076
+ const mdFile = machineRosterMarkdownFile(options);
1077
+ const md = readRosterMarkdownLayer('machine', mdFile, { ...options, root });
1078
+ if (md) return { ...md, file: rosterFileLabel(mdFile, root) };
1079
+ const picks = readMachineRosterJson(options);
1080
+ const jsonFile = machineRosterFile(options);
1081
+ return { scope: 'machine', format: Object.keys(picks).length ? 'json' : 'none', path: jsonFile, file: rosterFileLabel(jsonFile, root), picks, team: [], warnings: [] };
1082
+ }
1083
+
1084
+ // --- session changes ----------------------------------------------------
1085
+ //
1086
+ // A session file sits above this project and all projects, one job at a
1087
+ // time: a job it names replaces that job's whole list for this shell only.
1088
+ // The key is ATRIS_ROSTER_SESSION, else the terminal's own session id. Agent
1089
+ // shells have no terminal, so they set the variable. A file no resolution
1090
+ // has read for a day is ignored and removed on the next read.
1091
+
1092
+ const SESSION_ROSTER_TTL_MS = 24 * 60 * 60 * 1000;
1093
+ const NO_SESSION_MESSAGE = 'this shell has no session to attach changes to; set ATRIS_ROSTER_SESSION=<name> and run it again';
1094
+ let terminalSessionMemo;
1095
+
1096
+ function terminalSessionKey() {
1097
+ if (terminalSessionMemo === undefined) {
1098
+ try {
1099
+ terminalSessionMemo = require('../utils/auth').getTerminalSessionId() || '';
1100
+ } catch {
1101
+ terminalSessionMemo = '';
1102
+ }
1103
+ }
1104
+ return terminalSessionMemo;
1105
+ }
1106
+
1107
+ // Test runs only key off ATRIS_ROSTER_SESSION, never the terminal around them.
1108
+ function rosterSessionKey(options = {}) {
1109
+ if (options.sessionKey !== undefined) return String(options.sessionKey || '').trim();
1110
+ const named = String(process.env.ATRIS_ROSTER_SESSION || '').trim();
1111
+ if (named) return named;
1112
+ if (process.env.NODE_TEST_CONTEXT) return '';
1113
+ return terminalSessionKey();
1114
+ }
1115
+
1116
+ function sessionRosterDir(options = {}) {
1117
+ if (options.sessionRosterDir) return options.sessionRosterDir;
1118
+ if (process.env.ATRIS_ROSTER_SESSIONS_DIR) return process.env.ATRIS_ROSTER_SESSIONS_DIR;
1119
+ if (options.machineRosterFile || process.env.ATRIS_MACHINE_ROSTER_PATH) return path.join(path.dirname(machineRosterFile(options)), 'sessions');
1120
+ return path.join(os.homedir(), '.atris', 'sessions');
1121
+ }
1122
+
1123
+ // Like the all-projects roster: off in test runs unless a test points the
1124
+ // home folder somewhere scratch.
1125
+ function sessionRosterEnabled(options = {}) {
1126
+ if (options.sessionRoster === false) return false;
1127
+ if (options.sessionRosterDir || process.env.ATRIS_ROSTER_SESSIONS_DIR) return true;
1128
+ if (options.machineRosterFile || process.env.ATRIS_MACHINE_ROSTER_PATH) return true;
1129
+ return !process.env.NODE_TEST_CONTEXT;
1130
+ }
1131
+
1132
+ // The file name is a short readable part of the key plus a hash of the whole
1133
+ // key, so "shell/b" and "shell-b", or two long keys that only differ at the
1134
+ // end, never share a file. Only letters, digits, _ . - reach the name, and it
1135
+ // never starts with a dot or dash, so a key cannot climb out of the folder.
1136
+ function sessionRosterFile(options = {}) {
1137
+ const key = rosterSessionKey(options);
1138
+ if (!key) return '';
1139
+ const readable = key.replace(/[^A-Za-z0-9_.-]/g, '-').replace(/^[.-]+/, '').slice(0, 32).replace(/[.-]+$/, '');
1140
+ const hash = crypto.createHash('sha256').update(key).digest('hex').slice(0, 12);
1141
+ return path.join(sessionRosterDir(options), `roster-${readable ? `${readable}-` : ''}${hash}.md`);
1142
+ }
1143
+
1144
+ function sessionClock(options = {}) {
1145
+ const at = options.sessionClock !== undefined ? new Date(options.sessionClock).getTime() : Date.now();
1146
+ return Number.isFinite(at) ? at : Date.now();
1147
+ }
1148
+
1149
+ // Remove a session file nobody has read for a day. true when it was stale.
1150
+ function dropStaleSessionFile(file, clock) {
1151
+ try {
1152
+ if (clock - fs.statSync(file).mtimeMs <= SESSION_ROSTER_TTL_MS) return false;
1153
+ } catch {
1154
+ return false;
1155
+ }
1156
+ try { fs.unlinkSync(file); } catch {}
1157
+ return true;
1158
+ }
1159
+
1160
+ function readSessionRosterLayer(options = {}) {
1161
+ const root = options.root || process.cwd();
1162
+ const key = sessionRosterEnabled(options) ? rosterSessionKey(options) : '';
1163
+ const file = key ? sessionRosterFile(options) : '';
1164
+ const empty = { scope: 'session', format: 'none', key, path: file, file: file ? rosterFileLabel(file, root) : '', picks: {}, team: [], warnings: [] };
1165
+ if (!file || !fs.existsSync(file)) return empty;
1166
+ const clock = sessionClock(options);
1167
+ if (dropStaleSessionFile(file, clock)) return { ...empty, stale: true };
1168
+ const md = readRosterMarkdownLayer('session', file, { ...options, root });
1169
+ if (!md) return empty;
1170
+ // Reading marks the file used, so an active session never goes stale.
1171
+ try { fs.utimesSync(file, new Date(clock), new Date(clock)); } catch {}
1172
+ return { ...md, key, file: rosterFileLabel(file, root) };
1173
+ }
1174
+
1175
+ // Every other stale session roster in the folder goes when one is written.
1176
+ function sweepStaleSessionRosters(options = {}) {
1177
+ const dir = sessionRosterDir(options);
1178
+ const clock = sessionClock(options);
1179
+ let names = [];
1180
+ try { names = fs.readdirSync(dir); } catch { return; }
1181
+ for (const name of names) {
1182
+ if (/^roster-.+\.md$/.test(name)) dropStaleSessionFile(path.join(dir, name), clock);
1183
+ }
1184
+ }
1185
+
1186
+ function clearSessionRoster(options = {}) {
1187
+ const key = rosterSessionKey(options);
1188
+ if (!key) throw new Error(NO_SESSION_MESSAGE);
1189
+ const file = sessionRosterFile(options);
1190
+ if (!sessionRosterEnabled(options) || !fs.existsSync(file)) return false;
1191
+ fs.unlinkSync(file);
1192
+ return true;
1193
+ }
1194
+
1195
+ // Every layer at once: this session's changes, this project's roster, and
1196
+ // the all-projects roster, each from ROSTER.md when it exists, else from the
1197
+ // older JSON file.
1198
+ function readRosterState(root = process.cwd(), options = {}) {
1199
+ return {
1200
+ session: readSessionRosterLayer({ ...options, root }),
1201
+ project: readProjectRoster(root, options),
1202
+ machine: readMachineRosterLayer({ ...options, root }),
1203
+ };
1204
+ }
1205
+
1206
+ // --- writing the sectioned shape ------------------------------------------
1207
+
1208
+ // A model the way a person writes it on a worker line, when that spelling
1209
+ // reads back as the same model; else the saved id itself.
1210
+ function workerModelText(engine, model) {
1211
+ const label = rosterModelLabel(model);
1212
+ try {
1213
+ if (normalizeRosterModel(engine, label) === model) return label;
1214
+ } catch {}
1215
+ return model;
1216
+ }
1217
+
1218
+ // One worker line: "- claude code, model: opus 5.5, effort: high, max: 20 min, until 2026-10-24".
1219
+ function workerLineText(worker) {
1220
+ const parts = [rosterToolLabel(worker.engine)];
1221
+ if (worker.model) parts.push(`model: ${workerModelText(worker.engine, worker.model)}`);
1222
+ if (worker.effort) parts.push(`effort: ${worker.effort}`);
1223
+ if (Number(worker.max_seconds) > 0) parts.push(`max: ${rosterMaxText(worker.max_seconds).replace(/^max /, '')}`);
1224
+ if (worker.until) parts.push(`until ${worker.until}`);
1225
+ return `- ${parts.join(', ')}`;
1226
+ }
1227
+
1228
+ // "## small build", or "## quick fixes (like build)" when the name does not
1229
+ // say its kind.
1230
+ function jobHeadingText(key, like = '') {
1231
+ const label = rosterJobLabel(key);
1232
+ const custom = !ENGINE_ROLES.includes(key);
1233
+ const likeText = custom && like && inferJobKind(label) !== like ? ` (like ${like})` : '';
1234
+ return `## ${label}${likeText}`;
1235
+ }
1236
+
1237
+ // A one-line pick as worker lines with the same meaning: the engine keeps
1238
+ // the line's until, the backup never expires, and both keep the time cap. A
1239
+ // backup model the engine cannot run was never used, so it is left off.
1240
+ function workerLinesForPick(pick) {
1241
+ return pickWorkers(pick).map((worker) => {
1242
+ const model = worker.lenient_model ? savedRosterModel(worker.engine, worker.model) || '' : worker.model;
1243
+ return workerLineText({ ...worker, model });
1244
+ });
1245
+ }
1246
+
1247
+ function jobBlock(key, pick) {
1248
+ return [jobHeadingText(key, pick.like || ''), ...workerLinesForPick(pick)];
1249
+ }
1250
+
1251
+ // The first write to a layer with no ROSTER.md carries its JSON picks over,
1252
+ // dates included. The JSON file stays where it is, untouched.
1253
+ function rosterMarkdownFromPicks(picks) {
1254
+ const { ROSTER_MARKDOWN_TEMPLATE } = require('./roster-markdown');
1255
+ const keys = [...ENGINE_ROLES.filter((role) => role in (picks || {})), ...customRosterJobKeys(picks)];
1256
+ const order = ['executor', 'validator', 'navigator'];
1257
+ keys.sort((a, b) => {
1258
+ const ai = order.indexOf(a);
1259
+ const bi = order.indexOf(b);
1260
+ return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
1261
+ });
1262
+ const blocks = keys
1263
+ .map((key) => ({ key, pick: rosterPickObject(picks[key]) }))
1264
+ .filter(({ key, pick }) => pick && (ENGINE_ROLES.includes(key) || customPickRole(pick)))
1265
+ .map(({ key, pick }) => [...jobBlock(key, pick), '']);
1266
+ return [...ROSTER_MARKDOWN_TEMPLATE, ...blocks.flat()].join('\n');
1267
+ }
1268
+
1269
+ function isBlankLine(line) {
1270
+ return !String(line || '').trim();
1271
+ }
1272
+
1273
+ // A ROSTER.md in the one-line shape, rewritten into sections with the same
1274
+ // meaning: each good job line becomes its own "## job" section (the backup is
1275
+ // the second worker), team lines become "- member: value", and comments,
1276
+ // notes, and every other line stay where they were. A line that could not be
1277
+ // used stays as written, so it still warns; a second line for a job that was
1278
+ // already set turns into a comment, since it never counted.
1279
+ function sectionedRosterText(text, file, options = {}) {
1280
+ const { scanRosterMarkdown, joinLines, OLD_ROSTER_NOTE, ROSTER_NOTE } = require('./roster-markdown');
1281
+ const scan = scanRosterMarkdown(text);
1282
+ const oldJobs = scan.entries.filter((entry) => entry.section === 'jobs' && !entry.malformed);
1283
+ const plainTeam = scan.entries.filter((entry) => entry.section === 'team' && !entry.dashed && !entry.malformed);
1284
+ const oldNote = scan.lines.some((line) => line.trim() === OLD_ROSTER_NOTE);
1285
+ if (!oldJobs.length && !plainTeam.length && !oldNote) return text;
1286
+ const { picks } = parseRosterMarkdown(text, file, options);
1287
+ const pickAt = new Map(Object.entries(picks).filter(([, pick]) => !pick.workers).map(([key, pick]) => [pick.line, key]));
1288
+ const byIndex = new Map(scan.entries.map((entry) => [entry.index, entry]));
1289
+ const firstJob = oldJobs.length ? oldJobs[0].index : -1;
1290
+ const SLOT = '\u0000';
1291
+ const kept = [];
1292
+ const moved = [];
1293
+ const blocks = [];
1294
+ scan.lines.forEach((raw, index) => {
1295
+ const entry = byIndex.get(index);
1296
+ if (index === firstJob) kept.push(SLOT);
1297
+ if (entry && entry.section === 'jobs' && !entry.malformed) {
1298
+ const key = pickAt.get(entry.lineNumber);
1299
+ if (key) {
1300
+ // A comment right above the line and a note at its end go with it.
1301
+ const above = [];
1302
+ while (kept.length && kept[kept.length - 1] !== SLOT && /^\s*<!--.*-->\s*$/.test(kept[kept.length - 1])) above.unshift(kept.pop());
1303
+ const [heading, ...workers] = jobBlock(key, picks[key]);
1304
+ const note = /\s*(<!--.*?-->)\s*$/.exec(raw);
1305
+ blocks.push('', ...above, `${heading}${note ? ` ${note[1]}` : ''}`, ...workers);
1306
+ return;
1307
+ }
1308
+ const sameJob = rosterJobKey(splitJobName(entry.name).name);
1309
+ if (sameJob && picks[sameJob]) {
1310
+ moved.push(`<!-- never used, ${rosterJobLabel(sameJob)} was set on another line: ${raw.trim()} -->`);
1311
+ return;
1312
+ }
1313
+ // A line that could not be used keeps warning above the sections.
1314
+ moved.push(raw);
1315
+ return;
1316
+ }
1317
+ if (entry && entry.section === 'team' && !entry.dashed && !entry.malformed) {
1318
+ kept.push(`${/^\s*/.exec(raw)[0]}- ${raw.trim()}`);
1319
+ return;
1320
+ }
1321
+ kept.push(raw.trim() === OLD_ROSTER_NOTE ? ROSTER_NOTE : raw);
1322
+ });
1323
+ const out = [];
1324
+ for (const line of kept) {
1325
+ if (line === SLOT) out.push(...moved, ...blocks, '');
1326
+ else out.push(line);
1327
+ }
1328
+ // One blank line between blocks, never two, and none at the very end.
1329
+ const tidy = out.filter((line, index) => !(isBlankLine(line) && index > 0 && isBlankLine(out[index - 1])));
1330
+ while (tidy.length && isBlankLine(tidy[tidy.length - 1])) tidy.pop();
1331
+ return joinLines(tidy, scan.newline);
1332
+ }
1333
+
1334
+ // Where an assign writes: this session, all projects (--everywhere), or this
1335
+ // project.
1336
+ function rosterWriteTarget(options, root) {
1337
+ if (options.session && options.everywhere) throw new Error('pick one: --session or --everywhere');
1338
+ if (options.session) {
1339
+ if (!rosterSessionKey(options)) throw new Error(NO_SESSION_MESSAGE);
1340
+ if (!sessionRosterEnabled(options)) throw new Error('session changes are off in test runs; set ATRIS_ROSTER_SESSIONS_DIR to a scratch folder');
1341
+ return { scope: 'session', file: sessionRosterFile(options), layer: readSessionRosterLayer({ ...options, root }) };
1342
+ }
1343
+ if (options.everywhere) {
1344
+ if (!machineRosterEnabled(options)) throw new Error('the all-projects roster is off in test runs; set ATRIS_MACHINE_ROSTER_PATH to a scratch file');
1345
+ return { scope: 'machine', file: machineRosterMarkdownFile(options), layer: readMachineRosterLayer({ ...options, root }) };
1346
+ }
1347
+ return { scope: 'project', file: projectRosterFile(projectRosterRoot(root)), layer: null };
1348
+ }
1349
+
1350
+ // The new list of worker lines for one assign. null removes the job.
1351
+ function assignedWorkerLines(key, engineName, options, context) {
1352
+ const { registry, savedPick, currentLines, currentWorkers, jobName } = context;
1353
+ const builtIn = ENGINE_ROLES.includes(key);
1354
+ const label = rosterJobLabel(key);
1355
+ if (options.clear) return { lines: null };
1356
+ if (options.remove !== undefined) {
1357
+ const tool = rosterToolName(options.remove);
1358
+ if (!tool) throw new Error(`unknown tool "${options.remove}"`);
1359
+ const keep = currentLines.filter((line, index) => currentWorkers[index].engine !== tool);
1360
+ if (keep.length === currentLines.length) throw new Error(`${label} has no ${tool} worker to remove`);
1361
+ return { lines: keep.length ? keep : null };
1362
+ }
1363
+ const likeName = String(options.like || '').trim();
1364
+ const likeKind = likeName ? rosterJob(likeName) : '';
1365
+ if (likeName && !likeKind) throw new Error(`unknown kind "${likeName}". use --like search, build, or review`);
1366
+ if (builtIn && likeKind && ENGINE_JOBS[likeKind] !== key) throw new Error(`${label} is a built-in job; --like is only for your own jobs`);
1367
+ const savedKind = builtIn ? '' : Object.keys(ENGINE_JOBS).find((kind) => ENGINE_JOBS[kind] === customPickRole(savedPick)) || '';
1368
+ const kind = builtIn ? label : likeKind || inferJobKind(jobName) || savedKind;
1369
+ if (!kind) throw new Error(`say what kind of job "${label}" is: add --like search, --like build, or --like review`);
1370
+ const role = ENGINE_JOBS[kind];
1371
+ const engineId = rosterToolName(engineName);
1372
+ const engine = registry.engines.find((entry) => entry.id === engineId);
1373
+ if (!engine) throw new Error(`unknown engine "${engineName}"`);
1374
+ if (!engineHasRole(engine, role)) throw new Error(`${engine.id} cannot do ${builtIn ? label : `${kind} work, so it cannot take ${label}`}`);
1375
+ // --backup takes engine words with an optional model and effort, the
1376
+ // same as a written line: --backup "claude opus 5.5".
1377
+ const backupName = String(options.backup || '').trim();
1378
+ if (backupName && options.add) throw new Error('use --backup without --add; --add puts a worker at the end of the list');
1379
+ let backupRead = null;
1380
+ if (backupName) {
1381
+ try {
1382
+ backupRead = resolveRosterWords(backupName);
1383
+ } catch (error) {
1384
+ throw new Error(rosterToolName(backupName.split(' ')[0]) ? `the backup ${error.message}` : `unknown backup engine "${backupName}"`);
1385
+ }
1386
+ }
1387
+ const backup = backupRead ? registry.engines.find((entry) => entry.id === backupRead.engine) : null;
1388
+ if (backupName && !backup) throw new Error(`unknown backup engine "${backupName}"`);
1389
+ if (backup && !engineHasRole(backup, role)) throw new Error(`${backup.id} cannot do ${builtIn ? label : `${kind} work, so it cannot back up ${label}`}`);
1390
+ // No --days means the worker holds until someone changes the line.
1391
+ const days = options.days === undefined ? null : Number(options.days);
1392
+ if (days !== null && (!Number.isSafeInteger(days) || days < 1)) throw new Error('days must be a positive whole number');
1393
+ const model = normalizeRosterModel(engine.id, options.model);
1394
+ if (model && !engineTakesModel(engine.id)) throw new Error(`${engine.id} runs one fixed model and cannot take "${model}"`);
1395
+ const effort = String(options.effort || '').trim().toLowerCase();
1396
+ if (effort && !EFFORT_WORDS.includes(effort)) throw new Error(`"${effort}" is not an effort; use ${EFFORT_WORDS.join(', ')}`);
1397
+ if (effort && !engineEffortLevels(engine.id).includes(effort)) throw new Error(effortRefusal(engine.id, effort));
1398
+ const maxSeconds = options.max === undefined ? null : parseRosterMax(`max ${String(options.max).replace(/^max\s*:?\s*/i, '')}`);
1399
+ if (options.max !== undefined && !maxSeconds) throw new Error(`"${options.max}" is not a time cap; use --max "20 min", --max 90s, or --max 2h`);
1400
+ const now = rosterDate(options.now);
1401
+ const line = workerLineText({
1402
+ engine: engine.id,
1403
+ model,
1404
+ ...(effort ? { effort } : {}),
1405
+ ...(maxSeconds ? { max_seconds: maxSeconds } : {}),
1406
+ until: days ? rosterUntil(now, days) : '',
1407
+ });
1408
+ const heading = jobHeadingText(key, builtIn ? '' : kind);
1409
+ if (options.add) return { lines: [...currentLines, line], heading };
1410
+ // A note the person left at the end of the old lead line stays with the lead.
1411
+ const leadNote = currentLines.length ? /\s*(<!--.*?-->)\s*$/.exec(currentLines[0]) : null;
1412
+ const lead = leadNote ? `${line} ${leadNote[1]}` : line;
1413
+ // The new lead replaces the first line; the same tool and model further
1414
+ // down would only repeat it, so that line goes.
1415
+ const same = (index) => currentWorkers[index].engine === engine.id && (currentWorkers[index].model || '') === model;
1416
+ const rest = currentLines.filter((text, index) => index > 0 && !same(index));
1417
+ if (!backup) return { lines: [lead, ...rest], heading };
1418
+ const backupLine = workerLineText({
1419
+ engine: backup.id,
1420
+ model: backupRead.model || '',
1421
+ ...(backupRead.effort ? { effort: backupRead.effort } : {}),
1422
+ ...(maxSeconds ? { max_seconds: maxSeconds } : {}),
1423
+ until: '',
1424
+ });
1425
+ // Lead first, backup second, then every old worker in its old order with
1426
+ // its settings as written, keeping only the first line for each tool and
1427
+ // model pair: claude on haiku and claude on opus are two workers. The old
1428
+ // lead stays when it is a different pair, and then its note stays on its
1429
+ // own line.
1430
+ const pair = (tool, pinned) => `${tool}\u0000${pinned || ''}`;
1431
+ const placed = [pair(engine.id, model), pair(backup.id, backupRead.model || '')];
1432
+ const taken = new Set(placed);
1433
+ const others = currentLines.filter((text, index) => {
1434
+ const worker = pair(currentWorkers[index].engine, currentWorkers[index].model);
1435
+ if (taken.has(worker)) return false;
1436
+ taken.add(worker);
1437
+ return true;
1438
+ });
1439
+ const keptOldLead = currentLines.length > 0
1440
+ && !placed.includes(pair(currentWorkers[0].engine, currentWorkers[0].model));
1441
+ return { lines: [keptOldLead ? line : lead, backupLine, ...others], heading };
1442
+ }
1443
+
1444
+ // Assign one job by editing its section in ROSTER.md: this project's
1445
+ // atris/ROSTER.md, ~/.atris/ROSTER.md with everywhere, or this session's file
1446
+ // with session. The default sets the lead worker (the first line) and keeps
1447
+ // the rest; add appends a worker; remove drops every worker on that tool;
1448
+ // backup sets the second worker; clear removes the job. Only that job's
1449
+ // section changes; comments, order, and every other line stay as written. A
1450
+ // file still in the one-line shape is rewritten into sections first, with
1451
+ // the same meaning. The three built-in jobs keep their role keys; any other
1452
+ // name is a custom job whose kind (search, build, or review) comes from
1453
+ // --like, its name, or its saved section, and tools are checked against it.
1454
+ function setRosterPick(jobName, engineName, options = {}, root = process.cwd()) {
1455
+ const { replaceJobSection, scanRosterMarkdown, jobSections } = require('./roster-markdown');
1456
+ const key = rosterJobKey(jobName);
1457
+ if (!key) throw new Error(rosterJobNameError(jobName));
1458
+ const registry = readEngineRegistry(root, { persist: false });
1459
+ const target = rosterWriteTarget(options, root);
1460
+ const layer = target.layer || readProjectRoster(root, { ...options, registry });
1461
+ const file = target.file;
1462
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
1463
+ if (existing === null && (options.clear || options.remove !== undefined) && !Object.keys(layer.picks || {}).length) return {};
1464
+ const base = existing !== null ? sectionedRosterText(existing, file, { ...options, root }) : rosterMarkdownFromPicks(layer.picks);
1465
+ const parsed = parseRosterMarkdown(base, file, { ...options, root });
1466
+ const savedPick = rosterPickObject(parsed.picks[key]);
1467
+ const section = jobSections(scanRosterMarkdown(base)).find((entry) => rosterJobHeading(entry.heading.title).key === key);
1468
+ const workerEntries = section ? section.workers : [];
1469
+ const currentWorkers = workerEntries.map((entry) => {
1470
+ const worker = savedPick && savedPick.workers ? savedPick.workers.find((item) => item.line === entry.lineNumber) : null;
1471
+ return worker || { engine: rosterToolName(String(entry.value).split(',')[0]), model: '' };
1472
+ });
1473
+ const { lines, heading } = assignedWorkerLines(key, engineName, options, {
1474
+ registry,
1475
+ savedPick,
1476
+ currentLines: workerEntries.map((entry) => entry.raw),
1477
+ currentWorkers,
1478
+ jobName,
1479
+ });
1480
+ const next = replaceJobSection(base, {
1481
+ matches: (title) => rosterJobHeading(title).key === key,
1482
+ // Only an assign names a heading, and only when the job's kind changes;
1483
+ // otherwise the heading stays exactly as the person wrote it.
1484
+ heading: section && heading && rosterJobHeading(section.heading.title).kind === rosterJobHeading(String(heading).replace(/^#+\s*/, '')).kind
1485
+ ? ''
1486
+ : heading,
1487
+ lines,
1488
+ });
1489
+ writeAtomic(file, next);
1490
+ if (target.scope === 'session') {
1491
+ sweepStaleSessionRosters(options);
1492
+ return readSessionRosterLayer({ ...options, root }).picks;
1493
+ }
1494
+ return target.scope === 'machine' ? readMachineRoster(options) : readProjectRoster(root, options).picks;
1495
+ }
1496
+
1497
+ function renewRoster(roster, date) {
1498
+ const next = { ...(roster || {}) };
1499
+ for (const key of [...ENGINE_ROLES, ...customRosterJobKeys(next)]) {
1500
+ if (next[key]) next[key] = { ...next[key], until: rosterUntil(date, 30), set_at: date.toISOString() };
1501
+ }
1502
+ return next;
1503
+ }
1504
+
1505
+ // Renew every dated line in one ROSTER.md for thirty days. Only the date
1506
+ // after "until" changes; lines with no until never expire, so they stay
1507
+ // exactly as written, and so does everything else on a renewed line.
1508
+ function renewRosterMarkdown(file, date, options = {}) {
1509
+ const { rewriteRosterValues } = require('./roster-markdown');
1510
+ const text = fs.readFileSync(file, 'utf8');
1511
+ const parsed = parseRosterMarkdown(text, file, options);
1512
+ const renewLines = new Set();
1513
+ for (const pick of Object.values(parsed.picks)) {
1514
+ if (!Array.isArray(pick.workers)) {
1515
+ if (pick.until) renewLines.add(pick.line);
1516
+ continue;
1517
+ }
1518
+ for (const worker of pick.workers) if (!worker.error && worker.until) renewLines.add(worker.line);
1519
+ }
1520
+ const until = rosterUntil(date, 30);
1521
+ const next = rewriteRosterValues(text, (entry) => {
1522
+ if ((entry.section !== 'jobs' && entry.section !== 'job') || !renewLines.has(entry.lineNumber)) return undefined;
1523
+ return entry.raw.replace(/(,\s*until\s*:?\s+)(\d{4}-\d{2}-\d{2})/i, `$1${until}`);
1524
+ });
1525
+ if (next !== text) writeAtomic(file, next);
1526
+ }
1527
+
1528
+ // Confirm renews this project's picks and the all-projects picks together.
1529
+ // In a ROSTER.md only lines that carry an until date are renewed.
1530
+ function confirmRoster(root = process.cwd(), now = new Date(), options = {}) {
1531
+ const date = rosterDate(now);
1532
+ const projectMd = projectRosterFile(projectRosterRoot(root));
1533
+ if (fs.existsSync(projectMd)) {
1534
+ renewRosterMarkdown(projectMd, date, { ...options, root, now: date });
1535
+ } else {
1536
+ const registry = readEngineRegistry(root, { persist: false });
1537
+ if (registry.roster) writeEngineRegistry(root, { ...registry, updated_at: date.toISOString(), roster: renewRoster(registry.roster, date) });
1538
+ }
1539
+ if (machineRosterEnabled(options)) {
1540
+ const machineMd = machineRosterMarkdownFile(options);
1541
+ if (fs.existsSync(machineMd)) {
1542
+ renewRosterMarkdown(machineMd, date, { ...options, root, now: date });
1543
+ } else {
1544
+ const machine = readMachineRosterJson(options);
1545
+ if (Object.keys(machine).length) writeMachineRoster(renewRoster(machine, date), options);
1546
+ }
1547
+ }
1548
+ return readProjectRoster(root, { ...options, now: date }).picks;
1549
+ }
1550
+
278
1551
  function engineRegistryView(root = process.cwd()) {
279
1552
  return readEngineRegistry(root).engines;
280
1553
  }
@@ -295,13 +1568,131 @@ function resolveRegisteredEngine(name, root = process.cwd()) {
295
1568
  return engine;
296
1569
  }
297
1570
 
1571
+ const ROSTER_SOURCES = Object.freeze({ session: 'this session', project: 'this project', machine: 'all projects' });
1572
+ const ROSTER_LAYERS = Object.freeze(['session', 'project', 'machine']);
1573
+
1574
+ // A pick's workers, each with the engine it would run (model, effort, and
1575
+ // time cap pinned) or the reason it is skipped: bad line, expired, bad model,
1576
+ // or not ready (down, or it cannot do this kind of work).
1577
+ function rosterWorkerWalk(pick, role, registry, now) {
1578
+ const ready = registry.engines
1579
+ .filter((engine) => engine.health && engine.health.status === 'ready')
1580
+ .filter((engine) => engineHasRole(engine, role));
1581
+ return pickWorkers(pick).map((worker) => {
1582
+ if (worker.error) return { worker, skip: 'bad line', engine: null, raw: null };
1583
+ if (rosterPickExpired(worker, now)) return { worker, skip: 'expired', engine: null, raw: null };
1584
+ const saved = savedRosterModel(worker.engine, worker.model);
1585
+ if (saved === null && !worker.lenient_model) return { worker, skip: 'bad model', engine: null, raw: null };
1586
+ const raw = ready.find((engine) => engine.id === worker.engine) || null;
1587
+ if (!raw) return { worker, skip: 'not ready', engine: null, raw: null };
1588
+ // A backup runs its own model and effort, never the lead's.
1589
+ const model = saved || '';
1590
+ const effort = savedRosterEffort(raw.id, worker.effort);
1591
+ const maxSeconds = Number(worker.max_seconds) > 0 ? Number(worker.max_seconds) : 0;
1592
+ const engine = model || effort || maxSeconds
1593
+ ? {
1594
+ ...raw,
1595
+ ...(model ? { roster_model: model } : {}),
1596
+ ...(effort ? { roster_effort: effort } : {}),
1597
+ ...(maxSeconds ? { roster_max_seconds: maxSeconds } : {}),
1598
+ }
1599
+ : raw;
1600
+ return { worker, skip: '', engine, raw };
1601
+ });
1602
+ }
1603
+
1604
+ // One pick's walk down its workers in order. The first worker that can run
1605
+ // leads; null means the next pick in line decides. A custom pick only counts
1606
+ // when its kind matches the role being resolved. team is every worker that
1607
+ // can run, lead first.
1608
+ function rosterChoiceForPick(pick, role, registry, { key, label, source, scopeText = '', now } = {}) {
1609
+ if (!pick || !pick.engine) return null;
1610
+ if (key !== undefined && pickRoleForKey(key, pick) !== role) return null;
1611
+ const walk = rosterWorkerWalk(pick, role, registry, now);
1612
+ const leadIndex = walk.findIndex((step) => !step.skip);
1613
+ if (leadIndex === -1) return null;
1614
+ const lead = walk[leadIndex];
1615
+ const after = walk.slice(leadIndex + 1).filter((step) => !step.skip);
1616
+ const first = walk[0];
1617
+ const why = first.skip === 'expired' ? 'expired'
1618
+ : first.skip === 'bad model' ? `names a model ${first.worker.engine} cannot run`
1619
+ : first.skip === 'bad line' ? 'has a bad first line'
1620
+ : 'is not ready';
1621
+ return {
1622
+ engine: lead.engine,
1623
+ backup: leadIndex === 0 && after.length ? after[0].raw : null,
1624
+ team: [lead.engine, ...after.map((step) => step.engine)],
1625
+ lead_index: leadIndex,
1626
+ walk,
1627
+ source,
1628
+ job: label,
1629
+ pick,
1630
+ reason: leadIndex === 0
1631
+ ? `roster pick for ${label}${scopeText}: ${lead.engine.id}`
1632
+ : `roster pick for ${label}${scopeText} ${why}, using backup: ${lead.engine.id}`,
1633
+ };
1634
+ }
1635
+
1636
+ const ROSTER_SCOPE_TEXT = Object.freeze({ session: ' (this session)', project: '', machine: ' (all projects)' });
1637
+
1638
+ // Walk one job's roster layers in order: this session, this project, all
1639
+ // projects. Within a layer the job's workers go in order. The first worker
1640
+ // that can run wins; null means the next job in line (or the router) decides.
1641
+ function rosterChoiceForKey(key, role, registry, machine, options = {}) {
1642
+ const label = rosterJobLabel(key);
1643
+ const session = options.sessionRosterPicks || {};
1644
+ const layers = [
1645
+ { source: 'session', pick: session[key] },
1646
+ { source: 'project', pick: registry.roster && registry.roster[key] },
1647
+ { source: 'machine', pick: machine && machine[key] },
1648
+ ];
1649
+ for (const { source, pick } of layers) {
1650
+ const choice = rosterChoiceForPick(pick, role, registry, {
1651
+ key,
1652
+ label,
1653
+ source,
1654
+ scopeText: ROSTER_SCOPE_TEXT[source],
1655
+ now: options.now,
1656
+ });
1657
+ if (choice) return choice;
1658
+ }
1659
+ return null;
1660
+ }
1661
+
1662
+ // The jobs to try, in order: the named job (options.job), else small build
1663
+ // for a low-stakes build, then the role's own job.
1664
+ function rosterJobKeysFor(role, options = {}) {
1665
+ const named = options.job ? rosterJobKey(options.job) : '';
1666
+ const first = named || (role === 'executor' && options.lowStakes === true ? SMALL_BUILD_JOB : '');
1667
+ return first && first !== role ? [first, role] : [role];
1668
+ }
1669
+
1670
+ // The registry with this project's picks swapped in from wherever they live
1671
+ // now: atris/ROSTER.md when it exists, else the roster saved in engines.json.
1672
+ function withProjectRoster(registry, root, options = {}) {
1673
+ const roster = options.projectRosterPicks || readProjectRoster(root, { ...options, registry }).picks;
1674
+ return { ...registry, roster };
1675
+ }
1676
+
1677
+ function rosterChoice(role, registry, options = {}) {
1678
+ const machine = options.machineRosterPicks || readMachineRoster(options);
1679
+ const sessionRosterPicks = options.sessionRosterPicks || readSessionRosterLayer(options).picks;
1680
+ for (const key of rosterJobKeysFor(role, options)) {
1681
+ const choice = rosterChoiceForKey(key, role, registry, machine, { ...options, sessionRosterPicks });
1682
+ if (choice) return choice;
1683
+ }
1684
+ return null;
1685
+ }
1686
+
298
1687
  function resolveEngineForRoleRanked(role, root = process.cwd(), options = {}) {
299
1688
  const normalizedRole = String(role || '').trim().toLowerCase();
300
1689
  if (!ENGINE_ROLES.includes(normalizedRole)) {
301
1690
  throw new Error(`Unknown role "${role}". Known roles: ${ENGINE_ROLES.join(', ')}`);
302
1691
  }
303
- const engines = engineRegistryView(root)
1692
+ const registry = readEngineRegistry(root);
1693
+ const engines = registry.engines
304
1694
  .filter((engine) => engine.roles.includes(normalizedRole))
1695
+ .filter((engine) => !routerSkipsRole(engine, normalizedRole))
305
1696
  .filter((engine) => engine.health && engine.health.status === 'ready')
306
1697
  .sort((a, b) => {
307
1698
  const byOrder = Number(a.fallback_order) - Number(b.fallback_order);
@@ -313,11 +1704,43 @@ function resolveEngineForRoleRanked(role, root = process.cwd(), options = {}) {
313
1704
  lowStakes: options.lowStakes,
314
1705
  stakes: options.stakes,
315
1706
  });
316
- return {
317
- engine: ranked.candidates[0] || null,
318
- reason: routerPickExplanation(ranked),
319
- ranked: ranked.candidates,
320
- };
1707
+ const choice = rosterChoice(normalizedRole, withProjectRoster(registry, root, options), options);
1708
+ if (choice) {
1709
+ // The job's own workers go first, in order, then the router's list.
1710
+ const after = choice.walk.slice(choice.lead_index + 1).filter((step) => !step.skip).map((step) => step.raw);
1711
+ const lead = [choice.engine, ...after].filter((engine, index, list) => list.findIndex((other) => other.id === engine.id) === index);
1712
+ const leadIds = new Set(lead.map((engine) => engine.id));
1713
+ return {
1714
+ engine: choice.engine,
1715
+ reason: choice.reason,
1716
+ source: choice.source,
1717
+ job: choice.job,
1718
+ team: choice.team,
1719
+ ranked: [...lead, ...ranked.candidates.filter((candidate) => !leadIds.has(candidate.id))],
1720
+ };
1721
+ }
1722
+ return { engine: ranked.candidates[0] || null, reason: routerPickExplanation(ranked), source: 'router', team: [], ranked: ranked.candidates };
1723
+ }
1724
+
1725
+ // The ordered team for one job: every worker that can run right now, lead
1726
+ // first, each with its model, effort, and time cap pinned. Split work goes
1727
+ // across the team in this order. With no roster line the router's single
1728
+ // pick is the whole team.
1729
+ function resolveJobTeam(job, root = process.cwd(), options = {}) {
1730
+ const key = rosterJobKey(job);
1731
+ if (!key) throw new Error(rosterJobNameError(job));
1732
+ const label = rosterJobLabel(key);
1733
+ const role = rosterJobRole(job, root, options);
1734
+ if (!role) return { job: label, key, role: '', source: 'none', lead: null, team: [], reason: `${label} names no kind of work yet` };
1735
+ const custom = !ENGINE_ROLES.includes(key);
1736
+ const resolved = resolveEngineForRoleRanked(role, root, { ...options, ...(custom ? { job: key } : {}) });
1737
+ const team = resolved.team && resolved.team.length ? resolved.team : resolved.engine ? [resolved.engine] : [];
1738
+ return { job: label, key, role, source: resolved.source, lead: resolved.engine || null, team, reason: resolved.reason };
1739
+ }
1740
+
1741
+ // True when a roster line (not the router) decided.
1742
+ function rosterDecided(source) {
1743
+ return ROSTER_LAYERS.includes(source);
321
1744
  }
322
1745
 
323
1746
  function resolveEngineForRole(role, root = process.cwd(), options = {}) {
@@ -330,6 +1753,22 @@ function resolveEngineForRole(role, root = process.cwd(), options = {}) {
330
1753
  return picked.engine;
331
1754
  }
332
1755
 
1756
+ // 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.
1758
+ // A caller that already chose an engine (a mission's preferred engine,
1759
+ // --engine) still gets the pins.
1760
+ function rosterPinFor(role, engineId, root = process.cwd(), options = {}) {
1761
+ const normalizedRole = String(role || '').trim().toLowerCase();
1762
+ if (!ENGINE_ROLES.includes(normalizedRole) || !engineId) return {};
1763
+ const choice = rosterChoice(normalizedRole, withProjectRoster(readEngineRegistry(root, { persist: false }), root, options), options);
1764
+ if (!choice || choice.engine.id !== engineId) return {};
1765
+ const pin = {};
1766
+ for (const field of ['roster_model', 'roster_effort', 'roster_max_seconds']) {
1767
+ if (choice.engine[field]) pin[field] = choice.engine[field];
1768
+ }
1769
+ return pin;
1770
+ }
1771
+
333
1772
  function resolveEngineForRoleWithPreference(role, root = process.cwd(), preferredEngineId = '', options = {}) {
334
1773
  const requested = String(preferredEngineId || '').trim();
335
1774
  if (!requested) {
@@ -341,8 +1780,9 @@ function resolveEngineForRoleWithPreference(role, root = process.cwd(), preferre
341
1780
  }
342
1781
  const preferred = resolveRegisteredEngine(requested, root);
343
1782
  if (preferred.health && preferred.health.status === 'ready') {
1783
+ const pin = rosterPinFor(role, preferred.id, root, options);
344
1784
  return {
345
- engine: preferred,
1785
+ engine: Object.keys(pin).length ? { ...preferred, ...pin } : preferred,
346
1786
  requested_engine: preferred.id,
347
1787
  engine_fallback_reason: null,
348
1788
  };
@@ -413,6 +1853,35 @@ function engineDoctorReport(root = process.cwd()) {
413
1853
 
414
1854
  module.exports = {
415
1855
  ENGINE_ROLES,
1856
+ ENGINE_JOBS,
1857
+ SMALL_BUILD_JOB,
1858
+ ROSTER_SOURCES,
1859
+ rosterJobKey,
1860
+ rosterJobLabel,
1861
+ rosterJobNameError,
1862
+ rosterJobRole,
1863
+ inferJobKind,
1864
+ readRosterState,
1865
+ rosterPickFromValue,
1866
+ rosterChoiceForPick,
1867
+ customRosterJobKeys,
1868
+ setRosterPick,
1869
+ confirmRoster,
1870
+ normalizeRosterModel,
1871
+ rosterModelLabel,
1872
+ parseRosterUntil,
1873
+ rosterPickExpired,
1874
+ rosterPinFor,
1875
+ rosterMaxText,
1876
+ mainCheckoutRoot,
1877
+ ROSTER_LAYERS,
1878
+ rosterDecided,
1879
+ rosterToolLabel,
1880
+ rosterWorkerWalk,
1881
+ resolveJobTeam,
1882
+ rosterSessionKey,
1883
+ clearSessionRoster,
1884
+ NO_SESSION_MESSAGE,
416
1885
  ENGINE_DUTIES,
417
1886
  ENGINE_HEALTH_STATUSES,
418
1887
  engineRegistryFile,