atris 3.71.2 → 3.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/atris/skills/campaigns/SKILL.md +1 -1
  2. package/bin/atris.js +8 -2
  3. package/commands/autoland.js +102 -2
  4. package/commands/campaign.js +263 -11
  5. package/commands/clean.js +60 -4
  6. package/commands/gm.js +1273 -7
  7. package/commands/improve.js +3 -2
  8. package/commands/member.js +28 -3
  9. package/commands/mission.js +82 -27
  10. package/commands/pulse.js +89 -5
  11. package/commands/sync.js +2 -2
  12. package/commands/task.js +49 -13
  13. package/commands/worktree.js +24 -2
  14. package/commands/zen.js +183 -0
  15. package/lib/auto-accept-certified.js +9 -4
  16. package/lib/campaign-cadence.js +53 -14
  17. package/lib/campaign-core.js +98 -17
  18. package/lib/campaign-keep.js +91 -0
  19. package/lib/campaign-lanes.js +11 -6
  20. package/lib/campaign-loop.js +173 -32
  21. package/lib/campaign-reading.js +14 -1
  22. package/lib/campaign-rsi.js +697 -0
  23. package/lib/campaign-run-lock.js +9 -1
  24. package/lib/campaign-schedule.js +124 -0
  25. package/lib/campaign-schema.js +3 -1
  26. package/lib/campaign-store.js +34 -2
  27. package/lib/campaign-trace.js +77 -29
  28. package/lib/engine-resource.js +5 -1
  29. package/lib/gm-bet.js +2219 -0
  30. package/lib/gm-phone-page.js +1102 -0
  31. package/lib/gm-phone.js +682 -0
  32. package/lib/gm-season.js +1162 -0
  33. package/lib/gm-target.js +292 -0
  34. package/lib/job-code-verify.js +169 -0
  35. package/lib/known-commands.js +1 -1
  36. package/lib/member-autoland.js +105 -14
  37. package/lib/member-progress.js +2 -2
  38. package/lib/member-report.js +11 -1
  39. package/lib/member-self-next-job.js +6 -1
  40. package/lib/member-up.js +17 -8
  41. package/lib/merged-review-close.js +206 -0
  42. package/lib/mission-code-provenance.js +118 -0
  43. package/lib/mission-protected-lane.js +52 -4
  44. package/lib/pulse.js +2 -1
  45. package/lib/qr.js +338 -0
  46. package/lib/rsi-record.js +88 -0
  47. package/lib/task-receipt.js +24 -6
  48. package/lib/yes-lock.js +530 -0
  49. package/lib/zen-apply.js +511 -0
  50. package/lib/zen-workspace.js +121 -0
  51. package/lib/zen.js +395 -0
  52. package/package.json +1 -1
@@ -0,0 +1,1162 @@
1
+ 'use strict';
2
+
3
+ // GM season: turns task events into a front office board.
4
+ // The core is pure (outcomes in, rows out). loadSeason() is the one thin
5
+ // loader that touches the task db, member switches, and missions.
6
+ //
7
+ // Every job a member takes ends one of three ways: landed, dropped (the claim
8
+ // was let go), or sent back. Ratings count all three, so a member that lands
9
+ // 8 of 28 jobs does not look like a 99 just because autoland keeps the 8.
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ const HOUR_MS = 60 * 60 * 1000;
15
+ const DAY_MS = 24 * HOUR_MS;
16
+ const SEASON_DAYS = 30;
17
+ const ROOKIE_FINISHED = 3;
18
+ const STALE_MS = 6 * HOUR_MS;
19
+ const ACTIVE_MISSION_STATUSES = new Set(['running', 'ready']);
20
+ const KEPT_ACTIONS = new Set(['accepted', 'merged']);
21
+ const FINISHED = new Set(['landed', 'dropped', 'sent_back', 'abandoned']);
22
+ const FORM_LETTER = { landed: 'k', dropped: 'd', sent_back: 'x', abandoned: 'a' };
23
+ const PLAY_WORD = { took: 'took', landed: 'landed', dropped: 'dropped', sent_back: 'sent back', abandoned: 'abandoned' };
24
+
25
+ // An outcome is one play:
26
+ // { event_id, task_id, member, title, kind, at, claimed_at, landed_by }
27
+ // kind is took | landed | dropped | sent_back | abandoned. abandoned is a
28
+ // claim that ended archived without landing; a job still in review is
29
+ // pending and not counted. at and claimed_at are epoch ms; claimed_at may
30
+ // be null. landed_by is autoland | you, only on landed.
31
+
32
+ function byTimeAsc(a, b) {
33
+ return Number(a.at || 0) - Number(b.at || 0);
34
+ }
35
+
36
+ function isFinished(outcome) {
37
+ return Boolean(outcome && FINISHED.has(outcome.kind));
38
+ }
39
+
40
+ // jobs = landed + dropped + sent back + abandoned in the window, the same
41
+ // count the rating divides by. Jobs still in review are pending.
42
+ function tally(outcomes) {
43
+ const out = { took: 0, landed: 0, dropped: 0, sent_back: 0, abandoned: 0 };
44
+ for (const o of outcomes || []) {
45
+ if (Object.prototype.hasOwnProperty.call(out, o.kind)) out[o.kind] += 1;
46
+ }
47
+ out.finished = out.landed + out.dropped + out.sent_back + out.abandoned;
48
+ out.jobs = out.finished;
49
+ return out;
50
+ }
51
+
52
+ // round(60 + 39 * landed / finished), rookie under 3 finished jobs. This is
53
+ // stricter than the website rule (kept / graded) on purpose: drops and
54
+ // abandoned jobs count.
55
+ function ovr(outcomes) {
56
+ const { landed, finished } = tally(outcomes);
57
+ if (finished < ROOKIE_FINISHED) return null;
58
+ return Math.round(60 + 39 * landed / finished);
59
+ }
60
+
61
+ // Last n finished jobs, oldest on the left, newest on the right.
62
+ function form(outcomes, n = 5) {
63
+ return [...(outcomes || [])]
64
+ .filter(isFinished)
65
+ .sort(byTimeAsc)
66
+ .slice(-n)
67
+ .map(o => FORM_LETTER[o.kind])
68
+ .join('');
69
+ }
70
+
71
+ function outcomeHours(outcome) {
72
+ const at = Number(outcome && outcome.at);
73
+ const claimed = Number(outcome && outcome.claimed_at);
74
+ if (!Number.isFinite(at) || !Number.isFinite(claimed) || !claimed || at < claimed) return null;
75
+ return Math.round(((at - claimed) / HOUR_MS) * 10) / 10;
76
+ }
77
+
78
+ // Titles stay long here; the screen code fits them to the terminal width.
79
+ const TITLE_MAX = 200;
80
+
81
+ function clipTitle(value, max = 48) {
82
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
83
+ if (text.length <= max) return text;
84
+ return `${text.slice(0, Math.max(0, max - 3)).trim()}...`;
85
+ }
86
+
87
+ // Newest n plays of every kind, newest first. Finished plays carry the hours
88
+ // from claim to result. ids in `seen` are old; the rest are marked new.
89
+ function lastPlays(outcomes, n = 5, seen = null) {
90
+ return [...(outcomes || [])]
91
+ .sort((a, b) => byTimeAsc(b, a))
92
+ .slice(0, n)
93
+ .map((o) => {
94
+ const id = o.event_id || `${o.task_id}:${o.kind}:${o.at}`;
95
+ return {
96
+ id,
97
+ task_id: o.task_id || null,
98
+ member: o.member,
99
+ title: clipTitle(o.title, TITLE_MAX),
100
+ kind: o.kind,
101
+ play: PLAY_WORD[o.kind],
102
+ hours: isFinished(o) ? outcomeHours(o) : null,
103
+ at: o.at,
104
+ fresh: Boolean(seen && !seen.has(id)),
105
+ };
106
+ });
107
+ }
108
+
109
+ const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
110
+
111
+ // "8:04 pm" in local time for today, "oct 15 8:04 pm" for any other day.
112
+ function clockLabel(ms, now = Date.now()) {
113
+ const d = new Date(ms);
114
+ if (!Number.isFinite(d.getTime())) return '';
115
+ const h = d.getHours();
116
+ const m = String(d.getMinutes()).padStart(2, '0');
117
+ const time = `${h % 12 || 12}:${m} ${h < 12 ? 'am' : 'pm'}`;
118
+ const today = new Date(now);
119
+ const sameDay = d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate();
120
+ return sameDay ? time : `${MONTHS[d.getMonth()]} ${d.getDate()} ${time}`;
121
+ }
122
+
123
+ // Monday 00:00 local time for the week that contains now.
124
+ function weekStart(now = Date.now()) {
125
+ const d = new Date(now);
126
+ const daysSinceMonday = (d.getDay() + 6) % 7;
127
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate() - daysSinceMonday).getTime();
128
+ }
129
+
130
+ function ownerMood(outcomes, now = Date.now(), stuck = 0, waiting = 0) {
131
+ const since = weekStart(now);
132
+ const week = (outcomes || []).filter(o => Number(o.at) >= since && Number(o.at) <= Number(now));
133
+ const byAutoland = week.filter(o => o.kind === 'landed' && o.landed_by === 'autoland').length;
134
+ const byYou = week.filter(o => o.kind === 'landed' && o.landed_by !== 'autoland').length;
135
+ const { sent_back: sentBack, dropped, abandoned } = tally(week);
136
+ return {
137
+ since,
138
+ landed_by_autoland: byAutoland,
139
+ landed_by_you: byYou,
140
+ sent_back: sentBack,
141
+ dropped,
142
+ abandoned,
143
+ stuck,
144
+ waiting_on_review: waiting,
145
+ line: `this week: ${byAutoland} landed by autoland, ${byYou} by you, ${sentBack} sent back, ${dropped} dropped, ${abandoned} abandoned, ${waiting} waiting on review, ${stuck} stuck`,
146
+ };
147
+ }
148
+
149
+ // "7h" under a day, "2d" after.
150
+ function ageLabel(ms) {
151
+ const hours = Math.max(0, Number(ms) || 0) / HOUR_MS;
152
+ if (hours < 1) return `${Math.max(1, Math.round(hours * 60))}m`;
153
+ if (hours < 24) return `${Math.round(hours)}h`;
154
+ return `${Math.round(hours / 24)}d`;
155
+ }
156
+
157
+ // A claim is { task_id, ref, title, member, last_event_at }. It is live when
158
+ // its task had any event in the last 6 hours, stuck otherwise.
159
+ function claimStates(claims = [], now = Date.now(), staleMs = STALE_MS) {
160
+ return claims
161
+ .filter(c => c && c.member)
162
+ .map((c) => {
163
+ const age = Math.max(0, Number(now) - Number(c.last_event_at || 0));
164
+ return { ...c, age_ms: age, age: ageLabel(age), stuck: age >= staleMs };
165
+ });
166
+ }
167
+
168
+ // Stuck claims, oldest first, so the jammed belt is at the top.
169
+ function stuckRows(claims = [], now = Date.now(), n = 5) {
170
+ return claimStates(claims, now)
171
+ .filter(c => c.stuck)
172
+ .sort((a, b) => b.age_ms - a.age_ms)
173
+ .slice(0, n)
174
+ .map(c => ({ task_id: c.task_id, ref: c.ref || c.task_id, title: clipTitle(c.title, TITLE_MAX), member: c.member, age: c.age }));
175
+ }
176
+
177
+ // Running missions count as a job only while they moved in the last 6 hours.
178
+ function liveMissionOwners(missions = [], now = Date.now(), staleMs = STALE_MS) {
179
+ return missions
180
+ .filter(m => m.status === 'running' && m.owner && Number(now) - Number(m.last_at || 0) < staleMs)
181
+ .map(m => String(m.owner));
182
+ }
183
+
184
+ // Jobs a member finished and handed in (status review), waiting on the
185
+ // reviewer, oldest first. Age counts from the proof, not the claim.
186
+ function reviewRows(reviews = [], now = Date.now(), n = 3) {
187
+ return reviews
188
+ .filter(r => r && r.member)
189
+ .map(r => ({ ...r, age_ms: Math.max(0, Number(now) - Number(r.since || 0)) }))
190
+ .sort((a, b) => b.age_ms - a.age_ms)
191
+ .slice(0, n)
192
+ .map(r => ({ task_id: r.task_id, ref: r.ref || r.task_id, title: clipTitle(r.title, TITLE_MAX), member: r.member, age: ageLabel(r.age_ms) }));
193
+ }
194
+
195
+ function statusByMember({ claims = [], missions = [], asleep = [], reviews = [], now = Date.now() } = {}) {
196
+ const asleepSet = new Set(asleep);
197
+ const live = new Set(liveMissionOwners(missions, now));
198
+ const stuckAge = new Map();
199
+ for (const c of claimStates(claims, now)) {
200
+ if (!c.stuck) live.add(c.member);
201
+ else if (!stuckAge.has(c.member) || c.age_ms < stuckAge.get(c.member).age_ms) stuckAge.set(c.member, c);
202
+ }
203
+ return (name) => {
204
+ if (asleepSet.has(name)) return 'asleep';
205
+ if (live.has(name)) return 'on a job';
206
+ if (stuckAge.has(name)) return `stuck ${stuckAge.get(name).age}`;
207
+ const waits = reviews.filter(r => r.member === name).map(r => Number(now) - Number(r.since || 0));
208
+ if (waits.length) return `waiting on review ${ageLabel(Math.min(...waits))}`;
209
+ return 'idle';
210
+ };
211
+ }
212
+
213
+ // What happened between the last interactive look and now.
214
+ function sinceLastLook(outcomes = [], lastSeen = null, now = Date.now()) {
215
+ const seenAt = Number(lastSeen);
216
+ if (!Number.isFinite(seenAt) || seenAt <= 0) return null;
217
+ const after = outcomes.filter(o => Number(o.at) > seenAt && Number(o.at) <= Number(now));
218
+ const { landed, dropped, sent_back: sentBack, abandoned } = tally(after);
219
+ const ago = ageLabel(Number(now) - seenAt);
220
+ const line = landed + dropped + sentBack + abandoned === 0
221
+ ? `nothing happened since you looked ${ago} ago`
222
+ : `since you looked (${ago} ago): ${landed} landed, ${dropped} dropped, ${sentBack} sent back, ${abandoned} abandoned`;
223
+ return { last_seen_at: seenAt, landed, dropped, sent_back: sentBack, abandoned, line };
224
+ }
225
+
226
+ function rosterOrder(a, b) {
227
+ if ((a.ovr == null) !== (b.ovr == null)) return a.ovr == null ? 1 : -1;
228
+ return (b.ovr || 0) - (a.ovr || 0)
229
+ || b.finished - a.finished
230
+ || b.last_at - a.last_at
231
+ || a.name.localeCompare(b.name);
232
+ }
233
+
234
+ // Members by rating, rookies last. Members on a job with no plays yet fill
235
+ // any empty slots so the roster shows who is working.
236
+ function rosterRows({ outcomes = [], claims = [], missions = [], asleep = [], reviews = [], now = Date.now(), limit = 8 } = {}) {
237
+ const statusOf = statusByMember({ claims, missions, asleep, reviews, now });
238
+ const holders = new Set([...claims.map(c => c.member), ...liveMissionOwners(missions, now)].filter(Boolean));
239
+ const groups = new Map();
240
+ for (const o of outcomes) {
241
+ if (!o.member) continue;
242
+ if (!groups.has(o.member)) groups.set(o.member, []);
243
+ groups.get(o.member).push(o);
244
+ }
245
+ const rows = Array.from(groups.entries()).map(([name, list]) => ({
246
+ name,
247
+ ...tally(list),
248
+ ovr: ovr(list),
249
+ form: form(list),
250
+ status: statusOf(name),
251
+ last_at: Math.max(...list.map(o => Number(o.at) || 0)),
252
+ }));
253
+ rows.sort(rosterOrder);
254
+ for (const name of [...holders].sort()) {
255
+ if (groups.has(name)) continue;
256
+ rows.push({
257
+ name,
258
+ ...tally([]),
259
+ ovr: null,
260
+ form: '',
261
+ status: statusOf(name),
262
+ last_at: 0,
263
+ });
264
+ }
265
+ return rows.slice(0, limit);
266
+ }
267
+
268
+ // Open tasks first (oldest first, the queue order), then active missions.
269
+ function upNext({ openTasks = [], missions = [] } = {}, n = 3) {
270
+ const tasks = [...openTasks]
271
+ .sort((a, b) => Number(a.created_at || 0) - Number(b.created_at || 0))
272
+ .map(t => ({ kind: 'task', ref: t.ref || null, title: clipTitle(t.title, TITLE_MAX), owner: t.owner || null }));
273
+ const active = missions
274
+ .filter(m => ACTIVE_MISSION_STATUSES.has(m.status))
275
+ .map(m => ({ kind: 'mission', ref: null, title: clipTitle(m.objective || m.slug || m.id, TITLE_MAX), owner: m.owner || null }));
276
+ return [...tasks, ...active].slice(0, n);
277
+ }
278
+
279
+ function parseJson(text) {
280
+ if (!text) return null;
281
+ try {
282
+ return JSON.parse(text);
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+
288
+ function landedBy(row, payload) {
289
+ if (payload && payload.auto_accepted === true) return 'autoland';
290
+ return /auto/i.test(String(row.actor || '')) ? 'autoland' : 'you';
291
+ }
292
+
293
+ // One raw event row to one play, or null when it is not a play.
294
+ // claimed and claim_released belong to the actor; landed and sent back
295
+ // belong to whoever held the claim then (the last earlier claimed event,
296
+ // falling back to tasks.claimed_by). A release goes to the member that held
297
+ // the job (previous_claimed_by), then released_by, then the actor, so a
298
+ // person clearing a stale claim does not take the drop.
299
+ function outcomeFromEvent(row) {
300
+ const payload = parseJson(row.payload) || {};
301
+ const holder = row.claim_actor || row.claimed_by || null;
302
+ let kind = null;
303
+ let member = null;
304
+ if (row.event_type === 'claimed') {
305
+ kind = 'took';
306
+ member = row.actor || payload.claimed_by;
307
+ } else if (row.event_type === 'claim_released') {
308
+ kind = 'dropped';
309
+ member = payload.previous_claimed_by || payload.released_by || row.actor;
310
+ } else if (row.event_type === 'completed' && KEPT_ACTIONS.has(payload.action)) {
311
+ kind = 'landed';
312
+ member = holder;
313
+ } else if (row.event_type === 'revision_requested' || row.event_type === 'reopened') {
314
+ kind = 'sent_back';
315
+ member = holder;
316
+ } else if (row.event_type === 'archived' && Number(row.abandons) === 1 && row.claim_event_id) {
317
+ // archived before the claim landed, was let go, completed, reopened, or archived
318
+ kind = 'abandoned';
319
+ member = holder;
320
+ }
321
+ if (!kind || !member) return null;
322
+ const claimedAt = kind === 'took' ? null : (row.claim_event_at != null ? Number(row.claim_event_at) : (row.claimed_at != null ? Number(row.claimed_at) : null));
323
+ // One claim episode per job: a took is its own claim; anything else
324
+ // belongs to the claim before it, or to a claim older than the history.
325
+ const claimId = kind === 'took'
326
+ ? (row.event_id || `${row.task_id}:${row.created_at}`)
327
+ : (row.claim_event_id || `${row.task_id}:before`);
328
+ return {
329
+ event_id: row.event_id || null,
330
+ claim_id: claimId,
331
+ task_id: row.task_id,
332
+ member: String(member),
333
+ title: row.title || '',
334
+ kind,
335
+ at: Number(row.created_at),
336
+ claimed_at: claimedAt,
337
+ landed_by: kind === 'landed' ? landedBy(row, payload) : null,
338
+ };
339
+ }
340
+
341
+ function outcomesFromEvents(rows) {
342
+ return (rows || []).map(outcomeFromEvent).filter(Boolean).sort(byTimeAsc);
343
+ }
344
+
345
+ function emptySeason(workspaceRoot, now) {
346
+ return {
347
+ workspace_root: workspaceRoot,
348
+ workspace_name: path.basename(workspaceRoot),
349
+ now,
350
+ outcomes: [],
351
+ open_tasks: [],
352
+ claims: [],
353
+ reviews: [],
354
+ asleep: [],
355
+ missions: [],
356
+ last_seen_at: null,
357
+ };
358
+ }
359
+
360
+ // Last looks live under ~/.atris/state/gm-last-seen/, one small file per
361
+ // workspace named by the sha1 of its absolute path, so looking at a club never
362
+ // writes into that club's repo and two open boards never clobber each other.
363
+ // The older single map file (gm-last-seen.json) is still read as a fallback.
364
+ function lastSeenDir() {
365
+ const { atrisHome } = require('./state-home');
366
+ return path.join(atrisHome(), 'state', 'gm-last-seen');
367
+ }
368
+
369
+ function lastSeenKey(workspaceRoot) {
370
+ return path.resolve(String(workspaceRoot || '.'));
371
+ }
372
+
373
+ function lastSeenPath(workspaceRoot, dir) {
374
+ const digest = require('crypto').createHash('sha1').update(lastSeenKey(workspaceRoot)).digest('hex');
375
+ return path.join(dir, `${digest}.json`);
376
+ }
377
+
378
+ function validSeen(value) {
379
+ const at = Number(value);
380
+ return Number.isFinite(at) && at > 0 ? at : null;
381
+ }
382
+
383
+ function readLastSeen(workspaceRoot, { dir = lastSeenDir() } = {}) {
384
+ try {
385
+ return validSeen(JSON.parse(fs.readFileSync(lastSeenPath(workspaceRoot, dir), 'utf8')).last_seen_at);
386
+ } catch {
387
+ // fall through to the older single map file
388
+ }
389
+ try {
390
+ const legacy = JSON.parse(fs.readFileSync(path.join(path.dirname(dir), 'gm-last-seen.json'), 'utf8'));
391
+ const entry = legacy && legacy.workspaces && legacy.workspaces[lastSeenKey(workspaceRoot)];
392
+ return validSeen(entry && entry.last_seen_at);
393
+ } catch {
394
+ return null;
395
+ }
396
+ }
397
+
398
+ // Only the interactive board writes this; --board and --json only read it.
399
+ // Written to a temp file then renamed, so a reader never sees half a file.
400
+ // A read-only or missing home just means no recap next time, never a crash.
401
+ function writeLastSeen(workspaceRoot, at = Date.now(), { dir = lastSeenDir() } = {}) {
402
+ try {
403
+ const file = lastSeenPath(workspaceRoot, dir);
404
+ fs.mkdirSync(dir, { recursive: true });
405
+ const tmp = `${file}.${process.pid}.${require('crypto').randomBytes(4).toString('hex')}.tmp`;
406
+ const body = { workspace: lastSeenKey(workspaceRoot), last_seen_at: at, last_seen_iso: new Date(at).toISOString() };
407
+ fs.writeFileSync(tmp, `${JSON.stringify(body)}\n`);
408
+ fs.renameSync(tmp, file);
409
+ return true;
410
+ } catch {
411
+ return false;
412
+ }
413
+ }
414
+
415
+ // Right before u releases a claim: is it still held by the same member with
416
+ // no task event for 6 hours? The board's stuck list can be minutes old.
417
+ function stillStuck(taskId, member, { dbPath, now = Date.now(), staleMs = STALE_MS } = {}) {
418
+ const taskDb = require('./task-db');
419
+ const target = dbPath || taskDb.getDbPath();
420
+ if (!taskId || !member || !fs.existsSync(target)) return false;
421
+ let db;
422
+ try {
423
+ const { DatabaseSync } = require('node:sqlite');
424
+ db = new DatabaseSync(target, { readOnly: true });
425
+ const row = db.prepare(`
426
+ SELECT status, claimed_by, claimed_at, updated_at,
427
+ (SELECT MAX(e.created_at) FROM task_events e WHERE e.task_id = tasks.id) AS last_event_at
428
+ FROM tasks WHERE id = ?
429
+ `).get(taskId);
430
+ if (!row || row.status !== 'claimed' || String(row.claimed_by) !== String(member)) return false;
431
+ const last = Number(row.last_event_at || row.claimed_at || row.updated_at || 0);
432
+ return Number(now) - last >= staleMs;
433
+ } catch {
434
+ return false;
435
+ } finally {
436
+ if (db) {
437
+ try { db.close(); } catch {}
438
+ }
439
+ }
440
+ }
441
+
442
+ function missionLastAt(mission) {
443
+ for (const value of [mission.last_tick_at, mission.updated_at, mission.created_at]) {
444
+ const ms = typeof value === 'number' ? value : Date.parse(value || '');
445
+ if (Number.isFinite(ms)) return ms;
446
+ }
447
+ return 0;
448
+ }
449
+
450
+ // Play events since a time, each with the claim before it. The claim is found
451
+ // by per-task version, so two events in the same millisecond still find the
452
+ // right holder. `extraWhere` narrows it (one workspace, or none for the league).
453
+ function playEventsSql(extraWhere = '') {
454
+ return `
455
+ SELECT e.event_id, e.task_id, e.workspace_root, e.event_type, e.actor, e.payload, e.created_at,
456
+ t.title, t.claimed_by, t.claimed_at,
457
+ c.event_id AS claim_event_id, c.created_at AS claim_event_at, c.actor AS claim_actor,
458
+ CASE WHEN e.event_type = 'archived' AND c.event_id IS NOT NULL AND NOT EXISTS (
459
+ SELECT 1 FROM task_events x
460
+ WHERE x.task_id = e.task_id AND x.version > c.version AND x.version < e.version
461
+ -- any completion ends the episode: a plain "done" with no
462
+ -- accept is not a landing, but it is not abandoned either.
463
+ -- reopened ends it too: the holder already took the sent back,
464
+ -- so a later archive with no new claim is not also their abandon
465
+ AND x.event_type IN ('claim_released', 'archived', 'completed', 'reopened')
466
+ ) THEN 1 ELSE 0 END AS abandons
467
+ FROM task_events e
468
+ JOIN tasks t ON t.id = e.task_id
469
+ LEFT JOIN task_events c ON c.event_id = (
470
+ SELECT p.event_id FROM task_events p
471
+ WHERE p.task_id = e.task_id AND p.event_type = 'claimed' AND p.version < e.version
472
+ ORDER BY p.version DESC LIMIT 1)
473
+ WHERE e.created_at >= ?
474
+ AND e.event_type IN ('claimed', 'claim_released', 'completed', 'revision_requested', 'reopened', 'archived')
475
+ ${extraWhere}
476
+ ORDER BY e.created_at ASC`;
477
+ }
478
+
479
+ // The one loader: last 30 days of plays for a workspace, plus open claims,
480
+ // who is asleep, and what is up next. Opens the db read-only.
481
+ // `lastSeen` pins the recap to a session's opening look; without it the
482
+ // stored look is used. A look older than the window gets its own query, so
483
+ // coming back after 40 days still counts every landing since.
484
+ function loadSeason(workspaceRoot, { now = Date.now(), days = SEASON_DAYS, dbPath, lastSeen, lastSeenDir: seenDir, targetsDir } = {}) {
485
+ const season = emptySeason(workspaceRoot, now);
486
+ const { asleepMembers } = require('./member-switches');
487
+ season.asleep = asleepMembers(workspaceRoot);
488
+ season.last_seen_at = lastSeen !== undefined ? lastSeen : readLastSeen(workspaceRoot, seenDir ? { dir: seenDir } : {});
489
+
490
+ try {
491
+ const { listMissions } = require('../commands/mission');
492
+ season.missions = (listMissions(workspaceRoot) || []).map(m => ({
493
+ id: m.id,
494
+ slug: m.slug,
495
+ objective: m.objective,
496
+ owner: m.owner,
497
+ status: m.status,
498
+ last_at: missionLastAt(m),
499
+ }));
500
+ } catch {
501
+ season.missions = [];
502
+ }
503
+
504
+ const taskDb = require('./task-db');
505
+ const target = dbPath || taskDb.getDbPath();
506
+ if (!fs.existsSync(target)) {
507
+ // no task history yet: the target still shows, scored against nothing
508
+ season.no_history = true;
509
+ season.targets = loadTargetView(null, workspaceRoot, { now, since: now - days * DAY_MS, playsSince: null, outcomes: [], claims: [], dir: targetsDir });
510
+ return season;
511
+ }
512
+
513
+ let db;
514
+ try {
515
+ const { DatabaseSync } = require('node:sqlite');
516
+ db = new DatabaseSync(target, { readOnly: true });
517
+ const since = now - days * DAY_MS;
518
+ const playsSince = db.prepare(playEventsSql('AND e.workspace_root = ?'));
519
+ season.outcomes = outcomesFromEvents(playsSince.all(since, workspaceRoot));
520
+ const seenAt = Number(season.last_seen_at);
521
+ season.recap_outcomes = Number.isFinite(seenAt) && seenAt > 0 && seenAt < since
522
+ ? outcomesFromEvents(playsSince.all(seenAt, workspaceRoot))
523
+ : null;
524
+
525
+ const taskRows = db.prepare(`
526
+ SELECT id, title, status, tag, workspace_root, claimed_by, claimed_at, created_at, updated_at, metadata,
527
+ (SELECT MAX(e.created_at) FROM task_events e WHERE e.task_id = tasks.id) AS last_event_at,
528
+ (SELECT MAX(e.created_at) FROM task_events e WHERE e.task_id = tasks.id AND e.event_type = 'proof_ready') AS proof_at,
529
+ (SELECT e.event_id FROM task_events e WHERE e.task_id = tasks.id AND e.event_type = 'claimed'
530
+ ORDER BY e.version DESC LIMIT 1) AS claim_event_id
531
+ FROM tasks WHERE workspace_root = ?
532
+ `).all(workspaceRoot).map(row => ({ ...row, metadata: parseJson(row.metadata) || {} }));
533
+ const live = taskDb.withTaskDisplayRefs(taskRows).filter(t => ['open', 'claimed', 'review'].includes(t.status));
534
+ const refOf = t => t.display_id || t.legacy_ref || null;
535
+ season.open_tasks = live
536
+ .filter(t => t.status === 'open')
537
+ .map(t => ({ id: t.id, ref: refOf(t), title: t.title, created_at: t.created_at, owner: t.metadata.assigned_to || null }));
538
+ season.reviews = live
539
+ .filter(t => t.status === 'review' && t.claimed_by)
540
+ .map(t => ({
541
+ task_id: t.id,
542
+ ref: refOf(t),
543
+ title: t.title,
544
+ member: String(t.claimed_by),
545
+ since: Number(t.proof_at || t.last_event_at || t.updated_at || 0),
546
+ }));
547
+ season.claims = live
548
+ .filter(t => t.status === 'claimed' && t.claimed_by)
549
+ .map(t => ({
550
+ task_id: t.id,
551
+ ref: refOf(t),
552
+ title: t.title,
553
+ member: String(t.claimed_by),
554
+ claim_id: t.claim_event_id || `${t.id}:claim`,
555
+ last_event_at: Number(t.last_event_at || t.claimed_at || t.updated_at || 0),
556
+ }));
557
+
558
+ season.targets = loadTargetView(db, workspaceRoot, { now, since, playsSince, outcomes: season.outcomes, claims: season.claims, dir: targetsDir });
559
+ } catch (err) {
560
+ season.error = err && err.message ? err.message : String(err);
561
+ } finally {
562
+ if (db) {
563
+ try { db.close(); } catch {}
564
+ }
565
+ }
566
+ return season;
567
+ }
568
+
569
+ // The owner's weekly target for this workspace: this week's standing, the
570
+ // suggested default, and the season record over completed target weeks.
571
+ // Plays older than the 30 day window are read only when a view needs them.
572
+ function loadTargetView(db, workspaceRoot, { now, since, playsSince, outcomes, claims, dir }) {
573
+ const gmTarget = require('./gm-target');
574
+ const store = gmTarget.readTargets(workspaceRoot, dir ? { dir } : {});
575
+ const from = gmTarget.historyStart(store, now);
576
+ const history = db && from < since ? outcomesFromEvents(playsSince.all(from, workspaceRoot)) : outcomes;
577
+ const hasPastWeeks = Object.keys(store.weeks).some(k => k < gmTarget.weekKey(now));
578
+ return {
579
+ has_target: Object.keys(store.weeks).length > 0,
580
+ current: gmTarget.currentStatus({ store, outcomes: history, claims, now }),
581
+ default: gmTarget.defaultTarget(history, now),
582
+ record: gmTarget.seasonRecord({
583
+ store,
584
+ outcomes: history,
585
+ eventsByTask: db && hasPastWeeks ? gmTarget.loadClaimHistory(db, workspaceRoot, from) : new Map(),
586
+ now,
587
+ }),
588
+ };
589
+ }
590
+
591
+ // Everything the board needs, computed from a loaded season. `seen` holds
592
+ // play ids from the last draw so new plays can be tagged. `lastSeen`
593
+ // overrides the stored last look (the interactive board pins it per session).
594
+ function seasonBoard(season, { limit = 8, seen = null, lastSeen } = {}) {
595
+ const stuckAll = claimStates(season.claims, season.now).filter(c => c.stuck);
596
+ const recapFrom = lastSeen === undefined ? season.last_seen_at : lastSeen;
597
+ return {
598
+ workspace_name: season.workspace_name,
599
+ now: season.now,
600
+ week_of: weekStart(season.now),
601
+ mood: ownerMood(season.outcomes, season.now, stuckAll.length, (season.reviews || []).length),
602
+ since_last_look: sinceLastLook(season.recap_outcomes || season.outcomes, recapFrom, season.now),
603
+ roster: rosterRows({
604
+ outcomes: season.outcomes,
605
+ claims: season.claims,
606
+ missions: season.missions,
607
+ asleep: season.asleep,
608
+ reviews: season.reviews || [],
609
+ now: season.now,
610
+ limit,
611
+ }),
612
+ // two kinds of jam: work waiting on a reviewer, and claims stuck with
613
+ // the player. With both on screen, each gets three rows.
614
+ waiting_on_review: reviewRows(season.reviews || [], season.now, 3),
615
+ waiting_on_review_total: (season.reviews || []).length,
616
+ stuck: stuckRows(season.claims, season.now, (season.reviews || []).length ? 3 : 5),
617
+ last_plays: lastPlays(season.outcomes, 5, seen),
618
+ up_next: upNext({ openTasks: season.open_tasks, missions: season.missions }),
619
+ // the owner's weekly target: this week's standing (null with no target),
620
+ // the suggested default, and the record over completed target weeks
621
+ target: season.targets ? season.targets.current : null,
622
+ target_default: season.targets ? season.targets.default : null,
623
+ record: season.targets && season.targets.has_target ? season.targets.record : null,
624
+ no_history: Boolean(season.no_history),
625
+ error: season.error || null,
626
+ };
627
+ }
628
+
629
+ // ---- league: every real project folder is a club ---------------------------
630
+
631
+ const WORKTREE_SEGMENT = /\/\.(agent-worktrees|worktrees)\/|\/\.claude\/worktrees\//;
632
+
633
+ // A club is a real project folder: not a scratch run under /tmp or the system
634
+ // temp dir (the shared scratch helper decides that), and not an agent's
635
+ // throwaway worktree checkout.
636
+ function isClubRoot(root) {
637
+ const text = String(root || '').replace(/\\/g, '/');
638
+ if (!text.startsWith('/')) return false;
639
+ if (WORKTREE_SEGMENT.test(`${text}/`)) return false;
640
+ const { isUnderGenericScratchRoot } = require('./scratch-root');
641
+ return !isUnderGenericScratchRoot(text);
642
+ }
643
+
644
+ // Club names are folder names; two folders with the same name get their
645
+ // parent folder in front so the league stays readable.
646
+ // Names grow one parent folder at a time until every label is unique, so
647
+ // /one/common/app and /two/common/app become one/common/app and two/common/app.
648
+ function clubNames(roots) {
649
+ const parts = new Map(roots.map(root => [root, String(root).split('/').filter(Boolean)]));
650
+ const depth = new Map(roots.map(root => [root, 1]));
651
+ const label = root => parts.get(root).slice(-depth.get(root)).join('/') || root;
652
+ for (let pass = 0; pass < 64; pass += 1) {
653
+ const seen = new Map();
654
+ for (const root of roots) {
655
+ const name = label(root);
656
+ if (!seen.has(name)) seen.set(name, []);
657
+ seen.get(name).push(root);
658
+ }
659
+ const clashes = [...seen.values()].filter(group => group.length > 1);
660
+ if (!clashes.length) break;
661
+ let grew = false;
662
+ for (const group of clashes) {
663
+ for (const root of group) {
664
+ if (depth.get(root) < parts.get(root).length) {
665
+ depth.set(root, depth.get(root) + 1);
666
+ grew = true;
667
+ }
668
+ }
669
+ }
670
+ if (!grew) break;
671
+ }
672
+ return new Map(roots.map(root => [root, label(root)]));
673
+ }
674
+
675
+ function leagueOrder(a, b) {
676
+ return b.landed_week - a.landed_week
677
+ || ((a.ovr == null) - (b.ovr == null))
678
+ || (b.ovr || 0) - (a.ovr || 0)
679
+ || b.landed_30d - a.landed_30d
680
+ || (b.last_play_at || 0) - (a.last_play_at || 0)
681
+ || a.club.localeCompare(b.club);
682
+ }
683
+
684
+ // One row per club. clubs: [{ root, outcomes, claims, last_event_at }].
685
+ function leagueRows(clubs = [], now = Date.now()) {
686
+ const since = weekStart(now);
687
+ const names = clubNames(clubs.map(c => c.root));
688
+ return clubs.map((club) => {
689
+ const outcomes = club.outcomes || [];
690
+ const counts = tally(outcomes);
691
+ const best = rosterRows({ outcomes, limit: 50 }).find(row => row.ovr != null) || null;
692
+ // Last move on any task, not only plays: a club whose members only post
693
+ // notes and proofs is still awake, and a quiet one should look quiet.
694
+ const lastPlayAt = outcomes.length ? Math.max(...outcomes.map(o => Number(o.at) || 0)) : 0;
695
+ const lastAt = Math.max(lastPlayAt, Number(club.last_event_at) || 0);
696
+ return {
697
+ club: names.get(club.root),
698
+ root: club.root,
699
+ ovr: ovr(outcomes),
700
+ landed_week: outcomes.filter(o => o.kind === 'landed' && Number(o.at) >= since).length,
701
+ landed_30d: counts.landed,
702
+ dropped_30d: counts.dropped,
703
+ abandoned_30d: counts.abandoned,
704
+ finished_30d: counts.finished,
705
+ stuck: claimStates(club.claims || [], now).filter(c => c.stuck).length,
706
+ last_play_at: lastAt || null,
707
+ last_play: lastAt ? ageLabel(now - lastAt) : '-',
708
+ best_player: best ? { name: best.name, ovr: best.ovr } : null,
709
+ target: club.target ? club.target.verdict : null,
710
+ };
711
+ }).sort(leagueOrder);
712
+ }
713
+
714
+ // Reads every workspace's plays for the window in one pass, read-only.
715
+ function loadLeague({ now = Date.now(), days = SEASON_DAYS, dbPath, isClub = isClubRoot, targetsDir } = {}) {
716
+ const league = { now, clubs: [], error: null };
717
+ const taskDb = require('./task-db');
718
+ const target = dbPath || taskDb.getDbPath();
719
+ if (!fs.existsSync(target)) return league;
720
+ let db;
721
+ try {
722
+ const { DatabaseSync } = require('node:sqlite');
723
+ db = new DatabaseSync(target, { readOnly: true });
724
+ const since = now - days * DAY_MS;
725
+ const active = db.prepare(`
726
+ SELECT workspace_root AS root, MAX(created_at) AS last_event_at
727
+ FROM task_events WHERE created_at >= ? GROUP BY workspace_root
728
+ `).all(since).filter(row => isClub(row.root) && fs.existsSync(row.root));
729
+ const byRoot = new Map(active.map(row => [row.root, { root: row.root, last_event_at: Number(row.last_event_at), events: [], claims: [] }]));
730
+ for (const row of db.prepare(playEventsSql()).all(since)) {
731
+ const club = byRoot.get(row.workspace_root);
732
+ if (club) club.events.push(row);
733
+ }
734
+ const claimed = db.prepare(`
735
+ SELECT id, workspace_root, claimed_by, claimed_at, updated_at,
736
+ (SELECT MAX(e.created_at) FROM task_events e WHERE e.task_id = tasks.id) AS last_event_at
737
+ FROM tasks WHERE status = 'claimed' AND claimed_by IS NOT NULL
738
+ `).all();
739
+ for (const t of claimed) {
740
+ const club = byRoot.get(t.workspace_root);
741
+ if (!club) continue;
742
+ club.claims.push({ task_id: t.id, member: String(t.claimed_by), last_event_at: Number(t.last_event_at || t.claimed_at || t.updated_at || 0) });
743
+ }
744
+ const gmTarget = require('./gm-target');
745
+ league.clubs = [...byRoot.values()].map((club) => {
746
+ const outcomes = outcomesFromEvents(club.events);
747
+ const store = gmTarget.readTargets(club.root, targetsDir ? { dir: targetsDir } : {});
748
+ return {
749
+ root: club.root,
750
+ last_event_at: club.last_event_at,
751
+ outcomes,
752
+ claims: club.claims,
753
+ target: gmTarget.currentStatus({ store, outcomes, claims: club.claims, now }),
754
+ };
755
+ });
756
+ } catch (err) {
757
+ league.error = err && err.message ? err.message : String(err);
758
+ } finally {
759
+ if (db) {
760
+ try { db.close(); } catch {}
761
+ }
762
+ }
763
+ return league;
764
+ }
765
+
766
+ // Clubs with fewer than 3 finished jobs in the window are quiet: counted,
767
+ // not listed, so experiment folders do not bury the real projects.
768
+ function leagueBoard(league) {
769
+ const all = leagueRows(league.clubs, league.now);
770
+ const rows = all.filter(row => row.finished_30d >= ROOKIE_FINISHED);
771
+ return {
772
+ now: league.now,
773
+ days: SEASON_DAYS,
774
+ rows,
775
+ hidden: all.length - rows.length,
776
+ hidden_clubs: all.filter(row => row.finished_30d < ROOKIE_FINISHED).map(row => ({ club: row.club, root: row.root })),
777
+ error: league.error || null,
778
+ };
779
+ }
780
+
781
+ // ---- player card: one member's profile page --------------------------------
782
+
783
+ const CARD_WEEKS = 6;
784
+ const SCOUT_WINDOW = 30;
785
+ const SCOUT_WORDS = { dropped: 'drops jobs', abandoned: 'abandons jobs', sent_back: 'gets sent back' };
786
+
787
+ // The honest rating per calendar week (monday to monday), oldest first, for
788
+ // the last `weeks` weeks including this one. null is a rookie week.
789
+ function weeklyRatings(outcomes = [], now = Date.now(), weeks = CARD_WEEKS) {
790
+ const thisWeek = weekStart(now);
791
+ const out = [];
792
+ for (let i = weeks - 1; i >= 0; i -= 1) {
793
+ const start = weekStart(thisWeek - i * 7 * DAY_MS + DAY_MS);
794
+ const end = i === 0 ? Number(now) + 1 : weekStart(start + 8 * DAY_MS);
795
+ out.push(ovr(outcomes.filter(o => Number(o.at) >= start && Number(o.at) < end)));
796
+ }
797
+ return out;
798
+ }
799
+
800
+ // Median hours from claim to landing, one decimal, or null with no landings.
801
+ function medianLandingHours(outcomes = []) {
802
+ const hours = outcomes.filter(o => o.kind === 'landed').map(outcomeHours).filter(h => h != null).sort((a, b) => a - b);
803
+ if (!hours.length) return null;
804
+ const mid = Math.floor(hours.length / 2);
805
+ const value = hours.length % 2 ? hours[mid] : (hours[mid - 1] + hours[mid]) / 2;
806
+ return Math.round(value * 10) / 10;
807
+ }
808
+
809
+ // One plain scouting line: the outcome it most often has besides landing,
810
+ // over its last 30 finished jobs, or a clean sheet.
811
+ function scoutingLine(outcomes = []) {
812
+ const recent = [...outcomes].filter(isFinished).sort(byTimeAsc).slice(-SCOUT_WINDOW);
813
+ if (!recent.length) return 'no finished jobs to scout yet';
814
+ const counts = tally(recent);
815
+ const misses = ['dropped', 'abandoned', 'sent_back']
816
+ .map(kind => [kind, counts[kind]])
817
+ .filter(([, n]) => n > 0)
818
+ .sort((a, b) => b[1] - a[1]);
819
+ if (!misses.length) return `clean sheet: landed all ${recent.length} of its last ${recent.length} finished`;
820
+ const [kind, n] = misses[0];
821
+ return `${SCOUT_WORDS[kind]}: ${n} of last ${recent.length} finished`;
822
+ }
823
+
824
+ function frontmatterField(text, field) {
825
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text || ''));
826
+ if (!block) return null;
827
+ const match = new RegExp(`^${field}:\\s*(.*)$`, 'm').exec(block[1]);
828
+ const value = match ? match[1].trim().replace(/^["']|["']$/g, '') : '';
829
+ return value || null;
830
+ }
831
+
832
+ // Names close to a typed one: same start, contains it, or a small edit away.
833
+ function closeNames(wanted, names = [], limit = 5) {
834
+ const w = String(wanted || '').toLowerCase();
835
+ const distance = (a, b) => {
836
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
837
+ for (let j = 1; j <= b.length; j += 1) dp[0][j] = j;
838
+ for (let i = 1; i <= a.length; i += 1) {
839
+ for (let j = 1; j <= b.length; j += 1) {
840
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
841
+ }
842
+ }
843
+ return dp[a.length][b.length];
844
+ };
845
+ return [...new Set(names)]
846
+ .map(name => ({ name, d: distance(w, name.toLowerCase()), near: name.toLowerCase().includes(w) || w.includes(name.toLowerCase()) }))
847
+ .filter(x => x.near || x.d <= Math.max(2, Math.floor(w.length / 3)))
848
+ .sort((a, b) => (b.near - a.near) || a.d - b.d || a.name.localeCompare(b.name))
849
+ .slice(0, limit)
850
+ .map(x => x.name);
851
+ }
852
+
853
+ // Everything on the card, from a season loaded over the last 6 weeks. Pure.
854
+ function memberCard(name, season, { role = null, engine = null, now = season.now } = {}) {
855
+ const mine = season.outcomes.filter(o => o.member === name);
856
+ const since30 = Number(now) - SEASON_DAYS * DAY_MS;
857
+ const last30 = mine.filter(o => Number(o.at) >= since30);
858
+ const counts = tally(last30);
859
+ const claims = claimStates(season.claims.filter(c => c.member === name), now);
860
+ const live = claims.filter(c => !c.stuck).sort((a, b) => a.age_ms - b.age_ms)[0];
861
+ const stuck = claims.filter(c => c.stuck).sort((a, b) => a.age_ms - b.age_ms)[0];
862
+ const mission = season.missions.find(m => m.status === 'running' && m.owner === name && Number(now) - Number(m.last_at || 0) < STALE_MS);
863
+ let status = { state: 'idle', title: null };
864
+ if (live) status = { state: 'on a job', title: live.title };
865
+ else if (mission) status = { state: 'on a job', title: mission.objective || mission.slug || mission.id };
866
+ else if (stuck) status = { state: `stuck ${stuck.age}`, title: stuck.title };
867
+ return {
868
+ name,
869
+ workspace_name: season.workspace_name,
870
+ role,
871
+ awake: !season.asleep.includes(name),
872
+ status,
873
+ ovr: ovr(last30),
874
+ weekly: weeklyRatings(mine, now),
875
+ record_30d: { landed: counts.landed, dropped: counts.dropped, sent_back: counts.sent_back, abandoned: counts.abandoned },
876
+ median_landing_hours: medianLandingHours(last30),
877
+ engine,
878
+ scouting: scoutingLine(mine),
879
+ last_plays: [...mine].sort((a, b) => byTimeAsc(b, a)).slice(0, 8).map(o => ({
880
+ at: o.at,
881
+ outcome: PLAY_WORD[o.kind],
882
+ title: clipTitle(o.title, TITLE_MAX),
883
+ })),
884
+ now,
885
+ };
886
+ }
887
+
888
+ // The card's loader: 6 weeks of plays, role from MEMBER.md, and the engine
889
+ // only when a roster line or the member file records one. Read-only. Returns
890
+ // { card } or { error, close } for a name nobody knows.
891
+ function loadCard(workspaceRoot, name, { now = Date.now(), dbPath } = {}) {
892
+ const wanted = String(name || '').trim();
893
+ const days = Math.ceil((Number(now) - (weekStart(now) - (CARD_WEEKS - 1) * 7 * DAY_MS)) / DAY_MS) + 1;
894
+ const season = loadSeason(workspaceRoot, { now, days, dbPath, lastSeen: null });
895
+ if (season.error) return { error: `could not read the task list: ${season.error}`, close: [] };
896
+ const teamDir = path.join(workspaceRoot, 'atris', 'team');
897
+ let team = [];
898
+ try {
899
+ team = fs.readdirSync(teamDir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name);
900
+ } catch {
901
+ team = [];
902
+ }
903
+ const known = new Set([...team, ...season.outcomes.map(o => o.member), ...season.claims.map(c => c.member)]);
904
+ if (!wanted || !known.has(wanted)) {
905
+ return { error: `no member called "${wanted}" in ${season.workspace_name}`, close: closeNames(wanted, [...known]) };
906
+ }
907
+ // Names can come from task data, so a holder like ../../somewhere still
908
+ // gets a card from its history, but nothing is read from disk for it.
909
+ if (!isSafeMemberName(wanted)) {
910
+ return { card: memberCard(wanted, season, { role: null, engine: null, now }), error: null };
911
+ }
912
+ let memberText = null;
913
+ try {
914
+ memberText = fs.readFileSync(path.join(teamDir, wanted, 'MEMBER.md'), 'utf8');
915
+ } catch {
916
+ memberText = null;
917
+ }
918
+ const role = frontmatterField(memberText, 'role');
919
+ let engine = null;
920
+ const fileEngine = frontmatterField(memberText, 'engine') || frontmatterField(memberText, 'executed_by');
921
+ const fileModel = frontmatterField(memberText, 'model');
922
+ if (fileEngine) engine = { engine: fileEngine, model: fileModel, source: `atris/team/${wanted}/MEMBER.md` };
923
+ if (!engine) engine = rosterEngine(wanted, workspaceRoot, { role: role || '', hasCard: memberText != null });
924
+ return { card: memberCard(wanted, season, { role, engine, now }), error: null };
925
+ }
926
+
927
+ // Only letters, digits, dot, dash, underscore, and no leading dot: no path
928
+ // separators and never . or .. so a name can never leave atris/team.
929
+ function isSafeMemberName(name) {
930
+ return /^[a-z0-9_-][a-z0-9._-]*$/i.test(String(name || ''));
931
+ }
932
+
933
+ // The engine the roster file declares for this member, read straight from the
934
+ // roster text with no check of what this machine has installed (policy, not
935
+ // a probe): the member's own team line, else the job section its known job
936
+ // falls under ("## build"), first worker first, with that file and line. With
937
+ // no member card and no team line the job is not known, so nothing is shown.
938
+ function rosterEngine(name, workspaceRoot, { rosterState, role = '', hasCard = true } = {}) {
939
+ try {
940
+ const reg = require('./engine-registry');
941
+ const { autoJobForMember } = require('./member-engine');
942
+ const state = rosterState || reg.readRosterState(workspaceRoot);
943
+ const layers = reg.ROSTER_LAYERS || ['session', 'project', 'machine'];
944
+ const key = String(name || '').trim().toLowerCase();
945
+ let teamLine = null;
946
+ for (const scope of layers) {
947
+ const layer = state[scope];
948
+ const hit = layer && Array.isArray(layer.team) ? layer.team.find(t => t.member === key) : null;
949
+ if (hit) {
950
+ teamLine = { ...hit, file: hit.file || layer.file || layer.path };
951
+ break;
952
+ }
953
+ }
954
+ if (!teamLine && !hasCard) return null;
955
+ const kind = autoJobForMember(name, role || '');
956
+ let jobKey = reg.ENGINE_JOBS[kind];
957
+ if (teamLine) {
958
+ const asJob = reg.rosterJobKey(teamLine.value);
959
+ const saved = scope => Boolean(asJob && state[scope] && state[scope].picks && state[scope].picks[asJob]);
960
+ if (asJob && (reg.ENGINE_ROLES.includes(asJob) || layers.some(saved))) {
961
+ jobKey = asJob;
962
+ } else {
963
+ const pick = reg.rosterPickFromValue(String(teamLine.value), reg.ENGINE_JOBS[kind]);
964
+ const lead = (pick && Array.isArray(pick.workers) && pick.workers[0]) || pick;
965
+ if (!lead || !lead.engine || !teamLine.file) return null;
966
+ return { engine: lead.engine, model: lead.model || null, source: `${teamLine.file}${teamLine.line ? ` line ${teamLine.line}` : ''}` };
967
+ }
968
+ }
969
+ for (const scope of layers) {
970
+ const layer = state[scope];
971
+ const pick = layer && layer.picks && layer.picks[jobKey];
972
+ if (!pick) continue;
973
+ const lead = (Array.isArray(pick.workers) && pick.workers[0]) || pick;
974
+ const file = pick.file || layer.file || layer.path;
975
+ if (!lead || !lead.engine || !file) continue;
976
+ return {
977
+ engine: lead.engine,
978
+ model: lead.model || pick.model || null,
979
+ source: `the ${reg.rosterJobLabel(jobKey)} job in ${file}${lead.line ? ` line ${lead.line}` : ''}`,
980
+ };
981
+ }
982
+ return null;
983
+ } catch {
984
+ return null;
985
+ }
986
+ }
987
+
988
+ // ---- replay: a time-lapse of a window of plays -----------------------------
989
+
990
+ const REPLAY_TARGET_MS = 75 * 1000;
991
+ const REPLAY_MIN_PLAY_MS = 150;
992
+ const REPLAY_MAX_PLAY_MS = 1500;
993
+ const REPLAY_MIN_TOTAL_MS = 1500;
994
+ const REPLAY_DEFAULT_SINCE_MS = 12 * HOUR_MS;
995
+ const REPLAY_TICKER = 6;
996
+
997
+ // "12h", "2d", "30m", or "last-seen". With no value, the last look, else 12h.
998
+ // Returns { since, label } or null when the text makes no sense.
999
+ function parseSince(text, { now = Date.now(), lastSeen = null } = {}) {
1000
+ const raw = String(text == null ? '' : text).trim().toLowerCase();
1001
+ const seen = Number(lastSeen);
1002
+ const hasSeen = Number.isFinite(seen) && seen > 0 && seen < now;
1003
+ if (!raw || raw === 'last-seen' || raw === 'last') {
1004
+ if (hasSeen) return { since: seen, label: 'your last look' };
1005
+ if (raw === 'last-seen' || raw === 'last') return { since: now - REPLAY_DEFAULT_SINCE_MS, label: '12h (no last look yet)' };
1006
+ return { since: now - REPLAY_DEFAULT_SINCE_MS, label: '12h' };
1007
+ }
1008
+ const match = raw.match(/^(\d+(?:\.\d+)?)\s*(m|min|h|d|w)$/);
1009
+ if (!match) return null;
1010
+ const unit = { m: 60 * 1000, min: 60 * 1000, h: HOUR_MS, d: DAY_MS, w: 7 * DAY_MS }[match[2]];
1011
+ const span = Number(match[1]) * unit;
1012
+ if (!(span > 0)) return null;
1013
+ return { since: now - span, label: raw };
1014
+ }
1015
+
1016
+ // Time per play so the window plays in about 75 seconds: never faster than
1017
+ // one play per 150ms, never slower than 1.5s a play, and at least 1.5s in
1018
+ // all. `speed` multiplies on top (2 = twice as fast).
1019
+ function replayDelayMs(playCount, speed = 1) {
1020
+ const n = Math.max(0, Number(playCount) || 0);
1021
+ if (!n) return 0;
1022
+ let per = Math.min(REPLAY_MAX_PLAY_MS, Math.max(REPLAY_MIN_PLAY_MS, REPLAY_TARGET_MS / n));
1023
+ if (per * n < REPLAY_MIN_TOTAL_MS) per = REPLAY_MIN_TOTAL_MS / n;
1024
+ const mult = Number(speed) > 0 ? Number(speed) : 1;
1025
+ return Math.max(1, Math.round(per / mult));
1026
+ }
1027
+
1028
+ function replayKey(play, byClub) {
1029
+ return byClub ? (play.club || '?') : play.member;
1030
+ }
1031
+
1032
+ // Rows of the replay, fixed for the whole run: the most active members (or
1033
+ // clubs in league mode) in the window, so lines never jump around.
1034
+ function replayRows(plays = [], { byClub = false, limit = 10 } = {}) {
1035
+ const counts = new Map();
1036
+ for (const p of plays) {
1037
+ const key = replayKey(p, byClub);
1038
+ if (!key) continue;
1039
+ counts.set(key, (counts.get(key) || 0) + 1);
1040
+ }
1041
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, limit).map(([key]) => key);
1042
+ }
1043
+
1044
+ // The state after plays[0..index]: running tallies per row, the last six
1045
+ // plays newest first (the newest marked), and who landed on this frame.
1046
+ function replayFrame(plays = [], index = 0, { byClub = false, rows = null } = {}) {
1047
+ const shown = rows || replayRows(plays, { byClub });
1048
+ const upto = plays.length ? plays.slice(0, Math.max(0, Math.min(index, plays.length - 1)) + 1) : [];
1049
+ const byKey = new Map(shown.map(key => [key, []]));
1050
+ for (const p of upto) {
1051
+ const list = byKey.get(replayKey(p, byClub));
1052
+ if (list) list.push(p);
1053
+ }
1054
+ const current = upto[upto.length - 1] || null;
1055
+ const ticker = upto.slice(-REPLAY_TICKER).reverse().map((p, i) => ({ ...p, newest: i === 0, play: PLAY_WORD[p.kind] }));
1056
+ const rowsOut = shown.map((key) => {
1057
+ const t = tally(byKey.get(key));
1058
+ return {
1059
+ key,
1060
+ landed: t.landed,
1061
+ dropped: t.dropped,
1062
+ sent_back: t.sent_back,
1063
+ abandoned: t.abandoned,
1064
+ bump: Boolean(current && current.kind === 'landed' && replayKey(current, byClub) === key),
1065
+ };
1066
+ });
1067
+ return { index: upto.length - 1, total: plays.length, at: current ? current.at : null, rows: rowsOut, ticker };
1068
+ }
1069
+
1070
+ // One closing line: totals for the window plus the jams left on the belt.
1071
+ function replaySummary(plays = [], { stuck = [], since, now = Date.now() } = {}) {
1072
+ const t = tally(plays);
1073
+ const word = Number(now) - Number(since) <= DAY_MS ? 'night total' : 'window total';
1074
+ const jams = stuck.length
1075
+ ? `, ${stuck.length} stuck ${stuck.length === 1 ? 'jam' : 'jams'} at ${[...new Set(stuck.map(s => s.member))].slice(0, 3).join(', ')}`
1076
+ : ', no stuck jams';
1077
+ return `${word}: ${t.landed} landed, ${t.dropped} dropped, ${t.sent_back} sent back, ${t.abandoned} abandoned${jams}`;
1078
+ }
1079
+
1080
+ // Plays since a time for one workspace (or every club with league), plus the
1081
+ // claims stuck right now. Read-only.
1082
+ function loadReplay({ workspaceRoot = null, since, now = Date.now(), dbPath, league = false, isClub = isClubRoot } = {}) {
1083
+ const out = { since, now, plays: [], stuck: [], error: null };
1084
+ const taskDb = require('./task-db');
1085
+ const target = dbPath || taskDb.getDbPath();
1086
+ if (!fs.existsSync(target)) return out;
1087
+ let db;
1088
+ try {
1089
+ const { DatabaseSync } = require('node:sqlite');
1090
+ db = new DatabaseSync(target, { readOnly: true });
1091
+ const keep = league ? (root => isClub(root)) : (root => root === workspaceRoot);
1092
+ const rows = league
1093
+ ? db.prepare(playEventsSql()).all(since)
1094
+ : db.prepare(playEventsSql('AND e.workspace_root = ?')).all(since, workspaceRoot);
1095
+ const kept = rows.filter(row => keep(row.workspace_root));
1096
+ // same unique labels as the league, so /one/app and /two/app stay apart
1097
+ const names = clubNames([...new Set(kept.map(row => row.workspace_root))]);
1098
+ const clubOf = root => names.get(root) || path.basename(root);
1099
+ out.plays = kept
1100
+ .map(row => ({ row, play: outcomeFromEvent(row) }))
1101
+ .filter(x => x.play && Number(x.play.at) <= now)
1102
+ .map(x => ({ ...x.play, club: clubOf(x.row.workspace_root) }))
1103
+ .sort(byTimeAsc);
1104
+ const claimed = db.prepare(`
1105
+ SELECT id, workspace_root, claimed_by, claimed_at, updated_at,
1106
+ (SELECT MAX(e.created_at) FROM task_events e WHERE e.task_id = tasks.id) AS last_event_at
1107
+ FROM tasks WHERE status = 'claimed' AND claimed_by IS NOT NULL
1108
+ `).all().filter(t => keep(t.workspace_root));
1109
+ out.stuck = claimStates(claimed.map(t => ({
1110
+ task_id: t.id,
1111
+ member: String(t.claimed_by),
1112
+ last_event_at: Number(t.last_event_at || t.claimed_at || t.updated_at || 0),
1113
+ })), now).filter(c => c.stuck);
1114
+ } catch (err) {
1115
+ out.error = err && err.message ? err.message : String(err);
1116
+ } finally {
1117
+ if (db) {
1118
+ try { db.close(); } catch {}
1119
+ }
1120
+ }
1121
+ return out;
1122
+ }
1123
+
1124
+ module.exports = {
1125
+ ageLabel,
1126
+ closeNames,
1127
+ isSafeMemberName,
1128
+ rosterEngine,
1129
+ loadCard,
1130
+ medianLandingHours,
1131
+ memberCard,
1132
+ scoutingLine,
1133
+ weeklyRatings,
1134
+ loadReplay,
1135
+ parseSince,
1136
+ replayDelayMs,
1137
+ replayFrame,
1138
+ replayRows,
1139
+ replaySummary,
1140
+ reviewRows,
1141
+ isClubRoot,
1142
+ leagueBoard,
1143
+ leagueRows,
1144
+ loadLeague,
1145
+ clockLabel,
1146
+ form,
1147
+ lastPlays,
1148
+ loadSeason,
1149
+ outcomesFromEvents,
1150
+ ovr,
1151
+ ownerMood,
1152
+ readLastSeen,
1153
+ rosterRows,
1154
+ seasonBoard,
1155
+ sinceLastLook,
1156
+ stillStuck,
1157
+ stuckRows,
1158
+ tally,
1159
+ upNext,
1160
+ weekStart,
1161
+ writeLastSeen,
1162
+ };