atris 3.61.0 → 3.63.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 (53) hide show
  1. package/README.md +37 -5
  2. package/atris/features/README.md +8 -0
  3. package/atris/skills/README.md +1 -0
  4. package/atris/skills/commerce/SKILL.md +96 -0
  5. package/atris/skills/engines/SKILL.md +9 -9
  6. package/bin/atris.js +12 -0
  7. package/commands/autoland.js +36 -5
  8. package/commands/autopilot.js +105 -6
  9. package/commands/buy.js +242 -0
  10. package/commands/engine.js +96 -21
  11. package/commands/host.js +56 -4
  12. package/commands/land.js +129 -8
  13. package/commands/member.js +68 -14
  14. package/commands/mission.js +116 -4
  15. package/commands/one-lap.js +14 -10
  16. package/commands/stream.js +56 -22
  17. package/commands/task.js +7 -0
  18. package/commands/team.js +82 -10
  19. package/commands/transactions.js +115 -0
  20. package/commands/worktree.js +39 -0
  21. package/commands/youtube.js +58 -26
  22. package/lib/commerce.js +75 -0
  23. package/lib/engine-ask.js +112 -9
  24. package/lib/engine-registry.js +300 -39
  25. package/lib/fleet.js +444 -26
  26. package/lib/functional-owner.js +15 -6
  27. package/lib/host.js +296 -170
  28. package/lib/known-commands.js +1 -1
  29. package/lib/member-engine.js +25 -1
  30. package/lib/member-park.js +138 -0
  31. package/lib/member-scaffold.js +39 -0
  32. package/lib/member-type.js +27 -0
  33. package/lib/roster-markdown.js +1 -1
  34. package/lib/roster-prep.js +289 -0
  35. package/lib/roster-runs.js +389 -0
  36. package/lib/roster-suggest.js +145 -0
  37. package/lib/router-brain.js +111 -3
  38. package/lib/runner-command.js +37 -2
  39. package/lib/self-drive.js +26 -2
  40. package/lib/state-detection.js +3 -3
  41. package/lib/task-db.js +38 -3
  42. package/lib/team-lineup.js +7 -2
  43. package/lib/todo.js +1 -1
  44. package/lib/wish-audit.js +1 -1
  45. package/package.json +1 -1
  46. package/scripts/det/cpu-hotspots.js +216 -0
  47. package/scripts/det/ytnotes +169 -23
  48. package/scripts/det/ytquote-repair.js +18 -4
  49. package/scripts/det/ytrail-bench.js +214 -0
  50. package/scripts/det/ytrail-eval.js +128 -56
  51. package/templates/members/host/MEMBER.md +5 -3
  52. package/templates/members/host/MISSION.md +1 -1
  53. package/templates/members/host/skills/host/SKILL.md +64 -16
@@ -6,6 +6,7 @@ const { spawnSync } = require('child_process');
6
6
  const { hasFlag, readFlag } = require('../lib/arg-parser');
7
7
  const { stampLatestOpenBriefForWorktree } = require('../lib/brief-ledger');
8
8
  const { isConductorArtifact } = require('../lib/conductor-artifacts');
9
+ const { fixMapDocs } = require('../lib/map-refs');
9
10
  const close = require('./close');
10
11
 
11
12
  const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
@@ -641,6 +642,7 @@ function shipHelp() {
641
642
  console.log('');
642
643
  console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
643
644
  console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
645
+ console.log(' --no-map-refresh skip the automatic refresh of moved map line references');
644
646
  console.log(' unstaged regenerated adapter files are skipped unless staged first or named in --message');
645
647
  console.log(' recommended flight verify: npm run test:fast && node --test <focused files>');
646
648
  }
@@ -725,6 +727,35 @@ function runShipHealthCheck(workspaceRoot, options = {}) {
725
727
  }
726
728
  }
727
729
 
730
+ const MAP_REFRESH_COMMIT_MESSAGE = 'Refresh map references that moved';
731
+ const MAP_REFRESH_TRAILER = 'Co-authored-by: Atris <299057014+atris-builder[bot]@users.noreply.github.com>';
732
+
733
+ // Rewrites map line refs whose code moved (the same fix `atris doc-health
734
+ // --fix-refs` applies), then commits only the rewritten map files. A human-only
735
+ // remainder or an error never blocks the ship; pass --no-map-refresh to skip.
736
+ function refreshMovedMapRefs(root, args = []) {
737
+ if (hasFlag(args, '--no-map-refresh')) return { skipped: true, refreshed: 0, left: 0 };
738
+ try {
739
+ const { changes, left } = fixMapDocs(root);
740
+ if (changes.length) {
741
+ const docs = [...new Set(changes.map(change => change.doc))];
742
+ runGit(['add', '--', ...docs], { cwd: root });
743
+ runGit(['commit', '-m', MAP_REFRESH_COMMIT_MESSAGE, '-m', MAP_REFRESH_TRAILER], { cwd: root });
744
+ console.log(`map: refreshed ${changes.length} moved references`);
745
+ } else {
746
+ console.log('map: references current');
747
+ }
748
+ if (left.length) {
749
+ console.log(`map: ${left.length} references need a human; run atris doc-health --fix-refs to see them`);
750
+ }
751
+ return { skipped: false, refreshed: changes.length, left: left.length };
752
+ } catch (error) {
753
+ const message = String((error && error.message) || error).toLowerCase().replace(/\s+/g, ' ').trim();
754
+ console.log(`map: refresh skipped (${message}); the ship continues`);
755
+ return { skipped: false, refreshed: 0, left: 0, error };
756
+ }
757
+ }
758
+
728
759
  function shipWorktree(args) {
729
760
  if (hasFlag(args, '--help') || hasFlag(args, '-h') || args[0] === 'help') {
730
761
  shipHelp();
@@ -816,6 +847,12 @@ function shipWorktree(args) {
816
847
  if (!dryRun && !proofTreeMatches('head')) return 3;
817
848
  }
818
849
 
850
+ if (dryRun) {
851
+ console.log('map: skipped (dry-run)');
852
+ } else {
853
+ refreshMovedMapRefs(root, args);
854
+ }
855
+
819
856
  if (verify) {
820
857
  const beforeVerify = dryRun ? [] : statusSnapshot(root);
821
858
  console.log(`verify: ${verify}`);
@@ -1227,6 +1264,7 @@ function help() {
1227
1264
  console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local]');
1228
1265
  console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
1229
1266
  console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
1267
+ console.log(' --no-map-refresh skip the automatic refresh of moved map line references');
1230
1268
  console.log(' recommended verify: npm run test:fast && node --test <focused files>');
1231
1269
  console.log(' atris worktree status');
1232
1270
  console.log(' atris worktree guard [--allow-primary] [--allow-dirty]');
@@ -1270,6 +1308,7 @@ module.exports = {
1270
1308
  parseAgentFlightName,
1271
1309
  parseWorktrees,
1272
1310
  normalizeTargetRef,
1311
+ refreshMovedMapRefs,
1273
1312
  slugify,
1274
1313
  taskTokens,
1275
1314
  statusCounts,
@@ -7,7 +7,7 @@ const os = require('os');
7
7
  const path = require('path');
8
8
  const https = require('https');
9
9
 
10
- const YTNOTES_USAGE = 'usage: ytnotes <youtube-url> [youtube-url-or-playlist...] [haiku|atris-fast|gemini|grok|codex|cursor]';
10
+ const YTNOTES_USAGE = 'usage: ytnotes <youtube-url> [youtube-url-or-playlist...] [auto|gemini|haiku|atris-fast|grok|codex|cursor]';
11
11
  const YTNOTES_HINT = 'zero credits, local captions + a fast engine';
12
12
  const NOTES_PLAYLIST_CAP = 10;
13
13
 
@@ -40,6 +40,7 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
40
40
  output(` ${commandName} watch list`);
41
41
  output(` ${commandName} watch remove <number>`);
42
42
  output(` ${commandName} watch tick`);
43
+ output(` ${commandName} bench [--quick] [--case <name>]`);
43
44
  output(` ${commandName} <youtube-url> [options]`);
44
45
  output('');
45
46
  output('search = free local discovery (ytsearch / yt-dlp), returns youtu.be links; rich free search writes one apply and a failing keep/revert pack; thin hands off to teach');
@@ -50,6 +51,7 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
50
51
  output('process = 5 credits cloud knowledge (needs a filled Apply); rich process writes one apply and a failing keep/revert pack');
51
52
  output('digest = one decision page from this week\'s video briefs; rich digest writes one apply and a failing keep/revert pack');
52
53
  output('watch = subscribed channels turn into briefs without a human; add hands off to tick; tick hands off to teach when it briefed');
54
+ output('bench = run notes on real videos, uncached, and grade what a user gets; --quick runs the short video and the stranger checks');
53
55
  output('Process a YouTube video through Atris using timestamped transcript-first analysis.');
54
56
  output('Falls back to cloud video processing when local captions are unavailable.');
55
57
  output('');
@@ -236,23 +238,31 @@ function captionHostAllowed(urlString) {
236
238
  }
237
239
  }
238
240
 
239
- function chooseCaptionTrack(info = {}) {
240
- const preferred = ['en', 'en-orig', 'en-US', 'en-GB'];
241
- const chooseFrom = (trackSets = {}) => {
242
- for (const language of preferred) {
243
- for (const track of trackSets[language] || []) {
244
- if (track?.url && ['json3', 'vtt', 'srv3', 'ttml'].includes(track.ext)) return { language, track };
245
- }
241
+ // Caption tracks in the order to try them. Human subtitles first, then the
242
+ // auto track in the language actually spoken ("en-orig", "es-orig"). YouTube's
243
+ // machine-translated tracks (plain "en" on most English videos) are rate
244
+ // limited with HTTP 429, so they only come after every real track.
245
+ function captionTrackCandidates(info = {}) {
246
+ const candidates = [];
247
+ const add = (trackSets = {}, languages) => {
248
+ for (const language of languages) {
249
+ if (language === 'live_chat') continue;
250
+ const tracks = (trackSets[language] || []).filter((track) => track?.url);
251
+ const track = tracks.find((t) => ['json3', 'vtt', 'srv3', 'ttml'].includes(t.ext)) || tracks[0];
252
+ if (track && !candidates.some((c) => c.track === track)) candidates.push({ language, track });
246
253
  }
247
- for (const [language, tracks] of Object.entries(trackSets)) {
248
- for (const track of tracks || []) {
249
- if (track?.url) return { language, track };
250
- }
251
- }
252
- return null;
253
254
  };
255
+ const manual = info.subtitles || {};
256
+ const auto = info.automatic_captions || {};
257
+ add(manual, ['en', 'en-US', 'en-GB', ...Object.keys(manual).filter((l) => l.startsWith('en-'))]);
258
+ add(manual, Object.keys(manual));
259
+ add(auto, ['en-orig', ...Object.keys(auto).filter((l) => l.endsWith('-orig'))]);
260
+ add(auto, ['en', 'en-US', 'en-GB']);
261
+ return candidates;
262
+ }
254
263
 
255
- return chooseFrom(info.subtitles) || chooseFrom(info.automatic_captions);
264
+ function chooseCaptionTrack(info = {}) {
265
+ return captionTrackCandidates(info)[0] || null;
256
266
  }
257
267
 
258
268
  function formatTimestampFromMs(ms) {
@@ -478,12 +488,14 @@ function parseYtDlpInfoJson(result) {
478
488
  }
479
489
 
480
490
  function localCaptionNames(id) {
481
- // scripts/det/ytnotes keeps these VTT names plus leftover yt_<id>.clean.txt.
491
+ // scripts/det/ytnotes keeps these VTT names (.local.vtt = transcribed on this
492
+ // machine when the video had no captions) plus leftover yt_<id>.clean.txt.
482
493
  return [
483
494
  `yt_${id}.en.vtt`,
484
495
  `yt_${id}.en-orig.vtt`,
485
496
  `yt_${id}.en-US.vtt`,
486
497
  `yt_${id}.en-GB.vtt`,
498
+ `yt_${id}.local.vtt`,
487
499
  `yt_${id}.clean.txt`,
488
500
  ];
489
501
  }
@@ -521,14 +533,14 @@ function readLocalCaptionText({ url, id, workDir } = {}) {
521
533
 
522
534
  async function loadCaptionRaw(info, youtubeUrl, deps = {}) {
523
535
  const payload = info && typeof info === 'object' ? info : {};
524
- const selected = chooseCaptionTrack(payload);
525
- let raw = '';
526
- if (selected?.track?.url) {
527
- raw = await (deps.fetchCaptionText || fetchCaptionText)(selected.track.url);
528
- }
529
- if (String(raw || '').trim()) {
530
- return { raw, language: selected?.language || 'unknown' };
531
- }
536
+ const fetchText = deps.fetchCaptionText || fetchCaptionText;
537
+ // A blocked track (429) falls through to the next one instead of failing.
538
+ const candidates = captionTrackCandidates(payload).slice(0, 4);
539
+ for (const candidate of candidates) {
540
+ const raw = await fetchText(candidate.track.url);
541
+ if (String(raw || '').trim()) return { raw, language: candidate.language };
542
+ }
543
+ const selected = candidates[0];
532
544
  const local = readLocalCaptionText({
533
545
  url: youtubeUrl,
534
546
  id: payload.id,
@@ -559,7 +571,7 @@ async function extractLocalTranscript(youtubeUrl, deps = {}) {
559
571
  const result = runner('yt-dlp', ytDlpInfoArgs(youtubeUrl), {
560
572
  encoding: 'utf8',
561
573
  timeout: 20000,
562
- maxBuffer: 10 * 1024 * 1024,
574
+ maxBuffer: 64 * 1024 * 1024,
563
575
  });
564
576
  const info = parseYtDlpInfoJson(result);
565
577
  const loaded = await loadCaptionRaw(info, youtubeUrl, deps);
@@ -3697,7 +3709,7 @@ async function extractTeachSource(youtubeUrl, deps = {}) {
3697
3709
  const result = runner('yt-dlp', ytDlpInfoArgs(youtubeUrl), {
3698
3710
  encoding: 'utf8',
3699
3711
  timeout: 20000,
3700
- maxBuffer: 10 * 1024 * 1024,
3712
+ maxBuffer: 64 * 1024 * 1024,
3701
3713
  });
3702
3714
  const info = parseYtDlpInfoJson(result);
3703
3715
  const loaded = await loadCaptionRaw(info, youtubeUrl, deps);
@@ -3841,6 +3853,20 @@ async function runYoutubeTeach(args = [], deps = {}) {
3841
3853
  return applyCode;
3842
3854
  }
3843
3855
 
3856
+ // Bench the notes feature end to end. The script prints its own lines.
3857
+ function runYoutubeBench(argv, deps = {}) {
3858
+ const run = (deps.spawnSync || spawnSync)(
3859
+ process.execPath,
3860
+ [path.join(__dirname, '..', 'scripts', 'det', 'ytrail-bench.js'), ...argv],
3861
+ { stdio: 'inherit' },
3862
+ );
3863
+ if (run.error) {
3864
+ (deps.output || console.error)(`bench did not start: ${run.error.message}`);
3865
+ return 1;
3866
+ }
3867
+ return typeof run.status === 'number' ? run.status : 1;
3868
+ }
3869
+
3844
3870
  async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
3845
3871
  const output = deps.output || ((line = '') => console.log(line));
3846
3872
  if (argv[0] === 'search') {
@@ -3870,6 +3896,11 @@ async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
3870
3896
  if (!deps.output && !deps.runner) process.exit(code);
3871
3897
  return code;
3872
3898
  }
3899
+ if (argv[0] === 'bench') {
3900
+ const code = runYoutubeBench(argv.slice(1), deps);
3901
+ if (!deps.output && !deps.spawnSync) process.exit(code);
3902
+ return code;
3903
+ }
3873
3904
  if (argv[0] === 'watch') {
3874
3905
  const code = await watchCommand(argv.slice(1), { ...deps, output });
3875
3906
  if (!deps.output && !deps.fetcher && !deps.runner && !deps.briefFiler) process.exit(code);
@@ -3968,5 +3999,6 @@ module.exports = {
3968
3999
  searchApplyRel,
3969
4000
  firstRichWatchLesson,
3970
4001
  fileTeachExperiment,
4002
+ runYoutubeBench,
3971
4003
  youtubeCommand,
3972
4004
  };
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ // Shared helpers for `atris buy` and `atris transactions`.
4
+ // Backend: /api/commerce/* and /api/transactions (commerce_router.py).
5
+
6
+ const { ROLLOUT_MESSAGE, oneLine } = require('./developer-api');
7
+
8
+ // Flags whose values the backend reads as numbers.
9
+ const NUMERIC_KEYS = new Set(['quantity', 'dollars', 'limit_price', 'min_each_usd', 'max_each_usd', 'limit']);
10
+
11
+ function snake(key) {
12
+ return String(key).replace(/-/g, '_');
13
+ }
14
+
15
+ function coerce(key, value) {
16
+ if (value === true) return true;
17
+ const text = String(value);
18
+ if (/^[[{]/.test(text.trim())) {
19
+ try { return JSON.parse(text); } catch { /* keep as text */ }
20
+ }
21
+ if (NUMERIC_KEYS.has(key) && text.trim() !== '' && Number.isFinite(Number(text))) return Number(text);
22
+ return text;
23
+ }
24
+
25
+ // --event-url x --quantity 2 --accept-extras -> { event_url: 'x', quantity: 2, accept_extras: true }
26
+ function parseFlags(argv = []) {
27
+ const flags = {};
28
+ const pos = [];
29
+ for (let i = 0; i < argv.length; i += 1) {
30
+ const arg = String(argv[i]);
31
+ if (arg === '-h') { flags.help = true; continue; }
32
+ if (arg.startsWith('--')) {
33
+ const eq = arg.indexOf('=');
34
+ const rawKey = eq > 0 ? arg.slice(2, eq) : arg.slice(2);
35
+ const key = snake(rawKey);
36
+ if (eq > 0) { flags[key] = coerce(key, arg.slice(eq + 1)); continue; }
37
+ const next = argv[i + 1];
38
+ if (next != null && !String(next).startsWith('--')) { flags[key] = coerce(key, next); i += 1; } else flags[key] = true;
39
+ continue;
40
+ }
41
+ pos.push(arg);
42
+ }
43
+ return { flags, pos };
44
+ }
45
+
46
+ function formatCents(cents, currency = 'usd') {
47
+ if (cents == null || cents === '' || !Number.isFinite(Number(cents))) return '-';
48
+ const n = Number(cents) / 100;
49
+ const sign = n < 0 ? '-' : '';
50
+ const body = `$${Math.abs(n).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`;
51
+ const code = String(currency || 'usd').toLowerCase();
52
+ return code === 'usd' ? `${sign}${body}` : `${sign}${body} ${code.toUpperCase()}`;
53
+ }
54
+
55
+ // "2026-09-29T18:04:00Z" -> "in 30 min" / "expired"
56
+ function untilText(iso, now = Date.now()) {
57
+ const at = Date.parse(iso);
58
+ if (!Number.isFinite(at)) return '';
59
+ const mins = Math.round((at - now) / 60000);
60
+ if (mins <= 0) return 'expired';
61
+ if (mins < 90) return `in ${mins} min`;
62
+ return `in ${Math.round(mins / 60)} h`;
63
+ }
64
+
65
+ // The backend's error envelope is {"error": {"message", "type"}, "atris": {...}}.
66
+ // A bare 404 with no envelope means the route itself is not deployed yet.
67
+ function errorFrom(result) {
68
+ const data = result && result.data;
69
+ const envelope = data && typeof data === 'object' && data.error && typeof data.error === 'object';
70
+ if (result && Number(result.status) === 404 && !envelope) return ROLLOUT_MESSAGE;
71
+ if (envelope) return oneLine(data.error.message || data.error);
72
+ return oneLine(result && result.error);
73
+ }
74
+
75
+ module.exports = { parseFlags, formatCents, untilText, errorFrom };
package/lib/engine-ask.js CHANGED
@@ -64,7 +64,43 @@ function parseBoundedInteger(raw, flag, min, max) {
64
64
  return value;
65
65
  }
66
66
 
67
- function normalizeAskJob(job, index) {
67
+ // The ask job's roster line for this engine. Questions are not searches, so
68
+ // a bare ask rides the engine's own default model; only an `## ask` section
69
+ // that lists this engine as a live worker pins its model and effort. Layers
70
+ // resolve the way every job resolves: this session, this project, all
71
+ // projects. Never throws.
72
+ function askRosterPin(engine, root) {
73
+ try {
74
+ const {
75
+ ENGINE_JOBS,
76
+ ROSTER_LAYERS,
77
+ readEngineRegistry,
78
+ readRosterState,
79
+ rosterWorkerWalk,
80
+ } = require('./engine-registry');
81
+ const registry = readEngineRegistry(root, { persist: false });
82
+ const state = readRosterState(root, { registry });
83
+ for (const layer of ROSTER_LAYERS) {
84
+ const pick = state[layer] && state[layer].picks && state[layer].picks.ask;
85
+ if (!pick || typeof pick !== 'object') continue;
86
+ const role = ENGINE_JOBS[pick.like] || 'navigator';
87
+ const step = rosterWorkerWalk(pick, role, registry).find(
88
+ (entry) => !entry.skip && entry.engine && entry.engine.id === engine
89
+ );
90
+ if (!step) continue;
91
+ const pin = {};
92
+ for (const field of ['roster_model', 'roster_effort']) {
93
+ if (step.engine[field]) pin[field] = step.engine[field];
94
+ }
95
+ return pin;
96
+ }
97
+ return {};
98
+ } catch {
99
+ return {};
100
+ }
101
+ }
102
+
103
+ function normalizeAskJob(job, index, pinFor = null) {
68
104
  if (!job || typeof job !== 'object' || Array.isArray(job)) {
69
105
  throw new Error(`job ${index + 1} must be an object`);
70
106
  }
@@ -78,10 +114,15 @@ function normalizeAskJob(job, index) {
78
114
  if (Buffer.byteLength(prompt) > MAX_ASK_PROMPT_BYTES) {
79
115
  throw new Error(`job ${index + 1} prompt must be ${MAX_ASK_PROMPT_BYTES} bytes or fewer`);
80
116
  }
81
- const model = resolveAskModel(engine, job.model);
117
+ // No model named: the ask line's model and effort ride along when the
118
+ // roster has one for this engine. A named model means the roster does not
119
+ // decide.
120
+ const pin = !String(job.model || '').trim() && pinFor ? pinFor(engine) : {};
121
+ const model = resolveAskModel(engine, job.model || pin.roster_model || '');
122
+ const effort = askEffortArgs(engine, pin.roster_effort).length ? String(pin.roster_effort).trim().toLowerCase() : '';
82
123
  const label = String(job.label || '').trim();
83
124
  if (label.length > 80) throw new Error(`job ${index + 1} label must be 80 characters or fewer`);
84
- return { engine, model, prompt, label };
125
+ return { engine, model, ...(effort ? { effort } : {}), prompt, label };
85
126
  }
86
127
 
87
128
  function labelAskJobs(jobs) {
@@ -166,14 +207,14 @@ function parseEngineAskArgs(args, { root = process.cwd(), readFile = fs.readFile
166
207
  throw new Error(`could not read --jobs ${jobsFile}: ${error.message}`);
167
208
  }
168
209
  if (!Array.isArray(parsed)) throw new Error('--jobs must contain a json array');
169
- jobs = parsed.map(normalizeAskJob);
210
+ jobs = parsed.map((job, index) => normalizeAskJob(job, index, (engine) => askRosterPin(engine, root)));
170
211
  } else {
171
212
  if (!commonPrompt || !requestedEngines.length) throw new Error(askUsage());
172
213
  jobs = requestedEngines.map((requestedEngine, index) => normalizeAskJob({
173
214
  engine: requestedEngine,
174
215
  model: requestedModel,
175
216
  prompt: commonPrompt,
176
- }, index));
217
+ }, index, (engine) => askRosterPin(engine, root)));
177
218
  }
178
219
 
179
220
  if (!jobs.length) throw new Error('at least one engine ask job is required');
@@ -205,8 +246,21 @@ function resolveAskModel(engine, modelName = '') {
205
246
  return RUNNER_PROFILE_DEFS[engine]?.model || DEFAULT_CLAUDE_RUNNER_MODEL;
206
247
  }
207
248
 
249
+ // The effort flag as argv, from the same profile table the runners use
250
+ // ("--effort high", "-c model_reasoning_effort=high"). Empty when the engine
251
+ // takes no effort or not that word.
252
+ function askEffortArgs(engine, effort) {
253
+ const word = String(effort || '').trim().toLowerCase();
254
+ const profile = RUNNER_PROFILE_DEFS[engine];
255
+ if (!word || !profile || !profile.effortFlag || !(profile.efforts || []).includes(word)) return [];
256
+ const parts = profile.effortFlag.split(/\s+/).filter(Boolean);
257
+ const last = parts.pop();
258
+ return last.endsWith('=') ? [...parts, `${last}${word}`] : [...parts, last, word];
259
+ }
260
+
208
261
  function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
209
262
  timeoutMs = DEFAULT_ASK_TIMEOUT_MS,
263
+ effort = '',
210
264
  } = {}) {
211
265
  const engine = canonicalEngineName(engineName);
212
266
  const profile = RUNNER_PROFILE_DEFS[engine];
@@ -214,6 +268,7 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
214
268
  const model = resolveAskModel(engine, modelName);
215
269
  assertAskModelSupported(engine, model);
216
270
  const request = guardedPrompt(prompt);
271
+ const effortArgs = askEffortArgs(engine, effort);
217
272
 
218
273
  if (engine === 'atris-fast' || engine === 'composer') {
219
274
  return { engine, bin: profile.bin, args: ['--fast', '--cloud', '--print', request] };
@@ -225,6 +280,7 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
225
280
  args: [
226
281
  '-p', request,
227
282
  '--model', model || profile.model || DEFAULT_CLAUDE_RUNNER_MODEL,
283
+ ...effortArgs,
228
284
  '--tools', 'Read,Glob,Grep,WebSearch,WebFetch',
229
285
  '--permission-mode', 'plan',
230
286
  '--no-session-persistence',
@@ -235,7 +291,7 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
235
291
  return {
236
292
  engine,
237
293
  bin: profile.bin,
238
- args: ['exec', ...(model ? ['-m', model] : []), '--sandbox', 'read-only', '--ephemeral', '--ignore-user-config', '--ignore-rules', '--color', 'never', request],
294
+ args: ['exec', ...(model ? ['-m', model] : []), ...effortArgs, '--sandbox', 'read-only', '--ephemeral', '--ignore-user-config', '--ignore-rules', '--color', 'never', request],
239
295
  };
240
296
  }
241
297
  if (engine === 'cursor') {
@@ -252,7 +308,7 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
252
308
  return {
253
309
  engine,
254
310
  bin: profile.bin,
255
- args: ['--no-memory', '--no-subagents', '--permission-mode', 'plan', '--sandbox', 'read-only', ...(model ? ['--model', model] : []), '-p', request],
311
+ args: ['--no-memory', '--no-subagents', '--permission-mode', 'plan', '--sandbox', 'read-only', ...(model ? ['--model', model] : []), ...effortArgs, '-p', request],
256
312
  };
257
313
  }
258
314
  if (engine === 'agy') {
@@ -269,6 +325,7 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
269
325
  '--dangerously-skip-permissions',
270
326
  '--print-timeout', `${Math.max(1, Math.ceil(timeoutMs / 1000))}s`,
271
327
  ...(model ? ['--model', model] : []),
328
+ ...effortArgs,
272
329
  '-p', request,
273
330
  ],
274
331
  };
@@ -283,7 +340,7 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
283
340
  if (engine === 'opencode') {
284
341
  // `run` with a message is headless print mode; the built-in plan agent
285
342
  // keeps the ask read-only (verified live 2026-08-21, ~7s per lookup).
286
- return { engine, bin: profile.bin, args: ['run', '--agent', 'plan', ...(model ? ['-m', model] : []), request] };
343
+ return { engine, bin: profile.bin, args: ['run', '--agent', 'plan', ...(model ? ['-m', model] : []), ...effortArgs, request] };
287
344
  }
288
345
  throw new Error(`engine ask has no read-only command for ${engine}`);
289
346
  }
@@ -480,7 +537,7 @@ async function runEngineAskJobs(jobs, {
480
537
  const answers = new Array(jobs.length);
481
538
  let nextIndex = 0;
482
539
  const execute = executeAskJob || (async (job) => {
483
- const invocation = buildReadOnlyEngineInvocation(job.engine, job.prompt, job.model, { timeoutMs });
540
+ const invocation = buildReadOnlyEngineInvocation(job.engine, job.prompt, job.model, { timeoutMs, effort: job.effort || '' });
484
541
  return runAskProcess(invocation, { cwd: invocation.cwd || root, timeoutMs, signal, onOutputChunk });
485
542
  });
486
543
  const workerCount = Math.min(concurrency, jobs.length);
@@ -534,6 +591,51 @@ function recordEngineAskHealth(answers, root) {
534
591
  }
535
592
  }
536
593
 
594
+ // One run-record line per answer, job "ask": the worker as it ran, its
595
+ // seconds, how it ended, and tokens when printed. A cancelled ask is the
596
+ // operator's stop, not the engine's result, so it writes nothing.
597
+ function recordEngineAskRuns(answers, root, { timeoutMs = 0, startedAt = '' } = {}) {
598
+ let appendRosterRun;
599
+ let parseRunUsage;
600
+ let engineRunsView;
601
+ try {
602
+ ({ appendRosterRun, parseRunUsage } = require('./roster-runs'));
603
+ ({ engineRunsView } = require('./roster-models'));
604
+ } catch {
605
+ return;
606
+ }
607
+ const maxSeconds = Math.round(Number(timeoutMs) / 1000) || 0;
608
+ for (const answer of answers) {
609
+ if (!answer || answer.cancelled || answer.reason === 'cancelled') continue;
610
+ let outcome = 'landed';
611
+ let detail = '';
612
+ if (answer.timed_out || answer.reason === 'timeout') {
613
+ outcome = 'stalled';
614
+ detail = maxSeconds >= 60 ? `stalled at ${Math.round(maxSeconds / 60)} min` : `stalled at ${maxSeconds}s`;
615
+ } else if (!answer.ok) {
616
+ const credit = engineFailureHealthStatus({ ...answer, status: 'errored' }) === 'credit_out';
617
+ outcome = credit ? 'credit out' : 'failed';
618
+ detail = credit ? 'the engine hit its usage limit' : compactFailure(answer);
619
+ }
620
+ try {
621
+ appendRosterRun(root, {
622
+ at: startedAt || new Date(Date.now() - (Number(answer.duration_ms) || 0)).toISOString(),
623
+ job: 'ask',
624
+ engine: answer.engine,
625
+ model: answer.model || engineRunsView(answer.engine).model || '',
626
+ ...(answer.effort ? { effort: answer.effort } : {}),
627
+ ...(maxSeconds ? { max_seconds: maxSeconds } : {}),
628
+ seconds: Math.max(0, (Number(answer.duration_ms) || 0) / 1000),
629
+ outcome,
630
+ ...(detail ? { detail } : {}),
631
+ ...(answer.label ? { task: answer.label } : {}),
632
+ source: 'ask',
633
+ ...parseRunUsage({ stdout: answer.stdout, stderr: answer.stderr }),
634
+ });
635
+ } catch { /* best effort */ }
636
+ }
637
+ }
638
+
537
639
  function engineAskReceipt(answers, { concurrency, timeoutMs, at = new Date().toISOString() }) {
538
640
  const receiptAnswers = answers.map((answer) => ({ ...answer, status: answerStatus(answer) }));
539
641
  const answered = receiptAnswers.filter((answer) => answer.status === 'answered').length;
@@ -670,6 +772,7 @@ async function runEngineAskCommand(args, root = process.cwd(), deps = {}) {
670
772
  onOutputChunk: (chunk, stream) => appendLiveLog(liveLogPath, chunk, stream),
671
773
  });
672
774
  recordEngineAskHealth(answers, root);
775
+ recordEngineAskRuns(answers, root, { timeoutMs: parsed.timeoutMs, startedAt });
673
776
  const receipt = engineAskReceipt(answers, {
674
777
  concurrency: parsed.concurrency,
675
778
  timeoutMs: parsed.timeoutMs,