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/README.md +4 -0
- package/bin/atris.js +17 -0
- package/commands/agent-spawn.js +1 -0
- package/commands/engine.js +181 -63
- package/commands/hire.js +189 -0
- package/commands/mail.js +163 -0
- package/commands/mission.js +3 -2
- package/commands/stream.js +22 -1
- package/commands/team.js +355 -67
- package/lib/auto-accept-certified.js +83 -9
- package/lib/business-target.js +131 -0
- package/lib/engine-processes.js +430 -0
- package/lib/engine-registry.js +16 -3
- package/lib/known-commands.js +1 -1
- package/lib/member-identity.js +56 -0
- package/lib/roster-models.js +11 -0
- package/lib/roster-stale.js +241 -0
- package/lib/team-lineup.js +10 -71
- package/lib/team-presence.js +224 -0
- package/lib/text-table.js +81 -0
- package/package.json +1 -1
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 {
|
|
9
|
-
|
|
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({
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
//
|
|
298
|
-
//
|
|
299
|
-
function
|
|
300
|
-
const
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
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 === '
|
|
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 -
|
|
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
|
|
629
|
-
:
|
|
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,
|