atris 3.64.0 → 3.65.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/team.js CHANGED
@@ -2,11 +2,26 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { spawnSync } = require('child_process');
5
6
 
6
- const { canonicalEngineName } = require('../lib/engine-registry');
7
+ const { canonicalEngineName, mainCheckoutRoot } = require('../lib/engine-registry');
7
8
  const taskDb = require('../lib/task-db');
8
- const { buildTeamPresence, DEFAULT_FRESHNESS_WINDOW_MS, renderTeamPresence } = require('../lib/team-presence');
9
- const { LINEUP_UNREADABLE, memberLineup, readLineupSafe, renderLineup } = require('../lib/team-lineup');
9
+ const {
10
+ buildMemberActivity,
11
+ buildTeamPresence,
12
+ DEFAULT_FRESHNESS_WINDOW_MS,
13
+ IDLE_AFTER_DAYS,
14
+ activeWindowStartDay,
15
+ logFileDay,
16
+ memberActivityRows,
17
+ parseGitLog,
18
+ renderTeamPresence,
19
+ } = require('../lib/team-presence');
20
+ const { readRosterRuns } = require('../lib/roster-runs');
21
+ const { engineModelText, engineRunsView, modelLabel } = require('../lib/roster-models');
22
+ const { LINEUP_UNREADABLE, TEAM_FOOTER, memberLineup, readLineupSafe } = require('../lib/team-lineup');
23
+ const { listLiveEngineRuns, liveRunEngineText, shortElapsed } = require('../lib/engine-processes');
24
+ const { renderTable, terminalWidth } = require('../lib/text-table');
10
25
  const { isMemberParked, isParkedFrontmatter, setMemberParked } = require('../lib/member-park');
11
26
  const { readEngineRegistry } = require('./engine');
12
27
  const { listMissions, listWorktreeRollupMissions } = require('./mission');
@@ -148,22 +163,78 @@ function memberFocus(rawNow, { awake, alwaysOn }) {
148
163
  return focus;
149
164
  }
150
165
 
151
- function memberIsActive({ frontmatterEngine, awake }) {
152
- // A stale focus line in now.md does not make a member active, only an
153
- // assigned engine or live presence does.
154
- return Boolean(frontmatterEngine) || awake;
166
+ function memberIsActive({ awake, activity }) {
167
+ // Only real work makes a member active: live presence, or evidence in the
168
+ // last 7 days (a dated log, a commit that names it, a recorded run, a task
169
+ // or mission event). An engine in MEMBER.md or a focus line in now.md is
170
+ // a plan, not work.
171
+ return Boolean(awake) || Boolean(activity && activity.status === 'active');
155
172
  }
156
173
 
157
- function clipCell(text, max) {
158
- const value = String(text || '').trim();
159
- if (!max || value.length <= max) return value;
160
- if (max <= 0) return '';
161
- return value.slice(0, max);
174
+ // --- activity evidence, read once for the whole team ----------------------
175
+
176
+ const ACTIVITY_WINDOW_MS = IDLE_AFTER_DAYS * 24 * 60 * 60 * 1000;
177
+
178
+ // Dated file names under the member's logs/ folder and one level below it
179
+ // (logs/2026/2026-09-28.md). The date in the name, never the file time: a
180
+ // fresh checkout stamps every file with today.
181
+ function memberLogDays(member, root) {
182
+ const dir = member?.dir
183
+ ? path.join(member.dir, 'logs')
184
+ : path.join(root, 'atris', 'team', String(member?.name || '').trim(), 'logs');
185
+ const days = [];
186
+ let entries = [];
187
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return days; }
188
+ for (const entry of entries) {
189
+ if (entry.isDirectory()) {
190
+ let inner = [];
191
+ try { inner = fs.readdirSync(path.join(dir, entry.name)); } catch { inner = []; }
192
+ for (const name of inner) {
193
+ const day = logFileDay(name);
194
+ if (day) days.push(day);
195
+ }
196
+ continue;
197
+ }
198
+ const day = logFileDay(entry.name);
199
+ if (day) days.push(day);
200
+ }
201
+ return days;
202
+ }
203
+
204
+ // One git log for the window, for every member at once.
205
+ function readTeamCommits(root, sinceMs, deps = {}) {
206
+ if (Array.isArray(deps.commits)) return deps.commits;
207
+ const run = deps.runGit || ((args) => spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 5000, maxBuffer: 32 * 1024 * 1024 }));
208
+ const result = run(['log', `--since=${new Date(sinceMs).toISOString()}`, '--format=%x1e%aI%x1f%B', 'HEAD']);
209
+ if (!result || result.status !== 0) return [];
210
+ return parseGitLog(result.stdout);
211
+ }
212
+
213
+ // Activity per member name (lowercase), without the per-day buckets.
214
+ function collectMemberActivity(root, members, deps = {}) {
215
+ const nowMs = deps.nowMs || (typeof deps.now === 'function' ? deps.now() : Date.now());
216
+ const sinceMs = nowMs - ACTIVITY_WINDOW_MS;
217
+ const logDays = {};
218
+ for (const member of members) {
219
+ const name = String(member?.name || '').trim().toLowerCase();
220
+ if (name) logDays[name] = memberLogDays(member, root);
221
+ }
222
+ const events = Array.isArray(deps.streamEvents)
223
+ ? deps.streamEvents
224
+ : collectStreamEvents({ root, sinceMs, nowMs, deps: deps.streamDeps, skipLanding: true });
225
+ return buildMemberActivity({
226
+ nowMs,
227
+ members: Object.keys(logDays),
228
+ logDays,
229
+ commits: readTeamCommits(root, sinceMs, deps),
230
+ runs: Array.isArray(deps.runs) ? deps.runs : readRosterRuns(root, { now: nowMs, days: IDLE_AFTER_DAYS }),
231
+ events,
232
+ });
162
233
  }
163
234
 
164
235
  function rosterStatus(entry) {
165
236
  if (entry.status === 'awake') return 'live';
166
- if (String(entry.engine || '').trim()) return 'assigned';
237
+ if (entry.active) return 'active';
167
238
  return 'idle';
168
239
  }
169
240
 
@@ -177,21 +248,30 @@ function escapeHtml(text) {
177
248
 
178
249
  function collectTeamRoster(deps = {}) {
179
250
  const root = deps.root || repoRoot(deps.cwd || process.cwd());
251
+ const nowMs = typeof deps.now === 'function' ? deps.now() : Date.now();
252
+ const missions = collectMissions(root, deps);
253
+ const members = collectMembers(root, deps).filter((member) => !isTemplateMember(member));
254
+ // One read of the stream feeds both views: presence keeps its 15-minute
255
+ // awake window, activity looks back 14 days.
256
+ const streamEvents = Array.isArray(deps.streamEvents) || deps.activity
257
+ ? (deps.streamEvents || [])
258
+ : collectStreamEvents({ root, sinceMs: nowMs - ACTIVITY_WINDOW_MS, nowMs, deps: deps.streamDeps, skipLanding: true });
180
259
  // The roster only reads who is awake, never the landing wait, so it skips
181
260
  // the landing board entirely.
182
- const presence = deps.presence || collectTeamPresence({ ...deps, skipLanding: true });
261
+ const presence = deps.presence || collectTeamPresence({ ...deps, missions, streamEvents, skipLanding: true });
183
262
  const awake = new Set(presence.members.map((member) => String(member.name || '').trim().toLowerCase()));
263
+ const activityRows = deps.activity || collectMemberActivity(root, members, { ...deps, nowMs, streamEvents });
264
+ const activityByName = new Map(activityRows.map((row) => [row.name, row]));
184
265
  const engineRoster = deps.engineRoster || readEngineRegistry(root, { persist: false }).engines;
185
266
  const engineByOwner = new Map();
186
- for (const mission of collectMissions(root, deps)) {
267
+ for (const mission of missions) {
187
268
  if (!ROSTER_LIVE_MISSION_STATUSES.has(String(mission?.status || '').toLowerCase())) continue;
188
269
  const owner = String(mission?.owner || mission?.member || '').trim().toLowerCase();
189
270
  const engine = missionEngine(mission);
190
271
  // Missions arrive newest-first; the first live one per owner wins.
191
272
  if (owner && engine && !engineByOwner.has(owner)) engineByOwner.set(owner, engine);
192
273
  }
193
- return collectMembers(root, deps)
194
- .filter((member) => !isTemplateMember(member))
274
+ return members
195
275
  .map((member) => {
196
276
  const name = String(member?.name || '').trim().toLowerCase();
197
277
  const missionEngine = engineByOwner.get(name) || '';
@@ -199,7 +279,8 @@ function collectTeamRoster(deps = {}) {
199
279
  const alwaysOn = memberAlwaysOn(member);
200
280
  const isAwake = awake.has(name);
201
281
  const rawNow = readMemberNow(member, root);
202
- const active = memberIsActive({ frontmatterEngine, awake: isAwake, rawNow });
282
+ const activity = activityByName.get(name) || null;
283
+ const active = memberIsActive({ awake: isAwake, activity });
203
284
  const focus = memberFocus(rawNow, { awake: isAwake, alwaysOn });
204
285
  return {
205
286
  name,
@@ -211,6 +292,7 @@ function collectTeamRoster(deps = {}) {
211
292
  now: rawNow,
212
293
  focus,
213
294
  active,
295
+ activity,
214
296
  parked: isParkedFrontmatter(member?.frontmatter),
215
297
  };
216
298
  })
@@ -253,55 +335,206 @@ function withParkedLabels(rosterRows, all) {
253
335
  };
254
336
  }
255
337
 
256
- function renderTeamRoster(allRows, deps = {}) {
257
- if (!allRows.length) {
258
- return 'no team members yet. create one with: atris member create <name> --role="..."';
338
+ function shortDate(day) {
339
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(day || ''));
340
+ if (!match) return '';
341
+ const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
342
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }).toLowerCase();
343
+ }
344
+
345
+ function lastActiveText(activity) {
346
+ if (!activity || !activity.last_active) return '';
347
+ if (activity.days_since === 0) return 'last active today';
348
+ if (activity.days_since === 1) return 'last active yesterday';
349
+ return `last active ${shortDate(activity.last_active)}`;
350
+ }
351
+
352
+ // "on <tool> <model>", or just the model for claude-family tools, only
353
+ // when a run recorded it.
354
+ function activityEngineText(activity) {
355
+ if (!activity || !activity.engine) return '';
356
+ if (!activity.model) return `on ${activity.engine}`;
357
+ const label = modelLabel(activity.model);
358
+ return label.startsWith(activity.engine) || ['claude', 'fable', 'haiku'].includes(activity.engine)
359
+ ? `on ${label}`
360
+ : `on ${activity.engine} ${label}`;
361
+ }
362
+
363
+ // One line of facts per active member: when, how much, on what, how it went.
364
+ function activityLine(entry) {
365
+ const activity = entry.activity || {};
366
+ const parts = [];
367
+ if (entry.status === 'awake') parts.push(entry.focus && entry.focus !== '-' ? `live now: ${entry.focus.replace(/ \(live\)$/, '')}` : 'live now');
368
+ const last = lastActiveText(activity);
369
+ if (last && !(entry.status === 'awake' && activity.days_since === 0)) parts.push(last);
370
+ const runs = Number(activity.runs_7d) || 0;
371
+ if (runs) parts.push(`${runs} run${runs === 1 ? '' : 's'} in 7 days`);
372
+ const engine = activityEngineText(activity);
373
+ if (engine) parts.push(engine);
374
+ for (const outcome of ['landed', 'failed', 'reverted']) {
375
+ if (Number(activity[outcome]) > 0) parts.push(`${activity[outcome]} ${outcome}`);
259
376
  }
260
- const { rows: rosterRows, parkedRows } = withParkedLabels(allRows, Boolean(deps.all));
261
- const activeRows = rosterRows.filter((entry) => entry.active);
262
- const restRows = rosterRows.filter((entry) => !entry.active);
263
- const termWidth = deps.termWidth || process.stdout.columns || 80;
264
- const memberW = Math.max(6, ...activeRows.map((entry) => entry.name.length));
265
- const engineW = Math.max(6, ...activeRows.map((entry) => (entry.engine || '-').length));
266
- const statusW = 8;
267
- const sep = 3;
268
- const focusW = Math.max(8, termWidth - memberW - engineW - statusW - sep * 3);
269
- const lines = ['active team:'];
270
- if (activeRows.length) {
271
- for (const entry of activeRows) {
272
- const engine = entry.engine || '-';
273
- const status = rosterStatus(entry);
274
- const focus = clipCell(entry.focus || '-', focusW);
275
- lines.push(
276
- `${clipCell(entry.name, memberW).padEnd(memberW)} | ${clipCell(engine, engineW).padEnd(engineW)} | ${status.padEnd(statusW)} | ${focus}`,
277
- );
278
- }
279
- } else {
280
- lines.push('(none)');
377
+ return parts.join(', ');
378
+ }
379
+
380
+ // --- the team table --------------------------------------------------------
381
+ //
382
+ // One row per member: its job and engine from the roster (the roster file is
383
+ // the truth, MEMBER.md frontmatter is not), whether it is working now (a live
384
+ // engine run names it), worked this week, or is quiet, what it is doing, and
385
+ // when it last did anything. Runs that name no member get their own rows.
386
+ // DOING is the live prompt for a running member; for a member that worked
387
+ // this week it is the week's record (runs and outcomes), which is fresher
388
+ // than the status line in now.md, used only when there is no record.
389
+
390
+ const STATE_ORDER = Object.freeze({ 'working now': 0, 'this week': 1, quiet: 2, parked: 3 });
391
+
392
+ // "today", "2d", or "-" when nothing was ever seen.
393
+ function lastSeenText(activity) {
394
+ const days = activity ? activity.days_since : null;
395
+ if (days === null || days === undefined || !Number.isFinite(Number(days))) return '-';
396
+ return Number(days) === 0 ? 'today' : `${days}d`;
397
+ }
398
+
399
+ // "23 runs this week, 2 landed", from the activity evidence.
400
+ function activityFacts(activity) {
401
+ if (!activity) return '';
402
+ const runs = Number(activity.runs_7d) || 0;
403
+ const parts = runs ? [`${runs} run${runs === 1 ? '' : 's'} this week`] : [];
404
+ for (const outcome of ['landed', 'failed', 'reverted']) {
405
+ if (Number(activity[outcome]) > 0) parts.push(`${activity[outcome]} ${outcome}`);
406
+ }
407
+ return parts.join(', ');
408
+ }
409
+
410
+ // The tool and model a member runs per the roster, with the model each tool
411
+ // picks on its own filled in (claude's atris default, codex settings).
412
+ function placedEngineText(place) {
413
+ const view = engineRunsView(place.engine, { model: place.model || '', effort: place.effort || '' });
414
+ return engineModelText(view.engine, view.model);
415
+ }
416
+
417
+ function liveDoing(runs) {
418
+ const [first] = runs;
419
+ const text = first.doing && first.doing !== '-' ? first.doing : 'running';
420
+ return runs.length > 1 ? `${runs.length} runs: ${text}` : text;
421
+ }
422
+
423
+ function liveRunJson(run) {
424
+ return {
425
+ pid: run.pid,
426
+ engine: run.engine,
427
+ model: run.model || null,
428
+ effort: run.effort || null,
429
+ member: run.member || null,
430
+ doing: run.doing,
431
+ elapsed_seconds: run.elapsed_seconds,
432
+ };
433
+ }
434
+
435
+ // The table's rows, in order, for text and json alike. Parked members are
436
+ // left out unless `all` (or unless they are working right now).
437
+ function teamTableRows(rosterRows, { lineup = null, liveRuns = [], all = false } = {}) {
438
+ const runsByMember = new Map();
439
+ const loose = [];
440
+ const members = new Set(rosterRows.map((entry) => entry.name));
441
+ for (const run of Array.isArray(liveRuns) ? liveRuns : []) {
442
+ // "acting as <name>" that names no member here ("acting as an expert",
443
+ // a member of another project) is an unattached run that keeps the name.
444
+ if (!run.member || !members.has(run.member)) { loose.push(run); continue; }
445
+ if (!runsByMember.has(run.member)) runsByMember.set(run.member, []);
446
+ runsByMember.get(run.member).push(run);
447
+ }
448
+ const rows = [];
449
+ const placed = (name) => memberLineup(lineup, name);
450
+ const add = (entry, name, runs) => {
451
+ const place = placed(name);
452
+ const working = runs.length > 0;
453
+ const state = working ? 'working now' : entry && entry.parked ? 'parked' : entry && entry.active ? 'this week' : 'quiet';
454
+ const focus = entry ? String(entry.focus || '').replace(/ \(live\)$/, '').replace(/^-$/, '') : '';
455
+ const doing = working ? liveDoing(runs)
456
+ : state === 'this week' ? (activityFacts(entry.activity) || focus || '-')
457
+ : '-';
458
+ rows.push({
459
+ member: name,
460
+ job: place && place.job ? place.job : '-',
461
+ engine_model: working ? liveRunEngineText(runs[0]) : place && place.engine ? placedEngineText(place) : '-',
462
+ state,
463
+ doing,
464
+ last: working ? shortElapsed(runs[0].elapsed_seconds) : lastSeenText(entry && entry.activity),
465
+ days_since: entry && entry.activity && Number.isFinite(Number(entry.activity.days_since)) ? Number(entry.activity.days_since) : null,
466
+ elapsed_seconds: working ? runs[0].elapsed_seconds : null,
467
+ live_runs: runs.map(liveRunJson),
468
+ entry,
469
+ });
470
+ };
471
+ for (const entry of rosterRows) {
472
+ const runs = runsByMember.get(entry.name) || [];
473
+ if (entry.parked && !all && !runs.length) continue;
474
+ add(entry, entry.name, runs);
281
475
  }
282
- lines.push('');
283
- lines.push('rest of the team:');
284
- if (restRows.length) {
285
- lines.push(wrapCommaNames(restRows.map((entry) => entry.name), termWidth));
286
- } else {
287
- lines.push('(none)');
476
+ for (const run of loose) {
477
+ rows.push({
478
+ member: '(no member)',
479
+ job: '-',
480
+ engine_model: liveRunEngineText(run),
481
+ state: 'working now',
482
+ doing: run.member ? `as ${run.member}: ${run.doing || '-'}` : (run.doing || '-'),
483
+ last: shortElapsed(run.elapsed_seconds),
484
+ days_since: null,
485
+ elapsed_seconds: run.elapsed_seconds,
486
+ live_runs: [liveRunJson(run)],
487
+ entry: null,
488
+ });
288
489
  }
289
- const parkedLine = parkedSummaryLine(parkedRows, termWidth);
290
- if (parkedLine) {
291
- lines.push('');
292
- lines.push(parkedLine);
490
+ const far = (value) => (value === null || value === undefined ? Infinity : value);
491
+ return rows.sort((a, b) => STATE_ORDER[a.state] - STATE_ORDER[b.state]
492
+ || far(a.elapsed_seconds) - far(b.elapsed_seconds)
493
+ || far(a.days_since) - far(b.days_since)
494
+ || (a.member === '(no member)') - (b.member === '(no member)')
495
+ || a.member.localeCompare(b.member));
496
+ }
497
+
498
+ const TEAM_COLUMNS = Object.freeze([
499
+ { header: 'MEMBER' },
500
+ { header: 'JOB' },
501
+ { header: 'ENGINE · MODEL' },
502
+ { header: 'STATUS' },
503
+ { header: 'DOING', clip: true, min: 12 },
504
+ { header: 'LAST' },
505
+ ]);
506
+
507
+ // The whole `atris team` text view: the table, the parked line, and two
508
+ // copyable commands. Pure: live runs and the lineup come in through deps.
509
+ function renderTeamRoster(allRows, deps = {}) {
510
+ const liveRuns = Array.isArray(deps.liveRuns) ? deps.liveRuns : [];
511
+ if (!allRows.length && !liveRuns.length) {
512
+ return 'no team members yet. create one with: atris member create <name> --role="..."';
293
513
  }
514
+ const width = terminalWidth(deps.termWidth);
515
+ const all = Boolean(deps.all);
516
+ const lineup = deps.lineup && deps.lineup.ok ? deps.lineup : null;
517
+ const rows = teamTableRows(allRows, { lineup, liveRuns, all });
518
+ // DOING sits before LAST, so it is clipped first and LAST keeps its place.
519
+ const table = renderTable(TEAM_COLUMNS, rows.map((row) => [row.member, row.job, row.engine_model, row.state, row.doing, row.last]), { width });
520
+ const lines = [table];
521
+ const parkedRows = all ? [] : allRows.filter((entry) => entry.parked && !rows.some((row) => row.entry === entry));
522
+ const parkedLine = parkedSummaryLine(parkedRows, width);
523
+ if (parkedLine) lines.push('', parkedLine);
524
+ if (deps.lineup && !deps.lineup.ok) lines.push('', LINEUP_UNREADABLE);
525
+ lines.push('', ...TEAM_FOOTER);
294
526
  return lines.join('\n');
295
527
  }
296
528
 
297
- // The lineup (who does each job, who is on each job) above today's active
298
- // and rest lists.
299
- function renderTeamWithLineup(rosterRows, lineup, deps = {}) {
300
- const width = Math.min(deps.termWidth || process.stdout.columns || 80, 100);
301
- const today = renderTeamRoster(rosterRows, deps);
302
- if (!lineup || !lineup.ok) return `${today}\n\n${LINEUP_UNREADABLE}`;
303
- const block = renderLineup(lineup, { width });
304
- return block ? `${block}\n\n${today}` : today;
529
+ // For --json: every member (parked ones say so), with the table's fields
530
+ // next to today's, then one entry per run that names no member.
531
+ function teamJsonRows(allRows, deps = {}) {
532
+ const rows = teamTableRows(allRows, { lineup: deps.lineup && deps.lineup.ok ? deps.lineup : null, liveRuns: deps.liveRuns || [], all: true });
533
+ const byEntry = new Map(rows.filter((row) => row.entry).map((row) => [row.entry, row]));
534
+ const table = (row) => ({ job: row.job, engine_model: row.engine_model, state: row.state, doing: row.doing, last: row.last, live_runs: row.live_runs });
535
+ const members = allRows.map((entry) => ({ ...entry, lineup: memberLineup(deps.lineup, entry.name), ...table(byEntry.get(entry)) }));
536
+ const others = rows.filter((row) => !row.entry).map((row) => ({ name: row.member, member: row.member !== '(no member)', lineup: null, ...table(row) }));
537
+ return [...members, ...others];
305
538
  }
306
539
 
307
540
  function renderTeamRosterHtml(allRows, meta = {}) {
@@ -314,7 +547,7 @@ function renderTeamRosterHtml(allRows, meta = {}) {
314
547
  const statusDot = (entry) => {
315
548
  const status = rosterStatus(entry);
316
549
  if (status === 'live') return '<span class="dot dot-live" title="live"></span><span class="status-label">live</span>';
317
- if (status === 'assigned') return '<span class="dot dot-assigned" title="assigned"></span><span class="status-label">assigned</span>';
550
+ if (status === 'active') return '<span class="dot dot-assigned" title="active this week"></span><span class="status-label">active</span>';
318
551
  return '<span class="dot dot-idle" title="idle"></span><span class="status-label">idle</span>';
319
552
  };
320
553
 
@@ -324,7 +557,7 @@ function renderTeamRosterHtml(allRows, meta = {}) {
324
557
  <td class="col-member">${escapeHtml(entry.name)}</td>
325
558
  <td class="col-engine">${escapeHtml(entry.engine || '-')}</td>
326
559
  <td class="col-status">${statusDot(entry)}</td>
327
- <td class="col-focus">${escapeHtml(entry.focus || '-')}</td>
560
+ <td class="col-focus">${escapeHtml(activityLine(entry) || entry.focus || '-')}</td>
328
561
  </tr>`).join('')
329
562
  : '<tr><td colspan="4" class="empty">(none)</td></tr>';
330
563
 
@@ -445,6 +678,46 @@ function renderTeamRosterHtml(allRows, meta = {}) {
445
678
  </html>`;
446
679
  }
447
680
 
681
+ // The self-improvement loop's feed: one row per member per day, so it can
682
+ // compare member and model pairs over time. Each record rewrites only the
683
+ // last 7 days (the window it can see) of the members this checkout knows;
684
+ // a member missing here keeps its rows, and older rows and lines it cannot
685
+ // read stay exactly as they were. The file lives in the main checkout, so
686
+ // every worktree of a project feeds one history.
687
+ const MEMBER_ACTIVITY_FILE = path.join('.atris', 'state', 'member_activity.jsonl');
688
+
689
+ function memberActivityFile(root) {
690
+ const main = mainCheckoutRoot(root);
691
+ return path.join(main && fs.existsSync(main) ? main : root, MEMBER_ACTIVITY_FILE);
692
+ }
693
+
694
+ function recordMemberActivity(root, rosterRows, nowMs = Date.now()) {
695
+ const file = memberActivityFile(root);
696
+ const activity = rosterRows.map((entry) => entry.activity).filter(Boolean);
697
+ const rows = memberActivityRows(activity, nowMs);
698
+ const observed = new Set(activity.map((member) => String(member.name || '').toLowerCase()));
699
+ const since = activeWindowStartDay(nowMs);
700
+ let kept = [];
701
+ try {
702
+ kept = fs.readFileSync(file, 'utf8').split(/\r?\n/).filter((line) => {
703
+ if (!line.trim()) return false;
704
+ try {
705
+ const row = JSON.parse(line);
706
+ return !(row && typeof row.day === 'string' && row.day >= since
707
+ && observed.has(String(row.member || '').toLowerCase()));
708
+ } catch {
709
+ return true;
710
+ }
711
+ });
712
+ } catch { /* no file yet */ }
713
+ const body = [...kept, ...rows.map((row) => JSON.stringify(row))].join('\n');
714
+ fs.mkdirSync(path.dirname(file), { recursive: true });
715
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
716
+ fs.writeFileSync(tmp, body ? `${body}\n` : '', 'utf8');
717
+ fs.renameSync(tmp, file);
718
+ return { file, rows: rows.length, members: new Set(rows.map((row) => row.member)).size };
719
+ }
720
+
448
721
  function writeTeamBoardHtml(rosterRows, deps = {}) {
449
722
  const workspace = deps.cwd || process.cwd();
450
723
  const outPath = path.join(workspace, 'atris', 'team', 'team-board.html');
@@ -531,14 +804,15 @@ function renderTeamPrune(report, days = DEFAULT_PRUNE_DAYS) {
531
804
 
532
805
  function helpText() {
533
806
  return [
534
- 'atris team - who does each job, with its tool and model, then active members and the rest',
807
+ 'atris team - one table, one row per member: job, tool and model from the roster, working now / this week / quiet, what it is doing, and when it last worked',
535
808
  'atris team --all - also list parked members in place',
809
+ 'atris team --record - also save one row per member per day to .atris/state/member_activity.jsonl',
536
810
  'atris team presence - show who is awake and what they are doing',
537
811
  'atris team prune - flag members with no recent activity; deletes nothing',
538
812
  'atris team park <name> [--note "<reason>"] - hide a member from the team views; it still runs by name',
539
813
  'atris team unpark <name> - bring a parked member back',
540
814
  '',
541
- 'usage: atris team [roster|presence] [--all] [--json] [--html]',
815
+ 'usage: atris team [roster|presence] [--all] [--json] [--html] [--record]',
542
816
  'usage: atris team prune [--days N] [--json]',
543
817
  'usage: atris team park <name> [--note "<reason>"] | atris team unpark <name>',
544
818
  ].join('\n');
@@ -603,7 +877,7 @@ function teamCommand(args = [], deps = {}) {
603
877
  return teamParkCommand(args[0] === 'park', args.slice(1), deps);
604
878
  }
605
879
  const rosterArgs = args.filter((arg) => arg !== 'roster');
606
- const rosterFlags = new Set(['--json', '--html', '--all']);
880
+ const rosterFlags = new Set(['--json', '--html', '--all', '--record']);
607
881
  if (args[0] !== 'presence' && rosterArgs.every((arg) => rosterFlags.has(arg))) {
608
882
  const html = rosterArgs.includes('--html');
609
883
  const json = rosterArgs.includes('--json');
@@ -613,6 +887,15 @@ function teamCommand(args = [], deps = {}) {
613
887
  return 2;
614
888
  }
615
889
  const roster = deps.roster || collectTeamRoster(deps);
890
+ let recordNote = '';
891
+ if (rosterArgs.includes('--record')) {
892
+ const root = deps.root || repoRoot(deps.cwd || process.cwd());
893
+ const nowMs = typeof deps.now === 'function' ? deps.now() : Date.now();
894
+ const saved = recordMemberActivity(root, roster, nowMs);
895
+ recordNote = `recorded ${saved.rows} day${saved.rows === 1 ? '' : 's'} of work for ${saved.members} member${saved.members === 1 ? '' : 's'} to ${saved.file}\n`;
896
+ // --json and --html keep stdout to their one answer; the note goes to stderr.
897
+ if (json || html) (deps.error || process.stderr.write.bind(process.stderr))(recordNote);
898
+ }
616
899
  if (html) {
617
900
  const outPath = writeTeamBoardHtml(roster, { ...deps, all });
618
901
  (deps.write || process.stdout.write.bind(process.stdout))(`${outPath}\n`);
@@ -624,10 +907,13 @@ function teamCommand(args = [], deps = {}) {
624
907
  const lineup = deps.lineup !== undefined
625
908
  ? deps.lineup
626
909
  : readLineupSafe(deps.root || repoRoot(deps.cwd || process.cwd()), deps.lineupNow || new Date());
910
+ // Who is working this minute: live engine runs on this machine.
911
+ const liveRuns = Array.isArray(deps.liveRuns) ? deps.liveRuns : listLiveEngineRuns(deps);
627
912
  const output = json
628
- ? JSON.stringify(roster.map((entry) => ({ ...entry, lineup: memberLineup(lineup, entry.name) })), null, 2)
629
- : renderTeamWithLineup(roster, lineup, { ...deps, all });
913
+ ? JSON.stringify(teamJsonRows(roster, { lineup, liveRuns }), null, 2)
914
+ : renderTeamRoster(roster, { ...deps, lineup, liveRuns, all });
630
915
  (deps.write || process.stdout.write.bind(process.stdout))(`${output}\n`);
916
+ if (recordNote && !json) (deps.write || process.stdout.write.bind(process.stdout))(`\n${recordNote}`);
631
917
  return 0;
632
918
  }
633
919
  if (args[0] !== 'presence' || args.some((arg, index) => index > 0 && arg !== '--json')) {
@@ -643,7 +929,9 @@ function teamCommand(args = [], deps = {}) {
643
929
  }
644
930
 
645
931
  module.exports = {
932
+ collectMemberActivity,
646
933
  collectTeamPrune,
934
+ recordMemberActivity,
647
935
  collectTeamRoster,
648
936
  renderTeamPrune,
649
937
  renderTeamRoster,