atris 3.74.22 → 3.74.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/bin/atris.js +10 -1
- package/commands/improve.js +1 -1
- package/commands/mission.js +20 -4
- package/commands/rsi.js +25 -1
- package/commands/scorecard.js +297 -0
- package/commands/spend.js +78 -0
- package/lib/claude-spend-guard.js +288 -0
- package/lib/engine-ask.js +5 -2
- package/lib/engine-validate.js +2 -1
- package/lib/known-commands.js +2 -2
- package/lib/member-save-work.js +2 -1
- package/lib/repo-scorecard.js +1135 -0
- package/lib/roster-models.js +3 -1
- package/lib/rsi-loop.js +353 -0
- package/lib/runner-command.js +66 -2
- package/package.json +1 -1
package/lib/roster-models.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/lib/rsi-loop.js
ADDED
|
@@ -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
|
+
};
|
package/lib/runner-command.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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