atris 3.35.0 → 3.37.0
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/AGENTS.md +37 -0
- package/README.md +5 -3
- package/atris/GETTING_STARTED.md +1 -1
- package/atris/atris.md +3 -0
- package/atris/policies/day-loop-voice.md +102 -0
- package/atris/policies/outbound-artifact-gate.md +2 -0
- package/atris/skills/design/SKILL.md +56 -32
- package/atris/skills/endgame/SKILL.md +12 -6
- package/atris/skills/engines/SKILL.md +22 -4
- package/atris/skills/fable-method/SKILL.md +66 -0
- package/atris/skills/improve/SKILL.md +65 -45
- package/atris/skills/youtube/SKILL.md +10 -1
- package/atris.md +2 -0
- package/ax +147 -19
- package/bin/atris.js +576 -267
- package/commands/activate.js +194 -88
- package/commands/agents.js +166 -0
- package/commands/autoland.js +459 -107
- package/commands/autopilot-front.js +20 -2
- package/commands/autopilot.js +118 -2
- package/commands/avail.js +407 -0
- package/commands/bench.js +188 -0
- package/commands/brain.js +3 -0
- package/commands/brief.js +651 -0
- package/commands/business-sync.js +192 -6
- package/commands/clean.js +50 -24
- package/commands/close.js +1083 -0
- package/commands/cloud.js +245 -0
- package/commands/compile.js +292 -1
- package/commands/computer.js +150 -3
- package/commands/dream.js +365 -0
- package/commands/drill.js +371 -0
- package/commands/engine.js +993 -32
- package/commands/experiments.js +28 -0
- package/commands/feedback.js +34 -12
- package/commands/fleet-report.js +206 -0
- package/commands/gm.js +23 -0
- package/commands/goal.js +247 -0
- package/commands/improve.js +642 -26
- package/commands/init.js +72 -44
- package/commands/interview.js +67 -1
- package/commands/land.js +152 -52
- package/commands/lifecycle.js +39 -3
- package/commands/log.js +84 -1
- package/commands/loops.js +220 -16
- package/commands/meet.js +220 -0
- package/commands/member.js +511 -34
- package/commands/mission.js +3144 -338
- package/commands/next.js +137 -0
- package/commands/now.js +220 -25
- package/commands/one-lap.js +776 -0
- package/commands/orb.js +314 -0
- package/commands/pack-craft.js +179 -0
- package/commands/pack.js +823 -0
- package/commands/play.js +3 -2
- package/commands/probe.js +30 -3
- package/commands/pulse.js +241 -46
- package/commands/push.js +260 -82
- package/commands/rainmaker.js +49 -0
- package/commands/report.js +415 -0
- package/commands/scout.js +147 -0
- package/commands/search.js +363 -0
- package/commands/skill.js +47 -3
- package/commands/slop.js +50 -2
- package/commands/soul.js +1 -1
- package/commands/stream.js +861 -0
- package/commands/study.js +693 -0
- package/commands/sync.js +67 -54
- package/commands/task.js +1346 -117
- package/commands/team.js +73 -0
- package/commands/verify.js +96 -0
- package/commands/watch.js +303 -0
- package/commands/wish.js +500 -0
- package/commands/workflow.js +11 -5
- package/commands/worktree.js +234 -13
- package/commands/xp.js +29 -11
- package/lib/auto-accept-certified.js +331 -34
- package/lib/autoland.js +319 -54
- package/lib/ax-auto-lane.js +96 -0
- package/lib/bench/context.js +147 -0
- package/lib/bench/engines.js +141 -0
- package/lib/bench/report.js +140 -0
- package/lib/bench/runner.js +512 -0
- package/lib/brief-ledger.js +350 -0
- package/lib/cloud-mission.js +259 -0
- package/lib/codex-flight.js +154 -0
- package/lib/default-runner.js +45 -0
- package/lib/default-verifier.js +70 -0
- package/lib/engine-registry.js +232 -0
- package/lib/experiments/daily.js +640 -0
- package/lib/fleet.js +2219 -67
- package/lib/improve-vitals-html.js +171 -0
- package/lib/known-commands.js +58 -0
- package/lib/loop-doctor.js +416 -0
- package/lib/member-switches.js +144 -0
- package/lib/mission-room.js +1 -0
- package/lib/mission-root.js +52 -0
- package/lib/next-moves.js +327 -10
- package/lib/one-lap-validator.js +60 -0
- package/lib/orb-context.js +477 -0
- package/lib/orb-scorecard.js +224 -0
- package/lib/policy-lessons.js +52 -1
- package/lib/pulse.js +277 -3
- package/lib/receipt-block.js +168 -0
- package/lib/receipt-evidence.js +65 -4
- package/lib/router-brain.js +352 -0
- package/lib/runner-command.js +10 -0
- package/lib/self-drive.js +258 -0
- package/lib/short-name.js +103 -0
- package/lib/spawn-env.js +18 -0
- package/lib/state-detection.js +56 -1
- package/lib/sync-status.js +59 -0
- package/lib/task-db.js +108 -29
- package/lib/task-proof.js +23 -1
- package/lib/team-presence.js +260 -0
- package/lib/tool-result-encode.js +7 -0
- package/lib/trust-tiers.js +90 -0
- package/lib/usage.js +107 -0
- package/lib/voice-gate.js +163 -0
- package/lib/wish-audit.js +1368 -0
- package/lib/wish-delegate.js +1840 -0
- package/lib/wish-design.js +110 -0
- package/lib/wish-stats.js +183 -0
- package/lib/wish-store.js +354 -0
- package/lib/zip.js +221 -0
- package/package.json +3 -1
- package/templates/loops/atris/loops/LOOPS.md +55 -0
- package/templates/loops/atris/loops/TICK.md +24 -0
- package/templates/loops/atris/loops/feedback.md +22 -0
- package/templates/loops/atris/loops/quality.md +22 -0
- package/templates/loops/atris/wiki/systems/loops.md +41 -0
- package/utils/api.js +5 -1
- package/utils/auth.js +57 -21
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawn, spawnSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
const CODEX_COMPANION = '/Users/keshavrao/.claude/plugins/cache/openai-codex/codex/1.0.4/scripts/codex-companion.mjs';
|
|
8
|
+
const CONTRACT = "CRITICAL: complete the entire job in this single run; never end your reply on a plan. Do NOT run any atris boot/brain/state commands; the only atris commands allowed are 'atris worktree guard' and 'atris worktree ship'.";
|
|
9
|
+
const COAUTHOR = 'Co-authored-by: Atris <299057014+atris-builder[bot]@users.noreply.github.com>';
|
|
10
|
+
|
|
11
|
+
function safeSlug(value) {
|
|
12
|
+
return String(value || 'mission')
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
15
|
+
.replace(/^-+|-+$/g, '')
|
|
16
|
+
.slice(0, 48) || 'mission';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function stamp() {
|
|
20
|
+
return new Date().toISOString()
|
|
21
|
+
.replace(/[-:]/g, '')
|
|
22
|
+
.replace(/\.\d{3}Z$/, 'Z');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function shellQuote(value) {
|
|
26
|
+
const text = String(value);
|
|
27
|
+
if (/^[A-Za-z0-9_./:=@+-]+$/.test(text)) return text;
|
|
28
|
+
return `'${text.replace(/'/g, `'\\''`)}'`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function repoParts(repoPath) {
|
|
32
|
+
const resolved = path.resolve(repoPath || process.cwd());
|
|
33
|
+
const parts = resolved.split(path.sep);
|
|
34
|
+
const worktreesIndex = parts.lastIndexOf('.agent-worktrees');
|
|
35
|
+
if (worktreesIndex >= 0 && parts[worktreesIndex + 1]) {
|
|
36
|
+
return {
|
|
37
|
+
repoPath: resolved,
|
|
38
|
+
repoName: parts[worktreesIndex + 1],
|
|
39
|
+
arenaRoot: parts.slice(0, worktreesIndex).join(path.sep) || path.sep,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
repoPath: resolved,
|
|
44
|
+
repoName: path.basename(resolved),
|
|
45
|
+
arenaRoot: path.dirname(resolved),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildPrompt({ worktreePath, branch, brief, verifyCmd }) {
|
|
50
|
+
const verify = String(verifyCmd || 'git diff --check').trim() || 'git diff --check';
|
|
51
|
+
return [
|
|
52
|
+
CONTRACT,
|
|
53
|
+
`Worktree: ${worktreePath}`,
|
|
54
|
+
`Branch: ${branch}`,
|
|
55
|
+
'',
|
|
56
|
+
String(brief || '').trim(),
|
|
57
|
+
'',
|
|
58
|
+
'Instructions:',
|
|
59
|
+
`- First run: atris worktree guard`,
|
|
60
|
+
`- Verify with this bare command and use its real exit code: ${verify}`,
|
|
61
|
+
'- Stage and commit only files you changed.',
|
|
62
|
+
`- Commit with a plain-English message and include this trailer: ${COAUTHOR}`,
|
|
63
|
+
`- Run: atris worktree ship --message "<plain-English summary>" --verify "${verify.replace(/"/g, '\\"')}" --merge`,
|
|
64
|
+
'- Report the PR URL, commit sha, verify exit code, and ship exit code.',
|
|
65
|
+
'- If rebase or merge reports a conflict, stop immediately and report the conflict; do not resolve it yourself.',
|
|
66
|
+
'- If a git hook blocks your commit, STOP and report it; never bypass it with --no-verify.',
|
|
67
|
+
].join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderCommand(argv) {
|
|
71
|
+
return argv.map(shellQuote).join(' ');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function runWorktreeAdd(repoPath, branch, worktreePath) {
|
|
75
|
+
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
|
76
|
+
const baseArgs = ['-C', repoPath, 'worktree', 'add', '-b', branch, worktreePath];
|
|
77
|
+
const master = spawnSync('git', [...baseArgs, 'origin/master'], { encoding: 'utf8' });
|
|
78
|
+
if (master.status === 0) return { baseRef: 'origin/master' };
|
|
79
|
+
const main = spawnSync('git', [...baseArgs, 'origin/main'], { encoding: 'utf8' });
|
|
80
|
+
if (main.status === 0) return { baseRef: 'origin/main' };
|
|
81
|
+
const detail = [
|
|
82
|
+
`origin/master: ${(master.stderr || master.stdout || '').trim()}`,
|
|
83
|
+
`origin/main: ${(main.stderr || main.stdout || '').trim()}`,
|
|
84
|
+
].join('\n');
|
|
85
|
+
throw new Error(`git worktree add failed for ${worktreePath}\n${detail}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseTaskId(stdout) {
|
|
89
|
+
const text = String(stdout || '');
|
|
90
|
+
const jsonMatch = text.match(/"jobId"\s*:\s*"([^"]+)"/);
|
|
91
|
+
if (jsonMatch) return jsonMatch[1];
|
|
92
|
+
const taskMatch = text.match(/\b(task-[A-Za-z0-9_.:-]+)\b/);
|
|
93
|
+
if (taskMatch) return taskMatch[1];
|
|
94
|
+
const asMatch = text.match(/\bas\s+([A-Za-z0-9_.:-]+)\b/);
|
|
95
|
+
return asMatch ? asMatch[1] : '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function spawnCodex(worktreePath, prompt) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const argv = [CODEX_COMPANION, 'task', '--background', '--write', prompt];
|
|
101
|
+
const child = spawn('node', argv, {
|
|
102
|
+
cwd: worktreePath,
|
|
103
|
+
env: { ...process.env, CODEX_COMPANION_SANDBOX: 'danger-full-access' },
|
|
104
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
105
|
+
});
|
|
106
|
+
let stdout = '';
|
|
107
|
+
let stderr = '';
|
|
108
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
109
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
110
|
+
child.on('error', reject);
|
|
111
|
+
child.on('close', (code) => {
|
|
112
|
+
if (code !== 0) {
|
|
113
|
+
reject(new Error(`codex companion exited ${code}\n${stderr || stdout}`));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const taskId = parseTaskId(stdout);
|
|
117
|
+
if (!taskId) {
|
|
118
|
+
reject(new Error(`could not parse codex task id from stdout\n${stdout}`));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
resolve({ taskId, stdout, stderr });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function dispatchCodexFlight({ repoPath, slug, brief, verifyCmd, dryRun = false } = {}) {
|
|
127
|
+
const repo = repoParts(repoPath);
|
|
128
|
+
const flightSlug = safeSlug(slug);
|
|
129
|
+
const flightStamp = stamp();
|
|
130
|
+
const branch = `codex/${flightSlug}-${flightStamp}`;
|
|
131
|
+
const worktreePath = path.join(repo.arenaRoot, '.agent-worktrees', repo.repoName, `codex-${flightSlug}-${flightStamp}`);
|
|
132
|
+
const prompt = buildPrompt({ worktreePath, branch, brief, verifyCmd });
|
|
133
|
+
const worktreeCommand = ['git', '-C', repo.repoPath, 'worktree', 'add', '-b', branch, worktreePath, 'origin/master'];
|
|
134
|
+
const fallbackCommand = ['git', '-C', repo.repoPath, 'worktree', 'add', '-b', branch, worktreePath, 'origin/main'];
|
|
135
|
+
|
|
136
|
+
if (dryRun) {
|
|
137
|
+
console.log(`Worktree command: ${renderCommand(worktreeCommand)}`);
|
|
138
|
+
console.log(`Fallback command: ${renderCommand(fallbackCommand)}`);
|
|
139
|
+
console.log(`Codex cwd: ${worktreePath}`);
|
|
140
|
+
console.log('Prompt:');
|
|
141
|
+
console.log(prompt);
|
|
142
|
+
return { dryRun: true, prompt };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
runWorktreeAdd(repo.repoPath, branch, worktreePath);
|
|
146
|
+
const task = await spawnCodex(worktreePath, prompt);
|
|
147
|
+
return { taskId: task.taskId, worktreePath, branch };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
dispatchCodexFlight,
|
|
152
|
+
buildPrompt,
|
|
153
|
+
parseTaskId,
|
|
154
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Self-contained flag reads so this helper can be shared by commands/mission.js
|
|
4
|
+
// and commands/member.js, which each define their own readFlag/hasFlag. Handles
|
|
5
|
+
// both `--flag value` and `--flag=value`.
|
|
6
|
+
function flagValue(args, name) {
|
|
7
|
+
const list = Array.isArray(args) ? args : [];
|
|
8
|
+
const eq = `${name}=`;
|
|
9
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
10
|
+
const token = String(list[i]);
|
|
11
|
+
if (token === name) return i + 1 < list.length ? String(list[i + 1]) : '';
|
|
12
|
+
if (token.startsWith(eq)) return token.slice(eq.length);
|
|
13
|
+
}
|
|
14
|
+
return '';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function hasFlag(args, name) {
|
|
18
|
+
const list = Array.isArray(args) ? args : [];
|
|
19
|
+
return list.some((token) => String(token) === name || String(token).startsWith(`${name}=`));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A live codex session drives a run by passing its native goal slot state
|
|
23
|
+
// (--native-goal-* / --visible-goal-*) or by explicitly claiming the slot
|
|
24
|
+
// (--take-goal-slot). Absent those, nothing is on the other end.
|
|
25
|
+
function runHasLiveCodexSession(args) {
|
|
26
|
+
return Boolean(
|
|
27
|
+
flagValue(args, '--native-goal-status')
|
|
28
|
+
|| flagValue(args, '--visible-goal-status')
|
|
29
|
+
|| flagValue(args, '--native-goal-objective')
|
|
30
|
+
|| flagValue(args, '--visible-goal-objective')
|
|
31
|
+
|| hasFlag(args, '--take-goal-slot'),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Default runner for the run-objective path (`atris mission run "<objective>"`,
|
|
36
|
+
// `member run`). codex_goal hands the mission to a live codex session's native
|
|
37
|
+
// goal slot; with no such session (cron, fleet, a plain shell) it stalls forever
|
|
38
|
+
// waiting for a native goal start that never comes (proven footgun 2026-07-16).
|
|
39
|
+
// So codex_goal only when a live codex session is driving; otherwise claude,
|
|
40
|
+
// which drives itself headless. An explicit --runner always wins over this.
|
|
41
|
+
function defaultObjectiveRunner(args) {
|
|
42
|
+
return runHasLiveCodexSession(args) ? 'codex_goal' : 'claude';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { runHasLiveCodexSession, defaultObjectiveRunner };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MISSION_VERIFIER_TIMEOUT_MS = 120000;
|
|
7
|
+
|
|
8
|
+
// allowBroadSuite=false is for callers that FREEZE this as a mission's default
|
|
9
|
+
// verifier. A broad suite (`npm test`, `node --test`) auto-selected as a silent
|
|
10
|
+
// default is a proven footgun: in atrisos-backend `npm test` runs
|
|
11
|
+
// backend/scripts/test_fast.sh, which fails without its env and killed missions
|
|
12
|
+
// after two ticks (2026-07-16). A default the operator never chose must be a
|
|
13
|
+
// check that reflects the mission's own work, not the whole repo's health — so
|
|
14
|
+
// the mission lane falls back to the always-safe `git diff --check` and leaves
|
|
15
|
+
// broad suites to explicit `--verify`. Fleet/wish keep the suite default.
|
|
16
|
+
function resolveDefaultVerifier(root = process.cwd(), { allowBroadSuite = true } = {}) {
|
|
17
|
+
const repoRoot = path.resolve(root);
|
|
18
|
+
if (allowBroadSuite) {
|
|
19
|
+
try {
|
|
20
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
|
21
|
+
if (pkg.scripts && pkg.scripts.test) return 'npm test';
|
|
22
|
+
} catch {}
|
|
23
|
+
try {
|
|
24
|
+
if (fs.statSync(path.join(repoRoot, 'test')).isDirectory()) return 'node --test';
|
|
25
|
+
} catch {}
|
|
26
|
+
}
|
|
27
|
+
return 'git diff --check';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function missionVerifierTimeoutMs(env = process.env) {
|
|
31
|
+
const parsed = Number(env.ATRIS_MISSION_VERIFIER_TIMEOUT_MS);
|
|
32
|
+
if (Number.isFinite(parsed) && parsed >= 1000) return Math.floor(parsed);
|
|
33
|
+
return DEFAULT_MISSION_VERIFIER_TIMEOUT_MS;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function broadSuiteVerifier(command) {
|
|
37
|
+
const compact = String(command || '').trim().replace(/\s+/g, ' ');
|
|
38
|
+
return compact === 'node --test'
|
|
39
|
+
|| compact === 'node --test .'
|
|
40
|
+
|| compact === 'node --test test'
|
|
41
|
+
|| compact === 'node --test tests'
|
|
42
|
+
|| compact === 'npm test';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function verifierWindowText(timeoutMs) {
|
|
46
|
+
const minutes = timeoutMs / 60000;
|
|
47
|
+
if (minutes === 1) return 'one minute';
|
|
48
|
+
if (minutes === 2) return 'two minutes';
|
|
49
|
+
if (Number.isInteger(minutes)) return `${minutes} minutes`;
|
|
50
|
+
return `${Math.round(timeoutMs / 1000)} seconds`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function verifierBudgetWarning(command, env = process.env) {
|
|
54
|
+
if (!broadSuiteVerifier(command)) return null;
|
|
55
|
+
const timeoutMs = missionVerifierTimeoutMs(env);
|
|
56
|
+
return {
|
|
57
|
+
code: 'verifier_may_outlive_window',
|
|
58
|
+
message: `warning: this wish uses the whole test suite, but mission verification stops after ${verifierWindowText(timeoutMs)}. add a focused check command if that suite runs longer.`,
|
|
59
|
+
verifier: String(command || '').trim(),
|
|
60
|
+
verifier_timeout_ms: timeoutMs,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = {
|
|
65
|
+
DEFAULT_MISSION_VERIFIER_TIMEOUT_MS,
|
|
66
|
+
broadSuiteVerifier,
|
|
67
|
+
missionVerifierTimeoutMs,
|
|
68
|
+
resolveDefaultVerifier,
|
|
69
|
+
verifierBudgetWarning,
|
|
70
|
+
};
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
const {
|
|
7
|
+
RUNNER_PROFILE_DEFS,
|
|
8
|
+
RUNNER_PROFILE_ALIASES,
|
|
9
|
+
RUNNER_PROFILE_NAMES,
|
|
10
|
+
} = require('./runner-command');
|
|
11
|
+
const { rankEnginesDetailed, routerPickExplanation } = require('./router-brain');
|
|
12
|
+
|
|
13
|
+
const ENGINE_REGISTRY_SCHEMA = 'atris.engine_registry.v2';
|
|
14
|
+
const ENGINE_TIERS = Object.freeze(['fast', 'pro', 'max']);
|
|
15
|
+
const ENGINE_ROLES = Object.freeze(['navigator', 'executor', 'validator']);
|
|
16
|
+
const ENGINE_HEALTH_STATUSES = Object.freeze(['ready', 'not_installed', 'credit_out', 'error']);
|
|
17
|
+
|
|
18
|
+
const ENGINE_SEED_META = Object.freeze({
|
|
19
|
+
'atris-fast': Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator']), fallback_order: 10 }),
|
|
20
|
+
codex: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), fallback_order: 10 }),
|
|
21
|
+
claude: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), fallback_order: 20 }),
|
|
22
|
+
cursor: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), fallback_order: 30 }),
|
|
23
|
+
devin: Object.freeze({ tier: 'max', roles: Object.freeze(['executor']), fallback_order: 40 }),
|
|
24
|
+
grok: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), fallback_order: 45 }),
|
|
25
|
+
fable: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), fallback_order: 50 }),
|
|
26
|
+
composer: Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator', 'executor']), fallback_order: 60 }),
|
|
27
|
+
haiku: Object.freeze({ tier: 'fast', roles: Object.freeze(['validator']), fallback_order: 70 }),
|
|
28
|
+
hermes: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), fallback_order: 80 }),
|
|
29
|
+
droid: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), fallback_order: 90 }),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function engineRegistryFile(root = process.cwd()) {
|
|
33
|
+
return path.join(root, '.atris', 'state', 'engines.json');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function binInstalled(bin) {
|
|
37
|
+
const safe = String(bin || '').replace(/[^A-Za-z0-9_.-]/g, '');
|
|
38
|
+
if (!safe) return false;
|
|
39
|
+
const probe = spawnSync('sh', ['-c', `command -v ${safe}`], { encoding: 'utf8' });
|
|
40
|
+
return probe.status === 0 && Boolean(String(probe.stdout || '').trim());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function canonicalEngineName(name) {
|
|
44
|
+
const trimmed = String(name || '').trim();
|
|
45
|
+
if (!trimmed) return '';
|
|
46
|
+
if (RUNNER_PROFILE_DEFS[trimmed]) return trimmed;
|
|
47
|
+
if (RUNNER_PROFILE_ALIASES[trimmed]) return RUNNER_PROFILE_ALIASES[trimmed];
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readRawRegistry(file) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
54
|
+
if (Array.isArray(parsed)) return { engines: parsed };
|
|
55
|
+
if (parsed && Array.isArray(parsed.engines)) return parsed;
|
|
56
|
+
} catch {}
|
|
57
|
+
return { engines: [] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeTier(value, fallback = 'pro') {
|
|
61
|
+
const tier = String(value || '').trim();
|
|
62
|
+
return ENGINE_TIERS.includes(tier) ? tier : fallback;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeRoles(value, fallback = ['executor']) {
|
|
66
|
+
const roles = Array.isArray(value) ? value.map((role) => String(role || '').trim()) : [];
|
|
67
|
+
const filtered = roles.filter((role, index) => ENGINE_ROLES.includes(role) && roles.indexOf(role) === index);
|
|
68
|
+
return filtered.length ? filtered : fallback;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeFallbackOrder(value, fallback) {
|
|
72
|
+
const order = Number(value);
|
|
73
|
+
return Number.isInteger(order) ? order : fallback;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeEngineEntry(id, saved = {}) {
|
|
77
|
+
const def = RUNNER_PROFILE_DEFS[id];
|
|
78
|
+
const seed = ENGINE_SEED_META[id] || { tier: 'pro', roles: ['executor'], fallback_order: 100 };
|
|
79
|
+
const installed = binInstalled(def.bin);
|
|
80
|
+
const savedHealth = saved && saved.health && typeof saved.health === 'object' ? saved.health : {};
|
|
81
|
+
const savedStatus = String(savedHealth.status || '').trim();
|
|
82
|
+
const savedFailure = savedStatus === 'credit_out'
|
|
83
|
+
|| savedStatus === 'error'
|
|
84
|
+
|| (savedStatus === 'not_installed' && Boolean(savedHealth.last_failure_ts));
|
|
85
|
+
const status = savedFailure ? savedStatus : (installed ? 'ready' : 'not_installed');
|
|
86
|
+
const health = { status };
|
|
87
|
+
if (savedFailure && savedHealth.last_failure_ts) health.last_failure_ts = String(savedHealth.last_failure_ts);
|
|
88
|
+
return {
|
|
89
|
+
id,
|
|
90
|
+
name: id,
|
|
91
|
+
bin: def.bin,
|
|
92
|
+
tier: normalizeTier(saved.tier, seed.tier),
|
|
93
|
+
roles: normalizeRoles(saved.roles, Array.from(seed.roles)),
|
|
94
|
+
fallback_order: normalizeFallbackOrder(saved.fallback_order, seed.fallback_order),
|
|
95
|
+
installed,
|
|
96
|
+
health,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function seededRegistry(root = process.cwd()) {
|
|
101
|
+
const file = engineRegistryFile(root);
|
|
102
|
+
const raw = readRawRegistry(file);
|
|
103
|
+
const savedById = new Map();
|
|
104
|
+
for (const entry of raw.engines || []) {
|
|
105
|
+
const id = canonicalEngineName(entry && (entry.id || entry.name));
|
|
106
|
+
if (id) savedById.set(id, entry);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
schema: ENGINE_REGISTRY_SCHEMA,
|
|
110
|
+
updated_at: new Date().toISOString(),
|
|
111
|
+
engines: RUNNER_PROFILE_NAMES.map((id) => normalizeEngineEntry(id, savedById.get(id) || {})),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function writeEngineRegistry(root, registry) {
|
|
116
|
+
const file = engineRegistryFile(root);
|
|
117
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
118
|
+
fs.writeFileSync(file, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readEngineRegistry(root = process.cwd(), options = {}) {
|
|
122
|
+
const registry = seededRegistry(root);
|
|
123
|
+
if (options.persist !== false) writeEngineRegistry(root, registry);
|
|
124
|
+
return registry;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function engineRegistryView(root = process.cwd()) {
|
|
128
|
+
return readEngineRegistry(root).engines;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function registeredEngineIds(root = process.cwd()) {
|
|
132
|
+
return engineRegistryView(root).map((engine) => engine.id);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolveRegisteredEngine(name, root = process.cwd()) {
|
|
136
|
+
const requested = String(name || '').trim();
|
|
137
|
+
const id = canonicalEngineName(requested);
|
|
138
|
+
const engines = engineRegistryView(root);
|
|
139
|
+
const knownIds = engines.map((engine) => engine.id);
|
|
140
|
+
const engine = id ? engines.find((entry) => entry.id === id) : null;
|
|
141
|
+
if (!engine) {
|
|
142
|
+
throw new Error(`Unknown engine "${requested}". Registered engine ids: ${knownIds.join(', ')}`);
|
|
143
|
+
}
|
|
144
|
+
return engine;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function resolveEngineForRole(role, root = process.cwd(), options = {}) {
|
|
148
|
+
const normalizedRole = String(role || '').trim().toLowerCase();
|
|
149
|
+
if (!ENGINE_ROLES.includes(normalizedRole)) {
|
|
150
|
+
throw new Error(`Unknown role "${role}". Known roles: ${ENGINE_ROLES.join(', ')}`);
|
|
151
|
+
}
|
|
152
|
+
const engines = engineRegistryView(root)
|
|
153
|
+
.filter((engine) => engine.roles.includes(normalizedRole))
|
|
154
|
+
.filter((engine) => engine.health && engine.health.status === 'ready')
|
|
155
|
+
.sort((a, b) => {
|
|
156
|
+
const byOrder = Number(a.fallback_order) - Number(b.fallback_order);
|
|
157
|
+
return byOrder || String(a.id).localeCompare(String(b.id));
|
|
158
|
+
});
|
|
159
|
+
const ranked = rankEnginesDetailed(engines, {
|
|
160
|
+
root,
|
|
161
|
+
taskType: options.taskType || options.task_type || normalizedRole,
|
|
162
|
+
});
|
|
163
|
+
const selected = ranked.candidates[0] || null;
|
|
164
|
+
if (selected && process.env.ATRIS_ROUTER_EXPLAIN === '1') {
|
|
165
|
+
console.error(routerPickExplanation(ranked));
|
|
166
|
+
}
|
|
167
|
+
return selected;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolveEngineForRoleWithPreference(role, root = process.cwd(), preferredEngineId = '', options = {}) {
|
|
171
|
+
const requested = String(preferredEngineId || '').trim();
|
|
172
|
+
if (!requested) {
|
|
173
|
+
return {
|
|
174
|
+
engine: resolveEngineForRole(role, root, options),
|
|
175
|
+
requested_engine: null,
|
|
176
|
+
engine_fallback_reason: null,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const preferred = resolveRegisteredEngine(requested, root);
|
|
180
|
+
if (preferred.health && preferred.health.status === 'ready') {
|
|
181
|
+
return {
|
|
182
|
+
engine: preferred,
|
|
183
|
+
requested_engine: preferred.id,
|
|
184
|
+
engine_fallback_reason: null,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const fallback = resolveEngineForRole(role, root, options);
|
|
188
|
+
const status = preferred.health && preferred.health.status ? preferred.health.status : 'unknown';
|
|
189
|
+
return {
|
|
190
|
+
engine: fallback,
|
|
191
|
+
requested_engine: preferred.id,
|
|
192
|
+
engine_fallback_reason: `Requested engine ${preferred.id} is not ready (${status}); fell back to ${fallback ? fallback.id : 'no ready executor'}.`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function setEngineHealth(name, status, root = process.cwd()) {
|
|
197
|
+
const id = canonicalEngineName(name);
|
|
198
|
+
if (!id) {
|
|
199
|
+
throw new Error(`Unknown engine "${name}". Known engines: ${RUNNER_PROFILE_NAMES.join(', ')}`);
|
|
200
|
+
}
|
|
201
|
+
const normalizedStatus = String(status || '').trim();
|
|
202
|
+
if (!ENGINE_HEALTH_STATUSES.includes(normalizedStatus)) {
|
|
203
|
+
throw new Error(`Unknown health "${status}". Known health statuses: ${ENGINE_HEALTH_STATUSES.join(', ')}`);
|
|
204
|
+
}
|
|
205
|
+
const registry = readEngineRegistry(root, { persist: false });
|
|
206
|
+
const engines = registry.engines.map((engine) => {
|
|
207
|
+
if (engine.id !== id) return engine;
|
|
208
|
+
const health = { status: normalizedStatus };
|
|
209
|
+
if (normalizedStatus !== 'ready') health.last_failure_ts = new Date().toISOString();
|
|
210
|
+
return { ...engine, health };
|
|
211
|
+
});
|
|
212
|
+
const next = { ...registry, updated_at: new Date().toISOString(), engines };
|
|
213
|
+
writeEngineRegistry(root, next);
|
|
214
|
+
return engines.find((engine) => engine.id === id);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = {
|
|
218
|
+
ENGINE_REGISTRY_SCHEMA,
|
|
219
|
+
ENGINE_TIERS,
|
|
220
|
+
ENGINE_ROLES,
|
|
221
|
+
ENGINE_HEALTH_STATUSES,
|
|
222
|
+
engineRegistryFile,
|
|
223
|
+
binInstalled,
|
|
224
|
+
canonicalEngineName,
|
|
225
|
+
readEngineRegistry,
|
|
226
|
+
engineRegistryView,
|
|
227
|
+
registeredEngineIds,
|
|
228
|
+
resolveRegisteredEngine,
|
|
229
|
+
resolveEngineForRole,
|
|
230
|
+
resolveEngineForRoleWithPreference,
|
|
231
|
+
setEngineHealth,
|
|
232
|
+
};
|