atris 3.58.8 → 3.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -2
- package/atris/skills/engines/SKILL.md +28 -1
- package/bin/atris.js +3 -0
- package/commands/autopilot.js +75 -5
- package/commands/clean.js +17 -0
- package/commands/doc-health.js +11 -6
- package/commands/engine.js +384 -4
- package/commands/host.js +53 -0
- package/commands/member.js +57 -6
- package/commands/mission.js +72 -8
- package/commands/one-lap.js +58 -3
- package/commands/run.js +4 -1
- package/commands/scout.js +1 -1
- package/commands/search.js +5 -3
- package/commands/unknowns.js +5 -0
- package/commands/verify.js +12 -4
- package/commands/visualize.js +20 -3
- package/commands/workflow.js +17 -6
- package/lib/dispatch-scout.js +31 -22
- package/lib/engine-registry.js +1482 -13
- package/lib/feedback-triage.js +2 -0
- package/lib/fleet.js +107 -11
- package/lib/host.js +336 -0
- package/lib/known-commands.js +1 -1
- package/lib/map-refs.js +44 -1
- package/lib/member-engine.js +253 -0
- package/lib/roster-markdown.js +206 -0
- package/lib/roster-models.js +179 -0
- package/lib/runner-command.js +94 -14
- package/lib/self-drive.js +9 -1
- package/lib/wish-audit.js +10 -6
- package/lib/wish-delegate.js +10 -0
- package/package.json +1 -1
- package/templates/members/host/MEMBER.md +23 -0
- package/templates/members/host/MISSION.md +11 -0
- package/templates/members/host/SOUL.md +7 -0
- package/templates/members/host/context/why.md +3 -0
- package/templates/members/host/goals.json +5 -0
- package/templates/members/host/goals.md +3 -0
- package/templates/members/host/skills/host/SKILL.md +13 -0
package/lib/engine-registry.js
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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,
|