atris 3.64.0 → 3.66.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/atris/skills/engines/SKILL.md +2 -2
- package/bin/atris.js +45 -2
- package/commands/agent-spawn.js +1 -0
- package/commands/autoland.js +62 -0
- package/commands/brain.js +4 -0
- package/commands/engine.js +203 -63
- package/commands/hire.js +189 -0
- package/commands/link.js +367 -0
- package/commands/loops.js +4 -1
- package/commands/mail.js +163 -0
- package/commands/member.js +8 -1
- package/commands/mission.js +3 -2
- package/commands/orb.js +1 -1
- package/commands/stream.js +22 -1
- package/commands/team.js +355 -67
- package/commands/xp.js +2 -0
- package/commands/youtube.js +1 -1
- package/lib/auto-accept-certified.js +172 -18
- package/lib/business-target.js +131 -0
- package/lib/cleanup-summary.js +129 -0
- package/lib/engine-ask.js +3 -2
- package/lib/engine-processes.js +430 -0
- package/lib/engine-registry.js +219 -18
- package/lib/idle-review-sweep.js +130 -0
- package/lib/known-commands.js +2 -2
- package/lib/land-green.js +18 -4
- package/lib/member-identity.js +56 -0
- package/lib/member-result.js +303 -0
- package/lib/roster-models.js +12 -1
- package/lib/roster-stale.js +241 -0
- package/lib/runner-command.js +4 -4
- package/lib/task-list-keeper.js +25 -4
- package/lib/team-lineup.js +10 -71
- package/lib/team-presence.js +224 -0
- package/lib/text-table.js +81 -0
- package/lib/trust-tiers.js +131 -14
- package/lib/verify-worktree-rewrite.js +115 -0
- package/lib/voice-gate.js +2 -0
- package/package.json +1 -1
- package/scripts/det/ytnotes +1 -1
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 };
|
package/lib/trust-tiers.js
CHANGED
|
@@ -7,6 +7,10 @@ const HISTORY_FILES = [
|
|
|
7
7
|
path.join('.atris', 'state', 'career_xp_receipts.jsonl'),
|
|
8
8
|
path.join('.atris', 'state', 'scorecards.jsonl'),
|
|
9
9
|
];
|
|
10
|
+
// Where a legacy history row's builder is recovered from: the row only names
|
|
11
|
+
// its task, and these name who built that task.
|
|
12
|
+
const EPISODES_FILE = path.join('.atris', 'state', 'task_episodes.jsonl');
|
|
13
|
+
const PROJECTION_FILE = path.join('.atris', 'state', 'tasks.projection.json');
|
|
10
14
|
|
|
11
15
|
const PASS_OUTCOMES = new Set(['accepted', 'approved', 'done', 'pass', 'passed', 'success', 'succeeded']);
|
|
12
16
|
const FAIL_OUTCOMES = new Set(['bounced', 'fail', 'failed', 'rejected', 'revised', 'rework_requested']);
|
|
@@ -15,17 +19,34 @@ function normalizedActor(value) {
|
|
|
15
19
|
return String(value || '').trim().toLowerCase();
|
|
16
20
|
}
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
// Trust belongs to whoever BUILT the work, never to whoever approved it.
|
|
23
|
+
// History rows record `actor` as the approving reviewer (one human on 1,029
|
|
24
|
+
// of 1,057 receipts in one busy project), so keying on it, or on fields the
|
|
25
|
+
// rows never carried, left every builder at zero history and "still earning
|
|
26
|
+
// trust" forever. `actor` is deliberately not read here.
|
|
27
|
+
function stampedBuilder(row) {
|
|
28
|
+
return normalizedActor(
|
|
29
|
+
row?.builder
|
|
30
|
+
|| row?.built_by
|
|
31
|
+
|| row?.claimed_by
|
|
32
|
+
|| row?.metadata?.built_by
|
|
33
|
+
|| row?.metadata?.executed_by,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rowTaskId(row) {
|
|
38
|
+
return String(row?.source_task_id || row?.task_id || '').trim();
|
|
20
39
|
}
|
|
21
40
|
|
|
22
41
|
function rowPassed(row) {
|
|
23
42
|
for (const value of [row?.passed, row?.verify_passed, row?.metadata?.verify_passed]) {
|
|
24
43
|
if (typeof value === 'boolean') return value;
|
|
25
44
|
}
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
45
|
+
for (const value of [row?.outcome, row?.status, row?.rl_label]) {
|
|
46
|
+
const outcome = String(value || '').trim().toLowerCase();
|
|
47
|
+
if (PASS_OUTCOMES.has(outcome)) return true;
|
|
48
|
+
if (FAIL_OUTCOMES.has(outcome)) return false;
|
|
49
|
+
}
|
|
29
50
|
return null;
|
|
30
51
|
}
|
|
31
52
|
|
|
@@ -37,6 +58,53 @@ function rowTime(row) {
|
|
|
37
58
|
return null;
|
|
38
59
|
}
|
|
39
60
|
|
|
61
|
+
function fileStamp(file) {
|
|
62
|
+
try {
|
|
63
|
+
const stat = fs.statSync(file);
|
|
64
|
+
return `${stat.size}:${stat.mtimeMs}`;
|
|
65
|
+
} catch {
|
|
66
|
+
return 'missing';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function eachJsonLine(file, fn) {
|
|
71
|
+
let text;
|
|
72
|
+
try {
|
|
73
|
+
text = fs.readFileSync(file, 'utf8');
|
|
74
|
+
} catch {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const line of text.split(/\r?\n/)) {
|
|
78
|
+
if (!line.trim()) continue;
|
|
79
|
+
try {
|
|
80
|
+
const row = JSON.parse(line);
|
|
81
|
+
if (row && typeof row === 'object' && !Array.isArray(row)) fn(row);
|
|
82
|
+
} catch {
|
|
83
|
+
// A torn line in the episode log only costs that one lookup.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// task id -> builder, from the episode log (every review snapshot carries the
|
|
89
|
+
// task's built_by / claimed_by) and the live projection.
|
|
90
|
+
function readTaskBuilders(root) {
|
|
91
|
+
const builders = new Map();
|
|
92
|
+
const remember = (taskId, task) => {
|
|
93
|
+
const id = String(taskId || '').trim();
|
|
94
|
+
const builder = normalizedActor(task?.metadata?.built_by || task?.claimed_by);
|
|
95
|
+
if (id && builder) builders.set(id, builder);
|
|
96
|
+
};
|
|
97
|
+
eachJsonLine(path.join(root, EPISODES_FILE), (episode) => remember(episode.task_id, episode.state));
|
|
98
|
+
try {
|
|
99
|
+
const projection = JSON.parse(fs.readFileSync(path.join(root, PROJECTION_FILE), 'utf8'));
|
|
100
|
+
const tasks = Array.isArray(projection) ? projection : projection?.tasks;
|
|
101
|
+
for (const task of Array.isArray(tasks) ? tasks : []) remember(task?.id, task);
|
|
102
|
+
} catch {
|
|
103
|
+
// No projection: episode builders still apply.
|
|
104
|
+
}
|
|
105
|
+
return builders;
|
|
106
|
+
}
|
|
107
|
+
|
|
40
108
|
function readHistory(root) {
|
|
41
109
|
const rows = [];
|
|
42
110
|
let sequence = 0;
|
|
@@ -62,16 +130,58 @@ function readHistory(root) {
|
|
|
62
130
|
return rows;
|
|
63
131
|
}
|
|
64
132
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
133
|
+
// One outcome per review episode: a receipt and a scorecard written for the
|
|
134
|
+
// same episode are the same verdict, not two.
|
|
135
|
+
function builderOutcomes(root) {
|
|
68
136
|
const history = readHistory(root);
|
|
69
|
-
if (!history) return
|
|
137
|
+
if (!history) return null;
|
|
138
|
+
let builders = null;
|
|
139
|
+
const byBuilder = new Map();
|
|
140
|
+
const seenEpisodes = new Set();
|
|
141
|
+
for (const entry of history) {
|
|
142
|
+
const passed = rowPassed(entry.row);
|
|
143
|
+
if (passed === null) continue;
|
|
144
|
+
const episode = String(entry.row.source_episode_id || '').trim();
|
|
145
|
+
if (episode) {
|
|
146
|
+
if (seenEpisodes.has(episode)) continue;
|
|
147
|
+
seenEpisodes.add(episode);
|
|
148
|
+
}
|
|
149
|
+
let builder = stampedBuilder(entry.row);
|
|
150
|
+
if (!builder && rowTaskId(entry.row)) {
|
|
151
|
+
if (!builders) builders = readTaskBuilders(root);
|
|
152
|
+
builder = builders.get(rowTaskId(entry.row)) || '';
|
|
153
|
+
}
|
|
154
|
+
if (!builder) continue;
|
|
155
|
+
if (!byBuilder.has(builder)) byBuilder.set(builder, []);
|
|
156
|
+
byBuilder.get(builder).push({ ...entry, passed });
|
|
157
|
+
}
|
|
158
|
+
return byBuilder;
|
|
159
|
+
}
|
|
70
160
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
161
|
+
// Called once per waiting task on every status and tick, against a 12 MB
|
|
162
|
+
// episode log; memoize per root until one of the files changes.
|
|
163
|
+
const outcomeCache = new Map();
|
|
164
|
+
|
|
165
|
+
function cachedBuilderOutcomes(root) {
|
|
166
|
+
const resolved = path.resolve(root);
|
|
167
|
+
const stamp = [...HISTORY_FILES, EPISODES_FILE, PROJECTION_FILE]
|
|
168
|
+
.map((relative) => fileStamp(path.join(resolved, relative)))
|
|
169
|
+
.join('|');
|
|
170
|
+
const hit = outcomeCache.get(resolved);
|
|
171
|
+
if (hit && hit.stamp === stamp) return hit.value;
|
|
172
|
+
const value = builderOutcomes(resolved);
|
|
173
|
+
outcomeCache.set(resolved, { stamp, value });
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function computeTrustTier(builder, root = process.cwd()) {
|
|
178
|
+
const target = normalizedActor(builder);
|
|
179
|
+
if (!target) return 'probation';
|
|
180
|
+
const byBuilder = cachedBuilderOutcomes(root);
|
|
181
|
+
if (!byBuilder) return 'probation';
|
|
182
|
+
|
|
183
|
+
const outcomes = (byBuilder.get(target) || [])
|
|
184
|
+
.slice()
|
|
75
185
|
.sort((a, b) => {
|
|
76
186
|
if (a.time !== null && b.time !== null && a.time !== b.time) return a.time - b.time;
|
|
77
187
|
if (a.time !== null && b.time === null) return 1;
|
|
@@ -87,4 +197,11 @@ function computeTrustTier(actor, root = process.cwd()) {
|
|
|
87
197
|
return 'probation';
|
|
88
198
|
}
|
|
89
199
|
|
|
90
|
-
|
|
200
|
+
// The builder of a task: the stamped builder first, then whoever claimed it.
|
|
201
|
+
// Never the approver.
|
|
202
|
+
function taskTrustSubject(task) {
|
|
203
|
+
const metadata = task?.metadata || {};
|
|
204
|
+
return normalizedActor(metadata.built_by || task?.claimed_by || metadata.executed_by) || null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { computeTrustTier, taskTrustSubject };
|