atris 3.60.0 → 3.62.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 (41) hide show
  1. package/README.md +37 -5
  2. package/atris/skills/engines/SKILL.md +9 -9
  3. package/bin/atris.js +1 -0
  4. package/commands/autoland.js +36 -5
  5. package/commands/autopilot.js +105 -6
  6. package/commands/engine.js +96 -21
  7. package/commands/host.js +56 -4
  8. package/commands/land.js +129 -8
  9. package/commands/member.js +33 -8
  10. package/commands/mission.js +112 -3
  11. package/commands/one-lap.js +14 -10
  12. package/commands/stream.js +56 -22
  13. package/commands/team.js +82 -10
  14. package/commands/worktree.js +39 -0
  15. package/commands/youtube.js +36 -26
  16. package/lib/engine-ask.js +112 -9
  17. package/lib/engine-registry.js +283 -39
  18. package/lib/fleet.js +444 -26
  19. package/lib/functional-owner.js +15 -6
  20. package/lib/host.js +311 -175
  21. package/lib/member-engine.js +11 -1
  22. package/lib/member-park.js +138 -0
  23. package/lib/member-scaffold.js +37 -0
  24. package/lib/roster-markdown.js +1 -1
  25. package/lib/roster-prep.js +289 -0
  26. package/lib/roster-runs.js +389 -0
  27. package/lib/roster-suggest.js +145 -0
  28. package/lib/router-brain.js +111 -3
  29. package/lib/self-drive.js +26 -2
  30. package/lib/state-detection.js +3 -3
  31. package/lib/task-db.js +38 -3
  32. package/lib/team-lineup.js +7 -2
  33. package/lib/todo.js +1 -1
  34. package/lib/wish-audit.js +1 -1
  35. package/package.json +1 -1
  36. package/scripts/det/cpu-hotspots.js +216 -0
  37. package/scripts/det/ytnotes +105 -19
  38. package/scripts/det/ytquote-repair.js +18 -4
  39. package/templates/members/host/MEMBER.md +5 -3
  40. package/templates/members/host/MISSION.md +1 -1
  41. package/templates/members/host/skills/host/SKILL.md +61 -12
package/commands/team.js CHANGED
@@ -7,6 +7,7 @@ const { canonicalEngineName } = require('../lib/engine-registry');
7
7
  const taskDb = require('../lib/task-db');
8
8
  const { buildTeamPresence, DEFAULT_FRESHNESS_WINDOW_MS, renderTeamPresence } = require('../lib/team-presence');
9
9
  const { LINEUP_UNREADABLE, memberLineup, readLineupSafe, renderLineup } = require('../lib/team-lineup');
10
+ const { isMemberParked, isParkedFrontmatter, setMemberParked } = require('../lib/member-park');
10
11
  const { readEngineRegistry } = require('./engine');
11
12
  const { listMissions, listWorktreeRollupMissions } = require('./mission');
12
13
  const { collectSnapshot, collectStreamEvents, repoRoot } = require('./stream');
@@ -34,12 +35,14 @@ function collectTeamPresence(deps = {}) {
34
35
  const root = deps.root || repoRoot(deps.cwd || process.cwd());
35
36
  const nowMs = typeof deps.now === 'function' ? deps.now() : Date.now();
36
37
  const freshnessWindowMs = deps.freshnessWindowMs || DEFAULT_FRESHNESS_WINDOW_MS;
37
- const stream = deps.stream || collectSnapshot({ root, deps: deps.streamDeps });
38
+ const skipLanding = Boolean(deps.skipLanding);
39
+ const stream = deps.stream || collectSnapshot({ root, deps: deps.streamDeps, skipLanding });
38
40
  const streamEvents = deps.streamEvents || collectStreamEvents({
39
41
  root,
40
42
  sinceMs: nowMs - freshnessWindowMs,
41
43
  nowMs,
42
44
  deps: deps.streamDeps,
45
+ skipLanding,
43
46
  });
44
47
  return buildTeamPresence({
45
48
  nowMs,
@@ -174,7 +177,9 @@ function escapeHtml(text) {
174
177
 
175
178
  function collectTeamRoster(deps = {}) {
176
179
  const root = deps.root || repoRoot(deps.cwd || process.cwd());
177
- const presence = deps.presence || collectTeamPresence(deps);
180
+ // The roster only reads who is awake, never the landing wait, so it skips
181
+ // the landing board entirely.
182
+ const presence = deps.presence || collectTeamPresence({ ...deps, skipLanding: true });
178
183
  const awake = new Set(presence.members.map((member) => String(member.name || '').trim().toLowerCase()));
179
184
  const engineRoster = deps.engineRoster || readEngineRegistry(root, { persist: false }).engines;
180
185
  const engineByOwner = new Map();
@@ -206,6 +211,7 @@ function collectTeamRoster(deps = {}) {
206
211
  now: rawNow,
207
212
  focus,
208
213
  active,
214
+ parked: isParkedFrontmatter(member?.frontmatter),
209
215
  };
210
216
  })
211
217
  .filter((entry) => entry.name)
@@ -228,10 +234,30 @@ function wrapCommaNames(names, width = 80) {
228
234
  return lines.join('\n');
229
235
  }
230
236
 
231
- function renderTeamRoster(rosterRows, deps = {}) {
232
- if (!rosterRows.length) {
237
+ // Parked members leave the lists and come back as one line, unless --all
238
+ // asks for them in place.
239
+ function parkedSummaryLine(parkedRows, width = 80) {
240
+ if (!parkedRows.length) return '';
241
+ const names = parkedRows.map((entry) => entry.name);
242
+ names[0] = `parked (${parkedRows.length}): ${names[0]}`;
243
+ names[names.length - 1] = `${names[names.length - 1]} · atris team --all to show them`;
244
+ return wrapCommaNames(names, width);
245
+ }
246
+
247
+ function withParkedLabels(rosterRows, all) {
248
+ const parkedRows = rosterRows.filter((entry) => entry.parked);
249
+ if (!all) return { rows: rosterRows.filter((entry) => !entry.parked), parkedRows };
250
+ return {
251
+ rows: rosterRows.map((entry) => (entry.parked ? { ...entry, name: `${entry.name} (parked)` } : entry)),
252
+ parkedRows: [],
253
+ };
254
+ }
255
+
256
+ function renderTeamRoster(allRows, deps = {}) {
257
+ if (!allRows.length) {
233
258
  return 'no team members yet. create one with: atris member create <name> --role="..."';
234
259
  }
260
+ const { rows: rosterRows, parkedRows } = withParkedLabels(allRows, Boolean(deps.all));
235
261
  const activeRows = rosterRows.filter((entry) => entry.active);
236
262
  const restRows = rosterRows.filter((entry) => !entry.active);
237
263
  const termWidth = deps.termWidth || process.stdout.columns || 80;
@@ -260,6 +286,11 @@ function renderTeamRoster(rosterRows, deps = {}) {
260
286
  } else {
261
287
  lines.push('(none)');
262
288
  }
289
+ const parkedLine = parkedSummaryLine(parkedRows, termWidth);
290
+ if (parkedLine) {
291
+ lines.push('');
292
+ lines.push(parkedLine);
293
+ }
263
294
  return lines.join('\n');
264
295
  }
265
296
 
@@ -273,7 +304,8 @@ function renderTeamWithLineup(rosterRows, lineup, deps = {}) {
273
304
  return block ? `${block}\n\n${today}` : today;
274
305
  }
275
306
 
276
- function renderTeamRosterHtml(rosterRows, meta = {}) {
307
+ function renderTeamRosterHtml(allRows, meta = {}) {
308
+ const rosterRows = withParkedLabels(allRows, Boolean(meta.all)).rows;
277
309
  const activeRows = rosterRows.filter((entry) => entry.active);
278
310
  const restRows = rosterRows.filter((entry) => !entry.active);
279
311
  const generatedAt = meta.generatedAt || new Date().toISOString();
@@ -418,6 +450,7 @@ function writeTeamBoardHtml(rosterRows, deps = {}) {
418
450
  const outPath = path.join(workspace, 'atris', 'team', 'team-board.html');
419
451
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
420
452
  const html = renderTeamRosterHtml(rosterRows, {
453
+ all: Boolean(deps.all),
421
454
  workspace,
422
455
  generatedAt: deps.generatedAt || new Date().toISOString(),
423
456
  });
@@ -465,6 +498,8 @@ function collectTeamPrune(deps = {}) {
465
498
  for (const member of collectMembers(root, deps)) {
466
499
  const name = String(member?.name || '').trim().toLowerCase();
467
500
  if (!name) continue;
501
+ // A parked member is already set aside on purpose; flagging it again is noise.
502
+ if (isMemberParked(root, name)) continue;
468
503
  const signalMs = newestSignalMs(member);
469
504
  if (activeOwners.has(name) || (signalMs && nowMs - signalMs < days * DAY_MS)) {
470
505
  activeCount += 1;
@@ -497,14 +532,47 @@ function renderTeamPrune(report, days = DEFAULT_PRUNE_DAYS) {
497
532
  function helpText() {
498
533
  return [
499
534
  'atris team - who does each job, with its tool and model, then active members and the rest',
535
+ 'atris team --all - also list parked members in place',
500
536
  'atris team presence - show who is awake and what they are doing',
501
537
  'atris team prune - flag members with no recent activity; deletes nothing',
538
+ 'atris team park <name> [--note "<reason>"] - hide a member from the team views; it still runs by name',
539
+ 'atris team unpark <name> - bring a parked member back',
502
540
  '',
503
- 'usage: atris team [roster|presence] [--json] [--html]',
541
+ 'usage: atris team [roster|presence] [--all] [--json] [--html]',
504
542
  'usage: atris team prune [--days N] [--json]',
543
+ 'usage: atris team park <name> [--note "<reason>"] | atris team unpark <name>',
505
544
  ].join('\n');
506
545
  }
507
546
 
547
+ const PARK_USAGE = 'usage: atris team park <name> [--note "<reason>"] | atris team unpark <name>';
548
+
549
+ function teamParkCommand(parked, rest, deps = {}) {
550
+ const write = deps.write || process.stdout.write.bind(process.stdout);
551
+ const error = deps.error || process.stderr.write.bind(process.stderr);
552
+ let name = '';
553
+ let note = '';
554
+ let bad = false;
555
+ for (let i = 0; i < rest.length; i += 1) {
556
+ const arg = rest[i];
557
+ if (parked && arg === '--note') { i += 1; note = rest[i] || ''; if (!note) bad = true; continue; }
558
+ if (parked && arg.startsWith('--note=')) { note = arg.slice('--note='.length); continue; }
559
+ if (arg.startsWith('-') || name) { bad = true; continue; }
560
+ name = arg;
561
+ }
562
+ if (bad || !name) {
563
+ error(`${PARK_USAGE}\n`);
564
+ return 2;
565
+ }
566
+ const root = deps.root || repoRoot(deps.cwd || process.cwd());
567
+ const result = setMemberParked(root, name, { parked, note, now: deps.now ? new Date(deps.now()) : new Date() });
568
+ if (!result.ok) {
569
+ error(`${result.error}\n`);
570
+ return 1;
571
+ }
572
+ write(`${result.message}\n`);
573
+ return 0;
574
+ }
575
+
508
576
  function teamCommand(args = [], deps = {}) {
509
577
  if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
510
578
  (deps.write || process.stdout.write.bind(process.stdout))(`${helpText()}\n`);
@@ -531,18 +599,22 @@ function teamCommand(args = [], deps = {}) {
531
599
  (deps.write || process.stdout.write.bind(process.stdout))(`${output}\n`);
532
600
  return 0;
533
601
  }
602
+ if (args[0] === 'park' || args[0] === 'unpark') {
603
+ return teamParkCommand(args[0] === 'park', args.slice(1), deps);
604
+ }
534
605
  const rosterArgs = args.filter((arg) => arg !== 'roster');
535
- const rosterFlags = new Set(['--json', '--html']);
606
+ const rosterFlags = new Set(['--json', '--html', '--all']);
536
607
  if (args[0] !== 'presence' && rosterArgs.every((arg) => rosterFlags.has(arg))) {
537
608
  const html = rosterArgs.includes('--html');
538
609
  const json = rosterArgs.includes('--json');
610
+ const all = rosterArgs.includes('--all');
539
611
  if (html && json) {
540
612
  (deps.error || process.stderr.write.bind(process.stderr))('usage: atris team [--json] [--html] (not both)\n');
541
613
  return 2;
542
614
  }
543
615
  const roster = deps.roster || collectTeamRoster(deps);
544
616
  if (html) {
545
- const outPath = writeTeamBoardHtml(roster, deps);
617
+ const outPath = writeTeamBoardHtml(roster, { ...deps, all });
546
618
  (deps.write || process.stdout.write.bind(process.stdout))(`${outPath}\n`);
547
619
  return 0;
548
620
  }
@@ -554,12 +626,12 @@ function teamCommand(args = [], deps = {}) {
554
626
  : readLineupSafe(deps.root || repoRoot(deps.cwd || process.cwd()), deps.lineupNow || new Date());
555
627
  const output = json
556
628
  ? JSON.stringify(roster.map((entry) => ({ ...entry, lineup: memberLineup(lineup, entry.name) })), null, 2)
557
- : renderTeamWithLineup(roster, lineup, deps);
629
+ : renderTeamWithLineup(roster, lineup, { ...deps, all });
558
630
  (deps.write || process.stdout.write.bind(process.stdout))(`${output}\n`);
559
631
  return 0;
560
632
  }
561
633
  if (args[0] !== 'presence' || args.some((arg, index) => index > 0 && arg !== '--json')) {
562
- (deps.error || process.stderr.write.bind(process.stderr))('usage: atris team [roster|presence|prune] [--json] [--html]\n');
634
+ (deps.error || process.stderr.write.bind(process.stderr))('usage: atris team [roster|presence|prune|park|unpark] [--all] [--json] [--html]\n');
563
635
  return 2;
564
636
  }
565
637
  const presence = deps.presence || collectTeamPresence(deps);
@@ -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
 
@@ -236,23 +236,31 @@ function captionHostAllowed(urlString) {
236
236
  }
237
237
  }
238
238
 
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
- }
246
- }
247
- for (const [language, tracks] of Object.entries(trackSets)) {
248
- for (const track of tracks || []) {
249
- if (track?.url) return { language, track };
250
- }
239
+ // Caption tracks in the order to try them. Human subtitles first, then the
240
+ // auto track in the language actually spoken ("en-orig", "es-orig"). YouTube's
241
+ // machine-translated tracks (plain "en" on most English videos) are rate
242
+ // limited with HTTP 429, so they only come after every real track.
243
+ function captionTrackCandidates(info = {}) {
244
+ const candidates = [];
245
+ const add = (trackSets = {}, languages) => {
246
+ for (const language of languages) {
247
+ if (language === 'live_chat') continue;
248
+ const tracks = (trackSets[language] || []).filter((track) => track?.url);
249
+ const track = tracks.find((t) => ['json3', 'vtt', 'srv3', 'ttml'].includes(t.ext)) || tracks[0];
250
+ if (track && !candidates.some((c) => c.track === track)) candidates.push({ language, track });
251
251
  }
252
- return null;
253
252
  };
253
+ const manual = info.subtitles || {};
254
+ const auto = info.automatic_captions || {};
255
+ add(manual, ['en', 'en-US', 'en-GB', ...Object.keys(manual).filter((l) => l.startsWith('en-'))]);
256
+ add(manual, Object.keys(manual));
257
+ add(auto, ['en-orig', ...Object.keys(auto).filter((l) => l.endsWith('-orig'))]);
258
+ add(auto, ['en', 'en-US', 'en-GB']);
259
+ return candidates;
260
+ }
254
261
 
255
- return chooseFrom(info.subtitles) || chooseFrom(info.automatic_captions);
262
+ function chooseCaptionTrack(info = {}) {
263
+ return captionTrackCandidates(info)[0] || null;
256
264
  }
257
265
 
258
266
  function formatTimestampFromMs(ms) {
@@ -478,12 +486,14 @@ function parseYtDlpInfoJson(result) {
478
486
  }
479
487
 
480
488
  function localCaptionNames(id) {
481
- // scripts/det/ytnotes keeps these VTT names plus leftover yt_<id>.clean.txt.
489
+ // scripts/det/ytnotes keeps these VTT names (.local.vtt = transcribed on this
490
+ // machine when the video had no captions) plus leftover yt_<id>.clean.txt.
482
491
  return [
483
492
  `yt_${id}.en.vtt`,
484
493
  `yt_${id}.en-orig.vtt`,
485
494
  `yt_${id}.en-US.vtt`,
486
495
  `yt_${id}.en-GB.vtt`,
496
+ `yt_${id}.local.vtt`,
487
497
  `yt_${id}.clean.txt`,
488
498
  ];
489
499
  }
@@ -521,14 +531,14 @@ function readLocalCaptionText({ url, id, workDir } = {}) {
521
531
 
522
532
  async function loadCaptionRaw(info, youtubeUrl, deps = {}) {
523
533
  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
- }
534
+ const fetchText = deps.fetchCaptionText || fetchCaptionText;
535
+ // A blocked track (429) falls through to the next one instead of failing.
536
+ const candidates = captionTrackCandidates(payload).slice(0, 4);
537
+ for (const candidate of candidates) {
538
+ const raw = await fetchText(candidate.track.url);
539
+ if (String(raw || '').trim()) return { raw, language: candidate.language };
540
+ }
541
+ const selected = candidates[0];
532
542
  const local = readLocalCaptionText({
533
543
  url: youtubeUrl,
534
544
  id: payload.id,
@@ -559,7 +569,7 @@ async function extractLocalTranscript(youtubeUrl, deps = {}) {
559
569
  const result = runner('yt-dlp', ytDlpInfoArgs(youtubeUrl), {
560
570
  encoding: 'utf8',
561
571
  timeout: 20000,
562
- maxBuffer: 10 * 1024 * 1024,
572
+ maxBuffer: 64 * 1024 * 1024,
563
573
  });
564
574
  const info = parseYtDlpInfoJson(result);
565
575
  const loaded = await loadCaptionRaw(info, youtubeUrl, deps);
@@ -3697,7 +3707,7 @@ async function extractTeachSource(youtubeUrl, deps = {}) {
3697
3707
  const result = runner('yt-dlp', ytDlpInfoArgs(youtubeUrl), {
3698
3708
  encoding: 'utf8',
3699
3709
  timeout: 20000,
3700
- maxBuffer: 10 * 1024 * 1024,
3710
+ maxBuffer: 64 * 1024 * 1024,
3701
3711
  });
3702
3712
  const info = parseYtDlpInfoJson(result);
3703
3713
  const loaded = await loadCaptionRaw(info, youtubeUrl, deps);
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,