atris 3.74.21 → 3.74.23

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.
@@ -0,0 +1,288 @@
1
+ 'use strict';
2
+
3
+ // Claude spend guard. On 2026-10-28 unattended jobs ran Opus and Fable models
4
+ // in loops and billed about $900 of paid Claude usage in one day.
5
+ //
6
+ // The rule, enforced in two places:
7
+ // 1. Our own launchers call checkClaudeSpend() before starting `claude`, and
8
+ // hand it claudeChildEnv() so the pay-per-use key never reaches it.
9
+ // 2. `atris spend guard install` adds a Claude Code hook on this computer that
10
+ // applies the same rule to every unattended `claude` run, whatever script
11
+ // started it.
12
+ //
13
+ // Haiku runs freely. Any other Claude model in an unattended run needs the
14
+ // owner's yes for that run: ATRIS_CLAUDE_SPEND_OK=<model>[,<model>].
15
+ // A person typing in an interactive Claude session is never blocked.
16
+
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const path = require('path');
20
+ const { execFileSync } = require('child_process');
21
+
22
+ const APPROVAL_ENV = 'ATRIS_CLAUDE_SPEND_OK';
23
+ const KEY_OK_ENV = 'ATRIS_CLAUDE_KEY_OK';
24
+ // Either of these moves a `claude` run from the plan login to metered billing.
25
+ const PAID_KEY_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'];
26
+ const HOOK_MARK = 'claude-spend-guard';
27
+
28
+ function home(env = process.env) {
29
+ return env.HOME || os.homedir();
30
+ }
31
+
32
+ function ledgerPath(env = process.env) {
33
+ return path.join(home(env), '.atris', 'spend', 'claude-guard.jsonl');
34
+ }
35
+
36
+ function isFreeModel(model) {
37
+ return /haiku/i.test(String(model || ''));
38
+ }
39
+
40
+ function approvedModels(env = process.env) {
41
+ return String(env[APPROVAL_ENV] || '')
42
+ .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
43
+ }
44
+
45
+ function hasPaidKey(env = process.env) {
46
+ return PAID_KEY_VARS.some((name) => Boolean(env[name]));
47
+ }
48
+
49
+ // The model a `claude` run will use when no --model flag is given: the
50
+ // ANTHROPIC_MODEL env var, then the user's settings, then the account default,
51
+ // which on Max is Opus.
52
+ function defaultClaudeModel(env = process.env) {
53
+ if (env.ANTHROPIC_MODEL) return env.ANTHROPIC_MODEL;
54
+ try {
55
+ const settings = JSON.parse(fs.readFileSync(path.join(home(env), '.claude', 'settings.json'), 'utf8'));
56
+ if (settings && typeof settings.model === 'string' && settings.model) return settings.model;
57
+ } catch {}
58
+ return 'default';
59
+ }
60
+
61
+ // `--model haiku` can be remapped by ANTHROPIC_DEFAULT_HAIKU_MODEL; judge the
62
+ // model that actually runs.
63
+ function resolveAlias(model, env = process.env) {
64
+ const alias = String(model).toLowerCase().replace(/\[1m\]$/, '');
65
+ const mapped = { opus: 'ANTHROPIC_DEFAULT_OPUS_MODEL', sonnet: 'ANTHROPIC_DEFAULT_SONNET_MODEL',
66
+ haiku: 'ANTHROPIC_DEFAULT_HAIKU_MODEL' }[alias];
67
+ return (mapped && env[mapped]) || model;
68
+ }
69
+
70
+ function record(entry, env = process.env) {
71
+ try {
72
+ const file = ledgerPath(env);
73
+ fs.mkdirSync(path.dirname(file), { recursive: true });
74
+ fs.appendFileSync(file, JSON.stringify({ ts: new Date().toISOString(), host: os.hostname(), ...entry }) + '\n');
75
+ } catch {}
76
+ }
77
+
78
+ function blockMessage(model, job) {
79
+ const who = job ? `${job}: ` : '';
80
+ return `${who}${model} is a paid Claude model and this run is unattended. Haiku runs free. `
81
+ + `Get the owner's yes with a dollar estimate, then rerun with ${APPROVAL_ENV}=${model}.`;
82
+ }
83
+
84
+ // Decide whether an unattended Claude run may start.
85
+ // Returns { ok, reason, message }. Never throws.
86
+ function checkClaudeSpend({ model, job = '', env = process.env, checkKey = false, log = true } = {}) {
87
+ const resolved = resolveAlias(String(model || '').trim() || defaultClaudeModel(env), env);
88
+ if (checkKey && hasPaidKey(env) && env[KEY_OK_ENV] !== '1') {
89
+ const message = `${job ? `${job}: ` : ''}this unattended Claude run carries a pay-per-use key `
90
+ + `(${PAID_KEY_VARS.join(' or ')}). Unset it so the run uses the plan login.`;
91
+ if (log) record({ decision: 'blocked', reason: 'paid_key', model: resolved, job, cwd: process.cwd() }, env);
92
+ return { ok: false, reason: 'paid_key', model: resolved, message };
93
+ }
94
+ if (isFreeModel(resolved)) return { ok: true, reason: 'free_model', model: resolved };
95
+ if (approvedModels(env).includes(resolved.toLowerCase())) {
96
+ if (log) record({ decision: 'approved', model: resolved, job, cwd: process.cwd() }, env);
97
+ return { ok: true, reason: 'approved', model: resolved };
98
+ }
99
+ if (log) record({ decision: 'blocked', reason: 'paid_model', model: resolved, job, cwd: process.cwd() }, env);
100
+ return { ok: false, reason: 'paid_model', model: resolved, message: blockMessage(resolved, job) };
101
+ }
102
+
103
+ // A copy of env safe to hand a `claude` child: no pay-per-use key, so the run
104
+ // bills the plan login. ATRIS_CLAUDE_KEY_OK=1 keeps the key (servers only).
105
+ function claudeChildEnv(env = process.env) {
106
+ const out = { ...env };
107
+ if (out[KEY_OK_ENV] === '1') return out;
108
+ for (const name of PAID_KEY_VARS) delete out[name];
109
+ return out;
110
+ }
111
+
112
+ // ---- Claude Code hook --------------------------------------------------
113
+
114
+ function processArgs(pid) {
115
+ try {
116
+ if (process.platform === 'linux') {
117
+ return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').filter(Boolean);
118
+ }
119
+ const line = execFileSync('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8', timeout: 3000 }).trim();
120
+ return line ? line.split(/\s+/) : [];
121
+ } catch {
122
+ return [];
123
+ }
124
+ }
125
+
126
+ function parentPid(pid) {
127
+ try {
128
+ const out = execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], { encoding: 'utf8', timeout: 3000 }).trim();
129
+ const n = Number(out);
130
+ return Number.isInteger(n) && n > 1 ? n : 0;
131
+ } catch {
132
+ return 0;
133
+ }
134
+ }
135
+
136
+ function isClaudeArgv(argv) {
137
+ if (!argv.length) return false;
138
+ if (path.basename(argv[0]) === 'claude') return true;
139
+ return argv.slice(0, 3).some((a) => /claude-code|[\\/]claude$/.test(a));
140
+ }
141
+
142
+ // The argv of the `claude` process that ran this hook. Hooks run as direct
143
+ // children of claude; walk a few levels up in case a shell sits between.
144
+ function claudeArgv(startPid = process.ppid) {
145
+ let pid = startPid;
146
+ for (let i = 0; i < 4 && pid; i += 1) {
147
+ const argv = processArgs(pid);
148
+ if (isClaudeArgv(argv)) return argv;
149
+ pid = parentPid(pid);
150
+ }
151
+ return null;
152
+ }
153
+
154
+ function modelFromArgv(argv) {
155
+ let model = '';
156
+ for (let i = 0; i < argv.length; i += 1) {
157
+ if (argv[i] === '--model' && argv[i + 1]) model = argv[i + 1];
158
+ else if (argv[i].startsWith('--model=')) model = argv[i].slice('--model='.length);
159
+ }
160
+ return model;
161
+ }
162
+
163
+ // Unattended means print mode or an SDK run. A person's interactive session
164
+ // reports CLAUDE_CODE_ENTRYPOINT=cli and has no -p flag.
165
+ function isUnattended(argv, env = process.env) {
166
+ if (String(env.CLAUDE_CODE_ENTRYPOINT || '').startsWith('sdk')) return true;
167
+ return argv.includes('-p') || argv.includes('--print');
168
+ }
169
+
170
+ // Decide for the hook. Returns { block, message }.
171
+ function hookDecision({ argv, env = process.env }) {
172
+ if (!argv || !isUnattended(argv, env)) return { block: false };
173
+ const verdict = checkClaudeSpend({ model: modelFromArgv(argv), job: 'claude spend guard', env, checkKey: true });
174
+ return verdict.ok ? { block: false } : { block: true, message: verdict.message };
175
+ }
176
+
177
+ function readStdin() {
178
+ try {
179
+ return fs.readFileSync(0, 'utf8');
180
+ } catch {
181
+ return '';
182
+ }
183
+ }
184
+
185
+ // Entry point for the UserPromptSubmit hook. Exit 2 stops the run before any
186
+ // model call; anything unexpected fails open so a guard bug never breaks work.
187
+ function hookMain() {
188
+ let event = {};
189
+ try { event = JSON.parse(readStdin() || '{}'); } catch {}
190
+ if (event.hook_event_name && event.hook_event_name !== 'UserPromptSubmit') return 0;
191
+ const decision = hookDecision({ argv: claudeArgv() });
192
+ if (!decision.block) return 0;
193
+ try { fs.writeSync(2, `${decision.message}\n`); } catch {}
194
+ return 2;
195
+ }
196
+
197
+ // ---- install into ~/.claude/settings.json ------------------------------
198
+
199
+ function settingsPath(env = process.env) {
200
+ return path.join(home(env), '.claude', 'settings.json');
201
+ }
202
+
203
+ function hookScriptPath(env = process.env) {
204
+ return path.join(home(env), '.claude', 'hooks', `${HOOK_MARK}.js`);
205
+ }
206
+
207
+ function nodeBinary() {
208
+ for (const candidate of ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node']) {
209
+ if (fs.existsSync(candidate)) return candidate;
210
+ }
211
+ return process.execPath;
212
+ }
213
+
214
+ function readSettings(file) {
215
+ if (!fs.existsSync(file)) return {};
216
+ const text = fs.readFileSync(file, 'utf8');
217
+ if (!text.trim()) return {};
218
+ return JSON.parse(text);
219
+ }
220
+
221
+ function guardEntries(settings) {
222
+ const groups = (settings.hooks && settings.hooks.UserPromptSubmit) || [];
223
+ return groups.filter((g) => (g.hooks || []).some((h) => String(h.command || '').includes(HOOK_MARK)));
224
+ }
225
+
226
+ function guardStatus(env = process.env) {
227
+ let settings = {};
228
+ let readable = true;
229
+ try { settings = readSettings(settingsPath(env)); } catch { readable = false; }
230
+ return {
231
+ installed: readable && guardEntries(settings).length > 0 && fs.existsSync(hookScriptPath(env)),
232
+ settings: settingsPath(env),
233
+ script: hookScriptPath(env),
234
+ readable,
235
+ };
236
+ }
237
+
238
+ function installGuard({ env = process.env, modulePath = __filename } = {}) {
239
+ const file = settingsPath(env);
240
+ const settings = readSettings(file); // throws on unparseable settings: never clobber them
241
+ // The hook is a copy of this file, so it keeps working if the CLI moves.
242
+ // Rerun install after a CLI update to pick up guard fixes.
243
+ const script = hookScriptPath(env);
244
+ fs.mkdirSync(path.dirname(script), { recursive: true });
245
+ fs.copyFileSync(modulePath, script);
246
+ fs.chmodSync(script, 0o755);
247
+ settings.hooks = settings.hooks || {};
248
+ const groups = settings.hooks.UserPromptSubmit || [];
249
+ const kept = groups.filter((g) => !guardEntries({ hooks: { UserPromptSubmit: [g] } }).length);
250
+ // First in line, so it runs before any hook that might spend.
251
+ settings.hooks.UserPromptSubmit = [
252
+ { hooks: [{ type: 'command', command: `${nodeBinary()} ${script}` }] },
253
+ ...kept,
254
+ ];
255
+ fs.mkdirSync(path.dirname(file), { recursive: true });
256
+ const tmp = `${file}.atris-tmp-${process.pid}`;
257
+ fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
258
+ fs.renameSync(tmp, file);
259
+ return guardStatus(env);
260
+ }
261
+
262
+ function uninstallGuard({ env = process.env } = {}) {
263
+ const file = settingsPath(env);
264
+ const settings = readSettings(file);
265
+ if (settings.hooks && settings.hooks.UserPromptSubmit) {
266
+ const kept = settings.hooks.UserPromptSubmit.filter((g) => !guardEntries({ hooks: { UserPromptSubmit: [g] } }).length);
267
+ if (kept.length) settings.hooks.UserPromptSubmit = kept;
268
+ else delete settings.hooks.UserPromptSubmit;
269
+ const tmp = `${file}.atris-tmp-${process.pid}`;
270
+ fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
271
+ fs.renameSync(tmp, file);
272
+ }
273
+ try { fs.unlinkSync(hookScriptPath(env)); } catch {}
274
+ return guardStatus(env);
275
+ }
276
+
277
+ module.exports = {
278
+ APPROVAL_ENV,
279
+ checkClaudeSpend,
280
+ claudeChildEnv,
281
+ hookDecision,
282
+ guardStatus,
283
+ installGuard,
284
+ uninstallGuard,
285
+ ledgerPath,
286
+ };
287
+
288
+ if (require.main === module) process.exit(hookMain());
package/lib/engine-ask.js CHANGED
@@ -7,6 +7,7 @@ const path = require('path');
7
7
  const { spawn, spawnSync } = require('child_process');
8
8
  const {
9
9
  DEFAULT_CLAUDE_RUNNER_MODEL,
10
+ applySpendGate,
10
11
  RUNNER_PROFILE_DEFS,
11
12
  } = require('./runner-command');
12
13
  const {
@@ -252,7 +253,9 @@ function resolveAskModel(engine, modelName = '') {
252
253
  : currentClaudeModel(requested);
253
254
  }
254
255
  if (engine !== 'claude' && engine !== 'fable' && engine !== 'haiku') return '';
255
- return RUNNER_PROFILE_DEFS[engine]?.model || DEFAULT_CLAUDE_RUNNER_MODEL;
256
+ // No model named: this is the unattended default, so it goes through the
257
+ // spend gate. A model a person typed is their own yes.
258
+ return applySpendGate(RUNNER_PROFILE_DEFS[engine]?.model || DEFAULT_CLAUDE_RUNNER_MODEL);
256
259
  }
257
260
 
258
261
  // The effort flag as argv, from the same profile table the runners use
@@ -289,7 +292,7 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
289
292
  bin: profile.bin,
290
293
  args: [
291
294
  '-p', request,
292
- '--model', model || profile.model || DEFAULT_CLAUDE_RUNNER_MODEL,
295
+ '--model', model || applySpendGate(profile.model || DEFAULT_CLAUDE_RUNNER_MODEL),
293
296
  ...effortArgs,
294
297
  '--tools', 'Read,Glob,Grep,WebSearch,WebFetch',
295
298
  '--permission-mode', 'plan',
@@ -6,6 +6,7 @@ const os = require('node:os');
6
6
  const path = require('node:path');
7
7
  const {
8
8
  DEFAULT_CLAUDE_RUNNER_MODEL,
9
+ applySpendGate,
9
10
  RUNNER_PROFILE_DEFS,
10
11
  } = require('./runner-command');
11
12
  const { canonicalEngineName } = require('./engine-registry');
@@ -78,7 +79,7 @@ function refereeModel(engine) {
78
79
  const profile = RUNNER_PROFILE_DEFS[engine];
79
80
  if (!profile) return '';
80
81
  if (engine === 'claude' || engine === 'fable' || engine === 'haiku') {
81
- return profile.model || DEFAULT_CLAUDE_RUNNER_MODEL;
82
+ return applySpendGate(profile.model || DEFAULT_CLAUDE_RUNNER_MODEL);
82
83
  }
83
84
  return profile.model || '';
84
85
  }
@@ -4,7 +4,7 @@ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'work
4
4
  'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
5
5
  'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
6
6
  'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'sync-checkout', 'zen',
7
- 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
7
+ 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
8
  'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi', 'pay', 'trust'];
9
9
 
10
10
  // Damerau-Levenshtein edit distance between two short strings. Counts an
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { spawnSync } = require('child_process');
6
+ const { TRAILER } = require('../commands/sign');
6
7
 
7
8
  // A member that passed its check but never committed leaves its work loose in
8
9
  // a side checkout that cleanup later deletes (member lab 2026-10-24: navigator
@@ -45,7 +46,7 @@ function saveLeftoverMemberWork({ worktreePath, member = 'member', objective = '
45
46
  const staged = stagedAll.filter((file) => !SECRET_PATH.test(file));
46
47
  if (!staged.length) return { saved: false, reason: 'nothing_left', ...(skipped.length ? { skipped } : {}) };
47
48
  const title = String(objective || '').split('\n').map((line) => line.trim()).find(Boolean) || 'finished work';
48
- const message = `${member}: ${title.slice(0, 72)}\n\nSaved by atris after the check passed; the member left these changes uncommitted.`;
49
+ const message = `${member}: ${title.slice(0, 72)}\n\nSaved by atris after the check passed; the member left these changes uncommitted.\n\n${TRAILER}`;
49
50
  const commit = git(['commit', '-q', '-m', message], worktreePath);
50
51
  if (!commit.ok) return { saved: false, reason: 'commit_failed', files: staged };
51
52
  return { saved: true, files: staged, ...(skipped.length ? { skipped } : {}), sha: git(['rev-parse', 'HEAD'], worktreePath).stdout };
@@ -13,6 +13,7 @@ const {
13
13
  RUNNER_PROFILE_DEFS,
14
14
  RUNNER_PROFILE_ALIASES,
15
15
  DEFAULT_CLAUDE_RUNNER_MODEL,
16
+ applySpendGate,
16
17
  } = require('./runner-command');
17
18
 
18
19
  const CLAUDE_FAMILY = Object.freeze(['claude', 'fable', 'haiku']);
@@ -77,7 +78,8 @@ function engineRunsView(engineId, { model = '', effort = '' } = {}, options = {}
77
78
  view.model_source = 'codex settings';
78
79
  }
79
80
  } else if (CLAUDE_FAMILY.includes(id)) {
80
- view.model = String(process.env.ATRIS_RUNNER_MODEL || '').trim() || def.model || DEFAULT_CLAUDE_RUNNER_MODEL;
81
+ // Shown as it really runs: the spend gate may have dropped it to the cheap model.
82
+ view.model = applySpendGate(String(process.env.ATRIS_RUNNER_MODEL || '').trim() || def.model || DEFAULT_CLAUDE_RUNNER_MODEL, { silent: true });
81
83
  view.model_source = 'atris default';
82
84
  } else if (ATRIS_PINNED.includes(id) && def.model) {
83
85
  view.model = def.model;
@@ -21,7 +21,7 @@ const RUNS_FILE = path.join('.atris', 'state', 'roster_runs.jsonl');
21
21
  const ROTATED_NAME = /^roster_runs\.\d.*\.jsonl$/;
22
22
  const ROTATED_KEEP = 3;
23
23
  const ROTATED_READ = 2;
24
- const OUTCOMES = Object.freeze(['landed', 'failed', 'stalled', 'credit out']);
24
+ const OUTCOMES = Object.freeze(['landed', 'failed', 'stalled', 'credit out', 'review unavailable']);
25
25
  const DEFAULT_DAYS = 7;
26
26
  const TAIL_BYTES = 512 * 1024;
27
27
  // Past this size the log is renamed aside before the append, never trimmed:
@@ -131,6 +131,7 @@ function runReason(run) {
131
131
  if (given) return given.slice(0, 240);
132
132
  if (run.outcome === 'stalled') return 'it did not finish in time';
133
133
  if (run.outcome === 'credit out') return 'the engine hit its usage limit';
134
+ if (run.outcome === 'review unavailable') return 'the second review was unavailable';
134
135
  return 'it failed and the tool gave no reason';
135
136
  }
136
137
 
@@ -340,12 +341,15 @@ function summarizeRuns(runs, { days = DEFAULT_DAYS } = {}) {
340
341
  handed_over: list.filter((run) => run.handed_over_to).length,
341
342
  median_seconds: median(list.map((run) => Number(run.seconds)).filter((value) => value >= 0)),
342
343
  };
344
+ const reviewUnavailable = count('review unavailable');
345
+ if (reviewUnavailable) summary.review_unavailable = reviewUnavailable;
343
346
  const tokens = list.map((run) => run.tokens).filter((value) => Number.isFinite(value));
344
347
  if (tokens.length) summary.tokens = tokens.reduce((sum, value) => sum + value, 0);
345
348
  const parts = [`${summary.runs} run${summary.runs === 1 ? '' : 's'}`];
346
349
  if (summary.landed) parts.push(`${summary.landed} landed`);
347
350
  if (summary.stalled) parts.push(`${summary.stalled} stalled`);
348
351
  if (summary.failed) parts.push(`${summary.failed} failed`);
352
+ if (summary.review_unavailable) parts.push(`${summary.review_unavailable} review unavailable`);
349
353
  if (summary.credit_out) parts.push(`${summary.credit_out} credit out`);
350
354
  if (summary.median_seconds !== null) parts.push(`median ${durationText(summary.median_seconds)}`);
351
355
  summary.text = `last ${days} day${days === 1 ? '' : 's'}: ${parts.join(', ')}`;
@@ -26,7 +26,7 @@ const BOOT_TAIL_BYTES = 64 * 1024;
26
26
  // engine health already benches it, so credit out runs are left out.
27
27
  function judgedRuns(runs) {
28
28
  return runs
29
- .filter((run) => run.outcome !== 'credit out')
29
+ .filter((run) => run.outcome !== 'credit out' && run.outcome !== 'review unavailable')
30
30
  .map((run, index) => ({ run, index }))
31
31
  .sort((a, b) => (Date.parse(b.run.at) - Date.parse(a.run.at)) || (b.index - a.index))
32
32
  .slice(0, SUGGEST_WINDOW_RUNS)
@@ -261,7 +261,7 @@ function swapMember(root, memberName, engineWords, modelWords, options = {}) {
261
261
  // model this machine offers that can do the member's work, grouped by tool.
262
262
  // A model's last 7 days in the owner's words: the phone's swap card shows it
263
263
  // under the model, so it says finished, never the team's own word for it.
264
- const recordWords = seen => `${seen.landed} of ${seen.runs} finished this week`;
264
+ const recordWords = seen => `${seen.landed} of ${seen.runs - (seen.review_unavailable || 0)} finished this week${seen.review_unavailable ? `, ${seen.review_unavailable} awaiting review` : ''}`;
265
265
 
266
266
  // The current model is on top; models with a record in the last 7 days come
267
267
  // next, most landed first; the rest keep --available's order.
@@ -219,7 +219,7 @@ function readRosterRunObservations(root, now) {
219
219
  if (!fs.existsSync(file) && !rotatedRunsPaths(root).length) return [];
220
220
  const source = path.relative(root, file);
221
221
  return readRosterRuns(root, { days: ROSTER_RUN_DAYS, ...(now !== undefined ? { now } : {}) }).flatMap((run) => {
222
- if (run.outcome === 'credit out') return [];
222
+ if (run.outcome === 'credit out' || run.outcome === 'review unavailable') return [];
223
223
  const words = String(run.job || '').trim().toLowerCase().split(/\s+/);
224
224
  const role = ROSTER_JOB_ROLES[words[words.length - 1]];
225
225
  const atMs = Date.parse(run.at);