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.
Files changed (95) hide show
  1. package/AGENTS.md +3 -2
  2. package/GETTING_STARTED.md +2 -2
  3. package/PERSONA.md +11 -11
  4. package/README.md +1 -1
  5. package/atris/GEMINI.md +7 -6
  6. package/atris/PERSONA.md +4 -4
  7. package/atris/features/README.md +77 -6
  8. package/atris/skills/README.md +44 -15
  9. package/atris/skills/aeo/SKILL.md +10 -10
  10. package/atris/skills/atris/SKILL.md +1 -1
  11. package/atris/skills/engines/SKILL.md +69 -50
  12. package/atris/skills/meta/SKILL.md +4 -4
  13. package/bin/atris.js +8 -6
  14. package/commands/aeo.js +9 -12
  15. package/commands/analytics.js +8 -11
  16. package/commands/autopilot.js +79 -8
  17. package/commands/avail.js +1 -1
  18. package/commands/brain.js +3 -1
  19. package/commands/business.js +13 -8
  20. package/commands/clean.js +17 -0
  21. package/commands/close.js +2 -1
  22. package/commands/computer.js +15 -10
  23. package/commands/context-sync.js +3 -3
  24. package/commands/doc-health.js +55 -2
  25. package/commands/engine.js +384 -4
  26. package/commands/experiments.js +13 -4
  27. package/commands/host.js +53 -0
  28. package/commands/improve.js +1 -1
  29. package/commands/integrations.js +2 -5
  30. package/commands/learn.js +1 -1
  31. package/commands/member.js +231 -53
  32. package/commands/mission.js +129 -19
  33. package/commands/one-lap.js +58 -3
  34. package/commands/pack.js +2 -2
  35. package/commands/probe.js +11 -5
  36. package/commands/pull.js +1 -1
  37. package/commands/push.js +1 -1
  38. package/commands/rainmaker.js +3 -16
  39. package/commands/run.js +4 -1
  40. package/commands/scout.js +1 -1
  41. package/commands/search.js +5 -3
  42. package/commands/spaceship.js +1 -1
  43. package/commands/stream.js +27 -2
  44. package/commands/study.js +15 -9
  45. package/commands/sync.js +1 -1
  46. package/commands/task.js +13 -2
  47. package/commands/unknowns.js +5 -0
  48. package/commands/verify.js +12 -4
  49. package/commands/visualize.js +20 -3
  50. package/commands/workflow.js +23 -7
  51. package/lib/codex-flight.js +28 -5
  52. package/lib/dispatch-scout.js +31 -22
  53. package/lib/engine-registry.js +1482 -13
  54. package/lib/feedback-triage.js +2 -0
  55. package/lib/fleet.js +111 -12
  56. package/lib/host.js +336 -0
  57. package/lib/improve-vitals-html.js +3 -2
  58. package/lib/known-commands.js +1 -1
  59. package/lib/lesson-contradiction.js +2 -2
  60. package/lib/map-refs.js +226 -0
  61. package/lib/member-engine.js +253 -0
  62. package/lib/mission-room.js +4 -4
  63. package/lib/noninteractive.js +15 -4
  64. package/lib/policy-lessons.js +1 -1
  65. package/lib/receipt-block.js +137 -28
  66. package/lib/roster-markdown.js +206 -0
  67. package/lib/roster-models.js +179 -0
  68. package/lib/runner-command.js +95 -15
  69. package/lib/scorecard.js +4 -4
  70. package/lib/self-drive.js +9 -1
  71. package/lib/sleep-sync.js +11 -0
  72. package/lib/state-detection.js +28 -9
  73. package/lib/task-db.js +3 -2
  74. package/lib/todo-fallback.js +1 -1
  75. package/lib/wish-audit.js +10 -6
  76. package/lib/wish-delegate.js +29 -5
  77. package/mcp/atris-mcp/index.mjs +77 -24
  78. package/package.json +2 -5
  79. package/scripts/det/det.js +2 -2
  80. package/scripts/det/pr-description.js +2 -2
  81. package/scripts/det/test.js +2 -2
  82. package/templates/members/host/MEMBER.md +23 -0
  83. package/templates/members/host/MISSION.md +11 -0
  84. package/templates/members/host/SOUL.md +7 -0
  85. package/templates/members/host/context/why.md +3 -0
  86. package/templates/members/host/goals.json +5 -0
  87. package/templates/members/host/goals.md +3 -0
  88. package/templates/members/host/skills/host/SKILL.md +13 -0
  89. package/utils/api.js +1 -1
  90. package/utils/auth.js +17 -7
  91. package/utils/backend-root.js +31 -0
  92. package/utils/config.js +5 -4
  93. package/utils/owner-identity.js +61 -0
  94. package/atris/policies/LESSONS.md +0 -13
  95. package/atris/skills/autopilot/atris-autopilot.md +0 -71
@@ -98,18 +98,116 @@ function directProofText(value) {
98
98
  return ran ? `${ran[1]} passed` : text;
99
99
  }
100
100
 
101
- function proofText(receipt) {
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 || 'configured 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 'receipt says verifier passed';
111
- if (receipt?.at) return `receipt saved at ${lowerLine(receipt.at)}`;
112
- return 'receipt is present';
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 = historicalLandingText(trimPunctuation(changedText(receipt)), 180);
122
- const scale = scaleText(receipt);
123
- const proof = historicalLandingText(trimPunctuation(proofText(receipt)), 90);
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 `- mission: ${renderEmailLine(receipt).replace(/\.$/, '')}`;
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 lines = [
134
- '## mission receipt',
135
- '',
136
- `- what: ${truncateLine(trimPunctuation(changedText(receipt)), 160)}`,
137
- `- how big: ${scaleText(receipt)}`,
138
- `- how we know: ${truncateLine(trimPunctuation(proofText(receipt)), 160)}`,
139
- `- status: ${statusText(receipt)}`,
140
- ];
141
- if (receipt?.mission_id) lines.push(`- mission: ${lowerLine(receipt.mission_id)}`);
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 status = statusText(receipt);
148
- const what = truncateLine(trimPunctuation(changedText(receipt)), 64);
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: `${status} mission receipt`,
156
- sub: `${scale}; ${proof}`,
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
+ };