atris 3.76.0 → 3.78.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/README.md +2 -0
- package/commands/brief.js +219 -36
- package/commands/engine.js +14 -4
- package/commands/mission.js +23 -2
- package/commands/rating.js +7 -10
- package/lib/builder-pick.js +888 -0
- package/lib/claude-spend-guard.js +24 -0
- package/lib/fleet.js +135 -19
- package/lib/gm-season.js +110 -4
- package/lib/landing-check.js +296 -0
- package/lib/owner-brief.js +1060 -0
- package/lib/runner-command.js +78 -38
- package/lib/task-db.js +35 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -433,6 +433,8 @@ atris mission run --fleet --slots 3 # staff up to 3 engines
|
|
|
433
433
|
atris mission run --fleet --dry-run # preview the staffing only
|
|
434
434
|
```
|
|
435
435
|
|
|
436
|
+
Each job goes to the builder its real results favor. The fleet counts what each engine landed and missed since Nov 1, on jobs like this one and on the rest (the same plays `atris rating` shows), gives every engine a fair chance, and prints the pick under the job with the numbers behind it. A landing counts only when its commit is on origin's default branch (the branch `origin/HEAD` names, never a guess), is no older than the claim it lands (five minutes of clock skew allowed), and no other claim recorded the same commit first: a commit counts once, for the first claim that recorded it, and not again for a later claim on the same job (reopened and claimed again, by the same engine or another) or for another job. That is judged on every landing record in the task store, in every workspace that shares it and every later review pass. A landing pays the engine that finished the job: when a stalled or dead engine's job is finished by another, the engine that finished it is credited, and a landing whose hand-in names several engines without saying which finished counts for none. The folder is judged in one order. With no git, no origin, or `origin/HEAD` not set (`git remote set-head origin --auto` sets it), no landing can be checked, so jobs are dealt in turn and the flight says why in one line. With an engine's landing on record, the flight fetches origin's default branch once before it picks (so a merge from a minute ago counts); if that fetch fails, jobs are dealt in turn with one line, and if it works the landings are checked. With only misses on record, they steer on their own, with no fetch. Everything git does before a pick shares one five-second budget and never prompts: its input is closed, `GIT_TERMINAL_PROMPT` is 0, and ssh runs in batch mode unless you set your own ssh command, which is left as it is and mentioned in one line. A play counts toward the kind of job it was when the engine took it: the claim, whether made by the fleet or by `task do`, saves the job's title and tags, so tagging or renaming a finished job later changes nothing, and a claim made before that was saved counts at half weight. New engines get tried; engines that keep missing get fewer jobs. An engine that is out of credit, cooling, or one the Claude spend guard would stop (a paid model nobody approved, or a pay-per-use key) is never picked, and the flight says which. The guard's own hook (`atris spend guard install`) stays the real guard on every unattended Claude run; this check only avoids picking an engine the hook would refuse, so where it is unsure it leaves Claude out. The fleet's own launch is judged by the `--model` the fleet itself passes, read through the `ANTHROPIC_DEFAULT_*_MODEL` remaps in the environment the worker will really get (a sealed one-lap worker gets none); with no `--model`, by `ANTHROPIC_MODEL` in that same environment, else the paid default. No Claude settings file is read, from any home. A custom launch template (`ATRIS_RUNNER_COMMAND_TEMPLATE`) is never read for its model, because a model in it can be a flag, a word of the prompt, another command's, or one the shell works out only when it runs. It counts as a model nobody can tell, which counts as paid, so Claude is left out with one line saying so, unless `ATRIS_CLAUDE_SPEND_OK` approves `default`; `--engine claude` still uses it. Your own choices come first and are never picked for, with results or without: `--engine <name>` sends every job to one engine, a job that names its engine gets it, and a job assigned to a member goes to that member's own engine, the same one `atris member` uses. A choice the flight cannot give is never given to someone else: a job that names an engine this flight does not have, or whose member's engine it does not have, is left out of the flight, with one line naming the job and why. It is taken out before the flight is staffed, so it takes no slot and holds no file against another job. The pick goes on who a job is assigned to and the engine it names, as the task list showed them; the claim asks the store for both again, inside its own transaction, and a job that now says something else is not claimed for the old choice. It is left for the next flight with one line, and the task list is drawn again so that flight reads the job as it is now. An engine list you name is never filtered, on the first pick or on a retry. If an engine dies mid-job, the next one is picked the same way; if it stalls and the roster lists a backup order, the roster's order stands. `--pick turns` (or `ATRIS_PICK=turns`) deals engines in turn, as before.
|
|
437
|
+
|
|
436
438
|
## Verifiable Feedback Loop
|
|
437
439
|
|
|
438
440
|
Under the hood, Atris can keep score on real repo work.
|
package/commands/brief.js
CHANGED
|
@@ -15,9 +15,12 @@ const {
|
|
|
15
15
|
} = require('../lib/brief-ledger');
|
|
16
16
|
const { renderHtml } = require('../lib/html-render');
|
|
17
17
|
const { nextMoves, recordDecision, seedInboxFromMove } = require('../lib/next-moves');
|
|
18
|
+
const { SHOWN, buildOwnerBrief, cutAtSpace, ownerBlocks, printableLink, redacted, renderOwnerText } = require('../lib/owner-brief');
|
|
18
19
|
const { buildWeekReportData, renderWeekReport } = require('./report');
|
|
19
20
|
|
|
20
21
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
22
|
+
// Titles on the owner's screen stay on one line.
|
|
23
|
+
const OWNER_TITLE_MAX = 90;
|
|
21
24
|
const TASK_PROJECTION_FILE = path.join('.atris', 'state', 'tasks.projection.json');
|
|
22
25
|
const NEEDS_HUMAN_TAGS = new Set(['needs-human', 'human-review', 'human', 'manual', 'protected', 'denied']);
|
|
23
26
|
|
|
@@ -62,6 +65,29 @@ function outputText(value, fallback = '') {
|
|
|
62
65
|
return inlineText(value, fallback).toLowerCase();
|
|
63
66
|
}
|
|
64
67
|
|
|
68
|
+
// A link or a path is one word. Any word with a / in it counts, and the brackets, quotes, and
|
|
69
|
+
// sentence marks around it are not part of it. The shortener keeps such a word whole or drops
|
|
70
|
+
// it whole and never leaves a piece of one: a title must not end in `feature/` or in
|
|
71
|
+
// `Review https`. `start` and `end` are the word itself, `from` and `to` the word with what
|
|
72
|
+
// is around it.
|
|
73
|
+
const WRAP_OPEN = '([{<"\'`';
|
|
74
|
+
const WRAP_CLOSE = ')]}>"\'`.,;:!?';
|
|
75
|
+
|
|
76
|
+
function slashWords(text) {
|
|
77
|
+
const found = [];
|
|
78
|
+
const word = /\S+/g;
|
|
79
|
+
for (let match = word.exec(text); match; match = word.exec(text)) {
|
|
80
|
+
const from = match.index;
|
|
81
|
+
const to = from + match[0].length;
|
|
82
|
+
let start = from;
|
|
83
|
+
let end = to;
|
|
84
|
+
while (start < end && WRAP_OPEN.includes(text[start])) start += 1;
|
|
85
|
+
while (end > start && WRAP_CLOSE.includes(text[end - 1])) end -= 1;
|
|
86
|
+
if (text.slice(start, end).includes('/')) found.push({ start, end, from, to });
|
|
87
|
+
}
|
|
88
|
+
return found;
|
|
89
|
+
}
|
|
90
|
+
|
|
65
91
|
function finishText(value, max = 150) {
|
|
66
92
|
const clean = inlineText(value, '');
|
|
67
93
|
if (clean.length <= max) return clean;
|
|
@@ -71,28 +97,73 @@ function finishText(value, max = 150) {
|
|
|
71
97
|
const clause = Math.max(cut.lastIndexOf('; '), cut.lastIndexOf(', '), cut.lastIndexOf(': '));
|
|
72
98
|
if (clause > max * 0.45) return cut.slice(0, clause).trim();
|
|
73
99
|
const space = cut.lastIndexOf(' ');
|
|
74
|
-
|
|
100
|
+
let at = space > max * 0.55 ? space : max;
|
|
101
|
+
// The cut fell inside a link or a path, so the line stops before that word and it goes whole.
|
|
102
|
+
// When it is the first word there is nothing before it: it stays if it fits in twice the
|
|
103
|
+
// room, and goes if it does not.
|
|
104
|
+
const across = slashWords(clean).find((word) => word.from < at && at < word.to);
|
|
105
|
+
if (across) at = across.from > 0 ? across.from : (across.to <= max * 2 ? across.to : 0);
|
|
106
|
+
return clean.slice(0, at).trim();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A file name is plumbing and goes whole: a known extension that ends where the word does
|
|
110
|
+
// (`.json` is not `.js` with `on` left over), then the line and column numbers if there are any.
|
|
111
|
+
// Any other link or path is kept whole. Both are set aside while the plumbing is taken out, so
|
|
112
|
+
// nothing in the shortener ever works on a piece of one.
|
|
113
|
+
const FILE_EXT = '\\.(?:json5?|jsonl|jsx?|mdx?|py[cw]?|sh|tsx?)(?![a-z0-9])(?::\\d+)*';
|
|
114
|
+
const FILE_NAME = new RegExp(`${FILE_EXT}$`, 'i');
|
|
115
|
+
const FILE_NAMES = new RegExp(`\\S+${FILE_EXT}`, 'gi');
|
|
116
|
+
const FILE_MARK = '\u0003';
|
|
117
|
+
const MARKED_FILES = new RegExp(`${FILE_MARK}(?::\\d+)*`, 'g');
|
|
118
|
+
|
|
119
|
+
function setAside(text) {
|
|
120
|
+
const words = [];
|
|
121
|
+
let masked = '';
|
|
122
|
+
let last = 0;
|
|
123
|
+
for (const { start, end } of slashWords(text)) {
|
|
124
|
+
const word = text.slice(start, end);
|
|
125
|
+
const file = word.includes('://') ? null : FILE_NAME.exec(word);
|
|
126
|
+
masked += text.slice(last, start);
|
|
127
|
+
if (file) {
|
|
128
|
+
// The line numbers stay out of the mark: a colon after plumbing is where the detail starts.
|
|
129
|
+
masked += FILE_MARK;
|
|
130
|
+
last = end - /(?::\d+)*$/.exec(file[0])[0].length;
|
|
131
|
+
} else {
|
|
132
|
+
masked += `\u0001${words.length}\u0002`;
|
|
133
|
+
words.push(word);
|
|
134
|
+
last = end;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { masked: masked + text.slice(last), words };
|
|
75
138
|
}
|
|
76
139
|
|
|
77
140
|
function operatorTitle(value, max = 140) {
|
|
78
|
-
const
|
|
141
|
+
const clean = inlineText(value, '').replace(/[\u0001-\u0003]/g, ' ');
|
|
142
|
+
const { masked: raw, words } = setAside(clean);
|
|
79
143
|
const plumbing = /`|(?:^|\s)(?:node|npm|git|rg|grep)\s+|\S+\.(?:js|md|json|ts|py|sh)(?::\d+)?|\b[A-Z]{2,5}-\d+\b|\bpr\s*#?\s*\d+\b/i;
|
|
80
|
-
|
|
144
|
+
// Plumbing anywhere makes what follows a colon detail, in a link or a path too; but a colon
|
|
145
|
+
// inside a link or a path is not that colon.
|
|
146
|
+
const source = plumbing.test(clean) && raw.includes(':') ? raw.split(':')[0] : raw;
|
|
81
147
|
const scrubbed = source
|
|
82
148
|
.replace(/`[^`]*`/g, ' ')
|
|
83
149
|
.replace(/\b(?:done|check|verify|proof):\s+.*$/i, ' ')
|
|
84
|
-
.replace(/\b(?:node|npm|git|rg|grep)\s+[
|
|
85
|
-
.replace(
|
|
150
|
+
.replace(/\b(?:node|npm|git|rg|grep)\s+(?:[^.;,]|\.(?=\S))+/gi, ' ')
|
|
151
|
+
.replace(MARKED_FILES, ' ')
|
|
152
|
+
.replace(FILE_NAMES, ' ')
|
|
86
153
|
.replace(/\b[A-Z]{2,5}-\d+\b/g, ' ')
|
|
87
154
|
.replace(/\bpr\s*#?\s*\d+\b/gi, ' ')
|
|
88
155
|
.replace(/\s+/g, ' ')
|
|
89
156
|
.replace(/\([^a-z0-9]*\)/gi, ' ')
|
|
90
|
-
.replace(
|
|
157
|
+
.replace(/,(?:\s*,)+/g, ',')
|
|
91
158
|
.replace(/\s+([,.;:])/g, '$1')
|
|
92
159
|
.replace(/([:(])\s*([,.;:)]|$)/g, '')
|
|
93
160
|
.replace(/\s+\)/g, ')')
|
|
94
|
-
.
|
|
95
|
-
|
|
161
|
+
.replace(/\(\s+/g, '(')
|
|
162
|
+
.trim()
|
|
163
|
+
.replace(/\u0001(\d+)\u0002/g, (_, index) => words[Number(index)]);
|
|
164
|
+
const short = finishText(scrubbed || value, max);
|
|
165
|
+
// A title that is one link too long to show whole leaves nothing: say so, do not print a blank.
|
|
166
|
+
return short || (clean ? 'untitled' : '');
|
|
96
167
|
}
|
|
97
168
|
|
|
98
169
|
function taskId(task) {
|
|
@@ -135,16 +206,30 @@ function needsHumanTag(task) {
|
|
|
135
206
|
return taskTags(task).find((tag) => NEEDS_HUMAN_TAGS.has(tag)) || null;
|
|
136
207
|
}
|
|
137
208
|
|
|
209
|
+
// A pointer is printed like any other link, so it is cleaned like one (see
|
|
210
|
+
// printableLink in lib/owner-brief.js): a link loses its login, query string, and
|
|
211
|
+
// fragment, and one that carries a secret is not a pointer at all, so the next kind
|
|
212
|
+
// is tried. A file path that the redactor would change is passed over the same way.
|
|
213
|
+
// A link that could not be printed is gone, all of it: the other kinds are read from the
|
|
214
|
+
// words around it, cleaned, and never from a piece of it (the pr123 in token=pr123 is
|
|
215
|
+
// part of the secret, not a pull request). A proof can be as long as someone makes it, so
|
|
216
|
+
// only the first part is read, cut at a space (cutAtSpace), and the patterns here take
|
|
217
|
+
// each character once: a run is looked at from its first character only, never again
|
|
218
|
+
// from every character inside it.
|
|
138
219
|
function extractPointer(text) {
|
|
139
|
-
const value = inlineText(text, '');
|
|
220
|
+
const value = inlineText(cutAtSpace(text), '');
|
|
140
221
|
if (!value) return '';
|
|
141
222
|
const url = value.match(/https?:\/\/[^\s<>"')]+/i);
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
223
|
+
const link = url ? printableLink(url[0].replace(/(?<![.,;])[.,;]+$/, '')) : null;
|
|
224
|
+
if (link) return link;
|
|
225
|
+
const around = value.replace(/(?<![a-z0-9+.-])[a-z0-9+.-]*:\/\/\S*/gi, ' ');
|
|
226
|
+
const pathMatch = around.match(/(?:^|\s)((?:\.atris|atris)\/[^\s,;:)]+)/i);
|
|
227
|
+
const file = pathMatch ? pathMatch[1].replace(/(?<![.,;])[.,;]+$/, '') : '';
|
|
228
|
+
if (file && redacted(file) === file) return file;
|
|
229
|
+
const words = redacted(around);
|
|
230
|
+
const pr = words.match(/\bpr\s*#?\s*\d+\b/i);
|
|
146
231
|
if (pr) return pr[0].replace(/\s+/g, ' ');
|
|
147
|
-
return /\b(pass(?:ed)?|verified|green|merged|ok)\b/i.test(
|
|
232
|
+
return /\b(pass(?:ed)?|verified|green|merged|ok)\b/i.test(words) ? 'proof on file' : '';
|
|
148
233
|
}
|
|
149
234
|
|
|
150
235
|
function proofPointer(task) {
|
|
@@ -183,15 +268,34 @@ function formatAge(start, nowMs) {
|
|
|
183
268
|
return `${Math.floor(hours / 24)}d`;
|
|
184
269
|
}
|
|
185
270
|
|
|
186
|
-
|
|
271
|
+
// Why a job is on the waiting list: someone marked its review, it sits in a
|
|
272
|
+
// protected lane, it is tagged for a person, or it is plain review.
|
|
273
|
+
function waitingKind(task) {
|
|
187
274
|
const approval = String(task.review?.approval_status || task.metadata?.approval_status || '').toLowerCase();
|
|
188
|
-
if (approval && approval !== 'pending') return
|
|
275
|
+
if (approval && approval !== 'pending') return { kind: 'approval', approval };
|
|
189
276
|
const denied = deniedTagForTask(task);
|
|
190
|
-
if (denied) return
|
|
191
|
-
if (needsHumanTag(task)) return '
|
|
277
|
+
if (denied) return { kind: 'protected', lane: denied };
|
|
278
|
+
if (needsHumanTag(task)) return { kind: 'human' };
|
|
279
|
+
return { kind: 'review' };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function waitingReason(task) {
|
|
283
|
+
const why = waitingKind(task);
|
|
284
|
+
if (why.kind === 'approval') return `review lane is ${why.approval}`;
|
|
285
|
+
if (why.kind === 'protected') return `protected ${why.lane} lane`;
|
|
286
|
+
if (why.kind === 'human') return 'needs human lane';
|
|
192
287
|
return 'review lane';
|
|
193
288
|
}
|
|
194
289
|
|
|
290
|
+
// The same reasons, said the way the owner's screen says them.
|
|
291
|
+
function plainWaitingWhy(task) {
|
|
292
|
+
const why = waitingKind(task);
|
|
293
|
+
if (why.kind === 'approval') return `Waiting in review, marked ${why.approval}.`;
|
|
294
|
+
if (why.kind === 'protected') return `Protected ${why.lane} work, so only you can approve it.`;
|
|
295
|
+
if (why.kind === 'human') return 'Marked as needing a person.';
|
|
296
|
+
return 'Finished and waiting for your review.';
|
|
297
|
+
}
|
|
298
|
+
|
|
195
299
|
function isWaitingTask(task) {
|
|
196
300
|
const status = String(task.status || '').toLowerCase();
|
|
197
301
|
if (status === 'done' || status === 'failed' || status === 'cancelled') return false;
|
|
@@ -209,36 +313,56 @@ function buildLanded(tasks, cutoffMs, nowMs) {
|
|
|
209
313
|
.sort((a, b) => b.doneMs - a.doneMs)
|
|
210
314
|
.map(({ task, doneMs }) => ({
|
|
211
315
|
id: taskId(task),
|
|
212
|
-
title: inlineText(task.title, 'done task'),
|
|
316
|
+
title: inlineText(redacted(task.title), 'done task'),
|
|
213
317
|
proof: proofPointer(task),
|
|
214
318
|
age: formatAge(doneMs, nowMs),
|
|
215
319
|
}));
|
|
216
320
|
}
|
|
217
321
|
|
|
218
|
-
|
|
322
|
+
// Waiting jobs, longest waiting first, each with its task and when it started
|
|
323
|
+
// waiting so the owner's screen can order them its own way.
|
|
324
|
+
function waitingEntries(tasks, nowMs) {
|
|
219
325
|
return tasks
|
|
220
326
|
.filter(isWaitingTask)
|
|
221
327
|
.map((task) => {
|
|
222
328
|
const startMs = taskTime(task, ['metadata.agent_certified_at', 'updated_at', 'created_at']);
|
|
223
329
|
return {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
330
|
+
task,
|
|
331
|
+
since: startMs || 0,
|
|
332
|
+
item: {
|
|
333
|
+
id: taskId(task),
|
|
334
|
+
title: inlineText(redacted(task.title), 'review task'),
|
|
335
|
+
why: waitingReason(task),
|
|
336
|
+
age: formatAge(startMs, nowMs),
|
|
337
|
+
},
|
|
229
338
|
};
|
|
230
339
|
})
|
|
231
|
-
.sort((a, b) => a.
|
|
232
|
-
|
|
340
|
+
.sort((a, b) => a.since - b.since);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// What brief has always shown as waiting on you, as items on the owner's screen.
|
|
344
|
+
// A closed (archived) job keeps its place in the older list but nobody waits on it.
|
|
345
|
+
function decisionItems(entries) {
|
|
346
|
+
return entries
|
|
347
|
+
.filter(({ task }) => String(task.status || '').toLowerCase() !== 'archived')
|
|
348
|
+
.map(({ task, since, item }) => ({
|
|
349
|
+
task_id: typeof task.id === 'string' && task.id ? task.id : null,
|
|
350
|
+
accept_id: item.id,
|
|
351
|
+
title: task.title,
|
|
352
|
+
why: plainWaitingWhy(task),
|
|
353
|
+
since,
|
|
354
|
+
}));
|
|
233
355
|
}
|
|
234
356
|
|
|
357
|
+
// A move names a job in flight and whoever holds it, so its words are cleaned like any
|
|
358
|
+
// other title or name (see redacted in lib/owner-brief.js).
|
|
235
359
|
function buildMoves(root) {
|
|
236
360
|
return nextMoves(root, 3).map((move) => ({
|
|
237
361
|
id: move.id,
|
|
238
|
-
title: inlineText(move.title, 'next move'),
|
|
239
|
-
why: inlineText(move.why || move.source
|
|
362
|
+
title: inlineText(redacted(move.title), 'next move'),
|
|
363
|
+
why: inlineText(redacted(move.why || move.source), 'workspace signal'),
|
|
240
364
|
source: move.source || null,
|
|
241
|
-
owner: move.owner || move.member || null,
|
|
365
|
+
owner: redacted(move.owner || move.member) || null,
|
|
242
366
|
}));
|
|
243
367
|
}
|
|
244
368
|
|
|
@@ -247,26 +371,47 @@ function buildWeekLine(root, nowMs) {
|
|
|
247
371
|
return renderWeekReport(data).split(/\r?\n/)[0] || 'week in review: 0 landed, 0 completions, 0 xp';
|
|
248
372
|
}
|
|
249
373
|
|
|
250
|
-
|
|
374
|
+
// `owner` adds the owner's screen: needs_you, done_this_week, and team, read from the
|
|
375
|
+
// task store (see lib/owner-brief.js). The command asks for it; a caller that only
|
|
376
|
+
// wants the older lists (atris activate) gets exactly those and never opens the store.
|
|
377
|
+
function buildBriefData(root = process.cwd(), { days = 1, now = Date.now(), owner = false } = {}) {
|
|
251
378
|
const windowDays = Number.isFinite(Number(days)) && Number(days) > 0 ? Math.floor(Number(days)) : 1;
|
|
252
379
|
const nowMs = normalizeNow(now);
|
|
253
380
|
const cutoffMs = nowMs - windowDays * DAY_MS;
|
|
254
381
|
const tasks = readProjection(root);
|
|
255
|
-
|
|
382
|
+
const entries = waitingEntries(tasks, nowMs);
|
|
383
|
+
const data = {
|
|
256
384
|
schema: 'atris.brief.v1',
|
|
257
385
|
days: windowDays,
|
|
258
386
|
landed: buildLanded(tasks, cutoffMs, nowMs),
|
|
259
|
-
waiting:
|
|
387
|
+
waiting: entries.map((entry) => entry.item),
|
|
260
388
|
moves: buildMoves(root),
|
|
261
389
|
week: buildWeekLine(root, nowMs),
|
|
262
390
|
};
|
|
391
|
+
if (!owner) return data;
|
|
392
|
+
const { error, ...screen } = buildOwnerBrief(root, { now: nowMs, decisions: decisionItems(entries) });
|
|
393
|
+
return { ...data, ...screen, ...(error ? { owner_error: error } : {}) };
|
|
263
394
|
}
|
|
264
395
|
|
|
265
396
|
function plural(count, word, pluralWord = `${word}s`) {
|
|
266
397
|
return `${count} ${count === 1 ? word : pluralWord}`;
|
|
267
398
|
}
|
|
268
399
|
|
|
400
|
+
// The json carries each title as the cleaner leaves it: up to 300 characters, cut at a whole word. A
|
|
401
|
+
// person reads it short, on one line, with the plumbing left out (operatorTitle), so the screen, the
|
|
402
|
+
// page, and the text sent are made from this copy.
|
|
403
|
+
function withShortTitles(data) {
|
|
404
|
+
const short = (item) => ({ ...item, title: operatorTitle(item && item.title, OWNER_TITLE_MAX) || 'untitled' });
|
|
405
|
+
return {
|
|
406
|
+
...data,
|
|
407
|
+
needs_you: data.needs_you.map(short),
|
|
408
|
+
done_this_week: Array.isArray(data.done_this_week) ? data.done_this_week.map(short) : data.done_this_week,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
269
412
|
function renderBrief(data) {
|
|
413
|
+
// The owner's screen when the data has it, the older one-line-per-thing brief otherwise.
|
|
414
|
+
if (Array.isArray(data?.needs_you)) return renderOwnerText(withShortTitles(data));
|
|
270
415
|
const landed = Array.isArray(data?.landed) ? data.landed : [];
|
|
271
416
|
const waiting = Array.isArray(data?.waiting) ? data.waiting : [];
|
|
272
417
|
const moves = Array.isArray(data?.moves) ? data.moves : [];
|
|
@@ -324,7 +469,23 @@ function panelRows(items, emptyTitle, emptySub, valueLabel) {
|
|
|
324
469
|
}));
|
|
325
470
|
}
|
|
326
471
|
|
|
472
|
+
function renderOwnerHtml(data) {
|
|
473
|
+
const needs = data.needs_you.length;
|
|
474
|
+
const done = Array.isArray(data.done_this_week) ? data.done_this_week.length : 0;
|
|
475
|
+
const shown = withShortTitles(data);
|
|
476
|
+
return renderHtml({
|
|
477
|
+
theme: 'atris',
|
|
478
|
+
brand: { name: 'atris', accent: ' brief' },
|
|
479
|
+
blocks: [
|
|
480
|
+
{ type: 'title', headline: 'atris brief', sub: `${needs} ${needs === 1 ? 'thing needs' : 'things need'} you, ${done} landed this week` },
|
|
481
|
+
...ownerBlocks(shown),
|
|
482
|
+
{ type: 'close', tagline: 'Read from your task list. Nothing here was changed.' },
|
|
483
|
+
],
|
|
484
|
+
}, { title: 'atris brief' });
|
|
485
|
+
}
|
|
486
|
+
|
|
327
487
|
function renderBriefHtml(data) {
|
|
488
|
+
if (Array.isArray(data?.needs_you)) return renderOwnerHtml(data);
|
|
328
489
|
const landed = Array.isArray(data?.landed) ? data.landed : [];
|
|
329
490
|
const waiting = Array.isArray(data?.waiting) ? data.waiting : [];
|
|
330
491
|
const moves = Array.isArray(data?.moves) ? data.moves : [];
|
|
@@ -397,7 +558,8 @@ function showHelp() {
|
|
|
397
558
|
console.log(' atris brief log --engine <engine> --prompt-file <file> [--json|--raw]');
|
|
398
559
|
console.log(' atris brief outcome <brief_id> --result pass|fail|partial --note <text> [--json|--raw]');
|
|
399
560
|
console.log(' atris brief review [--lessons] [--json] [--limit N]');
|
|
400
|
-
console.log('
|
|
561
|
+
console.log('One screen: what needs you, what got done this week, and how the team is doing.');
|
|
562
|
+
console.log('Read only. It never changes your jobs. --days N sets the older landed list in --json.');
|
|
401
563
|
}
|
|
402
564
|
|
|
403
565
|
function isBriefLedgerSubcommand(value) {
|
|
@@ -519,9 +681,12 @@ function runBriefLedgerCommand(args, root) {
|
|
|
519
681
|
return 2;
|
|
520
682
|
}
|
|
521
683
|
|
|
684
|
+
// On the owner's screen only an item with an accept id can be accepted from the
|
|
685
|
+
// prompt. Pull requests are merged on GitHub, so a screen of those has nothing to ask.
|
|
522
686
|
function shouldPromptBrief({ flags, stdin = process.stdin, stdout = process.stdout, data }) {
|
|
523
687
|
if (flags?.noInput) return false;
|
|
524
688
|
if (!stdin?.isTTY || !stdout?.isTTY) return false;
|
|
689
|
+
if (Array.isArray(data?.needs_you)) return data.needs_you.slice(0, SHOWN).some((item) => item.accept_id);
|
|
525
690
|
return Boolean((data?.waiting || []).length || (data?.moves || []).length);
|
|
526
691
|
}
|
|
527
692
|
|
|
@@ -552,11 +717,26 @@ function parsePromptAnswer(answer) {
|
|
|
552
717
|
return { action: 'invalid' };
|
|
553
718
|
}
|
|
554
719
|
|
|
720
|
+
// "a 2" on the owner's screen means the second item printed there, which is not
|
|
721
|
+
// the second item of the older waiting list.
|
|
722
|
+
function acceptFromScreen(data, index, acceptTask) {
|
|
723
|
+
const item = index >= 0 && index < SHOWN ? data.needs_you[index] : null;
|
|
724
|
+
if (!item) return { ok: false, message: 'no item with that number on this screen' };
|
|
725
|
+
if (!item.accept_id) {
|
|
726
|
+
return { ok: false, message: `item ${index + 1} cannot be accepted from here. if it has a pull request, merge that on GitHub. otherwise look at the job yourself.` };
|
|
727
|
+
}
|
|
728
|
+
const result = acceptTask(item.accept_id);
|
|
729
|
+
return { ok: result.ok, message: result.output || (result.ok ? `accepted ${item.accept_id}` : `could not accept ${item.accept_id}`) };
|
|
730
|
+
}
|
|
731
|
+
|
|
555
732
|
function handleBriefAnswer(root, data, answer, deps = {}) {
|
|
556
733
|
const parsed = parsePromptAnswer(answer);
|
|
557
734
|
const acceptTask = deps.acceptTask || ((id) => runTaskAccept(root, id));
|
|
558
735
|
const moveApproval = deps.approveMove || ((move) => approveMove(root, move, deps.stamp));
|
|
736
|
+
const owner = Array.isArray(data?.needs_you);
|
|
559
737
|
if (parsed.action === 'skip') return { ok: true, skipped: true, message: 'skipped' };
|
|
738
|
+
if (parsed.action === 'accept' && owner) return acceptFromScreen(data, parsed.index, acceptTask);
|
|
739
|
+
if (parsed.action === 'move' && owner) return { ok: false, message: 'next moves are not on this screen' };
|
|
560
740
|
if (parsed.action === 'accept') {
|
|
561
741
|
const item = (data.waiting || [])[parsed.index];
|
|
562
742
|
if (!item) return { ok: false, message: 'no waiting item for that number' };
|
|
@@ -578,7 +758,10 @@ async function promptBrief(root, data, io = {}) {
|
|
|
578
758
|
const output = io.stdout || process.stdout;
|
|
579
759
|
const answer = await new Promise((resolve) => {
|
|
580
760
|
const rl = readline.createInterface({ input, output });
|
|
581
|
-
|
|
761
|
+
const question = Array.isArray(data?.needs_you)
|
|
762
|
+
? 'a N accept an item, or enter to skip: '
|
|
763
|
+
: 'a N accept waiting, m N approve move, or enter to skip: ';
|
|
764
|
+
rl.question(question, (value) => {
|
|
582
765
|
rl.close();
|
|
583
766
|
resolve(value);
|
|
584
767
|
});
|
|
@@ -613,7 +796,7 @@ async function briefCommand(args = [], root = process.cwd(), io = {}) {
|
|
|
613
796
|
return 0;
|
|
614
797
|
}
|
|
615
798
|
|
|
616
|
-
const data = buildBriefData(root, { days: flags.days });
|
|
799
|
+
const data = buildBriefData(root, { days: flags.days, owner: true });
|
|
617
800
|
if (flags.json) {
|
|
618
801
|
console.log(JSON.stringify(data, null, 2));
|
|
619
802
|
return 0;
|
package/commands/engine.js
CHANGED
|
@@ -2294,10 +2294,13 @@ function parseDispatchArgs(args) {
|
|
|
2294
2294
|
let base = '';
|
|
2295
2295
|
let json = false;
|
|
2296
2296
|
let yolo = false;
|
|
2297
|
+
let pick = '';
|
|
2297
2298
|
for (let i = 0; i < args.length; i += 1) {
|
|
2298
2299
|
const a = args[i];
|
|
2299
2300
|
if (a === '--engine') { engine = args[i + 1] || ''; i += 1; continue; }
|
|
2300
2301
|
if (a.startsWith('--engine=')) { engine = a.slice('--engine='.length); continue; }
|
|
2302
|
+
if (a === '--pick') { pick = args[i + 1] || ''; i += 1; continue; }
|
|
2303
|
+
if (a.startsWith('--pick=')) { pick = a.slice('--pick='.length); continue; }
|
|
2301
2304
|
if (a === '--prompt-file') { promptFile = args[i + 1] || ''; i += 1; continue; }
|
|
2302
2305
|
if (a.startsWith('--prompt-file=')) { promptFile = a.slice('--prompt-file='.length); continue; }
|
|
2303
2306
|
if (a === '--base') { base = args[i + 1] || ''; i += 1; continue; }
|
|
@@ -2307,13 +2310,19 @@ function parseDispatchArgs(args) {
|
|
|
2307
2310
|
if (a.startsWith('--')) continue;
|
|
2308
2311
|
taskIds.push(a);
|
|
2309
2312
|
}
|
|
2310
|
-
return { taskIds, engine, promptFile, base, json, yolo };
|
|
2313
|
+
return { taskIds, engine, promptFile, base, json, yolo, pick };
|
|
2311
2314
|
}
|
|
2312
2315
|
|
|
2313
2316
|
function runDispatchCommand(args, root) {
|
|
2314
|
-
const { taskIds, engine, promptFile, base, json, yolo } = parseDispatchArgs(args);
|
|
2317
|
+
const { taskIds, engine, promptFile, base, json, yolo, pick } = parseDispatchArgs(args);
|
|
2315
2318
|
if (!taskIds.length || !engine) {
|
|
2316
|
-
console.error('usage: atris engine dispatch <task-id> [<task-id> ...] --engine <engine> [--prompt-file <f>] [--yolo]');
|
|
2319
|
+
console.error('usage: atris engine dispatch <task-id> [<task-id> ...] --engine <engine> [--prompt-file <f>] [--yolo] [--pick rating|turns]');
|
|
2320
|
+
return 2;
|
|
2321
|
+
}
|
|
2322
|
+
// --pick rating|turns: what to do when the engine named here dies or stalls. rating (the default) hands the
|
|
2323
|
+
// job to the builder its results favor, turns keeps the old rule.
|
|
2324
|
+
if (args.some((arg) => arg === '--pick' || String(arg).startsWith('--pick=')) && !require('../lib/builder-pick').parsePickFlag(pick)) {
|
|
2325
|
+
console.error('engine dispatch: --pick takes rating or turns');
|
|
2317
2326
|
return 2;
|
|
2318
2327
|
}
|
|
2319
2328
|
const canonical = canonicalEngineName(engine);
|
|
@@ -2359,6 +2368,7 @@ function runDispatchCommand(args, root) {
|
|
|
2359
2368
|
engine: canonical,
|
|
2360
2369
|
prompt: promptOverride,
|
|
2361
2370
|
yolo,
|
|
2371
|
+
...(pick ? { pick } : {}),
|
|
2362
2372
|
...(base ? { checkoutBase: base } : {}),
|
|
2363
2373
|
...(pin.roster_model ? { model: pin.roster_model } : {}),
|
|
2364
2374
|
...(pin.roster_effort ? { effort: pin.roster_effort } : {}),
|
|
@@ -2623,7 +2633,7 @@ function engineCommand(args = [], deps = {}) {
|
|
|
2623
2633
|
if (sub === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
2624
2634
|
console.log('\n atris engine watch [<id>|latest] [--no-follow]\n follow one live transcript or list running engine work');
|
|
2625
2635
|
console.log('\n long read-only asks: atris engine ask "..." --engine agy --timeout <seconds>\n quick asks default to 120 seconds; explicit jobs allow up to 3600\n follow live work with atris engine watch latest');
|
|
2626
|
-
console.log('\n atris engine roster + current default\n atris engine roster [--json] show every job and its workers in order: search, build, review, and your own\n flags a model with a newer version on this machine, or a pin not proven in 30 days\n (a landed run, or "verified 2026-09-30" on its line, proves it)\n atris engine roster confirm renew every dated worker for 30 days\n atris engine roster session [clear]\n show or drop the roster changes for this shell only\n atris engine roster --available\n tools on this machine and the models each one offers\n atris engine roster set <job> <tool> <model>\n swap the lead of a job to a tool and model this machine offers; the old lead becomes the first backup\n atris engine roster set --member <name> <tool> <model>\n give one member its own model; every other job stays put\n atris engine roster --runs [job]\n the last 20 runs, newest first, with tool, model, time, and outcome\n atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--prep <job>] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]\n atris engine assign <job> --promote "<tool> [model]" | --remove <tool> | --clear [--session | --everywhere]\n sets the lead worker of the job; --promote moves one of its workers to the top as written;\n --add puts one more at the end; jobs: search, build, review,\n or your own like "small build" (--like search|build|review when the name does not say);\n --prep search has that job read the task first and hand this worker a short brief;\n --everywhere is for all projects; --session is this shell only (set ATRIS_ROSTER_SESSION=<name>)\n atris engines --chart show the fleet as an org chart\n atris engine list --json [--all] full registry: default + engines with tier, roles, fallback, health (--all includes hidden engines)\n atris engine set <name> --duty leader|errands|learning [--models "a, b"]\n arrange the fleet and save its model policy\n atris engine resolve <role> or <job> [--json]\n choose the best ready engine for navigator|executor|validator or a roster job;\n notes always answers: its roster pick, else agy then haiku\n atris engine health <name> --set ready|not_installed|credit_out\n flip runtime health, for example when credits run out\n atris engine doctor [--json]\n probe which engine CLIs are installed here and sync that into health policy\n atris engine <name> make that engine the default here\n atris engine test [name] preflight: run the engine CLI headless, report pass/fail\n atris engine bench [names...] [--runs N]\n ranked latency scoreboard of engine passes\n atris engine ask "<question>" --engine <name> [--engine <name> ...]\n ask several engines in parallel without allowing edits\n atris engine ask --jobs <jobs.json>\n ask different read-only questions in parallel\n atris engine validate <receipt-path|latest> [--engine <name>]\n check ask answers with a different read-only referee\n atris engine validate scoreboard\n show pass rates by worker engine\n atris engine sweep [--apply] [--json]\n list ssh-agents that engine shells left running; --apply rechecks and stops them\n atris engine dispatch <task-id> [<task-id> ...] --engine cursor|codex [--prompt-file <f>] [--yolo]\n one-command claim, worktree, build, verify, ship, ready\n atris engine login <provider> --yes\n upload a local provider CLI login to the backend vault\n atris engine login <provider> --computer [--seat <name>]\n atris engine login <provider> --business <id> [--seat <name>]\n sign in on an Atris computer by device flow\n atris engine login --list | --remove <provider>\n list or remove vaulted provider logins\n atris engine seats show which named accounts are ready to work\n atris engine seed <provider> --business <id>|--user\n push a vaulted login onto an Atris computer\n atris engine reset back to the house default\n --engine <name> one run on that engine (mission run / autopilot / run)\n');
|
|
2636
|
+
console.log('\n atris engine roster + current default\n atris engine roster [--json] show every job and its workers in order: search, build, review, and your own\n flags a model with a newer version on this machine, or a pin not proven in 30 days\n (a landed run, or "verified 2026-09-30" on its line, proves it)\n atris engine roster confirm renew every dated worker for 30 days\n atris engine roster session [clear]\n show or drop the roster changes for this shell only\n atris engine roster --available\n tools on this machine and the models each one offers\n atris engine roster set <job> <tool> <model>\n swap the lead of a job to a tool and model this machine offers; the old lead becomes the first backup\n atris engine roster set --member <name> <tool> <model>\n give one member its own model; every other job stays put\n atris engine roster --runs [job]\n the last 20 runs, newest first, with tool, model, time, and outcome\n atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--prep <job>] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]\n atris engine assign <job> --promote "<tool> [model]" | --remove <tool> | --clear [--session | --everywhere]\n sets the lead worker of the job; --promote moves one of its workers to the top as written;\n --add puts one more at the end; jobs: search, build, review,\n or your own like "small build" (--like search|build|review when the name does not say);\n --prep search has that job read the task first and hand this worker a short brief;\n --everywhere is for all projects; --session is this shell only (set ATRIS_ROSTER_SESSION=<name>)\n atris engines --chart show the fleet as an org chart\n atris engine list --json [--all] full registry: default + engines with tier, roles, fallback, health (--all includes hidden engines)\n atris engine set <name> --duty leader|errands|learning [--models "a, b"]\n arrange the fleet and save its model policy\n atris engine resolve <role> or <job> [--json]\n choose the best ready engine for navigator|executor|validator or a roster job;\n notes always answers: its roster pick, else agy then haiku\n atris engine health <name> --set ready|not_installed|credit_out\n flip runtime health, for example when credits run out\n atris engine doctor [--json]\n probe which engine CLIs are installed here and sync that into health policy\n atris engine <name> make that engine the default here\n atris engine test [name] preflight: run the engine CLI headless, report pass/fail\n atris engine bench [names...] [--runs N]\n ranked latency scoreboard of engine passes\n atris engine ask "<question>" --engine <name> [--engine <name> ...]\n ask several engines in parallel without allowing edits\n atris engine ask --jobs <jobs.json>\n ask different read-only questions in parallel\n atris engine validate <receipt-path|latest> [--engine <name>]\n check ask answers with a different read-only referee\n atris engine validate scoreboard\n show pass rates by worker engine\n atris engine sweep [--apply] [--json]\n list ssh-agents that engine shells left running; --apply rechecks and stops them\n atris engine dispatch <task-id> [<task-id> ...] --engine cursor|codex [--prompt-file <f>] [--yolo] [--pick rating|turns]\n one-command claim, worktree, build, verify, ship, ready; if the engine dies or stalls, the job goes to the builder its results favor (--pick turns keeps the old rule)\n atris engine login <provider> --yes\n upload a local provider CLI login to the backend vault\n atris engine login <provider> --computer [--seat <name>]\n atris engine login <provider> --business <id> [--seat <name>]\n sign in on an Atris computer by device flow\n atris engine login --list | --remove <provider>\n list or remove vaulted provider logins\n atris engine seats show which named accounts are ready to work\n atris engine seed <provider> --business <id>|--user\n push a vaulted login onto an Atris computer\n atris engine reset back to the house default\n --engine <name> one run on that engine (mission run / autopilot / run)\n');
|
|
2627
2637
|
return 0;
|
|
2628
2638
|
}
|
|
2629
2639
|
if (isFreshWorkspace(root)) {
|
package/commands/mission.js
CHANGED
|
@@ -758,6 +758,7 @@ function printJsonOrText(payload, lines, asJson) {
|
|
|
758
758
|
|
|
759
759
|
const MISSION_RUN_VALUE_FLAGS = [
|
|
760
760
|
'--slots',
|
|
761
|
+
'--pick',
|
|
761
762
|
'--max-ticks',
|
|
762
763
|
'--max-idle-ticks',
|
|
763
764
|
'--max-wall',
|
|
@@ -10256,14 +10257,34 @@ async function parseAndValidateMissionRunPhase(args) {
|
|
|
10256
10257
|
// safe-lane tasks, build in parallel worktrees, land serially. Humble flag,
|
|
10257
10258
|
// full loop, see lib/fleet.js. --dry-run previews the staffing only.
|
|
10258
10259
|
if (hasFlag(args, '--fleet')) {
|
|
10259
|
-
const { runFleetFlight, flightExitCode } = require('../lib/fleet');
|
|
10260
|
+
const { runFleetFlight, flightExitCode, FLEET_CAPABLE } = require('../lib/fleet');
|
|
10261
|
+
const { parsePickFlag } = require('../lib/builder-pick');
|
|
10260
10262
|
const slots = Math.max(1, Number(readFlag(args, '--slots', '')) || 3);
|
|
10263
|
+
// --pick rating|turns: rating (the default) deals each job to the builder its results favor, turns deals
|
|
10264
|
+
// in turn as the fleet always did. --engine <name> sends every job to that engine and skips the picker.
|
|
10265
|
+
const pickFlag = readFlag(args, '--pick', '');
|
|
10266
|
+
if ((pickFlag || args.some((arg) => arg === '--pick' || String(arg).startsWith('--pick='))) && !parsePickFlag(pickFlag)) {
|
|
10267
|
+
exitMissionError('--pick takes rating or turns', 2, asJson);
|
|
10268
|
+
}
|
|
10269
|
+
const engineFlag = readFlag(args, '--engine', '').toLowerCase();
|
|
10270
|
+
if (engineFlag && !FLEET_CAPABLE.includes(engineFlag)) {
|
|
10271
|
+
exitMissionError(`--engine for a fleet takes one of ${FLEET_CAPABLE.join(', ')}`, 2, asJson);
|
|
10272
|
+
}
|
|
10273
|
+
if (engineFlag && !hasFlag(args, '--dry-run')) {
|
|
10274
|
+
try {
|
|
10275
|
+
require('../lib/engine-registry').requireEngineBin(engineFlag);
|
|
10276
|
+
} catch (err) {
|
|
10277
|
+
exitMissionError(err.message, 2, asJson);
|
|
10278
|
+
}
|
|
10279
|
+
}
|
|
10261
10280
|
// Default: cut build worktrees from origin/master (checkoutBase). --base
|
|
10262
10281
|
// keeps launcher-HEAD only when the operator asks for it explicitly.
|
|
10263
10282
|
const baseOverride = readFlag(args, '--base', '');
|
|
10264
10283
|
const flight = await runFleetFlight({
|
|
10265
10284
|
slots,
|
|
10266
10285
|
dryRun: hasFlag(args, '--dry-run'),
|
|
10286
|
+
pick: pickFlag,
|
|
10287
|
+
...(engineFlag ? { engine: engineFlag } : {}),
|
|
10267
10288
|
log: asJson ? () => {} : console.log,
|
|
10268
10289
|
...(baseOverride ? { checkoutBase: baseOverride } : {}),
|
|
10269
10290
|
});
|
|
@@ -12485,7 +12506,7 @@ atris mission - durable goal + loop + owner + proof state
|
|
|
12485
12506
|
atris mission set-verifier <id> "<cmd>" [--json]
|
|
12486
12507
|
atris mission route set <id> [--file <route.json>|--file -] [--json] Validate and save a falsifiable route from a JSON file or stdin
|
|
12487
12508
|
atris mission "<objective>" [--owner <member>] Shortcut for: atris mission run "<objective>"
|
|
12488
|
-
atris mission run --fleet [--slots 3] [--dry-run] [--json] Staff every idle capable engine on claimable safe-lane tasks: parallel worktree builds, serial rebase-before-ship landings, receipt in atris/runs
|
|
12509
|
+
atris mission run --fleet [--slots 3] [--dry-run] [--json] [--pick rating|turns] [--engine <name>] Staff every idle capable engine on claimable safe-lane tasks: parallel worktree builds, serial rebase-before-ship landings, receipt in atris/runs/. Each job goes to the builder its real results favor (--pick turns deals in turn; --engine sends every job to one engine)
|
|
12489
12510
|
atris mission run "<objective>" --cloud [--lane fast|pro|max] [--agent <id>] Enqueue on the Atris backend instead of running local ticks
|
|
12490
12511
|
atris mission run ["objective"|<member> ["objective"]|id|--due] [--owner <member>] [--budget quick|long|deep] [--max-ticks 4] [--max-wall 3600] [--cadence "15m"] [--detach] [--land --repo <path> --verify "git diff --check"]
|
|
12491
12512
|
[--native-goal-status active|paused|usageLimited] [--native-goal-objective "..."] [--manual-ack] [--allow-native-goal-supersede] [--take-goal-slot]
|
package/commands/rating.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
const { hasFlag } = require('../lib/arg-parser');
|
|
9
9
|
const { modelLabel } = require('../lib/roster-models');
|
|
10
10
|
const rating = require('../lib/atris-rating');
|
|
11
|
+
const { steeringSummary } = require('../lib/builder-pick');
|
|
11
12
|
|
|
12
13
|
const USAGE = [
|
|
13
14
|
'usage: atris rating [engine | member] [--json]',
|
|
@@ -44,7 +45,7 @@ function readHere(deps = {}) {
|
|
|
44
45
|
return null;
|
|
45
46
|
}
|
|
46
47
|
if (!played || played.error || played.no_history) return null;
|
|
47
|
-
const { rosterRows, tally } = require('../lib/gm-season');
|
|
48
|
+
const { rosterRows, tally, missReasons } = require('../lib/gm-season');
|
|
48
49
|
// Jobs still in review whose pull request is open, per builder. These do not depend on the
|
|
49
50
|
// last 30 days of plays: a job can wait longer than that, and its builder still has it.
|
|
50
51
|
const waiting = new Map();
|
|
@@ -65,14 +66,7 @@ function readHere(deps = {}) {
|
|
|
65
66
|
const team = new Map(((lineup && lineup.ok && lineup.team) || []).map((row) => [String(row.member || '').toLowerCase(), row]));
|
|
66
67
|
const members = rows.map((row) => {
|
|
67
68
|
const seat = team.get(row.name.toLowerCase()) || {};
|
|
68
|
-
const
|
|
69
|
-
for (const play of played.outcomes || []) {
|
|
70
|
-
if (play.member === row.name && ['dropped', 'sent_back', 'abandoned'].includes(play.kind) && play.reason) {
|
|
71
|
-
reasons.set(play.reason, (reasons.get(play.reason) || 0) + 1);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
const miss_reasons = [...reasons].map(([reason, count]) => ({ reason, count }))
|
|
75
|
-
.sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)).slice(0, 3);
|
|
69
|
+
const miss_reasons = missReasons(played.outcomes || [], row.name);
|
|
76
70
|
return { member: row.name, ovr: row.ovr, landed: row.landed, finished: row.finished, ...(row.not_needed ? { not_needed: row.not_needed } : {}), ...(row.held ? { held: row.held } : {}), ...(row.could_not_start ? { could_not_start: row.could_not_start } : {}), ...(waiting.has(row.name) ? { waiting_on_pull_request: waiting.get(row.name) } : {}), job: seat.job || null, model: seat.model || null, ...(miss_reasons.length ? { miss_reasons } : {}) };
|
|
77
71
|
});
|
|
78
72
|
const buildJob = ((lineup && lineup.ok && lineup.jobs) || []).find((row) => row.job === 'build' && !row.key);
|
|
@@ -179,16 +173,19 @@ function ratingCommand(args = [], deps = {}) {
|
|
|
179
173
|
return 1;
|
|
180
174
|
}
|
|
181
175
|
const here = readHere(deps);
|
|
176
|
+
// Steering: the fleet picks each job's builder from the results above. The line shows only while that is on.
|
|
177
|
+
const steering = steeringSummary({ root: deps.root || taskDb.workspaceRoot(process.cwd()), env: deps.env || process.env });
|
|
182
178
|
if (json) {
|
|
183
179
|
write(`${JSON.stringify({
|
|
184
180
|
schema: 'atris.rating.v1',
|
|
185
181
|
scale: { floor: 25, top: 99, means: rating.SCALE_LINES.join(' ') },
|
|
186
182
|
combine: { bench: rating.COMBINE.bench, month: rating.COMBINE.month, tasks: rating.COMBINE.tasks, unseen: rating.COMBINE.unseen, engines: cards },
|
|
187
183
|
here,
|
|
184
|
+
steering,
|
|
188
185
|
}, null, 2)}\n`);
|
|
189
186
|
return 0;
|
|
190
187
|
}
|
|
191
|
-
write(`${rating.renderCombine(cards)}${here ? `\n\n${renderHere(here)}` : ''}\n`);
|
|
188
|
+
write(`${rating.renderCombine(cards)}${here ? `\n\n${renderHere(here)}` : ''}${steering.on ? `\n\n${steering.line}` : ''}\n`);
|
|
192
189
|
return 0;
|
|
193
190
|
}
|
|
194
191
|
|