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.
- package/README.md +37 -5
- package/atris/features/README.md +8 -0
- package/atris/skills/README.md +1 -0
- package/atris/skills/commerce/SKILL.md +96 -0
- package/atris/skills/engines/SKILL.md +9 -9
- package/bin/atris.js +12 -0
- package/commands/autoland.js +36 -5
- package/commands/autopilot.js +105 -6
- package/commands/buy.js +242 -0
- package/commands/engine.js +96 -21
- package/commands/host.js +56 -4
- package/commands/land.js +129 -8
- package/commands/member.js +68 -14
- package/commands/mission.js +116 -4
- package/commands/one-lap.js +14 -10
- package/commands/stream.js +56 -22
- package/commands/task.js +7 -0
- package/commands/team.js +82 -10
- package/commands/transactions.js +115 -0
- package/commands/worktree.js +39 -0
- package/commands/youtube.js +58 -26
- package/lib/commerce.js +75 -0
- package/lib/engine-ask.js +112 -9
- package/lib/engine-registry.js +300 -39
- package/lib/fleet.js +444 -26
- package/lib/functional-owner.js +15 -6
- package/lib/host.js +296 -170
- package/lib/known-commands.js +1 -1
- package/lib/member-engine.js +25 -1
- package/lib/member-park.js +138 -0
- package/lib/member-scaffold.js +39 -0
- package/lib/member-type.js +27 -0
- package/lib/roster-markdown.js +1 -1
- package/lib/roster-prep.js +289 -0
- package/lib/roster-runs.js +389 -0
- package/lib/roster-suggest.js +145 -0
- package/lib/router-brain.js +111 -3
- package/lib/runner-command.js +37 -2
- package/lib/self-drive.js +26 -2
- package/lib/state-detection.js +3 -3
- package/lib/task-db.js +38 -3
- package/lib/team-lineup.js +7 -2
- package/lib/todo.js +1 -1
- package/lib/wish-audit.js +1 -1
- package/package.json +1 -1
- package/scripts/det/cpu-hotspots.js +216 -0
- package/scripts/det/ytnotes +169 -23
- package/scripts/det/ytquote-repair.js +18 -4
- package/scripts/det/ytrail-bench.js +214 -0
- package/scripts/det/ytrail-eval.js +128 -56
- package/templates/members/host/MEMBER.md +5 -3
- package/templates/members/host/MISSION.md +1 -1
- package/templates/members/host/skills/host/SKILL.md +64 -16
package/commands/worktree.js
CHANGED
|
@@ -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,
|
package/commands/youtube.js
CHANGED
|
@@ -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|
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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:
|
|
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:
|
|
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
|
};
|
package/lib/commerce.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|