atris 3.58.7 → 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/GETTING_STARTED.md +2 -2
- package/PERSONA.md +11 -11
- package/README.md +1 -1
- package/atris/GEMINI.md +7 -6
- package/atris/PERSONA.md +4 -4
- package/atris/features/README.md +77 -6
- package/atris/skills/README.md +44 -15
- package/atris/skills/aeo/SKILL.md +10 -10
- package/atris/skills/atris/SKILL.md +1 -1
- package/atris/skills/engines/SKILL.md +69 -50
- package/atris/skills/meta/SKILL.md +4 -4
- package/bin/atris.js +8 -6
- package/commands/aeo.js +9 -12
- package/commands/analytics.js +8 -11
- package/commands/autopilot.js +79 -8
- package/commands/avail.js +1 -1
- package/commands/brain.js +3 -1
- package/commands/business.js +13 -8
- package/commands/clean.js +17 -0
- package/commands/close.js +2 -1
- package/commands/computer.js +15 -10
- package/commands/context-sync.js +3 -3
- package/commands/doc-health.js +55 -2
- package/commands/engine.js +384 -4
- package/commands/experiments.js +13 -4
- package/commands/host.js +53 -0
- package/commands/improve.js +1 -1
- package/commands/integrations.js +2 -5
- package/commands/learn.js +1 -1
- package/commands/member.js +231 -53
- package/commands/mission.js +129 -19
- package/commands/one-lap.js +58 -3
- package/commands/pack.js +2 -2
- package/commands/probe.js +11 -5
- package/commands/pull.js +1 -1
- package/commands/push.js +1 -1
- package/commands/rainmaker.js +3 -16
- package/commands/run.js +4 -1
- package/commands/scout.js +1 -1
- package/commands/search.js +5 -3
- package/commands/spaceship.js +1 -1
- package/commands/stream.js +27 -2
- package/commands/study.js +15 -9
- package/commands/sync.js +1 -1
- package/commands/task.js +13 -2
- package/commands/unknowns.js +5 -0
- package/commands/verify.js +12 -4
- package/commands/visualize.js +20 -3
- package/commands/workflow.js +23 -7
- package/lib/codex-flight.js +28 -5
- 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 +111 -12
- package/lib/host.js +336 -0
- package/lib/improve-vitals-html.js +3 -2
- package/lib/known-commands.js +1 -1
- package/lib/lesson-contradiction.js +2 -2
- package/lib/map-refs.js +226 -0
- package/lib/member-engine.js +253 -0
- package/lib/mission-room.js +4 -4
- package/lib/noninteractive.js +15 -4
- package/lib/policy-lessons.js +1 -1
- package/lib/receipt-block.js +137 -28
- package/lib/roster-markdown.js +206 -0
- package/lib/roster-models.js +179 -0
- package/lib/runner-command.js +95 -15
- package/lib/scorecard.js +4 -4
- package/lib/self-drive.js +9 -1
- package/lib/sleep-sync.js +11 -0
- package/lib/state-detection.js +28 -9
- package/lib/task-db.js +3 -2
- package/lib/todo-fallback.js +1 -1
- package/lib/wish-audit.js +10 -6
- package/lib/wish-delegate.js +29 -5
- package/mcp/atris-mcp/index.mjs +77 -24
- package/package.json +2 -5
- package/scripts/det/det.js +2 -2
- package/scripts/det/pr-description.js +2 -2
- package/scripts/det/test.js +2 -2
- 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/utils/api.js +1 -1
- package/utils/auth.js +17 -7
- package/utils/backend-root.js +31 -0
- package/utils/config.js +5 -4
- package/utils/owner-identity.js +61 -0
- package/atris/policies/LESSONS.md +0 -13
- package/atris/skills/autopilot/atris-autopilot.md +0 -71
package/lib/receipt-block.js
CHANGED
|
@@ -98,18 +98,116 @@ function directProofText(value) {
|
|
|
98
98
|
return ran ? `${ran[1]} passed` : text;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
// A receipt is a text from a good employee: one sentence a friend would send,
|
|
102
|
+
// plus one link a stranger can check. Ids, statuses, scores and verifier
|
|
103
|
+
// output stay underneath (they feed the rating); they never lead the surface.
|
|
104
|
+
// Standard: atris-labs atris/wiki/systems/receipt-standard.md
|
|
105
|
+
|
|
106
|
+
function sentence(value) {
|
|
107
|
+
const text = trimPunctuation(value);
|
|
108
|
+
if (!text) return '';
|
|
109
|
+
return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Bracketed IPv6 hosts need their own alternative: the plain class stops at
|
|
113
|
+
// ] and would cut http://[::1]:3000 down to http://[::1 .
|
|
114
|
+
const URL_RE = /https?:\/\/\[[0-9a-f.:]+\][^\s)>\]]*|https?:\/\/[^\s)>\]]+/i;
|
|
115
|
+
const URL_RE_GLOBAL = new RegExp(URL_RE.source, 'gi');
|
|
116
|
+
|
|
117
|
+
// A stranger cannot open localhost, loopback or *.local addresses, so they
|
|
118
|
+
// never count as the check link no matter which field they came from.
|
|
119
|
+
// Parsing with URL normalizes tricks like 127.1 or 0x7f.1 into 127.0.0.1.
|
|
120
|
+
function localCheckUrl(url) {
|
|
121
|
+
let host;
|
|
122
|
+
try {
|
|
123
|
+
host = new URL(url).hostname.toLowerCase();
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
|
|
128
|
+
return host === 'localhost'
|
|
129
|
+
|| host === '0.0.0.0'
|
|
130
|
+
|| host === '::1'
|
|
131
|
+
|| host.startsWith('127.')
|
|
132
|
+
|| host.endsWith('.local');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A checked/tested note that is nothing but a local URL carries no check a
|
|
136
|
+
// stranger can run; the verifier line speaks instead.
|
|
137
|
+
function localOnlyUrl(text) {
|
|
138
|
+
const value = trimPunctuation(text);
|
|
139
|
+
return /^https?:\/\/\S+$/i.test(value) && localCheckUrl(value);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function firstUrl(...values) {
|
|
143
|
+
for (const value of values) {
|
|
144
|
+
if (!value) continue;
|
|
145
|
+
for (const raw of String(value).match(URL_RE_GLOBAL) || []) {
|
|
146
|
+
const url = raw.replace(/[.,;:]+$/, '');
|
|
147
|
+
if (!localCheckUrl(url)) return url;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return '';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The link a stranger can click. Read explicit link fields first, then any
|
|
154
|
+
// URL the landing text already mentions. Local-only hosts never count, and
|
|
155
|
+
// empty means the receipt has none.
|
|
156
|
+
function checkLink(receipt) {
|
|
157
|
+
const land = landing(receipt);
|
|
158
|
+
const res = result(receipt);
|
|
159
|
+
return firstUrl(
|
|
160
|
+
land.link, land.url, land.check_url, land.proof_url,
|
|
161
|
+
res.link, res.url, res.pr_url, res.share_url, res.tick?.pr_url, res.tick?.share_url,
|
|
162
|
+
land.checked, land.tested,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// What a reader can check: the link when there is one, else the plain
|
|
167
|
+
// check that passed. Never "verifier passed" or "receipt is present".
|
|
168
|
+
function checkText(receipt) {
|
|
169
|
+
const link = checkLink(receipt);
|
|
170
|
+
if (link) return link;
|
|
102
171
|
const land = landing(receipt);
|
|
103
172
|
const verifier = verifierResult(receipt);
|
|
104
|
-
if (land.checked) return directProofText(land.checked);
|
|
105
|
-
if (land.tested) return directProofText(land.tested);
|
|
173
|
+
if (land.checked && !localOnlyUrl(land.checked)) return directProofText(land.checked);
|
|
174
|
+
if (land.tested && !localOnlyUrl(land.tested)) return directProofText(land.tested);
|
|
106
175
|
if (verifier) {
|
|
107
|
-
const command = lowerLine(verifier.command || receipt?.verifier || '
|
|
176
|
+
const command = lowerLine(verifier.command || receipt?.verifier || 'the checks');
|
|
108
177
|
return `${command} ${verifier.passed ? 'passed' : 'failed'}`;
|
|
109
178
|
}
|
|
110
|
-
if (result(receipt).passed === true) return '
|
|
111
|
-
|
|
112
|
-
|
|
179
|
+
if (result(receipt).passed === true) return 'the checks passed';
|
|
180
|
+
return '';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Dollars a mission actually brought in. Read from the landing (or result)
|
|
184
|
+
// so a member can write it next to what happened; the backend sums the same
|
|
185
|
+
// field into the member rating. Only a real number or a plain decimal string
|
|
186
|
+
// may count: Number() alone would read true as 1, [5] as 5, "0x10" as 16 and
|
|
187
|
+
// "1e3" as 1000. Junk and negatives count as zero.
|
|
188
|
+
const PLAIN_DECIMAL_RE = /^\d+(\.\d+)?$/;
|
|
189
|
+
|
|
190
|
+
function plainDecimalAmount(value) {
|
|
191
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
|
|
192
|
+
if (typeof value !== 'string') return 0;
|
|
193
|
+
const text = value.trim();
|
|
194
|
+
return PLAIN_DECIMAL_RE.test(text) ? Number(text) : 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function earnedUsd(receipt) {
|
|
198
|
+
const land = landing(receipt);
|
|
199
|
+
const res = result(receipt);
|
|
200
|
+
for (const value of [land.earned_usd, res.earned_usd, res.tick?.earned_usd]) {
|
|
201
|
+
if (value === undefined || value === null || value === '') continue;
|
|
202
|
+
const amount = plainDecimalAmount(value);
|
|
203
|
+
return amount > 0 ? Math.round(amount * 100) / 100 : 0;
|
|
204
|
+
}
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function usdText(amount) {
|
|
209
|
+
const whole = Number.isInteger(amount);
|
|
210
|
+
return `$${amount.toLocaleString('en-US', { minimumFractionDigits: whole ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
113
211
|
}
|
|
114
212
|
|
|
115
213
|
function nextText(receipt) {
|
|
@@ -117,43 +215,53 @@ function nextText(receipt) {
|
|
|
117
215
|
return lowerLine(text);
|
|
118
216
|
}
|
|
119
217
|
|
|
218
|
+
function whatText(receipt, max = 180) {
|
|
219
|
+
return historicalLandingText(trimPunctuation(changedText(receipt)), max);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// One line: "What happened. Check it: <link or check>."
|
|
223
|
+
function checkLine(receipt, max = 160) {
|
|
224
|
+
const check = trimPunctuation(checkText(receipt));
|
|
225
|
+
if (!check) return '';
|
|
226
|
+
// A link stays bare so it copies clean; a plain check reads as a sentence.
|
|
227
|
+
return firstUrl(check) ? `Check it: ${check}` : `Check it: ${truncateLine(check, max)}.`;
|
|
228
|
+
}
|
|
229
|
+
|
|
120
230
|
function renderEmailLine(receipt) {
|
|
121
|
-
const what =
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
return `${what}; ${scale}; we know because ${proof}.`;
|
|
231
|
+
const what = sentence(whatText(receipt));
|
|
232
|
+
const check = checkLine(receipt);
|
|
233
|
+
return check ? `${what} ${check}` : what;
|
|
125
234
|
}
|
|
126
235
|
|
|
127
236
|
function renderMorningCardRow(receipt) {
|
|
128
|
-
return `-
|
|
237
|
+
return `- ${renderEmailLine(receipt).replace(/\.$/, '')}`;
|
|
129
238
|
}
|
|
130
239
|
|
|
240
|
+
// Three lines lead. The machinery (size, status, mission id) sits below as
|
|
241
|
+
// details, where a builder can find it and a customer never has to read it.
|
|
131
242
|
function renderPageSection(receipt) {
|
|
132
243
|
const next = nextText(receipt);
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (next) lines.push(`- next: ${truncateLine(trimPunctuation(next), 160)}`);
|
|
244
|
+
const check = checkLine(receipt);
|
|
245
|
+
const lines = ['## mission receipt', '', sentence(whatText(receipt, 160))];
|
|
246
|
+
if (check) lines.push(check);
|
|
247
|
+
if (next) lines.push(`Next: ${sentence(truncateLine(next, 160))}`);
|
|
248
|
+
const details = [scaleText(receipt), `status ${statusText(receipt)}`];
|
|
249
|
+
const earned = earnedUsd(receipt);
|
|
250
|
+
if (earned > 0) details.push(`earned ${usdText(earned)}`);
|
|
251
|
+
if (receipt?.mission_id) details.push(`mission ${lowerLine(receipt.mission_id)}`);
|
|
252
|
+
lines.push('', `details: ${details.join('; ')}`);
|
|
143
253
|
return lines.join('\n');
|
|
144
254
|
}
|
|
145
255
|
|
|
146
256
|
function renderCard(receipt) {
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
const scale = scaleText(receipt);
|
|
150
|
-
const proof = truncateLine(trimPunctuation(proofText(receipt)), 88);
|
|
257
|
+
const what = sentence(whatText(receipt, 64));
|
|
258
|
+
const check = trimPunctuation(checkText(receipt));
|
|
151
259
|
return {
|
|
152
260
|
kind: 'statement',
|
|
153
261
|
headline: what,
|
|
154
262
|
text: what,
|
|
155
|
-
kicker:
|
|
156
|
-
sub:
|
|
263
|
+
kicker: 'mission receipt',
|
|
264
|
+
sub: check ? `Check it: ${truncateLine(check, 88)}` : '',
|
|
157
265
|
brand: 'atris',
|
|
158
266
|
size: 'og',
|
|
159
267
|
theme: 'atris',
|
|
@@ -161,6 +269,7 @@ function renderCard(receipt) {
|
|
|
161
269
|
}
|
|
162
270
|
|
|
163
271
|
module.exports = {
|
|
272
|
+
earnedUsd,
|
|
164
273
|
renderCard,
|
|
165
274
|
renderPageSection,
|
|
166
275
|
renderEmailLine,
|
|
@@ -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
|
+
};
|