atris 3.76.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 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
- return cut.slice(0, space > max * 0.55 ? space : max).trim();
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 raw = inlineText(value, '');
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
- const source = plumbing.test(raw) && raw.includes(':') ? raw.split(':')[0] : raw;
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+[^\.;,]+/gi, ' ')
85
- .replace(/\S+\.(?:js|md|json|ts|py|sh)(?::\d+)?/gi, ' ')
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(/,\s*,+/g, ',')
157
+ .replace(/,(?:\s*,)+/g, ',')
91
158
  .replace(/\s+([,.;:])/g, '$1')
92
159
  .replace(/([:(])\s*([,.;:)]|$)/g, '')
93
160
  .replace(/\s+\)/g, ')')
94
- .trim();
95
- return finishText(scrubbed || value, max);
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
- if (url) return url[0].replace(/[.,;]+$/, '');
143
- const pathMatch = value.match(/(?:^|\s)((?:\.atris|atris)\/[^\s,;:)]+)/i);
144
- if (pathMatch) return pathMatch[1].replace(/[.,;]+$/, '');
145
- const pr = value.match(/\bpr\s*#?\s*\d+\b/i);
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(value) ? 'proof on file' : '';
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
- function waitingReason(task) {
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 `review lane is ${approval}`;
275
+ if (approval && approval !== 'pending') return { kind: 'approval', approval };
189
276
  const denied = deniedTagForTask(task);
190
- if (denied) return `protected ${denied} lane`;
191
- if (needsHumanTag(task)) return 'needs human lane';
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
- function buildWaiting(tasks, nowMs) {
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
- id: taskId(task),
225
- title: inlineText(task.title, 'review task'),
226
- why: waitingReason(task),
227
- age: formatAge(startMs, nowMs),
228
- _sort: startMs || 0,
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._sort - b._sort)
232
- .map(({ _sort, ...item }) => item);
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 || 'workspace signal', 'workspace signal'),
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
- function buildBriefData(root = process.cwd(), { days = 1, now = Date.now() } = {}) {
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
- return {
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: buildWaiting(tasks, nowMs),
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('Shows what landed, what waits on you, and what the loop should do next.');
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
- rl.question('a N accept waiting, m N approve move, or enter to skip: ', (value) => {
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;
@@ -44,7 +44,7 @@ function readHere(deps = {}) {
44
44
  return null;
45
45
  }
46
46
  if (!played || played.error || played.no_history) return null;
47
- const { rosterRows, tally } = require('../lib/gm-season');
47
+ const { rosterRows, tally, missReasons } = require('../lib/gm-season');
48
48
  // Jobs still in review whose pull request is open, per builder. These do not depend on the
49
49
  // last 30 days of plays: a job can wait longer than that, and its builder still has it.
50
50
  const waiting = new Map();
@@ -65,14 +65,7 @@ function readHere(deps = {}) {
65
65
  const team = new Map(((lineup && lineup.ok && lineup.team) || []).map((row) => [String(row.member || '').toLowerCase(), row]));
66
66
  const members = rows.map((row) => {
67
67
  const seat = team.get(row.name.toLowerCase()) || {};
68
- const reasons = new Map();
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);
68
+ const miss_reasons = missReasons(played.outcomes || [], row.name);
76
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 } : {}) };
77
70
  });
78
71
  const buildJob = ((lineup && lineup.ok && lineup.jobs) || []).find((row) => row.job === 'build' && !row.key);
package/lib/gm-season.js CHANGED
@@ -89,6 +89,20 @@ function memberRatings(outcomes = []) {
89
89
  return new Map([...groups].map(([name, list]) => [name, { ovr: ovr(list), finished: tally(list).finished }]));
90
90
  }
91
91
 
92
+ // The words a member's misses came with, most often given first: the reason on
93
+ // its dropped, sent back, and abandoned plays. atris rating and the owner brief
94
+ // both show these, so both read them here.
95
+ function missReasons(outcomes, member, limit = 3) {
96
+ const reasons = new Map();
97
+ for (const play of outcomes || []) {
98
+ if (play.member === member && ['dropped', 'sent_back', 'abandoned'].includes(play.kind) && play.reason) {
99
+ reasons.set(play.reason, (reasons.get(play.reason) || 0) + 1);
100
+ }
101
+ }
102
+ return [...reasons].map(([reason, count]) => ({ reason, count }))
103
+ .sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)).slice(0, limit);
104
+ }
105
+
92
106
  // Last n finished jobs, oldest on the left, newest on the right.
93
107
  function form(outcomes, n = 5) {
94
108
  return [...(outcomes || [])]
@@ -610,6 +624,65 @@ function playEventsSql(extraWhere = '') {
610
624
  ORDER BY e.created_at ASC`;
611
625
  }
612
626
 
627
+ // A workspace's plays as they stood at an earlier time: the same plays loadSeason
628
+ // would have found on that day, from the events that existed by then and nothing
629
+ // after. Working out all of the history and cutting the plays by date afterwards is
630
+ // not the same thing. A landing that came later replaces an earlier miss, and a
631
+ // later "not needed" note clears one, so the miss would be gone from a rating that
632
+ // was printed with it. Every other fact a play is built from (the claim before it,
633
+ // whether the engine had started) is an earlier event in the same job, so cutting
634
+ // the events is enough.
635
+ //
636
+ // The job's own row is the one thing that is not an earlier event. It says who holds
637
+ // the job now, and today's rating falls back on that when a play has no claim on
638
+ // record before it. For a day in the past that is the wrong answer: a job nobody held
639
+ // that day would be handed to whoever took it later, and sent back to them. So the
640
+ // holder and the claim time are cleared here. A play with no claim on record at or
641
+ // before the day has no member and is not counted, the way a job nobody held was not
642
+ // counted on that day. (Only the title is still read from the row, and it is only
643
+ // shown.) `db` is an open task store.
644
+ function outcomesAsOf(db, workspaceRoot, asOf, days = SEASON_DAYS) {
645
+ const since = asOf - days * DAY_MS;
646
+ const rows = db.prepare(playEventsSql('AND e.workspace_root = ? AND e.created_at <= ?')).all(since, workspaceRoot, asOf)
647
+ .map(row => ({ ...row, claimed_by: null, claimed_at: null }));
648
+ return outcomesFromEvents(rows, since).filter(play => play.at <= asOf);
649
+ }
650
+
651
+ // The jobs open right now whose latest release is one the rating reads as held (the
652
+ // work is done and its pull request waits for a person, but the proof could not be
653
+ // saved) or as could_not_start (the build machine never ran the job), and that nobody
654
+ // has claimed or landed since, and that has not been archived or reopened since: a
655
+ // release older than the job's latest archive or reopen is not what the job is waiting
656
+ // on, because it was closed and then opened again for new work. There is no age limit:
657
+ // loadSeason only looks back 30 days, and a job left open by a release older than that
658
+ // is still where it was left.
659
+ // Each release is read by the rating's own query and rules (the member, the kind, the
660
+ // engine_started mark), so this cannot disagree with atris rating about what a release
661
+ // was. Returns one play per job, in the shape outcomesFromEvents makes. `db` is an open
662
+ // task store.
663
+ function openReleasePlays(db, workspaceRoot) {
664
+ const found = db.prepare(`
665
+ SELECT r.event_id AS event_id, r.task_id AS task_id, r.version AS version
666
+ FROM task_events r
667
+ JOIN tasks t ON t.id = r.task_id
668
+ WHERE t.workspace_root = ? AND t.status = 'open' AND r.event_type = 'claim_released'
669
+ -- a first cut on the words, so only the few releases of these kinds are read in full below
670
+ AND (r.payload LIKE '%proof_not_saved%' OR r.payload LIKE '%could_not_start%')
671
+ AND r.version = (SELECT MAX(x.version) FROM task_events x WHERE x.task_id = r.task_id AND x.event_type = 'claim_released')
672
+ AND NOT EXISTS (SELECT 1 FROM task_events later WHERE later.task_id = r.task_id AND later.version > r.version AND later.event_type IN ('claimed', 'archived', 'reopened'))`)
673
+ .all(workspaceRoot);
674
+ const release = db.prepare(playEventsSql('AND e.event_id = ?'));
675
+ const after = db.prepare(playEventsSql('AND e.task_id = ? AND e.version > ?'));
676
+ const plays = [];
677
+ for (const { event_id: eventId, task_id: taskId, version } of found) {
678
+ const play = outcomesFromEvents(release.all(0, eventId)).find(made => made.event_id === eventId);
679
+ if (!play || (play.kind !== 'held' && play.kind !== 'could_not_start')) continue;
680
+ if (outcomesFromEvents(after.all(0, taskId, version)).some(made => made.kind === 'landed')) continue;
681
+ plays.push(play);
682
+ }
683
+ return plays;
684
+ }
685
+
613
686
  // The one loader: last 30 days of plays for a workspace, plus open claims,
614
687
  // who is asleep, and what is up next. Opens the db read-only.
615
688
  // `lastSeen` pins the recap to a session's opening look; without it the
@@ -1282,11 +1355,15 @@ module.exports = {
1282
1355
  lastPlays,
1283
1356
  loadSeason,
1284
1357
  memberRatings,
1358
+ missReasons,
1359
+ openReleasePlays,
1360
+ outcomesAsOf,
1285
1361
  outcomesFromEvents,
1286
1362
  ovr,
1287
1363
  ownerMood,
1288
1364
  readLastSeen,
1289
1365
  rosterRows,
1366
+ SEASON_DAYS,
1290
1367
  seasonBoard,
1291
1368
  sinceLastLook,
1292
1369
  stillStuck,