explorbot 0.5.8 → 0.5.9

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 (62) hide show
  1. package/bin/explorbot-cli.ts +23 -3
  2. package/boat/prima/README.md +3 -0
  3. package/boat/prima/src/cli.ts +7 -1
  4. package/boat/prima/src/envelope.ts +18 -4
  5. package/boat/prima/src/help.ts +15 -4
  6. package/boat/prima/src/prima.ts +181 -25
  7. package/dist/bin/explorbot-cli.js +29 -3
  8. package/dist/boat/prima/src/cli.js +6 -1
  9. package/dist/boat/prima/src/envelope.js +16 -3
  10. package/dist/boat/prima/src/help.js +14 -4
  11. package/dist/boat/prima/src/prima.js +191 -27
  12. package/dist/package.json +1 -1
  13. package/dist/rules/navigator/verification-actions.md +5 -3
  14. package/dist/rules/planner/ignored-pages.md +6 -0
  15. package/dist/rules/quartermaster/ux/accessibility.md +8 -0
  16. package/dist/rules/quartermaster/ux/charts.md +6 -0
  17. package/dist/rules/quartermaster/ux/copy.md +9 -0
  18. package/dist/rules/quartermaster/ux/forms.md +7 -0
  19. package/dist/rules/quartermaster/ux/interaction.md +7 -0
  20. package/dist/rules/quartermaster/ux/motion.md +5 -0
  21. package/dist/rules/quartermaster/ux/navigation.md +7 -0
  22. package/dist/rules/quartermaster/ux/readability.md +6 -0
  23. package/dist/rules/quartermaster/ux/responsive.md +7 -0
  24. package/dist/src/ai/judge.d.ts +3 -1
  25. package/dist/src/ai/judge.js +13 -3
  26. package/dist/src/ai/navigator.js +3 -2
  27. package/dist/src/ai/pilot.js +10 -4
  28. package/dist/src/ai/planner.js +9 -1
  29. package/dist/src/ai/provider.d.ts +4 -0
  30. package/dist/src/ai/provider.js +27 -5
  31. package/dist/src/ai/quartermaster.d.ts +31 -3
  32. package/dist/src/ai/quartermaster.js +262 -17
  33. package/dist/src/ai/tester.js +6 -3
  34. package/dist/src/explorbot.d.ts +1 -1
  35. package/dist/src/explorbot.js +8 -13
  36. package/dist/src/explorer.js +1 -0
  37. package/dist/src/global-config.js +6 -2
  38. package/dist/src/utils/ux-probe.d.ts +25 -0
  39. package/dist/src/utils/ux-probe.js +155 -0
  40. package/package.json +1 -1
  41. package/rules/navigator/verification-actions.md +5 -3
  42. package/rules/planner/ignored-pages.md +6 -0
  43. package/rules/quartermaster/ux/accessibility.md +8 -0
  44. package/rules/quartermaster/ux/charts.md +6 -0
  45. package/rules/quartermaster/ux/copy.md +9 -0
  46. package/rules/quartermaster/ux/forms.md +7 -0
  47. package/rules/quartermaster/ux/interaction.md +7 -0
  48. package/rules/quartermaster/ux/motion.md +5 -0
  49. package/rules/quartermaster/ux/navigation.md +7 -0
  50. package/rules/quartermaster/ux/readability.md +6 -0
  51. package/rules/quartermaster/ux/responsive.md +7 -0
  52. package/src/ai/judge.ts +11 -3
  53. package/src/ai/navigator.ts +2 -2
  54. package/src/ai/pilot.ts +10 -4
  55. package/src/ai/planner.ts +10 -1
  56. package/src/ai/provider.ts +29 -6
  57. package/src/ai/quartermaster.ts +300 -18
  58. package/src/ai/tester.ts +5 -3
  59. package/src/explorbot.ts +9 -13
  60. package/src/explorer.ts +1 -0
  61. package/src/global-config.ts +5 -2
  62. package/src/utils/ux-probe.ts +163 -0
@@ -13,7 +13,7 @@ import { RecommendedModelsCommand } from '../src/commands/recommended-models-com
13
13
  import { App } from '../src/components/App.js';
14
14
  import { StatusPane } from '../src/components/StatusPane.js';
15
15
  import { decisionModelOption, knowledgeOption, wsOption } from '../src/commands/options/index.js';
16
- import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
16
+ import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS, outputPath } from '../src/config.js';
17
17
  import { ExplorBot, type ExplorBotOptions } from '../src/explorbot.js';
18
18
  import { remote } from '../src/remote.js';
19
19
  import { Stats } from '../src/stats.js';
@@ -123,6 +123,18 @@ async function startTUI(explorBot: ExplorBot): Promise<void> {
123
123
  });
124
124
  }
125
125
 
126
+ function printPageContext(explorBot?: ExplorBot): void {
127
+ const state = explorBot?.stateManager().getCurrentState();
128
+ if (!state) return;
129
+ const lines = [`Last page: ${state.fullUrl || state.url}`];
130
+ if (state.title) lines.push(`Title: ${state.title}`);
131
+ if (state.screenshotFile) lines.push(`Screenshot: file://${outputPath('states', state.screenshotFile)}`);
132
+ if (state.htmlFile) lines.push(`HTML: file://${outputPath('states', state.htmlFile)}`);
133
+ if (state.ariaSnapshotFile) lines.push(`ARIA: file://${outputPath('states', state.ariaSnapshotFile)}`);
134
+ if (state.ariaSnapshot) lines.push('', state.ariaSnapshot);
135
+ console.error(lines.join('\n'));
136
+ }
137
+
126
138
  async function showStatsAndExit(code: number): Promise<never> {
127
139
  if (remote.isAttached()) {
128
140
  await remote.close(code);
@@ -165,10 +177,11 @@ addCommonOptions(
165
177
  .option('--configure <spec>', 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"')
166
178
  .option('--dry-run', 'Mark picked tests as skipped without executing or generating new ones')
167
179
  ).action(async (explorePath, options) => {
180
+ let explorBot: ExplorBot | undefined;
168
181
  try {
169
182
  const urlFilter = new UrlFilter(options.urlFilter);
170
183
  const sitemapUrls = await readCliSitemap(explorePath, options.sitemap, urlFilter);
171
- const explorBot = new ExplorBot(buildExplorBotOptions(explorePath || sitemapUrls?.[0], options));
184
+ explorBot = new ExplorBot(buildExplorBotOptions(explorePath || sitemapUrls?.[0], options));
172
185
  await explorBot.start();
173
186
  if (explorePath) await explorBot.visit(explorePath);
174
187
  const { ExploreCommand } = await import('../src/commands/explore-command.js');
@@ -188,6 +201,7 @@ addCommonOptions(
188
201
  await showStatsAndExit(0);
189
202
  } catch (error) {
190
203
  console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
204
+ printPageContext(explorBot);
191
205
  await showStatsAndExit(1);
192
206
  }
193
207
  });
@@ -471,7 +485,13 @@ addCommonOptions(
471
485
  .join(' ');
472
486
  const { FreesailCommand } = await import('../src/commands/freesail-command.js');
473
487
  const cmd = new FreesailCommand(explorBot);
474
- await cmd.execute(args);
488
+ try {
489
+ await cmd.execute(args);
490
+ } catch (error) {
491
+ console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
492
+ printPageContext(explorBot);
493
+ await showStatsAndExit(1);
494
+ }
475
495
  });
476
496
 
477
497
  program
@@ -41,6 +41,7 @@ playwright-cli close
41
41
  | `verify <assertion>` | Assert a statement about the current page |
42
42
  | `ask <question>` | Answer a question about the current page |
43
43
  | `research` | Map the current page and return verified locators |
44
+ | `ux [focus]` | Review the area being worked on for UX issues at the current width, 768px and 375px |
44
45
  | `go <target>` | Navigate to a url, a path, or a page described in plain words |
45
46
  | `pw <fn>` | Run a Playwright function expression against the open page |
46
47
  | `status <hash>` | Show the artifacts and page detail recorded for an earlier command |
@@ -52,6 +53,8 @@ playwright-cli close
52
53
 
53
54
  `do` accounts for every instruction you gave: each is reported as ok, FAIL or ??. Nothing runs past the last instruction.
54
55
 
56
+ `ux` reviews one area, not the whole page. A named focus is shown first with at most one click, which may open a dialog, tab or section or go to another page; nothing is submitted, and when no click leads to it the command fails without reviewing. The area reviewed is the dialog or region open on the page, else the focus you named, else the area your session's commands on this page acted in, else the main content. It measures target sizes, spacing, overflow, text size, zoom and motion at each width, runs axe on the area, and asks the model to judge copy, forms and navigation against the rules in `rules/quartermaster/ux/`. Findings are ranked Critical to Low. It reports problems only: no replacement wording and no colors or styles, since the project owns those. Override a rule by placing a file of the same name in `./rules/quartermaster/ux/`, or add your own with `ai.agents.quartermaster.rules`.
57
+
55
58
  ## AI model
56
59
 
57
60
  Prima needs a model, and takes it from the environment. There is no `init` to run and nothing is written for you.
@@ -4,7 +4,7 @@ import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
4
4
  import { isVerboseMode, setQuietMode } from '../../../src/utils/logger.ts';
5
5
  import { clearActivityLine, trackActivityLine } from './activity-line.ts';
6
6
  import { type EnvelopeData, renderEnvelope } from './envelope.ts';
7
- import { askHelp, checkHelp, doHelp, helpContract, reportHelp, researchHelp, sessionHelp, statusHelp, verifyHelp } from './help.ts';
7
+ import { askHelp, checkHelp, doHelp, helpContract, reportHelp, researchHelp, sessionHelp, statusHelp, uxHelp, verifyHelp } from './help.ts';
8
8
  import { Prima, type PrimaOptions } from './prima.ts';
9
9
 
10
10
  let rootOptions: () => any = () => ({});
@@ -129,6 +129,12 @@ export function createPrimaCommands(name = 'prima'): Command {
129
129
  await runPrima(options, 'research', (prima) => prima.research({ data: options.data, deep: options.deep, fresh: options.fresh }));
130
130
  });
131
131
 
132
+ addBrowserOptions(cmd.command('ux [focus]').description('Review the area being worked on for UX issues, across breakpoints'))
133
+ .addHelpText('after', `\n${uxHelp}`)
134
+ .action(async (focus, options) => {
135
+ await runPrima(options, ['ux', focus].filter(Boolean).join(' '), (prima) => prima.ux(focus));
136
+ });
137
+
132
138
  addBrowserOptions(cmd.command('go <target>').description('Navigate to a url, a path, or a page described in plain words')).action(async (target, options) => {
133
139
  if (URL.canParse(target)) options.baseUrl = target;
134
140
  await runPrima(options, `go ${target}`, (prima) => prima.go(target));
@@ -29,12 +29,14 @@ export interface EnvelopeData {
29
29
  used?: string[];
30
30
  page: { url: string; previousUrl?: string; redirectedFrom?: string; title: string; state: string; visits: number };
31
31
  changes?: string | null;
32
- steps?: Array<{ label: string; ok: boolean; unconfirmed?: boolean; proof: string }>;
32
+ steps?: Array<{ label: string; ok: boolean; unconfirmed?: boolean; proof: string; state?: number }>;
33
+ states?: Array<{ url: string; aria: string }>;
33
34
  expectations?: SettledExpectation[];
34
35
  warning?: string;
35
36
  stepFiles?: string;
36
37
  value?: string;
37
38
  answer?: string;
39
+ pageIssues?: string[];
38
40
  research?: string;
39
41
  assertions?: Array<{ code: string; passed: boolean; proof: string[]; error?: string }>;
40
42
  failure?: { error: string; compactAria?: string };
@@ -44,7 +46,7 @@ export interface EnvelopeData {
44
46
  }
45
47
 
46
48
  export function renderEnvelope(data: EnvelopeData): string {
47
- const sections = [renderResult(data), renderPage(data), renderValue(data), renderChanges(data), renderSteps(data), renderExpectations(data), renderWarning(data), renderOutcome(data), ...renderFailure(data), renderInstance(data), renderArtifacts(data)];
49
+ const sections = [renderResult(data), renderPage(data), renderValue(data), renderChanges(data), renderSteps(data), renderStates(data), renderExpectations(data), renderWarning(data), renderOutcome(data), renderPageIssues(data), ...renderFailure(data), renderInstance(data), renderArtifacts(data)];
48
50
  return sections.filter((section) => section).join('\n\n');
49
51
  }
50
52
 
@@ -118,13 +120,20 @@ function renderSteps(data: EnvelopeData): string | null {
118
120
  let mark = 'FAIL';
119
121
  if (step.ok) mark = 'ok ';
120
122
  if (step.unconfirmed) mark = '?? ';
121
- lines.push(`${index + 1}. ${mark} ${step.label}`);
123
+ let label = step.label;
124
+ if (step.state) label += ` // state ${step.state}`;
125
+ lines.push(`${index + 1}. ${mark} ${label}`);
122
126
  for (const line of (step.proof || '').split('\n').filter(Boolean)) lines.push(` ${line}`);
123
127
  });
124
128
  if (data.stepFiles) lines.push('', `page after each step: ${data.stepFiles}/<n>-<step>.{${Object.values(STEP_FILES).join(',')}}`);
125
129
  return section('Steps', lines.join('\n'));
126
130
  }
127
131
 
132
+ function renderStates(data: EnvelopeData): string | null {
133
+ if (!data.states?.length) return null;
134
+ return section('States', data.states.map((state, index) => `${index + 1}. ${state.url}\n aria: ${state.aria}`).join('\n'));
135
+ }
136
+
128
137
  function renderExpectations(data: EnvelopeData): string | null {
129
138
  if (!data.expectations?.length) return null;
130
139
 
@@ -132,7 +141,7 @@ function renderExpectations(data: EnvelopeData): string | null {
132
141
  data.expectations.forEach((expectation, index) => {
133
142
  lines.push(`${index + 1}. ${EXPECTATION_LABELS[expectation.status]} ${expectation.text}`);
134
143
  if (expectation.resolution) lines.push(` resolved: ${expectation.resolution}`);
135
- if (!expectation.contradicted && !expectation.failed && !expectation.resolution) return;
144
+ if (expectation.status === 'passed' && !expectation.resolution) return;
136
145
  for (const line of (expectation.evidence || '').split('\n').filter(Boolean)) lines.push(` ${line}`);
137
146
  });
138
147
  return section('Expected outcomes', lines.join('\n'));
@@ -168,6 +177,11 @@ function renderOutcome(data: EnvelopeData): string | null {
168
177
  return section('Assertions', lines.join('\n'));
169
178
  }
170
179
 
180
+ function renderPageIssues(data: EnvelopeData): string | null {
181
+ if (!data.pageIssues?.length) return null;
182
+ return section('Page issues', ['Noticed on the page while running, unrelated to the steps or the outcome:', ...data.pageIssues.map((issue) => `- ${issue}`)].join('\n'));
183
+ }
184
+
171
185
  function renderFailure(data: EnvelopeData): Array<string | null> {
172
186
  if (!data.failure) return [];
173
187
  return [section('Failure', `error: ${data.failure.error}`), renderCompactAria(data.failure.compactAria)];
@@ -25,15 +25,17 @@ export const checkHelp = dedent`
25
25
  --expected one required outcome, repeatable for several. Without it the
26
26
  scenario text is the outcome. Each returns under
27
27
  ### Expected outcomes as PASSED, FAILED, CONTRADICTION or not verified.
28
- "not verified" means never checked, not false.
28
+ "not verified" means never checked, not false; the line under it names
29
+ what kept the run from checking it.
29
30
  Proof is a full-page screenshot: what a user sees counts, the log only shows actions.
30
31
  When screenshot and log disagree, the page structure breaks the tie: the outcome
31
32
  becomes PASSED or FAILED with a "resolved:" line naming the side that was wrong.
32
33
  CONTRADICTION remains when it cannot be broken, or when the page holds something
33
34
  it does not display; judge the html, aria and screenshot under ### Artifacts yourself.
34
- ok is false when any outcome FAILED or CONTRADICTED, or the run could not finish,
35
- reported as such rather than as an app failure.
36
- Side issues found on the way go under ### Answer, not as step failures.
35
+ ok is false when any outcome FAILED or CONTRADICTED, when none was verified, or when
36
+ the run could not finish, reported as such rather than as an app failure.
37
+ ### Steps lists only the steps that failed.
38
+ Side issues found on the way go under ### Page issues, not as step failures.
37
39
  `;
38
40
 
39
41
  export const doHelp = dedent`
@@ -56,6 +58,15 @@ export const researchHelp = dedent`
56
58
  The map is saved per page state and joins later commands there, so one research run pays for all that follow it.
57
59
  `;
58
60
 
61
+ export const uxHelp = dedent`
62
+ Reviews one area, not the page. A named focus is shown first with at most one click,
63
+ which may open a dialog, tab or section or go to another page; nothing is submitted. The area reviewed is the dialog or region open on the page,
64
+ else the named focus, else where the session's commands on this page acted, else main.
65
+ Measures it at the current width, 768px and 375px, then restores the viewport.
66
+ Findings come under ### Answer by priority; the review reports problems and never
67
+ proposes wording or visual style. ok stays true whatever it finds.
68
+ `;
69
+
59
70
  export const statusHelp = dedent`
60
71
  Reads recorded files, so it needs no browser and outlives the session.
61
72
  The hash is matched across all recorded sites. ### Artifacts lists every kept
@@ -20,7 +20,7 @@ import { findSiteWith, listSites } from '../../../src/global-config.ts';
20
20
  import { Reporter } from '../../../src/reporter.ts';
21
21
  import type { WebPageState } from '../../../src/state-manager.ts';
22
22
  import { Stats } from '../../../src/stats.ts';
23
- import { Task, Test, TestResult } from '../../../src/test-plan.ts';
23
+ import { Task, Test } from '../../../src/test-plan.ts';
24
24
  import { ariaRefSelector, ariaRefSnapshot, describeRef, parseAriaRefs, refIsGone } from '../../../src/utils/aria-ref.ts';
25
25
  import { compactAriaSnapshot } from '../../../src/utils/aria.ts';
26
26
  import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
@@ -37,6 +37,7 @@ const ITERATIONS_PER_INSTRUCTION = 2;
37
37
  const MAX_INSTRUCTION_ITERATIONS = 24;
38
38
  const CONTEXT_HTML_CAP = 6000;
39
39
  const MAX_TOOL_ROUNDTRIPS = 5;
40
+ const UX_SESSION_COMMANDS = 8;
40
41
  const AI_AGENT_NAME = 'prima';
41
42
  const CONNECT_TIMEOUT = 3000;
42
43
  const requireLib = createRequire(import.meta.url);
@@ -197,11 +198,13 @@ export class Prima {
197
198
  const unreported = this.openInstructions(ledger);
198
199
  if (!unreported || nudged) break;
199
200
  nudged = true;
201
+ let reminder = 'Do not act again on anything you have already carried out.';
202
+ if (!trace.length) reminder = 'Nothing has been carried out yet: text in a reply is never executed, only tool calls are. Act with tools.';
200
203
  conversation.addUserText(dedent`
201
204
  These instructions are still unreported:
202
205
  ${unreported}
203
206
 
204
- Report each one with completed() or blocked(). Do not act again on anything you have already carried out.
207
+ Report each one with completed() or blocked(). ${reminder}
205
208
  `);
206
209
  continue;
207
210
  }
@@ -257,7 +260,8 @@ export class Prima {
257
260
  }
258
261
 
259
262
  if (!trace.length && unfinished.length === ledger.length) {
260
- const reason = ['No action was performed for these instructions on the current page.', narration].filter(Boolean).join(' ');
263
+ let reason = 'No action was performed for these instructions on the current page.';
264
+ if (narration) reason += `\nThe model replied with text instead of calling a tool:\n${narration}`;
261
265
  return this.failureEnvelope(command, reason, previousState);
262
266
  }
263
267
 
@@ -382,22 +386,65 @@ export class Prima {
382
386
  const guard = await this.aiGuard(command);
383
387
  if (guard) return guard;
384
388
 
385
- const previousState = await this.baselineState();
386
389
  const outcomes = expected.length ? expected : [scenario];
390
+ const preparation = await this.preparationSteps(scenario, outcomes);
391
+ let prepared: EnvelopeData | null = null;
392
+ if (preparation.length) {
393
+ prepared = await this.do(preparation, command);
394
+ if (!prepared.ok) {
395
+ prepared.failure = { error: `the check never ran, because preparing the page for it failed:\n${prepared.failure?.error || ''}` };
396
+ return prepared;
397
+ }
398
+ }
399
+
400
+ const previousState = await this.baselineState();
387
401
  const test = new Test(scenario, 'normal', outcomes, previousState?.url || this.options.url || '');
388
402
  const tester = this.bot.agentTester();
389
403
 
390
- await tester.test(test, { startOnCurrentPage: true });
404
+ const states: Array<{ url: string; aria: string }> = [];
405
+ const visited = new Map<string, number>();
406
+ const timeline: Array<{ time: number; state: number }> = [];
407
+ const trackState = (state?: WebPageState | null) => {
408
+ if (!state?.hash) return;
409
+ let number = visited.get(state.hash);
410
+ if (!number) {
411
+ number = visited.size + 1;
412
+ visited.set(state.hash, number);
413
+ const aria = path.join(this.statusDir(), `state-${number}.aria.yml`);
414
+ mkdirSync(this.statusDir(), { recursive: true });
415
+ writeFileSync(aria, state.ariaSnapshot || '', 'utf-8');
416
+ states.push({ url: state.url, aria });
417
+ }
418
+ timeline.push({ time: performance.now(), state: number });
419
+ };
420
+ trackState(previousState);
421
+ const stopTracking = this.bot.stateManager().onStateChange((transition) => trackState(transition.toState));
422
+
423
+ await tester.test(test, { startOnCurrentPage: true }).finally(stopTracking);
391
424
 
392
425
  const notes = Object.values(test.notes || {}) as Array<{ message: string; status?: string; log?: string; observation?: boolean }>;
393
426
  const result = await this.capturedResult(this.bot.stateManager().getCurrentState(), { screenshot: this.visionEnabled() });
394
427
  const envelope = await this.reportEnvelope(command, result, previousState, {});
395
- const recorded = notes.filter((note) => !note.observation && !outcomes.includes(note.message));
396
- const failed = recorded.filter((note) => note.status === TestResult.FAILED);
397
- envelope.steps = failed.map((note) => ({ label: note.message, ok: false, proof: note.log || '' }));
398
-
399
- const routine = recorded.length - failed.length;
400
- if (routine) envelope.steps.push({ label: `${routine} further ${pluralize(routine, 'step')} ran without failing — prima status ${envelope.status} for the full log`, ok: true, proof: '' });
428
+ const repeats = new Map<string, { message: string; ok: boolean; count: number; proof: string; state?: number }>();
429
+ for (const [startedAt, step] of Object.entries(test.steps || {})) {
430
+ const ok = step.status !== 'failed';
431
+ const state = timeline.findLast((entry) => entry.time <= Number.parseFloat(startedAt))?.state;
432
+ const key = `${ok} ${state} ${step.text}`;
433
+ let proof = '';
434
+ if (!ok) proof = step.error || '';
435
+ repeats.set(key, { message: step.text, ok, count: (repeats.get(key)?.count || 0) + 1, proof, state });
436
+ }
437
+ const preparedSteps = (prepared?.steps || []).map((step) => ({ ...step, label: `prepare: ${step.label}` }));
438
+ envelope.steps = [
439
+ ...preparedSteps,
440
+ ...[...repeats.values()].map(({ message, ok, count, proof, state }) => {
441
+ let label = message;
442
+ if (count > 1) label += ` (×${count})`;
443
+ return { label, ok, proof, state };
444
+ }),
445
+ ];
446
+ if (prepared?.stepFiles) envelope.stepFiles = prepared.stepFiles;
447
+ if (states.length) envelope.states = states;
401
448
 
402
449
  envelope.expectations = await this.bot.agentPilot().settleExpectations(test, result);
403
450
 
@@ -407,9 +454,12 @@ export class Prima {
407
454
 
408
455
  const unreached = envelope.expectations.filter((expectation) => expectation.failed);
409
456
  const contradicted = envelope.expectations.filter((expectation) => expectation.contradicted);
410
- envelope.ok = !unreached.length && !contradicted.length;
457
+ const unverified = envelope.expectations.filter((expectation) => expectation.status === 'unverified');
458
+ const nothingVerified = unverified.length === envelope.expectations.length;
459
+ envelope.ok = !unreached.length && !contradicted.length && !nothingVerified;
411
460
 
412
461
  const problems = [...unreached.map((expectation) => `not reached: ${expectation.text}`), ...contradicted.map((expectation) => `the picture and the run disagree about: ${expectation.text}`)];
462
+ if (nothingVerified) problems.push(...unverified.map((expectation) => `not verified: ${expectation.text}`));
413
463
  if (problems.length) envelope.failure = { error: problems.join('\n') };
414
464
 
415
465
  if (!test.hasFinished || test.isSkipped) {
@@ -418,7 +468,7 @@ export class Prima {
418
468
  }
419
469
 
420
470
  const observations = notes.filter((note) => note.observation).map((note) => note.message);
421
- if (observations.length) envelope.answer = ['Page problems noticed while running, not step failures:', ...observations.map((line) => `- ${line}`)].join('\n');
471
+ if (observations.length) envelope.pageIssues = observations;
422
472
  return envelope;
423
473
  }
424
474
 
@@ -466,6 +516,29 @@ export class Prima {
466
516
  return this.reportEnvelope(command, result, previousState, { research: dropVolatileColumns(uiMap) });
467
517
  }
468
518
 
519
+ async ux(focus?: string): Promise<EnvelopeData> {
520
+ const command = ['ux', focus].filter(Boolean).join(' ');
521
+ const guard = await this.aiGuard(command);
522
+ if (guard) return guard;
523
+
524
+ const previousState = await this.baselineState();
525
+ let clicked: string[] = [];
526
+ if (focus) {
527
+ const shown = await this.showFocus(focus, previousState);
528
+ if (!shown) return this.failureEnvelope(command, `Could not show "${focus}": it is not on this page and no single click from here leads to it, so no review was made.`, previousState);
529
+ clicked = shown;
530
+ }
531
+
532
+ const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
533
+ let auditFocus = focus;
534
+ if (result.overlay.isOpen && result.overlay.describe() !== previousState?.overlay?.describe()) auditFocus = undefined;
535
+ const answer = await this.bot.agentQuartermaster().auditUx(result, { focus: auditFocus, commands: this.sessionCommandsOn(result.url) });
536
+ const envelope = await this.reportEnvelope(command, result, previousState, { answer });
537
+ if (clicked.length) envelope.steps = clicked.map((code) => ({ label: code, ok: true, proof: '' }));
538
+ if (!this.visionEnabled()) envelope.warning = 'No screenshot backed this review, so visual checks were skipped. Set ai.visionModel to include them.';
539
+ return envelope;
540
+ }
541
+
469
542
  async go(target: string): Promise<EnvelopeData> {
470
543
  const command = `go ${target}`;
471
544
  const code = `I.amOnPage('${target}')`;
@@ -487,7 +560,7 @@ export class Prima {
487
560
  let arrived = true;
488
561
 
489
562
  try {
490
- arrived = await this.bot.agentNavigator().visit(target, { recover: !isUrl });
563
+ arrived = await this.bot.agentNavigator().visit(target, { recover: !this.aiUnavailable() });
491
564
  } catch (error) {
492
565
  navigationError = error;
493
566
  }
@@ -498,7 +571,11 @@ export class Prima {
498
571
  if (isUrl) used.push(code);
499
572
  const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
500
573
  const envelope = await this.successEnvelope(command, used, result, previousState);
501
- if (!arrived) envelope.page.redirectedFrom = target;
574
+ if (arrived) return envelope;
575
+
576
+ envelope.page.redirectedFrom = target;
577
+ envelope.ok = false;
578
+ envelope.failure = { error: `${target} was not reached: the page redirected to ${result.url} and no AI model is available to recover (log in, follow knowledge)` };
502
579
  return envelope;
503
580
  }
504
581
 
@@ -516,7 +593,7 @@ export class Prima {
516
593
  const config = await this.loadConfig();
517
594
  const parser = ConfigParser.getInstance();
518
595
 
519
- return ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json });
596
+ return ConfigCommand.render(config, { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot(), json });
520
597
  }
521
598
 
522
599
  record(envelope: EnvelopeData, durationMs: number): void {
@@ -870,6 +947,42 @@ export class Prima {
870
947
  });
871
948
  }
872
949
 
950
+ private async preparationSteps(scenario: string, outcomes: string[]): Promise<string[]> {
951
+ const result = await this.capturedResult(await this.baselineState());
952
+ const page = compactAriaSnapshot(result.ariaSnapshot, true).slice(0, JUDGE_PAGE_CAP);
953
+
954
+ const judge = this.bot.judge?.();
955
+ if (judge) {
956
+ const question = 'Before this scenario can start, the current page needs actions the scenario does not perform itself: reaching the page where it starts, or setting up a precondition it relies on.';
957
+ const needed = await judge.decide(question, null, { scenario, outcomes, url: result.url, page });
958
+ if (needed.rejected) return [];
959
+ }
960
+
961
+ const prompt = dedent`
962
+ <scenario>
963
+ ${scenario}
964
+ </scenario>
965
+
966
+ <expected_outcomes>
967
+ ${outcomes.map((outcome) => `- ${outcome}`).join('\n')}
968
+ </expected_outcomes>
969
+
970
+ <page>
971
+ url: ${result.url}
972
+ ${page}
973
+ </page>
974
+
975
+ A check of this scenario runs next, starting from the current page. Decide whether the page must be prepared first.
976
+ Preparation is only what the scenario assumes but does not do: reaching the page where it starts when this is a different one,
977
+ or setting up a precondition it relies on. Never include the actions the scenario describes — the check performs and judges those.
978
+ List the preparation as short imperative instructions, in order. Return an empty list when the scenario can start here.
979
+ `;
980
+ const schema = z.object({ steps: z.array(z.string()).describe('preparation instructions, in order; empty when none are needed') });
981
+ const provider = this.bot.getProvider();
982
+ const response = await provider.generateObject([{ role: 'user', content: prompt }], schema, provider.getAgenticModel?.(AI_AGENT_NAME), { agentName: AI_AGENT_NAME }).catch(() => null);
983
+ return response?.object?.steps || [];
984
+ }
985
+
873
986
  private async pageContext(result: ActionResult): Promise<string> {
874
987
  const experience = this.bot.experienceTracker?.()?.renderExperienceTocFor?.(result) || '';
875
988
  const map = getPreviousResearch(result.baseHash);
@@ -922,13 +1035,8 @@ export class Prima {
922
1035
  const pick = await judge.decide(`Which listed control leads to: ${target}`, [...labels, UNDECIDED], { target });
923
1036
  if (!pick.value) return null;
924
1037
 
925
- const ref = refs[labels.indexOf(pick.value)].ref;
926
- const explorer = this.bot.getExplorer();
927
- if (await refIsGone(explorer, ref)) return null;
928
-
929
- const named = await describeRef(explorer, ref);
930
- const run = `I.usePlaywrightTo(${JSON.stringify(`go to ${target}`)}, async ({ page }) => page.locator(${JSON.stringify(ariaRefSelector(ref))}).click())`;
931
- if (!(await explorer.action().attempt(run, `go ${target}`))) return null;
1038
+ const code = await this.clickRef(refs[labels.indexOf(pick.value)].ref, `go to ${target}`);
1039
+ if (!code) return null;
932
1040
 
933
1041
  const newState = this.bot.stateManager().getCurrentState();
934
1042
  if (!newState) return null;
@@ -938,11 +1046,59 @@ export class Prima {
938
1046
  const arrived = await judge.decide(`The page now shows: ${target}`, null, { target, page });
939
1047
  if (arrived.rejected) return null;
940
1048
 
941
- let code = run;
942
- if (named) code = `I.click(${JSON.stringify(named)})`;
943
1049
  return this.successEnvelope(`go ${target}`, [code], await this.capturedResult(newState), previousState);
944
1050
  }
945
1051
 
1052
+ private async showFocus(focus: string, state: WebPageState | null): Promise<string[] | null> {
1053
+ if (!state) return null;
1054
+ const snapshot = (await this.refAriaSnapshot(ActionResult.fromState(state))) || '';
1055
+ const refs = parseAriaRefs(snapshot).filter((entry) => entry.name);
1056
+ const schema = z.object({
1057
+ shown: z.boolean().describe('whether the page already shows the area'),
1058
+ ref: z.enum(['none', ...refs.map((entry) => entry.ref)]).describe('when it is not shown, the one control whose click leads to it, or none'),
1059
+ });
1060
+ const prompt = dedent`
1061
+ A UX review needs this area on screen: ${focus}
1062
+ Say whether the page already shows it. If not, pick the one control whose single click opens, reveals or navigates to it.
1063
+ Its label may differ from how the area was named: judge by what the control leads to.
1064
+ Answer none when no control plausibly leads there.
1065
+
1066
+ <page_aria>
1067
+ ${snapshot.slice(0, JUDGE_PAGE_CAP)}
1068
+ </page_aria>
1069
+ `;
1070
+ const response = await this.bot
1071
+ .getProvider()
1072
+ .generateObject([{ role: 'user', content: prompt }], schema, undefined, { agentName: AI_AGENT_NAME })
1073
+ .catch(() => null);
1074
+ if (response?.object?.shown) return [];
1075
+ const ref = response?.object?.ref;
1076
+ if (!ref || ref === 'none') return null;
1077
+ const code = await this.clickRef(ref, `show ${focus}`);
1078
+ if (!code) return null;
1079
+ return [code];
1080
+ }
1081
+
1082
+ private async clickRef(ref: string, purpose: string): Promise<string | null> {
1083
+ const explorer = this.bot.getExplorer();
1084
+ if (await refIsGone(explorer, ref)) return null;
1085
+
1086
+ const named = await describeRef(explorer, ref);
1087
+ const run = `I.usePlaywrightTo(${JSON.stringify(purpose)}, async ({ page }) => page.locator(${JSON.stringify(ariaRefSelector(ref))}).click())`;
1088
+ if (!(await explorer.action().attempt(run, purpose))) return null;
1089
+ if (named) return `I.click(${JSON.stringify(named)})`;
1090
+ return run;
1091
+ }
1092
+
1093
+ private sessionCommandsOn(url: string): string[] {
1094
+ if (!this.session) return [];
1095
+ const { tests } = readSession(sessionFile(this.session.key));
1096
+ return tests
1097
+ .filter((test) => test.description?.split('\n')[0] === `page: ${url}` && !test.title.startsWith('ux'))
1098
+ .map((test) => test.title)
1099
+ .slice(-UX_SESSION_COMMANDS);
1100
+ }
1101
+
946
1102
  private executedCodes(code: unknown): string[] {
947
1103
  if (typeof code !== 'string') return [];
948
1104
  return code
@@ -13,7 +13,7 @@ import { RecommendedModelsCommand } from '../src/commands/recommended-models-com
13
13
  import { App } from '../src/components/App.js';
14
14
  import { StatusPane } from '../src/components/StatusPane.js';
15
15
  import { decisionModelOption, knowledgeOption, wsOption } from '../src/commands/options/index.js';
16
- import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
16
+ import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS, outputPath } from '../src/config.js';
17
17
  import { ExplorBot } from '../src/explorbot.js';
18
18
  import { remote } from '../src/remote.js';
19
19
  import { Stats } from '../src/stats.js';
@@ -94,6 +94,23 @@ async function startTUI(explorBot) {
94
94
  await cleanup();
95
95
  });
96
96
  }
97
+ function printPageContext(explorBot) {
98
+ const state = explorBot?.stateManager().getCurrentState();
99
+ if (!state)
100
+ return;
101
+ const lines = [`Last page: ${state.fullUrl || state.url}`];
102
+ if (state.title)
103
+ lines.push(`Title: ${state.title}`);
104
+ if (state.screenshotFile)
105
+ lines.push(`Screenshot: file://${outputPath('states', state.screenshotFile)}`);
106
+ if (state.htmlFile)
107
+ lines.push(`HTML: file://${outputPath('states', state.htmlFile)}`);
108
+ if (state.ariaSnapshotFile)
109
+ lines.push(`ARIA: file://${outputPath('states', state.ariaSnapshotFile)}`);
110
+ if (state.ariaSnapshot)
111
+ lines.push('', state.ariaSnapshot);
112
+ console.error(lines.join('\n'));
113
+ }
97
114
  async function showStatsAndExit(code) {
98
115
  if (remote.isAttached()) {
99
116
  await remote.close(code);
@@ -130,10 +147,11 @@ addCommonOptions(program
130
147
  .option('--focus <feature>', 'Focus area for exploration')
131
148
  .option('--configure <spec>', 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"')
132
149
  .option('--dry-run', 'Mark picked tests as skipped without executing or generating new ones')).action(async (explorePath, options) => {
150
+ let explorBot;
133
151
  try {
134
152
  const urlFilter = new UrlFilter(options.urlFilter);
135
153
  const sitemapUrls = await readCliSitemap(explorePath, options.sitemap, urlFilter);
136
- const explorBot = new ExplorBot(buildExplorBotOptions(explorePath || sitemapUrls?.[0], options));
154
+ explorBot = new ExplorBot(buildExplorBotOptions(explorePath || sitemapUrls?.[0], options));
137
155
  await explorBot.start();
138
156
  if (explorePath)
139
157
  await explorBot.visit(explorePath);
@@ -162,6 +180,7 @@ addCommonOptions(program
162
180
  }
163
181
  catch (error) {
164
182
  console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
183
+ printPageContext(explorBot);
165
184
  await showStatsAndExit(1);
166
185
  }
167
186
  });
@@ -423,7 +442,14 @@ addCommonOptions(program
423
442
  .join(' ');
424
443
  const { FreesailCommand } = await import('../src/commands/freesail-command.js');
425
444
  const cmd = new FreesailCommand(explorBot);
426
- await cmd.execute(args);
445
+ try {
446
+ await cmd.execute(args);
447
+ }
448
+ catch (error) {
449
+ console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
450
+ printPageContext(explorBot);
451
+ await showStatsAndExit(1);
452
+ }
427
453
  });
428
454
  program
429
455
  .command('config [url]')
@@ -4,7 +4,7 @@ import { browserErrorMessage } from "../../../src/utils/browser-errors.js";
4
4
  import { isVerboseMode, setQuietMode } from "../../../src/utils/logger.js";
5
5
  import { clearActivityLine, trackActivityLine } from "./activity-line.js";
6
6
  import { renderEnvelope } from "./envelope.js";
7
- import { askHelp, checkHelp, doHelp, helpContract, reportHelp, researchHelp, sessionHelp, statusHelp, verifyHelp } from "./help.js";
7
+ import { askHelp, checkHelp, doHelp, helpContract, reportHelp, researchHelp, sessionHelp, statusHelp, uxHelp, verifyHelp } from "./help.js";
8
8
  import { Prima } from "./prima.js";
9
9
  let rootOptions = () => ({});
10
10
  function buildOptions(subcommand) {
@@ -120,6 +120,11 @@ export function createPrimaCommands(name = 'prima') {
120
120
  .action(async (options) => {
121
121
  await runPrima(options, 'research', (prima) => prima.research({ data: options.data, deep: options.deep, fresh: options.fresh }));
122
122
  });
123
+ addBrowserOptions(cmd.command('ux [focus]').description('Review the area being worked on for UX issues, across breakpoints'))
124
+ .addHelpText('after', `\n${uxHelp}`)
125
+ .action(async (focus, options) => {
126
+ await runPrima(options, ['ux', focus].filter(Boolean).join(' '), (prima) => prima.ux(focus));
127
+ });
123
128
  addBrowserOptions(cmd.command('go <target>').description('Navigate to a url, a path, or a page described in plain words')).action(async (target, options) => {
124
129
  if (URL.canParse(target))
125
130
  options.baseUrl = target;