atris 3.58.8 → 3.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -2
- package/atris/skills/engines/SKILL.md +28 -1
- package/bin/atris.js +13 -0
- package/commands/autopilot.js +75 -5
- package/commands/clean.js +17 -0
- package/commands/doc-health.js +11 -6
- package/commands/engine.js +386 -4
- package/commands/host.js +62 -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/team.js +20 -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 +461 -0
- package/lib/intents.js +15 -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/team-lineup.js +167 -0
- 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 +15 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Which engine a team member runs on. Every member in atris/team/*/MEMBER.md
|
|
4
|
+
// gets a job on its own: navigators, researchers, and scouts search;
|
|
5
|
+
// validators, reviewers, judges, critics, and verifiers review; everyone else
|
|
6
|
+
// builds. A line under "## team" in ROSTER.md overrides that, either with a
|
|
7
|
+
// job ("researcher: search", "fixer: small build") or with engine or model
|
|
8
|
+
// words ("judge: opus 5.5"). The job's pick then comes from the roster.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const {
|
|
13
|
+
ENGINE_JOBS,
|
|
14
|
+
ENGINE_ROLES,
|
|
15
|
+
inferJobKind,
|
|
16
|
+
mainCheckoutRoot,
|
|
17
|
+
readEngineRegistry,
|
|
18
|
+
readRosterState,
|
|
19
|
+
resolveEngineForRoleRanked,
|
|
20
|
+
rosterChoiceForPick,
|
|
21
|
+
rosterDecided,
|
|
22
|
+
ROSTER_LAYERS,
|
|
23
|
+
rosterJobKey,
|
|
24
|
+
rosterJobLabel,
|
|
25
|
+
rosterJobRole,
|
|
26
|
+
rosterModelLabel,
|
|
27
|
+
rosterPickFromValue,
|
|
28
|
+
} = require('./engine-registry');
|
|
29
|
+
|
|
30
|
+
const SEARCH_WORDS = Object.freeze(['navigator', 'researcher', 'research', 'search', 'scout']);
|
|
31
|
+
const REVIEW_WORDS = Object.freeze(['validator', 'reviewer', 'review', 'judge', 'critic', 'verifier']);
|
|
32
|
+
|
|
33
|
+
function memberCardFile(root, name) {
|
|
34
|
+
return path.join(root, 'atris', 'team', name, 'MEMBER.md');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// This folder's card, else the main checkout's when this is a git worktree:
|
|
38
|
+
// the same fallback the roster file uses, so a card not yet committed still
|
|
39
|
+
// counts from a worktree. '' when neither has one.
|
|
40
|
+
function findMemberCard(root, name) {
|
|
41
|
+
const own = memberCardFile(root, name);
|
|
42
|
+
if (fs.existsSync(own)) return own;
|
|
43
|
+
const main = mainCheckoutRoot(root);
|
|
44
|
+
const shared = main ? memberCardFile(main, name) : '';
|
|
45
|
+
return shared && fs.existsSync(shared) ? shared : '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// name and role from the MEMBER.md frontmatter; found is false when there is
|
|
49
|
+
// no card for this name.
|
|
50
|
+
function readMemberCard(root, name) {
|
|
51
|
+
let text = '';
|
|
52
|
+
try {
|
|
53
|
+
text = fs.readFileSync(findMemberCard(root, name), 'utf8');
|
|
54
|
+
} catch {
|
|
55
|
+
return { name, role: '', found: false };
|
|
56
|
+
}
|
|
57
|
+
const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
58
|
+
const field = (key) => {
|
|
59
|
+
const match = block ? new RegExp(`^${key}:\\s*(.*)$`, 'm').exec(block[1]) : null;
|
|
60
|
+
return match ? match[1].trim().replace(/^["']|["']$/g, '') : '';
|
|
61
|
+
};
|
|
62
|
+
return { name: field('name') || name, role: field('role'), found: true };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function listTeamMembers(root = process.cwd()) {
|
|
66
|
+
const dir = path.join(root, 'atris', 'team');
|
|
67
|
+
let names = [];
|
|
68
|
+
try {
|
|
69
|
+
names = fs.readdirSync(dir, { withFileTypes: true })
|
|
70
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.') && !entry.name.startsWith('_'))
|
|
71
|
+
.map((entry) => entry.name)
|
|
72
|
+
.filter((name) => fs.existsSync(memberCardFile(root, name)));
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
return names.sort();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function words(text) {
|
|
80
|
+
return String(text || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function kindFromWords(list) {
|
|
84
|
+
if (list.some((word) => SEARCH_WORDS.includes(word))) return 'search';
|
|
85
|
+
if (list.some((word) => REVIEW_WORDS.includes(word))) return 'review';
|
|
86
|
+
return '';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The job a member gets with no team line: its name decides first, then its
|
|
90
|
+
// role title, else build.
|
|
91
|
+
function autoJobForMember(name, role = '') {
|
|
92
|
+
return kindFromWords(words(name)) || kindFromWords(words(role)) || 'build';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// This session's team line wins, then this project's, then the
|
|
96
|
+
// all-projects one.
|
|
97
|
+
function teamLineFor(state, member) {
|
|
98
|
+
const key = String(member || '').trim().toLowerCase();
|
|
99
|
+
const find = (layer) => (layer && Array.isArray(layer.team) ? layer.team.filter((line) => line.member === key)[0] : null);
|
|
100
|
+
for (const scope of ROSTER_LAYERS) {
|
|
101
|
+
const line = find(state[scope]);
|
|
102
|
+
if (line) return { ...line, scope };
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// What a team line value means: a job (built-in name, a job the roster
|
|
108
|
+
// already has, or a name that says its kind) or a direct engine pick for the
|
|
109
|
+
// member's own kind of work. error is a plain sentence for a line that means
|
|
110
|
+
// neither.
|
|
111
|
+
function interpretTeamValue(value, state, memberKind, options = {}) {
|
|
112
|
+
const text = String(value || '').trim();
|
|
113
|
+
if (!text) return { error: 'names no job or engine' };
|
|
114
|
+
const key = rosterJobKey(text);
|
|
115
|
+
if (key && ENGINE_ROLES.includes(key)) return { type: 'job', key };
|
|
116
|
+
const saved = (layer) => Boolean(key && layer && layer.picks && layer.picks[key]);
|
|
117
|
+
if (saved(state.session) || saved(state.project) || saved(state.machine)) return { type: 'job', key };
|
|
118
|
+
try {
|
|
119
|
+
return { type: 'pick', pick: rosterPickFromValue(text, ENGINE_JOBS[memberKind], options) };
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (key && inferJobKind(text)) return { type: 'job', key };
|
|
122
|
+
return { error: error.message };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function engineText(engine) {
|
|
127
|
+
if (!engine) return 'no ready engine';
|
|
128
|
+
return `${engine.id}${engine.roster_model ? ` ${rosterModelLabel(engine.roster_model)}` : ''}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The engine one member runs on, with a plain reason ("researcher does
|
|
132
|
+
// search: claude haiku"). roster_backed is true when a roster line decided
|
|
133
|
+
// (a job pick or a team line); when false the router decided, and callers
|
|
134
|
+
// keep whatever they did before the roster existed.
|
|
135
|
+
function resolveEngineForMember(memberName, root = process.cwd(), options = {}) {
|
|
136
|
+
const name = String(memberName || '').trim();
|
|
137
|
+
const card = readMemberCard(root, name);
|
|
138
|
+
const state = options.rosterState || readRosterState(root, options);
|
|
139
|
+
const autoKind = autoJobForMember(name, card.role);
|
|
140
|
+
const line = teamLineFor(state, name);
|
|
141
|
+
const read = line ? interpretTeamValue(line.value, state, autoKind, options) : null;
|
|
142
|
+
const override = read && !read.error ? read : null;
|
|
143
|
+
const yearless = override && override.type === 'pick' && override.pick.until_needs_year;
|
|
144
|
+
const pickOptions = {
|
|
145
|
+
...options,
|
|
146
|
+
sessionRosterPicks: state.session ? state.session.picks : {},
|
|
147
|
+
projectRosterPicks: state.project.picks,
|
|
148
|
+
machineRosterPicks: state.machine.picks,
|
|
149
|
+
};
|
|
150
|
+
const base = {
|
|
151
|
+
member: name,
|
|
152
|
+
title: card.role || '',
|
|
153
|
+
found: card.found || Boolean(line),
|
|
154
|
+
source: override ? 'file' : 'auto',
|
|
155
|
+
file: override ? line.file : null,
|
|
156
|
+
line: override ? line.line : null,
|
|
157
|
+
...(read && read.error ? { warning: read.error } : {}),
|
|
158
|
+
...(yearless ? { until_warning: `has no year in its until date, so it counts as expired; write until ${yearless}` } : {}),
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (override && override.type === 'pick') {
|
|
162
|
+
const role = ENGINE_JOBS[autoKind];
|
|
163
|
+
const registry = readEngineRegistry(root, { persist: false });
|
|
164
|
+
const choice = rosterChoiceForPick(override.pick, role, registry, { label: autoKind, source: line.scope, now: options.now });
|
|
165
|
+
if (choice) {
|
|
166
|
+
return {
|
|
167
|
+
...base,
|
|
168
|
+
job: autoKind,
|
|
169
|
+
job_key: role,
|
|
170
|
+
role,
|
|
171
|
+
engine: choice.engine,
|
|
172
|
+
model: choice.engine.roster_model || '',
|
|
173
|
+
pick_source: 'team',
|
|
174
|
+
roster_backed: true,
|
|
175
|
+
reason: `${name} does ${autoKind}: ${engineText(choice.engine)}`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const jobKey = override && override.type === 'job' ? override.key : ENGINE_JOBS[autoKind];
|
|
181
|
+
const role = rosterJobRole(jobKey, root, pickOptions) || ENGINE_JOBS[autoKind];
|
|
182
|
+
const custom = !ENGINE_ROLES.includes(jobKey);
|
|
183
|
+
const resolved = resolveEngineForRoleRanked(role, root, { ...pickOptions, ...(custom ? { job: jobKey } : {}) });
|
|
184
|
+
const job = rosterJobLabel(jobKey);
|
|
185
|
+
const fromRoster = rosterDecided(resolved.source);
|
|
186
|
+
return {
|
|
187
|
+
...base,
|
|
188
|
+
job,
|
|
189
|
+
job_key: jobKey,
|
|
190
|
+
role,
|
|
191
|
+
engine: resolved.engine || null,
|
|
192
|
+
model: resolved.engine && resolved.engine.roster_model ? resolved.engine.roster_model : '',
|
|
193
|
+
pick_source: resolved.source,
|
|
194
|
+
roster_backed: fromRoster || Boolean(override),
|
|
195
|
+
reason: `${name} does ${job}: ${engineText(resolved.engine)}`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The member's engine only when the roster decided it and the member is on
|
|
200
|
+
// the team (a MEMBER.md or a team line). null means keep the old routing.
|
|
201
|
+
// Never throws: a broken roster must not stop a run.
|
|
202
|
+
function memberRosterEngine(memberName, root = process.cwd(), options = {}) {
|
|
203
|
+
const name = String(memberName || '').trim();
|
|
204
|
+
if (!name || name.includes('/') || name.includes('\\')) return null;
|
|
205
|
+
try {
|
|
206
|
+
const picked = resolveEngineForMember(name, root, options);
|
|
207
|
+
if (!picked.found && options.requireMember !== false) return null;
|
|
208
|
+
return picked.roster_backed && picked.engine ? picked : null;
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// One row per member for `atris engine roster`, plus a warning for every
|
|
215
|
+
// team line that could not be used.
|
|
216
|
+
function teamRosterView(root = process.cwd(), options = {}) {
|
|
217
|
+
const state = options.rosterState || readRosterState(root, options);
|
|
218
|
+
const names = new Set(listTeamMembers(root));
|
|
219
|
+
const warnings = [];
|
|
220
|
+
for (const layer of [state.session, state.project, state.machine]) {
|
|
221
|
+
for (const line of (layer && layer.team) || []) {
|
|
222
|
+
if (findMemberCard(root, line.member)) names.add(line.member);
|
|
223
|
+
else if (layer.scope === 'project') warnings.push({ file: line.file, line: line.line, text: line.text, message: `there is no team member named ${line.member}, so the line does nothing` });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const rows = [...names].sort().map((name) => {
|
|
227
|
+
const picked = resolveEngineForMember(name, root, { ...options, rosterState: state });
|
|
228
|
+
const line = teamLineFor(state, name);
|
|
229
|
+
if (picked.warning && line) {
|
|
230
|
+
warnings.push({ file: line.file, line: line.line, text: line.text, message: `${picked.warning}, so ${name} picks automatically` });
|
|
231
|
+
}
|
|
232
|
+
if (picked.until_warning && line) warnings.push({ file: line.file, line: line.line, text: line.text, message: picked.until_warning });
|
|
233
|
+
return {
|
|
234
|
+
member: name,
|
|
235
|
+
job: picked.job,
|
|
236
|
+
engine: picked.engine ? picked.engine.id : null,
|
|
237
|
+
model: picked.model || null,
|
|
238
|
+
effort: picked.engine && picked.engine.roster_effort ? picked.engine.roster_effort : null,
|
|
239
|
+
max_seconds: picked.engine && picked.engine.roster_max_seconds ? picked.engine.roster_max_seconds : null,
|
|
240
|
+
source: picked.source,
|
|
241
|
+
file: picked.file,
|
|
242
|
+
reason: picked.reason,
|
|
243
|
+
};
|
|
244
|
+
});
|
|
245
|
+
return { rows, warnings };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = {
|
|
249
|
+
autoJobForMember,
|
|
250
|
+
resolveEngineForMember,
|
|
251
|
+
memberRosterEngine,
|
|
252
|
+
teamRosterView,
|
|
253
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ROSTER.md as text: which lines are job lines, worker lines, and team
|
|
4
|
+
// lines, and edits that touch one job while keeping everything else the
|
|
5
|
+
// person wrote (comments, order, blank lines, notes). What a line means
|
|
6
|
+
// (which engine, which job) lives in engine-registry; this file only knows
|
|
7
|
+
// the shape.
|
|
8
|
+
//
|
|
9
|
+
// Two shapes read side by side. The sectioned shape, which assign writes:
|
|
10
|
+
// ## build
|
|
11
|
+
// - claude code, model: opus 5.5
|
|
12
|
+
// - codex, effort: medium
|
|
13
|
+
// and the older one-line shape, still read as before:
|
|
14
|
+
// build: claude opus 5.5, backup codex
|
|
15
|
+
|
|
16
|
+
const OLD_ROSTER_NOTE = '> edit any time. each line is job: engine and model, an optional effort (low, medium, high, xhigh, max), then optional "backup <engine and model>", "max 20 min", and "until 2026-10-24" (the year is required).';
|
|
17
|
+
const ROSTER_NOTE = '> edit any time. each "## job" lists its workers in order, one per line: "- tool, model: name", then optional "effort: medium", "max: 20 min", and "until 2026-10-24" (the year is required). the first ready worker leads and the rest back it up.';
|
|
18
|
+
|
|
19
|
+
const ROSTER_MARKDOWN_TEMPLATE = [
|
|
20
|
+
'# roster',
|
|
21
|
+
'',
|
|
22
|
+
ROSTER_NOTE,
|
|
23
|
+
'',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function stripInlineComments(text) {
|
|
27
|
+
return String(text || '').replace(/<!--.*?-->/g, '').trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Sections: anything under a top heading, "## jobs", or "## roster" holds
|
|
31
|
+
// one-line job lines; "## team" holds member lines; any other heading is a
|
|
32
|
+
// job whose "- " lines are its workers. Other lines under a job heading are
|
|
33
|
+
// free notes that are never read as picks.
|
|
34
|
+
function sectionForHeading(level, title) {
|
|
35
|
+
const name = String(title || '').trim().toLowerCase();
|
|
36
|
+
if (level === 1) return 'jobs';
|
|
37
|
+
if (name === 'team') return 'team';
|
|
38
|
+
if (name === 'jobs' || name === 'roster') return 'jobs';
|
|
39
|
+
return 'job';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function scanRosterMarkdown(text) {
|
|
43
|
+
const source = String(text || '');
|
|
44
|
+
const newline = source.includes('\r\n') ? '\r\n' : '\n';
|
|
45
|
+
const lines = source.split(/\r?\n/);
|
|
46
|
+
const entries = [];
|
|
47
|
+
const headings = [];
|
|
48
|
+
let section = 'jobs';
|
|
49
|
+
let heading = null;
|
|
50
|
+
let inComment = false;
|
|
51
|
+
lines.forEach((raw, index) => {
|
|
52
|
+
const line = raw.trim();
|
|
53
|
+
if (inComment) {
|
|
54
|
+
if (line.includes('-->')) inComment = false;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!line) return;
|
|
58
|
+
if (line.startsWith('<!--')) {
|
|
59
|
+
if (!line.includes('-->')) inComment = true;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (line.startsWith('>') || line.startsWith('```')) return;
|
|
63
|
+
const headingMatch = /^(#{1,6})\s*(.*)$/.exec(line);
|
|
64
|
+
if (headingMatch) {
|
|
65
|
+
section = sectionForHeading(headingMatch[1].length, headingMatch[2]);
|
|
66
|
+
heading = { index, lineNumber: index + 1, level: headingMatch[1].length, title: stripInlineComments(headingMatch[2]), raw: line, section };
|
|
67
|
+
headings.push(heading);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const dashed = /^-\s+/.test(line);
|
|
71
|
+
if (line.startsWith('-') && !dashed) return;
|
|
72
|
+
const body = stripInlineComments(dashed ? line.replace(/^-\s+/, '') : line);
|
|
73
|
+
if (!body) return;
|
|
74
|
+
if (section === 'job') {
|
|
75
|
+
// Only "- " lines under a job heading are workers.
|
|
76
|
+
if (!dashed) return;
|
|
77
|
+
entries.push({ index, lineNumber: index + 1, section, raw: line, name: '', value: body, heading: heading.title, headingIndex: heading.index, dashed, malformed: false });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// A "- " bullet under the jobs heading is a note; under team it is a
|
|
81
|
+
// member line.
|
|
82
|
+
if (dashed && section !== 'team') return;
|
|
83
|
+
const colon = body.indexOf(':');
|
|
84
|
+
if (dashed && colon <= 0) return;
|
|
85
|
+
entries.push({
|
|
86
|
+
index,
|
|
87
|
+
lineNumber: index + 1,
|
|
88
|
+
section,
|
|
89
|
+
raw: line,
|
|
90
|
+
name: colon > 0 ? body.slice(0, colon).trim() : '',
|
|
91
|
+
value: colon > 0 ? body.slice(colon + 1).trim() : '',
|
|
92
|
+
dashed,
|
|
93
|
+
malformed: colon <= 0,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
return { lines, entries, headings, newline };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function joinLines(lines, newline) {
|
|
100
|
+
const text = lines.join(newline);
|
|
101
|
+
return text.endsWith(newline) ? text : `${text}${newline}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function trimTrailingBlank(lines) {
|
|
105
|
+
const out = [...lines];
|
|
106
|
+
while (out.length && !out[out.length - 1].trim()) out.pop();
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isBlank(line) {
|
|
111
|
+
return !String(line || '').trim();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// One job's section: its heading, the index where it ends (the next heading
|
|
115
|
+
// or the end of the file), and its worker lines in order.
|
|
116
|
+
function jobSections(scan) {
|
|
117
|
+
return scan.headings
|
|
118
|
+
.map((heading, i) => ({ heading, next: scan.headings[i + 1] }))
|
|
119
|
+
.filter(({ heading }) => heading.section === 'job')
|
|
120
|
+
.map(({ heading, next }) => ({
|
|
121
|
+
heading,
|
|
122
|
+
end: next ? next.index : scan.lines.length,
|
|
123
|
+
workers: scan.entries.filter((entry) => entry.section === 'job' && entry.headingIndex === heading.index),
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Two headings say the same thing when they differ only in case or spacing.
|
|
128
|
+
function sameHeading(a, b) {
|
|
129
|
+
const plain = (text) => stripInlineComments(String(text || '').replace(/^#+\s*/, '')).replace(/\s+/g, ' ').toLowerCase();
|
|
130
|
+
return plain(a) === plain(b);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Replace one job's worker lines with lines (in order), add the section when
|
|
134
|
+
// it is missing, or remove every section for the job when lines is null. matches(title)
|
|
135
|
+
// names the section by its heading; heading is the text for a new one, and
|
|
136
|
+
// when given it also replaces an existing heading that says something else
|
|
137
|
+
// (a note at the end of the old heading stays). Every other line outside that
|
|
138
|
+
// job's workers stays as written.
|
|
139
|
+
function replaceJobSection(text, { matches, heading, lines: workerLines }) {
|
|
140
|
+
const base = String(text || '').trim() ? String(text) : ROSTER_MARKDOWN_TEMPLATE.join('\n');
|
|
141
|
+
const scan = scanRosterMarkdown(base);
|
|
142
|
+
const lines = [...scan.lines];
|
|
143
|
+
const sections = jobSections(scan).filter((entry) => matches(entry.heading.title));
|
|
144
|
+
const section = sections[0];
|
|
145
|
+
if (section && workerLines === null) {
|
|
146
|
+
// Every section for the job goes, last first so earlier indexes hold; a
|
|
147
|
+
// second section that was ignored must not become the job.
|
|
148
|
+
for (const entry of [...sections].reverse()) {
|
|
149
|
+
let start = entry.heading.index;
|
|
150
|
+
let end = Math.min(entry.end, lines.length);
|
|
151
|
+
// Take the blank lines after the section with it, so no double gap stays.
|
|
152
|
+
while (end > start && isBlank(lines[end - 1])) end -= 1;
|
|
153
|
+
while (end < lines.length && isBlank(lines[end])) end += 1;
|
|
154
|
+
if (end >= lines.length) while (start > 0 && isBlank(lines[start - 1])) start -= 1;
|
|
155
|
+
lines.splice(start, end - start);
|
|
156
|
+
}
|
|
157
|
+
return joinLines(trimTrailingBlank(lines), scan.newline);
|
|
158
|
+
}
|
|
159
|
+
if (section) {
|
|
160
|
+
const at = section.workers.length ? section.workers[0].index : section.heading.index + 1;
|
|
161
|
+
for (const worker of [...section.workers].reverse()) lines.splice(worker.index, 1);
|
|
162
|
+
lines.splice(at, 0, ...workerLines);
|
|
163
|
+
if (heading && !sameHeading(section.heading.raw, heading)) {
|
|
164
|
+
const note = /\s*(<!--.*?-->)\s*$/.exec(section.heading.raw);
|
|
165
|
+
lines[section.heading.index] = note ? `${heading} ${note[1]}` : heading;
|
|
166
|
+
}
|
|
167
|
+
return joinLines(trimTrailingBlank(lines), scan.newline);
|
|
168
|
+
}
|
|
169
|
+
if (workerLines === null) return joinLines(trimTrailingBlank(lines), scan.newline);
|
|
170
|
+
// A new job goes right before the team, else at the end.
|
|
171
|
+
const team = scan.headings.find((entry) => entry.section === 'team');
|
|
172
|
+
const block = [heading, ...workerLines];
|
|
173
|
+
if (team) {
|
|
174
|
+
let at = team.index;
|
|
175
|
+
while (at > 0 && isBlank(lines[at - 1])) at -= 1;
|
|
176
|
+
lines.splice(at, 0, ...(at > 0 ? [''] : []), ...block);
|
|
177
|
+
return joinLines(trimTrailingBlank(lines), scan.newline);
|
|
178
|
+
}
|
|
179
|
+
const trimmed = trimTrailingBlank(lines);
|
|
180
|
+
return joinLines([...trimmed, ...(trimmed.length ? [''] : []), ...block], scan.newline);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Replace every line for which rewrite(entry) returns a string, leaving the
|
|
184
|
+
// rest of the file byte for byte.
|
|
185
|
+
function rewriteRosterValues(text, rewrite) {
|
|
186
|
+
const scan = scanRosterMarkdown(text);
|
|
187
|
+
const lines = [...scan.lines];
|
|
188
|
+
for (const entry of scan.entries) {
|
|
189
|
+
const next = rewrite(entry);
|
|
190
|
+
if (typeof next !== 'string') continue;
|
|
191
|
+
const indent = /^\s*/.exec(lines[entry.index])[0];
|
|
192
|
+
lines[entry.index] = `${indent}${next}`;
|
|
193
|
+
}
|
|
194
|
+
return joinLines(trimTrailingBlank(lines), scan.newline);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = {
|
|
198
|
+
OLD_ROSTER_NOTE,
|
|
199
|
+
ROSTER_NOTE,
|
|
200
|
+
ROSTER_MARKDOWN_TEMPLATE,
|
|
201
|
+
scanRosterMarkdown,
|
|
202
|
+
jobSections,
|
|
203
|
+
replaceJobSection,
|
|
204
|
+
rewriteRosterValues,
|
|
205
|
+
joinLines,
|
|
206
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// What model and effort each roster line really runs, and where each came
|
|
4
|
+
// from, so the roster view never shows an engine with a blank model. A pin
|
|
5
|
+
// on the line wins; with no pin, codex reads the top of its own settings
|
|
6
|
+
// file, claude-family engines run the atris default, and every other engine
|
|
7
|
+
// rides its own default. Nothing here spawns an engine or goes online.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const {
|
|
13
|
+
RUNNER_PROFILE_DEFS,
|
|
14
|
+
RUNNER_PROFILE_ALIASES,
|
|
15
|
+
DEFAULT_CLAUDE_RUNNER_MODEL,
|
|
16
|
+
} = require('./runner-command');
|
|
17
|
+
|
|
18
|
+
const CLAUDE_FAMILY = Object.freeze(['claude', 'fable', 'haiku']);
|
|
19
|
+
const ATRIS_PINNED = Object.freeze(['atris-fast', 'composer']);
|
|
20
|
+
|
|
21
|
+
// ~/.codex/config.toml unless a path is given. Test runs never read the real
|
|
22
|
+
// file unless they point at one on purpose.
|
|
23
|
+
function codexConfigFile(options = {}) {
|
|
24
|
+
if (options.codexConfigPath) return options.codexConfigPath;
|
|
25
|
+
if (process.env.ATRIS_CODEX_CONFIG_PATH) return process.env.ATRIS_CODEX_CONFIG_PATH;
|
|
26
|
+
if (process.env.NODE_TEST_CONTEXT) return '';
|
|
27
|
+
return path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'config.toml');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The top-level model and model_reasoning_effort, the lines before the first
|
|
31
|
+
// [section]. A tiny line reader, not a toml parser: anything else is ignored.
|
|
32
|
+
function readCodexSettings(options = {}) {
|
|
33
|
+
const file = codexConfigFile(options);
|
|
34
|
+
if (!file) return {};
|
|
35
|
+
let text = '';
|
|
36
|
+
try {
|
|
37
|
+
text = fs.readFileSync(file, 'utf8');
|
|
38
|
+
} catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
const settings = {};
|
|
42
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
43
|
+
const line = raw.trim();
|
|
44
|
+
if (line.startsWith('[')) break;
|
|
45
|
+
const match = /^(model|model_reasoning_effort)\s*=\s*(?:"([^"]*)"|'([^']*)')\s*(?:#.*)?$/.exec(line);
|
|
46
|
+
if (match) settings[match[1]] = (match[2] !== undefined ? match[2] : match[3]).trim();
|
|
47
|
+
}
|
|
48
|
+
return settings;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// claude-opus-5-5 reads as "opus 5.5"; anything else as written.
|
|
52
|
+
function modelLabel(model) {
|
|
53
|
+
const text = String(model || '').trim();
|
|
54
|
+
const grok = /^grok-(\d+(?:\.\d+)?)(-build-fast)?$/.exec(text);
|
|
55
|
+
if (grok) return `grok ${grok[1]}${grok[2] ? ' fast' : ''}`;
|
|
56
|
+
const match = /^claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/.exec(text);
|
|
57
|
+
if (!match) return text;
|
|
58
|
+
return `${match[1]} ${match[2]}${match[3] ? `.${match[3]}` : ''}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const SOURCE_TAIL = Object.freeze({ 'codex settings': 'from codex settings', 'atris default': 'atris default' });
|
|
62
|
+
const SOURCE_INLINE = Object.freeze({ 'codex settings': 'from codex settings', 'atris default': 'by atris default' });
|
|
63
|
+
|
|
64
|
+
// One engine's real model and effort. Sources: roster (pinned on the line),
|
|
65
|
+
// codex settings, atris default, or own default (the engine decides).
|
|
66
|
+
function engineRunsView(engineId, { model = '', effort = '' } = {}, options = {}) {
|
|
67
|
+
const id = RUNNER_PROFILE_ALIASES[engineId] || engineId;
|
|
68
|
+
const def = RUNNER_PROFILE_DEFS[id] || {};
|
|
69
|
+
const view = { engine: id, model: '', model_source: 'own default', effort: '', effort_source: '' };
|
|
70
|
+
if (model) {
|
|
71
|
+
view.model = model;
|
|
72
|
+
view.model_source = 'roster';
|
|
73
|
+
} else if (id === 'codex') {
|
|
74
|
+
const settings = readCodexSettings(options);
|
|
75
|
+
if (settings.model) {
|
|
76
|
+
view.model = settings.model;
|
|
77
|
+
view.model_source = 'codex settings';
|
|
78
|
+
}
|
|
79
|
+
} else if (CLAUDE_FAMILY.includes(id)) {
|
|
80
|
+
view.model = String(process.env.ATRIS_RUNNER_MODEL || '').trim() || def.model || DEFAULT_CLAUDE_RUNNER_MODEL;
|
|
81
|
+
view.model_source = 'atris default';
|
|
82
|
+
} else if (ATRIS_PINNED.includes(id) && def.model) {
|
|
83
|
+
view.model = def.model;
|
|
84
|
+
view.model_source = 'atris default';
|
|
85
|
+
}
|
|
86
|
+
if (effort) {
|
|
87
|
+
view.effort = effort;
|
|
88
|
+
view.effort_source = 'roster';
|
|
89
|
+
} else if (id === 'codex') {
|
|
90
|
+
const settings = readCodexSettings(options);
|
|
91
|
+
if (settings.model_reasoning_effort) {
|
|
92
|
+
view.effort = settings.model_reasoning_effort;
|
|
93
|
+
view.effort_source = 'codex settings';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
view.text = `${id} (${runsDetail(view)})`;
|
|
97
|
+
return view;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function runsDetail(view) {
|
|
101
|
+
const items = [{ text: view.model ? modelLabel(view.model) : 'its own default', source: view.model ? view.model_source : 'roster' }];
|
|
102
|
+
if (view.effort) items.push({ text: view.effort, source: view.effort_source });
|
|
103
|
+
const sourced = [...new Set(items.map((item) => item.source).filter((source) => SOURCE_TAIL[source]))];
|
|
104
|
+
// Every item shares one source: say it once at the end.
|
|
105
|
+
if (sourced.length === 1 && items.every((item) => item.source === sourced[0])) {
|
|
106
|
+
return [...items.map((item) => item.text), SOURCE_TAIL[sourced[0]]].join(', ');
|
|
107
|
+
}
|
|
108
|
+
return items.map((item) => (SOURCE_INLINE[item.source] ? `${item.text} ${SOURCE_INLINE[item.source]}` : item.text)).join(', ');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- what this machine offers --------------------------------------------
|
|
112
|
+
//
|
|
113
|
+
// Cheap local sources only: never the network, never the tool itself. Codex
|
|
114
|
+
// keeps a cache of the models it can run; claude takes the names atris knows;
|
|
115
|
+
// every other engine lists its seed models.
|
|
116
|
+
|
|
117
|
+
const KNOWN_CLAUDE_MODELS = Object.freeze(['opus 5.5', 'opus 5', 'sonnet 5', 'haiku 4.5', 'fable 5.1']);
|
|
118
|
+
|
|
119
|
+
// ~/.codex/models_cache.json unless a path is given. Test runs never read the
|
|
120
|
+
// real file unless they point at one on purpose.
|
|
121
|
+
function codexModelsCacheFile(options = {}) {
|
|
122
|
+
if (options.codexModelsCachePath) return options.codexModelsCachePath;
|
|
123
|
+
if (process.env.ATRIS_CODEX_MODELS_CACHE_PATH) return process.env.ATRIS_CODEX_MODELS_CACHE_PATH;
|
|
124
|
+
if (process.env.NODE_TEST_CONTEXT) return '';
|
|
125
|
+
return path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'models_cache.json');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Every "slug" in the cache, in order, once each. [] when there is no cache
|
|
129
|
+
// or it does not parse.
|
|
130
|
+
function readCodexModelSlugs(options = {}) {
|
|
131
|
+
const file = codexModelsCacheFile(options);
|
|
132
|
+
if (!file) return [];
|
|
133
|
+
let data;
|
|
134
|
+
try {
|
|
135
|
+
data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
const slugs = [];
|
|
140
|
+
const walk = (value, depth) => {
|
|
141
|
+
if (depth > 6 || !value || typeof value !== 'object') return;
|
|
142
|
+
if (Array.isArray(value)) {
|
|
143
|
+
value.forEach((item) => walk(item, depth + 1));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (typeof value.slug === 'string' && value.slug.trim() && !slugs.includes(value.slug.trim())) slugs.push(value.slug.trim());
|
|
147
|
+
for (const child of Object.values(value)) if (child && typeof child === 'object') walk(child, depth + 1);
|
|
148
|
+
};
|
|
149
|
+
walk(data, 0);
|
|
150
|
+
return slugs;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function homeLabel(file) {
|
|
154
|
+
const home = os.homedir();
|
|
155
|
+
const rel = path.relative(home, file);
|
|
156
|
+
return rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? `~/${rel}` : file;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// One row per installed engine: the models it offers and where that list
|
|
160
|
+
// came from ("from <file>" or "known names").
|
|
161
|
+
function availableModels(engines, options = {}) {
|
|
162
|
+
return (engines || []).map((engine) => {
|
|
163
|
+
const id = engine.id;
|
|
164
|
+
if (id === 'codex') {
|
|
165
|
+
const slugs = readCodexModelSlugs(options);
|
|
166
|
+
if (slugs.length) return { engine: id, models: slugs, source: `from ${homeLabel(codexModelsCacheFile(options))}` };
|
|
167
|
+
}
|
|
168
|
+
if (id === 'claude') return { engine: id, models: [...KNOWN_CLAUDE_MODELS], source: 'known names' };
|
|
169
|
+
const models = Array.isArray(engine.models) ? engine.models.filter(Boolean) : [];
|
|
170
|
+
return { engine: id, models, source: 'known names' };
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
modelLabel,
|
|
176
|
+
readCodexSettings,
|
|
177
|
+
engineRunsView,
|
|
178
|
+
availableModels,
|
|
179
|
+
};
|