atris 3.60.0 → 3.62.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 (41) hide show
  1. package/README.md +37 -5
  2. package/atris/skills/engines/SKILL.md +9 -9
  3. package/bin/atris.js +1 -0
  4. package/commands/autoland.js +36 -5
  5. package/commands/autopilot.js +105 -6
  6. package/commands/engine.js +96 -21
  7. package/commands/host.js +56 -4
  8. package/commands/land.js +129 -8
  9. package/commands/member.js +33 -8
  10. package/commands/mission.js +112 -3
  11. package/commands/one-lap.js +14 -10
  12. package/commands/stream.js +56 -22
  13. package/commands/team.js +82 -10
  14. package/commands/worktree.js +39 -0
  15. package/commands/youtube.js +36 -26
  16. package/lib/engine-ask.js +112 -9
  17. package/lib/engine-registry.js +283 -39
  18. package/lib/fleet.js +444 -26
  19. package/lib/functional-owner.js +15 -6
  20. package/lib/host.js +311 -175
  21. package/lib/member-engine.js +11 -1
  22. package/lib/member-park.js +138 -0
  23. package/lib/member-scaffold.js +37 -0
  24. package/lib/roster-markdown.js +1 -1
  25. package/lib/roster-prep.js +289 -0
  26. package/lib/roster-runs.js +389 -0
  27. package/lib/roster-suggest.js +145 -0
  28. package/lib/router-brain.js +111 -3
  29. package/lib/self-drive.js +26 -2
  30. package/lib/state-detection.js +3 -3
  31. package/lib/task-db.js +38 -3
  32. package/lib/team-lineup.js +7 -2
  33. package/lib/todo.js +1 -1
  34. package/lib/wish-audit.js +1 -1
  35. package/package.json +1 -1
  36. package/scripts/det/cpu-hotspots.js +216 -0
  37. package/scripts/det/ytnotes +105 -19
  38. package/scripts/det/ytquote-repair.js +18 -4
  39. package/templates/members/host/MEMBER.md +5 -3
  40. package/templates/members/host/MISSION.md +1 -1
  41. package/templates/members/host/skills/host/SKILL.md +61 -12
@@ -0,0 +1,389 @@
1
+ 'use strict';
2
+
3
+ // One line per run launched from a roster pick, so the owner can judge each
4
+ // tool and model on real work instead of memory. A line says when, which job,
5
+ // which member (if any), the engine, model, effort, time cap, seconds taken,
6
+ // and how it ended: landed, failed, stalled, credit out, and who took over
7
+ // when it was handed on. Tokens and cost appear only when the tool printed
8
+ // them in output we already capture; nothing is guessed.
9
+ //
10
+ // Writing never throws into the run that called it, and reading only looks
11
+ // at the tail of each file, skipping any line that does not parse.
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const RUNS_FILE = path.join('.atris', 'state', 'roster_runs.jsonl');
17
+ // Rotated logs sit beside the live one, named roster_runs.<UTC stamp to the
18
+ // millisecond>-<pid>.jsonl so two writers rotating in the same breath can
19
+ // never rename onto each other. The name starts with a digit so the legacy
20
+ // roster_runs.1.jsonl still counts and roster_runs.pending.jsonl does not.
21
+ const ROTATED_NAME = /^roster_runs\.\d.*\.jsonl$/;
22
+ const ROTATED_KEEP = 3;
23
+ const ROTATED_READ = 2;
24
+ const OUTCOMES = Object.freeze(['landed', 'failed', 'stalled', 'credit out']);
25
+ const DEFAULT_DAYS = 7;
26
+ const TAIL_BYTES = 512 * 1024;
27
+ // Past this size the log is renamed aside before the append, never trimmed:
28
+ // rotation moves whole records instead of cutting the oldest ones away.
29
+ const RUNS_ROTATE_BYTES = 2 * 1024 * 1024;
30
+ const JOB_ALIASES = Object.freeze({ navigator: 'search', executor: 'build', builder: 'build', validator: 'review', reviewer: 'review' });
31
+
32
+ function rosterRunsPath(root = process.cwd()) {
33
+ return path.join(root, RUNS_FILE);
34
+ }
35
+
36
+ // Rotated logs, oldest name first; the stamp in each name makes name order
37
+ // the order they were renamed in.
38
+ function rotatedRunsPaths(root = process.cwd()) {
39
+ const dir = path.dirname(path.join(root, RUNS_FILE));
40
+ let names;
41
+ try {
42
+ names = fs.readdirSync(dir);
43
+ } catch {
44
+ return [];
45
+ }
46
+ return names.filter((name) => ROTATED_NAME.test(name)).sort().map((name) => path.join(dir, name));
47
+ }
48
+
49
+ function nowMs(now) {
50
+ if (now instanceof Date) return now.getTime();
51
+ if (typeof now === 'function') return nowMs(now());
52
+ const value = Number(now);
53
+ return Number.isFinite(value) && now !== null && now !== undefined ? value : Date.now();
54
+ }
55
+
56
+ function clean(value) {
57
+ return String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
58
+ }
59
+
60
+ // "executor", "small-build", and "Small Build" all read as the job's name.
61
+ function jobName(value) {
62
+ const text = clean(value).toLowerCase().replace(/[-_]+/g, ' ');
63
+ return JOB_ALIASES[text] || text;
64
+ }
65
+
66
+ function wholeNumber(value) {
67
+ const number = Number(String(value == null ? '' : value).replace(/,/g, ''));
68
+ return Number.isFinite(number) && number >= 0 ? Math.round(number) : null;
69
+ }
70
+
71
+ function usageTotal(usage) {
72
+ if (!usage || typeof usage !== 'object') return null;
73
+ const parts = ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens']
74
+ .map((key) => wholeNumber(usage[key]))
75
+ .filter((value) => value !== null);
76
+ return parts.length ? parts.reduce((sum, value) => sum + value, 0) : null;
77
+ }
78
+
79
+ // Tokens and cost the tool itself reported, or nothing. Claude's json result
80
+ // carries usage and total_cost_usd; codex prints "tokens used" and the count.
81
+ function parseRunUsage({ usage = null, cost_usd = null, stdout = '', stderr = '' } = {}) {
82
+ const found = {};
83
+ const direct = usageTotal(usage);
84
+ if (direct !== null) found.tokens = direct;
85
+ if (typeof cost_usd === 'number' && Number.isFinite(cost_usd)) found.cost_usd = cost_usd;
86
+ const text = `${String(stdout || '')}\n${String(stderr || '')}`;
87
+ if (found.tokens === undefined) {
88
+ for (const line of text.split(/\r?\n/).reverse()) {
89
+ const trimmed = line.trim();
90
+ if (!trimmed.startsWith('{') || !trimmed.includes('"usage"')) continue;
91
+ try {
92
+ const event = JSON.parse(trimmed);
93
+ const tokens = usageTotal(event && event.usage);
94
+ if (tokens === null) continue;
95
+ found.tokens = tokens;
96
+ if (found.cost_usd === undefined && typeof event.total_cost_usd === 'number') found.cost_usd = event.total_cost_usd;
97
+ break;
98
+ } catch {}
99
+ }
100
+ }
101
+ if (found.tokens === undefined) {
102
+ const codex = /tokens used[:\s]*([\d,]+)/gi;
103
+ let match;
104
+ let last = null;
105
+ while ((match = codex.exec(text))) last = match[1];
106
+ const tokens = wholeNumber(last);
107
+ if (last && tokens !== null) found.tokens = tokens;
108
+ }
109
+ return found;
110
+ }
111
+
112
+ // One normalized line. Empty fields stay out so a line only says what we know.
113
+ function normalizeRun(input = {}, { now } = {}) {
114
+ const outcome = OUTCOMES.includes(input.outcome) ? input.outcome : 'failed';
115
+ const record = {
116
+ at: clean(input.at) || new Date(nowMs(now)).toISOString(),
117
+ job: jobName(input.job) || 'build',
118
+ engine: clean(input.engine) || 'unknown',
119
+ outcome,
120
+ };
121
+ const optional = {
122
+ member: clean(input.member),
123
+ model: clean(input.model),
124
+ effort: clean(input.effort),
125
+ task: clean(input.task),
126
+ detail: clean(input.detail).slice(0, 240),
127
+ handed_over_to: clean(input.handed_over_to),
128
+ source: clean(input.source),
129
+ prep: clean(input.prep).slice(0, 160),
130
+ };
131
+ for (const [key, value] of Object.entries(optional)) if (value) record[key] = value;
132
+ const cap = wholeNumber(input.max_seconds);
133
+ if (cap) record.max_seconds = cap;
134
+ const seconds = Number(input.seconds);
135
+ if (Number.isFinite(seconds) && seconds >= 0) record.seconds = Math.round(seconds);
136
+ const tokens = wholeNumber(input.tokens);
137
+ if (input.tokens !== undefined && input.tokens !== null && tokens !== null) record.tokens = tokens;
138
+ if (typeof input.cost_usd === 'number' && Number.isFinite(input.cost_usd)) record.cost_usd = input.cost_usd;
139
+ for (const key of ['brief_lines', 'brief_bytes']) {
140
+ const value = wholeNumber(input[key]);
141
+ if (input[key] !== undefined && input[key] !== null && value !== null) record[key] = value;
142
+ }
143
+ return record;
144
+ }
145
+
146
+ // Past the cap, the log moves aside under a fresh stamped name, so a second
147
+ // writer rotating in the same breath adds a file instead of replacing one.
148
+ // A rename that fails, including ENOENT when another writer got there
149
+ // first, deletes nothing: the append still lands in the live log. Only
150
+ // after a rename lands are rotated files past the newest few removed.
151
+ function rotateRunsFile(root, file, options = {}) {
152
+ let size = 0;
153
+ try {
154
+ size = fs.statSync(file).size;
155
+ } catch {
156
+ return;
157
+ }
158
+ if (size < RUNS_ROTATE_BYTES) return;
159
+ const dir = path.dirname(file);
160
+ const stamp = new Date(nowMs(options.now)).toISOString().replace(/[-:]/g, '');
161
+ let rotated = path.join(dir, `roster_runs.${stamp}-${process.pid}.jsonl`);
162
+ for (let attempt = 1; fs.existsSync(rotated); attempt += 1) {
163
+ rotated = path.join(dir, `roster_runs.${stamp}-${process.pid}-${attempt}.jsonl`);
164
+ }
165
+ try {
166
+ fs.renameSync(file, rotated);
167
+ } catch {
168
+ return;
169
+ }
170
+ const rotatedFiles = rotatedRunsPaths(root);
171
+ for (const old of rotatedFiles.slice(0, Math.max(0, rotatedFiles.length - ROTATED_KEEP))) {
172
+ try { fs.rmSync(old, { force: true }); } catch {}
173
+ }
174
+ }
175
+
176
+ // One whole line per call, no lock. Before the append, a log at the cap
177
+ // rotates aside so every record stays readable, and recording never throws
178
+ // into the run that made it.
179
+ function appendRosterRun(root = process.cwd(), input = {}, options = {}) {
180
+ try {
181
+ const record = normalizeRun(input, options);
182
+ const file = rosterRunsPath(root);
183
+ fs.mkdirSync(path.dirname(file), { recursive: true });
184
+ rotateRunsFile(root, file, options);
185
+ fs.appendFileSync(file, `${JSON.stringify(record)}\n`, 'utf8');
186
+ return record;
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
191
+
192
+ // Several attempts at one job, oldest first. Each attempt that was followed by
193
+ // another names who took over.
194
+ function appendRosterAttempts(root, attempts = [], shared = {}, options = {}) {
195
+ const list = (attempts || []).filter(Boolean);
196
+ return list.map((attempt, index) => appendRosterRun(root, {
197
+ ...shared,
198
+ ...attempt,
199
+ ...(index + 1 < list.length && list[index + 1].engine ? { handed_over_to: list[index + 1].engine } : {}),
200
+ }, options));
201
+ }
202
+
203
+ // The last few hundred KB only: the file grows forever and the roster view
204
+ // never needs more than recent days.
205
+ function readTail(file, bytes = TAIL_BYTES) {
206
+ let fd;
207
+ try {
208
+ fd = fs.openSync(file, 'r');
209
+ const size = fs.fstatSync(fd).size;
210
+ const start = Math.max(0, size - bytes);
211
+ const buffer = Buffer.alloc(size - start);
212
+ fs.readSync(fd, buffer, 0, buffer.length, start);
213
+ const text = buffer.toString('utf8');
214
+ return start > 0 ? text.slice(text.indexOf('\n') + 1) : text;
215
+ } catch {
216
+ return '';
217
+ } finally {
218
+ if (fd !== undefined) try { fs.closeSync(fd); } catch {}
219
+ }
220
+ }
221
+
222
+ const TEXT_FIELDS = Object.freeze(['at', 'job', 'engine', 'outcome', 'member', 'model', 'effort', 'task', 'detail', 'handed_over_to', 'source', 'prep']);
223
+ const NUMBER_FIELDS = Object.freeze(['max_seconds', 'seconds', 'tokens', 'cost_usd', 'brief_lines', 'brief_bytes']);
224
+
225
+ // A line as read back, with every known field its expected type: text
226
+ // trimmed or dropped, numbers finite or dropped ("0.42" reads as 0.42), so a
227
+ // hand-edited line can never break the views.
228
+ function readableRun(row) {
229
+ const out = {};
230
+ for (const [key, value] of Object.entries(row)) {
231
+ if (TEXT_FIELDS.includes(key)) {
232
+ if (typeof value !== 'string') continue;
233
+ const text = value.trim();
234
+ if (text) out[key] = text;
235
+ } else if (NUMBER_FIELDS.includes(key)) {
236
+ const number = typeof value === 'number' ? value
237
+ : typeof value === 'string' && value.trim() ? Number(value.trim())
238
+ : NaN;
239
+ if (Number.isFinite(number) && number >= 0) out[key] = number;
240
+ }
241
+ }
242
+ return out;
243
+ }
244
+
245
+ // Runs from the last `days` days, oldest first, read from the last
246
+ // `tailBytes` of each file. A missing file or a bad line is never an error.
247
+ function readRosterRuns(root = process.cwd(), { now, days = DEFAULT_DAYS, tailBytes = TAIL_BYTES } = {}) {
248
+ const since = nowMs(now) - Math.max(0, Number(days) || DEFAULT_DAYS) * 86400000;
249
+ const rows = [];
250
+ const bytes = Number(tailBytes) > 0 ? Number(tailBytes) : TAIL_BYTES;
251
+ // The newest rotated files hold the older records. A read that races a
252
+ // rotation can miss a file for that one read; the next read sees it, and
253
+ // these records only feed suggestions and ranking, so a miss costs
254
+ // nothing.
255
+ for (const file of [...rotatedRunsPaths(root).slice(-ROTATED_READ), rosterRunsPath(root)]) {
256
+ for (const line of readTail(file, bytes).split(/\r?\n/)) {
257
+ if (!line.trim()) continue;
258
+ let row;
259
+ try {
260
+ row = JSON.parse(line);
261
+ } catch {
262
+ continue;
263
+ }
264
+ if (!row || typeof row !== 'object' || Array.isArray(row)) continue;
265
+ row = readableRun(row);
266
+ if (!row.engine || !OUTCOMES.includes(row.outcome)) continue;
267
+ const at = Date.parse(row.at);
268
+ if (!Number.isFinite(at) || at < since) continue;
269
+ rows.push(row);
270
+ }
271
+ }
272
+ return rows;
273
+ }
274
+
275
+ function median(values) {
276
+ const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
277
+ if (!sorted.length) return null;
278
+ const middle = Math.floor(sorted.length / 2);
279
+ return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
280
+ }
281
+
282
+ function durationText(seconds) {
283
+ const value = Math.round(Number(seconds) || 0);
284
+ if (value < 60) return `${value}s`;
285
+ if (value < 3600) return `${Math.round(value / 60)} min`;
286
+ const hours = Math.floor(value / 3600);
287
+ const minutes = Math.round((value % 3600) / 60);
288
+ return minutes ? `${hours} h ${minutes} min` : `${hours} h`;
289
+ }
290
+
291
+ // The runs that belong to one worker on one job. A worker with a pinned model
292
+ // counts only runs on that model; with no pin, every run of the engine counts.
293
+ function workerRuns(runs, { job, engine, model = '' } = {}) {
294
+ const wantJob = jobName(job);
295
+ const wantEngine = clean(engine);
296
+ const wantModel = clean(model).toLowerCase();
297
+ return (runs || []).filter((run) => jobName(run.job) === wantJob
298
+ && run.engine === wantEngine
299
+ && (!wantModel || clean(run.model).toLowerCase() === wantModel));
300
+ }
301
+
302
+ // "last 7 days: 9 runs, 7 landed, 1 stalled, 1 failed, median 11 min", or
303
+ // null when there are no runs.
304
+ function summarizeRuns(runs, { days = DEFAULT_DAYS } = {}) {
305
+ const list = runs || [];
306
+ if (!list.length) return null;
307
+ const count = (outcome) => list.filter((run) => run.outcome === outcome).length;
308
+ const summary = {
309
+ days,
310
+ runs: list.length,
311
+ landed: count('landed'),
312
+ failed: count('failed'),
313
+ stalled: count('stalled'),
314
+ credit_out: count('credit out'),
315
+ handed_over: list.filter((run) => run.handed_over_to).length,
316
+ median_seconds: median(list.map((run) => Number(run.seconds)).filter((value) => value >= 0)),
317
+ };
318
+ const tokens = list.map((run) => run.tokens).filter((value) => Number.isFinite(value));
319
+ if (tokens.length) summary.tokens = tokens.reduce((sum, value) => sum + value, 0);
320
+ const parts = [`${summary.runs} run${summary.runs === 1 ? '' : 's'}`];
321
+ if (summary.landed) parts.push(`${summary.landed} landed`);
322
+ if (summary.stalled) parts.push(`${summary.stalled} stalled`);
323
+ if (summary.failed) parts.push(`${summary.failed} failed`);
324
+ if (summary.credit_out) parts.push(`${summary.credit_out} credit out`);
325
+ if (summary.median_seconds !== null) parts.push(`median ${durationText(summary.median_seconds)}`);
326
+ summary.text = `last ${days} day${days === 1 ? '' : 's'}: ${parts.join(', ')}`;
327
+ return summary;
328
+ }
329
+
330
+ function clockText(at) {
331
+ const date = new Date(at);
332
+ if (!Number.isFinite(date.getTime())) return String(at || '');
333
+ const day = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }).toLowerCase();
334
+ return `${day} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
335
+ }
336
+
337
+ function briefSizeText(bytes) {
338
+ const value = Number(bytes) || 0;
339
+ return value < 1024 ? `${value} bytes` : `${(value / 1024).toFixed(1).replace(/\.0$/, '')} KB`;
340
+ }
341
+
342
+ function outcomeText(run) {
343
+ let text = run.outcome;
344
+ if (run.detail && run.detail !== run.outcome) text = run.outcome === 'stalled' && /^stalled\b/.test(run.detail) ? run.detail : `${run.outcome}, ${run.detail}`;
345
+ if (run.handed_over_to) text += `, handed over to ${run.handed_over_to}`;
346
+ if (run.prep) text += `, ${run.prep}${run.brief_bytes ? ` (${briefSizeText(run.brief_bytes)} brief)` : ''}`;
347
+ return text;
348
+ }
349
+
350
+ // One plain line per attempt.
351
+ function renderRunLine(run) {
352
+ const worker = [run.engine, run.model, run.effort].filter(Boolean).join(' ');
353
+ const facts = [
354
+ run.seconds !== undefined ? durationText(run.seconds) : '',
355
+ run.max_seconds ? `cap ${durationText(run.max_seconds)}` : '',
356
+ run.tokens !== undefined ? `${run.tokens.toLocaleString('en-US')} tokens` : '',
357
+ run.cost_usd !== undefined ? `$${run.cost_usd.toFixed(2)}` : '',
358
+ ].filter(Boolean).join(', ');
359
+ const who = [run.job, run.member ? `for ${run.member}` : '', run.task].filter(Boolean).join(' ');
360
+ return `${clockText(run.at)} ${who}: ${worker}${facts ? ` (${facts})` : ''}, ${outcomeText(run)}`;
361
+ }
362
+
363
+ // Newest first, at most `limit`, optionally one job only.
364
+ function recentRuns(runs, { job = '', limit = 20 } = {}) {
365
+ const wantJob = job ? jobName(job) : '';
366
+ return (runs || [])
367
+ .filter((run) => !wantJob || jobName(run.job) === wantJob)
368
+ .map((run, index) => ({ run, index }))
369
+ .sort((a, b) => (Date.parse(b.run.at) - Date.parse(a.run.at)) || (b.index - a.index))
370
+ .slice(0, limit)
371
+ .map(({ run }) => run);
372
+ }
373
+
374
+ module.exports = {
375
+ OUTCOMES,
376
+ DEFAULT_DAYS,
377
+ RUNS_ROTATE_BYTES,
378
+ rosterRunsPath,
379
+ rotatedRunsPaths,
380
+ jobName,
381
+ parseRunUsage,
382
+ appendRosterRun,
383
+ appendRosterAttempts,
384
+ readRosterRuns,
385
+ workerRuns,
386
+ summarizeRuns,
387
+ renderRunLine,
388
+ recentRuns,
389
+ };
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ // Suggestions from the run record. When a job's first worker keeps stalling
4
+ // or failing and a worker further down its list keeps landing, the roster
5
+ // view says so once, with the command that would move the better worker up.
6
+ // Nothing here writes ROSTER.md: only a person (or a person telling an
7
+ // agent) changes the roster. This file only reads runs and words a line.
8
+
9
+ const { readRosterRuns, workerRuns } = require('./roster-runs');
10
+ const { rosterToolLabel } = require('./engine-registry');
11
+
12
+ // Only the last week counts, and within it each worker's newest few runs.
13
+ const SUGGEST_DAYS = 7;
14
+ const SUGGEST_WINDOW_RUNS = 5;
15
+ // The lead needs enough runs to judge; the challenger needs at least two.
16
+ const SUGGEST_MIN_LEAD_RUNS = 3;
17
+ const SUGGEST_MIN_CHALLENGER_RUNS = 2;
18
+ // The lead lands under half its runs, and the challenger's landed rate is
19
+ // at least 25 points higher.
20
+ const SUGGEST_LEAD_BELOW_POINTS = 50;
21
+ const SUGGEST_MIN_GAP_POINTS = 25;
22
+ // The boot line reads only this much of the end of the record.
23
+ const BOOT_TAIL_BYTES = 64 * 1024;
24
+
25
+ // A credit wall says nothing about how well a worker does the job, and
26
+ // engine health already benches it, so credit out runs are left out.
27
+ function judgedRuns(runs) {
28
+ return runs
29
+ .filter((run) => run.outcome !== 'credit out')
30
+ .map((run, index) => ({ run, index }))
31
+ .sort((a, b) => (Date.parse(b.run.at) - Date.parse(a.run.at)) || (b.index - a.index))
32
+ .slice(0, SUGGEST_WINDOW_RUNS)
33
+ .map(({ run }) => run);
34
+ }
35
+
36
+ function tally(runs) {
37
+ const landed = runs.filter((run) => run.outcome === 'landed').length;
38
+ return { runs: runs.length, landed };
39
+ }
40
+
41
+ function workerName(worker) {
42
+ if (worker.runs && worker.runs.text) return worker.runs.text;
43
+ return [rosterToolLabel(worker.engine), worker.model].filter(Boolean).join(' ');
44
+ }
45
+
46
+ function quoteArg(value) {
47
+ const text = String(value);
48
+ return /^[a-z0-9._:/@+-]+$/i.test(text) ? text : `"${text.replace(/(["\\$`])/g, '\\$1')}"`;
49
+ }
50
+
51
+ // The assign command that moves the challenger to the top of the job. It
52
+ // names the challenger's line exactly: the tool, plus its model and effort
53
+ // when its line has them, so --promote moves that worker and no other line
54
+ // of the same tool. --promote reorders lines as written, so the demoted
55
+ // lead keeps its own cap, prep, and notes.
56
+ function assignArgs(job, challenger) {
57
+ const words = [rosterToolLabel(challenger.engine), challenger.model, challenger.effort].filter(Boolean).join(' ');
58
+ return ['assign', job, '--promote', words];
59
+ }
60
+
61
+ function leadText(name, count) {
62
+ const missed = count.runs - count.landed;
63
+ const outcomes = [...new Set(count.list.filter((run) => run.outcome !== 'landed').map((run) => run.outcome))];
64
+ const last = `of its last ${count.runs} run${count.runs === 1 ? '' : 's'}`;
65
+ if (missed && outcomes.length === 1) return `${name} ${outcomes[0]} ${missed} ${last}`;
66
+ return `${name} landed ${count.landed} ${last}`;
67
+ }
68
+
69
+ // At most one suggestion for one job row of the roster report, or null. A
70
+ // row's workers carry their status: a worker that is cooling, expired, not
71
+ // ready, or on a bad line is skipped and is never suggested.
72
+ function jobSuggestion(row, runs) {
73
+ if (!row || !row.pick || !Array.isArray(row.workers) || row.workers.length < 2) return null;
74
+ const job = row.key || row.job;
75
+ const usable = row.workers.filter((worker) => worker.engine && !worker.text);
76
+ const lead = usable[0];
77
+ if (!lead) return null;
78
+ const countFor = (worker) => {
79
+ const list = judgedRuns(workerRuns(runs, { job, engine: worker.engine, model: worker.model || '' }));
80
+ return { ...tally(list), list };
81
+ };
82
+ const leadCount = countFor(lead);
83
+ if (leadCount.runs < SUGGEST_MIN_LEAD_RUNS) return null;
84
+ if (leadCount.landed * 100 >= SUGGEST_LEAD_BELOW_POINTS * leadCount.runs) return null;
85
+ let best = null;
86
+ for (const worker of usable.slice(1)) {
87
+ if (worker.status === 'skipped') continue;
88
+ const count = countFor(worker);
89
+ if (count.runs < SUGGEST_MIN_CHALLENGER_RUNS) continue;
90
+ // Whole-number math: the challenger's rate minus the lead's, in points.
91
+ const gap = 100 * (count.landed * leadCount.runs - leadCount.landed * count.runs);
92
+ if (gap < SUGGEST_MIN_GAP_POINTS * leadCount.runs * count.runs) continue;
93
+ const rate = count.landed / count.runs;
94
+ if (!best || rate > best.rate || (rate === best.rate && count.runs > best.count.runs)) best = { worker, count, rate };
95
+ }
96
+ if (!best) return null;
97
+ const leadName = workerName(lead);
98
+ const challengerName = workerName(best.worker);
99
+ const args = assignArgs(row.job, best.worker);
100
+ const command = `atris engine ${args.map(quoteArg).join(' ')}`;
101
+ const record = `${leadText(leadName, leadCount)}; ${challengerName} landed ${best.count.landed} of ${best.count.runs}`;
102
+ return {
103
+ job: row.job,
104
+ days: SUGGEST_DAYS,
105
+ lead: { engine: lead.engine, model: lead.model || null, runs: leadCount.runs, landed: leadCount.landed },
106
+ challenger: { engine: best.worker.engine, model: best.worker.model || null, runs: best.count.runs, landed: best.count.landed },
107
+ command,
108
+ args,
109
+ text: `${record}. to move ${rosterToolLabel(best.worker.engine)} up: ${command}`,
110
+ };
111
+ }
112
+
113
+ // Attach a suggestion to each job row that has one. Returns how many.
114
+ function attachSuggestions(jobs, runs) {
115
+ let count = 0;
116
+ for (const row of jobs || []) {
117
+ const suggestion = jobSuggestion(row, runs);
118
+ if (suggestion) {
119
+ row.suggestion = suggestion;
120
+ count += 1;
121
+ }
122
+ }
123
+ return count;
124
+ }
125
+
126
+ // How many jobs have a suggestion, reading only the end of the record. For
127
+ // the boot line: never throws, 0 when anything goes wrong.
128
+ function suggestionCount(jobs, root, { now } = {}) {
129
+ try {
130
+ const runs = readRosterRuns(root, { now, days: SUGGEST_DAYS, tailBytes: BOOT_TAIL_BYTES });
131
+ if (!runs.length) return 0;
132
+ return (jobs || []).filter((row) => jobSuggestion(row, runs)).length;
133
+ } catch {
134
+ return 0;
135
+ }
136
+ }
137
+
138
+ module.exports = {
139
+ SUGGEST_MIN_LEAD_RUNS,
140
+ SUGGEST_MIN_CHALLENGER_RUNS,
141
+ SUGGEST_LEAD_BELOW_POINTS,
142
+ SUGGEST_MIN_GAP_POINTS,
143
+ attachSuggestions,
144
+ suggestionCount,
145
+ };
@@ -85,6 +85,18 @@ function recordTimestamp(containers, fallbackMs) {
85
85
  return Number.isFinite(fallbackMs) ? fallbackMs : Date.now();
86
86
  }
87
87
 
88
+ // The id a receipt and a roster run record share for the same attempt: the
89
+ // task id (`task`, `task_id`), else the brief id. '' means the record names
90
+ // nothing shared, so it can never be deduped against another.
91
+ function recordRunRef(containers) {
92
+ const raw = firstField(
93
+ containers,
94
+ ['task_id', 'taskId', 'task', 'brief_id', 'briefId', 'run_id', 'runId'],
95
+ (value) => (typeof value === 'string' && value.trim()) || typeof value === 'number',
96
+ );
97
+ return raw === null ? '' : normalizedLabel(raw);
98
+ }
99
+
88
100
  function observationFromRecord(record, source, fallbackMs) {
89
101
  const containers = recordContainers(record);
90
102
  const engine = normalizedLabel(firstField(containers, [
@@ -115,6 +127,7 @@ function observationFromRecord(record, source, fallbackMs) {
115
127
  duration_ms: recordDuration(containers),
116
128
  at_ms: recordTimestamp(containers, fallbackMs),
117
129
  source,
130
+ run_ref: recordRunRef(containers),
118
131
  };
119
132
  }
120
133
 
@@ -188,12 +201,107 @@ function readDispatchReceiptObservations(root) {
188
201
  return observations;
189
202
  }
190
203
 
191
- function loadRouterHistory(root = process.cwd()) {
192
- return [
204
+ // Roster runs (.atris/state/roster_runs.jsonl) as one more signal, for jobs
205
+ // the router ranks because no roster line picks for them. Each run counts as
206
+ // one receipt, the same weight and the same 30-day half-life as a task
207
+ // receipt: landed is a pass; stalled and failed are a miss. Credit out is
208
+ // left out, since engine health already benches that worker. The job name
209
+ // maps to the role the router ranks by: build to executor, search to
210
+ // navigator, review to validator, and an owner's job by its last word
211
+ // ("small build" is build work). No record file means no observations, so
212
+ // the ranking is exactly what it was without one.
213
+ const ROSTER_RUN_DAYS = 30;
214
+ const ROSTER_JOB_ROLES = Object.freeze({ build: 'executor', search: 'navigator', review: 'validator' });
215
+
216
+ function readRosterRunObservations(root, now) {
217
+ const { readRosterRuns, rosterRunsPath, rotatedRunsPaths } = require('./roster-runs');
218
+ const file = rosterRunsPath(root);
219
+ if (!fs.existsSync(file) && !rotatedRunsPaths(root).length) return [];
220
+ const source = path.relative(root, file);
221
+ return readRosterRuns(root, { days: ROSTER_RUN_DAYS, ...(now !== undefined ? { now } : {}) }).flatMap((run) => {
222
+ if (run.outcome === 'credit out') return [];
223
+ const words = String(run.job || '').trim().toLowerCase().split(/\s+/);
224
+ const role = ROSTER_JOB_ROLES[words[words.length - 1]];
225
+ const atMs = Date.parse(run.at);
226
+ if (!role || !Number.isFinite(atMs)) return [];
227
+ return [{
228
+ engine: normalizedLabel(run.engine),
229
+ task_type: role,
230
+ verified_passed: run.outcome === 'landed',
231
+ duration_ms: Number.isFinite(run.seconds) ? run.seconds * 1000 : null,
232
+ at_ms: atMs,
233
+ source,
234
+ run_ref: normalizedLabel(run.task),
235
+ }];
236
+ });
237
+ }
238
+
239
+ // One landed dispatch writes both a receipt result row and a roster run
240
+ // record for the same attempt. The shared id is the task id; the receipt
241
+ // stamps the moment the run ended, the run record the moment it started.
242
+ // Same engine, same task id, a few minutes apart at either end means one
243
+ // run, and the receipt wins because its verified flag is authoritative.
244
+ // A receipt can pair with a run near either end, which is two separate
245
+ // windows per run, so no greedy order is safe. Each task id and engine
246
+ // group is tiny, so pairing uses exact maximum matching (augmenting paths):
247
+ // the pair count is maximal, one receipt cancels at most one run, a pair
248
+ // counts once, and a run or receipt left over still counts once on its own.
249
+ // A retry after a landed run is its own attempt and still counts.
250
+ const RUN_RECEIPT_WINDOW_MS = 5 * 60 * 1000;
251
+
252
+ // How far the receipt sits from the run's start or finish; the nearer end
253
+ // decides whether they describe the same attempt.
254
+ function receiptRunGap(receipt, run) {
255
+ const end = Number.isFinite(run.duration_ms) ? run.at_ms + run.duration_ms : run.at_ms;
256
+ return Math.min(Math.abs(receipt.at_ms - run.at_ms), Math.abs(receipt.at_ms - end));
257
+ }
258
+
259
+ // Maximum matching between receipt and run indexes, where canPair says
260
+ // whether a receipt and a run may describe the same attempt. Returns the
261
+ // set of paired run indexes.
262
+ function maxPairedRuns(receiptIds, runIds, canPair) {
263
+ const runOwner = new Map();
264
+ const tryReceipt = (r, seen) => {
265
+ for (const n of runIds) {
266
+ if (seen.has(n) || !canPair(r, n)) continue;
267
+ seen.add(n);
268
+ if (!runOwner.has(n) || tryReceipt(runOwner.get(n), seen)) {
269
+ runOwner.set(n, r);
270
+ return true;
271
+ }
272
+ }
273
+ return false;
274
+ };
275
+ for (const r of receiptIds) tryReceipt(r, new Set());
276
+ return new Set(runOwner.keys());
277
+ }
278
+
279
+ function loadRouterHistory(root = process.cwd(), { now } = {}) {
280
+ const receipts = [
193
281
  ...readTaskReceiptObservations(root),
194
282
  ...readMissionEventObservations(root),
195
283
  ...readDispatchReceiptObservations(root),
196
284
  ];
285
+ const runs = readRosterRunObservations(root, now);
286
+ const groups = new Map();
287
+ for (const [r, receipt] of receipts.entries()) {
288
+ if (!receipt.run_ref) continue;
289
+ const key = `${receipt.engine}\u0000${receipt.run_ref}`;
290
+ if (!groups.has(key)) groups.set(key, { receipts: [], runs: [] });
291
+ groups.get(key).receipts.push(r);
292
+ }
293
+ for (const [n, run] of runs.entries()) {
294
+ if (!run.run_ref) continue;
295
+ const group = groups.get(`${run.engine}\u0000${run.run_ref}`);
296
+ if (group) group.runs.push(n);
297
+ }
298
+ const pairedRuns = new Set();
299
+ const canPair = (r, n) => receiptRunGap(receipts[r], runs[n]) <= RUN_RECEIPT_WINDOW_MS;
300
+ for (const group of groups.values()) {
301
+ for (const n of maxPairedRuns(group.receipts, group.runs, canPair)) pairedRuns.add(n);
302
+ }
303
+ return [...receipts, ...runs.filter((run, n) => !pairedRuns.has(n))]
304
+ .map(({ run_ref, ...observation }) => observation);
197
305
  }
198
306
 
199
307
  function median(values) {
@@ -290,7 +398,7 @@ function rankEnginesDetailed(candidates, options = {}) {
290
398
  const minReceipts = Number.isInteger(minValue) && minValue > 0 ? minValue : DEFAULT_MIN_RECEIPTS;
291
399
  const observations = Array.isArray(options.observations)
292
400
  ? options.observations
293
- : loadRouterHistory(options.root || process.cwd());
401
+ : loadRouterHistory(options.root || process.cwd(), { now: options.now });
294
402
  const stats = computeEngineTaskStats(observations, options);
295
403
  const statsByEngine = new Map(stats
296
404
  .filter((row) => row.task_type === taskType)