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
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Outdated models on the roster. A roster worker whose model has a newer
|
|
4
|
+
// version among the models this machine or the engine registry already
|
|
5
|
+
// knows gets one plain line and the command that moves it forward. A pinned
|
|
6
|
+
// model whose newest proof (a "verified <date>" on its line, or a landed run
|
|
7
|
+
// on that model) is over 30 days old gets one line too. Nothing here goes
|
|
8
|
+
// online: the model lists come from availableModels (codex's own models
|
|
9
|
+
// cache, the claude names atris knows, and each engine's registry models).
|
|
10
|
+
|
|
11
|
+
const { modelLabel, availableModels } = require('./roster-models');
|
|
12
|
+
const { parseRosterUntil, rosterMaxText, rosterToolLabel } = require('./engine-registry');
|
|
13
|
+
|
|
14
|
+
const PIN_MAX_AGE_DAYS = 30;
|
|
15
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
const CLAUDE_FAMILY = Object.freeze(['claude', 'fable', 'haiku']);
|
|
17
|
+
|
|
18
|
+
// Family ordering across names is ambiguous: going from a "luna" tail to a
|
|
19
|
+
// "sol" tail one version up is a tier change as much as a version change,
|
|
20
|
+
// and nothing local says which tails rank above which. So versions are only
|
|
21
|
+
// compared within an identical name prefix, the text before the first
|
|
22
|
+
// version number: "x-6-astra" and "x-6.1-sol" share the prefix "x", and two
|
|
23
|
+
// names with different prefixes (sonnet and opus) never compare. Among
|
|
24
|
+
// newer versions, one that keeps the same tail (sol, fast, flash-high) wins;
|
|
25
|
+
// otherwise the highest version, and on a tie the one the list names first.
|
|
26
|
+
// Examples stay abstract here so this file never pins a real model name.
|
|
27
|
+
// A context tag at the end ("[1m]", "(beta)") is not part of the name, and
|
|
28
|
+
// a leading "claude-" before a family word is the vendor, not the family,
|
|
29
|
+
// so "claude-x-4-6[1m]" and "x 5" land in the same family.
|
|
30
|
+
function familyLabel(model) {
|
|
31
|
+
let text = String(model || '').trim();
|
|
32
|
+
while (/\s*(?:\[[^\]]*\]|\([^)]*\))$/.test(text)) text = text.replace(/\s*(?:\[[^\]]*\]|\([^)]*\))$/, '');
|
|
33
|
+
return modelLabel(text).toLowerCase().trim().replace(/^claude[\s_-]+(?=[a-z])/, '');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function modelFamily(model) {
|
|
37
|
+
const label = familyLabel(model);
|
|
38
|
+
const match = /^([a-z][a-z/_\s-]*?)[\s_-]*(\d+(?:[.-]\d{1,2}(?!\d))*)(.*)$/.exec(label);
|
|
39
|
+
if (!match) return null;
|
|
40
|
+
const prefix = match[1].replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
41
|
+
if (!prefix) return null;
|
|
42
|
+
return {
|
|
43
|
+
prefix,
|
|
44
|
+
version: match[2].split(/[.-]/).map(Number),
|
|
45
|
+
variant: match[3].replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, ''),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function compareVersions(a, b) {
|
|
50
|
+
const size = Math.max(a.length, b.length);
|
|
51
|
+
for (let i = 0; i < size; i += 1) {
|
|
52
|
+
const diff = (a[i] || 0) - (b[i] || 0);
|
|
53
|
+
if (diff) return diff > 0 ? 1 : -1;
|
|
54
|
+
}
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sameModel(a, b) {
|
|
59
|
+
const left = modelLabel(a).toLowerCase().trim();
|
|
60
|
+
return Boolean(left) && left === modelLabel(b).toLowerCase().trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The newest model in the list that beats this one, or null.
|
|
64
|
+
function newerModel(model, catalog = []) {
|
|
65
|
+
const current = modelFamily(model);
|
|
66
|
+
if (!current) return null;
|
|
67
|
+
let best = null;
|
|
68
|
+
for (const candidate of catalog || []) {
|
|
69
|
+
const family = modelFamily(candidate);
|
|
70
|
+
if (!family || family.prefix !== current.prefix) continue;
|
|
71
|
+
if (compareVersions(family.version, current.version) <= 0) continue;
|
|
72
|
+
const sameTail = family.variant === current.variant;
|
|
73
|
+
const better = !best
|
|
74
|
+
|| (sameTail && !best.sameTail)
|
|
75
|
+
|| (sameTail === best.sameTail && compareVersions(family.version, best.family.version) > 0);
|
|
76
|
+
if (better) best = { candidate, family, sameTail };
|
|
77
|
+
}
|
|
78
|
+
return best ? best.candidate : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Every model each engine is known to offer, from local files only. Claude,
|
|
82
|
+
// fable, and haiku run through the same CLI, so they share one list.
|
|
83
|
+
function modelCatalog(engines, options = {}) {
|
|
84
|
+
const catalog = {};
|
|
85
|
+
const add = (id, models) => {
|
|
86
|
+
const list = catalog[id] || (catalog[id] = []);
|
|
87
|
+
for (const model of models || []) if (model && !list.includes(model)) list.push(model);
|
|
88
|
+
};
|
|
89
|
+
const rows = availableModels(engines || [], options);
|
|
90
|
+
for (const row of rows) add(row.engine, row.models);
|
|
91
|
+
for (const engine of engines || []) add(engine.id, engine.models);
|
|
92
|
+
const claude = (rows.find((row) => row.engine === 'claude') || {}).models || [];
|
|
93
|
+
for (const id of CLAUDE_FAMILY) if (catalog[id]) add(id, claude);
|
|
94
|
+
return catalog;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function localDayText(date) {
|
|
98
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
99
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function wholeDaysSince(dayText, now) {
|
|
103
|
+
const day = parseRosterUntil(dayText);
|
|
104
|
+
if (!day) return null;
|
|
105
|
+
const date = new Date(now);
|
|
106
|
+
const today = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
107
|
+
return Math.round((today.getTime() - day.getTime()) / DAY_MS);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The newest proof that this engine and model answer: the line's own
|
|
111
|
+
// "verified" date or the newest landed run on the same engine and model.
|
|
112
|
+
function pinVerified(worker, runs) {
|
|
113
|
+
let best = worker.verified ? { at: worker.verified, source: 'roster line' } : null;
|
|
114
|
+
for (const run of runs || []) {
|
|
115
|
+
if (run.outcome !== 'landed' || run.engine !== worker.engine || !sameModel(run.model, worker.model)) continue;
|
|
116
|
+
const at = Date.parse(run.at);
|
|
117
|
+
if (!Number.isFinite(at)) continue;
|
|
118
|
+
const day = localDayText(new Date(at));
|
|
119
|
+
if (!best || day > best.at) best = { at: day, source: 'landed run' };
|
|
120
|
+
}
|
|
121
|
+
return best;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function quoteArg(text) {
|
|
125
|
+
const value = String(text || '');
|
|
126
|
+
return /^[A-Za-z0-9._:/@-]+$/.test(value) ? value : `"${value.replace(/"/g, '\\"')}"`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const SCOPE_FLAGS = Object.freeze({ 'all projects': '--everywhere', 'this session': '--session' });
|
|
130
|
+
|
|
131
|
+
// The assign command that moves a job's first worker to the newer model and
|
|
132
|
+
// keeps everything else on its line: effort, time cap, prep, end date.
|
|
133
|
+
function assignCommand(row, worker, model) {
|
|
134
|
+
const parts = ['atris engine assign', quoteArg(row.job), worker.engine, '--model', quoteArg(modelLabel(model))];
|
|
135
|
+
if (worker.effort) parts.push('--effort', worker.effort);
|
|
136
|
+
if (worker.max_seconds) parts.push('--max', quoteArg(rosterMaxText(worker.max_seconds).replace(/^max /, '')));
|
|
137
|
+
if (worker.prep) parts.push('--prep', quoteArg(worker.prep));
|
|
138
|
+
if (worker.until && Number(worker.ends_in_days) > 0) parts.push('--days', String(worker.ends_in_days));
|
|
139
|
+
if (SCOPE_FLAGS[row.from]) parts.push(SCOPE_FLAGS[row.from]);
|
|
140
|
+
return parts.join(' ');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function lineHint(file, line, change) {
|
|
144
|
+
const where = line ? `line ${line} of ${file || 'the roster'}` : (file || 'the roster');
|
|
145
|
+
return `${change} on ${where}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Mark every worker and job in a roster report with stale_model,
|
|
149
|
+
// newer_model, pin_age_days, and pin_verified_at, and list what needs a
|
|
150
|
+
// look in report.stale. Returns the report.
|
|
151
|
+
function attachStaleModels(report, { catalog = {}, runs = [], now = new Date() } = {}) {
|
|
152
|
+
const stale = [];
|
|
153
|
+
const today = localDayText(new Date(now));
|
|
154
|
+
for (const row of report.jobs || []) {
|
|
155
|
+
(row.workers || []).forEach((worker, index) => {
|
|
156
|
+
worker.stale_model = false;
|
|
157
|
+
worker.newer_model = null;
|
|
158
|
+
worker.pin_age_days = null;
|
|
159
|
+
worker.pin_verified_at = null;
|
|
160
|
+
const view = worker.runs;
|
|
161
|
+
if (!view || !view.model || !worker.engine || worker.why === 'expired') return;
|
|
162
|
+
const who = index === 0 ? row.job : `${row.job} backup`;
|
|
163
|
+
const tool = rosterToolLabel(worker.engine);
|
|
164
|
+
const newer = newerModel(view.model, catalog[worker.engine]);
|
|
165
|
+
if (newer) {
|
|
166
|
+
worker.stale_model = true;
|
|
167
|
+
worker.newer_model = newer;
|
|
168
|
+
const from = view.model_source === 'roster' ? '' : ` from ${view.model_source}`;
|
|
169
|
+
const renew = index === 0
|
|
170
|
+
? assignCommand(row, worker, newer)
|
|
171
|
+
: lineHint(row.file, worker.line, `change the model to ${modelLabel(newer)}`);
|
|
172
|
+
stale.push({
|
|
173
|
+
kind: 'newer model',
|
|
174
|
+
job: row.job,
|
|
175
|
+
engine: worker.engine,
|
|
176
|
+
model: view.model,
|
|
177
|
+
newer_model: newer,
|
|
178
|
+
from: row.from,
|
|
179
|
+
file: row.file,
|
|
180
|
+
line: worker.line,
|
|
181
|
+
renew,
|
|
182
|
+
text: `${who}: ${tool} runs ${modelLabel(view.model)}${from}, newer ${modelLabel(newer)} is available. renew: ${renew}`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
if (view.model_source !== 'roster') return;
|
|
186
|
+
const proof = pinVerified({ ...worker, model: view.model }, runs);
|
|
187
|
+
if (!proof) return;
|
|
188
|
+
worker.pin_verified_at = proof.at;
|
|
189
|
+
worker.pin_age_days = wholeDaysSince(proof.at, now);
|
|
190
|
+
if (worker.pin_age_days === null || worker.pin_age_days <= PIN_MAX_AGE_DAYS || newer) return;
|
|
191
|
+
stale.push({
|
|
192
|
+
kind: 'old pin',
|
|
193
|
+
job: row.job,
|
|
194
|
+
engine: worker.engine,
|
|
195
|
+
model: view.model,
|
|
196
|
+
pin_verified_at: proof.at,
|
|
197
|
+
pin_age_days: worker.pin_age_days,
|
|
198
|
+
from: row.from,
|
|
199
|
+
file: row.file,
|
|
200
|
+
line: worker.line,
|
|
201
|
+
text: `${who}: ${tool} on ${modelLabel(view.model)} was last verified ${worker.pin_age_days} days ago (${proof.source}). smoke one prompt, then ${lineHint(row.file, worker.line, `add "verified ${today}"`)}`,
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
// The job's own fields mirror the worker that leads it, else its first.
|
|
205
|
+
const lead = (row.workers || []).find((worker) => worker.status === 'leads') || (row.workers || [])[0] || {};
|
|
206
|
+
row.stale_model = Boolean(lead.stale_model);
|
|
207
|
+
row.newer_model = lead.newer_model || null;
|
|
208
|
+
row.pin_age_days = lead.pin_age_days === undefined ? null : lead.pin_age_days;
|
|
209
|
+
}
|
|
210
|
+
// A member line that pins its own model is checked too; a member that
|
|
211
|
+
// picks automatically runs its job's worker, already checked above.
|
|
212
|
+
for (const member of report.team || []) {
|
|
213
|
+
if (member.source !== 'file' || !member.model || !member.engine) continue;
|
|
214
|
+
const newer = newerModel(member.model, catalog[member.engine]);
|
|
215
|
+
member.stale_model = Boolean(newer);
|
|
216
|
+
member.newer_model = newer;
|
|
217
|
+
if (!newer) continue;
|
|
218
|
+
const renew = `change the model to ${modelLabel(newer)} on the ${member.member} line of ${member.file || 'the roster'}`;
|
|
219
|
+
stale.push({
|
|
220
|
+
kind: 'newer model',
|
|
221
|
+
member: member.member,
|
|
222
|
+
engine: member.engine,
|
|
223
|
+
model: member.model,
|
|
224
|
+
newer_model: newer,
|
|
225
|
+
file: member.file,
|
|
226
|
+
renew,
|
|
227
|
+
text: `${member.member}: ${rosterToolLabel(member.engine)} runs ${modelLabel(member.model)}, newer ${modelLabel(newer)} is available. renew: ${renew}`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
report.stale = stale;
|
|
231
|
+
return report;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = {
|
|
235
|
+
PIN_MAX_AGE_DAYS,
|
|
236
|
+
attachStaleModels,
|
|
237
|
+
compareVersions,
|
|
238
|
+
modelCatalog,
|
|
239
|
+
modelFamily,
|
|
240
|
+
newerModel,
|
|
241
|
+
};
|
package/lib/team-lineup.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// come from the same resolver `atris engine roster` uses (jobRosterView and
|
|
5
5
|
// rosterReport in commands/engine.js); this file only picks and words them.
|
|
6
6
|
|
|
7
|
-
const {
|
|
7
|
+
const { modelLabel } = require('./roster-models');
|
|
8
8
|
const { rosterToolLabel } = require('./engine-registry');
|
|
9
9
|
const { suggestionCount } = require('./roster-suggest');
|
|
10
10
|
|
|
@@ -75,75 +75,6 @@ function bootTeamLine(root = process.cwd(), { width = 69, now = new Date(), jobs
|
|
|
75
75
|
return `${builtIn.join(' · ')}${marker}`;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
function runsText(view) {
|
|
79
|
-
return view && view.text ? view.text : 'no ready tool';
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// "who does each job" lines: the tool and model running each job now, its
|
|
83
|
-
// backup, and a short note when the router or a backup is standing in.
|
|
84
|
-
function renderJobLines(jobs, { width = 80 } = {}) {
|
|
85
|
-
const rows = orderJobs(jobs);
|
|
86
|
-
if (!rows.length) return [];
|
|
87
|
-
const labelWidth = Math.max(...rows.map((row) => row.job.length));
|
|
88
|
-
return rows.map((row) => {
|
|
89
|
-
const head = ` ${row.job.padEnd(labelWidth)} ${runsText(row.now_runs)}`;
|
|
90
|
-
let note = '';
|
|
91
|
-
if (!row.pick) note = row.like ? `, same as ${row.like}` : ', picked by the router';
|
|
92
|
-
else if (row.lead === 'first' && row.backup_runs) note = `, backup ${row.backup_runs.text}`;
|
|
93
|
-
else if (row.lead !== 'first') note = row.status === 'expired' ? ', first pick expired' : ', first pick not ready';
|
|
94
|
-
const line = `${head}${note}`;
|
|
95
|
-
return line.length <= width || row.lead !== 'first' ? line : head;
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Names under each job, wrapped to the width. A member running something
|
|
100
|
-
// other than its job's tool and model says what it runs.
|
|
101
|
-
function renderMemberLines(team, jobs, { width = 80 } = {}) {
|
|
102
|
-
const members = Array.isArray(team) ? team : [];
|
|
103
|
-
if (!members.length) return [];
|
|
104
|
-
const rows = orderJobs(jobs);
|
|
105
|
-
const order = [...rows.map((row) => row.job)];
|
|
106
|
-
for (const member of members) if (member.job && !order.includes(member.job)) order.push(member.job);
|
|
107
|
-
const groups = order
|
|
108
|
-
.map((job) => ({ job, row: rows.find((row) => row.job === job) || null, names: members.filter((member) => member.job === job) }))
|
|
109
|
-
.filter((group) => group.names.length);
|
|
110
|
-
const labelWidth = Math.max(...groups.map((group) => group.job.length));
|
|
111
|
-
const indent = ' '.repeat(labelWidth + 4);
|
|
112
|
-
const lines = [];
|
|
113
|
-
for (const group of groups) {
|
|
114
|
-
const jobText = group.row && group.row.now_runs ? group.row.now_runs.text : '';
|
|
115
|
-
const names = group.names.map((member) => {
|
|
116
|
-
const text = member.engine ? engineRunsView(member.engine, { model: member.model || '', effort: member.effort || '' }).text : 'no ready tool';
|
|
117
|
-
return text === jobText ? member.member : `${member.member} on ${text}`;
|
|
118
|
-
});
|
|
119
|
-
let line = ` ${group.job.padEnd(labelWidth)} `;
|
|
120
|
-
let fresh = true;
|
|
121
|
-
for (const name of names) {
|
|
122
|
-
const piece = fresh ? name : `, ${name}`;
|
|
123
|
-
if (!fresh && line.length + piece.length + 1 > width) {
|
|
124
|
-
lines.push(`${line},`);
|
|
125
|
-
line = `${indent}${name}`;
|
|
126
|
-
} else {
|
|
127
|
-
line += piece;
|
|
128
|
-
}
|
|
129
|
-
fresh = false;
|
|
130
|
-
}
|
|
131
|
-
lines.push(line);
|
|
132
|
-
}
|
|
133
|
-
return lines;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// The block `atris team` prints before today's active and rest lists.
|
|
137
|
-
function renderLineup(lineup, { width = 80 } = {}) {
|
|
138
|
-
if (!lineup || !lineup.ok) return '';
|
|
139
|
-
const jobLines = renderJobLines(lineup.jobs, { width });
|
|
140
|
-
const memberLines = renderMemberLines(lineup.team, lineup.jobs, { width });
|
|
141
|
-
const blocks = [];
|
|
142
|
-
if (jobLines.length) blocks.push(['who does each job:', ...jobLines].join('\n'));
|
|
143
|
-
if (memberLines.length) blocks.push(['who is on each job:', ...memberLines].join('\n'));
|
|
144
|
-
return blocks.join('\n\n');
|
|
145
|
-
}
|
|
146
|
-
|
|
147
78
|
// Per member, for json: the job, tool, model, and whether a roster line or
|
|
148
79
|
// the automatic match decided it. null for a name the lineup does not know.
|
|
149
80
|
function memberLineup(lineup, name) {
|
|
@@ -161,12 +92,20 @@ function memberLineup(lineup, name) {
|
|
|
161
92
|
};
|
|
162
93
|
}
|
|
163
94
|
|
|
95
|
+
// The two commands under both team tables: the real way to change a job's
|
|
96
|
+
// worker (assign takes a job, not a member) and the real way to start a
|
|
97
|
+
// member working (it runs on the member's roster engine).
|
|
98
|
+
const TEAM_FOOTER = Object.freeze([
|
|
99
|
+
'change who does a job: atris engine assign <job> <tool> --model <model>',
|
|
100
|
+
'launch a member: atris member run <member> "<goal>" --minutes 30',
|
|
101
|
+
]);
|
|
102
|
+
|
|
164
103
|
const LINEUP_UNREADABLE = 'could not read who does each job, so tools and models are not shown. try: atris engine roster';
|
|
165
104
|
|
|
166
105
|
module.exports = {
|
|
167
106
|
LINEUP_UNREADABLE,
|
|
107
|
+
TEAM_FOOTER,
|
|
168
108
|
bootTeamLine,
|
|
169
109
|
memberLineup,
|
|
170
110
|
readLineupSafe,
|
|
171
|
-
renderLineup,
|
|
172
111
|
};
|
package/lib/team-presence.js
CHANGED
|
@@ -262,10 +262,234 @@ function renderTeamPresence(presence) {
|
|
|
262
262
|
return lines.join('\n');
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
// --- who really worked this week ------------------------------------------
|
|
266
|
+
//
|
|
267
|
+
// "Awake" above is a 15-minute window. A member that did real work yesterday
|
|
268
|
+
// is not awake, but it is active. Activity reads evidence that already sits
|
|
269
|
+
// on disk: dated files in atris/team/<member>/logs/, commits that name the
|
|
270
|
+
// member, runs the roster recorded for the member, and the same stream
|
|
271
|
+
// events presence reads (task claims, mission receipts, worktree commits),
|
|
272
|
+
// over a wider window. Each piece of evidence counts as one run on its day.
|
|
273
|
+
// A member with evidence in the last 7 days is active; 8 to 14 days is
|
|
274
|
+
// quiet; nothing in 14 days is idle.
|
|
275
|
+
|
|
276
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
277
|
+
const ACTIVE_DAYS = 7;
|
|
278
|
+
const IDLE_AFTER_DAYS = 14;
|
|
279
|
+
const FAILED_RUN_OUTCOMES = new Set(['failed', 'stalled', 'credit out']);
|
|
280
|
+
|
|
281
|
+
function localDay(ms) {
|
|
282
|
+
const date = new Date(ms);
|
|
283
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
284
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// The first real YYYY-MM-DD in a log file name, as a local day ('' if none).
|
|
288
|
+
function logFileDay(name) {
|
|
289
|
+
const match = /(\d{4})-(\d{2})-(\d{2})/.exec(String(name || ''));
|
|
290
|
+
if (!match) return '';
|
|
291
|
+
const [year, month, day] = match.slice(1).map(Number);
|
|
292
|
+
const date = new Date(year, month - 1, day);
|
|
293
|
+
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return '';
|
|
294
|
+
return match[0];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// `git log --format=%x1e%aI%x1f%B` output, read once for the whole team.
|
|
298
|
+
function parseGitLog(text) {
|
|
299
|
+
return String(text || '').split('\x1e').map((chunk) => {
|
|
300
|
+
const [at, ...rest] = chunk.split('\x1f');
|
|
301
|
+
const message = rest.join('\x1f').trim();
|
|
302
|
+
const ms = timestampMs(String(at || '').trim());
|
|
303
|
+
if (!ms || !message) return null;
|
|
304
|
+
const [subject, ...body] = message.split(/\r?\n/);
|
|
305
|
+
return { ms, subject: subject.trim(), body: body.join('\n') };
|
|
306
|
+
}).filter(Boolean);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Which members a commit names, and whether it is a revert. Plain words are
|
|
310
|
+
// not enough: member names like "notes", "sites", and "builder" also show up
|
|
311
|
+
// in ordinary messages and in the atris-builder co-author line. A commit
|
|
312
|
+
// names a member through a "Member: <name>" or "Atris-Member: <name>"
|
|
313
|
+
// trailer, a co-author whose display name is the member, or a subject that
|
|
314
|
+
// starts with "<name>:" or "<name>(scope):". A revert names whoever the
|
|
315
|
+
// reverted subject named.
|
|
316
|
+
function commitMembers(commit, known) {
|
|
317
|
+
const names = new Set();
|
|
318
|
+
const check = (value) => {
|
|
319
|
+
const key = String(value || '').trim().toLowerCase();
|
|
320
|
+
if (key && known.has(key)) names.add(key);
|
|
321
|
+
};
|
|
322
|
+
let subject = String(commit.subject || '');
|
|
323
|
+
const revert = /^revert\s+"(.+)"\s*$/i.exec(subject);
|
|
324
|
+
if (revert) subject = revert[1];
|
|
325
|
+
const prefix = /^([a-z0-9][a-z0-9_.-]*)(?:\([^)]*\))?:\s/i.exec(subject);
|
|
326
|
+
if (prefix) check(prefix[1]);
|
|
327
|
+
for (const line of String(commit.body || '').split(/\r?\n/)) {
|
|
328
|
+
const trailer = /^\s*(?:atris-)?member:\s*([a-z0-9_.-]+)\s*$/i.exec(line);
|
|
329
|
+
if (trailer) check(trailer[1]);
|
|
330
|
+
const coauthor = /^\s*co-authored-by:\s*([^<]+?)\s*</i.exec(line);
|
|
331
|
+
if (coauthor) check(coauthor[1]);
|
|
332
|
+
}
|
|
333
|
+
return { names: [...names], reverted: Boolean(revert) };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function emptyDay() {
|
|
337
|
+
return { runs: 0, landed: 0, failed: 0, reverted: 0, engines: {}, models: {} };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function topKey(counts) {
|
|
341
|
+
let best = '';
|
|
342
|
+
for (const [key, count] of Object.entries(counts || {})) {
|
|
343
|
+
if (!best || count > counts[best]) best = key;
|
|
344
|
+
}
|
|
345
|
+
return best || null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// The busiest engine and the busiest model among that engine's own runs, so
|
|
349
|
+
// the pair always names something that actually ran together.
|
|
350
|
+
function enginePair(engines, models) {
|
|
351
|
+
const engine = topKey(engines);
|
|
352
|
+
const own = Object.fromEntries(Object.entries(models || {}).filter(([key]) => !engine || key.startsWith(`${engine}\u0000`)));
|
|
353
|
+
const modelKey = topKey(own);
|
|
354
|
+
return { engine, model: modelKey ? modelKey.split('\u0000')[1] : null };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// One activity row per member, from evidence gathered by the caller:
|
|
358
|
+
// logDays: { member: ['2026-09-28', ...] }
|
|
359
|
+
// commits: parsed git log rows ({ ms, subject, body })
|
|
360
|
+
// runs: roster run records ({ at, member, engine, model, outcome })
|
|
361
|
+
// events: stream events ({ agent, ms | ts, summary })
|
|
362
|
+
function buildMemberActivity(input = {}) {
|
|
363
|
+
const nowMs = timestampMs(input.nowMs ?? input.now ?? Date.now()) || Date.now();
|
|
364
|
+
const names = [...new Set((input.members || []).map((name) => String(name || '').trim().toLowerCase()).filter(Boolean))];
|
|
365
|
+
const known = new Set(names);
|
|
366
|
+
const today = localDay(nowMs);
|
|
367
|
+
const activeSince = localDay(nowMs - (ACTIVE_DAYS - 1) * DAY_MS);
|
|
368
|
+
const quietSince = localDay(nowMs - (IDLE_AFTER_DAYS - 1) * DAY_MS);
|
|
369
|
+
const byMember = new Map(names.map((name) => [name, { days: {}, last: '', evidence: { logs: 0, commits: 0, runs: 0, events: 0 } }]));
|
|
370
|
+
const note = (member, day, source, apply) => {
|
|
371
|
+
const row = byMember.get(member);
|
|
372
|
+
if (!row || !day || day > today) return;
|
|
373
|
+
if (day > row.last) row.last = day;
|
|
374
|
+
if (day < quietSince) return;
|
|
375
|
+
const bucket = row.days[day] || (row.days[day] = emptyDay());
|
|
376
|
+
bucket.runs += 1;
|
|
377
|
+
row.evidence[source] += 1;
|
|
378
|
+
if (apply) apply(bucket);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
for (const [member, days] of Object.entries(input.logDays || {})) {
|
|
382
|
+
for (const day of days || []) note(String(member).toLowerCase(), logFileDay(day), 'logs');
|
|
383
|
+
}
|
|
384
|
+
for (const commit of input.commits || []) {
|
|
385
|
+
const { names: named, reverted } = commitMembers(commit, known);
|
|
386
|
+
for (const member of named) {
|
|
387
|
+
note(member, localDay(commit.ms), 'commits', (bucket) => {
|
|
388
|
+
if (reverted) bucket.reverted += 1;
|
|
389
|
+
else bucket.landed += 1;
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
for (const run of input.runs || []) {
|
|
394
|
+
const member = String(run.member || '').trim().toLowerCase();
|
|
395
|
+
const ms = timestampMs(run.at);
|
|
396
|
+
if (!member || !ms) continue;
|
|
397
|
+
note(member, localDay(ms), 'runs', (bucket) => {
|
|
398
|
+
if (run.outcome === 'landed') bucket.landed += 1;
|
|
399
|
+
else if (FAILED_RUN_OUTCOMES.has(run.outcome)) bucket.failed += 1;
|
|
400
|
+
if (run.engine) bucket.engines[run.engine] = (bucket.engines[run.engine] || 0) + 1;
|
|
401
|
+
if (run.model) bucket.models[`${run.engine || ''}\u0000${run.model}`] = (bucket.models[`${run.engine || ''}\u0000${run.model}`] || 0) + 1;
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
// A task or mission shows up as several stream events; one per summary
|
|
405
|
+
// per day counts.
|
|
406
|
+
const seenEvents = new Set();
|
|
407
|
+
for (const event of input.events || []) {
|
|
408
|
+
const member = memberName(event?.agent || event?.member || event?.owner).toLowerCase();
|
|
409
|
+
const ms = timestampMs(event?.ms || event?.ts || event?.at || event?.created_at);
|
|
410
|
+
if (!member || !ms || !known.has(member)) continue;
|
|
411
|
+
const day = localDay(ms);
|
|
412
|
+
const key = `${member}|${day}|${event.summary || event.event || ''}`;
|
|
413
|
+
if (seenEvents.has(key)) continue;
|
|
414
|
+
seenEvents.add(key);
|
|
415
|
+
note(member, day, 'events');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return names.map((name) => {
|
|
419
|
+
const row = byMember.get(name);
|
|
420
|
+
const week = Object.entries(row.days).filter(([day]) => day >= activeSince);
|
|
421
|
+
const sum = (field) => week.reduce((total, [, bucket]) => total + bucket[field], 0);
|
|
422
|
+
const engines = {};
|
|
423
|
+
const models = {};
|
|
424
|
+
for (const [, bucket] of week) {
|
|
425
|
+
for (const [key, count] of Object.entries(bucket.engines)) engines[key] = (engines[key] || 0) + count;
|
|
426
|
+
for (const [key, count] of Object.entries(bucket.models)) models[key] = (models[key] || 0) + count;
|
|
427
|
+
}
|
|
428
|
+
const { engine, model } = enginePair(engines, models);
|
|
429
|
+
const lastMs = row.last ? new Date(`${row.last}T12:00:00`).getTime() : 0;
|
|
430
|
+
const status = row.last >= activeSince ? 'active' : row.last >= quietSince ? 'quiet' : 'idle';
|
|
431
|
+
return {
|
|
432
|
+
name,
|
|
433
|
+
status,
|
|
434
|
+
last_active: row.last || null,
|
|
435
|
+
days_since: row.last ? Math.max(0, Math.round((new Date(`${today}T12:00:00`).getTime() - lastMs) / DAY_MS)) : null,
|
|
436
|
+
runs_7d: sum('runs'),
|
|
437
|
+
engine,
|
|
438
|
+
model,
|
|
439
|
+
landed: sum('landed'),
|
|
440
|
+
failed: sum('failed'),
|
|
441
|
+
reverted: sum('reverted'),
|
|
442
|
+
evidence: row.evidence,
|
|
443
|
+
days: Object.fromEntries(Object.entries(row.days).sort(([a], [b]) => a.localeCompare(b)).map(([day, bucket]) => [day, {
|
|
444
|
+
runs: bucket.runs,
|
|
445
|
+
...enginePair(bucket.engines, bucket.models),
|
|
446
|
+
landed: bucket.landed,
|
|
447
|
+
failed: bucket.failed,
|
|
448
|
+
reverted: bucket.reverted,
|
|
449
|
+
}])),
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// The first local day of the 7-day activity window.
|
|
455
|
+
function activeWindowStartDay(nowMs = Date.now()) {
|
|
456
|
+
return localDay((timestampMs(nowMs) || Date.now()) - (ACTIVE_DAYS - 1) * DAY_MS);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// One row per member per day with any evidence in the last 7 days, the
|
|
460
|
+
// shape the self-improvement loop reads from member_activity.jsonl.
|
|
461
|
+
function memberActivityRows(activity, nowMs = Date.now()) {
|
|
462
|
+
const since = activeWindowStartDay(nowMs);
|
|
463
|
+
const rows = [];
|
|
464
|
+
for (const member of activity || []) {
|
|
465
|
+
for (const [day, bucket] of Object.entries(member.days || {})) {
|
|
466
|
+
if (day < since || !bucket.runs) continue;
|
|
467
|
+
rows.push({
|
|
468
|
+
member: member.name,
|
|
469
|
+
day,
|
|
470
|
+
runs: bucket.runs,
|
|
471
|
+
engine: bucket.engine,
|
|
472
|
+
model: bucket.model,
|
|
473
|
+
landed: bucket.landed,
|
|
474
|
+
failed: bucket.failed,
|
|
475
|
+
reverted: bucket.reverted,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return rows.sort((a, b) => a.day.localeCompare(b.day) || a.member.localeCompare(b.member));
|
|
480
|
+
}
|
|
481
|
+
|
|
265
482
|
module.exports = {
|
|
266
483
|
ACTIVE_MISSION_STATES,
|
|
267
484
|
ACTIVE_TASK_STATES,
|
|
268
485
|
DEFAULT_FRESHNESS_WINDOW_MS,
|
|
486
|
+
IDLE_AFTER_DAYS,
|
|
487
|
+
activeWindowStartDay,
|
|
488
|
+
buildMemberActivity,
|
|
269
489
|
buildTeamPresence,
|
|
490
|
+
commitMembers,
|
|
491
|
+
logFileDay,
|
|
492
|
+
memberActivityRows,
|
|
493
|
+
parseGitLog,
|
|
270
494
|
renderTeamPresence,
|
|
271
495
|
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Plain aligned tables for terminal views: a header row, two spaces between
|
|
4
|
+
// columns, no borders or pipes. A cell may hold several lines; the extra
|
|
5
|
+
// lines sit under the first, in the same column. When the table is wider
|
|
6
|
+
// than the terminal, columns marked `clip` or `wrap` shrink (rightmost
|
|
7
|
+
// first): a clip column ends in "…", a wrap column carries on underneath.
|
|
8
|
+
// Every other column keeps its full width.
|
|
9
|
+
|
|
10
|
+
const GAP = ' ';
|
|
11
|
+
const ELLIPSIS = '…';
|
|
12
|
+
const FALLBACK_WIDTH = 100;
|
|
13
|
+
|
|
14
|
+
function terminalWidth(width) {
|
|
15
|
+
const value = Number(width || process.stdout.columns);
|
|
16
|
+
return Number.isFinite(value) && value >= 20 ? value : FALLBACK_WIDTH;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function clipText(text, width) {
|
|
20
|
+
const value = String(text);
|
|
21
|
+
if (value.length <= width) return value;
|
|
22
|
+
if (width <= 1) return ELLIPSIS.slice(0, Math.max(0, width));
|
|
23
|
+
return `${value.slice(0, width - 1).trimEnd()}${ELLIPSIS}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Word-wrap one line to a width; a word longer than the width is split.
|
|
27
|
+
function wrapText(text, width) {
|
|
28
|
+
const out = [];
|
|
29
|
+
let line = '';
|
|
30
|
+
for (let word of String(text).split(' ')) {
|
|
31
|
+
while (word.length > width) {
|
|
32
|
+
if (line) { out.push(line); line = ''; }
|
|
33
|
+
out.push(word.slice(0, width));
|
|
34
|
+
word = word.slice(width);
|
|
35
|
+
}
|
|
36
|
+
const next = line ? `${line} ${word}` : word;
|
|
37
|
+
if (next.length > width && line) { out.push(line); line = word; } else line = next;
|
|
38
|
+
}
|
|
39
|
+
out.push(line);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cellLines(value) {
|
|
44
|
+
if (Array.isArray(value)) {
|
|
45
|
+
const lines = value.filter((line) => line !== null && line !== undefined).map(String);
|
|
46
|
+
return lines.length ? lines : [''];
|
|
47
|
+
}
|
|
48
|
+
return [value === null || value === undefined ? '' : String(value)];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// columns: [{ header, clip?, min? }]; rows: arrays of cells, one per column.
|
|
52
|
+
// Returns the table as one string, each line trimmed at the end.
|
|
53
|
+
function renderTable(columns, rows, { width } = {}) {
|
|
54
|
+
const max = terminalWidth(width);
|
|
55
|
+
const cells = rows.map((row) => columns.map((_, index) => cellLines(row[index])));
|
|
56
|
+
const widths = columns.map((column, index) => Math.max(
|
|
57
|
+
String(column.header).length,
|
|
58
|
+
...cells.map((row) => Math.max(...row[index].map((line) => line.length))),
|
|
59
|
+
));
|
|
60
|
+
let over = widths.reduce((sum, value) => sum + value, 0) + GAP.length * (columns.length - 1) - max;
|
|
61
|
+
for (let index = columns.length - 1; index >= 0 && over > 0; index -= 1) {
|
|
62
|
+
if (!columns[index].clip && !columns[index].wrap) continue;
|
|
63
|
+
const floor = Math.max(String(columns[index].header).length, columns[index].min || 8);
|
|
64
|
+
const cut = Math.min(over, Math.max(0, widths[index] - floor));
|
|
65
|
+
widths[index] -= cut;
|
|
66
|
+
over -= cut;
|
|
67
|
+
}
|
|
68
|
+
const line = (parts) => parts
|
|
69
|
+
.map((text, index) => clipText(text, widths[index]).padEnd(index === parts.length - 1 ? 0 : widths[index]))
|
|
70
|
+
.join(GAP)
|
|
71
|
+
.trimEnd();
|
|
72
|
+
const out = [line(columns.map((column) => column.header))];
|
|
73
|
+
for (const raw of cells) {
|
|
74
|
+
const row = raw.map((lines, index) => (columns[index].wrap ? lines.flatMap((text) => wrapText(text, widths[index])) : lines));
|
|
75
|
+
const height = Math.max(...row.map((lines) => lines.length));
|
|
76
|
+
for (let at = 0; at < height; at += 1) out.push(line(row.map((lines) => lines[at] || '')));
|
|
77
|
+
}
|
|
78
|
+
return out.join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { FALLBACK_WIDTH, clipText, renderTable, terminalWidth };
|