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/README.md +46 -4
- package/atris/CLAUDE.md +8 -0
- package/atris.md +8 -0
- package/ax +458 -15
- package/bin/atris.js +200 -76
- package/commands/autoland.js +78 -12
- package/commands/autopilot-front.js +273 -0
- package/commands/engine.js +299 -0
- package/commands/init.js +21 -0
- package/commands/integrations.js +147 -0
- package/commands/member.js +85 -0
- package/commands/mission.js +500 -43
- package/commands/run-front.js +144 -0
- package/commands/run.js +9 -6
- package/commands/sign.js +90 -0
- package/commands/task.js +380 -20
- package/commands/truth.js +72 -11
- package/lib/autoland.js +133 -25
- package/lib/fleet.js +354 -0
- package/lib/inspect-fields.js +174 -0
- package/lib/operator-next.js +7 -0
- package/lib/pulse.js +4 -3
- package/lib/runner-command.js +54 -11
- package/lib/task-db.js +57 -2
- package/package.json +3 -2
- package/scripts/agent_worktree.py +72 -0
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
|
+
};
|
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 =
|
|
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
|
|
423
|
-
#
|
|
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}
|
package/lib/runner-command.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
bin: '
|
|
26
|
-
model: '
|
|
27
|
-
commandTemplate: '{bin}
|
|
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: ${
|
|
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: ${
|
|
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,
|