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.
@@ -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 { engineRunsView, modelLabel } = require('./roster-models');
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
  };
@@ -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 };
@@ -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
- function rowActor(row) {
19
- return normalizedActor(row?.claimed_by || row?.metadata?.executed_by);
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 outcome = String(row?.outcome || row?.status || '').trim().toLowerCase();
27
- if (PASS_OUTCOMES.has(outcome)) return true;
28
- if (FAIL_OUTCOMES.has(outcome)) return false;
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
- function computeTrustTier(actor, root = process.cwd()) {
66
- const target = normalizedActor(actor);
67
- if (!target) return 'probation';
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 'probation';
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
- const outcomes = history
72
- .filter(({ row }) => rowActor(row) === target)
73
- .map((entry) => ({ ...entry, passed: rowPassed(entry.row) }))
74
- .filter(({ passed }) => passed !== null)
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
- module.exports = { computeTrustTier };
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 };