atris 3.75.0 → 3.77.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/commands/brief.js +219 -36
- package/commands/rating.js +18 -11
- package/commands/task.js +72 -5
- package/commands/worktree.js +27 -6
- package/lib/fleet.js +58 -4
- package/lib/gm-season.js +93 -1
- package/lib/held-pull-requests.js +589 -0
- package/lib/owner-brief.js +1060 -0
- package/lib/pull-request-link.js +25 -0
- package/lib/review-integrity.js +52 -5
- package/lib/task-db.js +519 -135
- package/package.json +1 -1
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/rating.js
CHANGED
|
@@ -44,26 +44,29 @@ function readHere(deps = {}) {
|
|
|
44
44
|
return null;
|
|
45
45
|
}
|
|
46
46
|
if (!played || played.error || played.no_history) return null;
|
|
47
|
-
const { rosterRows } = require('../lib/gm-season');
|
|
47
|
+
const { rosterRows, tally, missReasons } = require('../lib/gm-season');
|
|
48
|
+
// Jobs still in review whose pull request is open, per builder. These do not depend on the
|
|
49
|
+
// last 30 days of plays: a job can wait longer than that, and its builder still has it.
|
|
50
|
+
const waiting = new Map();
|
|
51
|
+
for (const review of played.reviews || []) {
|
|
52
|
+
if (review && review.waiting_on_pull_request && review.member) waiting.set(review.member, (waiting.get(review.member) || 0) + 1);
|
|
53
|
+
}
|
|
54
|
+
const roster = rosterRows({ outcomes: played.outcomes || [], limit: Number.MAX_SAFE_INTEGER });
|
|
48
55
|
// A member whose only misses were later confirmed not needed, or whose only jobs
|
|
49
56
|
// wait on a pull request or could not start, has no finished job, but still shows,
|
|
50
57
|
// so those jobs have somewhere to be counted.
|
|
51
|
-
const rows =
|
|
58
|
+
const rows = roster.filter((row) => row.finished > 0 || row.not_needed > 0 || row.held > 0 || row.could_not_start > 0 || waiting.has(row.name));
|
|
59
|
+
for (const name of waiting.keys()) {
|
|
60
|
+
if (!roster.some((row) => row.name === name)) rows.push({ name, ...tally([]), ovr: null });
|
|
61
|
+
}
|
|
52
62
|
if (!rows.length) return null;
|
|
53
63
|
let lineup = deps.lineup;
|
|
54
64
|
if (lineup === undefined) lineup = require('../lib/team-lineup').readLineupSafe(root);
|
|
55
65
|
const team = new Map(((lineup && lineup.ok && lineup.team) || []).map((row) => [String(row.member || '').toLowerCase(), row]));
|
|
56
66
|
const members = rows.map((row) => {
|
|
57
67
|
const seat = team.get(row.name.toLowerCase()) || {};
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
if (play.member === row.name && ['dropped', 'sent_back', 'abandoned'].includes(play.kind) && play.reason) {
|
|
61
|
-
reasons.set(play.reason, (reasons.get(play.reason) || 0) + 1);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
const miss_reasons = [...reasons].map(([reason, count]) => ({ reason, count }))
|
|
65
|
-
.sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)).slice(0, 3);
|
|
66
|
-
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 } : {}), job: seat.job || null, model: seat.model || null, ...(miss_reasons.length ? { miss_reasons } : {}) };
|
|
68
|
+
const miss_reasons = missReasons(played.outcomes || [], row.name);
|
|
69
|
+
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 } : {}) };
|
|
67
70
|
});
|
|
68
71
|
const buildJob = ((lineup && lineup.ok && lineup.jobs) || []).find((row) => row.job === 'build' && !row.key);
|
|
69
72
|
const buildModel = buildJob && buildJob.now_runs ? buildJob.now_runs.model : null;
|
|
@@ -126,6 +129,10 @@ function renderMember(m, card) {
|
|
|
126
129
|
if (m.not_needed) lines.push(` not needed: ${m.not_needed} (confirmed by someone else, so ${m.not_needed === 1 ? 'it does' : 'they do'} not count either way)`);
|
|
127
130
|
if (m.held) lines.push(` waiting on a pull request: ${m.held} (the work is done and a person has to land it, so ${m.held === 1 ? 'it does' : 'they do'} not count either way)`);
|
|
128
131
|
if (m.could_not_start) lines.push(` ${couldNotStartLine(m.could_not_start)}`);
|
|
132
|
+
if (m.waiting_on_pull_request) {
|
|
133
|
+
const one = m.waiting_on_pull_request === 1;
|
|
134
|
+
lines.push(` ${m.waiting_on_pull_request} ${one ? 'job waits' : 'jobs wait'} on an open pull request. Check GitHub and credit ${one ? 'it' : 'them'}: atris task sweep --held`);
|
|
135
|
+
}
|
|
129
136
|
if (m.ovr == null) lines.push(' rated once it has 3 finished jobs');
|
|
130
137
|
if (m.miss_reasons && m.miss_reasons.length) {
|
|
131
138
|
lines.push(' top miss reasons:');
|
package/commands/task.js
CHANGED
|
@@ -210,6 +210,7 @@ atris task - durable local task state (SQLite, gitignored)
|
|
|
210
210
|
Preview certified Review rows; live accept needs --confirm-human-accept --as <human>
|
|
211
211
|
atris task sweep --auto-accept [--json] Auto-accept verified Review rows; protected lanes wait for human
|
|
212
212
|
atris task sweep [--json] [--stale-days N] [--claimer NAME] [--dry-run] Release expired claims
|
|
213
|
+
atris task sweep --held [--everywhere] [--dry-run] [--json] Check held jobs against GitHub: credit a merge, release a closed pull request
|
|
213
214
|
atris task audit [--limit <n>] [--revise] re-run stored verifies for newest accepted tasks; report-only unless --revise
|
|
214
215
|
atris task revise <id> --note "..." Send reviewed work back to Do
|
|
215
216
|
|
|
@@ -400,8 +401,10 @@ function failAgentProofOnly(label, detail) {
|
|
|
400
401
|
);
|
|
401
402
|
}
|
|
402
403
|
|
|
403
|
-
|
|
404
|
-
|
|
404
|
+
// Write straight to stdout and wait until it is all out, so a command that exits right after
|
|
405
|
+
// printing (process.exit drops whatever a pipe still holds) never loses the end of its output.
|
|
406
|
+
function writeStdoutSync(text) {
|
|
407
|
+
const buffer = Buffer.from(text, 'utf8');
|
|
405
408
|
const retryWait = new Int32Array(new SharedArrayBuffer(4));
|
|
406
409
|
let offset = 0;
|
|
407
410
|
while (offset < buffer.length) {
|
|
@@ -417,6 +420,10 @@ function printJson(value) {
|
|
|
417
420
|
}
|
|
418
421
|
}
|
|
419
422
|
|
|
423
|
+
function printJson(value) {
|
|
424
|
+
writeStdoutSync(`${JSON.stringify(value, null, 2)}\n`);
|
|
425
|
+
}
|
|
426
|
+
|
|
420
427
|
function refreshCareerXpProjection(workspaceRoot) {
|
|
421
428
|
if (!workspaceRoot) return null;
|
|
422
429
|
try {
|
|
@@ -9743,6 +9750,9 @@ function cmdReady(args) {
|
|
|
9743
9750
|
const merge = readyMergeFlags(args);
|
|
9744
9751
|
guardExplicitActor('atris task ready', flag(args, '--as'));
|
|
9745
9752
|
const actor = String(flag(args, '--as') || DEFAULT_OWNER);
|
|
9753
|
+
// The others who built the work with --as, by name, comma apart: a flight that ran for a member
|
|
9754
|
+
// hands in under the member's name and names the engines that ran here. Only a hand-in uses it.
|
|
9755
|
+
const builtWith = textFlag(args, ['--built-with']).split(',').map((name) => name.trim()).filter(Boolean);
|
|
9746
9756
|
const resultFields = {
|
|
9747
9757
|
changed: textFlag(args, ['--changed', '--done']),
|
|
9748
9758
|
checked: textFlag(args, ['--checked', '--check', '--verified']),
|
|
@@ -9790,6 +9800,7 @@ function cmdReady(args) {
|
|
|
9790
9800
|
landing,
|
|
9791
9801
|
merge,
|
|
9792
9802
|
result: resultSentence,
|
|
9803
|
+
builtWith,
|
|
9793
9804
|
});
|
|
9794
9805
|
if (!result.ready) {
|
|
9795
9806
|
console.error(`ready failed: ${result.reason}`);
|
|
@@ -10419,10 +10430,12 @@ function cmdCertifyVerified(args, options = {}) {
|
|
|
10419
10430
|
results.push({ ref, action: 'skipped', reason: evaluation.reason });
|
|
10420
10431
|
continue;
|
|
10421
10432
|
}
|
|
10422
|
-
// judge != worker: the re-run only counts as an independent check when
|
|
10423
|
-
//
|
|
10433
|
+
// judge != worker: the re-run only counts as an independent check when its actor never built
|
|
10434
|
+
// the row it is judging: not its builder now, and not anyone who built it before (the store
|
|
10435
|
+
// reads the whole history, so a job sent back, let go or reopened does not forget its builders).
|
|
10424
10436
|
const builder = reviewIntegrity.taskBuilder(task);
|
|
10425
|
-
|
|
10437
|
+
const verifier = reviewIntegrity.normalizeActor(actor);
|
|
10438
|
+
if (verifier && ((builder && verifier === builder) || taskDb.authorsOfTask(db, task).has(verifier))) {
|
|
10426
10439
|
results.push({ ref, action: 'skipped', reason: 'verifier_is_builder' });
|
|
10427
10440
|
continue;
|
|
10428
10441
|
}
|
|
@@ -10906,7 +10919,61 @@ function evaluateSweepAutoAccept(task, root, includeProtected = false) {
|
|
|
10906
10919
|
return { eligible: false, ref, reason: verifier.reason, evidence: verifier.evidence };
|
|
10907
10920
|
}
|
|
10908
10921
|
|
|
10922
|
+
// atris task sweep --held: ask GitHub about each job waiting on an open pull request and write
|
|
10923
|
+
// down what happened (lib/held-pull-requests.js). Anyone may run it, agents included: GitHub is
|
|
10924
|
+
// the only source and the run grants nothing, so the agent block that guards --auto-accept does
|
|
10925
|
+
// not apply here. It takes none of the expired-claims flags.
|
|
10926
|
+
function cmdSweepHeld(args) {
|
|
10927
|
+
for (const name of ['--auto-accept', '--stale-days', '--claimer']) {
|
|
10928
|
+
if (hasFlag(args, name)) {
|
|
10929
|
+
failTask('atris task sweep', 'held_flags_conflict', `atris task sweep: --held cannot be combined with ${name}`);
|
|
10930
|
+
}
|
|
10931
|
+
}
|
|
10932
|
+
const named = flag(args, '--as');
|
|
10933
|
+
if (named === true) failTask('atris task sweep', 'missing_actor', 'atris task sweep: --as needs a name');
|
|
10934
|
+
guardExplicitActor('atris task sweep', named);
|
|
10935
|
+
const actor = String(named || DEFAULT_OWNER);
|
|
10936
|
+
const dryRun = hasFlag(args, '--dry-run');
|
|
10937
|
+
const json = wantsJson(args);
|
|
10938
|
+
const taskDb = getTaskDb();
|
|
10939
|
+
const workspaceRoot = scopedWorkspaceRoot(taskDb, args);
|
|
10940
|
+
const { sweepHeldJobs, describeHeldSweep } = require('../lib/held-pull-requests');
|
|
10941
|
+
|
|
10942
|
+
// A dry run only reads: it opens the task list read only and never creates one.
|
|
10943
|
+
let db = null;
|
|
10944
|
+
let reader = null;
|
|
10945
|
+
if (!dryRun) {
|
|
10946
|
+
db = taskDb.open();
|
|
10947
|
+
} else if (fs.existsSync(taskDb.getDbPath())) {
|
|
10948
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
10949
|
+
reader = new DatabaseSync(taskDb.getDbPath(), { readOnly: true });
|
|
10950
|
+
db = reader;
|
|
10951
|
+
}
|
|
10952
|
+
let report;
|
|
10953
|
+
try {
|
|
10954
|
+
report = sweepHeldJobs(taskDb, db, { workspaceRoot, actor, dryRun });
|
|
10955
|
+
} finally {
|
|
10956
|
+
if (reader) reader.close();
|
|
10957
|
+
}
|
|
10958
|
+
|
|
10959
|
+
const changed = report.summary.landed + report.summary.released > 0;
|
|
10960
|
+
const projectionPath = changed ? writeDefaultProjection(taskDb, db).outPath : null;
|
|
10961
|
+
if (json) printJson({ ...report, ...(projectionPath ? { projection_path: projectionPath } : {}) });
|
|
10962
|
+
else writeStdoutSync(`${describeHeldSweep(report).join('\n')}\n`);
|
|
10963
|
+
if (report.ok) return;
|
|
10964
|
+
|
|
10965
|
+
// GitHub could not be reached, so the rest were not asked and nothing more changed. Whatever
|
|
10966
|
+
// was settled before that is still refreshed on the task list.
|
|
10967
|
+
if (changed) autoRenderTodoFromDb();
|
|
10968
|
+
if (!json) {
|
|
10969
|
+
const left = report.summary.not_asked;
|
|
10970
|
+
console.error(`stopped: ${report.outage.detail}${left ? `. ${left} held ${left === 1 ? 'job was' : 'jobs were'} not asked` : ''}`);
|
|
10971
|
+
}
|
|
10972
|
+
process.exit(1);
|
|
10973
|
+
}
|
|
10974
|
+
|
|
10909
10975
|
function cmdSweep(args) {
|
|
10976
|
+
if (hasFlag(args, '--held')) return cmdSweepHeld(args);
|
|
10910
10977
|
if (!hasFlag(args, '--auto-accept')) {
|
|
10911
10978
|
const taskDb = getTaskDb();
|
|
10912
10979
|
const staleRaw = flag(args, '--stale-days');
|