testronaut 1.10.2 β†’ 1.12.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 (36) hide show
  1. package/README.md +198 -0
  2. package/TODO.md +5 -0
  3. package/bin/cli.js +234 -5
  4. package/bin/terminalUi.js +51 -0
  5. package/core/agent.js +1 -0
  6. package/core/browserEvidence.js +184 -0
  7. package/core/jevRouting.js +77 -0
  8. package/core/jevShadow.js +325 -0
  9. package/core/jevSuiteSelection.js +39 -0
  10. package/core/jevUsage.js +62 -0
  11. package/core/optimization.js +98 -0
  12. package/core/turnLoop.js +174 -10
  13. package/experiments/CROSS_PROVIDER.md +19 -0
  14. package/experiments/README.md +62 -0
  15. package/experiments/buildExperimentPublicationPackage.js +219 -0
  16. package/experiments/buildOptimizationDataPackage.js +390 -0
  17. package/experiments/crossProviderBenchmark.js +227 -0
  18. package/experiments/optimizationBenchmark.js +339 -0
  19. package/package.json +5 -1
  20. package/tests/binTests/cli.helpers.test.js +16 -0
  21. package/tests/binTests/cli.integration.test.js +14 -0
  22. package/tests/binTests/terminalUi.test.js +34 -0
  23. package/tests/coreTests/browserEvidence.test.js +70 -0
  24. package/tests/coreTests/jevRouting.test.js +57 -0
  25. package/tests/coreTests/jevShadow.test.js +158 -0
  26. package/tests/coreTests/jevSuiteSelection.test.js +24 -0
  27. package/tests/coreTests/jevUsage.test.js +58 -0
  28. package/tests/coreTests/optimization.test.js +80 -0
  29. package/tests/coreTests/turnLoop.test.js +304 -0
  30. package/tests/experimentTests/buildOptimizationDataPackage.test.js +25 -0
  31. package/tests/experimentTests/crossProviderBenchmark.test.js +37 -0
  32. package/tests/experimentTests/optimizationBenchmark.test.js +49 -0
  33. package/tests/toolsTests/generateHtmlReport.test.js +83 -0
  34. package/tests/toolsTests/turnLoopUtils.test.js +17 -0
  35. package/tools/generateHtmlReport.js +96 -26
  36. package/tools/turnLoopUtils.js +7 -11
package/README.md CHANGED
@@ -374,6 +374,204 @@ Notes:
374
374
 
375
375
  ---
376
376
 
377
+ ## πŸ”¬ Experimental Jev shadow mode
378
+
379
+ Jev shadow mode evaluates each agent turn without changing browser actions,
380
+ mission status, or exit codes. Its typed recommendations, latency, model, and
381
+ token usage are written to each step in the JSON report. A run-level
382
+ `jevShadow` summary aggregates completed calls, errors, latency, and tokens.
383
+
384
+ Enable it with a TypeSafe API key:
385
+
386
+ ```bash
387
+ export TYPESAFE_API_KEY=YOUR_KEY
388
+ TESTRONAUT_JEV_SHADOW=1 testronaut login.mission.js
389
+ ```
390
+
391
+ Optional overrides:
392
+
393
+ ```bash
394
+ export TESTRONAUT_JEV_MODEL=jev-latest
395
+ export TESTRONAUT_JEV_TIMEOUT_MS=5000
396
+ export TESTRONAUT_JEV_API_BASE=https://api.typesafe.ai
397
+ ```
398
+
399
+ The shadow request contains a bounded representation of the mission, assistant
400
+ response, redacted tool arguments, recent events, Ground Control state, and
401
+ structured browser evidence derived from DOM snapshots already collected by
402
+ Testronaut. Browser evidence includes the query-free URL, title, headings,
403
+ control labels, element counts, and value-free live control flags (`hasValue`,
404
+ `checked`, `disabled`, `readOnly`, and `visible`). It excludes raw DOM, body
405
+ text, input values and value lengths, URL queries, and URL fragments. Because this data is sent to a
406
+ third-party service, enable the experiment only for test data appropriate for that service.
407
+ API errors and timeouts are recorded as shadow telemetry and do not stop the
408
+ mission.
409
+
410
+ ### Experimental completion gate
411
+
412
+ After validating shadow results, opt into early completion with:
413
+
414
+ ```bash
415
+ TESTRONAUT_JEV_GATE=1 testronaut login.mission.js
416
+ ```
417
+
418
+ The gate defaults to an 80% threshold and requires all three independent Jev
419
+ signalsβ€”mission completion, complete progress, and finish-next probabilityβ€”to
420
+ meet it. It also requires structured browser evidence and a clean tool turn.
421
+ Before returning success, Testronaut captures a final screenshot when
422
+ screenshots are enabled and records completion telemetry. Jev errors, missing
423
+ evidence, incomplete resource guards, and screenshot failures fail open to the
424
+ ordinary agent loop.
425
+
426
+ Use a different experimental threshold with, for example:
427
+
428
+ ```bash
429
+ TESTRONAUT_JEV_GATE=1 TESTRONAUT_JEV_GATE_THRESHOLD=0.85 testronaut login.mission.js
430
+ ```
431
+
432
+ JSON reports retain the full Jev evaluation telemetry. HTML reports show the
433
+ models used and input/output token totals at run, mission, and turn levels
434
+ without displaying internal strategy counts or estimated cost. The JSON cost
435
+ estimate defaults to Jev's current `$0.042` per million input tokens. Override
436
+ the reporting rate without changing API behavior:
437
+
438
+ ```bash
439
+ TESTRONAUT_JEV_INPUT_USD_PER_MILLION=0.042 testronaut
440
+ ```
441
+
442
+ If the gate is enabled without an API key, the report highlights every skipped
443
+ evaluation. Ensure `TYPESAFE_API_KEY` or `TESTRONAUT_JEV_API_KEY` is exported in
444
+ the same process that launches Testronaut.
445
+
446
+ ### Additional Jev savings experiments
447
+
448
+ Enable the batched shadow strategy bundle without changing mission behavior:
449
+
450
+ ```bash
451
+ TESTRONAUT_JEV_STRATEGIES=1 testronaut
452
+ ```
453
+
454
+ The same Jev request then records:
455
+
456
+ - relevant structured DOM regions for future context filtering;
457
+ - a choice among controls already enumerated by Testronaut;
458
+ - deterministic, fast-model, full-model, observe, or finish routing;
459
+ - postcondition verification probability;
460
+ - continue, retry, reobserve, escalate, or stop recovery policy;
461
+ - mission stage and prerequisite readiness.
462
+
463
+ Strategy results and aggregates appear in JSON reports. They remain advisory
464
+ unless a separate experiment such as dynamic routing is explicitly enabled.
465
+
466
+ ### Stable Jev guardrails
467
+
468
+ Run Jev completion and evidence guardrails without allowing any model switch:
469
+
470
+ ```bash
471
+ export TESTRONAUT_JEV_API_KEY="your-key"
472
+ testronaut --guardrails
473
+ ```
474
+
475
+ This profile is fail-open: if Jev is unavailable, times out, or lacks sufficient
476
+ evidence, the primary model continues normally. It does not request the larger
477
+ experimental strategy question set and ignores fast-model routing configuration.
478
+ The default completion probability is 0.8.
479
+
480
+ ```json
481
+ {
482
+ "guardrails": {
483
+ "enabled": true,
484
+ "mode": "live",
485
+ "completionProbability": 0.8
486
+ }
487
+ }
488
+ ```
489
+
490
+ Guardrails are opt-in and use a user-supplied Jev key. The earlier
491
+ `--optimize=tokens --optimization-mode=live` and nested optimization config
492
+ remain supported as compatibility forms. Run `testronaut config --json` to
493
+ check credential readiness without printing the key.
494
+
495
+ Reports expose stable `jevGuardrails` telemetry separately from beta
496
+ `modelRouting` telemetry. The legacy `jevShadow` field remains for compatibility.
497
+
498
+ ### Beta dynamic model routing
499
+
500
+ Choose an optimization objective and whether it runs in shadow or live mode:
501
+
502
+ ```bash
503
+ testronaut --optimize=cost --optimization-mode=live --beta-model-routing
504
+ testronaut --optimize=tokens --optimization-mode=shadow
505
+ testronaut --optimize=balanced --optimization-mode=live --beta-model-routing
506
+ testronaut --optimize=off
507
+ ```
508
+
509
+ The supported objectives are `off`, `cost`, `tokens`, `balanced`, and `speed`.
510
+ `cost` requests cheaper family-local models; `tokens` uses stable guardrails
511
+ without model switching; `balanced` combines guardrails and beta routing; and
512
+ `speed` requests beta fast-model routing. The `cost`, `balanced`, and `speed`
513
+ objectives cannot switch models unless beta routing is explicitly enabled.
514
+ When an objective is supplied without a mode, it defaults to `shadow`.
515
+
516
+ The equivalent `testronaut-config.json` configuration is:
517
+
518
+ ```json
519
+ {
520
+ "optimization": {
521
+ "objective": "balanced",
522
+ "mode": "live",
523
+ "guardrails": {
524
+ "completionProbability": 0.8,
525
+ "minimumRoutingProbability": 0.4
526
+ },
527
+ "beta": {
528
+ "modelRouting": true
529
+ }
530
+ }
531
+ }
532
+ ```
533
+
534
+ CLI flags override configuration. Existing `TESTRONAUT_JEV_*` variables remain
535
+ available as lower-level experimental overrides.
536
+
537
+ Beta dynamic routing uses a high-confidence Jev `fast_model` recommendation from the
538
+ completed turn to choose the model for the next turn. Start in shadow mode to
539
+ record eligibility without changing models:
540
+
541
+ ```bash
542
+ TESTRONAUT_JEV_ROUTING=shadow TESTRONAUT_JEV_FAST_MODEL=gpt-5.6-luna testronaut
543
+ ```
544
+
545
+ After reviewing the recommendations, enable one-turn live routing:
546
+
547
+ ```bash
548
+ TESTRONAUT_JEV_ROUTING=live TESTRONAUT_JEV_FAST_MODEL=gpt-5.6-luna testronaut
549
+ ```
550
+
551
+ The family-local defaults are `gpt-5.6-luna` for OpenAI,
552
+ `gemini-3.5-flash-lite` for Gemini, and `claude-haiku-4-5` for Anthropic. An
553
+ explicit `TESTRONAUT_JEV_FAST_MODEL` must still belong to the active provider
554
+ family. Routing uses Jev's selected `fast_model` probability with a default
555
+ threshold of 40%; change it with `TESTRONAUT_JEV_ROUTING_THRESHOLD`.
556
+ Authentication, completion, failed,
557
+ uncertain recovery, and low-confidence turns stay on the primary model. If the
558
+ routed request failsβ€”or a routed action requires a retryβ€”Testronaut retries on
559
+ the primary model. Reports
560
+ record the actual model and tokens for every turn and split aggregate tokens by
561
+ model.
562
+
563
+ Evaluate risk-based suite selection separately as a dry run:
564
+
565
+ ```bash
566
+ TYPESAFE_API_KEY=YOUR_KEY testronaut select --base=origin/main
567
+ ```
568
+
569
+ This reads the git diff, asks one bounded relevance question per discovered
570
+ mission, and prints selected mission filenames without running or skipping any
571
+ tests. Use repeated `--changed-file=path` arguments instead of a git diff when
572
+ testing a hypothetical change. The default selection threshold is 70%; override
573
+ it with `TESTRONAUT_JEV_SELECTION_THRESHOLD`.
574
+
377
575
  ## πŸ“‹ Reports
378
576
 
379
577
  Testronaut generates JSON, HTML, and run-specific screenshots under the configured `outputDir` (default):
package/TODO.md CHANGED
@@ -20,6 +20,11 @@
20
20
  - [ ] Design an optional mission scope/side-effect policy that agents can enforce and reports can display.
21
21
  - [ ] Evaluate semantic DOM diffs/chunks after configurable limits, selector scoping, and table trimming have production data.
22
22
 
23
+ ## Optimization and Rate-Limit Follow-Ups
24
+
25
+ - [ ] Run a history-informed model-routing benchmark before expanding cross-provider routing. Give Jev versioned, per-model/per-mission-class reliability, token, latency, and price observations from prior runs; require a configurable reliability floor; compare against the best fixed-model-plus-guardrails control with blocked repetitions; keep selection at the mission boundary; record the evidence and reason for every choice; and define cold-start, stale-data, low-confidence, and provider-failure fallbacks. Do not retry a failed mission on another provider in the benchmark because that would confound routing quality with retry recovery.
26
+ - [ ] Add an opt-in CLI/config policy that reroutes an imminent throttled LLM request to a compatible model as an alternative to the default backoff behavior. Keep backoff as the default; use observed TPM/RPM headroom and projected request tokens; constrain failover to explicitly configured provider/model families; preserve tool/history compatibility; prevent routing loops; fall back safely when the alternate model is unavailable; and record the original model, routed model, trigger, token usage, latency, and outcome in reports.
27
+
23
28
  ## Automated MFA Follow-Ups
24
29
 
25
30
  - [ ] Add an integration smoke test against a local or staging API fixture for `get_mfa_code`.
package/bin/cli.js CHANGED
@@ -48,7 +48,7 @@ import fetch from 'node-fetch';
48
48
  import crypto from 'crypto';
49
49
  import http from 'http';
50
50
  import { resolveProviderModel } from '../llm/modelResolver.js';
51
- import { exec as execCmd } from 'child_process';
51
+ import { exec as execCmd, execFileSync } from 'child_process';
52
52
  import { promisify } from 'util';
53
53
  const exec = promisify(execCmd);
54
54
  import url from 'url';
@@ -57,6 +57,11 @@ import { discoverMissionFiles } from '../core/missionDiscovery.js';
57
57
  import { loadMissionModule } from '../core/missionLoader.js';
58
58
  import { loadConfig } from '../core/config.js';
59
59
  import { matchesTagFilter, normalizeTagMatch, normalizeTags } from '../core/tags.js';
60
+ import { createTerminalUi } from './terminalUi.js';
61
+ import { summarizeJevUsage } from '../core/jevUsage.js';
62
+ import { evaluateJevShadow, resolveJevShadowConfig } from '../core/jevShadow.js';
63
+ import { buildJevSuiteSelectionRequest, interpretJevSuiteSelection } from '../core/jevSuiteSelection.js';
64
+ import { applyOptimizationPolicy, OPTIMIZATION_MODES, OPTIMIZATION_OBJECTIVES, resolveOptimizationPolicy } from '../core/optimization.js';
60
65
 
61
66
  // Keep PW browsers inside the project to avoid global cache skew
62
67
  process.env.PLAYWRIGHT_BROWSERS_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || '0';
@@ -78,6 +83,7 @@ const jsonOutput = args.includes('--json');
78
83
  const quietOutput = args.includes('--quiet') || jsonOutput;
79
84
  args = args.filter(arg => arg !== '--json' && arg !== '--quiet');
80
85
  const writeOutput = value => process.stdout.write(`${typeof value === 'string' ? value : JSON.stringify(value, null, 2)}\n`);
86
+ const terminalUi = createTerminalUi({ quiet: quietOutput });
81
87
  if (quietOutput) {
82
88
  console.log = () => {};
83
89
  console.warn = () => {};
@@ -358,6 +364,7 @@ export const __test__ = {
358
364
  createVercelBypassHeader,
359
365
  parseProviderArgs,
360
366
  parseRunOptionsArgs,
367
+ parseOptimizationArgs,
361
368
  detectCliName,
362
369
  isDirectInvocation,
363
370
  resolveMissionPath,
@@ -387,6 +394,7 @@ function resolveReportFile(requested, reportDir, cwd = process.cwd()) {
387
394
 
388
395
  function buildEffectiveConfig(config = {}, cwd = process.cwd()) {
389
396
  const resolved = resolveProviderModel({ cwd });
397
+ const optimization = resolveOptimizationPolicy({ config });
390
398
  const source = key => process.env[key] ? 'environment' : undefined;
391
399
  return {
392
400
  configFile: path.resolve(cwd, 'testronaut-config.json'),
@@ -399,6 +407,21 @@ function buildEffectiveConfig(config = {}, cwd = process.cwd()) {
399
407
  addTags: normalizeTags([...(config.addTags || []), ...cliAddTags]),
400
408
  screenshots: screenshotsEnabled,
401
409
  authenticated: Boolean(config.sessionToken),
410
+ optimization,
411
+ guardrails: {
412
+ enabled: optimization.objective === 'tokens' && optimization.mode !== 'off',
413
+ mode: optimization.objective === 'tokens' ? optimization.mode : 'off',
414
+ completionProbability: optimization.completionProbability,
415
+ failOpen: true,
416
+ credentialConfigured: Boolean(resolveJevShadowConfig().apiKey),
417
+ credentialSource: process.env.TESTRONAUT_JEV_API_KEY
418
+ ? 'TESTRONAUT_JEV_API_KEY'
419
+ : process.env.TYPESAFE_API_KEY
420
+ ? 'TYPESAFE_API_KEY'
421
+ : process.env.JEV_API_KEY
422
+ ? 'JEV_API_KEY'
423
+ : null,
424
+ },
402
425
  };
403
426
  }
404
427
 
@@ -506,6 +529,56 @@ function parseTagArgs(argsList) {
506
529
  }
507
530
  __test__.parseTagArgs = parseTagArgs;
508
531
 
532
+ function parseOptimizationArgs(argsList) {
533
+ const nextArgs = [...argsList];
534
+ const values = {};
535
+ let invalid = false;
536
+ const guardrailsIndex = nextArgs.findIndex(arg => arg === '--guardrails' || arg.startsWith('--guardrails='));
537
+ if (guardrailsIndex >= 0) {
538
+ const raw = nextArgs[guardrailsIndex];
539
+ const inline = raw.includes('=');
540
+ const following = nextArgs[guardrailsIndex + 1];
541
+ const consumesFollowing = !inline && ['live', 'shadow', 'off'].includes(String(following || '').toLowerCase());
542
+ const guardrailsMode = inline
543
+ ? raw.slice(raw.indexOf('=') + 1).trim().toLowerCase()
544
+ : consumesFollowing ? following.toLowerCase() : 'live';
545
+ if (!['live', 'shadow', 'off'].includes(guardrailsMode)) invalid = true;
546
+ else {
547
+ values.guardrailsMode = guardrailsMode;
548
+ values.objective = guardrailsMode === 'off' ? 'off' : 'tokens';
549
+ values.mode = guardrailsMode;
550
+ }
551
+ nextArgs.splice(guardrailsIndex, consumesFollowing ? 2 : 1);
552
+ }
553
+ const betaRoutingIndex = nextArgs.indexOf('--beta-model-routing');
554
+ if (betaRoutingIndex >= 0) {
555
+ values.betaModelRouting = true;
556
+ nextArgs.splice(betaRoutingIndex, 1);
557
+ }
558
+ const definitions = [
559
+ { names: ['--optimize'], key: 'objective' },
560
+ { names: ['--optimization-mode'], key: 'mode' },
561
+ ];
562
+ for (const definition of definitions) {
563
+ const index = nextArgs.findIndex(arg => definition.names.some(name => arg === name || arg.startsWith(`${name}=`)));
564
+ if (index < 0) continue;
565
+ const raw = nextArgs[index];
566
+ const inline = raw.includes('=');
567
+ const value = inline ? raw.slice(raw.indexOf('=') + 1) : nextArgs[index + 1];
568
+ if (!value || (!inline && value.startsWith('-'))) invalid = true;
569
+ else values[definition.key] = value.trim().toLowerCase();
570
+ nextArgs.splice(index, inline ? 1 : (value && !value.startsWith('-') ? 2 : 1));
571
+ }
572
+ const probe = resolveOptimizationPolicy({
573
+ env: {
574
+ ...(values.objective ? { TESTRONAUT_OPTIMIZE: values.objective } : {}),
575
+ ...(values.mode ? { TESTRONAUT_OPTIMIZATION_MODE: values.mode } : {}),
576
+ },
577
+ });
578
+ if (!probe.valid) invalid = true;
579
+ return { args: nextArgs, ...values, invalid };
580
+ }
581
+
509
582
  // Look for --model=<id> or --model <id>
510
583
  let modelOverride;
511
584
  const modelFlagIndex = args.findIndex(a => a === '--model' || a.startsWith('--model='));
@@ -532,6 +605,16 @@ if (providerResult.invalid) {
532
605
  console.warn('⚠️ Invalid --provider value. Provide a non-empty provider id.');
533
606
  }
534
607
  args = providerResult.args;
608
+
609
+ const optimizationArgs = parseOptimizationArgs(args);
610
+ args = optimizationArgs.args;
611
+ if (optimizationArgs.invalid) {
612
+ console.error(`❌ --optimize must be one of ${OPTIMIZATION_OBJECTIVES.join(', ')} and --optimization-mode must be one of ${OPTIMIZATION_MODES.join(', ')}.`);
613
+ process.exit(1);
614
+ }
615
+ if (optimizationArgs.objective) process.env.TESTRONAUT_OPTIMIZE = optimizationArgs.objective;
616
+ if (optimizationArgs.mode) process.env.TESTRONAUT_OPTIMIZATION_MODE = optimizationArgs.mode;
617
+ if (optimizationArgs.betaModelRouting) process.env.TESTRONAUT_BETA_MODEL_ROUTING = '1';
535
618
  const providerOverride = providerResult.provider;
536
619
  if (providerOverride) {
537
620
  process.env.TESTRONAUT_PROVIDER = providerOverride.trim();
@@ -754,6 +837,7 @@ Usage:
754
837
  ${cliName} <file> Run a .mission.js or .mission.ts file by name or path
755
838
  ${cliName} login Log in and store session token
756
839
  ${cliName} list List discovered missions and tags without running them
840
+ ${cliName} select Ask Jev which missions are relevant to the current git diff (dry-run only)
757
841
  ${cliName} config Show effective configuration and value sources
758
842
  ${cliName} upload [file] Upload the latest report or a selected report file/run ID
759
843
  ${cliName} serve Serve & open the most recent HTML report (read-only)
@@ -765,6 +849,10 @@ Options:
765
849
  --debug[=<bool>] Enable verbose debug logs (or set TESTRONAUT_DEBUG=1)
766
850
  --provider=<id> Override LLM provider (openai, gemini, or anthropic)
767
851
  --model=<id> Override the provider model for this run
852
+ --optimize=<objective> Optimization objective: off, cost, tokens, balanced, or speed
853
+ --optimization-mode=<m> Optimization control: off, shadow, or live
854
+ --guardrails[=<mode>] Opt in to Jev guardrails: live (default), shadow, or off
855
+ --beta-model-routing Enable beta model switching for cost, balanced, or speed
768
856
  --session=<mode> Browser auth reuse: isolated (default) or shared-auth
769
857
  -o, --options key=value Set run options, such as mfa=github-test-mfa or inbox=github-staging
770
858
  --dev Use the staging API base URL
@@ -778,6 +866,8 @@ Options:
778
866
  --no-upload-screenshots Upload report JSON without its screenshots
779
867
  --json Emit machine-readable command/run output
780
868
  --quiet Suppress informational logs
869
+ --base=<git-ref> Base ref for Jev suite selection (default: HEAD~1)
870
+ --changed-file=<path> Explicit changed file for Jev suite selection; repeatable
781
871
  --retry_limit=<n> Override agent turn retry limits (minimum 1, maximum 10)
782
872
  --tag=<tag> Run missions matching a tag; repeat for multiple tags
783
873
  --tags=<tag,...> Compact comma-list form (OR/any by default)
@@ -789,6 +879,7 @@ Examples:
789
879
  ${cliName}
790
880
  ${cliName} login
791
881
  ${cliName} list
882
+ ${cliName} select --base=origin/main
792
883
  ${cliName} upload
793
884
  ${cliName} serve
794
885
  ${cliName} --init
@@ -862,10 +953,91 @@ if (args.includes('serve') || args.includes('view')) {
862
953
 
863
954
  const { root: missionsRoot, files: discoveredMissions } = await discoverMissionFiles({ cwd: process.cwd() });
864
955
  const tagConfig = await loadConfig(process.cwd());
956
+ const optimizationPolicy = resolveOptimizationPolicy({ config: tagConfig });
957
+ if (!optimizationPolicy.valid) {
958
+ console.error('❌ Invalid optimization objective or mode in testronaut-config.json.');
959
+ process.exit(1);
960
+ }
961
+ applyOptimizationPolicy(optimizationPolicy, process.env, {
962
+ force: Boolean(optimizationArgs.objective || optimizationArgs.mode || optimizationArgs.betaModelRouting),
963
+ });
964
+ const jevPreflight = resolveJevShadowConfig();
965
+ if (optimizationPolicy.objective === 'tokens' && optimizationPolicy.mode !== 'off' && !jevPreflight.apiKey) {
966
+ console.warn('⚠️ Jev guardrails are enabled but no user-supplied key was found. Set TESTRONAUT_JEV_API_KEY; this run will fail open to the primary model.');
967
+ }
968
+ if (optimizationPolicy.routingRequested && !optimizationPolicy.betaModelRouting) {
969
+ console.warn('⚠️ Model routing is beta and remains disabled. Add --beta-model-routing or optimization.beta.modelRouting=true to enable it.');
970
+ }
865
971
  const outputDir = resolveReportDir(tagConfig);
866
972
  process.env.TESTRONAUT_OUTPUT_DIR = outputDir;
867
973
  process.env.TESTRONAUT_RUN_ID = runId;
868
974
 
975
+ if (args[0] === 'select') {
976
+ const baseArg = args.find(arg => arg.startsWith('--base='));
977
+ const base = baseArg ? baseArg.slice('--base='.length) : 'HEAD~1';
978
+ const explicitChangedFiles = args
979
+ .filter(arg => arg.startsWith('--changed-file='))
980
+ .map(arg => arg.slice('--changed-file='.length))
981
+ .filter(Boolean);
982
+ let changedFiles = explicitChangedFiles;
983
+ if (!changedFiles.length) {
984
+ try {
985
+ const outputs = [
986
+ execFileSync('git', ['diff', '--name-only', `${base}...HEAD`], { cwd: process.cwd(), encoding: 'utf8' }),
987
+ execFileSync('git', ['diff', '--name-only'], { cwd: process.cwd(), encoding: 'utf8' }),
988
+ execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: process.cwd(), encoding: 'utf8' }),
989
+ ];
990
+ changedFiles = [...new Set(outputs.flatMap(output => output.split(/\r?\n/).filter(Boolean)))].sort();
991
+ } catch (error) {
992
+ writeOutput({ mode: 'jev-suite-selection', status: 'error', error: `Could not resolve git diff from ${base}: ${error.message}` });
993
+ process.exitCode = 1;
994
+ return;
995
+ }
996
+ }
997
+
998
+ const missions = [];
999
+ for (const file of discoveredMissions) {
1000
+ try {
1001
+ const mission = await loadMissionModule(path.resolve(missionsRoot, file));
1002
+ missions.push({ file, tags: normalizeTags(mission.tags) });
1003
+ } catch (error) {
1004
+ missions.push({ file, tags: [], loadError: error.message });
1005
+ }
1006
+ }
1007
+ if (!missions.length) {
1008
+ writeOutput({ mode: 'jev-suite-selection', status: 'error', error: 'No missions were discovered.' });
1009
+ process.exitCode = 1;
1010
+ return;
1011
+ }
1012
+ const request = buildJevSuiteSelectionRequest({ changedFiles, missions });
1013
+ const config = { ...resolveJevShadowConfig(), enabled: true };
1014
+ const evaluation = await evaluateJevShadow(request.state, { config, questions: request.questions });
1015
+ const thresholdRaw = Number(process.env.TESTRONAUT_JEV_SELECTION_THRESHOLD);
1016
+ const threshold = Number.isFinite(thresholdRaw) && thresholdRaw >= 0 && thresholdRaw <= 1 ? thresholdRaw : 0.7;
1017
+ const selection = evaluation.status === 'ok'
1018
+ ? interpretJevSuiteSelection(evaluation.answers, request.catalog, threshold)
1019
+ : [];
1020
+ writeOutput({
1021
+ mode: 'jev-suite-selection',
1022
+ dryRun: true,
1023
+ base,
1024
+ changedFiles,
1025
+ threshold,
1026
+ evaluation: {
1027
+ status: evaluation.status,
1028
+ model: evaluation.model,
1029
+ usage: evaluation.usage,
1030
+ latencyMs: evaluation.latencyMs,
1031
+ reason: evaluation.reason,
1032
+ error: evaluation.error,
1033
+ },
1034
+ missions: selection,
1035
+ selected: selection.filter(mission => mission.selected).map(mission => mission.file),
1036
+ });
1037
+ if (evaluation.status !== 'ok') process.exitCode = 1;
1038
+ return;
1039
+ }
1040
+
869
1041
  if (args.length === 1 && args[0] === 'config') {
870
1042
  writeOutput(buildEffectiveConfig(tagConfig));
871
1043
  return;
@@ -879,14 +1051,14 @@ if (args.length === 1 && args[0] === 'list') {
879
1051
  const listedMissions = [];
880
1052
  const listTags = cliTags ?? normalizeTags(tagConfig?.tags, { allowUntagged: true });
881
1053
  const listTagMatch = normalizeTagMatch(cliTagMatch ?? tagConfig?.tagMatch);
882
- if (!jsonOutput) console.log(`Missions in ${path.relative(process.cwd(), missionsRoot) || '.'}:`);
1054
+ if (!jsonOutput) terminalUi.phase('preflight', `Missions in ${path.relative(process.cwd(), missionsRoot) || '.'}`);
883
1055
  for (const file of discoveredMissions) {
884
1056
  try {
885
1057
  const mission = await loadMissionModule(path.resolve(missionsRoot, file));
886
1058
  const tags = normalizeTags(mission.tags);
887
1059
  const selected = matchesTagFilter(tags, listTags, listTagMatch);
888
1060
  listedMissions.push({ file, tags, selected });
889
- if (!jsonOutput) console.log(` ${selected ? 'βœ“' : 'Β·'} ${file}${tags.length ? ` [${tags.join(', ')}]` : ''}`);
1061
+ if (!jsonOutput) terminalUi.mission(selected, file, tags);
890
1062
  } catch (error) {
891
1063
  console.log(` ${file} [could not load: ${error.message}]`);
892
1064
  process.exitCode = 1;
@@ -943,6 +1115,17 @@ if (dryRun) {
943
1115
  return;
944
1116
  }
945
1117
 
1118
+ if (!jsonOutput) {
1119
+ const previewModel = resolveProviderModel({ cwd: process.cwd() });
1120
+ terminalUi.banner({
1121
+ version: CLI_VERSION,
1122
+ provider: previewModel.provider,
1123
+ model: previewModel.model,
1124
+ session: sessionMode,
1125
+ });
1126
+ terminalUi.phase('preflight', `${explicitFiles ? args.length : discoveredMissions.length} mission file(s) queued`);
1127
+ }
1128
+
946
1129
  const runFile = async (filePath) => {
947
1130
  const modulePath = resolveMissionPath(filePath, { cwd: process.cwd(), missionsRoot });
948
1131
  try {
@@ -960,6 +1143,7 @@ const runFile = async (filePath) => {
960
1143
  }
961
1144
 
962
1145
  if (typeof missionsModule.executeMission === 'function') {
1146
+ if (!jsonOutput) terminalUi.phase('launch', path.relative(process.cwd(), modulePath) || filePath);
963
1147
  const result = await missionsModule.executeMission();
964
1148
  if (result == null) {
965
1149
  console.error(`❌ Mission did not return a result: ${filePath}`);
@@ -1018,19 +1202,26 @@ const flatMissions = allResults.flatMap(entry => {
1018
1202
  steps = mergeDuplicateTurns(fromJsonl);
1019
1203
  }
1020
1204
  }
1021
- return {
1205
+ const mission = {
1022
1206
  ...m,
1023
1207
  steps,
1024
1208
  file: entry.file,
1025
1209
  };
1210
+ const jevShadow = summarizeJevUsage(steps);
1211
+ return jevShadow.evaluations ? { ...mission, jevShadow } : mission;
1026
1212
  });
1027
1213
  });
1028
1214
 
1215
+ const allMissionSteps = flatMissions
1216
+ .flatMap(mission => Array.isArray(mission.steps) ? mission.steps : []);
1217
+ const jevShadowUsage = summarizeJevUsage(allMissionSteps);
1218
+
1029
1219
  // Read provider/model from config (allow env override)
1030
1220
  const { provider: llmProvider, model: llmModel } = resolveProviderModel({ cwd: process.cwd() });
1031
1221
 
1032
1222
 
1033
1223
  const report = {
1224
+ schemaVersion: 2,
1034
1225
  runId,
1035
1226
  cli: { version: CLI_VERSION },
1036
1227
  startTime: startTime.toISOString(),
@@ -1040,6 +1231,7 @@ const report = {
1040
1231
  model: llmModel,
1041
1232
  },
1042
1233
  session: { mode: sessionMode },
1234
+ optimization: optimizationPolicy,
1043
1235
  summary: {
1044
1236
  totalMissions: flatMissions.length,
1045
1237
  passed: flatMissions.filter(m => m.status === 'passed').length,
@@ -1047,14 +1239,51 @@ const report = {
1047
1239
  },
1048
1240
  missions: flatMissions
1049
1241
  };
1242
+ if (process.env.TESTRONAUT_EXPERIMENT_ID) {
1243
+ report.experiment = {
1244
+ id: process.env.TESTRONAUT_EXPERIMENT_ID,
1245
+ condition: process.env.TESTRONAUT_EXPERIMENT_CONDITION || null,
1246
+ block: Number(process.env.TESTRONAUT_EXPERIMENT_BLOCK) || null,
1247
+ unit: process.env.TESTRONAUT_EXPERIMENT_UNIT || null,
1248
+ selectedBy: process.env.TESTRONAUT_EXPERIMENT_SELECTED_BY || null,
1249
+ selectionScore: process.env.TESTRONAUT_EXPERIMENT_SELECTION_SCORE === ''
1250
+ ? null
1251
+ : Number(process.env.TESTRONAUT_EXPERIMENT_SELECTION_SCORE),
1252
+ };
1253
+ }
1254
+ if (jevShadowUsage.evaluations) {
1255
+ const gateMode = /^(1|true|yes|on)$/i.test(String(process.env.TESTRONAUT_JEV_GATE || ''));
1256
+ report.jevShadow = {
1257
+ mode: gateMode ? 'completion-gate' : 'shadow',
1258
+ ...jevShadowUsage,
1259
+ };
1260
+ const { strategies, ...guardrailUsage } = jevShadowUsage;
1261
+ report.jevGuardrails = {
1262
+ mode: gateMode ? 'live' : 'shadow',
1263
+ completionProbability: optimizationPolicy.completionProbability,
1264
+ failOpen: true,
1265
+ ...guardrailUsage,
1266
+ };
1267
+ if (optimizationPolicy.routingRequested) {
1268
+ report.modelRouting = {
1269
+ stability: 'beta',
1270
+ optedIn: optimizationPolicy.betaModelRouting,
1271
+ active: optimizationPolicy.routing,
1272
+ mode: optimizationPolicy.routing ? optimizationPolicy.mode : 'off',
1273
+ strategies,
1274
+ };
1275
+ }
1276
+ }
1050
1277
  report.tags = normalizeTags(flatMissions.flatMap(m =>
1051
1278
  m.submissionType === 'mission' ? (m.tags ?? []) : []
1052
1279
  ));
1053
1280
 
1054
1281
  fs.mkdirSync(outputDir, { recursive: true });
1055
1282
  fs.writeFileSync(path.join(outputDir, `${runId}.json`), JSON.stringify(report, null, 2));
1056
- generateHtmlReport(report, path.join(outputDir, `${runId}.html`));
1283
+ const htmlReportPath = path.join(outputDir, `${runId}.html`);
1284
+ generateHtmlReport(report, htmlReportPath);
1057
1285
  if (jsonOutput) writeOutput(report);
1286
+ else terminalUi.summary({ ...report.summary, reportPath: path.relative(process.cwd(), htmlReportPath) });
1058
1287
 
1059
1288
  try {
1060
1289
  if (!process.env.TN_KEEP_TMP && fs.existsSync(TMP_DIR)) {
@@ -0,0 +1,51 @@
1
+ const ANSI = {
2
+ reset: '\u001b[0m',
3
+ bold: '\u001b[1m',
4
+ dim: '\u001b[2m',
5
+ orange: '\u001b[38;5;208m',
6
+ cyan: '\u001b[38;5;81m',
7
+ teal: '\u001b[38;5;43m',
8
+ red: '\u001b[38;5;203m',
9
+ cream: '\u001b[38;5;230m',
10
+ };
11
+
12
+ export function shouldUseColor(stream = process.stdout, env = process.env) {
13
+ return Boolean(stream?.isTTY) && !('NO_COLOR' in env) && env.TERM !== 'dumb';
14
+ }
15
+
16
+ export function createTerminalUi({
17
+ stream = process.stdout,
18
+ color = shouldUseColor(stream),
19
+ quiet = false,
20
+ } = {}) {
21
+ const paint = (codes, value) => color ? `${codes}${value}${ANSI.reset}` : String(value);
22
+ const write = value => {
23
+ if (!quiet) stream.write(`${value}\n`);
24
+ };
25
+ const label = value => paint(`${ANSI.bold}${ANSI.orange}`, value.padEnd(10));
26
+
27
+ return {
28
+ banner({ version, provider, model, session }) {
29
+ write(paint(`${ANSI.bold}${ANSI.cream}`, 'πŸ§‘β€πŸš€ TESTRONAUT / MISSION RUNNER'));
30
+ write(paint(ANSI.dim, ` v${version} β€’ ${provider}/${model} β€’ ${session}`));
31
+ write(paint(ANSI.dim, ' ─────────────────────────────────────────'));
32
+ },
33
+ phase(name, message) {
34
+ write(`${label(name.toUpperCase())}${paint(ANSI.cyan, message)}`);
35
+ },
36
+ mission(selected, file, tags = []) {
37
+ const marker = selected ? paint(ANSI.teal, '●') : paint(ANSI.dim, 'β—‹');
38
+ const tagText = tags.length ? paint(ANSI.dim, ` ${tags.map(tag => `#${tag}`).join(' ')}`) : '';
39
+ write(` ${marker} ${file}${tagText}`);
40
+ },
41
+ summary({ totalMissions = 0, passed = 0, failed = 0, reportPath }) {
42
+ const status = failed === 0
43
+ ? paint(`${ANSI.bold}${ANSI.teal}`, 'MISSION COMPLETE')
44
+ : paint(`${ANSI.bold}${ANSI.red}`, 'MISSION NEEDS REVIEW');
45
+ write('');
46
+ write(`${label('COMPLETE')}${status}`);
47
+ write(` ${paint(ANSI.teal, `${passed} passed`)} ${paint(failed ? ANSI.red : ANSI.dim, `${failed} failed`)} ${paint(ANSI.dim, `${totalMissions} total`)}`);
48
+ if (reportPath) write(paint(ANSI.dim, ` Report: ${reportPath}`));
49
+ },
50
+ };
51
+ }
package/core/agent.js CHANGED
@@ -259,6 +259,7 @@ Email invitation and magic links:
259
259
  steps,
260
260
  stepsArchive,
261
261
  missionName,
262
+ goal: userContent,
262
263
  retryLimit,
263
264
  groundControl,
264
265
  resourceGuard: opts.resourceGuard,