atris 3.31.0 → 3.33.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/fleet.js ADDED
@@ -0,0 +1,354 @@
1
+ 'use strict';
2
+
3
+ // The fleet conductor's primitives. Humble name outside (`atris mission run
4
+ // --fleet`), the full loop inside: staff every idle engine on claimable
5
+ // safe-lane tasks, one mission per task, one worktree per engine, land
6
+ // arrivals serially. Proven by hand on 2026-07-02 (PRs #191-193, three
7
+ // engines) before any of this code existed — each function here is one thing
8
+ // the orchestrator did manually that day.
9
+ //
10
+ // INVARIANT: the fleet has NO state file. Flight state IS missions +
11
+ // worktrees + receipts. Kill a flight mid-run and nothing is orphaned — it
12
+ // is just missions in flight, resumable the normal way.
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { spawnSync } = require('child_process');
17
+ const { RUNNER_PROFILE_DEFS, buildRunnerCommand } = require('./runner-command');
18
+
19
+ // Lanes a fleet may never staff on its own: the human keeps irreversible
20
+ // calls. Mirrors the autoland denied lanes.
21
+ const DENIED_TAGS = ['billing', 'deploy', 'security', 'customer', 'external', 'feedback', 'voice'];
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // T1 — dispatch primitive
25
+
26
+ // A task's own text is its spec: the board convention writes "Done: ..." and
27
+ // "Check: ..." into the title/description. Extract both so the dispatch
28
+ // prompt is generated, never hand-written.
29
+ function parseDoneCheck(text) {
30
+ const s = String(text || '');
31
+ const done = (s.match(/Done:\s*([^]*?)(?=Check:|$)/i) || [])[1];
32
+ const check = (s.match(/Check:\s*([^]*?)$/i) || [])[1];
33
+ return {
34
+ done: done ? done.trim().replace(/\s+/g, ' ') : '',
35
+ check: check ? check.trim().replace(/\s+/g, ' ') : '',
36
+ };
37
+ }
38
+
39
+ // The bounded prompt every engine gets. Same contract the manual flight used:
40
+ // isolated worktree, commit never push, MAP first, focused verify, report.
41
+ function buildFleetPrompt(task, { worktreePath } = {}) {
42
+ const ref = task.display_id || task.id || 'TASK';
43
+ const title = String(task.title || '').trim();
44
+ const { done, check } = parseDoneCheck(title);
45
+ const lines = [
46
+ `You are working task ${ref} in this repo checkout (an isolated git worktree${worktreePath ? ` at ${worktreePath}` : ''} — commit here, NEVER push).`,
47
+ '',
48
+ `Task: ${title}`,
49
+ '',
50
+ ];
51
+ if (done) lines.push(`Done criteria: ${done}`, '');
52
+ if (check) lines.push(`Check: ${check}`, '');
53
+ lines.push(
54
+ 'Rules:',
55
+ '- Read atris/MAP.md first to locate the code; never guess file locations.',
56
+ '- Run git status first. Stage ONLY files you changed. Never revert or touch files another agent modified.',
57
+ '- Include or update a focused regression test; run it with node --test before committing.',
58
+ '- Commit on the current branch with a clear message. Do not push. Do not create branches.',
59
+ '',
60
+ 'Final report (plain text): files changed, test command + result, commit sha (or say the commit failed and why).'
61
+ );
62
+ return lines.join('\n');
63
+ }
64
+
65
+ // Shell command that runs one engine on one prompt file, from the worktree.
66
+ // Rides the same profile definitions the whole CLI uses — no fleet-only
67
+ // spawn shapes.
68
+ // Build work needs file tools: allowedTools reaches the default claude-shaped
69
+ // spawn; template engines (cursor/codex/devin) ignore it — their CLIs manage
70
+ // their own permissions.
71
+ const FLEET_ALLOWED_TOOLS = 'Bash,Read,Edit,Write,Grep,Glob';
72
+
73
+ function buildEngineCommand(engineName, promptFile) {
74
+ if (!RUNNER_PROFILE_DEFS[engineName]) throw new Error(`unknown engine "${engineName}"`);
75
+ const prev = process.env.ATRIS_RUNNER_PROFILE;
76
+ process.env.ATRIS_RUNNER_PROFILE = engineName;
77
+ try {
78
+ const cmd = buildRunnerCommand({ promptFile, allowedTools: FLEET_ALLOWED_TOOLS });
79
+ // devin's default is read-only for writes; fleet builds ALWAYS run in an
80
+ // isolated worktree, so the conductor grants write permission here and
81
+ // only here (the profile itself stays safe for non-worktree ticks).
82
+ if (engineName === 'devin') return cmd.replace(/^devin -p /, 'devin -p --permission-mode dangerous ');
83
+ return cmd;
84
+ } finally {
85
+ if (prev === undefined) delete process.env.ATRIS_RUNNER_PROFILE;
86
+ else process.env.ATRIS_RUNNER_PROFILE = prev;
87
+ }
88
+ }
89
+
90
+ // Run one engine on one task in one worktree. Blocking; the conductor runs
91
+ // dispatches in parallel via child processes, not threads. `runner` is
92
+ // injectable for tests.
93
+ function dispatchToEngine({ task, engine, worktreePath, timeoutMs = 900000, runner = null }) {
94
+ const prompt = buildFleetPrompt(task, { worktreePath });
95
+ const promptFile = path.join(worktreePath, '.atris', `fleet-prompt-${task.display_id || 'task'}.md`);
96
+ fs.mkdirSync(path.dirname(promptFile), { recursive: true });
97
+ fs.writeFileSync(promptFile, prompt);
98
+ const command = buildEngineCommand(engine, promptFile);
99
+ const exec = runner || ((cmd) => spawnSync('sh', ['-c', cmd], {
100
+ cwd: worktreePath,
101
+ encoding: 'utf8',
102
+ timeout: timeoutMs,
103
+ }));
104
+ const result = exec(command);
105
+ return {
106
+ task: task.display_id || task.id,
107
+ engine,
108
+ worktreePath,
109
+ command,
110
+ exitCode: result.status,
111
+ report: String(result.stdout || '').slice(-8000),
112
+ stderr: String(result.stderr || '').slice(-2000),
113
+ };
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // T2 — staffing
118
+
119
+ function taskTags(task) {
120
+ const fromTags = Array.isArray(task.tags) ? task.tags : [];
121
+ const fromTitle = (String(task.title || '').match(/#([a-z0-9-]+)/gi) || []).map((t) => t.slice(1));
122
+ return [...fromTags, ...fromTitle].map((t) => String(t).toLowerCase());
123
+ }
124
+
125
+ function isSafeLane(task) {
126
+ const tags = taskTags(task);
127
+ return !tags.some((t) => DENIED_TAGS.includes(t));
128
+ }
129
+
130
+ // Rough file-surface guess from a task's text: paths and bare `commands/x.js`
131
+ // style mentions. Used only to keep concurrent picks disjoint — the merge
132
+ // conflict on 2026-07-02 (two engines in commands/mission.js) is the tax this
133
+ // avoids.
134
+ function fileSurface(task) {
135
+ const text = String(task.title || '');
136
+ const matches = text.match(/\b(?:commands|lib|bin|test|scripts)\/[A-Za-z0-9._/-]+/g) || [];
137
+ return [...new Set(matches)];
138
+ }
139
+
140
+ function surfacesOverlap(a, b) {
141
+ return a.some((f) => b.includes(f));
142
+ }
143
+
144
+ // Pick up to `slots` open, safe-lane, mutually file-disjoint tasks. Tasks
145
+ // whose surface cannot be guessed are allowed one at a time (the unknown
146
+ // surface could overlap anything).
147
+ function staffFlight(tasks, { slots = 3 } = {}) {
148
+ const open = (tasks || []).filter((t) => t && t.status === 'open' && isSafeLane(t));
149
+ const picked = [];
150
+ let blindPicked = false;
151
+ for (const task of open) {
152
+ if (picked.length >= slots) break;
153
+ const surface = fileSurface(task);
154
+ if (surface.length === 0) {
155
+ if (blindPicked) continue;
156
+ blindPicked = true;
157
+ picked.push({ task, surface });
158
+ continue;
159
+ }
160
+ if (picked.some((p) => surfacesOverlap(p.surface, surface))) continue;
161
+ picked.push({ task, surface });
162
+ }
163
+ return picked.map((p, i) => ({ task: p.task, surface: p.surface, slot: i }));
164
+ }
165
+
166
+ // Pair staffed tasks with installed engines, round-robin. `engines` comes
167
+ // from commands/engine.js roster (installed only).
168
+ function assignEngines(staffed, engines) {
169
+ const pool = (engines || []).filter(Boolean);
170
+ if (!pool.length) return [];
171
+ return staffed.map((entry, i) => ({ ...entry, engine: pool[i % pool.length] }));
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // T3 — serial landing lane
176
+
177
+ // Land one arrival: rebase onto latest base first; a conflict pauses the
178
+ // landing (never auto-resolve) and reports which files collided so the
179
+ // conductor or a human can take it. `git` is injectable for tests.
180
+ function landArrival({ worktreePath, git = null }) {
181
+ const run = git || ((args) => spawnSync('git', args, { cwd: worktreePath, encoding: 'utf8' }));
182
+ const fetch = run(['fetch', 'origin']);
183
+ if (fetch.status !== 0) return { ok: false, stage: 'fetch', detail: String(fetch.stderr || '').trim() };
184
+ const rebase = run(['rebase', 'origin/master']);
185
+ if (rebase.status !== 0) {
186
+ const conflicted = run(['diff', '--name-only', '--diff-filter=U']);
187
+ run(['rebase', '--abort']);
188
+ return {
189
+ ok: false,
190
+ stage: 'rebase_conflict',
191
+ conflicts: String(conflicted.stdout || '').trim().split('\n').filter(Boolean),
192
+ };
193
+ }
194
+ return { ok: true, stage: 'rebased' };
195
+ }
196
+
197
+ module.exports = {
198
+ DENIED_TAGS,
199
+ get FLEET_CAPABLE() { return FLEET_CAPABLE; },
200
+ get runFleetFlight() { return runFleetFlight; },
201
+ get focusedCheck() { return focusedCheck; },
202
+ parseDoneCheck,
203
+ buildFleetPrompt,
204
+ buildEngineCommand,
205
+ dispatchToEngine,
206
+ taskTags,
207
+ isSafeLane,
208
+ fileSurface,
209
+ surfacesOverlap,
210
+ staffFlight,
211
+ assignEngines,
212
+ landArrival,
213
+ };
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // T4 — the conductor: one flight, watchable, receipted
217
+
218
+ // Engines that can edit a repo headlessly. atris-fast (ax) is a chat lane,
219
+ // not a repo worker — it keeps owning normal mission ticks, not fleet builds.
220
+ const FLEET_CAPABLE = ['claude', 'codex', 'cursor', 'devin'];
221
+
222
+ function nowStamp() {
223
+ return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
224
+ }
225
+
226
+ function readProjectionTasks(root) {
227
+ try {
228
+ const raw = fs.readFileSync(path.join(root, '.atris', 'state', 'tasks.projection.json'), 'utf8');
229
+ const projection = JSON.parse(raw);
230
+ return Array.isArray(projection.tasks) ? projection.tasks : [];
231
+ } catch {
232
+ return [];
233
+ }
234
+ }
235
+
236
+ function defaultOwnCli(root) {
237
+ const bin = path.resolve(__dirname, '..', 'bin', 'atris.js');
238
+ return (cliArgs, cwd = root) => {
239
+ const result = spawnSync(process.execPath, [bin, ...cliArgs], { cwd, encoding: 'utf8', timeout: 600000 });
240
+ return { status: result.status, stdout: String(result.stdout || ''), stderr: String(result.stderr || '') };
241
+ };
242
+ }
243
+
244
+ // Extract a runnable focused check from the task's Check: line; the wide
245
+ // gates (npm test, CI) stay at the ship gate, not per-arrival.
246
+ function focusedCheck(task) {
247
+ const { check } = parseDoneCheck(task.title);
248
+ const m = check.match(/node --test [^,;.]+/);
249
+ return m ? m[0].trim() : '';
250
+ }
251
+
252
+ // One flight. Staff -> dispatch in parallel -> land serially -> receipt.
253
+ // No fleet state file: progress is narrated via `log`, durability lives in
254
+ // worktrees, task claims, and the receipt written at the end.
255
+ async function runFleetFlight({
256
+ root = process.cwd(),
257
+ slots = 3,
258
+ engines = null,
259
+ dryRun = false,
260
+ log = console.log,
261
+ ownCli = null,
262
+ dispatcher = null,
263
+ lander = null,
264
+ } = {}) {
265
+ const cli = ownCli || defaultOwnCli(root);
266
+ const roster = engines || (() => {
267
+ const { roster: fullRoster } = require('../commands/engine');
268
+ return fullRoster(root).filter((e) => e.installed && FLEET_CAPABLE.includes(e.name)).map((e) => e.name);
269
+ })();
270
+
271
+ const staffed = assignEngines(staffFlight(readProjectionTasks(root), { slots }), roster);
272
+ const flight = {
273
+ at: new Date().toISOString(),
274
+ root,
275
+ slots,
276
+ roster,
277
+ staffed: staffed.map((s) => ({ task: s.task.display_id, title: String(s.task.title || '').slice(0, 140), engine: s.engine, surface: s.surface })),
278
+ results: [],
279
+ landed: [],
280
+ paused: [],
281
+ };
282
+
283
+ log('');
284
+ log(` fleet — ${roster.length} engine${roster.length === 1 ? '' : 's'} ready, ${staffed.length} task${staffed.length === 1 ? '' : 's'} staffed`);
285
+ for (const s of staffed) log(` ${s.engine.padEnd(8)} → ${s.task.display_id} ${String(s.task.title || '').slice(0, 80)}`);
286
+ log('');
287
+
288
+ if (dryRun || staffed.length === 0) {
289
+ flight.dry_run = true;
290
+ return flight;
291
+ }
292
+
293
+ // Claim + cut a worktree per assignment, then dispatch all in parallel.
294
+ const dispatch = dispatcher || ((entry) => new Promise((resolve) => {
295
+ resolve(dispatchToEngine({ task: entry.task, engine: entry.engine, worktreePath: entry.worktreePath }));
296
+ }));
297
+
298
+ const prepared = [];
299
+ for (const entry of staffed) {
300
+ cli(['task', 'claim', String(entry.task.display_id), '--as', `fleet-${entry.engine}`]);
301
+ const started = cli(['worktree', 'start', '--agent', entry.engine, '--task', `fleet-${String(entry.task.display_id).toLowerCase()}`]);
302
+ const wt = (started.stdout.match(/next: cd (.+)/) || [])[1];
303
+ if (!wt) {
304
+ flight.paused.push({ task: entry.task.display_id, stage: 'worktree_start', detail: started.stderr.slice(0, 200) });
305
+ continue;
306
+ }
307
+ prepared.push({ ...entry, worktreePath: wt.trim() });
308
+ log(` ${entry.engine.padEnd(8)} building ${entry.task.display_id} in ${path.basename(wt.trim())}`);
309
+ }
310
+
311
+ const results = await Promise.all(prepared.map((entry) =>
312
+ dispatch(entry).then((r) => ({ entry, result: r })).catch((err) => ({ entry, result: { exitCode: 1, report: '', stderr: String(err && err.message || err) } }))
313
+ ));
314
+ flight.results = results.map(({ entry, result }) => ({ task: entry.task.display_id, engine: entry.engine, exitCode: result.exitCode }));
315
+
316
+ // Land serially: rebase-before-ship, conflict pauses (never auto-resolve).
317
+ const land = lander || (({ entry }) => {
318
+ const rebased = landArrival({ worktreePath: entry.worktreePath });
319
+ if (!rebased.ok) return rebased;
320
+ const check = focusedCheck(entry.task) || 'git log -1 --oneline';
321
+ const shipped = cli(['worktree', 'ship', '--message', `${String(entry.task.title || '').split(/[.:]/)[0].slice(0, 90)} (${entry.task.display_id}, built by ${entry.engine})`, '--verify', check, '--merge'], entry.worktreePath);
322
+ if (shipped.status !== 0 || !/done: worktree shipped/.test(shipped.stdout)) {
323
+ return { ok: false, stage: 'ship', detail: (shipped.stderr || shipped.stdout).slice(-300) };
324
+ }
325
+ return { ok: true, stage: 'shipped' };
326
+ });
327
+
328
+ for (const { entry, result } of results) {
329
+ if (result.exitCode !== 0) {
330
+ flight.paused.push({ task: entry.task.display_id, engine: entry.engine, stage: 'build', detail: (result.stderr || '').slice(0, 200) });
331
+ log(` ${entry.engine.padEnd(8)} ✗ build failed ${entry.task.display_id} — worktree kept for takeover`);
332
+ continue;
333
+ }
334
+ log(` ${entry.engine.padEnd(8)} landing ${entry.task.display_id}...`);
335
+ const landed = land({ entry, result });
336
+ if (landed.ok) {
337
+ flight.landed.push({ task: entry.task.display_id, engine: entry.engine });
338
+ cli(['task', 'ready', String(entry.task.display_id), '--proof', `Built by ${entry.engine} engine in fleet flight, landed via worktree ship gate (rebase-before-ship, verify re-run). Report tail: ${String(result.report || '').slice(-300).replace(/\n/g, ' ')}`, '--as', `fleet-${entry.engine}`]);
339
+ log(` ${entry.engine.padEnd(8)} ✓ landed ${entry.task.display_id}`);
340
+ } else {
341
+ flight.paused.push({ task: entry.task.display_id, engine: entry.engine, ...landed });
342
+ log(` ${entry.engine.padEnd(8)} ⏸ paused ${entry.task.display_id} at ${landed.stage}${landed.conflicts ? ` (${landed.conflicts.join(', ')})` : ''} — worktree kept`);
343
+ }
344
+ }
345
+
346
+ const receiptDir = path.join(root, 'atris', 'runs');
347
+ fs.mkdirSync(receiptDir, { recursive: true });
348
+ flight.receipt = path.join(receiptDir, `fleet-${nowStamp()}.json`);
349
+ fs.writeFileSync(flight.receipt, `${JSON.stringify(flight, null, 2)}\n`);
350
+ log('');
351
+ log(` flight over: ${flight.landed.length} landed, ${flight.paused.length} paused · receipt: ${path.relative(root, flight.receipt)}`);
352
+ log('');
353
+ return flight;
354
+ }
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+
3
+ const MISSION_INSPECT_FIELDS = new Set(['status', 'runner', 'ack', 'pings']);
4
+
5
+ const TASK_INSPECT_FIELDS = new Set([
6
+ 'status',
7
+ 'title',
8
+ 'claimed_by',
9
+ 'tag',
10
+ 'review',
11
+ ]);
12
+
13
+ function parseFieldList(raw) {
14
+ return String(raw || '')
15
+ .split(',')
16
+ .map((part) => part.trim().toLowerCase())
17
+ .filter(Boolean);
18
+ }
19
+
20
+ function readFieldsFlag(args, flagName = '--fields') {
21
+ const index = args.indexOf(flagName);
22
+ if (index === -1) return null;
23
+ const raw = args[index + 1];
24
+ if (!raw || String(raw).startsWith('--')) {
25
+ return { error: `${flagName} requires a comma-separated field list` };
26
+ }
27
+ const fields = parseFieldList(raw);
28
+ if (!fields.length) {
29
+ return { error: `${flagName} requires at least one field` };
30
+ }
31
+ return { fields };
32
+ }
33
+
34
+ function stripInspectArgs(args, { flagName = '--fields' } = {}) {
35
+ const out = [];
36
+ for (let i = 0; i < args.length; i += 1) {
37
+ const arg = String(args[i]);
38
+ if (arg === '--json') continue;
39
+ if (arg === flagName) {
40
+ i += 1;
41
+ continue;
42
+ }
43
+ out.push(args[i]);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ function validateFields(fields, allowed, label) {
49
+ const unknown = fields.filter((field) => !allowed.has(field));
50
+ if (!unknown.length) return null;
51
+ return `Unknown ${label} inspect field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}. Allowed: ${[...allowed].join(', ')}`;
52
+ }
53
+
54
+ function missionAckInspectValue(mission) {
55
+ const ack = mission && mission.native_goal_ack;
56
+ if (!ack || typeof ack !== 'object') {
57
+ return {
58
+ acknowledged: false,
59
+ status: null,
60
+ runtime: null,
61
+ objective: null,
62
+ acknowledged_at: null,
63
+ };
64
+ }
65
+ return {
66
+ acknowledged: true,
67
+ status: ack.status || null,
68
+ runtime: ack.runtime || null,
69
+ objective: ack.objective || null,
70
+ acknowledged_at: ack.acknowledged_at || null,
71
+ };
72
+ }
73
+
74
+ function missionPingsInspectValue(mission) {
75
+ const pings = Array.isArray(mission && mission.pings) ? mission.pings.filter(Boolean) : [];
76
+ const pending = pings.filter((ping) => ping && !ping.consumed_at).length;
77
+ return {
78
+ total: pings.length,
79
+ pending,
80
+ };
81
+ }
82
+
83
+ function missionInspectFieldValues(mission, fields) {
84
+ const values = {};
85
+ for (const field of fields) {
86
+ switch (field) {
87
+ case 'status':
88
+ values.status = mission.status || null;
89
+ break;
90
+ case 'runner': {
91
+ const runner = mission.runner || 'manual';
92
+ values.runner = mission.model ? `${runner} (${mission.model})` : runner;
93
+ break;
94
+ }
95
+ case 'ack':
96
+ values.ack = missionAckInspectValue(mission);
97
+ break;
98
+ case 'pings':
99
+ values.pings = missionPingsInspectValue(mission);
100
+ break;
101
+ default:
102
+ break;
103
+ }
104
+ }
105
+ return values;
106
+ }
107
+
108
+ function formatInspectScalar(value) {
109
+ if (value === null || value === undefined) return 'null';
110
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
111
+ if (typeof value === 'number') return String(value);
112
+ if (typeof value === 'string') return value;
113
+ return JSON.stringify(value);
114
+ }
115
+
116
+ function formatInspectFieldText(field, value) {
117
+ if (field === 'ack') {
118
+ if (!value || value.acknowledged !== true) return 'unacknowledged';
119
+ const bits = [value.status || 'acknowledged'];
120
+ if (value.acknowledged_at) bits.push(`@ ${value.acknowledged_at}`);
121
+ return bits.join(' ');
122
+ }
123
+ if (field === 'pings') {
124
+ if (!value || typeof value !== 'object') return '0';
125
+ if (value.pending === value.total) return String(value.pending);
126
+ return `${value.pending} pending / ${value.total} total`;
127
+ }
128
+ if (field === 'runner' && value && typeof value === 'object') {
129
+ return value.model ? `${value.runner} (${value.model})` : String(value.runner || 'manual');
130
+ }
131
+ if (field === 'review' && value && typeof value === 'object') {
132
+ return JSON.stringify(value);
133
+ }
134
+ return formatInspectScalar(value);
135
+ }
136
+
137
+ function inspectTextLines(fields, values) {
138
+ if (fields.length === 1) {
139
+ const field = fields[0];
140
+ return [formatInspectFieldText(field, values[field])];
141
+ }
142
+ return fields.map((field) => `${field}: ${formatInspectFieldText(field, values[field])}`);
143
+ }
144
+
145
+ function buildInspectPayload({
146
+ action,
147
+ idKey,
148
+ idValue,
149
+ fields,
150
+ values,
151
+ }) {
152
+ return {
153
+ ok: true,
154
+ action,
155
+ [idKey]: idValue,
156
+ fields: values,
157
+ requested_fields: fields,
158
+ };
159
+ }
160
+
161
+ module.exports = {
162
+ MISSION_INSPECT_FIELDS,
163
+ TASK_INSPECT_FIELDS,
164
+ parseFieldList,
165
+ readFieldsFlag,
166
+ stripInspectArgs,
167
+ validateFields,
168
+ missionAckInspectValue,
169
+ missionPingsInspectValue,
170
+ missionInspectFieldValues,
171
+ formatInspectFieldText,
172
+ inspectTextLines,
173
+ buildInspectPayload,
174
+ };
@@ -0,0 +1,7 @@
1
+ function printOperatorNext(command, { label = 'Next' } = {}) {
2
+ const clean = String(command || '').trim();
3
+ if (!clean) return;
4
+ console.log(`${label}: ${clean}`);
5
+ }
6
+
7
+ module.exports = { printOperatorNext };
package/lib/pulse.js CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
+ const { DEFAULT_CLAUDE_RUNNER_MODEL } = require('./runner-command');
17
18
 
18
19
  const PULSE_RECEIPT_SCHEMA = 'atris.pulse_tick.v1';
19
20
  // Reuse the improve-tick scorecard schema so the brain + policy-lessons see
@@ -354,7 +355,7 @@ function buildTickScript(opts = {}) {
354
355
  stateHome,
355
356
  deadlineEpoch,
356
357
  marker = PULSE_MARKER,
357
- model = 'opus',
358
+ model = DEFAULT_CLAUDE_RUNNER_MODEL,
358
359
  runnerProfile = '',
359
360
  runnerBin = '',
360
361
  runnerCommandTemplate = '',
@@ -419,8 +420,8 @@ log="$LOG_DIR/$stamp.log"
419
420
 
420
421
  cd "$ROOT" || { echo "$(date -Iseconds) ROOT missing" >> "$LOG_DIR/control.log"; exit 1; }
421
422
 
422
- # Autonomous ticks must target a live model alias, never a versioned id that can
423
- # retire out from under the loop (lesson: retired-model-kills-loop-silently).
423
+ # Autonomous ticks use the pinned default unless the installer supplied a model.
424
+ # Operators can still override per install with --model or env.
424
425
  ${runnerModelExport}
425
426
  ${runnerProfileExport}
426
427
  ${runnerBinExport}
@@ -10,24 +10,64 @@
10
10
  // ATRIS_RUNNER_PROFILE -> legacy ATRIS_CLAUDE_MODEL env -> pinned default.
11
11
  const DEFAULT_CLAUDE_RUNNER_MODEL = 'claude-opus-4-8';
12
12
  const DEFAULT_CLAUDE_RUNNER_BIN = 'claude';
13
- const RUNNER_PROFILES = Object.freeze({
13
+
14
+ // Canonical runner profiles. Each entry is the config an operator would pick
15
+ // on purpose. Aliases (spelling variants of the same profile) resolve to the
16
+ // canonical config but are kept OUT of operator-facing lists so config errors
17
+ // stay honest: "one of: atris-fast", not three duplicate spellings.
18
+ const RUNNER_PROFILE_DEFS = Object.freeze({
19
+ // House engine first: the default intelligence is our own.
14
20
  'atris-fast': Object.freeze({
15
21
  bin: 'ax',
16
22
  model: 'atris:fast',
17
23
  commandTemplate: '{bin} --fast {prompt}',
18
24
  }),
19
- 'atris2-fast': Object.freeze({
20
- bin: 'ax',
21
- model: 'atris:fast',
22
- commandTemplate: '{bin} --fast {prompt}',
25
+ // Guest engines: any installed headless coding CLI is a swappable worker.
26
+ // No commandTemplate on claude — the default spawn shape is already
27
+ // claude-compatible (bin -p prompt --model X --allowedTools ...).
28
+ claude: Object.freeze({
29
+ bin: 'claude',
30
+ model: '',
31
+ commandTemplate: '',
23
32
  }),
24
- 'atris-2-fast': Object.freeze({
25
- bin: 'ax',
26
- model: 'atris:fast',
27
- commandTemplate: '{bin} --fast {prompt}',
33
+ codex: Object.freeze({
34
+ bin: 'codex',
35
+ model: '',
36
+ commandTemplate: '{bin} exec {prompt}',
28
37
  }),
38
+ cursor: Object.freeze({
39
+ bin: 'cursor-agent',
40
+ model: '',
41
+ commandTemplate: '{bin} --trust -p {prompt}',
42
+ }),
43
+ devin: Object.freeze({
44
+ bin: 'devin',
45
+ model: '',
46
+ commandTemplate: '{bin} -p -- {prompt}',
47
+ }),
48
+ });
49
+
50
+ // Alias -> canonical profile name. Every alias resolves to the same config as
51
+ // its canonical target; adding a spelling variant here is a config change, not
52
+ // a duplicated profile body that can drift.
53
+ const RUNNER_PROFILE_ALIASES = Object.freeze({
54
+ 'atris2-fast': 'atris-fast',
55
+ 'atris-2-fast': 'atris-fast',
29
56
  });
30
57
 
58
+ // Back-compat surface: RUNNER_PROFILES still resolves every accepted name
59
+ // (canonical + alias) to its frozen config, so existing lookups by any spelling
60
+ // keep working.
61
+ const RUNNER_PROFILES = Object.freeze(
62
+ Object.fromEntries([
63
+ ...Object.entries(RUNNER_PROFILE_DEFS),
64
+ ...Object.entries(RUNNER_PROFILE_ALIASES).map(([alias, target]) => [alias, RUNNER_PROFILE_DEFS[target]]),
65
+ ])
66
+ );
67
+
68
+ // Operator-facing list: canonical profile names only (no alias noise).
69
+ const RUNNER_PROFILE_NAMES = Object.freeze(Object.keys(RUNNER_PROFILE_DEFS));
70
+
31
71
  function shellWord(value) {
32
72
  const s = String(value || '');
33
73
  if (/^[A-Za-z0-9_./:-]+$/.test(s)) return s;
@@ -51,7 +91,7 @@ function resolveRunnerProfile() {
51
91
  if (!name) return null;
52
92
  const profile = RUNNER_PROFILES[name];
53
93
  if (!profile) {
54
- throw new Error(`Unknown ATRIS_RUNNER_PROFILE "${name}". Known profiles: ${Object.keys(RUNNER_PROFILES).join(', ')}`);
94
+ throw new Error(`Unknown ATRIS_RUNNER_PROFILE "${name}". Known profiles: ${RUNNER_PROFILE_NAMES.join(', ')}`);
55
95
  }
56
96
  return profile;
57
97
  }
@@ -98,7 +138,7 @@ function buildRunnerAvailabilityCommand() {
98
138
  function runnerAvailabilityFailureMessage(error) {
99
139
  const message = error && error.message ? String(error.message).trim() : '';
100
140
  if (message.startsWith('Unknown ATRIS_RUNNER_PROFILE')) {
101
- return `${message}. Set ATRIS_RUNNER_PROFILE to one of: ${Object.keys(RUNNER_PROFILES).join(', ')}.`;
141
+ return `${message}. Set ATRIS_RUNNER_PROFILE to one of: ${RUNNER_PROFILE_NAMES.join(', ')}.`;
102
142
  }
103
143
 
104
144
  let runnerBin = 'configured runner';
@@ -154,6 +194,9 @@ module.exports = {
154
194
  DEFAULT_CLAUDE_RUNNER_MODEL,
155
195
  DEFAULT_CLAUDE_RUNNER_BIN,
156
196
  RUNNER_PROFILES,
197
+ RUNNER_PROFILE_DEFS,
198
+ RUNNER_PROFILE_ALIASES,
199
+ RUNNER_PROFILE_NAMES,
157
200
  resolveRunnerProfileName,
158
201
  resolveRunnerProfile,
159
202
  resolveRunnerModel: resolveClaudeRunnerModel,