atris 3.65.0 → 3.67.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/lib/land-green.js CHANGED
@@ -23,6 +23,9 @@ const { runGit } = require('./git-spawn');
23
23
 
24
24
  const DEFAULT_VERIFY = 'npm test';
25
25
  const DEFAULT_VERIFY_TIMEOUT_MS = 20 * 60 * 1000;
26
+ // Stop the walk after this many consecutive branches that landed nothing, so
27
+ // a queue of erroring branches does not burn a scratch checkout per branch.
28
+ const DEFAULT_MAX_MISSES = 4;
26
29
  const FAILING_LINE = /^(?:✖|not ok)\s+(.+?)(?:\s+\(\d+(?:\.\d+)?ms\))?\s*$/;
27
30
 
28
31
  function gitOut(args, cwd) {
@@ -291,6 +294,7 @@ function landStaleGreenBranches(root, {
291
294
  ttlDays,
292
295
  base: baseOverride = '',
293
296
  limit = 1,
297
+ maxMisses = DEFAULT_MAX_MISSES,
294
298
  now = Date.now(),
295
299
  verifyCommand = DEFAULT_VERIFY,
296
300
  verifyTimeoutMs = DEFAULT_VERIFY_TIMEOUT_MS,
@@ -307,19 +311,29 @@ function landStaleGreenBranches(root, {
307
311
  .filter((b) => b.state === 'active' && b.stale)
308
312
  .sort((a, b) => b.activityHours - a.activityHours);
309
313
  const verdicts = [];
310
- let attempts = 0;
314
+ // Only landed and red verdicts spend the per-tick budget: an erroring or
315
+ // ineligible branch must not starve the rest of the queue. A bounded run of
316
+ // misses still stops the walk so a fully broken queue does not burn a
317
+ // scratch checkout per branch per tick.
318
+ let spent = 0;
319
+ let misses = 0;
311
320
  for (const candidate of candidates) {
312
- if (attempts >= limit) {
313
- verdicts.push(verdictFor(candidate.name, live.base, { action: 'skipped', reason: 'per_tick_limit' }));
321
+ if (spent >= limit || misses >= maxMisses) {
322
+ verdicts.push(verdictFor(candidate.name, live.base, { action: 'skipped', reason: spent >= limit ? 'per_tick_limit' : 'attempt_cap' }));
314
323
  continue;
315
324
  }
316
- attempts += 1;
317
325
  let verdict;
318
326
  try {
319
327
  verdict = landOneBranch(root, live.base, candidate.name, { verifyCommand, verifyTimeoutMs, dryRun, push, runVerifyFn, env });
320
328
  } catch (err) {
321
329
  verdict = verdictFor(candidate.name, live.base, { action: 'error', reason: 'exception', detail: String((err && err.message) || err).slice(0, 300) });
322
330
  }
331
+ if (verdict.action === 'landed' || verdict.action === 'red') {
332
+ spent += 1;
333
+ misses = 0;
334
+ } else {
335
+ misses += 1;
336
+ }
323
337
  if (verdict.action === 'red' && openFlags) {
324
338
  try {
325
339
  const flagged = openRedFlag(root, verdict, { runCli });
@@ -0,0 +1,303 @@
1
+ 'use strict';
2
+
3
+ // atris member result <member> [--since <iso-time>] [--json]
4
+ //
5
+ // Reads back the newest real result of a member run as one object: the run
6
+ // line from the roster's run record, the newest receipt in the member's
7
+ // logs/, the files it made under work/, and one result number when the
8
+ // receipt states one plainly. Read only.
9
+ //
10
+ // A member run usually works in its own git worktree, so its receipt and
11
+ // files land there, not in the main checkout. The run's mission id leads to
12
+ // that worktree; both places are read, newest file wins.
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ const { readRosterRuns } = require('./roster-runs');
18
+
19
+ const MEMBER_NAME_RE = /^[a-zA-Z0-9._-]+$/;
20
+ const RUN_DAYS = 90;
21
+ const RECEIPT_LINES = 40;
22
+ const ARTIFACT_LINES = 20;
23
+ const MAX_ARTIFACTS = 50;
24
+ const MAX_WALK_FILES = 2000;
25
+ const HEAD_READ_BYTES = 64 * 1024;
26
+ const SKIP_DIRS = new Set(['.git', 'node_modules', '__pycache__', '.venv', 'venv']);
27
+
28
+ function usage() {
29
+ return [
30
+ 'usage:',
31
+ ' atris member result <member> [--since <iso-time>] [--json]',
32
+ '',
33
+ 'newest result of a member run: run record, receipt, artifacts, and one result number.',
34
+ '',
35
+ 'options:',
36
+ ' --since <iso-time> only files changed after this time (default: start of the newest run)',
37
+ ' --json print the result as json',
38
+ ].join('\n');
39
+ }
40
+
41
+ function parseResultArgs(args = []) {
42
+ let member = '';
43
+ let since = '';
44
+ let json = false;
45
+ let help = false;
46
+ for (let index = 0; index < args.length; index += 1) {
47
+ const arg = String(args[index]);
48
+ if (arg === '--help' || arg === '-h') { help = true; continue; }
49
+ if (arg === '--json') { json = true; continue; }
50
+ if (arg === '--since') {
51
+ if (index + 1 >= args.length) throw new Error('--since needs an iso time');
52
+ since = String(args[index + 1]);
53
+ index += 1;
54
+ continue;
55
+ }
56
+ if (arg.startsWith('--since=')) { since = arg.slice('--since='.length); continue; }
57
+ if (arg.startsWith('--')) throw new Error(`unknown option ${arg}`);
58
+ if (member) throw new Error(usage());
59
+ member = arg.trim();
60
+ }
61
+ if (help) return { help: true, json };
62
+ if (!member) throw new Error(usage());
63
+ if (!MEMBER_NAME_RE.test(member)) throw new Error(`"${member}" is not a member name`);
64
+ if (since && !Number.isFinite(Date.parse(since))) throw new Error(`--since "${since}" is not a time; use an iso time like 2026-10-02T18:00:00Z`);
65
+ return { help: false, member, since, json };
66
+ }
67
+
68
+ // The member's newest run line. `id` is the mission id the run worked on.
69
+ function newestMemberRun(root, member, { now } = {}) {
70
+ const runs = readRosterRuns(root, { now, days: RUN_DAYS })
71
+ .filter((run) => run.member === member && Number.isFinite(Date.parse(run.at)));
72
+ if (!runs.length) return null;
73
+ runs.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
74
+ const run = runs[runs.length - 1];
75
+ const startedMs = Date.parse(run.at);
76
+ const seconds = Number.isFinite(Number(run.seconds)) ? Number(run.seconds) : null;
77
+ return {
78
+ id: run.task || null,
79
+ started: new Date(startedMs).toISOString(),
80
+ ended: seconds === null ? null : new Date(startedMs + seconds * 1000).toISOString(),
81
+ status: run.outcome,
82
+ engine: run.engine,
83
+ model: run.model || null,
84
+ seconds,
85
+ ...(run.detail ? { detail: run.detail } : {}),
86
+ };
87
+ }
88
+
89
+ // Where the run's mission lives when that is a sibling worktree, else null.
90
+ function runWorktreeRoot(root, missionId, deps = {}) {
91
+ if (!missionId) return null;
92
+ try {
93
+ const listRolled = deps.listWorktreeRollupMissions
94
+ || require('../commands/mission').listWorktreeRollupMissions;
95
+ const found = listRolled(root).find((mission) => mission && mission.id === missionId);
96
+ if (!found || !found.worktree_root || !fs.existsSync(found.worktree_root)) return null;
97
+ return path.resolve(found.worktree_root);
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ // Files under dir changed after sinceMs, newest first. Bounded walk.
104
+ function changedFiles(dir, sinceMs) {
105
+ const out = [];
106
+ const stack = [dir];
107
+ let seen = 0;
108
+ while (stack.length && seen < MAX_WALK_FILES) {
109
+ const current = stack.pop();
110
+ let entries;
111
+ try {
112
+ entries = fs.readdirSync(current, { withFileTypes: true });
113
+ } catch {
114
+ continue;
115
+ }
116
+ for (const entry of entries) {
117
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue;
118
+ const full = path.join(current, entry.name);
119
+ if (entry.isDirectory()) { stack.push(full); continue; }
120
+ if (!entry.isFile()) continue;
121
+ seen += 1;
122
+ let stat;
123
+ try {
124
+ stat = fs.statSync(full);
125
+ } catch {
126
+ continue;
127
+ }
128
+ if (sinceMs !== null && stat.mtimeMs <= sinceMs) continue;
129
+ out.push({ path: full, size: stat.size, mtimeMs: stat.mtimeMs });
130
+ }
131
+ }
132
+ return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
133
+ }
134
+
135
+ // First n lines when the file reads as text, else null.
136
+ function headLines(file, count) {
137
+ let fd;
138
+ try {
139
+ fd = fs.openSync(file, 'r');
140
+ const buffer = Buffer.alloc(HEAD_READ_BYTES);
141
+ const bytes = fs.readSync(fd, buffer, 0, HEAD_READ_BYTES, 0);
142
+ const chunk = buffer.subarray(0, bytes);
143
+ if (chunk.includes(0)) return null;
144
+ return chunk.toString('utf8').split(/\r?\n/).slice(0, count).join('\n').replace(/\s+$/, '');
145
+ } catch {
146
+ return null;
147
+ } finally {
148
+ if (fd !== undefined) try { fs.closeSync(fd); } catch {}
149
+ }
150
+ }
151
+
152
+ const COUNT_WORDS = [
153
+ 'tests?', 'passed', 'checks?', 'files?', 'items?', 'rows?', 'leads?', 'replies', 'emails?', 'messages?',
154
+ 'users?', 'customers?', 'signups?', 'clips?', 'shots?', 'scenes?', 'bugs?', 'errors?', 'issues?', 'tasks?',
155
+ 'wins?', 'deals?', 'meetings?', 'calls?', 'views?', 'sales', 'orders?', 'bets?', 'pages?', 'questions?', 'answers?',
156
+ ].join('|');
157
+
158
+ // One result number when the receipt states one plainly: a single distinct
159
+ // percent, else a single dollar amount, else a single count of things. Two
160
+ // different values of the same kind means none is the obvious one.
161
+ function obviousNumber(text) {
162
+ const body = String(text || '');
163
+ const kinds = [
164
+ {
165
+ kind: 'percent',
166
+ re: /(?<![\w.$])(\d+(?:\.\d+)?)\s?(%|percent\b)/gi,
167
+ value: (match) => Number(match[1]),
168
+ },
169
+ {
170
+ kind: 'dollars',
171
+ re: /\$\s?(\d[\d,]*(?:\.\d+)?)(\s?[kKmM]\b)?/g,
172
+ value: (match) => {
173
+ const base = Number(match[1].replace(/,/g, ''));
174
+ const unit = String(match[2] || '').trim().toLowerCase();
175
+ return unit === 'k' ? base * 1000 : unit === 'm' ? base * 1000000 : base;
176
+ },
177
+ },
178
+ {
179
+ kind: 'count',
180
+ re: new RegExp(`(?<![\\w.:/#-])(\\d[\\d,]*)\\s+(?:of\\s+\\d[\\d,]*\\s+)?(?:${COUNT_WORDS})\\b`, 'gi'),
181
+ value: (match) => Number(match[1].replace(/,/g, '')),
182
+ },
183
+ ];
184
+ for (const { kind, re, value } of kinds) {
185
+ const found = new Map();
186
+ for (const match of body.matchAll(re)) {
187
+ const number = value(match);
188
+ if (!Number.isFinite(number)) continue;
189
+ if (!found.has(number)) found.set(number, match[0].trim());
190
+ }
191
+ if (found.size === 1) {
192
+ const [[number, textValue]] = [...found.entries()];
193
+ return { value: number, text: textValue, kind };
194
+ }
195
+ if (found.size > 1) return null;
196
+ }
197
+ return null;
198
+ }
199
+
200
+ function memberResult(root, member, { since = '', now, deps = {} } = {}) {
201
+ const run = newestMemberRun(root, member, { now });
202
+ const worktree = run ? runWorktreeRoot(root, run.id, deps) : null;
203
+ const roots = [...new Set([worktree, path.resolve(root)].filter(Boolean))];
204
+ const memberHere = roots.some((dir) => fs.existsSync(path.join(dir, 'atris', 'team', member)));
205
+ if (!run && !memberHere) return { ok: false, error: `no member named ${member} here, and no run of one on record` };
206
+
207
+ const sinceIso = since ? new Date(Date.parse(since)).toISOString() : (run ? run.started : null);
208
+ const sinceMs = sinceIso ? Date.parse(sinceIso) : null;
209
+
210
+ const logs = roots.flatMap((dir) => changedFiles(path.join(dir, 'atris', 'team', member, 'logs'), sinceMs));
211
+ logs.sort((a, b) => b.mtimeMs - a.mtimeMs);
212
+ const newestLog = logs[0] || null;
213
+ const receipt = newestLog ? {
214
+ path: newestLog.path,
215
+ modified: new Date(newestLog.mtimeMs).toISOString(),
216
+ head: headLines(newestLog.path, RECEIPT_LINES),
217
+ } : null;
218
+
219
+ // The member's work/ in every root; a run's own worktree also counts its
220
+ // top-level work/, since nothing else writes into that checkout.
221
+ const workDirs = roots.map((dir) => path.join(dir, 'atris', 'team', member, 'work'));
222
+ if (worktree) workDirs.push(path.join(worktree, 'work'));
223
+ const files = workDirs.flatMap((dir) => changedFiles(dir, sinceMs));
224
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs);
225
+ const artifacts = files.slice(0, MAX_ARTIFACTS).map((file) => ({
226
+ path: file.path,
227
+ size: file.size,
228
+ modified: new Date(file.mtimeMs).toISOString(),
229
+ head: headLines(file.path, ARTIFACT_LINES),
230
+ }));
231
+
232
+ const number = receipt ? obviousNumber(receipt.head) : null;
233
+ return {
234
+ ok: true,
235
+ value: {
236
+ member,
237
+ since: sinceIso,
238
+ run,
239
+ worktree,
240
+ receipt,
241
+ artifacts,
242
+ artifacts_more: Math.max(0, files.length - MAX_ARTIFACTS),
243
+ number: number ? number.value : null,
244
+ number_text: number ? number.text : null,
245
+ },
246
+ };
247
+ }
248
+
249
+ function printHuman(value) {
250
+ const { run } = value;
251
+ console.log('');
252
+ if (run) {
253
+ const model = run.model ? ` (${run.model})` : '';
254
+ const took = run.seconds === null ? '' : `, ${Math.round(run.seconds)}s`;
255
+ console.log(`${value.member}: last run ${run.status}, started ${run.started}${took}, on ${run.engine}${model}`);
256
+ if (run.id) console.log(` run: ${run.id}`);
257
+ } else {
258
+ console.log(`${value.member}: no run on record in the last ${RUN_DAYS} days`);
259
+ }
260
+ if (value.worktree) console.log(` worked in: ${value.worktree}`);
261
+ console.log(` since: ${value.since || 'any time'}`);
262
+ if (value.receipt) {
263
+ console.log(`\nreceipt: ${value.receipt.path}`);
264
+ const lines = String(value.receipt.head || '').split('\n').slice(0, 12);
265
+ for (const line of lines) console.log(` ${line}`);
266
+ } else {
267
+ console.log('\nreceipt: none written since then');
268
+ }
269
+ console.log(`\nartifacts: ${value.artifacts.length}${value.artifacts_more ? ` (+${value.artifacts_more} more)` : ''}`);
270
+ for (const file of value.artifacts) console.log(` ${file.path} (${file.size} bytes)`);
271
+ console.log(`\nnumber: ${value.number_text || 'none stated plainly'}\n`);
272
+ }
273
+
274
+ function memberResultCommand(args = [], deps = {}) {
275
+ const root = deps.root || process.cwd();
276
+ let parsed;
277
+ try {
278
+ parsed = parseResultArgs(args);
279
+ } catch (error) {
280
+ console.error(`member result: ${error.message}`);
281
+ return 2;
282
+ }
283
+ if (parsed.help) {
284
+ console.log(usage());
285
+ return 0;
286
+ }
287
+ const result = memberResult(root, parsed.member, { since: parsed.since, now: deps.now, deps });
288
+ if (!result.ok) {
289
+ if (parsed.json) console.log(JSON.stringify({ ok: false, error: result.error }, null, 2));
290
+ else console.error(`member result: ${result.error}`);
291
+ return 1;
292
+ }
293
+ if (parsed.json) console.log(JSON.stringify(result.value, null, 2));
294
+ else printHuman(result.value);
295
+ return 0;
296
+ }
297
+
298
+ module.exports = {
299
+ memberResult,
300
+ memberResultCommand,
301
+ obviousNumber,
302
+ parseResultArgs,
303
+ };
@@ -124,7 +124,7 @@ function runsDetail(view) {
124
124
  // keeps a cache of the models it can run; claude takes the names atris knows;
125
125
  // every other engine lists its seed models.
126
126
 
127
- const KNOWN_CLAUDE_MODELS = Object.freeze(['opus 5.5', 'opus 5', 'sonnet 5', 'haiku 4.5', 'fable 5.1']);
127
+ const KNOWN_CLAUDE_MODELS = Object.freeze(['opus 5.5', 'opus 5', 'sonnet 5', 'haiku 5.5', 'fable 5.1']);
128
128
 
129
129
  // ~/.codex/models_cache.json unless a path is given. Test runs never read the
130
130
  // real file unless they point at one on purpose.
@@ -71,7 +71,7 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
71
71
  }),
72
72
  haiku: Object.freeze({
73
73
  bin: 'claude',
74
- model: 'claude-haiku-4-5',
74
+ model: 'claude-haiku-5-5',
75
75
  commandTemplate: '',
76
76
  effortFlag: '--effort',
77
77
  efforts: CLAUDE_EFFORTS,
@@ -230,12 +230,12 @@ function ownModelsOnlyEngineForBin(bin) {
230
230
  // vendor's models drops a claude model name (a mission's stored default, a
231
231
  // claude env pin) and rides its own default instead.
232
232
  function runnerModelFor(engineId, model) {
233
- const value = String(model || '').trim();
233
+ // Required here, not at the top: engine-registry requires this file.
234
+ const { isClaudeFamilyModel, currentClaudeModel } = require('./engine-registry');
235
+ const value = currentClaudeModel(String(model || '').trim());
234
236
  const name = String(engineId || '').trim().toLowerCase();
235
237
  const def = RUNNER_PROFILE_DEFS[RUNNER_PROFILE_ALIASES[name] || name];
236
238
  if (!value || !def || !def.ownModelsOnly) return value;
237
- // Required here, not at the top: engine-registry requires this file.
238
- const { isClaudeFamilyModel } = require('./engine-registry');
239
239
  return isClaudeFamilyModel(value) ? '' : value;
240
240
  }
241
241
 
@@ -6,6 +6,7 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  const taskDb = require('./task-db');
9
+ const { protectedLaneOf } = require('./auto-accept-certified');
9
10
 
10
11
  const DAY_MS = 24 * 60 * 60 * 1000;
11
12
  const OPEN_STATUSES = new Set(['open', 'claimed', 'review']);
@@ -57,12 +58,28 @@ function actorName(actor) {
57
58
  return actor || process.env.ATRIS_AGENT_ID || process.env.USER || 'task-list';
58
59
  }
59
60
 
60
- function decisionFor(row, now) {
61
+ function parsedMetadata(row) {
62
+ let metadata = row && row.metadata;
63
+ if (typeof metadata === 'string') {
64
+ try { metadata = JSON.parse(metadata); } catch { metadata = null; }
65
+ }
66
+ return metadata && typeof metadata === 'object' ? metadata : {};
67
+ }
68
+
69
+ // Money, deploys, security, customer, and outward work is the human's call.
70
+ // Sitting still is not a decision, so idle time never puts it away.
71
+ function isProtectedRow(row) {
72
+ return Boolean(protectedLaneOf({ ...row, metadata: parsedMetadata(row) }));
73
+ }
74
+
75
+ function decisionFor(row, now, { includeDone = true } = {}) {
61
76
  if (!row || row.status === 'archived') return null;
62
77
  if (row.status === 'done') {
78
+ if (!includeDone) return null;
63
79
  return { reason: REASONS.done, fromDone: true, fromFailed: false };
64
80
  }
65
81
  if (!Object.prototype.hasOwnProperty.call(IDLE_LIMITS_MS, row.status)) return null;
82
+ if (OPEN_STATUSES.has(row.status) && isProtectedRow(row)) return null;
66
83
  const at = Number(row.updated_at || row.created_at || 0);
67
84
  if (!((now - at) > IDLE_LIMITS_MS[row.status])) return null;
68
85
  return { reason: REASONS[row.status], fromDone: false, fromFailed: row.status === 'failed' };
@@ -112,7 +129,10 @@ function noteKeep(workspaceRoot, actor, count) {
112
129
  }
113
130
  }
114
131
 
115
- function keepTaskList(db, { workspaceRoot, actor, now = Date.now(), missions } = {}) {
132
+ // includeDone:false leaves finished rows on the list: the hourly tick must
133
+ // not hide work it just landed from the digest and mission checks that read
134
+ // done rows later in the same tick. Boot and `task keep` still put them away.
135
+ function keepTaskList(db, { workspaceRoot, actor, now = Date.now(), missions, includeDone = true } = {}) {
116
136
  const who = actorName(actor);
117
137
  const seen = new Set();
118
138
  const toArchive = [];
@@ -120,7 +140,7 @@ function keepTaskList(db, { workspaceRoot, actor, now = Date.now(), missions } =
120
140
  for (const row of selectCandidates(db, workspaceRoot, now)) {
121
141
  if (!row || seen.has(row.id)) continue;
122
142
  seen.add(row.id);
123
- const decision = decisionFor(row, now);
143
+ const decision = decisionFor(row, now, { includeDone });
124
144
  if (decision) {
125
145
  toArchive.push({ id: row.id, previous_status: row.status, ...decision });
126
146
  continue;
@@ -171,7 +191,7 @@ function keepTaskList(db, { workspaceRoot, actor, now = Date.now(), missions } =
171
191
  return { put_away: putAway, reaped };
172
192
  }
173
193
 
174
- function keepWorkspaceTaskList(cwd = process.cwd(), { actor } = {}) {
194
+ function keepWorkspaceTaskList(cwd = process.cwd(), { actor, includeDone = true } = {}) {
175
195
  const dbPath = taskDb.getDbPath();
176
196
  if (!dbPath || !fs.existsSync(dbPath)) {
177
197
  return { put_away: [], reaped: [], skipped: 'no_db' };
@@ -181,6 +201,7 @@ function keepWorkspaceTaskList(cwd = process.cwd(), { actor } = {}) {
181
201
  workspaceRoot: taskDb.workspaceRoot(cwd),
182
202
  actor,
183
203
  now: Date.now(),
204
+ includeDone,
184
205
  });
185
206
  }
186
207
 
@@ -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 };