atris 3.70.0 → 3.71.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 (76) hide show
  1. package/README.md +18 -0
  2. package/atris/skills/README.md +1 -0
  3. package/atris/skills/campaigns/SKILL.md +81 -0
  4. package/atris/skills/meta/SKILL.md +52 -8
  5. package/ax +226 -42
  6. package/bin/atris.js +26 -6
  7. package/commands/autoland.js +176 -7
  8. package/commands/autopilot.js +71 -11
  9. package/commands/campaign.js +759 -0
  10. package/commands/compile.js +19 -8
  11. package/commands/doctor.js +58 -0
  12. package/commands/dream.js +18 -3
  13. package/commands/engine.js +147 -5
  14. package/commands/improve.js +119 -9
  15. package/commands/init.js +18 -1
  16. package/commands/interview.js +15 -3
  17. package/commands/land.js +22 -6
  18. package/commands/link.js +1 -0
  19. package/commands/member.js +359 -94
  20. package/commands/mission.js +249 -36
  21. package/commands/report.js +42 -8
  22. package/commands/run.js +9 -2
  23. package/commands/scout.js +9 -1
  24. package/commands/task.js +71 -17
  25. package/commands/team.js +212 -4
  26. package/commands/unknowns.js +15 -1
  27. package/commands/verify.js +46 -0
  28. package/commands/worktree.js +108 -15
  29. package/commands/write.js +12 -1
  30. package/commands/youtube.js +17 -2
  31. package/lib/accept-verify-gate.js +1 -0
  32. package/lib/auto-accept-certified.js +1 -0
  33. package/lib/autoland.js +13 -4
  34. package/lib/campaign-cadence.js +131 -0
  35. package/lib/campaign-core.js +330 -0
  36. package/lib/campaign-holdout.js +65 -0
  37. package/lib/campaign-loop.js +242 -0
  38. package/lib/campaign-missions.js +237 -0
  39. package/lib/campaign-new.js +251 -0
  40. package/lib/campaign-protect.js +75 -0
  41. package/lib/campaign-reading.js +147 -0
  42. package/lib/campaign-schema.js +196 -0
  43. package/lib/campaign-store.js +380 -0
  44. package/lib/campaign-sync.js +302 -0
  45. package/lib/campaign-trace.js +300 -0
  46. package/lib/conductor-artifacts.js +15 -1
  47. package/lib/daily-log.js +11 -1
  48. package/lib/dispatch-scout.js +18 -0
  49. package/lib/engine-ask.js +33 -3
  50. package/lib/engine-registry.js +2 -0
  51. package/lib/engine-resource.js +518 -0
  52. package/lib/fleet.js +125 -16
  53. package/lib/init-commit.js +80 -0
  54. package/lib/intents.js +8 -0
  55. package/lib/known-commands.js +1 -1
  56. package/lib/land-green.js +58 -7
  57. package/lib/landing-proof.js +352 -0
  58. package/lib/member-autoland.js +395 -18
  59. package/lib/member-board.js +7 -1
  60. package/lib/member-map-fold.js +126 -0
  61. package/lib/member-map-inbox.js +80 -0
  62. package/lib/member-progress.js +48 -1
  63. package/lib/member-report.js +127 -8
  64. package/lib/member-self-next-job.js +97 -0
  65. package/lib/member-task-pick.js +41 -14
  66. package/lib/member-up.js +249 -16
  67. package/lib/roster-prep.js +25 -2
  68. package/lib/runner-command.js +5 -0
  69. package/lib/task-db.js +47 -11
  70. package/lib/task-explanation.js +7 -0
  71. package/lib/task-list-keeper.js +8 -1
  72. package/lib/task-receipt.js +6 -1
  73. package/mcp/atris-mcp/index.mjs +212 -10
  74. package/package.json +2 -1
  75. package/scripts/det/engine-reading.js +37 -0
  76. package/scripts/det/ytnotes +29 -5
package/ax CHANGED
@@ -97,8 +97,76 @@ function tierColor(mode) {
97
97
  return TIER_COLORS[mode] || ANSI.accent;
98
98
  }
99
99
 
100
+ // Any model by name: `/model <name>` or `--model <name>` rides as mode `model:<spec>`.
101
+ const MODEL_ALIASES = Object.freeze({
102
+ mercury: 'mercury',
103
+ 'mercury-3': 'mercury',
104
+ 'inception:mercury-3-preview-1002': 'mercury',
105
+ fast: 'fast',
106
+ pro: 'pro',
107
+ max: 'max',
108
+ rapid: 'rapid',
109
+ alpha: 'alpha',
110
+ });
111
+
112
+ // The /model pick list. Each row answered as itself on the backend 2026-10-14.
113
+ const MODEL_CHOICES = Object.freeze([
114
+ { mode: 'fast', label: 'Atris 2 Fast' },
115
+ { mode: 'pro', label: 'Atris 2 Pro' },
116
+ { mode: 'max', label: 'Atris 2 Max' },
117
+ { mode: 'rapid', label: 'Atris Rapid' },
118
+ { mode: 'alpha', label: 'Atris Alpha' },
119
+ { mode: 'mercury', label: 'Mercury 3 (test)' },
120
+ { mode: 'model:openrouter:anthropic/claude-opus-5.5', label: 'Claude Opus 5.5' },
121
+ { mode: 'model:openai:gpt-6-astra', label: 'GPT-6 Astra' },
122
+ { mode: 'model:openai:gpt-5.6-terra', label: 'GPT-5.6 Terra' },
123
+ { mode: 'model:openrouter:google/gemini-3.8-flash', label: 'Gemini 3.8 Flash' },
124
+ { mode: 'model:xai:grok-4.6', label: 'Grok 4.6' },
125
+ { mode: 'model:openrouter:deepseek/deepseek-v4.1-flash', label: 'DeepSeek V4.1 Flash' },
126
+ { mode: 'model:openrouter:z-ai/glm-5.3-flash', label: 'GLM 5.3 Flash' },
127
+ ].map(choice => Object.freeze(choice)));
128
+
129
+ function squashModelName(value) {
130
+ return String(value || '').toLowerCase().replace(/[^a-z0-9]/g, '');
131
+ }
132
+
133
+ function modeForModelName(name) {
134
+ const raw = String(name || '').trim();
135
+ if (!raw) return null;
136
+ if (/^\d+$/.test(raw)) {
137
+ const choice = MODEL_CHOICES[Number(raw) - 1];
138
+ return choice ? choice.mode : null;
139
+ }
140
+ const alias = MODEL_ALIASES[raw.toLowerCase()];
141
+ if (alias) return alias;
142
+ // "opus", "gemini", "gpt-6": a unique start of a pick-list name picks it.
143
+ const query = squashModelName(raw);
144
+ const exact = MODEL_CHOICES.find(choice => squashModelName(choice.label) === query);
145
+ if (exact) return exact.mode;
146
+ const hits = MODEL_CHOICES.filter(choice => squashModelName(choice.label).includes(query));
147
+ if (hits.length === 1) return hits[0].mode;
148
+ if (!raw.includes(':')) return null;
149
+ return `model:${raw}`;
150
+ }
151
+
152
+ function modelChoiceList(currentMode, options = {}) {
153
+ const width = MODEL_CHOICES.reduce((max, choice) => Math.max(max, choice.label.length), 0);
154
+ const rows = MODEL_CHOICES.map((choice, index) => {
155
+ const mark = choice.mode === currentMode ? '●' : ' ';
156
+ const num = String(index + 1).padStart(2);
157
+ const id = choice.mode.startsWith('model:') ? choice.mode.slice('model:'.length) : modelForMode(choice.mode);
158
+ return ` ${mark} ${num} ${paint(choice.label.padEnd(width), [tierColor(choice.mode)], options)} ${paint(id, [ANSI.muted], options)}`;
159
+ });
160
+ if (String(currentMode || '').startsWith('model:') && !MODEL_CHOICES.some(choice => choice.mode === currentMode)) {
161
+ rows.push(` ● - ${currentMode.slice('model:'.length)}`);
162
+ }
163
+ return [...rows, paint(' pick: /model 6 · /model opus · /model provider:model-name', [ANSI.muted], options)].join('\n');
164
+ }
165
+
100
166
  function modelForMode(mode) {
167
+ if (String(mode || '').startsWith('model:')) return mode.slice('model:'.length);
101
168
  if (mode === 'code-fast') return CODE_FAST.model;
169
+ if (mode === 'mercury') return 'inception:mercury-3-preview-1002';
102
170
  if (mode === 'max') return 'atris:max';
103
171
  if (mode === 'alpha') return 'atris:alpha';
104
172
  if (mode === 'rapid') return 'atris:rapid';
@@ -147,7 +215,12 @@ function formatSeconds(totalSeconds) {
147
215
  }
148
216
 
149
217
  function tierLabel(mode) {
218
+ if (String(mode || '').startsWith('model:')) {
219
+ const choice = MODEL_CHOICES.find(item => item.mode === mode);
220
+ return choice ? choice.label : mode.slice('model:'.length);
221
+ }
150
222
  if (mode === 'code-fast') return 'Atris Code Fast';
223
+ if (mode === 'mercury') return 'mercury 3 (test model)';
151
224
  if (mode === 'max') return 'Atris 2 Max';
152
225
  if (mode === 'alpha') return 'Atris Alpha';
153
226
  if (mode === 'rapid') return 'Atris Rapid';
@@ -160,7 +233,7 @@ function formatHeader({ mode = 'pro', cwd = process.cwd(), chat = false, reasoni
160
233
  return [
161
234
  paint(`${tierLabel(mode)}${chat ? ' chat' : ''}${effortSuffix}`, [ANSI.bold, tierColor(normalizeMode(mode))], options),
162
235
  cwd,
163
- chat ? paint('shift-tab bypass · /auto /log /today /now /who · /fast /pro /max /rapid /alpha /effort · exit to leave', [ANSI.muted], options) : '',
236
+ chat ? paint('shift-tab bypass · /auto /log /today /now /who · /model to switch models · /effort · exit to leave', [ANSI.muted], options) : '',
164
237
  ].filter(Boolean).join('\n');
165
238
  }
166
239
 
@@ -169,11 +242,11 @@ function formatUsage() {
169
242
  'ax - Atris local/code agent',
170
243
  '',
171
244
  'Usage:',
172
- ' ax [--auto|--max|--pro|--fast|--rapid|--alpha|--code-fast] [--local|--cloud] <message>',
173
- ' ax [--auto|--max|--pro|--fast|--rapid|--alpha|--code-fast] [--local|--cloud] --print <message>',
174
- ' ax [--auto|--max|--pro|--fast|--rapid|--alpha|--code-fast] [--local|--cloud] --chat',
245
+ ' ax [--auto|--max|--pro|--fast|--rapid|--alpha|--mercury|--code-fast] [--local|--cloud] <message>',
246
+ ' ax [--auto|--max|--pro|--fast|--rapid|--alpha|--mercury|--code-fast] [--local|--cloud] --print <message>',
247
+ ' ax [--auto|--max|--pro|--fast|--rapid|--alpha|--mercury|--code-fast] [--local|--cloud] --chat',
175
248
  ' ax [--max|--pro|--fast|--rapid|--alpha] --business <slug> [<message>|--chat]',
176
- ' ax [--max|--pro|--fast|--rapid|--alpha|--code-fast] --doctor',
249
+ ' ax [--max|--pro|--fast|--rapid|--alpha|--mercury|--code-fast] --doctor',
177
250
  ' ax --approvals',
178
251
  ' ax --approve <approval-id>',
179
252
  ' ax --deny <approval-id>',
@@ -191,6 +264,9 @@ function formatUsage() {
191
264
  ' --pro hosted Atris 2, deeper tool loop',
192
265
  ' --fast hosted Atris 2, faster low-latency turns',
193
266
  ' --rapid hosted Atris Rapid, cheapest chat and first-hop tools',
267
+ ' --mercury mercury 3 test model, with turn timing',
268
+ ' --model <name> any model by name (mercury, or provider:model); in chat use /model <name>',
269
+ ' --diffuse watch mercury text refine live in a terminal',
194
270
  ' --alpha hosted experimental lane (free preview model, swap via ATRIS2_ALPHA_MODEL)',
195
271
  ' --effort <level> reasoning effort: none, low, medium, high, xhigh, or default',
196
272
  ' --code-fast Atris Code Fast public lane',
@@ -986,7 +1062,9 @@ function resolveRoute(message, options = {}) {
986
1062
  }
987
1063
 
988
1064
  function normalizeMode(mode) {
1065
+ if (String(mode || '').startsWith('model:')) return mode;
989
1066
  if (mode === 'code-fast' || mode === 'code') return 'code-fast';
1067
+ if (mode === 'mercury') return 'mercury';
990
1068
  if (mode === 'max') return 'max';
991
1069
  if (mode === 'alpha') return 'alpha';
992
1070
  if (mode === 'rapid') return 'rapid';
@@ -1113,7 +1191,9 @@ function nextApproveMode(arg, current) {
1113
1191
  function formatPrompt(mode, options = {}) {
1114
1192
  if (!mode) return '› ';
1115
1193
  const tier = normalizeMode(mode);
1116
- const label = paint(tier, [ANSI.bold, tierColor(tier)], options);
1194
+ // Picked models show their short name: "claude-opus-5.5", not "model:openrouter:...".
1195
+ const short = tier.startsWith('model:') ? tier.split(/[:/]/).pop() : tier;
1196
+ const label = paint(short, [ANSI.bold, tierColor(tier)], options);
1117
1197
  const tag = approveModeTag(options.approveMode);
1118
1198
  if (tag) {
1119
1199
  return `${label} ${paint(`[${tag}]`, [ANSI.muted], options)} › `;
@@ -1167,7 +1247,7 @@ function formatReasoningEffortLabel(value) {
1167
1247
 
1168
1248
  const CHAT_COMMANDS = Object.freeze([
1169
1249
  { name: '/help', description: 'list all commands', type: 'help' },
1170
- { name: '/model', description: 'show the current lane and how to switch', type: 'model' },
1250
+ { name: '/model', description: 'pick a model from the list', type: 'model' },
1171
1251
  { name: '/status', description: 'show runtime health', type: 'status' },
1172
1252
  { name: '/fast', description: 'switch to quick answers with low latency', type: 'tier', mode: 'fast' },
1173
1253
  { name: '/pro', description: 'switch to a deeper tool loop', type: 'tier', mode: 'pro' },
@@ -1299,10 +1379,14 @@ function runChatSlash(line, options = {}) {
1299
1379
  };
1300
1380
  }
1301
1381
  if (command.type === 'model') {
1302
- return {
1303
- type: 'model',
1304
- output: `· current lane: ${tierLabel(mode)} · effort: ${formatReasoningEffortLabel(options.reasoningEffort)} · switch with /fast /pro /max /rapid /alpha · effort with /effort`,
1305
- };
1382
+ if (parsed.arg) {
1383
+ const next = modeForModelName(parsed.arg);
1384
+ if (!next) {
1385
+ return { type: 'model', output: `no single model matches "${parsed.arg}"\n${modelChoiceList(mode, options)}` };
1386
+ }
1387
+ return { type: 'tier', mode: next, output: `· ${tierLabel(next)}` };
1388
+ }
1389
+ return { type: 'model', output: modelChoiceList(mode, options) };
1306
1390
  }
1307
1391
  if (command.type === 'effort') {
1308
1392
  const current = formatReasoningEffortLabel(options.reasoningEffort);
@@ -1384,13 +1468,21 @@ function attachSlashMenu(rl, output, options = {}) {
1384
1468
  const original = rl._ttyWrite.bind(rl);
1385
1469
  let visibleRows = 0;
1386
1470
 
1471
+ // Move back by row count, not a saved screen position: drawing the menu at
1472
+ // the bottom of a short window scrolls the screen, and a saved position then
1473
+ // points into the menu, so typed text landed on a menu row (seen 2026-10-14).
1474
+ const backToInput = (rowsBelow) => {
1475
+ if (rowsBelow) output.write(`\x1b[${rowsBelow}A`);
1476
+ const cols = typeof rl.getCursorPos === 'function' ? rl.getCursorPos().cols : 0;
1477
+ output.write(`\r${cols ? `\x1b[${cols}C` : ''}`);
1478
+ };
1479
+
1387
1480
  const clear = () => {
1388
1481
  if (!visibleRows) return;
1389
- output.write('\x1b7');
1390
1482
  for (let index = 0; index < visibleRows; index += 1) {
1391
1483
  output.write('\x1b[1B\r\x1b[2K');
1392
1484
  }
1393
- output.write('\x1b8');
1485
+ backToInput(visibleRows);
1394
1486
  visibleRows = 0;
1395
1487
  };
1396
1488
 
@@ -1399,10 +1491,10 @@ function attachSlashMenu(rl, output, options = {}) {
1399
1491
  if (typeof options.isActive === 'function' && !options.isActive()) return;
1400
1492
  const matches = filterChatCommands(rl.line);
1401
1493
  if (!matches.length) return;
1402
- const rows = chatMenu(output, matches).split('\n');
1403
- output.write('\x1b7');
1494
+ const room = Math.max(3, (Number(output.rows) || 24) - 2);
1495
+ const rows = chatMenu(output, matches.slice(0, room)).split('\n');
1404
1496
  for (const row of rows) output.write(`\r\n${row}`);
1405
- output.write('\x1b8');
1497
+ backToInput(rows.length);
1406
1498
  visibleRows = rows.length;
1407
1499
  };
1408
1500
 
@@ -2963,28 +3055,19 @@ function latestPendingApprovalRequest(history = []) {
2963
3055
  return null;
2964
3056
  }
2965
3057
 
3058
+ // A yes made only of these words runs the staged action: "approved do it",
3059
+ // "yes go ahead please". Any other word ("no", "wait", "but", "change") falls
3060
+ // through to the model, so a hedged or edited reply never fires the action.
3061
+ const APPROVAL_YES_WORDS = new Set(['yes', 'y', 'yep', 'yeah', 'yup', 'sure', 'ok', 'okay', 'approve', 'approved', 'confirm', 'confirmed']);
3062
+ const APPROVAL_FILLER_WORDS = new Set(['do', 'it', 'go', 'ahead', 'please', 'pls', 'create', 'send', 'post', 'run', 'now', 'that', 'looks', 'good']);
3063
+
2966
3064
  function messageApprovesPendingApproval(message) {
2967
- const normalized = String(message || '').trim().toLowerCase().replace(/[.!?]+$/g, '');
2968
- return new Set([
2969
- 'yes',
2970
- 'y',
2971
- 'yep',
2972
- 'yeah',
2973
- 'ok',
2974
- 'okay',
2975
- 'approve',
2976
- 'approved',
2977
- 'confirm',
2978
- 'create it',
2979
- 'send it',
2980
- 'post it',
2981
- 'do it',
2982
- 'go ahead',
2983
- 'looks good',
2984
- 'yes create it',
2985
- 'yes send it',
2986
- 'yes post it',
2987
- ]).has(normalized);
3065
+ const normalized = String(message || '').trim().toLowerCase().replace(/[.!?,]+/g, ' ').replace(/\s+/g, ' ').trim();
3066
+ if (['do it', 'go ahead', 'looks good', 'create it', 'send it', 'post it'].includes(normalized)) return true;
3067
+ const words = normalized.split(' ').filter(Boolean);
3068
+ if (!words.length || words.length > 6) return false;
3069
+ if (!words.some(word => APPROVAL_YES_WORDS.has(word))) return false;
3070
+ return words.every(word => APPROVAL_YES_WORDS.has(word) || APPROVAL_FILLER_WORDS.has(word));
2988
3071
  }
2989
3072
 
2990
3073
  function messageCancelsPendingApproval(message) {
@@ -3027,6 +3110,7 @@ function buildPayload(message, options = {}) {
3027
3110
  verify_command: verifyCommand || 'true',
3028
3111
  local_tools: [backendApiToolDescriptor()]
3029
3112
  };
3113
+ if (mode === 'mercury' && options.diffuse) payload.diffuse = true;
3030
3114
  if (previousMessages.length) {
3031
3115
  payload.previous_messages = previousMessages;
3032
3116
  }
@@ -3059,6 +3143,13 @@ function buildPayload(message, options = {}) {
3059
3143
  if (!local && options.connectionUserId) {
3060
3144
  payload.connection_user_id = options.connectionUserId;
3061
3145
  }
3146
+ if (payload.diffuse) {
3147
+ // The refining view is plain chat: the backend skips tools while diffusing,
3148
+ // and a workspace or tool list would route the turn away from it.
3149
+ delete payload.local_tools;
3150
+ delete payload.workspace_path;
3151
+ payload.max_turns = 1;
3152
+ }
3062
3153
  return payload;
3063
3154
  }
3064
3155
 
@@ -3092,6 +3183,42 @@ function codeFastWorkspaceNotice(result) {
3092
3183
  return null;
3093
3184
  }
3094
3185
 
3186
+ // Drafts own a temporary area ending at the start of an empty row.
3187
+ // Clip to the visible terminal so wrapping and scrolling cannot leave debris.
3188
+ function clearTextSnapshot(state, output) {
3189
+ if (!state.snapshotRows || !output || !output.isTTY) return;
3190
+ output.write(`\r\x1b[${state.snapshotRows}A\x1b[J`);
3191
+ state.snapshotRows = 0;
3192
+ }
3193
+
3194
+ function renderTextSnapshot(state, output, content) {
3195
+ stopProgress(state);
3196
+ clearTextSnapshot(state, output);
3197
+ const width = Math.max(1, (output.columns || 80) - 1);
3198
+ const height = Math.max(1, (output.rows || 24) - 1);
3199
+ const clipped = [];
3200
+ for (const line of String(content).replace(/[\x00-\x09\x0b-\x1f\x7f]/g, '').split('\n')) {
3201
+ // Wrap each line to the terminal width so the whole draft stays visible.
3202
+ let used = 0;
3203
+ let text = '';
3204
+ for (const char of line) {
3205
+ // Non-ASCII cells are conservatively two columns, including emoji.
3206
+ const cells = /\p{Mark}/u.test(char) ? 0 : char.codePointAt(0) > 0xff ? 2 : 1;
3207
+ if (used + cells > width) {
3208
+ clipped.push(text);
3209
+ text = '';
3210
+ used = 0;
3211
+ }
3212
+ text += char;
3213
+ used += cells;
3214
+ }
3215
+ clipped.push(text);
3216
+ }
3217
+ clipped.splice(height);
3218
+ output.write(`${paint(clipped.join('\n'), [ANSI.dim], output)}\n`);
3219
+ state.snapshotRows = clipped.length;
3220
+ }
3221
+
3095
3222
  function handleEvent(event, state, output) {
3096
3223
  if (!event || typeof event !== 'object') return;
3097
3224
  state.events.push(event);
@@ -3114,6 +3241,19 @@ function handleEvent(event, state, output) {
3114
3241
  return;
3115
3242
  }
3116
3243
 
3244
+ if ((event.type === 'text_snapshot' || event.type === 'text_delta' || event.type === 'text')
3245
+ && event.content && state.firstTextMs == null) {
3246
+ state.firstTextMs = Date.now() - state.startedAt;
3247
+ }
3248
+ if (event.type === 'text_snapshot') {
3249
+ if (state.diffuse) {
3250
+ state.snapshotCount += 1;
3251
+ if (output && output.isTTY) renderTextSnapshot(state, output, event.content || '');
3252
+ }
3253
+ return;
3254
+ }
3255
+
3256
+ clearTextSnapshot(state, output);
3117
3257
  if ((event.type === 'text_delta' || event.type === 'text') && event.content) {
3118
3258
  state.output += event.content;
3119
3259
  if (output && output.isTTY) writeStreamingText(state, output, event.content);
@@ -3184,6 +3324,13 @@ function handleEvent(event, state, output) {
3184
3324
  }
3185
3325
 
3186
3326
  if (event.type === 'receipt' && event.receipt) {
3327
+ // A picked model the backend could not reach is swapped silently; say so.
3328
+ const answeredBy = String(event.receipt.model || '');
3329
+ const asked = String(state.requestedModel || '');
3330
+ if (answeredBy && asked && !asked.startsWith('atris:') && !asked.endsWith(answeredBy) && !answeredBy.endsWith(asked.split(':').pop())) {
3331
+ flushPendingText(state, output);
3332
+ writeAuxLine(state, output, paint(`· ${asked} did not answer; fell back to ${answeredBy}`, [ANSI.muted], output));
3333
+ }
3187
3334
  const approvalLine = formatApprovalReceipt(event.receipt);
3188
3335
  const previewRows = formatTaskPreviewRows(event.receipt, output);
3189
3336
  if (approvalLine) {
@@ -3234,6 +3381,7 @@ function isTurnInterruptedError(error) {
3234
3381
  }
3235
3382
 
3236
3383
  async function postTurn(message, options = {}) {
3384
+ const startedAt = Date.now();
3237
3385
  let route = options.business ? 'local' : resolveRoute(message, options);
3238
3386
  if (route === 'local' && !options.business && !pinLocalCheckout(options)
3239
3387
  && !(await localBackendReachable())) {
@@ -3265,6 +3413,12 @@ async function postTurn(message, options = {}) {
3265
3413
  wroteText: false,
3266
3414
  wroteActivity: false,
3267
3415
  durationMs: 0,
3416
+ startedAt,
3417
+ firstTextMs: null,
3418
+ diffuse: Boolean(payload.diffuse),
3419
+ requestedModel: String(payload.model || ''),
3420
+ snapshotCount: 0,
3421
+ snapshotRows: 0,
3268
3422
  lastChar: '\n',
3269
3423
  progress: null,
3270
3424
  inAuxBlock: false,
@@ -3306,7 +3460,6 @@ async function postTurn(message, options = {}) {
3306
3460
  return new Promise((resolve, reject) => {
3307
3461
  let settled = false;
3308
3462
  let abortListener = null;
3309
- const startedAt = Date.now();
3310
3463
  state.progress = createProgressReporter(output, options);
3311
3464
  state.progress.start();
3312
3465
 
@@ -3316,7 +3469,16 @@ async function postTurn(message, options = {}) {
3316
3469
  if (abortListener && options.signal) options.signal.removeEventListener('abort', abortListener);
3317
3470
  if (state.progress) state.progress.stop();
3318
3471
  state.progress = null;
3472
+ clearTextSnapshot(state, output);
3319
3473
  state.durationMs = Date.now() - startedAt;
3474
+ const timedMode = normalizeMode(options.mode);
3475
+ if (timedMode === 'mercury' || timedMode.startsWith('model:')) {
3476
+ const first = state.firstTextMs == null ? 'none' : `${(state.firstTextMs / 1000).toFixed(2)}s`;
3477
+ const steps = state.diffuse ? `, ${state.snapshotCount} refining steps` : '';
3478
+ const stderr = options.stderr || process.stderr;
3479
+ if (output && output.isTTY && state.lastChar !== '\n') output.write('\n');
3480
+ stderr.write(`${paint(`${timedMode === 'mercury' ? 'mercury 3' : tierLabel(timedMode)}: first text ${first}, done ${(state.durationMs / 1000).toFixed(2)}s${steps}`, [ANSI.dim], stderr)}\n`);
3481
+ }
3320
3482
  if (error) reject(error);
3321
3483
  else resolve(value);
3322
3484
  };
@@ -3564,6 +3726,8 @@ async function runHeadlessTurn(message, options = {}) {
3564
3726
  business: options.business,
3565
3727
  verify: options.verify,
3566
3728
  reasoningEffort: options.reasoningEffort,
3729
+ diffuse: options.diffuse,
3730
+ stderr: options.stderr,
3567
3731
  output: sink,
3568
3732
  showProgress: false,
3569
3733
  });
@@ -3684,7 +3848,7 @@ async function chat(options = {}) {
3684
3848
  }
3685
3849
  if (logger) logger.write(`${trimmed}\n`);
3686
3850
  const color = slash.mode ? [tierColor(slash.mode)] : [ANSI.muted];
3687
- const preservesOwnFormatting = slash.type === 'help' || slash.type === 'status';
3851
+ const preservesOwnFormatting = slash.type === 'help' || slash.type === 'status' || slash.type === 'model';
3688
3852
  output.write(`${preservesOwnFormatting ? slash.output : paint(slash.output, color, output)}\n\n`);
3689
3853
  return false;
3690
3854
  }
@@ -3814,6 +3978,7 @@ async function chat(options = {}) {
3814
3978
  business: options.business,
3815
3979
  verify: options.verify,
3816
3980
  reasoningEffort: session.reasoningEffort,
3981
+ diffuse: options.diffuse,
3817
3982
  conversationId,
3818
3983
  turnId,
3819
3984
  approveBackendApi: options.approveBackendApi,
@@ -4733,8 +4898,22 @@ async function main() {
4733
4898
  args.splice(verifyIdx, 2);
4734
4899
  }
4735
4900
 
4736
- const explicitLane = args.includes('--code-fast') || args.includes('--code')
4901
+ // --model <name>: any backend model by name, or a lane alias like mercury.
4902
+ let modelLane = null;
4903
+ const modelIdx = args.indexOf('--model');
4904
+ if (modelIdx !== -1) {
4905
+ modelLane = modeForModelName(args[modelIdx + 1]);
4906
+ if (!modelLane) {
4907
+ console.error('Usage: ax --model <name> [prompt] e.g. --model mercury or --model fireworks:glm-5.2');
4908
+ process.exit(1);
4909
+ }
4910
+ args.splice(modelIdx, 2);
4911
+ }
4912
+
4913
+ const explicitLane = modelLane || (args.includes('--code-fast') || args.includes('--code')
4737
4914
  ? 'code-fast'
4915
+ : args.includes('--mercury')
4916
+ ? 'mercury'
4738
4917
  : args.includes('--max')
4739
4918
  ? 'max'
4740
4919
  : args.includes('--rapid')
@@ -4745,7 +4924,8 @@ async function main() {
4745
4924
  ? 'fast'
4746
4925
  : args.includes('--pro')
4747
4926
  ? 'pro'
4748
- : null;
4927
+ : null);
4928
+ const diffuse = args.includes('--diffuse');
4749
4929
  const autoEnabled = args.includes('--auto') && !explicitLane;
4750
4930
  let mode = explicitLane || 'pro';
4751
4931
  const doctor = args.includes('--doctor');
@@ -4828,7 +5008,7 @@ async function main() {
4828
5008
 
4829
5009
  const route = forceCloud ? 'cloud' : forceLocal ? 'local' : 'auto';
4830
5010
  const prompt = args
4831
- .filter(arg => !['--auto', '--auto-approve', '--max', '--fast', '--rapid', '--alpha', '--pro', '--code-fast', '--code', '--chat', '--doctor', '--approvals', '--self-test', '--benchmark', '--print', '--headless', '--local', '--cloud', '--help', '-h', '--effort'].includes(arg))
5011
+ .filter(arg => !['--auto', '--auto-approve', '--max', '--fast', '--rapid', '--alpha', '--mercury', '--diffuse', '--pro', '--code-fast', '--code', '--chat', '--doctor', '--approvals', '--self-test', '--benchmark', '--print', '--headless', '--local', '--cloud', '--help', '-h', '--effort'].includes(arg))
4832
5012
  .join(' ')
4833
5013
  .trim();
4834
5014
 
@@ -4990,6 +5170,7 @@ async function main() {
4990
5170
  verify,
4991
5171
  autoPick,
4992
5172
  reasoningEffort,
5173
+ diffuse,
4993
5174
  })
4994
5175
  : { ok: false, model: modelForMode(mode), output: '', durationMs: 0, error: 'missing prompt' };
4995
5176
  // Drain stdout before exiting: process.exit right after a large write
@@ -5013,6 +5194,7 @@ async function main() {
5013
5194
  auto: autoEnabled,
5014
5195
  autoApprove,
5015
5196
  reasoningEffort,
5197
+ diffuse,
5016
5198
  });
5017
5199
  return;
5018
5200
  }
@@ -5032,6 +5214,7 @@ async function main() {
5032
5214
  business,
5033
5215
  verify,
5034
5216
  reasoningEffort,
5217
+ diffuse,
5035
5218
  });
5036
5219
  appendAutoOutcome(autoPick, {
5037
5220
  ok: true,
@@ -5156,6 +5339,7 @@ module.exports = {
5156
5339
  messageApprovesPendingApproval,
5157
5340
  messageCancelsPendingApproval,
5158
5341
  modelForMode,
5342
+ normalizeMode,
5159
5343
  normalizeReasoningEffort,
5160
5344
  parseSseBlock,
5161
5345
  persistPendingApproval,
package/bin/atris.js CHANGED
@@ -34,6 +34,13 @@ function exitWhenFlushed(code) {
34
34
  setTimeout(() => process.exit(code), 3000).unref();
35
35
  }
36
36
 
37
+ // Exit right after stdout drains: a write past the 64KB pipe buffer is async,
38
+ // and an empty write's callback fires only once every earlier write is out.
39
+ function exitAfterStdoutDrain(code) {
40
+ if (process.stdout.writableLength === 0) process.exit(code);
41
+ else process.stdout.write('', () => process.exit(code));
42
+ }
43
+
37
44
  let CLI_VERSION = 'unknown';
38
45
  try {
39
46
  const pkgRaw = fs.readFileSync(PACKAGE_JSON_PATH, 'utf8');
@@ -513,6 +520,11 @@ function showStartHelp() {
513
520
  function showHelpShort() {
514
521
  console.log('atris: you say what you want. already won. one next step.');
515
522
  console.log('');
523
+ console.log('Start here:');
524
+ console.log(' 1. atris init set up this folder');
525
+ console.log(' 2. atris doctor check this computer is ready');
526
+ console.log(' 3. atris "what you want" say it, the work starts');
527
+ console.log('');
516
528
  console.log(' atris "what do you want here?" empty folder');
517
529
  console.log(' atris later "..." remember without a room');
518
530
  console.log(' atris guide "..." translate your words');
@@ -534,6 +546,7 @@ function helpCatalog() {
534
546
  { name: 'review', summary: 'validate work and capture learnings', json: false, interactive_risk: 'low' },
535
547
  { name: 'recap', summary: 'spoken recap of what your team did', json: true, interactive_risk: 'low' },
536
548
  { name: 'mission', summary: 'durable goal + owner + verifier + tick', json: true, interactive_risk: 'medium' },
549
+ { name: 'campaign', summary: 'long goal + judge + budget + seats, made of missions', json: true, interactive_risk: 'low' },
537
550
  { name: 'log', summary: 'append an idea to today inbox (or --repl)', json: true, interactive_risk: 'low' },
538
551
  { name: 'wish', summary: 'headless inbox / mission intake', json: true, interactive_risk: 'low' },
539
552
  { name: 'learn', summary: 'structured project learnings', json: false, interactive_risk: 'low' },
@@ -624,6 +637,7 @@ function showHelpAll() {
624
637
  console.log(' atris autoland tick # second check runs, task lands');
625
638
  console.log(' mission - Goal + loop + member owner + verifier + receipt; --budget quick|long|deep sets bounded tiers');
626
639
  console.log(' decide - Answer open mission asks and send the decision to the next tick');
640
+ console.log(' campaign - Long-running goals with a judge, a budget, and members in seats');
627
641
  console.log(' release - Tag release, bump version, create GitHub release, draft /launch; atris release preflight before a v* tag');
628
642
  console.log(' learn - Project learnings (patterns, pitfalls, preferences)');
629
643
  console.log(' study - On-demand learning feed: ingest topic, start server, open browser');
@@ -1156,7 +1170,8 @@ function showAutopilotHelp() {
1156
1170
 
1157
1171
  if (command === 'help' || command === '--help' || command === '-h') {
1158
1172
  showHelp({ args: process.argv.slice(3) });
1159
- process.exit(0);
1173
+ exitAfterStdoutDrain(0);
1174
+ return;
1160
1175
  }
1161
1176
 
1162
1177
  // Core command handlers, loaded eagerly (used by interactiveEntry default path)
@@ -1893,12 +1908,13 @@ function showWelcomeVisualization() {
1893
1908
  }
1894
1909
 
1895
1910
  // The guarantee gauge, only when it has bad news: landings a human had to
1896
- // fix this week. Zero is the healthy state and stays silent; no git history
1911
+ // fix this week. Coaching (role, goal, context edits) is not bad news. Zero is the healthy state and stays silent; no git history
1897
1912
  // (not a repo, git missing) also stays silent.
1898
1913
  try {
1899
1914
  const rev = require('../commands/improve').collectRevisionSignals(cwd, { days: 7 });
1900
- if (rev.revised > 0) {
1901
- console.log(` ${rev.revised} landing${rev.revised === 1 ? '' : 's'} this week needed a human fix; run atris improve revisions`);
1915
+ const fixed = Number.isFinite(rev.fixed) ? rev.fixed : rev.revised;
1916
+ if (fixed > 0) {
1917
+ console.log(` ${fixed} landing${fixed === 1 ? '' : 's'} this week needed a human fix; run atris improve revisions`);
1902
1918
  }
1903
1919
  } catch {
1904
1920
  // Silent: the gauge never blocks boot.
@@ -2000,6 +2016,10 @@ if (command === 'guide') {
2000
2016
  Promise.resolve(require('../commands/team').teamCommand(process.argv.slice(3)))
2001
2017
  .then((code) => process.exit(code || 0))
2002
2018
  .catch((err) => { console.error(`\nerror: ${err.message || err}`); process.exit(1); });
2019
+ } else if (command === 'campaign') {
2020
+ Promise.resolve(require('../commands/campaign').campaignCommand(process.argv.slice(3)))
2021
+ .then((code) => process.exit(code || 0))
2022
+ .catch((err) => { console.error(`\nerror: ${err.message || err}`); process.exit(1); });
2003
2023
  } else if (command === 'who') {
2004
2024
  Promise.resolve(require('../commands/who').whoCommand(process.argv.slice(3)))
2005
2025
  .then((code) => process.exit(code || 0))
@@ -3246,8 +3266,8 @@ if (command === 'guide') {
3246
3266
  // both (mirrors the `compile` command dispatch just above).
3247
3267
  const engineArgs = command === 'engines' && process.argv.length <= 3 ? [] : process.argv.slice(3);
3248
3268
  Promise.resolve(require('../commands/engine').engineCommand(engineArgs))
3249
- .then((code) => process.exit(typeof code === 'number' ? code : 0))
3250
- .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
3269
+ .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
3270
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); exitAfterStdoutDrain(1); });
3251
3271
  } else if (command === 'link') {
3252
3272
  // Link: ask the roster's engine what two things could become together.
3253
3273
  Promise.resolve(require('../commands/link').linkCommand(process.argv.slice(3)))