atris 3.33.3 → 3.35.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.
Files changed (54) hide show
  1. package/AGENTS.md +0 -2
  2. package/FOR_AGENTS.md +5 -3
  3. package/atris/skills/engines/SKILL.md +16 -7
  4. package/atris/skills/render-cli/SKILL.md +88 -0
  5. package/atris.md +19 -0
  6. package/ax +475 -17
  7. package/bin/atris.js +206 -44
  8. package/commands/aeo.js +52 -0
  9. package/commands/autoland.js +313 -19
  10. package/commands/business.js +91 -8
  11. package/commands/codex-goal.js +26 -2
  12. package/commands/drive.js +187 -0
  13. package/commands/engine.js +73 -2
  14. package/commands/feed.js +202 -0
  15. package/commands/github.js +38 -0
  16. package/commands/gm.js +262 -3
  17. package/commands/init.js +13 -1
  18. package/commands/integrations.js +39 -11
  19. package/commands/interview.js +143 -0
  20. package/commands/land.js +114 -13
  21. package/commands/lesson.js +112 -1
  22. package/commands/linear.js +38 -0
  23. package/commands/loops.js +212 -0
  24. package/commands/member.js +398 -42
  25. package/commands/mission.js +598 -64
  26. package/commands/now.js +25 -1
  27. package/commands/radar.js +259 -14
  28. package/commands/serve.js +54 -0
  29. package/commands/status.js +50 -5
  30. package/commands/stripe.js +38 -0
  31. package/commands/supabase.js +39 -0
  32. package/commands/task.js +935 -71
  33. package/commands/truth.js +29 -3
  34. package/commands/unknowns.js +627 -0
  35. package/commands/update.js +44 -0
  36. package/commands/vercel.js +38 -0
  37. package/commands/worktree.js +68 -10
  38. package/commands/write.js +399 -0
  39. package/lib/auto-accept-certified.js +70 -19
  40. package/lib/autoland.js +39 -3
  41. package/lib/fleet.js +256 -13
  42. package/lib/memory-view.js +14 -5
  43. package/lib/mission-runtime-loop.js +7 -0
  44. package/lib/official-cli-integration.js +174 -0
  45. package/lib/outbound-send-gate.js +165 -0
  46. package/lib/permission-grants.js +293 -0
  47. package/lib/review-integrity.js +147 -0
  48. package/lib/runner-command.js +23 -0
  49. package/lib/task-db.js +220 -7
  50. package/lib/task-proof.js +20 -0
  51. package/lib/task-receipt.js +93 -0
  52. package/package.json +1 -1
  53. package/utils/update-check.js +27 -6
  54. package/atris/learnings.jsonl +0 -1
@@ -0,0 +1,293 @@
1
+ 'use strict';
2
+
3
+ // Persistent permission grants (CLI-831).
4
+ // A grant lets an exact approved command pattern auto-redeem its workspace
5
+ // approval instead of re-asking. syncGrants() pushes the local store to the
6
+ // backend and pulls back grants created or revoked from atrisos-web, so the
7
+ // same grants can be reviewed and revoked from the web.
8
+ // Design brief: atris/designs/permission-grants-cli-831.md
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const crypto = require('crypto');
14
+
15
+ const GRANTS_SCHEMA = 'atris.permission_grants.v1';
16
+
17
+ // Backend reconciliation endpoint. The CLI POSTs its local grants and the
18
+ // backend echoes the authoritative set (including web-created/revoked grants).
19
+ const GRANTS_SYNC_ENDPOINT = '/workspace/permission-grants/sync';
20
+
21
+ // Shell metacharacters change the meaning of a command after matching, so a
22
+ // command containing any of them is never grantable and never matchable.
23
+ const SHELL_META = /[;&|<>`$(){}\[\]*?~\\\n\r]|\r|\n/;
24
+
25
+ // Commands that must always re-ask, no matter what the operator granted.
26
+ const NEVER_GRANTABLE = [
27
+ /^sudo\b/,
28
+ /\brm\s+(-\w*[rf]\w*\s+)+/,
29
+ /\bgit\s+push\s+.*--force/,
30
+ /\bgit\s+reset\s+--hard/,
31
+ /\b(sh|bash|zsh)\s+-c\b/,
32
+ /\bnode\s+-e\b/,
33
+ /\bpython3?\s+-c\b/,
34
+ /\bchmod\b|\bchown\b/,
35
+ /credentials\.json|\.ssh\b|\.env\b/,
36
+ /\batris\s+task\s+accept\b/,
37
+ /\bautoland\b.*polic/,
38
+ ];
39
+
40
+ function grantsFilePath() {
41
+ return process.env.ATRIS_PERMISSION_GRANTS_FILE
42
+ || path.join(os.homedir(), '.atris', 'permission-grants.json');
43
+ }
44
+
45
+ function parseArgv(command) {
46
+ const text = String(command || '').trim();
47
+ if (!text || SHELL_META.test(text)) return null;
48
+ const argv = text.split(/\s+/).filter(Boolean);
49
+ return argv.length ? argv : null;
50
+ }
51
+
52
+ function commandIsGrantable(command) {
53
+ const text = String(command || '').trim();
54
+ if (!text) return { ok: false, reason: 'empty command' };
55
+ if (SHELL_META.test(text)) return { ok: false, reason: 'shell metacharacters are never grantable' };
56
+ for (const rule of NEVER_GRANTABLE) {
57
+ if (rule.test(text)) return { ok: false, reason: `never grantable: matches ${rule}` };
58
+ }
59
+ return { ok: true };
60
+ }
61
+
62
+ function loadGrants(file = grantsFilePath()) {
63
+ try {
64
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
65
+ if (data && data.schema === GRANTS_SCHEMA && Array.isArray(data.grants)) return data;
66
+ } catch {
67
+ // Missing or unreadable store means no grants; fail closed to re-asking.
68
+ }
69
+ return { schema: GRANTS_SCHEMA, grants: [] };
70
+ }
71
+
72
+ function saveGrants(store, file = grantsFilePath()) {
73
+ const dir = path.dirname(file);
74
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
75
+ const tmp = `${file}.${process.pid}.tmp`;
76
+ fs.writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
77
+ fs.renameSync(tmp, file);
78
+ }
79
+
80
+ function canonicalRoot(workspaceRoot) {
81
+ try {
82
+ return fs.realpathSync(String(workspaceRoot || ''));
83
+ } catch {
84
+ return String(workspaceRoot || '');
85
+ }
86
+ }
87
+
88
+ function addGrant({ command, workspaceRoot, createdVia = 'cli', expiresInDays = 30, file } = {}) {
89
+ const grantable = commandIsGrantable(command);
90
+ if (!grantable.ok) return { ok: false, reason: grantable.reason };
91
+ const argv = parseArgv(command);
92
+ if (!argv) return { ok: false, reason: 'command does not parse to a plain argv' };
93
+ const root = canonicalRoot(workspaceRoot);
94
+ if (!root || root === '/') return { ok: false, reason: 'grants must be scoped to a workspace root' };
95
+ const store = loadGrants(file);
96
+ const now = new Date();
97
+ const grant = {
98
+ grant_id: `grant-${crypto.randomBytes(6).toString('hex')}`,
99
+ status: 'active',
100
+ scope: { kind: 'workspace', workspace_root: root },
101
+ principal: { created_via: createdVia },
102
+ action_type: 'local_command',
103
+ pattern: {
104
+ type: 'exact_argv',
105
+ argv,
106
+ display: argv.join(' '),
107
+ normalized_hash: crypto.createHash('sha256').update(argv.join(' ')).digest('hex'),
108
+ },
109
+ constraints: {
110
+ expires_at: new Date(now.getTime() + expiresInDays * 24 * 60 * 60 * 1000).toISOString(),
111
+ cwd_must_be_within_workspace: true,
112
+ },
113
+ audit: { created_at: now.toISOString(), last_used_at: null, use_count: 0 },
114
+ };
115
+ store.grants.push(grant);
116
+ saveGrants(store, file);
117
+ return { ok: true, grant };
118
+ }
119
+
120
+ function revokeGrant(grantId, file) {
121
+ const store = loadGrants(file);
122
+ const grant = store.grants.find(g => g.grant_id === grantId || String(g.grant_id).startsWith(String(grantId)));
123
+ if (!grant) return { ok: false, reason: 'grant not found' };
124
+ grant.status = 'revoked';
125
+ grant.sync = { ...(grant.sync || {}), revoked_at: new Date().toISOString() };
126
+ saveGrants(store, file);
127
+ return { ok: true, grant };
128
+ }
129
+
130
+ function matchGrant({ command, workspaceRoot, now = new Date(), file } = {}) {
131
+ const argv = parseArgv(command);
132
+ if (!argv) return null;
133
+ if (!commandIsGrantable(command).ok) return null;
134
+ const root = canonicalRoot(workspaceRoot);
135
+ const store = loadGrants(file);
136
+ for (const grant of store.grants) {
137
+ if (grant.status !== 'active') continue;
138
+ if (grant.action_type !== 'local_command') continue;
139
+ if (!grant.scope || grant.scope.workspace_root !== root) continue;
140
+ const expiresAt = grant.constraints && grant.constraints.expires_at;
141
+ if (expiresAt && new Date(expiresAt).getTime() <= now.getTime()) continue;
142
+ const maxUses = grant.constraints && grant.constraints.max_uses;
143
+ if (maxUses && (grant.audit?.use_count || 0) >= maxUses) continue;
144
+ const pattern = grant.pattern || {};
145
+ if (pattern.type === 'exact_argv') {
146
+ if (Array.isArray(pattern.argv)
147
+ && pattern.argv.length === argv.length
148
+ && pattern.argv.every((token, i) => token === argv[i])) return grant;
149
+ } else if (pattern.type === 'argv_prefix') {
150
+ if (Array.isArray(pattern.argv)
151
+ && pattern.argv.length <= argv.length
152
+ && pattern.argv.every((token, i) => token === argv[i])) return grant;
153
+ }
154
+ }
155
+ return null;
156
+ }
157
+
158
+ function recordUse(grantId, file) {
159
+ const store = loadGrants(file);
160
+ const grant = store.grants.find(g => g.grant_id === grantId);
161
+ if (!grant) return null;
162
+ grant.audit = grant.audit || {};
163
+ grant.audit.use_count = (grant.audit.use_count || 0) + 1;
164
+ grant.audit.last_used_at = new Date().toISOString();
165
+ saveGrants(store, file);
166
+ return grant;
167
+ }
168
+
169
+ // A grant pulled from the backend is only trusted if it would itself have been
170
+ // grantable locally. This is the fail-closed seam for sync: a compromised or
171
+ // buggy backend can never inject an auto-approvable dangerous pattern, because
172
+ // its command must survive the same NEVER_GRANTABLE / metacharacter gate that
173
+ // addGrant() applies. Malformed or non-command grants are dropped.
174
+ function normalizeRemoteGrant(remote) {
175
+ if (!remote || typeof remote !== 'object') return null;
176
+ if (String(remote.action_type || '') !== 'local_command') return null;
177
+ const grantId = String(remote.grant_id || '');
178
+ if (!grantId) return null;
179
+ const pattern = remote.pattern || {};
180
+ const type = pattern.type;
181
+ if (type !== 'exact_argv' && type !== 'argv_prefix') return null;
182
+ if (!Array.isArray(pattern.argv) || !pattern.argv.length) return null;
183
+ if (!pattern.argv.every(token => typeof token === 'string' && token.length)) return null;
184
+ const display = typeof pattern.display === 'string' && pattern.display
185
+ ? pattern.display
186
+ : pattern.argv.join(' ');
187
+ if (!commandIsGrantable(display).ok) return null;
188
+ const root = String((remote.scope && remote.scope.workspace_root) || '');
189
+ if (!root || root === '/') return null;
190
+ return {
191
+ grant_id: grantId,
192
+ status: remote.status === 'revoked' ? 'revoked' : 'active',
193
+ scope: { kind: 'workspace', workspace_root: root },
194
+ principal: (remote.principal && typeof remote.principal === 'object')
195
+ ? remote.principal
196
+ : { created_via: 'web' },
197
+ action_type: 'local_command',
198
+ pattern: {
199
+ type,
200
+ argv: pattern.argv.slice(),
201
+ display,
202
+ normalized_hash: crypto.createHash('sha256').update(pattern.argv.join(' ')).digest('hex'),
203
+ },
204
+ constraints: (remote.constraints && typeof remote.constraints === 'object')
205
+ ? remote.constraints
206
+ : {},
207
+ audit: (remote.audit && typeof remote.audit === 'object')
208
+ ? remote.audit
209
+ : { created_at: null, last_used_at: null, use_count: 0 },
210
+ sync: (remote.sync && typeof remote.sync === 'object')
211
+ ? remote.sync
212
+ : { source: 'web' },
213
+ };
214
+ }
215
+
216
+ // Merge the backend's authoritative grants into the local store in place.
217
+ // - New grants (created/managed from web) are pulled down so the CLI can redeem
218
+ // them locally.
219
+ // - Revocation always wins: a grant revoked on the web is revoked locally, and a
220
+ // grant we already revoked is never resurrected. Permission state fails closed.
221
+ function mergeRemoteGrants(store, remoteGrants = [], now = new Date()) {
222
+ const byId = new Map(store.grants.map(g => [g.grant_id, g]));
223
+ let pulled = 0;
224
+ let revoked = 0;
225
+ let rejected = 0;
226
+ for (const raw of Array.isArray(remoteGrants) ? remoteGrants : []) {
227
+ const remote = normalizeRemoteGrant(raw);
228
+ if (!remote) { rejected += 1; continue; }
229
+ const local = byId.get(remote.grant_id);
230
+ if (!local) {
231
+ store.grants.push(remote);
232
+ byId.set(remote.grant_id, remote);
233
+ if (remote.status === 'revoked') revoked += 1;
234
+ else pulled += 1;
235
+ continue;
236
+ }
237
+ if (remote.status === 'revoked' && local.status !== 'revoked') {
238
+ local.status = 'revoked';
239
+ local.sync = {
240
+ ...(local.sync || {}),
241
+ revoked_at: (remote.sync && remote.sync.revoked_at) || now.toISOString(),
242
+ revoked_by: 'web',
243
+ };
244
+ revoked += 1;
245
+ }
246
+ }
247
+ return { pulled, revoked, rejected };
248
+ }
249
+
250
+ // Push the local grants to the backend and merge back the authoritative set.
251
+ // apiRequestJson is injected (utils/api) so this stays offline-testable. The
252
+ // call is best-effort: on any transport/HTTP failure the local store is left
253
+ // untouched and { ok: false, reason } is returned — grants keep working locally.
254
+ async function syncGrants({ apiRequestJson, token, file, endpoint = GRANTS_SYNC_ENDPOINT, now = new Date() } = {}) {
255
+ if (typeof apiRequestJson !== 'function') {
256
+ return { ok: false, reason: 'no api client' };
257
+ }
258
+ const store = loadGrants(file);
259
+ let response;
260
+ try {
261
+ response = await apiRequestJson(endpoint, {
262
+ method: 'POST',
263
+ token,
264
+ body: { schema: GRANTS_SCHEMA, grants: store.grants },
265
+ });
266
+ } catch (error) {
267
+ return { ok: false, reason: (error && error.message) || 'sync request failed', pushed: store.grants.length };
268
+ }
269
+ if (!response || !response.ok) {
270
+ return { ok: false, reason: (response && response.error) || 'sync request failed', pushed: store.grants.length };
271
+ }
272
+ const remoteGrants = (response.data && Array.isArray(response.data.grants)) ? response.data.grants : [];
273
+ const merged = mergeRemoteGrants(store, remoteGrants, now);
274
+ saveGrants(store, file);
275
+ return { ok: true, pushed: store.grants.length, ...merged };
276
+ }
277
+
278
+ module.exports = {
279
+ GRANTS_SCHEMA,
280
+ GRANTS_SYNC_ENDPOINT,
281
+ grantsFilePath,
282
+ parseArgv,
283
+ commandIsGrantable,
284
+ loadGrants,
285
+ saveGrants,
286
+ addGrant,
287
+ revokeGrant,
288
+ matchGrant,
289
+ recordUse,
290
+ normalizeRemoteGrant,
291
+ mergeRemoteGrants,
292
+ syncGrants,
293
+ };
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ // judge != worker: the builder of a task cannot be the actor whose review
4
+ // pass certifies or lands it. This module is the single home for actor
5
+ // identity rules; task-db, auto-accept, and the task CLI all lean on it.
6
+ // Level 1 trust: normalized string identity + builder exclusion + reserved
7
+ // system names + roster validation (warn by default). It stops accidental
8
+ // and lazy self-judging and makes deliberate spoofing auditable; signed
9
+ // events (level 2) are a follow-up, not this file.
10
+
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+ const functionalOwner = require('./functional-owner');
15
+
16
+ // System actors written by the machinery itself; a human or agent may never
17
+ // claim/ready/review under these names.
18
+ const RESERVED_ACTORS = new Set(['autoland-verifier', 'auto-accept-certified']);
19
+
20
+ function normalizeActor(value) {
21
+ return String(value || '')
22
+ .trim()
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9]+/g, '-')
25
+ .replace(/^-+|-+$/g, '');
26
+ }
27
+
28
+ function envActor() {
29
+ return normalizeActor(process.env.ATRIS_AGENT_ID || process.env.USER || '');
30
+ }
31
+
32
+ // Builder identity, oldest signal wins: explicit stamp, then who claimed it,
33
+ // then whoever sent the first proof.
34
+ function taskBuilder(task) {
35
+ if (!task || typeof task !== 'object') return null;
36
+ const metadata = task.metadata || {};
37
+ const stamped = normalizeActor(metadata.built_by);
38
+ if (stamped) return stamped;
39
+ const claimed = normalizeActor(task.claimed_by);
40
+ if (claimed) return claimed;
41
+ for (const event of task.events || []) {
42
+ if (event && event.event_type === 'proof_ready') {
43
+ const actor = normalizeActor(event.actor || (event.payload && event.payload.actor));
44
+ if (actor) return actor;
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function reviewEventActors(task) {
51
+ const actors = new Set();
52
+ for (const event of (task && task.events) || []) {
53
+ if (!event || !['proof_ready', 'reviewed'].includes(event.event_type)) continue;
54
+ const actor = normalizeActor(event.actor || (event.payload && event.payload.actor));
55
+ if (actor) actors.add(actor);
56
+ }
57
+ return actors;
58
+ }
59
+
60
+ // Actors who reviewed the task and are not its builder.
61
+ function independentReviewActors(task) {
62
+ const builder = taskBuilder(task);
63
+ const actors = reviewEventActors(task);
64
+ if (builder) actors.delete(builder);
65
+ return actors;
66
+ }
67
+
68
+ // True when at least one review pass came from someone other than the
69
+ // builder. When the builder cannot be resolved at all, fall back to
70
+ // requiring two distinct actors — independence cannot be proven from one.
71
+ function hasIndependentReview(task) {
72
+ const builder = taskBuilder(task);
73
+ if (!builder) return reviewEventActors(task).size >= 2;
74
+ return independentReviewActors(task).size >= 1;
75
+ }
76
+
77
+ function isReservedActor(actor) {
78
+ return RESERVED_ACTORS.has(normalizeActor(actor));
79
+ }
80
+
81
+ function rosterActors(root) {
82
+ const actors = new Set(functionalOwner.listWorkspaceMemberSlugs(root));
83
+ for (const id of ['atris-2-fast', 'atris2', 'atris2-fast', 'claude', 'codex', 'codex-executor', 'cursor', 'devin', 'executor', 'openclaw', 'windsurf']) {
84
+ actors.add(id);
85
+ }
86
+ try {
87
+ const user = normalizeActor(os.userInfo().username);
88
+ if (user) actors.add(user);
89
+ } catch (_) { /* identity lookup can fail in stripped containers */ }
90
+ const envUser = normalizeActor(process.env.USER);
91
+ if (envUser) actors.add(envUser);
92
+ const agentId = normalizeActor(process.env.ATRIS_AGENT_ID);
93
+ if (agentId) actors.add(agentId);
94
+ try {
95
+ const policyPath = path.join(root || process.cwd(), '.atris', 'policy', 'autoland.json');
96
+ const policy = JSON.parse(fs.readFileSync(policyPath, 'utf8'));
97
+ const enabledBy = normalizeActor(policy.enabled_by);
98
+ if (enabledBy) actors.add(enabledBy);
99
+ } catch (_) { /* no policy is fine */ }
100
+ return actors;
101
+ }
102
+
103
+ // off | warn | enforce. Env wins, then .atris/review-policy.json, then off.
104
+ // Off by default on purpose: ad-hoc --as names are everywhere in scripts and
105
+ // fixtures, and a warning that fires on legitimate flows teaches everyone to
106
+ // ignore warnings (the cry-wolf lesson). Reserved-actor rejection and
107
+ // builder-exclusion do not depend on this mode and are always enforced.
108
+ function actorValidationMode(root) {
109
+ const env = String(process.env.ATRIS_ACTOR_VALIDATION || '').toLowerCase();
110
+ if (['off', 'warn', 'enforce'].includes(env)) return env;
111
+ try {
112
+ const policyPath = path.join(root || process.cwd(), '.atris', 'review-policy.json');
113
+ const policy = JSON.parse(fs.readFileSync(policyPath, 'utf8'));
114
+ const mode = String(policy.actor_validation || '').toLowerCase();
115
+ if (['off', 'warn', 'enforce'].includes(mode)) return mode;
116
+ } catch (_) { /* no policy is fine */ }
117
+ return 'off';
118
+ }
119
+
120
+ // Reserved names are rejected in every mode; unknown names pass in warn mode
121
+ // (with ok true + reason so callers can print) and fail in enforce mode.
122
+ function validateActor(actor, { root } = {}) {
123
+ const normalized = normalizeActor(actor);
124
+ if (!normalized) return { ok: true, mode: 'off', actor: normalized };
125
+ if (isReservedActor(normalized)) {
126
+ return { ok: false, mode: 'enforce', actor: normalized, reason: 'reserved_actor' };
127
+ }
128
+ const mode = actorValidationMode(root);
129
+ if (mode === 'off') return { ok: true, mode, actor: normalized };
130
+ if (rosterActors(root).has(normalized)) return { ok: true, mode, actor: normalized };
131
+ if (mode === 'enforce') return { ok: false, mode, actor: normalized, reason: 'actor_not_on_roster' };
132
+ return { ok: true, mode, actor: normalized, reason: 'actor_not_on_roster' };
133
+ }
134
+
135
+ module.exports = {
136
+ RESERVED_ACTORS,
137
+ normalizeActor,
138
+ envActor,
139
+ taskBuilder,
140
+ reviewEventActors,
141
+ independentReviewActors,
142
+ hasIndependentReview,
143
+ isReservedActor,
144
+ rosterActors,
145
+ actorValidationMode,
146
+ validateActor,
147
+ };
@@ -40,11 +40,33 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
40
40
  model: '',
41
41
  commandTemplate: '{bin} --trust -p {prompt}',
42
42
  }),
43
+ // Compatibility names for fleet rosters. These map to real installed
44
+ // runners instead of retired or UI-only model names.
45
+ fable: Object.freeze({
46
+ bin: 'claude',
47
+ model: '',
48
+ commandTemplate: '',
49
+ }),
50
+ composer: Object.freeze({
51
+ bin: 'ax',
52
+ model: 'composer-2-5-fast',
53
+ commandTemplate: '{bin} --fast {prompt}',
54
+ }),
55
+ haiku: Object.freeze({
56
+ bin: 'claude',
57
+ model: 'claude-haiku-4-5',
58
+ commandTemplate: '',
59
+ }),
43
60
  devin: Object.freeze({
44
61
  bin: 'devin',
45
62
  model: '',
46
63
  commandTemplate: '{bin} -p -- {prompt}',
47
64
  }),
65
+ hermes: Object.freeze({
66
+ bin: 'hermes',
67
+ model: '',
68
+ commandTemplate: '{bin} -p -- {prompt}',
69
+ }),
48
70
  });
49
71
 
50
72
  // Alias -> canonical profile name. Every alias resolves to the same config as
@@ -53,6 +75,7 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
53
75
  const RUNNER_PROFILE_ALIASES = Object.freeze({
54
76
  'atris2-fast': 'atris-fast',
55
77
  'atris-2-fast': 'atris-fast',
78
+ 'hermes-agent': 'hermes',
56
79
  });
57
80
 
58
81
  // Back-compat surface: RUNNER_PROFILES still resolves every accepted name