atris 3.74.22 → 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.
package/bin/atris.js CHANGED
@@ -669,7 +669,7 @@ function showHelpAll() {
669
669
  console.log(' land - The landing: what is actually done vs still in the air; --reap backs up + clears overdue');
670
670
  console.log(' caretaker - Classify open pull requests on origin (scan only; no fix, comment, or merge)');
671
671
  console.log(' drive - One self-driving tick: mission doctor -> auto-fix -> count disengagements');
672
- console.log(' rsi - Read the Dream-RSI attempt ledger (trees, attempts, policy, dreams)');
672
+ console.log(' rsi - Read the Dream-RSI attempt ledger; rsi loop ships notebook fixes with no mission');
673
673
  console.log(` autoland - Approve the policy once; ${require('../lib/autoland').certifiedWorkLandsPhrase(process.cwd())}, you keep irreversible calls`);
674
674
  console.log(' engine - engine registry, answer validation, dispatch flights, and live progress');
675
675
  console.log(' link - What two things could become together: one line, why, a first thing to make, who makes it');
@@ -2495,6 +2495,10 @@ if (command === 'guide') {
2495
2495
  Promise.resolve().then(() => require('../commands/balance').balanceCommand(process.argv.slice(3)))
2496
2496
  .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
2497
2497
  .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2498
+ } else if (command === 'spend') {
2499
+ // Spend guard: unattended Claude runs on paid models need a yes (2026-10-28, $900 day).
2500
+ const code = require('../commands/spend').spendCommand(process.argv.slice(3));
2501
+ exitAfterStdoutDrain(typeof code === 'number' ? code : 0);
2498
2502
  } else if (command === 'usage') {
2499
2503
  Promise.resolve().then(() => require('../commands/usage').usageCommand(process.argv.slice(3)))
2500
2504
  .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
@@ -453,7 +453,7 @@ function collectImproveVitals(options = {}, deps = {}) {
453
453
  return fs.existsSync(plistPath);
454
454
  })();
455
455
  const installed = launchdInstalled || legacyCronInstalled;
456
- const installNudge = installed ? null : 'the scheduled improve loop is off. turn it on: atris pulse install --model claude-sonnet-5';
456
+ const installNudge = installed ? null : 'the scheduled improve loop is off. turn it on: atris pulse install';
457
457
 
458
458
  const experimentsPath = path.join(root, '.atris', 'state', 'experiments-daily.json');
459
459
  const experiments = readJsonFile(experimentsPath, {});
@@ -9360,7 +9360,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
9360
9360
  `## Your task`,
9361
9361
  `Do ONE increment of work toward the stop condition. ONE. No more.`,
9362
9362
  `- ${memberIdentityLine(mission.owner)} Your member files are preloaded above; open atris/team/${mission.owner}/MEMBER.md only if that copy is marked truncated. Work in that identity, inside its scope and stop rules. Atris writes your member log from your receipt, so end the receipt with one \`learned: <one line>\` line instead of editing the log.`,
9363
- `- FIRST: inspect current mission/task state before acting. Read the relevant files, run \`atris mission status ${mission.id}\`, \`git status\`, or \`atris task list\` as needed so you know what's already done.`,
9363
+ `- Start with the job: inspect the files or state directly relevant to the objective, then do the smallest useful action. Skip workspace boot and start-of-session routines, including brain activation, general Atris or task listings, and reading MAP, persona, or skill files solely for startup. Inspect mission or task state only if the job requires it.`,
9364
9364
  `- Pick the smallest concrete action that moves the mission forward.`,
9365
9365
  `- Before acting, state your single next move in one sentence.`,
9366
9366
  `- Edit / run / research as needed for the lane.`,
@@ -10082,6 +10082,16 @@ function engineHealthRoot(cwd) {
10082
10082
  return dir && path.basename(dir) === '.git' ? path.dirname(dir) : cwd;
10083
10083
  }
10084
10084
 
10085
+ function reviewerHealthRoot(engine) {
10086
+ return path.join(require('../lib/state-home').atrisHome(), 'reviewer-health', engine);
10087
+ }
10088
+
10089
+ function reviewerCooling(engine, registry) {
10090
+ const saved = registry.readEngineRegistry(reviewerHealthRoot(engine), { persist: false })
10091
+ .engines.find((entry) => entry.id === engine);
10092
+ return saved?.health?.status === 'cooling' && !registry.engineReadyAt(saved);
10093
+ }
10094
+
10085
10095
  // Every spelling and version of one model family counts as the same model,
10086
10096
  // with or without the long-context tag. Unknown counts as same.
10087
10097
  function reviewerMayBeSameModel(a, b) {
@@ -10109,12 +10119,12 @@ function engineVerifierLineup(mission, cwd, preferredEngine = '', options = {})
10109
10119
  const workerEngine = canonicalEngineName(mission?.runner) || 'claude';
10110
10120
  const workerModel = resolveMissionTickRunnerModel(mission);
10111
10121
  const team = registry.resolveEngineForRoleRanked('validator', root, { job: 'review', quiet: true }).team || [];
10112
- const backup = team.find((engine) => engine && engine.id !== primary
10122
+ const backup = team.find((engine) => engine && engine.id !== primary && !reviewerCooling(engine.id, registry)
10113
10123
  && !(engine.id === workerEngine && reviewerMayBeSameModel(engine.roster_model, workerModel)));
10114
10124
  if (!backup) return lineup;
10115
10125
  lineup.push({ engine: backup.id, model: backup.roster_model || '' });
10116
10126
  const saved = registry.readEngineRegistry(root, { persist: false }).engines.find((engine) => engine.id === primary);
10117
- if (saved && !registry.engineReadyAt(saved)) lineup.shift();
10127
+ if ((saved && !registry.engineReadyAt(saved)) || reviewerCooling(primary, registry)) lineup.shift();
10118
10128
  } catch {}
10119
10129
  return lineup;
10120
10130
  }
@@ -10134,7 +10144,7 @@ async function runEngineVerifier(mission, options = {}) {
10134
10144
  sessionId: crypto.randomUUID(),
10135
10145
  cwd,
10136
10146
  signal: options.signal,
10137
- timeoutMs: ENGINE_VERIFY_TIMEOUT_MS,
10147
+ timeoutMs: options.timeoutMs || ENGINE_VERIFY_TIMEOUT_MS,
10138
10148
  prompt: buildEngineVerifyPrompt(mission, options.tickIndex || 1, missionDoneLooksLike(mission, cwd), {
10139
10149
  timeoutMs: ENGINE_VERIFY_TIMEOUT_MS,
10140
10150
  checkPassed: options.checkPassed || '',
@@ -10147,11 +10157,16 @@ async function runEngineVerifier(mission, options = {}) {
10147
10157
  restoreRunnerProfile();
10148
10158
  }
10149
10159
  verdict = engineVerifierResultFromRun(engineResult, engine);
10160
+ if (engineResult?.ok && !engineResult?.timedOut && !engineResult?.aborted) {
10161
+ try { setEngineHealth(engine, 'ready', healthRoot); } catch {}
10162
+ try { setEngineHealth(engine, 'ready', reviewerHealthRoot(engine)); } catch {}
10163
+ }
10150
10164
  if (verdict.mode !== 'engine-unavailable' || engineResult?.aborted) return verdict;
10151
10165
  // Member lab 10/26: codex hung on every review, and each job waited out
10152
10166
  // the full five minutes. Bench it so the next review starts on the backup.
10153
10167
  if (engineResult?.timedOut) {
10154
10168
  try { setEngineHealth(engine, 'cooling', healthRoot); } catch {}
10169
+ try { setEngineHealth(engine, 'cooling', reviewerHealthRoot(engine)); } catch {}
10155
10170
  }
10156
10171
  }
10157
10172
  return verdict;
@@ -13007,6 +13022,7 @@ module.exports = {
13007
13022
  receiptLearnedLine,
13008
13023
  tickTouchedCheckDefinition,
13009
13024
  engineVerifierLineup,
13025
+ runEngineVerifier,
13010
13026
  claudeRunnerMcpArgs,
13011
13027
  crystallizeMemberSkillLesson,
13012
13028
  listSiblingWorkspaceMissionHints,
package/commands/rsi.js CHANGED
@@ -6,6 +6,8 @@
6
6
  // outcomes, the live policy id, and the last dream receipt. Read-only: it
7
7
  // never touches record.py, never writes state, and exits 0 even when the
8
8
  // workspace has no ledger yet.
9
+ //
10
+ // `atris rsi loop` ships notebook fixes with no mission queued (lib/rsi-loop.js).
9
11
 
10
12
  const fs = require('fs');
11
13
  const rsi = require('../lib/rsi-record');
@@ -114,8 +116,29 @@ Usage:
114
116
  atris rsi same as status
115
117
  atris rsi status trees + attempts by lane, last night's outcomes, policy, last dream
116
118
  atris rsi status --json machine-readable status
119
+ atris rsi loop [--minutes M] [--json]
120
+ find notebook fixes with no mission queued, verify, commit and
121
+ push them on one reusable isolated branch (default budget 5 minutes)
122
+
123
+ status is read-only. loop checks atris/ pages for line refs that point at
124
+ moved code and for wording the slop rules can repair, commits each check
125
+ that verifies, and pushes the branch. It removes the local branch and checkout
126
+ only once the remote tip matches; the remote branch is reused on later runs.
127
+ The budget is checked between steps and limits network operations.
128
+ The main checkout is never edited.
129
+
130
+ State lives in .atris/state/rsi/ (or $ATRIS_RSI_STATE); loop reports go to loops.jsonl there.`);
131
+ }
117
132
 
118
- Read-only. State lives in .atris/state/rsi/ (or $ATRIS_RSI_STATE).`);
133
+ async function runLoopCommand(args) {
134
+ const rsiLoop = require('../lib/rsi-loop');
135
+ const i = args.indexOf('--minutes');
136
+ const raw = i >= 0 ? String(args[i + 1] || '') : '';
137
+ const minutes = i < 0 ? undefined : (/^\d+(\.\d+)?$/.test(raw) ? Number(raw) : NaN);
138
+ const result = await rsiLoop.runLoop({ minutes, workspace: process.cwd() });
139
+ console.log(args.includes('--json') ? JSON.stringify(result) : rsiLoop.formatLoopReport(result));
140
+ if (result.refused) return 2;
141
+ return result.ok ? 0 : 1;
119
142
  }
120
143
 
121
144
  function run(argv = []) {
@@ -125,6 +148,7 @@ function run(argv = []) {
125
148
  return 0;
126
149
  }
127
150
  const sub = args[0] && !args[0].startsWith('-') ? args[0] : 'status';
151
+ if (sub === 'loop') return runLoopCommand(args.slice(1));
128
152
  if (sub !== 'status') {
129
153
  console.log(`unknown rsi subcommand: ${sub}`);
130
154
  showHelp();
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const guard = require('../lib/claude-spend-guard');
5
+
6
+ function showSpendHelp() {
7
+ console.log('usage: atris spend guard [install|uninstall|status]');
8
+ console.log(' atris spend log [--limit N]');
9
+ console.log('');
10
+ console.log('The guard stops unattended Claude runs on paid models (anything but Haiku)');
11
+ console.log(`unless ${guard.APPROVAL_ENV}=<model> names the model for that run.`);
12
+ console.log('It also stops unattended runs that carry a pay-per-use Anthropic key.');
13
+ console.log('Interactive Claude sessions are never blocked.');
14
+ }
15
+
16
+ function printStatus(status) {
17
+ if (!status.readable) {
18
+ console.log(`guard: unknown, could not read ${status.settings}`);
19
+ return;
20
+ }
21
+ console.log(status.installed ? 'guard: on' : 'guard: off');
22
+ console.log(`settings: ${status.settings}`);
23
+ console.log(`hook: ${status.script}`);
24
+ }
25
+
26
+ function guardCommand(action = 'status') {
27
+ if (action === 'install') {
28
+ const status = guard.installGuard();
29
+ printStatus(status);
30
+ if (status.installed) {
31
+ console.log('Unattended Claude runs on paid models now need a yes on this computer.');
32
+ }
33
+ return status.installed ? 0 : 1;
34
+ }
35
+ if (action === 'uninstall') {
36
+ printStatus(guard.uninstallGuard());
37
+ return 0;
38
+ }
39
+ if (action === 'status') {
40
+ const status = guard.guardStatus();
41
+ printStatus(status);
42
+ return status.installed ? 0 : 1;
43
+ }
44
+ showSpendHelp();
45
+ return 1;
46
+ }
47
+
48
+ function logCommand(args) {
49
+ const i = args.indexOf('--limit');
50
+ const limit = i >= 0 ? Math.max(1, Number(args[i + 1]) || 20) : 20;
51
+ const file = guard.ledgerPath();
52
+ if (!fs.existsSync(file)) {
53
+ console.log('no guard decisions recorded on this computer.');
54
+ return 0;
55
+ }
56
+ const rows = fs.readFileSync(file, 'utf8').trim().split('\n').slice(-limit);
57
+ for (const line of rows) {
58
+ try {
59
+ const r = JSON.parse(line);
60
+ console.log(`${r.ts} ${r.decision} ${r.model} ${r.job || ''} ${r.cwd || ''}`.trimEnd());
61
+ } catch {}
62
+ }
63
+ return 0;
64
+ }
65
+
66
+ function spendCommand(args = []) {
67
+ const [sub, ...rest] = args;
68
+ if (!sub || sub === '--help' || sub === '-h' || sub === 'help') {
69
+ showSpendHelp();
70
+ return sub ? 0 : 1;
71
+ }
72
+ if (sub === 'guard') return guardCommand(rest[0] || 'status');
73
+ if (sub === 'log') return logCommand(rest);
74
+ showSpendHelp();
75
+ return 1;
76
+ }
77
+
78
+ module.exports = { spendCommand };
@@ -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;
@@ -0,0 +1,353 @@
1
+ 'use strict';
2
+
3
+ // atris rsi loop: find and ship notebook fixes with no mission queued.
4
+ //
5
+ // One run works on one isolated branch cut from the remote mainline. Built-in
6
+ // checks find problems in the atris/ notebook and fix them with no engine:
7
+ // line refs in the map docs that point at moved code, and plain-wording
8
+ // repairs the slop rules can make safely. Each check is applied, kept inside
9
+ // the allowed paths, verified, and committed on its own; a check that fails
10
+ // its verify is undone. The run then pushes the branch within the time left.
11
+ //
12
+ // Cleanup happens only after delivery is proven: the remote branch tip equals
13
+ // the checkout's HEAD and the checkout has no uncommitted changes. Any other
14
+ // end keeps the checkout and branch, and the next run picks the branch up.
15
+ // The main checkout is never edited, so its own uncommitted work is safe.
16
+ //
17
+ // Report: one atris.rsi.loop.v1 row per run in <rsi state>/loops.jsonl.
18
+
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+ const { spawnSync } = require('child_process');
23
+ const rsi = require('./rsi-record');
24
+ const mapRefs = require('./map-refs');
25
+ const slop = require('../commands/slop');
26
+ const worktree = require('../commands/worktree');
27
+
28
+ const LOOP_SCHEMA = 'atris.rsi.loop.v1';
29
+ const DEFAULT_MINUTES = 5;
30
+ const MAX_MINUTES = 60;
31
+ const OWNER = 'rsi';
32
+ const JOB = 'notebook';
33
+ const BRANCH_PREFIX = `codex/${OWNER}-${JOB}`;
34
+ const MAX_WORDING_FILES = 10;
35
+ const CO_AUTHOR = 'Co-authored-by: Atris <299057014+atris-builder[bot]@users.noreply.github.com>';
36
+ // The board and journal are rewritten by atris itself; team templates and
37
+ // atris/CLAUDE.md ship to every user project and need a version bump.
38
+ const OFF_LIMITS = /^atris\/(?:team\/|logs\/|TODO\.md$|CLAUDE\.md$)/;
39
+
40
+ function allowedPath(file) {
41
+ return /^atris\/.+\.md$/.test(file) && !OFF_LIMITS.test(file);
42
+ }
43
+
44
+ function git(cwd, args, options = {}) {
45
+ return spawnSync('git', args, { cwd, encoding: 'utf8', killSignal: 'SIGKILL', ...options });
46
+ }
47
+
48
+ function gitOut(cwd, args) {
49
+ const r = git(cwd, args);
50
+ return r.status === 0 ? String(r.stdout || '').trim() : '';
51
+ }
52
+
53
+ // Changed paths in a checkout, untracked files listed one by one.
54
+ function changedPaths(cwd) {
55
+ const r = git(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
56
+ if (r.status !== 0) return null;
57
+ return String(r.stdout || '').split('\0').filter(Boolean)
58
+ .map((entry) => ({ code: entry.slice(0, 2), file: entry.slice(3) }));
59
+ }
60
+
61
+ // Put back exactly what a check touched in the run's own checkout.
62
+ function undo(wt, changes) {
63
+ for (const { code, file } of changes) {
64
+ if (code === '??') fs.rmSync(path.join(wt, file), { force: true });
65
+ else {
66
+ git(wt, ['reset', '-q', '--', file]);
67
+ git(wt, ['checkout', '--', file]);
68
+ }
69
+ }
70
+ }
71
+
72
+ function lockPath(root) {
73
+ return path.join(path.dirname(rsi.attemptsPath(root)), 'loop.lock');
74
+ }
75
+
76
+ function loopsPath(root) {
77
+ return path.join(path.dirname(rsi.attemptsPath(root)), 'loops.jsonl');
78
+ }
79
+
80
+ function alive(pid) {
81
+ if (!Number.isInteger(pid) || pid <= 0) return false;
82
+ try {
83
+ process.kill(pid, 0);
84
+ return true;
85
+ } catch (error) {
86
+ return error.code === 'EPERM';
87
+ }
88
+ }
89
+
90
+ // One loop per workspace. A lock whose process is gone is stale and is taken.
91
+ function acquireLock(root, { pid = process.pid, host = os.hostname(), isAlive = alive } = {}) {
92
+ const file = lockPath(root);
93
+ fs.mkdirSync(path.dirname(file), { recursive: true });
94
+ const read = () => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
95
+ for (let attempt = 0; attempt < 2; attempt += 1) {
96
+ try {
97
+ fs.writeFileSync(file, `${JSON.stringify({ pid, host, started_at: new Date().toISOString() })}\n`, { flag: 'wx' });
98
+ return {
99
+ ok: true,
100
+ release: () => {
101
+ const holder = read();
102
+ if (holder && holder.pid === pid && holder.host === host) fs.rmSync(file, { force: true });
103
+ },
104
+ };
105
+ } catch (error) {
106
+ if (error.code !== 'EEXIST') throw error;
107
+ }
108
+ const holder = read();
109
+ if (holder && (holder.host !== host || isAlive(holder.pid))) return { ok: false, holder };
110
+ fs.rmSync(file, { force: true });
111
+ }
112
+ return { ok: false, holder: read() };
113
+ }
114
+
115
+ // Map docs: rewrite refs whose symbol moved to one clear line. Passes when no
116
+ // moved ref is left and no more refs need a person than before.
117
+ const mapRefsCheck = {
118
+ name: 'map_refs',
119
+ run(wt) {
120
+ const root = fs.realpathSync(wt);
121
+ const unsafe = mapRefs.existingMapDocs(wt).some((doc) => {
122
+ try {
123
+ const full = path.join(wt, doc);
124
+ return !fs.lstatSync(full).isFile() || !fs.realpathSync(full).startsWith(`${root}${path.sep}`);
125
+ } catch { return true; }
126
+ });
127
+ if (unsafe) throw new Error('map file is a symlink or outside the isolated checkout');
128
+ const before = mapRefs.checkMapDocs(wt);
129
+ const fixed = mapRefs.fixMapDocs(wt).changes.length;
130
+ return {
131
+ found: fixed,
132
+ message: `rsi: point ${fixed} notebook line ref${fixed === 1 ? '' : 's'} at the moved code`,
133
+ verify: () => {
134
+ const after = mapRefs.checkMapDocs(wt);
135
+ const left = (s) => s.ambiguous + s.missing + s.missing_file;
136
+ return after.moved === 0 && left(after) <= left(before);
137
+ },
138
+ };
139
+ },
140
+ };
141
+
142
+ // Wording: the slop rules' safe repairs on tracked notebook pages, a few
143
+ // files per run. Passes when the edited lines scan clean.
144
+ const wordingCheck = {
145
+ name: 'wording',
146
+ run(wt) {
147
+ const tracked = gitOut(wt, ['ls-files', '--', 'atris']).split('\n').filter(allowedPath).filter((file) => {
148
+ try {
149
+ const full = path.join(wt, file);
150
+ return fs.lstatSync(full).isFile() && fs.realpathSync(full).startsWith(`${fs.realpathSync(wt)}${path.sep}`);
151
+ } catch { return false; }
152
+ });
153
+ const rules = slop.RULES.concat(slop.loadProjectRules(wt));
154
+ const fixable = new Set(rules.filter((r) => typeof r.fix === 'function').map((r) => r.id));
155
+ const flagged = [...new Set(slop.scanPaths(tracked, { root: wt, rules }).findings
156
+ .filter((f) => fixable.has(f.rule)).map((f) => f.file))];
157
+ const { fixedFiles } = slop.applyFixes(flagged.slice(0, MAX_WORDING_FILES), rules);
158
+ return {
159
+ found: fixedFiles.length,
160
+ message: `rsi: plain wording in ${fixedFiles.length} notebook page${fixedFiles.length === 1 ? '' : 's'}`,
161
+ verify: () => slop.scanPaths(fixedFiles, { root: wt, changedLines: slop.gitChangedLines(false, wt) }).findings.length === 0,
162
+ };
163
+ },
164
+ };
165
+
166
+ const CHECKS = [mapRefsCheck, wordingCheck];
167
+
168
+ // The newest kept run whose commits never reached the base, when its checkout
169
+ // is gone or clean, so a cut-off run is finished instead of redone.
170
+ function openCheckout(root, base) {
171
+ const branch = BRANCH_PREFIX;
172
+ const remoteRef = `refs/remotes/origin/${branch}`;
173
+ const remoteHead = gitOut(root, ['rev-parse', '--verify', remoteRef]);
174
+ const localHead = gitOut(root, ['rev-parse', '--verify', `refs/heads/${branch}`]);
175
+ // Never overwrite an existing local branch. Resume only our clean checkout.
176
+ const checkout = worktree.listWorktrees(root).find((wt) => wt.branch === branch);
177
+ if (checkout) {
178
+ const changes = changedPaths(checkout.path);
179
+ if (!changes || changes.length) return { error: 'RSI checkout contains uncommitted work; preserve it and resolve it before retrying' };
180
+ }
181
+ const start = localHead || remoteHead || base;
182
+ if (remoteHead && git(root, ['merge-base', '--is-ancestor', remoteHead, start]).status !== 0) {
183
+ return { error: 'local RSI branch does not contain the remote RSI branch; resolve the divergence before retrying' };
184
+ }
185
+ // Do not silently omit changes from the current mainline.
186
+ if (git(root, ['merge-base', '--is-ancestor', base, start]).status !== 0) {
187
+ return { error: 'RSI branch is behind or diverged from the remote mainline; integrate it before retrying' };
188
+ }
189
+ if (checkout) return { branch, wt: checkout.path, resumed: true };
190
+ const wt = path.join(path.dirname(root), '.agent-worktrees', path.basename(root), 'rsi-notebook');
191
+ const args = localHead ? ['worktree', 'add', wt, branch] : ['worktree', 'add', '-b', branch, wt, start];
192
+ const added = git(root, args);
193
+ if (added.status !== 0) return { error: String(added.stderr || 'worktree add failed').trim() };
194
+ return { branch, wt, resumed: Boolean(localHead || remoteHead), error: null };
195
+ }
196
+
197
+ // Remove the checkout and local branch. Plain removal refuses a checkout with
198
+ // changes, so nothing unsaved is ever lost here.
199
+ function cleanup(root, wt, branch) {
200
+ if (git(root, ['worktree', 'remove', wt]).status !== 0) return false;
201
+ return git(root, ['branch', '-D', branch]).status === 0;
202
+ }
203
+
204
+ /**
205
+ * Run the notebook checks and ship them within opts.minutes. deps can replace
206
+ * now, checks, and isAlive for tests. Returns the report row plus
207
+ * report_path, or { ok: false, refused } when the run does not start.
208
+ */
209
+ async function runLoop(opts = {}, deps = {}) {
210
+ const root = path.resolve(opts.workspace || process.cwd());
211
+ const clock = deps.now || Date.now;
212
+ const checks = deps.checks || CHECKS;
213
+ const minutes = opts.minutes === undefined ? DEFAULT_MINUTES : opts.minutes;
214
+ if (!(typeof minutes === 'number' && minutes > 0 && minutes <= MAX_MINUTES)) {
215
+ return { ok: false, refused: `--minutes must be a number above 0 and at most ${MAX_MINUTES}` };
216
+ }
217
+ if (process.env[rsi.GUARD_ENV]) return { ok: false, refused: 'an rsi attempt is already open in this process; a loop inside it would nest' };
218
+ if (!gitOut(root, ['rev-parse', '--verify', 'HEAD'])) return { ok: false, refused: 'not a git checkout with a commit' };
219
+ if (!gitOut(root, ['remote', 'get-url', 'origin'])) return { ok: false, refused: 'no origin remote to push the fixes to' };
220
+ const lock = acquireLock(root, { isAlive: deps.isAlive || alive });
221
+ if (!lock.ok) {
222
+ const h = lock.holder || {};
223
+ return { ok: false, refused: `another rsi loop holds this workspace (pid ${h.pid || '?'} on ${h.host || '?'})` };
224
+ }
225
+
226
+ const startedMs = clock();
227
+ const deadline = startedMs + minutes * 60000;
228
+ const left = () => deadline - clock();
229
+ const row = {
230
+ schema: LOOP_SCHEMA,
231
+ started_at: new Date(startedMs).toISOString(),
232
+ max_minutes: minutes,
233
+ branch: null,
234
+ resumed: false,
235
+ checks: [],
236
+ commits: 0,
237
+ pushed_sha: null,
238
+ delivered: false,
239
+ cleaned: false,
240
+ kept: null,
241
+ stop_reason: null,
242
+ };
243
+ try {
244
+ await shipOnce(root, row, { checks, left, now: clock });
245
+ } catch (error) {
246
+ row.stop_reason = row.stop_reason || 'error';
247
+ row.error = String((error && error.message) || error);
248
+ } finally {
249
+ lock.release();
250
+ }
251
+ row.finished_at = new Date(clock()).toISOString();
252
+ row.ok = ['delivered', 'nothing_to_do'].includes(row.stop_reason);
253
+ const reportPath = loopsPath(root);
254
+ fs.mkdirSync(path.dirname(reportPath), { recursive: true });
255
+ fs.appendFileSync(reportPath, `${JSON.stringify(row)}\n`, 'utf8');
256
+ return { ...row, report_path: reportPath };
257
+ }
258
+
259
+ async function shipOnce(root, row, { checks, left, now }) {
260
+ if (left() <= 0) { row.stop_reason = 'time_budget'; return; }
261
+ const fetched = git(root, ['fetch', '-q', 'origin'], { timeout: Math.max(1, Math.min(30000, left())), env: worktree.gitNetworkEnvironment(process.env, root) });
262
+ if (fetched.status !== 0) { row.stop_reason = 'fetch_failed'; row.error = String(fetched.stderr || fetched.error || 'fetch failed').trim(); return; }
263
+ if (left() <= 0) { row.stop_reason = 'time_budget'; return; }
264
+ const base = worktree.defaultMainlineBase(root);
265
+ const opened = openCheckout(root, base);
266
+ if (opened.error) { row.stop_reason = 'checkout_failed'; row.error = opened.error; return; }
267
+ const { wt, branch } = opened;
268
+ row.branch = branch;
269
+ row.resumed = opened.resumed;
270
+ const keep = (reason) => { row.stop_reason = reason; row.kept = wt; };
271
+
272
+ for (const check of checks) {
273
+ if (left() <= 0) return keep('time_budget');
274
+ const result = check.run(wt);
275
+ const changes = changedPaths(wt);
276
+ if (!changes) return keep('status_failed');
277
+ const entry = { name: check.name, found: result.found || 0, files: changes.map((c) => c.file), outcome: 'nothing' };
278
+ row.checks.push(entry);
279
+ if (!changes.length) continue;
280
+ if (changes.some((c) => !allowedPath(c.file))) { undo(wt, changes); entry.outcome = 'outside_allowed_paths'; continue; }
281
+ if (!result.verify()) { undo(wt, changes); entry.outcome = 'verify_failed'; continue; }
282
+ const staged = git(wt, ['add', '--', ...entry.files]);
283
+ if (staged.status !== 0) { row.error = String(staged.stderr || 'git add failed').trim(); return keep('stage_failed'); }
284
+ const committed = git(wt, ['commit', '-q', '-m', `${result.message}\n\n${CO_AUTHOR}`]);
285
+ if (committed.status !== 0) {
286
+ entry.outcome = 'commit_blocked';
287
+ row.error = String(committed.stderr || committed.stdout || '').trim().slice(0, 400);
288
+ return keep('commit_blocked');
289
+ }
290
+ entry.outcome = 'committed';
291
+ row.commits += 1;
292
+ }
293
+
294
+ const remoteHead = gitOut(root, ['rev-parse', '--verify', `refs/remotes/origin/${branch}`]);
295
+ const headBeforePush = gitOut(wt, ['rev-parse', 'HEAD']);
296
+ const ahead = remoteHead ? remoteHead !== headBeforePush : gitOut(wt, ['cherry', base, 'HEAD']).split('\n').some((line) => line.startsWith('+'));
297
+ if (!ahead) {
298
+ row.stop_reason = 'nothing_to_do';
299
+ row.cleaned = cleanup(root, wt, branch);
300
+ if (!row.cleaned) row.kept = wt;
301
+ return;
302
+ }
303
+ if (left() <= 0) return keep('time_budget');
304
+ const head = gitOut(wt, ['rev-parse', 'HEAD']);
305
+ const pushed = git(wt, ['push', '-q', 'origin', `HEAD:refs/heads/${branch}`], {
306
+ timeout: left(),
307
+ env: worktree.gitNetworkEnvironment(process.env, wt),
308
+ });
309
+ if (pushed.status !== 0) {
310
+ row.error = String(pushed.stderr || (pushed.error && pushed.error.message) || '').trim().slice(0, 400);
311
+ return keep(pushed.signal || left() <= 0 ? 'time_budget' : 'push_failed');
312
+ }
313
+ const remoteTip = git(wt, ['ls-remote', 'origin', `refs/heads/${branch}`], { timeout: Math.max(1, left()), env: worktree.gitNetworkEnvironment(process.env, wt) }).stdout?.trim().split(/\s+/)[0];
314
+ const clean = (changedPaths(wt) || [null]).length === 0;
315
+ if (remoteTip !== head || !clean) return keep('delivery_unproven');
316
+ row.pushed_sha = head;
317
+ row.delivered = true;
318
+ row.stop_reason = 'delivered';
319
+ row.cleaned = cleanup(root, wt, branch);
320
+ if (!row.cleaned) row.kept = wt;
321
+ }
322
+
323
+ const STOP_WORDS = {
324
+ delivered: 'the fixes are pushed',
325
+ nothing_to_do: 'the checks found nothing to fix',
326
+ time_budget: 'the time ran out before the push',
327
+ push_failed: 'the push was refused',
328
+ commit_blocked: 'a commit was refused',
329
+ delivery_unproven: 'the remote branch does not match the commit',
330
+ checkout_failed: 'the isolated checkout could not be made',
331
+ error: 'something failed',
332
+ };
333
+
334
+ function formatLoopReport(result = {}) {
335
+ if (result.refused) return `rsi loop did not start: ${result.refused}.`;
336
+ const lines = [`rsi loop: ${result.commits} fix commit${result.commits === 1 ? '' : 's'}, stopped because ${STOP_WORDS[result.stop_reason] || result.stop_reason}.`];
337
+ for (const c of result.checks || []) lines.push(` ${c.name}: ${c.outcome}${c.files.length ? ` (${c.files.length} file${c.files.length === 1 ? '' : 's'})` : ''}`);
338
+ if (result.branch) lines.push(`branch: ${result.branch}${result.pushed_sha ? ` at ${result.pushed_sha.slice(0, 12)}` : ''}`);
339
+ if (result.kept) lines.push(`kept for the next run: ${result.kept}`);
340
+ if (result.error) lines.push(`detail: ${String(result.error).split('\n')[0]}`);
341
+ if (result.report_path) lines.push(`report: ${result.report_path}`);
342
+ return lines.join('\n');
343
+ }
344
+
345
+ module.exports = {
346
+ BRANCH_PREFIX,
347
+ CHECKS,
348
+ allowedPath,
349
+ runLoop,
350
+ formatLoopReport,
351
+ loopsPath,
352
+ lockPath,
353
+ };
@@ -8,7 +8,10 @@
8
8
  // so autonomous runs are reproducible, while keeping explicit per-run/env knobs
9
9
  // first in precedence. Precedence: explicit model -> ATRIS_RUNNER_MODEL env ->
10
10
  // ATRIS_RUNNER_PROFILE -> legacy ATRIS_CLAUDE_MODEL env -> pinned default.
11
- const DEFAULT_CLAUDE_RUNNER_MODEL = 'claude-opus-5-5';
11
+ // The default is the cheap model: an unattended loop must never land on a
12
+ // costly one by accident (2026-10-28: loops spent about $900 in one day).
13
+ // Opus, Fable and Sonnet run unattended only through the spend gate below.
14
+ const DEFAULT_CLAUDE_RUNNER_MODEL = 'claude-haiku-5-5';
12
15
  const DEFAULT_FABLE_RUNNER_MODEL = 'claude-fable-5-1';
13
16
  const DEFAULT_CLAUDE_RUNNER_BIN = 'claude';
14
17
  // Effort words a roster line may carry after the model. Each engine lists the
@@ -215,7 +218,63 @@ function runnerProfileValue(key) {
215
218
  return profile && profile[key] ? profile[key] : '';
216
219
  }
217
220
 
221
+ // The spend gate. Every model an unattended run is about to launch passes
222
+ // through here. Opus, Fable and Sonnet need the owner's yes, given as
223
+ // ATRIS_CLAUDE_SPEND_OK=<that exact model id>, so one yes covers one model. Without
224
+ // it the run drops to the cheap model, which keeps loops alive but stops the
225
+ // spend. The warning prints once per model per process.
226
+ const SPEND_GATED_MODEL = /opus|fable|sonnet/i;
227
+ const SPEND_FALLBACK_MODEL = DEFAULT_CLAUDE_RUNNER_MODEL;
228
+ const spendGateWarned = new Set();
229
+
230
+ function isSpendGatedModel(model) {
231
+ return SPEND_GATED_MODEL.test(String(model || ''));
232
+ }
233
+
234
+ // `fallback` is the model to run instead. An engine that is not the claude cli
235
+ // cannot take a claude model id, so it passes '' and rides its own default.
236
+ function applySpendGate(model, { silent = false, fallback = SPEND_FALLBACK_MODEL } = {}) {
237
+ const value = String(model || '').trim();
238
+ if (!value || !isSpendGatedModel(value)) return value;
239
+ if (String(process.env.ATRIS_CLAUDE_SPEND_OK || '').trim() === value) return value;
240
+ if (!silent && !spendGateWarned.has(`${value}>${fallback}`)) {
241
+ spendGateWarned.add(`${value}>${fallback}`);
242
+ const instead = fallback || "that engine's own default model";
243
+ process.stderr.write(`atris: unattended run on ${value} needs the owner's yes; running on ${instead}. To allow it for this run: ATRIS_CLAUDE_SPEND_OK=${value}\n`);
244
+ }
245
+ return fallback;
246
+ }
247
+
248
+ function resetSpendGateWarnings() {
249
+ spendGateWarned.clear();
250
+ }
251
+
252
+ // Engines that run only their own vendor's models drop any claude model name
253
+ // later, so the gate has nothing to stop there and stays quiet.
254
+ function runnerDropsClaudeModels() {
255
+ const name = resolveRunnerProfileName();
256
+ const def = name ? RUNNER_PROFILE_DEFS[RUNNER_PROFILE_ALIASES[name] || name] : null;
257
+ if (def && def.ownModelsOnly) return true;
258
+ return Boolean(ownModelsOnlyEngineForBin(resolveClaudeRunnerBin()));
259
+ }
260
+
261
+ // Whether the launch goes through the claude cli, the only engine that can take
262
+ // the cheap claude fallback by name.
263
+ function runnerRidesClaudeCli() {
264
+ const name = resolveRunnerProfileName();
265
+ const canonical = name ? (RUNNER_PROFILE_ALIASES[name] || name) : '';
266
+ return !canonical || canonical === 'claude' || canonical === 'fable' || canonical === 'haiku';
267
+ }
268
+
269
+ // The choke point: every unattended Claude launch (mission, autopilot, run,
270
+ // pulse, fleet) resolves its model here, directly or via buildRunnerCommand.
218
271
  function resolveClaudeRunnerModel(mission) {
272
+ const model = resolveClaudeRunnerModelUngated(mission);
273
+ if (!isSpendGatedModel(model) || runnerDropsClaudeModels()) return model;
274
+ return applySpendGate(model, { fallback: runnerRidesClaudeCli() ? SPEND_FALLBACK_MODEL : '' });
275
+ }
276
+
277
+ function resolveClaudeRunnerModelUngated(mission) {
219
278
  const explicit = mission && mission.model != null ? normalizeClaudeModelName(mission.model) : '';
220
279
  if (explicit) return explicit;
221
280
  const env = normalizeClaudeModelName(firstConfiguredEnv(['ATRIS_RUNNER_MODEL']));
@@ -233,7 +292,10 @@ function resolveClaudeRunnerModel(mission) {
233
292
  // '' means nothing is pinned, so the engine's CLI picks its own default.
234
293
  function resolvePinnedRunnerModel(mission) {
235
294
  const explicit = mission && mission.model != null ? String(mission.model).trim() : '';
236
- return explicit || runnerProfileValue('model');
295
+ const pinned = explicit || runnerProfileValue('model');
296
+ // Cheap check first: an ungated pin never needs the profile looked up.
297
+ if (!isSpendGatedModel(pinned) || runnerDropsClaudeModels()) return pinned;
298
+ return applySpendGate(pinned, { fallback: runnerRidesClaudeCli() ? SPEND_FALLBACK_MODEL : '' });
237
299
  }
238
300
 
239
301
  // The own-models-only engine a runner bin launches ("codex", "/opt/bin/grok"),
@@ -447,6 +509,8 @@ module.exports = {
447
509
  HIDDEN_PROFILE_NAMES,
448
510
  resolveRunnerProfile,
449
511
  resolveClaudeRunnerModel,
512
+ applySpendGate,
513
+ resetSpendGateWarnings,
450
514
  resolvePinnedRunnerModel,
451
515
  runnerModelFor,
452
516
  resolveClaudeRunnerBin,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.74.22",
3
+ "version": "3.74.23",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",