explorbot 0.4.4 → 0.4.6

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 (86) hide show
  1. package/boat/api-tester/src/apibot.ts +18 -2
  2. package/boat/api-tester/src/cli.ts +85 -274
  3. package/boat/api-tester/src/commands/api-command.ts +10 -0
  4. package/boat/api-tester/src/commands/explore-command.ts +52 -0
  5. package/boat/api-tester/src/commands/init-command.ts +119 -0
  6. package/boat/api-tester/src/commands/know-command.ts +44 -0
  7. package/boat/api-tester/src/commands/plan-command.ts +42 -0
  8. package/boat/api-tester/src/commands/test-command.ts +54 -0
  9. package/boat/prima/src/prima.ts +8 -3
  10. package/dist/boat/api-tester/src/apibot.js +14 -1
  11. package/dist/boat/api-tester/src/cli.js +87 -243
  12. package/dist/boat/api-tester/src/commands/api-command.js +7 -0
  13. package/dist/boat/api-tester/src/commands/explore-command.js +41 -0
  14. package/dist/boat/api-tester/src/commands/init-command.js +88 -0
  15. package/dist/boat/api-tester/src/commands/know-command.js +39 -0
  16. package/dist/boat/api-tester/src/commands/plan-command.js +37 -0
  17. package/dist/boat/api-tester/src/commands/test-command.js +45 -0
  18. package/dist/boat/prima/src/prima.js +10 -3
  19. package/dist/package.json +4 -4
  20. package/dist/src/ai/fisherman/tools.js +7 -1
  21. package/dist/src/ai/fisherman.js +2 -1
  22. package/dist/src/ai/pilot.d.ts +0 -1
  23. package/dist/src/ai/pilot.js +8 -24
  24. package/dist/src/ai/planner.d.ts +4 -0
  25. package/dist/src/ai/planner.js +28 -0
  26. package/dist/src/ai/provider.js +3 -1
  27. package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
  28. package/dist/src/ai/researcher/deep-analysis.js +14 -6
  29. package/dist/src/ai/rules.js +8 -7
  30. package/dist/src/ai/scout/tools.d.ts +17 -0
  31. package/dist/src/ai/scout/tools.js +130 -0
  32. package/dist/src/ai/scout.d.ts +21 -0
  33. package/dist/src/ai/scout.js +150 -0
  34. package/dist/src/ai/tools.d.ts +1 -1
  35. package/dist/src/ai/tools.js +62 -31
  36. package/dist/src/api/spec-reader.d.ts +1 -0
  37. package/dist/src/api/spec-reader.js +93 -1
  38. package/dist/src/application-spec.d.ts +3 -0
  39. package/dist/src/application-spec.js +21 -5
  40. package/dist/src/commands/base-command.d.ts +3 -3
  41. package/dist/src/commands/init-command.d.ts +3 -0
  42. package/dist/src/commands/init-command.js +6 -3
  43. package/dist/src/config.d.ts +6 -1
  44. package/dist/src/explorbot.d.ts +3 -0
  45. package/dist/src/explorbot.js +33 -0
  46. package/dist/src/explorer.d.ts +1 -1
  47. package/dist/src/explorer.js +1 -1
  48. package/dist/src/knowledge-tracker.d.ts +1 -0
  49. package/dist/src/knowledge-tracker.js +3 -0
  50. package/dist/src/utils/aria-ref.d.ts +16 -0
  51. package/dist/src/utils/aria-ref.js +47 -0
  52. package/dist/src/utils/aria.js +3 -3
  53. package/dist/src/utils/html-diff.js +4 -1
  54. package/dist/src/utils/web-annotate.js +3 -15
  55. package/dist/src/utils/web-element.d.ts +0 -2
  56. package/dist/src/utils/web-element.js +0 -8
  57. package/docs/api-testing/basics.md +26 -2
  58. package/docs/reference/configuration.md +28 -1
  59. package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +317 -0
  60. package/docs/web-testing/agents.md +9 -1
  61. package/docs/web-testing/planner.md +5 -0
  62. package/docs/workflow/application-spec.md +4 -0
  63. package/package.json +4 -4
  64. package/src/ai/fisherman/tools.ts +8 -1
  65. package/src/ai/fisherman.ts +2 -1
  66. package/src/ai/pilot.ts +8 -25
  67. package/src/ai/planner.ts +33 -0
  68. package/src/ai/provider.ts +2 -1
  69. package/src/ai/researcher/deep-analysis.ts +13 -6
  70. package/src/ai/rules.ts +8 -7
  71. package/src/ai/scout/tools.ts +150 -0
  72. package/src/ai/scout.ts +173 -0
  73. package/src/ai/tools.ts +75 -38
  74. package/src/api/spec-reader.ts +106 -1
  75. package/src/application-spec.ts +22 -4
  76. package/src/commands/base-command.ts +3 -3
  77. package/src/commands/init-command.ts +6 -3
  78. package/src/config.ts +7 -0
  79. package/src/explorbot.ts +36 -0
  80. package/src/explorer.ts +1 -1
  81. package/src/knowledge-tracker.ts +4 -0
  82. package/src/utils/aria-ref.ts +61 -0
  83. package/src/utils/aria.ts +3 -3
  84. package/src/utils/html-diff.ts +3 -1
  85. package/src/utils/web-annotate.ts +3 -15
  86. package/src/utils/web-element.ts +0 -9
@@ -0,0 +1,317 @@
1
+ # Pagination rule: page numbers and infinite scroll
2
+
3
+ ## Problem
4
+
5
+ Explorbot cannot reach list content that is not already loaded. A list shows a window onto
6
+ a larger collection, and when the item under test is outside that window the tester concludes
7
+ it is absent.
8
+
9
+ Nothing in the repo scrolls anything. `<actions>` (`src/ai/rules.ts:307`) documents no scroll
10
+ command, so the model has no way to know scrolling is available. Where scrolling is mentioned
11
+ at all it is as a passing hint inside an unrelated suggestion string
12
+ (`src/ai/tools.ts:875`, `:989`, `:1297`).
13
+
14
+ Two strategies cover practically every paginated list on the web:
15
+
16
+ - **Controls that replace the window** — next, previous, page numbers, load more.
17
+ - **Appending on scroll** — new items are fetched and appended as the list is scrolled.
18
+
19
+ The second is the hard one, because the scroller is often a container with its own scrollbar
20
+ rather than the page. `I.scrollPageToBottom()` moves the window and leaves such a container
21
+ untouched.
22
+
23
+ ## Approach
24
+
25
+ Detection finds out which strategy a list uses; a rule tells the tester what to do about it,
26
+ and is injected only when there is something to say. No new tool: the gesture already exists in
27
+ CodeceptJS and is reachable through `form`.
28
+
29
+ Splitting it that way is what keeps the rule short. The tester never has to discover anything
30
+ at run time, and never carries guidance for a strategy this page does not use.
31
+
32
+ ### Why `I.scrollTo` is sufficient
33
+
34
+ `Playwright.scrollTo(locator)` calls `el.scrollIntoViewIfNeeded()`
35
+ (`node_modules/codeceptjs/lib/helper/Playwright.js:1679`), which scrolls **every scrollable
36
+ ancestor** of the target. Pointing it at the last item currently in a list therefore scrolls
37
+ that list's own scroller.
38
+
39
+ Verified against Chromium on a page with both a scrollable `div` and a scrollable window:
40
+
41
+ ```
42
+ before {"box":0, "win":0}
43
+ scrollIntoView {"box":2200, "win":1821}
44
+ after cjs tail {"box":2200, "win":1821}
45
+ ```
46
+
47
+ Both scrollers moved. The `window.scrollBy` call CodeceptJS runs afterwards is a no-op: it
48
+ passes one object to a two-positional-parameter function, so both deltas coerce to `NaN` and
49
+ normalize to zero. Nothing needs to be worked around.
50
+
51
+ The gesture is a single line beginning with `I.`, so it passes the `form` tool's line check
52
+ (`src/ai/tools.ts:384`) and needs no new tool.
53
+
54
+ ### Signals already on the wire
55
+
56
+ - **`pageDiff.ariaChanges` / `ariaChangeCount`** — `diffAriaSnapshots` (`src/utils/aria.ts:503`)
57
+ counts node summaries, so appended rows surface as counted additions.
58
+ - **`pageDiff.requests`** — `Action.recordNetworkCall` (`src/action.ts:314`) captures same-origin
59
+ xhr/fetch as `{method, path, status}`, deduped. It stores `url.pathname` only, so the query
60
+ string is dropped: presence of a call, never a page number.
61
+
62
+ ## Design
63
+
64
+ ### A. Researcher determines each list's pagination strategy
65
+
66
+ Four steps, cheapest first, stopping as soon as one answers. This is the escalation ladder from
67
+ CLAUDE.md end to end: a table lookup, then AI judgment, then a probe whose result converts
68
+ judgment back into a recorded fact.
69
+
70
+ **0. Do the ARIA/HTML conventions name it? (deterministic, no research, no AI)**
71
+
72
+ Some markup states the answer outright. These are spec-defined attributes and values, so this
73
+ tier is a lookup, not a guess — and it is the only step that works when research has not run.
74
+
75
+ | Marker | Means | Available in |
76
+ |---|---|---|
77
+ | `[aria-current="page"]` | current page of a pagination set | HTML only |
78
+ | `a[rel="next"]`, `a[rel="prev"]` | sequential document relations | HTML only |
79
+ | `[role="feed"]` | scrollable list that grows as it is scrolled | HTML and ARIA snapshot |
80
+ | `[aria-setsize="-1"]` | total count unknown, so the set loads lazily | HTML only |
81
+
82
+ The first two mean `controls`, the last two mean `infinite`.
83
+
84
+ Verified against Chromium: `ariaSnapshot()` does **not** emit `aria-current`, so a link marked
85
+ as the current page is indistinguishable from its neighbours in the ARIA path. Explorbot also
86
+ dissolves `navigation` wrappers (`src/utils/aria.ts:46`, `:147`) and treats the role as
87
+ template chrome (`src/utils/aria.ts:543`), so a `nav` labelled "Pagination" never reaches the
88
+ model either. **These markers must be read from HTML.** `role="feed"` is the one exception —
89
+ it survives as `- feed "…"` in the snapshot.
90
+
91
+ Two constraints that keep this a lookup rather than a heuristic:
92
+
93
+ - The value must be `aria-current="page"` exactly. `aria-current="true"` is what tabs and
94
+ breadcrumbs use and would over-match — confirmed in the same probe.
95
+ - A `nav` whose `aria-label` reads "Pagination" is author prose, not closed grammar. It is not
96
+ part of this tier.
97
+
98
+ **Absence proves nothing.** A pager built from plain buttons, and an infinite feed built from
99
+ plain divs, carry none of these. That is what steps 1–3 are for.
100
+
101
+ **1. Are there pagination controls? (AI, free)**
102
+
103
+ Only asked when step 0 found nothing. Controls are named in open-ended ways — words, arrows,
104
+ bare numbers — so this is AI judgment, not a pattern match. Researcher is already describing
105
+ the section, so it costs nothing extra:
106
+ a new `rules/researcher/pagination.md`, loaded alongside the existing three at
107
+ `src/ai/researcher/sections.ts:81`, asks it to note when a section contains controls that move
108
+ between pages of the same collection.
109
+
110
+ If found, the section records `> Pagination: controls` and the remaining steps are skipped.
111
+
112
+ **2. Can the section scroll at all? (deterministic gate)**
113
+
114
+ Only asked when no controls were found. One `page.evaluate` per container:
115
+
116
+ - `el.scrollHeight > el.clientHeight` → the container has its own scroller.
117
+ - `el.getBoundingClientRect().bottom > innerHeight` → the list continues below the fold.
118
+ - Neither → nothing more to do; no line recorded.
119
+
120
+ This gate exists to keep step 3 from running on every short list.
121
+
122
+ **3. Probe: does scrolling load more? (deterministic measurement)**
123
+
124
+ Scroll the container to its end, wait for readiness (`waitForPageReadiness`,
125
+ `src/utils/page-readiness.ts`), and compare. More descendant rows than before, or a same-origin
126
+ xhr/fetch fired during the scroll, means the list appends. Record `> Pagination: infinite`.
127
+
128
+ Then restore `scrollTop` to what it was, so screenshots, coordinates and later research see the
129
+ page as they found it. Scroll position is not app state, so this needs none of the modal
130
+ cleanup `_restorePageState` does in `deep-analysis.ts:453` — there is nothing to reuse there.
131
+
132
+ **Recorded vocabulary:** `controls` or `infinite`, as a line in the section's container
133
+ blockquote. Nothing is written when a list neither paginates nor grows, which is the common
134
+ case and should stay silent.
135
+
136
+ ```
137
+ > Container: '.semantic-container'
138
+ > Pagination: infinite
139
+ ```
140
+
141
+ **This line has a reader**, because section B injects the rule only when pagination was
142
+ detected, and that decision is code. `extractPaginationFromBlockquote` joins
143
+ `extractContainerFromBlockquote` (`src/ai/researcher/parser.ts:86`) and returns the recorded
144
+ value or null.
145
+
146
+ That makes `Pagination:` a closed vocabulary read deterministically by code, so the envelope
147
+ checklist from CLAUDE.md applies and holds: read by code, scoped to a section of a state,
148
+ optional with "absent" as the default, and written by one module.
149
+
150
+ ### A2. Where the code goes
151
+
152
+ New `src/ai/researcher/pagination.ts` mixin, composed into `ResearcherBase`
153
+ (`src/ai/researcher.ts:47`), owning steps 2 and 3. Step 1 is prompt text in
154
+ `rules/researcher/pagination.md` and needs no code.
155
+
156
+ It runs after `validateContainers` (`src/ai/researcher/locators.ts:268`), on containers that
157
+ survived validation, so a probe never targets a selector already known to be broken.
158
+
159
+ Not `deep-analysis.ts`: that mixin owns the same interact-measure-restore shape, but it is
160
+ gated behind `deep` (`src/ai/researcher.ts:287`), and infinite scroll has to be detected on
161
+ ordinary research runs too. It is also already 26k.
162
+
163
+ Not `locators.ts`: that mixin owns locator validity, not list behaviour.
164
+
165
+ **One writer for the blockquote.** `updateSectionContainer`
166
+ (`src/ai/researcher/locators.ts:300`) currently owns that `blockquote[0]` replace. The
167
+ pagination mixin must not write it independently. Extract the blockquote composition into one
168
+ helper both call, so `Container:` and `Pagination:` are always emitted by the same code.
169
+
170
+ A new `src/utils/pagination.ts` owns the two deterministic halves — the step 0 marker scan over
171
+ HTML, and the in-page evaluate functions for steps 2 and 3, self-contained with no outer-scope
172
+ references. One concern: how a list continues. `measureLayout` in `overlay.ts` is not reused:
173
+ it is xpath-based and returns a modal-scoring `RegionLayout`, while sections carry CSS
174
+ selectors and need neither.
175
+
176
+ ### B. The pagination rule, injected only when pagination was detected
177
+
178
+ Not part of the static system message. A page with no list should not carry list guidance, and
179
+ a page with page numbers should not be told how to scroll.
180
+
181
+ **Seam:** `reinjectContextIfNeeded` (`src/ai/tester.ts:554`), which already injects per-state
182
+ blocks conditionally — `focusedElementRule` when something is focused, an `<overlay>` block
183
+ when a region is open. A `<pagination>` block joins them. Navigator gets the same block where
184
+ it builds its own per-state context (`src/ai/navigator.ts:414`).
185
+
186
+ **Condition:** the state shows pagination if step 0's markers are present in the current HTML,
187
+ or `extractPaginationFromBlockquote` finds a recorded value for a section. Markers win when
188
+ both are available, since they describe the page as it is now rather than as research left it.
189
+
190
+ **Which text:** the strategy selects the fragment, so the model is never shown the other one.
191
+
192
+ `paginationControlsRule`:
193
+
194
+ ```
195
+ <pagination>
196
+ This list pages through a larger collection. If what you need is not on screen,
197
+ click next or the page number you need before concluding it is absent.
198
+ </pagination>
199
+ ```
200
+
201
+ `infiniteScrollRule`:
202
+
203
+ ```
204
+ <pagination>
205
+ This list grows as it is scrolled. If what you need is not on screen, scroll to
206
+ the last item in the list — every scrollable ancestor of that item scrolls, so
207
+ this reaches a list with its own scrollbar.
208
+
209
+ New rows in the aria changes mean more arrived; a request with none means nothing
210
+ was left. Stop on the first attempt that adds no rows: the end of a collection is
211
+ an answer, not a failure.
212
+ </pagination>
213
+ ```
214
+
215
+ Both live in `src/ai/rules.ts` as exports. Not `rules/*.md`: those load per agent, and these
216
+ have two consumers.
217
+
218
+ **Reach narrows from the earlier draft.** Composing into `actionRule` would have reached
219
+ Rerunner (`src/ai/rerunner.ts:450`) and Captain web-mode (`src/ai/captain/web-mode.ts:148`) too;
220
+ conditional injection reaches only agents that build per-state context, which is Tester and
221
+ Navigator. That is the cost of making it conditional, and it is the right trade: Rerunner heals
222
+ known steps rather than traversing lists.
223
+
224
+ Constraints both fragments keep: general phrasing, no selectors, no site names, no example
225
+ taken from a debug session, one to three lines per bullet.
226
+
227
+ ### C. `actionRule` documents the gesture
228
+
229
+ `<actions>` (`src/ai/rules.ts:307`) gains a scroll entry covering `I.scrollTo(<locator>)` —
230
+ stating that it scrolls every scrollable ancestor of the target — and
231
+ `I.scrollPageToBottom()` for the window. The `form` tool description
232
+ (`src/ai/tools.ts:354`) gains "reach items further down a list" as a use case, since it
233
+ currently reads as a typing tool.
234
+
235
+ ### D. Tool output that contradicts the rule
236
+
237
+ Both are wrong reporting on existing tools, not new behaviour.
238
+
239
+ **`src/ai/tools.ts:415`** — `hasObservablePageChange` (`src/ai/tools.ts:1236`) ignores
240
+ `pageDiff.requests`. A scroll that fires a fetch which has not yet rendered rows, and a scroll
241
+ at the true end of a list, both return `failedToolResult` carrying the suggestion "Treat the
242
+ field/form action as not completed. Re-locate the editable control…" and commit
243
+ `TestResult.FAILED` into the note. That note is read by final review and by Historian, so a
244
+ correct end-of-list check is recorded as a failed test step.
245
+
246
+ Fix: one line in `hasObservablePageChange` — `if (data.pageDiff.requests?.length) return true;`
247
+ — and a no-change message that states what was observed rather than prescribing a
248
+ form-specific recovery.
249
+
250
+ **`src/ai/tools.ts:1208`** — `isMajorPageChange` (`src/ai/tools.ts:1220`) fires at
251
+ `ariaChangeCount >= 50` with no URL change (`LARGE_ARIA_CHANGE_THRESHOLD`,
252
+ `src/utils/aria.ts:581`), producing "MAJOR PAGE CHANGE. Page entered a different mode."
253
+ `diffByCount` (`src/utils/aria.ts:309`) pushes one entry per surplus occurrence, so a batch of
254
+ appended rows clears 50 easily. The rule says growth is the same state; the tool says the mode
255
+ changed.
256
+
257
+ Fix: a diff consisting of additions with no corresponding removals is growth, not a mode
258
+ change. Mode changes churn — they remove as well as add.
259
+
260
+ The added/removed split exists inside `diffAriaSnapshots` but is flattened into a single
261
+ `count` before it reaches the check, so it has to be threaded through:
262
+
263
+ 1. `AriaDiff` (`src/utils/aria.ts:587`) gains `added: number; removed: number` — both arrays
264
+ are already computed at `src/utils/aria.ts:522`.
265
+ 2. `Diff` (`src/action-result.ts:650`) stores them alongside `_ariaChangeCount`, set where
266
+ `diffAriaSnapshots` is called (`src/action-result.ts:740`), and exposes them.
267
+ 3. `PageDiff` (`src/action-result.ts:46`) gains `ariaAdded` / `ariaRemoved`, populated next to
268
+ `ariaChanges` / `ariaChangeCount` (`src/action-result.ts:552`).
269
+ 4. `isMajorPageChange` then reads the split instead of the total.
270
+
271
+ ## Risks
272
+
273
+ - **The probe costs a scroll per candidate section.** Steps 1 and 2 narrow it to sections that
274
+ have no pagination controls and can actually scroll, which on most pages is zero or one. If
275
+ it still proves too slow, the gate to tighten is step 2, not the probe itself.
276
+ - **Region misclassification.** `OverlayPage.detectRegion` (`src/utils/overlay.ts:41`) accepts
277
+ in-flow added content that is `sizable` (≥5,000 chars, `src/utils/region.ts:99`), dominant
278
+ (≥70% of added raw size) and carries a name or root. A large appended batch in one list
279
+ container fits all three, which would fork the state hash with `region_<name>` and record a
280
+ transition. The probe can trigger this during research as well as the tester during a run.
281
+ Watch for it on the first real run; not changed here.
282
+ - **Virtualized lists.** Recycled nodes keep counts flat, so the probe sees no growth and the
283
+ aria diff shows renames (`src/utils/aria.ts:325`) rather than additions. Such a list records
284
+ nothing and the rule will not know to scroll it. Out of scope.
285
+ - **The stop condition lives only in the prompt.** No tool enforces it, so a model that keeps
286
+ scrolling past an attempt which added no rows will keep scrolling. Accepted: the tester's own
287
+ iteration cap is the only backstop.
288
+ - **A REST call alone no longer stops the loop, by design.** After the D fix an empty-payload
289
+ 200 counts as an observable change, so the tool reports success. The rule makes rows, not
290
+ calls, the evidence that more arrived — otherwise a list that answers every scroll with an
291
+ empty page would loop.
292
+
293
+ ## Testing
294
+
295
+ - Unit coverage for step 0's marker scan: `aria-current="page"` and `rel=next/prev` yield
296
+ `controls`; `role="feed"` and `aria-setsize="-1"` yield `infinite`; `aria-current="true"` on a
297
+ tab list yields nothing.
298
+ - `tests/integration/researcher-sections.test.ts` — `> Pagination: controls` appears for a
299
+ section whose UI map holds next/prev controls, and step 1 is not asked when step 0 already
300
+ answered.
301
+ - A browser test for steps 2 and 3, following `tests/integration/overlay-modal-browser.test.ts`:
302
+ a container with its own scroller that appends on scroll (`infinite`), one that does not
303
+ (silent), a list with pagination controls (`controls`, no probe runs), and confirmation that
304
+ `scrollTop` is restored afterwards.
305
+ - An aimock prompt-inspection test that the matching fragment reaches the tester on a paginated
306
+ state, the other fragment does not, and neither appears on a state with no list, per
307
+ `docs/contributing/ai-integration-tests.md`.
308
+ - Unit coverage for the two `tools.ts` corrections: requests-only diff counts as observable;
309
+ an additions-only diff over the threshold is not a major page change.
310
+
311
+ ## Out of scope
312
+
313
+ - Any new tool. The gesture is reachable through `form` today.
314
+ - Persisting the strategy across runs. Research recomputes it, and the research cache is
315
+ session-scoped.
316
+ - Virtualized list support.
317
+ - Changing `detectRegion` thresholds.
@@ -44,7 +44,7 @@ See [Researcher Agent](./researcher.md) for configuration and usage.
44
44
 
45
45
  Generates test scenarios from research findings.
46
46
 
47
- The Planner writes business-focused scenarios with priority levels (critical/important/high/normal/low) and expected outcomes for verification. It balances positive and negative cases, skips scenarios you already have, and cycles through planning styles (normal, psycho, curious) to broaden coverage across iterations. You can add your own styles and page-specific rules.
47
+ The Planner writes business-focused scenarios with priority levels (critical/important/high/normal/low) and expected outcomes for verification. It balances positive and negative cases, skips scenarios you already have, and cycles through planning styles (normal, psycho, curious) to broaden coverage across iterations. You can add your own styles and page-specific rules. With the Scout agent enabled, it also plans from collected documentation, weighted by `docsWeight`.
48
48
 
49
49
  Commands that use Planner:
50
50
  - `/plan [--focus <feature>]`
@@ -52,6 +52,14 @@ Commands that use Planner:
52
52
 
53
53
  See [Planner Agent](./planner.md) for planning styles, customization, and configuration.
54
54
 
55
+ ## Scout Agent
56
+
57
+ Retrieves documentation relevant to the page being planned.
58
+
59
+ Scout searches the collected documentation corpus — the [application spec](../workflow/application-spec.md) from `explorbot docs collect` plus any extra markdown directories you configure — and reports the documented capabilities, states and transitions that matter for the current page and focus. The Planner receives them as a `<docs_context>` block and grounds part of its scenarios in them. Pages already injected as `<application_spec>` for the current URL are not repeated. Scout is opt-in (`ai.agents.scout.enabled`) and searches with ripgrep or grep, falling back to an in-process scan when neither is installed.
60
+
61
+ See [Configuration: Scout agent](../reference/configuration.md#scout-agent).
62
+
55
63
  ## Tester Agent
56
64
 
57
65
  Runs the planned scenarios.
@@ -53,6 +53,11 @@ ai: {
53
53
  | `styles` | `string[]` | `['normal', 'curious', 'psycho']` | Style names and cycling order |
54
54
  | `rules` | `RuleEntry[]` | `[]` | URL-aware rule files from `rules/planner/` |
55
55
  | `systemPrompt` | `string` | - | Inline instructions appended to the prompt |
56
+ | `docsWeight` | `number` | `70` | With Scout enabled, the rough share of scenarios exercising documented behavior; the rest explore beyond the documentation |
57
+
58
+ ## Planning from documentation
59
+
60
+ With the [Scout agent](../reference/configuration.md#scout-agent) enabled, the Planner also receives a `<docs_context>` block — capabilities, states and transitions retrieved from collected documentation that are relevant to the current page and focus. `docsWeight` steers the mix: at `70` roughly seven of ten scenarios exercise documented behavior and three explore what the documentation does not cover. Set it to `100` for documentation-only planning, or lower it to lean on the Planner's own reading of the page. Pages whose documentation is already injected as `<application_spec>` are not repeated in `<docs_context>`.
56
61
 
57
62
  ## Planning Styles
58
63
 
@@ -75,3 +75,7 @@ Screenshots and other relative links may be included for readers, but Explorbot
75
75
  ## Validation
76
76
 
77
77
  Explorbot rejects a bundle when `index.md` or `pages/` is missing, when it contains no page files, or when a page has an unsupported format, version, or missing URL.
78
+
79
+ ## Scout
80
+
81
+ Beyond the per-URL injection, the same bundle feeds the [Scout agent](../reference/configuration.md#scout-agent): when Scout is enabled, it searches `pages/` (and any extra `ai.agents.scout.dirs`) for documentation relevant to the page being planned and reports it to the Planner. Pages already injected for the current URL are excluded from scouting, so the two channels never duplicate each other.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -101,7 +101,7 @@
101
101
  "bash-tool": "^1.3.15",
102
102
  "chalk": "^5.6.2",
103
103
  "cli-highlight": "^2.1.11",
104
- "codeceptjs": "4.0.0-rc.16",
104
+ "codeceptjs": "4.2.0-beta.2",
105
105
  "commander": "^14.0.1",
106
106
  "debug": "^4.4.3",
107
107
  "dedent": "^1.6.0",
@@ -123,8 +123,8 @@
123
123
  "ora-classic": "^5.4.2",
124
124
  "parse5": "^8.0.0",
125
125
  "pixelmatch": "^7.2.0",
126
- "playwright": "^1.62",
127
- "playwright-core": "^1.62",
126
+ "playwright": "^1.63",
127
+ "playwright-core": "^1.63",
128
128
  "pngjs": "^7.0.0",
129
129
  "react": "^19.1.1",
130
130
  "sambanova-ai-provider": "^1.2.2",
@@ -7,11 +7,13 @@ import type { RequestStore } from '../../api/request-store.ts';
7
7
  import { extractEndpointDefinition } from '../../api/spec-reader.ts';
8
8
  import type { Test } from '../../test-plan.ts';
9
9
  import { tag } from '../../utils/logger.ts';
10
+ import { truncate } from '../../utils/strings.ts';
10
11
  import { isDynamicSegment } from '../../utils/url-matcher.ts';
11
12
  import type { Fisherman } from '../fisherman.ts';
12
13
  import type { RequestHaul } from './request-haul.ts';
13
14
 
14
15
  const BODY_PREVIEW_LIMIT = 2000;
16
+ const READS_IN_ANSWER = 3;
15
17
 
16
18
  export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, haul: RequestHaul, opts: { spec?: any; baseEndpoint?: string; readOnly?: boolean }) {
17
19
  const readOnly = opts.readOnly === true;
@@ -242,7 +244,7 @@ export function createAskApiTool(fisherman: Fisherman | null, task: Test) {
242
244
  }
243
245
 
244
246
  task.addNote(`Asked API: ${question} — ${result.summary}`);
245
- tag('success').log(`Ask API: ${result.summary}`);
247
+ tag('success').log(`Ask API: ${truncate(result.summary, 200)}`);
246
248
  return { answered: true, answer: result.summary };
247
249
  },
248
250
  }),
@@ -285,6 +287,11 @@ function synthesizeResult(haul: RequestHaul, declaredDone: boolean, readOnly: bo
285
287
  succeeded = haul.successfulReads();
286
288
  successLabel = 'successful reads';
287
289
  }
290
+ if (readOnly && succeeded.length > 0) {
291
+ const bodies = succeeded.slice(-READS_IN_ANSWER).map((read) => `${read.toEndpoint()} → ${read.rawResponseBody.substring(0, BODY_PREVIEW_LIMIT)}`);
292
+ return { success: true, summary: bodies.join('\n\n'), created: [], failed: [] };
293
+ }
294
+
288
295
  let summary = `Stopped before finishing: ${made.length} requests, ${succeeded.length} ${successLabel}, ${failures.length} failed`;
289
296
  const lastFailure = failures[failures.length - 1];
290
297
  if (lastFailure) summary += `; last failure: ${lastFailure.toSummary()}`;
@@ -3,6 +3,7 @@ import type { ApiClient } from '../api/api-client.ts';
3
3
  import { type EndpointFamily, type RequestStore, isFailedRequest } from '../api/request-store.ts';
4
4
  import { listAllEndpoints } from '../api/spec-reader.ts';
5
5
  import { createDebug, tag } from '../utils/logger.ts';
6
+ import { truncate } from '../utils/strings.ts';
6
7
 
7
8
  const debugLog = createDebug('explorbot:fisherman');
8
9
  import { loop } from '../utils/loop.ts';
@@ -137,7 +138,7 @@ export class Fisherman implements Agent {
137
138
  await this.runSession(conversation, tools, { haul, isFinished, finishFromText, label: `fisherman lookup: ${question.slice(0, 50)}` });
138
139
 
139
140
  const result = getResult();
140
- tag('info').log(`Fisherman answer: ${result.summary}`);
141
+ tag('info').log(`Fisherman answer: ${truncate(result.summary, 200)}`);
141
142
  return result;
142
143
  }
143
144
 
package/src/ai/pilot.ts CHANGED
@@ -363,8 +363,6 @@ export class Pilot implements Agent {
363
363
  return dedent`
364
364
  SCENARIO: ${task.scenario}
365
365
 
366
- ${this.buildDeletionScope(task)}
367
-
368
366
  EXPECTED RESULTS (milestones):
369
367
  ${task.expected.map((e) => `- ${e}`).join('\n')}
370
368
  `;
@@ -372,20 +370,22 @@ export class Pilot implements Agent {
372
370
 
373
371
  private buildResetSystemPrompt(task: Test): string {
374
372
  return dedent`
375
- You are Pilot — decide whether a reset is legitimate. Reset is DESTRUCTIVE: it abandons this
376
- iteration's work, but server-side side effects (records created, forms submitted) persist.
377
- Unnecessary resets create duplicate data and infinite loops.
373
+ You are Pilot — decide whether a reset is legitimate. Reset only re-navigates to the start URL:
374
+ it writes nothing, though it abandons this iteration's work and server-side side effects persist.
375
+ The hazard is the tester REDOING a completed flow afterwards — duplicate data and infinite loops.
378
376
 
379
377
  ${this.buildSharedEvidenceRules()}
380
378
 
381
379
  DECISION:
382
- - "allow": current page cannot host the scenario, irrecoverable error, or no path back.
383
- - "continue": prior action already succeeded (URL changed, record visible, confirmation shown) — verify/finish instead. Or scenario goal may already be met; instruct tester to verify the actual outcome rather than redo. Provide guidance.
380
+ - "allow": current page cannot host the scenario, irrecoverable error, no path back, or an
381
+ expectation requires the outcome to survive a reload or a return to the start page and no
382
+ reset has been taken yet this run — there the reset IS the check, not a redo.
383
+ - "continue": the outcome the scenario needs is already observable on the CURRENT page — verify/finish instead. Provide guidance.
384
384
  - "fail": resetCount >= 2 and underlying situation hasn't changed; same flow tried twice with same failure mode.
385
385
  - "skipped": feature doesn't exist on this app or prerequisites can't be met.
386
386
 
387
387
  PRIORITY:
388
- 1) Successful side effects in session_log → almost never allow reset.
388
+ 1) Successful side effects in session_log → allow reset only to re-observe them, never to repeat them.
389
389
  2) resetCount — each prior reset raises the bar.
390
390
  3) Tester's stated reason — weigh against evidence, don't trust blindly.
391
391
 
@@ -1102,23 +1102,6 @@ export class Pilot implements Agent {
1102
1102
  .join('\n\n');
1103
1103
  }
1104
1104
 
1105
- private buildDeletionScope(task: Test): string {
1106
- const deletableItems = task.plan
1107
- ? task.plan
1108
- .listTests()
1109
- .filter((t) => t.isSuccessful && t.sessionName)
1110
- .map((t) => t.sessionName!)
1111
- : [];
1112
- const scenarioLower = task.scenario.toLowerCase();
1113
- if (deletableItems.length > 0) {
1114
- return `For deletion scenarios, items can only be deleted if their title contains: ${deletableItems.join(', ')}`;
1115
- }
1116
- if (scenarioLower.includes('delete') || scenarioLower.includes('remove')) {
1117
- return 'No items available for deletion — test should create an item first';
1118
- }
1119
- return '';
1120
- }
1121
-
1122
1105
  private getSystemPrompt(task: Test, initialState: ActionResult): string {
1123
1106
  const interactive = isInteractive();
1124
1107
  const stepsText = task.plannedSteps.length > 0 ? task.plannedSteps.map((s, i) => `${i + 1}. ${s}`).join('\n') : 'No planned steps';
package/src/ai/planner.ts CHANGED
@@ -25,6 +25,7 @@ import { POSSIBLE_SECTIONS, type Researcher } from './researcher.ts';
25
25
  import { findSimilarStateHash } from './researcher/cache.ts';
26
26
  import { hasFocusedSection } from './researcher/focus.ts';
27
27
  import { capabilityGroundingRule, dataProtectionRules, fileUploadRule } from './rules.ts';
28
+ import type { Scout } from './scout.ts';
28
29
 
29
30
  const debugLog = createDebug('explorbot:planner');
30
31
 
@@ -63,6 +64,7 @@ export class Planner extends PlannerBase implements Agent {
63
64
  private lastSuite: Suite | null = null;
64
65
  researcher: Researcher;
65
66
  private fisherman: Fisherman | null = null;
67
+ private scout: Scout | null = null;
66
68
 
67
69
  constructor(deps: AgentDeps, researcher: Researcher) {
68
70
  super();
@@ -78,10 +80,19 @@ export class Planner extends PlannerBase implements Agent {
78
80
  this.fisherman = fisherman;
79
81
  }
80
82
 
83
+ setScout(scout: Scout): void {
84
+ this.scout = scout;
85
+ }
86
+
81
87
  private get sectionOrder(): string[] {
82
88
  return ConfigParser.getInstance().getConfig().ai?.agents?.researcher?.sections || Object.keys(POSSIBLE_SECTIONS);
83
89
  }
84
90
 
91
+ private get docsWeight(): number {
92
+ const value = ConfigParser.getInstance().getConfig().ai?.agents?.planner?.docsWeight ?? 70;
93
+ return Math.max(0, Math.min(100, value));
94
+ }
95
+
85
96
  private getDefaultStartUrl(state: { url: string; fullUrl?: string }): string {
86
97
  return state.fullUrl || state.url;
87
98
  }
@@ -328,6 +339,7 @@ export class Planner extends PlannerBase implements Agent {
328
339
  const conversation = new Conversation([], model);
329
340
  conversation.autoTrimTag('page_research', 20000);
330
341
  conversation.autoTrimTag('tested_scenarios', 10000);
342
+ conversation.autoTrimTag('docs_context', 8000);
331
343
 
332
344
  conversation.addUserText(this.getSystemMessage(feature));
333
345
 
@@ -385,6 +397,11 @@ export class Planner extends PlannerBase implements Agent {
385
397
  const research = await this.researcher.research(currentState || state, {
386
398
  deep: true,
387
399
  });
400
+
401
+ let docsPromise: Promise<string> | null = null;
402
+ if (this.scout && this.docsWeight > 0) {
403
+ docsPromise = this.scout.collectDocs({ url: state.url, title: state.title, feature, excludeUrls: this.knowledgeTracker.applicationSpecUrls(state) });
404
+ }
388
405
  let plannerResearch = mdq(research).query('code').replace('');
389
406
  plannerResearch = mdq(plannerResearch)
390
407
  .query('table')
@@ -419,6 +436,22 @@ export class Planner extends PlannerBase implements Agent {
419
436
  conversation.addUserText(applicationContext);
420
437
  }
421
438
 
439
+ if (docsPromise) {
440
+ const docs = await docsPromise;
441
+ if (docs) {
442
+ conversation.addUserText(dedent`
443
+ <docs_context>
444
+ Documentation retrieved from the collected corpus by the Scout agent.
445
+ Ground scenarios in these documented capabilities where they apply; treat them as supporting context, not a script.
446
+
447
+ Aim for roughly ${this.docsWeight}% of the scenarios to exercise behavior documented above; the remainder may explore beyond the documentation.
448
+
449
+ ${docs}
450
+ </docs_context>
451
+ `);
452
+ }
453
+ }
454
+
422
455
  conversation.addUserText(dedent`
423
456
  ${this.buildApproach(style)}
424
457
 
@@ -409,7 +409,8 @@ export class Provider {
409
409
  setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
410
410
  promptLog(`Using model: ${modelName}`);
411
411
 
412
- const toolsWithCommentary = tools?.commentary ? tools : { ...tools, commentary: createHarmonyChannelFallbackTool() };
412
+ let toolsWithCommentary = tools;
413
+ if (!tools?.commentary && options.toolChoice !== 'required') toolsWithCommentary = { ...tools, commentary: createHarmonyChannelFallbackTool() };
413
414
  const toolNames = Object.keys(toolsWithCommentary || {});
414
415
  tag('debug').log(`Tools enabled: [${toolNames.join(', ')}]`);
415
416
  promptLog('Available tools:', toolNames);
@@ -116,7 +116,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
116
116
  );
117
117
 
118
118
  tag('substep').log(`Researching overlay: ${region.name}`);
119
- const sectionMarkdown = await this._analyzeExpandedAction('', region.name, diff, alreadyExpanded);
119
+ const sectionMarkdown = await this._analyzeExpandedAction('', region.name, diff, alreadyExpanded, region.root);
120
120
  if (!sectionMarkdown) {
121
121
  debugLog(`Overlay "${region.name}" produced no meaningful expansion`);
122
122
  return null;
@@ -421,9 +421,9 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
421
421
  await new Promise((r) => setTimeout(r, 500));
422
422
 
423
423
  let diff: Diff;
424
+ let currAR: ActionResult;
424
425
  try {
425
- await this.explorer.capture();
426
- const currAR = ActionResult.fromState(this.stateManager.getCurrentState()!);
426
+ currAR = await this.explorer.capture();
427
427
  diff = await currAR.diff(previousState);
428
428
  } catch (err) {
429
429
  tag('warning').log(`State capture failed after click: ${err instanceof Error ? err.message : err}`);
@@ -444,7 +444,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
444
444
  return { status: 'none', code: clickCode };
445
445
  }
446
446
 
447
- const sectionMarkdown = await this._analyzeExpandedAction(clickCode, description, diff, alreadyExpanded);
447
+ const sectionMarkdown = await this._analyzeExpandedAction(clickCode, description, diff, alreadyExpanded, currAR.overlay.root);
448
448
  await this._restorePageState(state.url, originalAria);
449
449
  if (!sectionMarkdown) return { status: 'none', code: clickCode };
450
450
  return { status: 'revealed', code: clickCode, sectionMarkdown };
@@ -467,7 +467,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
467
467
  }
468
468
  }
469
469
 
470
- private async _analyzeExpandedAction(code: string, description: string, diff: Diff, alreadyExpanded: string[]): Promise<string | null> {
470
+ private async _analyzeExpandedAction(code: string, description: string, diff: Diff, alreadyExpanded: string[], containerCss: string | null = null): Promise<string | null> {
471
471
  const alreadyHint = alreadyExpanded.length > 0 ? `\nAlready expanded sections:\n${alreadyExpanded.join('\n')}` : '';
472
472
 
473
473
  let intro: string;
@@ -532,7 +532,14 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
532
532
  const sections = parseResearchSections(text);
533
533
  if (sections.length === 0) return null;
534
534
 
535
- return sections[0].rawMarkdown;
535
+ const sectionMarkdown = sections[0].rawMarkdown;
536
+ if (!containerCss) return sectionMarkdown;
537
+
538
+ let heading = mdq(sectionMarkdown).query('h3[0]');
539
+ if (heading.count() === 0) heading = mdq(sectionMarkdown).query('h2[0]');
540
+ if (heading.count() === 0) return sectionMarkdown;
541
+
542
+ return heading.replace(`${heading.text().trimEnd()}\n\n> Container: '${containerCss}'\n\n`);
536
543
  }
537
544
 
538
545
  private _deduplicateExpandedSections(sections: string[]): string[] {