atris 3.58.8 → 3.60.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 (43) hide show
  1. package/AGENTS.md +3 -2
  2. package/atris/skills/engines/SKILL.md +28 -1
  3. package/bin/atris.js +13 -0
  4. package/commands/autopilot.js +75 -5
  5. package/commands/clean.js +17 -0
  6. package/commands/doc-health.js +11 -6
  7. package/commands/engine.js +386 -4
  8. package/commands/host.js +62 -0
  9. package/commands/member.js +57 -6
  10. package/commands/mission.js +72 -8
  11. package/commands/one-lap.js +58 -3
  12. package/commands/run.js +4 -1
  13. package/commands/scout.js +1 -1
  14. package/commands/search.js +5 -3
  15. package/commands/team.js +20 -3
  16. package/commands/unknowns.js +5 -0
  17. package/commands/verify.js +12 -4
  18. package/commands/visualize.js +20 -3
  19. package/commands/workflow.js +17 -6
  20. package/lib/dispatch-scout.js +31 -22
  21. package/lib/engine-registry.js +1482 -13
  22. package/lib/feedback-triage.js +2 -0
  23. package/lib/fleet.js +107 -11
  24. package/lib/host.js +461 -0
  25. package/lib/intents.js +15 -0
  26. package/lib/known-commands.js +1 -1
  27. package/lib/map-refs.js +44 -1
  28. package/lib/member-engine.js +253 -0
  29. package/lib/roster-markdown.js +206 -0
  30. package/lib/roster-models.js +179 -0
  31. package/lib/runner-command.js +94 -14
  32. package/lib/self-drive.js +9 -1
  33. package/lib/team-lineup.js +167 -0
  34. package/lib/wish-audit.js +10 -6
  35. package/lib/wish-delegate.js +10 -0
  36. package/package.json +1 -1
  37. package/templates/members/host/MEMBER.md +23 -0
  38. package/templates/members/host/MISSION.md +11 -0
  39. package/templates/members/host/SOUL.md +7 -0
  40. package/templates/members/host/context/why.md +3 -0
  41. package/templates/members/host/goals.json +5 -0
  42. package/templates/members/host/goals.md +3 -0
  43. package/templates/members/host/skills/host/SKILL.md +15 -0
@@ -11,6 +11,10 @@
11
11
  const DEFAULT_CLAUDE_RUNNER_MODEL = 'claude-opus-5-5';
12
12
  const DEFAULT_FABLE_RUNNER_MODEL = 'claude-fable-5';
13
13
  const DEFAULT_CLAUDE_RUNNER_BIN = 'claude';
14
+ // Effort words a roster line may carry after the model. Each engine lists the
15
+ // ones its CLI takes; an engine with no list takes none.
16
+ const EFFORT_WORDS = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']);
17
+ const CLAUDE_EFFORTS = EFFORT_WORDS;
14
18
 
15
19
  // Canonical runner profiles. Each entry is the config an operator would pick
16
20
  // on purpose. Aliases (spelling variants of the same profile) resolve to the
@@ -30,16 +34,24 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
30
34
  bin: 'claude',
31
35
  model: '',
32
36
  commandTemplate: '',
37
+ effortFlag: '--effort',
38
+ efforts: CLAUDE_EFFORTS,
33
39
  }),
40
+ // No -m or effort unless the roster pins one: codex then reads its own
41
+ // ~/.codex/config.toml, which is what the roster view reports.
34
42
  codex: Object.freeze({
35
43
  bin: 'codex',
36
44
  model: '',
37
- commandTemplate: '{bin} exec {prompt}',
45
+ commandTemplate: '{bin} exec {pinnedModelFlag} {pinnedEffortFlag} {prompt}',
46
+ effortFlag: '-c model_reasoning_effort=',
47
+ efforts: Object.freeze(['low', 'medium', 'high', 'xhigh']),
38
48
  }),
49
+ // cursor-agent takes effort only inside a bracketed model name some models
50
+ // accept, so a roster effort on cursor is refused rather than guessed.
39
51
  cursor: Object.freeze({
40
52
  bin: 'cursor-agent',
41
53
  model: '',
42
- commandTemplate: '{bin} --trust -p {prompt}',
54
+ commandTemplate: '{bin} --trust {pinnedModelFlag} -p {prompt}',
43
55
  }),
44
56
  // Fable is a distinct Claude Code model, not a friendly name for whatever
45
57
  // Claude model happens to be selected locally.
@@ -47,6 +59,8 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
47
59
  bin: 'claude',
48
60
  model: DEFAULT_FABLE_RUNNER_MODEL,
49
61
  commandTemplate: '',
62
+ effortFlag: '--effort',
63
+ efforts: CLAUDE_EFFORTS,
50
64
  }),
51
65
  composer: Object.freeze({
52
66
  bin: 'ax',
@@ -57,29 +71,46 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
57
71
  bin: 'claude',
58
72
  model: 'claude-haiku-4-5',
59
73
  commandTemplate: '',
74
+ effortFlag: '--effort',
75
+ efforts: CLAUDE_EFFORTS,
60
76
  }),
77
+ // Every engine with a model flag takes --model only when one is pinned (a
78
+ // roster pick or a mission model); otherwise it rides its own CLI default.
79
+ // {pinnedModelFlag} and {pinnedEffortFlag} render to nothing when unpinned.
80
+ // devin has no effort flag, so a roster effort on devin is refused.
61
81
  devin: Object.freeze({
62
82
  bin: 'devin',
63
83
  model: '',
64
- commandTemplate: '{bin} -p -- {prompt}',
84
+ commandTemplate: '{bin} -p {pinnedModelFlag} -- {prompt}',
65
85
  }),
86
+ // No hard pin: a pinned grok-4.6 went stale when the grok CLI moved its
87
+ // own default to grok-4.7-build-fast, so an unpinned run rides the CLI.
66
88
  grok: Object.freeze({
67
89
  bin: 'grok',
68
- model: 'grok-4.6',
69
- commandTemplate: '{bin} --always-approve -p {prompt}',
90
+ model: '',
91
+ commandTemplate: '{bin} --always-approve {pinnedModelFlag} {pinnedEffortFlag} -p {prompt}',
92
+ effortFlag: '--reasoning-effort',
93
+ efforts: Object.freeze(['low', 'medium', 'high']),
70
94
  }),
71
95
  agy: Object.freeze({
72
96
  bin: 'agy',
73
- // gemini 3.8 flash is the 2026-09-04 bake-off pick beside opencode muse.
74
- // --add-dir or agy edits its own scratch folder; the headless note or
75
- // gemini stops to ask "may I edit?" and exits 0 having changed nothing.
76
- model: 'gemini-3.8-flash-high',
77
- commandTemplate: '{bin} --mode accept-edits --dangerously-skip-permissions --add-dir "$PWD" {modelFlag} -p "You are running headless with edit permission already granted. Apply changes directly and never ask for confirmation. "{prompt}',
97
+ // No hard pin: the agy CLI keeps its own session model, so an unpinned
98
+ // run rides that and a roster line names gemini 3.8 flash (the 2026-09-04
99
+ // bake-off pick) when it wants it. --add-dir or agy edits its own scratch
100
+ // folder; the headless note or gemini stops to ask "may I edit?" and
101
+ // exits 0 having changed nothing.
102
+ model: '',
103
+ commandTemplate: '{bin} --mode accept-edits --dangerously-skip-permissions --add-dir "$PWD" {pinnedModelFlag} {pinnedEffortFlag} -p "You are running headless with edit permission already granted. Apply changes directly and never ask for confirmation. "{prompt}',
104
+ effortFlag: '--effort',
105
+ efforts: Object.freeze(['low', 'medium', 'high']),
78
106
  }),
107
+ // opencode names models provider/model; --variant is its effort knob.
79
108
  opencode: Object.freeze({
80
109
  bin: 'opencode',
81
110
  model: '',
82
- commandTemplate: '{bin} run {prompt}',
111
+ commandTemplate: '{bin} run {pinnedModelFlag} {pinnedEffortFlag} {prompt}',
112
+ effortFlag: '--variant',
113
+ efforts: Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']),
83
114
  }),
84
115
  // Private engine: fully functional (routable, selectable, probeable) but
85
116
  // kept out of every operator-facing list. For engines an operator wants to
@@ -171,6 +202,15 @@ function resolveClaudeRunnerModel(mission) {
171
202
  return DEFAULT_CLAUDE_RUNNER_MODEL;
172
203
  }
173
204
 
205
+ // The model chosen for this launch: an explicit model (a roster pick, a
206
+ // mission model) or the profile's own pin. ATRIS_RUNNER_MODEL is left out on
207
+ // purpose: it usually names a claude model, which devin or grok would reject.
208
+ // '' means nothing is pinned, so the engine's CLI picks its own default.
209
+ function resolvePinnedRunnerModel(mission) {
210
+ const explicit = mission && mission.model != null ? String(mission.model).trim() : '';
211
+ return explicit || runnerProfileValue('model');
212
+ }
213
+
174
214
  function resolveClaudeRunnerBin() {
175
215
  const env = firstConfiguredEnv(['ATRIS_RUNNER_BIN']);
176
216
  if (env) return env;
@@ -206,8 +246,29 @@ function runnerAvailabilityFailureMessage(error) {
206
246
  return `${runnerBin} CLI not found. Set ATRIS_RUNNER_BIN (or legacy ATRIS_CLAUDE_BIN), or install the configured runner first.`;
207
247
  }
208
248
 
209
- function renderRunnerCommandTemplate(template, { promptFile, allowedTools, model }) {
249
+ // The effort flag for the active profile, or '' when no effort is pinned.
250
+ // Throws a plain sentence when the engine cannot take that effort, so a bad
251
+ // pin never launches silently at the wrong effort.
252
+ function runnerEffortFlag(effort) {
253
+ const word = String(effort || '').trim().toLowerCase();
254
+ if (!word) return '';
255
+ const profile = resolveRunnerProfile();
256
+ const flag = profile ? profile.effortFlag : RUNNER_PROFILE_DEFS.claude.effortFlag;
257
+ const allowed = profile ? profile.efforts : RUNNER_PROFILE_DEFS.claude.efforts;
258
+ const name = resolveRunnerProfileName() || 'claude';
259
+ if (!flag || !allowed || !allowed.includes(word)) throw new Error(`${name} cannot take the effort "${word}"`);
260
+ return flag.endsWith('=') ? `${flag}${word}` : `${flag} ${word}`;
261
+ }
262
+
263
+ function renderRunnerCommandTemplate(template, { promptFile, allowedTools, model, pinnedModel = '', effort = '' }) {
210
264
  const allowedToolsFlag = allowedTools ? `--allowedTools ${shellWord(allowedTools)}` : '';
265
+ // The pinned flags take their leading space with them, so an unpinned
266
+ // launch reads exactly like the template without the placeholders.
267
+ template = template.replace(/\s*\{pinnedModelFlag\}/g, pinnedModel ? ` --model ${shellWord(pinnedModel)}` : '');
268
+ // Checked even when the template has no slot, so an engine that cannot
269
+ // take effort refuses instead of running at its default.
270
+ const effortFlag = runnerEffortFlag(effort);
271
+ template = template.replace(/\s*\{pinnedEffortFlag\}/g, effortFlag ? ` ${effortFlag}` : '');
211
272
  const promptFileWord = shellWord(promptFile);
212
273
  const values = {
213
274
  bin: shellWord(resolveClaudeRunnerBin()),
@@ -231,24 +292,42 @@ function renderRunnerCommandTemplate(template, { promptFile, allowedTools, model
231
292
  // for GLM/OpenAI/other local runners. The old ATRIS_CLAUDE_* env vars remain
232
293
  // aliases for existing installs. allowedTools is optional: some call sites
233
294
  // (e.g. horizon proposal) run without a tool allowlist.
234
- function buildRunnerCommand({ promptFile, allowedTools, model } = {}) {
295
+ function buildRunnerCommand({ promptFile, allowedTools, model, effort } = {}) {
235
296
  if (!promptFile) {
236
297
  throw new Error('buildRunnerCommand: promptFile is required');
237
298
  }
238
299
  const resolved = resolveClaudeRunnerModel({ model });
239
300
  const template = resolveClaudeRunnerCommandTemplate();
240
301
  if (template) {
241
- return renderRunnerCommandTemplate(template, { promptFile, allowedTools, model: resolved });
302
+ return renderRunnerCommandTemplate(template, { promptFile, allowedTools, model: resolved, pinnedModel: resolvePinnedRunnerModel({ model }), effort });
242
303
  }
243
304
  const safePath = String(promptFile).replace(/'/g, "'\\''");
244
305
  let cmd = `${shellWord(resolveClaudeRunnerBin())} -p "$(cat '${safePath}')" --model ${shellWord(resolved)}`;
306
+ const effortFlag = runnerEffortFlag(effort);
307
+ if (effortFlag) cmd += ` ${effortFlag}`;
245
308
  if (allowedTools) {
246
309
  cmd += ` --allowedTools ${shellWord(allowedTools)}`;
247
310
  }
248
311
  return cmd;
249
312
  }
250
313
 
314
+ // Whether this engine's launch can carry a pinned model, and which effort
315
+ // words it takes. The roster checks both when it reads a line.
316
+ function engineTakesModel(engineId) {
317
+ const def = RUNNER_PROFILE_DEFS[RUNNER_PROFILE_ALIASES[engineId] || engineId];
318
+ if (!def) return false;
319
+ return !def.commandTemplate || def.commandTemplate.includes('{pinnedModelFlag}');
320
+ }
321
+
322
+ function engineEffortLevels(engineId) {
323
+ const def = RUNNER_PROFILE_DEFS[RUNNER_PROFILE_ALIASES[engineId] || engineId];
324
+ return def && def.efforts ? def.efforts : [];
325
+ }
326
+
251
327
  module.exports = {
328
+ EFFORT_WORDS,
329
+ engineTakesModel,
330
+ engineEffortLevels,
252
331
  DEFAULT_CLAUDE_RUNNER_MODEL,
253
332
  DEFAULT_FABLE_RUNNER_MODEL,
254
333
  DEFAULT_CLAUDE_RUNNER_BIN,
@@ -259,6 +338,7 @@ module.exports = {
259
338
  HIDDEN_PROFILE_NAMES,
260
339
  resolveRunnerProfile,
261
340
  resolveClaudeRunnerModel,
341
+ resolvePinnedRunnerModel,
262
342
  resolveClaudeRunnerBin,
263
343
  resolveClaudeRunnerCommandTemplate,
264
344
  buildRunnerAvailabilityCommand,
package/lib/self-drive.js CHANGED
@@ -272,7 +272,15 @@ function handleMissionBlocker({ mission, stopReason, workspaceRoot, appendEvent
272
272
  agent: engine.id,
273
273
  task: `mission-blocker-${String(ref).toLowerCase()}`,
274
274
  });
275
- deps.dispatchToEngine({ task, engine: engine.id, worktreePath: worktree.path, root: scopedRoot });
275
+ deps.dispatchToEngine({
276
+ task,
277
+ engine: engine.id,
278
+ worktreePath: worktree.path,
279
+ root: scopedRoot,
280
+ ...(engine.roster_model ? { model: engine.roster_model } : {}),
281
+ ...(engine.roster_effort ? { effort: engine.roster_effort } : {}),
282
+ ...(engine.roster_max_seconds ? { maxSeconds: engine.roster_max_seconds } : {}),
283
+ });
276
284
  projectTasks(deps.taskDb, db, scopedRoot);
277
285
  appendEvent?.('mission_blocker_dispatched', {
278
286
  task_id: ref,
@@ -0,0 +1,167 @@
1
+ 'use strict';
2
+
3
+ // Who does each job, worded for `atris team` and the session boot. The rows
4
+ // come from the same resolver `atris engine roster` uses (jobRosterView and
5
+ // rosterReport in commands/engine.js); this file only picks and words them.
6
+
7
+ const { engineRunsView, modelLabel } = require('./roster-models');
8
+ const { rosterToolLabel } = require('./engine-registry');
9
+
10
+ const BUILT_IN_JOBS = Object.freeze(['build', 'review', 'search']);
11
+ const CLAUDE_FAMILY = Object.freeze(['claude', 'fable', 'haiku']);
12
+
13
+ // Built-in jobs in the order people say them, then the owner's own jobs.
14
+ function orderJobs(rows) {
15
+ const list = Array.isArray(rows) ? rows : [];
16
+ const builtIn = BUILT_IN_JOBS.map((job) => list.find((row) => row.job === job && !row.key)).filter(Boolean);
17
+ return [...builtIn, ...list.filter((row) => !builtIn.includes(row))];
18
+ }
19
+
20
+ // "opus 5.5" for a claude model, "codex gpt-6-astra" for anything else, and
21
+ // just the tool when it runs its own default.
22
+ function shortRuns(view) {
23
+ if (!view || !view.engine) return '';
24
+ const tool = rosterToolLabel(view.engine);
25
+ if (!view.model) return tool;
26
+ const label = modelLabel(view.model);
27
+ // "atris:fast" on atris fast says the tool twice; "grok 4.7 fast" on grok
28
+ // already names it.
29
+ const plain = (text) => String(text).toLowerCase().replace(/[^a-z0-9.]+/g, ' ').trim();
30
+ if (plain(label) === plain(tool)) return tool;
31
+ if (CLAUDE_FAMILY.includes(view.engine) || plain(label).startsWith(`${plain(tool)} `)) return label;
32
+ return `${tool} ${label}`;
33
+ }
34
+
35
+ // The full lineup: every job and every member, read once. Throws when the
36
+ // roster cannot be read; callers decide how to say so.
37
+ function readLineup(root = process.cwd(), now = new Date()) {
38
+ const { rosterReport } = require('../commands/engine');
39
+ const report = rosterReport(root, now);
40
+ return { jobs: orderJobs(report.jobs), team: report.team || [] };
41
+ }
42
+
43
+ // The same read, never throwing: { ok: false, error } when it fails.
44
+ function readLineupSafe(root = process.cwd(), now = new Date()) {
45
+ try {
46
+ return { ok: true, ...readLineup(root, now) };
47
+ } catch (error) {
48
+ return { ok: false, error: String((error && error.message) || error || 'unknown error'), jobs: [], team: [] };
49
+ }
50
+ }
51
+
52
+ // One boot line: the tool and model that runs each built-in job right now,
53
+ // custom jobs only when all of them still fit. '' when nothing resolves.
54
+ function bootTeamLine(root = process.cwd(), { width = 69, now = new Date(), jobs = null } = {}) {
55
+ let rows = jobs;
56
+ if (!rows) {
57
+ const { jobRosterView } = require('../commands/engine');
58
+ rows = jobRosterView(root, now);
59
+ }
60
+ const ordered = orderJobs(rows);
61
+ const part = (row) => {
62
+ const text = shortRuns(row.now_runs);
63
+ return text ? `${row.job} ${text}` : '';
64
+ };
65
+ const builtIn = ordered.filter((row) => BUILT_IN_JOBS.includes(row.job) && !row.key).map(part).filter(Boolean);
66
+ const custom = ordered.filter((row) => !BUILT_IN_JOBS.includes(row.job) || row.key).map(part).filter(Boolean);
67
+ if (!builtIn.length && !custom.length) return '';
68
+ const all = [...builtIn, ...custom].join(' · ');
69
+ if (all.length <= width || !builtIn.length) return all;
70
+ return builtIn.join(' · ');
71
+ }
72
+
73
+ function runsText(view) {
74
+ return view && view.text ? view.text : 'no ready tool';
75
+ }
76
+
77
+ // "who does each job" lines: the tool and model running each job now, its
78
+ // backup, and a short note when the router or a backup is standing in.
79
+ function renderJobLines(jobs, { width = 80 } = {}) {
80
+ const rows = orderJobs(jobs);
81
+ if (!rows.length) return [];
82
+ const labelWidth = Math.max(...rows.map((row) => row.job.length));
83
+ return rows.map((row) => {
84
+ const head = ` ${row.job.padEnd(labelWidth)} ${runsText(row.now_runs)}`;
85
+ let note = '';
86
+ if (!row.pick) note = row.like ? `, same as ${row.like}` : ', picked by the router';
87
+ else if (row.lead === 'first' && row.backup_runs) note = `, backup ${row.backup_runs.text}`;
88
+ else if (row.lead !== 'first') note = row.status === 'expired' ? ', first pick expired' : ', first pick not ready';
89
+ const line = `${head}${note}`;
90
+ return line.length <= width || row.lead !== 'first' ? line : head;
91
+ });
92
+ }
93
+
94
+ // Names under each job, wrapped to the width. A member running something
95
+ // other than its job's tool and model says what it runs.
96
+ function renderMemberLines(team, jobs, { width = 80 } = {}) {
97
+ const members = Array.isArray(team) ? team : [];
98
+ if (!members.length) return [];
99
+ const rows = orderJobs(jobs);
100
+ const order = [...rows.map((row) => row.job)];
101
+ for (const member of members) if (member.job && !order.includes(member.job)) order.push(member.job);
102
+ const groups = order
103
+ .map((job) => ({ job, row: rows.find((row) => row.job === job) || null, names: members.filter((member) => member.job === job) }))
104
+ .filter((group) => group.names.length);
105
+ const labelWidth = Math.max(...groups.map((group) => group.job.length));
106
+ const indent = ' '.repeat(labelWidth + 4);
107
+ const lines = [];
108
+ for (const group of groups) {
109
+ const jobText = group.row && group.row.now_runs ? group.row.now_runs.text : '';
110
+ const names = group.names.map((member) => {
111
+ const text = member.engine ? engineRunsView(member.engine, { model: member.model || '', effort: member.effort || '' }).text : 'no ready tool';
112
+ return text === jobText ? member.member : `${member.member} on ${text}`;
113
+ });
114
+ let line = ` ${group.job.padEnd(labelWidth)} `;
115
+ let fresh = true;
116
+ for (const name of names) {
117
+ const piece = fresh ? name : `, ${name}`;
118
+ if (!fresh && line.length + piece.length + 1 > width) {
119
+ lines.push(`${line},`);
120
+ line = `${indent}${name}`;
121
+ } else {
122
+ line += piece;
123
+ }
124
+ fresh = false;
125
+ }
126
+ lines.push(line);
127
+ }
128
+ return lines;
129
+ }
130
+
131
+ // The block `atris team` prints before today's active and rest lists.
132
+ function renderLineup(lineup, { width = 80 } = {}) {
133
+ if (!lineup || !lineup.ok) return '';
134
+ const jobLines = renderJobLines(lineup.jobs, { width });
135
+ const memberLines = renderMemberLines(lineup.team, lineup.jobs, { width });
136
+ const blocks = [];
137
+ if (jobLines.length) blocks.push(['who does each job:', ...jobLines].join('\n'));
138
+ if (memberLines.length) blocks.push(['who is on each job:', ...memberLines].join('\n'));
139
+ return blocks.join('\n\n');
140
+ }
141
+
142
+ // Per member, for json: the job, tool, model, and whether a roster line or
143
+ // the automatic match decided it. null for a name the lineup does not know.
144
+ function memberLineup(lineup, name) {
145
+ if (!lineup || !lineup.ok) return null;
146
+ const key = String(name || '').trim().toLowerCase();
147
+ const row = (lineup.team || []).find((member) => String(member.member || '').toLowerCase() === key);
148
+ if (!row) return null;
149
+ return {
150
+ job: row.job || null,
151
+ engine: row.engine || null,
152
+ model: row.model || null,
153
+ effort: row.effort || null,
154
+ source: row.source === 'file' ? 'roster' : 'automatic',
155
+ file: row.file || null,
156
+ };
157
+ }
158
+
159
+ const LINEUP_UNREADABLE = 'could not read who does each job, so tools and models are not shown. try: atris engine roster';
160
+
161
+ module.exports = {
162
+ LINEUP_UNREADABLE,
163
+ bootTeamLine,
164
+ memberLineup,
165
+ readLineupSafe,
166
+ renderLineup,
167
+ };
package/lib/wish-audit.js CHANGED
@@ -407,9 +407,9 @@ function inferBudgetTier(text) {
407
407
  return tier || 'long';
408
408
  }
409
409
 
410
- function resolveRole(role, root) {
410
+ function resolveRole(role, root, options = {}) {
411
411
  const resolver = engineRegistry.resolveRole || engineRegistry.resolve || engineRegistry.resolveEngineForRole;
412
- return resolver(role, root);
412
+ return resolver(role, root, options);
413
413
  }
414
414
 
415
415
  // Route-time validator policy: the routing decision must not depend on which
@@ -430,12 +430,15 @@ function policyValidator(root, excludeId = '') {
430
430
  }
431
431
  }
432
432
 
433
- function resolveExecutor(root, engineOverride = '') {
433
+ // A wish already sized quick (a small fix, tweak, or patch) asks the roster
434
+ // for the owner's small build pick first; with none set, it routes as any
435
+ // build. The router's own ranking is unchanged either way.
436
+ function resolveExecutor(root, engineOverride = '', options = {}) {
434
437
  if (engineOverride) {
435
- return engineRegistry.resolveEngineForRoleWithPreference('executor', root, engineOverride);
438
+ return engineRegistry.resolveEngineForRoleWithPreference('executor', root, engineOverride, options);
436
439
  }
437
440
  return {
438
- engine: resolveRole('executor', root),
441
+ engine: resolveRole('executor', root, options),
439
442
  requested_engine: null,
440
443
  engine_fallback_reason: null,
441
444
  };
@@ -1034,7 +1037,8 @@ function auditWish(text, root = process.cwd(), options = {}) {
1034
1037
  let requestedEngine = null;
1035
1038
  let engineFallbackReason = null;
1036
1039
  try {
1037
- const resolved = resolveExecutor(root, engineOverride);
1040
+ const small = inferBudgetTier(text) === 'quick' ? { job: engineRegistry.SMALL_BUILD_JOB } : {};
1041
+ const resolved = resolveExecutor(root, engineOverride, small);
1038
1042
  executor = resolved.engine;
1039
1043
  requestedEngine = resolved.requested_engine;
1040
1044
  engineFallbackReason = resolved.engine_fallback_reason;
@@ -763,6 +763,13 @@ function startWishDelegation(wish, audit, root, options = {}) {
763
763
  const missionVerifier = verifyPlan.command || resolveDefaultVerifier(root);
764
764
  const verifyWarning = verifierBudgetWarning(missionVerifier);
765
765
  const engine = wishDelegationEngine(audit, options);
766
+ // The build roster can pin a model for this engine; carry it onto the
767
+ // mission so the runner does not fall back to the engine default.
768
+ // Its effort and time cap ride along the same way.
769
+ const rosterPicked = Boolean(audit.executor && audit.executor.id === engine);
770
+ const rosterModel = audit.executor && audit.executor.id === engine && audit.executor.roster_model
771
+ ? audit.executor.roster_model
772
+ : '';
766
773
  const now = stampIso();
767
774
  const ownerResolution = resolveFunctionalOwner({
768
775
  requestedOwner: audit.executor.id,
@@ -899,6 +906,9 @@ function startWishDelegation(wish, audit, root, options = {}) {
899
906
  mission_room_receipt_path: writtenRoom.relativePath,
900
907
  mission_room_name: writtenRoom.room?.name || null,
901
908
  source: 'wish',
909
+ ...(rosterModel ? { model: rosterModel } : {}),
910
+ ...(rosterPicked && audit.executor.roster_effort ? { roster_effort: audit.executor.roster_effort } : {}),
911
+ ...(rosterPicked && audit.executor.roster_max_seconds ? { roster_max_seconds: audit.executor.roster_max_seconds } : {}),
902
912
  ...(verifyWarning ? { verifier_budget_warning: verifyWarning } : {}),
903
913
  ...(designBrief ? {
904
914
  design_brief_paths: designBrief.paths,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.58.8",
3
+ "version": "3.60.0",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: host
3
+ role: The Host
4
+ description: Gets to know people through fun questions and makes welcome, useful introductions with two private yeses.
5
+ version: 1.0.0
6
+ skills:
7
+ - host
8
+ permissions:
9
+ can-read: true
10
+ can-plan: true
11
+ can-execute: true
12
+ can-approve: false
13
+ approval-required:
14
+ - deliver-message
15
+ ---
16
+
17
+ # The Host
18
+
19
+ A warm, discreet companion for a company, club, or personal network. Notice what each person is into and what might help them now. Make a small, concrete invitation when two people could each benefit.
20
+
21
+ Ask people directly. Never read their messages or infer private facts from a quiet week. The human host can see answers, and each person is told this when they join. Published cards contain only the five friendly card sections. Never rank, score, evaluate, or hold people accountable.
22
+
23
+ Read `skills/host/SKILL.md` for the operating loop. The door adapter delivers queued messages. The Host only drafts them.
@@ -0,0 +1,11 @@
1
+ # Mission
2
+
3
+ Help people find conversations they would be glad to have and connections they might otherwise miss.
4
+
5
+ ## North Star
6
+
7
+ Useful conversations that both people freely chose.
8
+
9
+ ## Boundaries
10
+
11
+ Ask one fun question every few days. Keep a private memory and a small published card per person. Only introduce two people after two private yeses. Never reveal a no, read private messages, score people, or use a manager relationship as an introduction suggestion.
@@ -0,0 +1,7 @@
1
+ # The Host's soul
2
+
3
+ Be on each person's side. Noticing a small win or a timely need is the start of a good relationship.
4
+
5
+ Trust comes first. A thin signal is a reason to ask, not a reason to assume. One thoughtful introduction is better than ten plausible ones. A private no stays private.
6
+
7
+ Sound like a thoughtful friend: curious, specific, and easy to decline.
@@ -0,0 +1,3 @@
1
+ # Why this exists
2
+
3
+ Groups are full of people who could help each other, but the useful overlap is often invisible. The Host learns by asking, remembers what people volunteered, and offers introductions that are current, mutual, and easy to refuse.
@@ -0,0 +1,5 @@
1
+ {
2
+ "schema": "atris.member_goals.v1",
3
+ "member": "host",
4
+ "goals": []
5
+ }
@@ -0,0 +1,3 @@
1
+ # Goals
2
+
3
+ Get to know the room one question at a time. Keep cards fresh, celebrate people, and make careful introductions.
@@ -0,0 +1,15 @@
1
+ # Host operating loop
2
+
3
+ Run in a workspace with `atris/`. Use `atris host <subcommand> --json` to read machine output. Treat all private paths as host-only. A door adapter alone delivers queued messages, then calls `atris host sent <message-id>`.
4
+ Doors pass `--ref <provider-ref>` when marking a message sent and `--reply-to-ref <provider-ref>` when handing back a reply.
5
+
6
+ 1. Run `atris host due`. For each person, read their private record and published card. Pick one playful question that fills a real gap in the card. Ask with `atris host ask <id> --question "..."`. Do not repeat a question or pressure someone who goes quiet.
7
+ 2. Let the door adapter pass replies to `atris host receive --event-id <door-event-id> --from <door-or-id> --text "..." --reply-to <message-id>` when the door provides the original outbox message id. When a reply is not a plain yes or no, read it and pass `--decision yes` or `--decision no` with what the person meant. If it is truly unclear, pass no decision. Drain replies before deciding what to do next. Update a published card with `atris host card <id> --patch <file.json> --expected-revision <revision>`. Keep answers and operational fields out of the card.
8
+ 3. Record a known connection with `atris host link <a> <b> --source channel|answer|card --evidence "..."`. Use `card` only for a tentative clue. The code blocks introductions when another source confirms a link.
9
+ Record a link only when two people already know each other. "Wants to meet" belongs on the card, not in links.
10
+ 4. Read `atris host people --json` before proposing. Only propose people with `can_be_introduced` true. New people get a warmer welcome with up to 3 introductions in their first month; prioritize them when proposing. Everyone else gets at most one introduction per 30 days. Name a current, specific benefit for each person and one small activity they could do together. Shared interests alone are not enough. Check existing links and exclude manager and report pairs. Draft the exact text, then call `atris host propose <a> <b> --reason "..." --activity "..." --text "..."`. Two private yeses are required. A no is never disclosed.
11
+ An introduction reason may use only what is already on both published cards, never private answers.
12
+ 5. Run `atris host schedule --json` after handling replies. For each requested introduction, use the calendar tools available in the workspace to find a time for both people, then call `atris host scheduled <attempt-id> --when "<time>"`. A nudge is private, and either person's yes is enough to request scheduling.
13
+ 6. Once a week run `atris host room`. Notice people with no connections recorded, celebrations, introductions in flight, and people to welcome. Use this as a gentle prompt, never a ranking or performance report.
14
+
15
+ On `leave`, `pause`, or `forget`, respect the person's choice. Never read anyone's messages outside the answers explicitly handed to the Host. Never send directly from this skill.