explorbot 0.3.4 → 0.4.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 (104) hide show
  1. package/bin/explorbot-cli.ts +18 -13
  2. package/boat/doc-collector/src/cli.ts +3 -0
  3. package/boat/doc-collector/src/docbot.ts +3 -1
  4. package/boat/prima/src/cli.ts +21 -8
  5. package/boat/prima/src/envelope.ts +35 -9
  6. package/boat/prima/src/prima.ts +23 -10
  7. package/dist/bin/explorbot-cli.js +19 -13
  8. package/dist/boat/doc-collector/src/cli.js +3 -0
  9. package/dist/boat/doc-collector/src/docbot.js +3 -1
  10. package/dist/boat/prima/src/cli.js +19 -8
  11. package/dist/boat/prima/src/envelope.js +24 -6
  12. package/dist/boat/prima/src/prima.js +23 -11
  13. package/dist/package.json +2 -2
  14. package/dist/src/action-result.d.ts +9 -1
  15. package/dist/src/action-result.js +57 -18
  16. package/dist/src/action.d.ts +1 -1
  17. package/dist/src/action.js +87 -12
  18. package/dist/src/ai/driller.d.ts +0 -1
  19. package/dist/src/ai/driller.js +8 -20
  20. package/dist/src/ai/fisherman-tools.d.ts +9 -0
  21. package/dist/src/ai/fisherman-tools.js +52 -6
  22. package/dist/src/ai/fisherman.d.ts +4 -2
  23. package/dist/src/ai/fisherman.js +48 -27
  24. package/dist/src/ai/historian/codeceptjs.js +1 -1
  25. package/dist/src/ai/historian/playwright.js +1 -1
  26. package/dist/src/ai/pilot.d.ts +1 -0
  27. package/dist/src/ai/pilot.js +15 -1
  28. package/dist/src/ai/planner.js +1 -1
  29. package/dist/src/ai/provider.js +47 -4
  30. package/dist/src/ai/researcher/deep-analysis.js +1 -3
  31. package/dist/src/ai/researcher.js +3 -3
  32. package/dist/src/ai/tester.d.ts +3 -0
  33. package/dist/src/ai/tester.js +40 -3
  34. package/dist/src/ai/tools.d.ts +1 -0
  35. package/dist/src/ai/tools.js +13 -6
  36. package/dist/src/api/request-result.d.ts +2 -0
  37. package/dist/src/api/request-result.js +8 -2
  38. package/dist/src/api/request-store.d.ts +3 -2
  39. package/dist/src/api/request-store.js +66 -14
  40. package/dist/src/commands/explore-command.d.ts +6 -0
  41. package/dist/src/commands/explore-command.js +27 -2
  42. package/dist/src/commands/freesail-command.js +10 -1
  43. package/dist/src/commands/plans-command.js +6 -6
  44. package/dist/src/config.js +1 -0
  45. package/dist/src/experience-tracker.js +5 -0
  46. package/dist/src/explorbot.d.ts +0 -1
  47. package/dist/src/explorbot.js +23 -36
  48. package/dist/src/state-manager.d.ts +5 -1
  49. package/dist/src/state-manager.js +10 -7
  50. package/dist/src/test-plan.d.ts +3 -0
  51. package/dist/src/test-plan.js +27 -0
  52. package/dist/src/utils/aria.d.ts +1 -1
  53. package/dist/src/utils/aria.js +6 -42
  54. package/dist/src/utils/html-diff.d.ts +4 -0
  55. package/dist/src/utils/html-diff.js +62 -7
  56. package/dist/src/utils/html.d.ts +5 -15
  57. package/dist/src/utils/html.js +14 -85
  58. package/dist/src/utils/overlay.d.ts +56 -11
  59. package/dist/src/utils/overlay.js +191 -21
  60. package/dist/src/utils/request-map.d.ts +7 -0
  61. package/dist/src/utils/request-map.js +16 -0
  62. package/dist/src/utils/url-matcher.js +4 -2
  63. package/docs/reference/commands.md +8 -1
  64. package/docs/reference/websocket.md +1 -0
  65. package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
  66. package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
  67. package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
  68. package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
  69. package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
  70. package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
  71. package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
  72. package/docs/workflow/agentic-usage.md +1 -0
  73. package/docs/workflow/ci.md +1 -0
  74. package/package.json +2 -2
  75. package/src/action-result.ts +61 -22
  76. package/src/action.ts +87 -14
  77. package/src/ai/driller.ts +7 -39
  78. package/src/ai/fisherman-tools.ts +56 -7
  79. package/src/ai/fisherman.ts +48 -28
  80. package/src/ai/historian/codeceptjs.ts +1 -1
  81. package/src/ai/historian/playwright.ts +1 -1
  82. package/src/ai/pilot.ts +11 -1
  83. package/src/ai/planner.ts +1 -1
  84. package/src/ai/provider.ts +48 -4
  85. package/src/ai/researcher/deep-analysis.ts +1 -2
  86. package/src/ai/researcher.ts +3 -3
  87. package/src/ai/tester.ts +40 -3
  88. package/src/ai/tools.ts +17 -9
  89. package/src/api/request-result.ts +10 -2
  90. package/src/api/request-store.ts +60 -13
  91. package/src/commands/explore-command.ts +25 -2
  92. package/src/commands/freesail-command.ts +7 -1
  93. package/src/commands/plans-command.ts +6 -6
  94. package/src/config.ts +1 -0
  95. package/src/experience-tracker.ts +5 -1
  96. package/src/explorbot.ts +20 -36
  97. package/src/state-manager.ts +13 -7
  98. package/src/test-plan.ts +29 -0
  99. package/src/utils/aria.ts +7 -44
  100. package/src/utils/html-diff.ts +62 -7
  101. package/src/utils/html.ts +14 -91
  102. package/src/utils/overlay.ts +226 -23
  103. package/src/utils/request-map.ts +19 -0
  104. package/src/utils/url-matcher.ts +3 -2
@@ -0,0 +1,1292 @@
1
+ # Region-of-Interest States Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Detect modals, drawers and soft-navigated subviews as first-class states by diffing HTML after each action, verify with a Playwright geometry probe whether the appeared region overlays the page, surface the region to Tester, Pilot, StateManager and experience files (`root:` frontmatter) — and **unify all overlay detection into `src/utils/overlay.ts`, deleting the old selector-heuristic path entirely**.
6
+
7
+ **Architecture:** A structural pipeline orchestrated by `Action.capturePageState` with every decision function in `overlay.ts`: memoized parse5 diff vs previous state → `findAppearedSubRoot` (≥ 10K chars) → browser coverage probe → `classifyRegionCoverage` → `Overlay.fromSubRoot`. `Overlay` gains `type: 'drawer' | 'region'`, `root` and `present` (`detected` keeps meaning "verified overlaying"). Named regions enter the state hash (`baseHash` escape hatch for research keys); experience files carry `root:` and load only while a matching region is open. After the new path lands, the selector-based path (`extractVisibleOverlayHtml`, `OVERLAY_SELECTORS`, `captureOverlayHtml`, `overlayHtml`, Driller's private extractor) is removed — detection is ARIA + diff/geometry, nothing else.
8
+
9
+ **Tech Stack:** Bun, TypeScript, parse5 (html-diff), Playwright `page.evaluate` (probe), gray-matter (experience frontmatter), bun:test.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-08-29-region-states-design.md` — read it first; the plan argues from it, including the "Removed code" table Task 11 executes.
12
+
13
+ ## Global Constraints
14
+
15
+ - Bun only — never Node.js; run tests with `bun test <path>`.
16
+ - **Execute in the dedicated worktree branched off `main`** (created via `bunosh worktree:create`, which symlinks the main checkout's `node_modules`). Never touch the main checkout at `~/projects/explorbot` — it carries unrelated in-flight work. This plan's code quotes were taken from a tree that had small uncommitted changes to `src/ai/pilot.ts` and `src/ai/researcher/deep-analysis.ts`; the regions this plan edits exist identically on `main`, but re-read every file immediately before editing — line numbers are approximate anchors, the quoted code is the authoritative anchor, and where a quote differs slightly from what's on disk, the on-disk code wins as the base for the edit.
17
+ - Per-task commits stage **only the files named in the task** (`git add <file> <file>`), never `git add -A` — the dirty tree holds unrelated work.
18
+ - Code style (from CLAUDE.md): no comments unless stated; no ternary operators; no `...(cond ? {k:v} : {})` spread — plain `if`; premature exit over if/else; `?.` over `&&` chains; private methods after public; new types at end of file; `dedent` for prompts; `mdq()` for markdown (never regex/includes on markdown).
19
+ - Prompts and rules must be GENERAL — no examples from debug sessions, no site-specific selectors or class names.
20
+ - No AI calls anywhere in the detection path — detection is structural (data tier).
21
+ - Run `bun run format` after each code change, before each commit.
22
+ - Never trigger the regression CI workflow (`regression` label / `gh workflow run`) — local unit + integration tests are the feedback loop.
23
+
24
+ ---
25
+
26
+ ### Task 1: Overlay core — types, root, `present`, `findAppearedSubRoot`
27
+
28
+ **Files:**
29
+ - Modify: `src/utils/overlay.ts`, `src/utils/html-diff.ts` (one-line export)
30
+ - Test: `tests/unit/overlay-detection.test.ts`
31
+
32
+ **Interfaces:**
33
+ - Consumes: `HtmlDiffPart` and `pathToXPath` from `html-diff.ts` (`pathToXPath` becomes exported); `extractHeadings` from `./html.js` (already imported in overlay.ts).
34
+ - Produces (later tasks rely on these exact names):
35
+ - `OverlayType = 'dialog' | 'modal' | 'drawer' | 'region'`; `OverlayData` gains `root?: string | null`.
36
+ - `Overlay` gains `readonly root: string | null`, `get present(): boolean`, `static fromSubRoot(subRoot: AppearedSubRoot, verdict: RegionVerdict): Overlay`, private `static nameFromHtml(html: string): string | null`.
37
+ - `findAppearedSubRoot(parts: HtmlDiffPart[]): AppearedSubRoot | null` in `overlay.ts`; `export interface AppearedSubRoot { container: string; elementXPath: string; subtree: string; size: number }` at end of `overlay.ts`.
38
+ - `RegionVerdict` is implemented in Task 2; for this task declare it in `overlay.ts`'s end-of-file types block: `export interface RegionVerdict { overlays: boolean; coverage: number }`.
39
+
40
+ - [x] **Step 1: Write the failing tests**
41
+
42
+ Append to `tests/unit/overlay-detection.test.ts` (extend its imports with `findAppearedSubRoot` from `../../src/utils/overlay.ts` and `htmlDiff` from `../../src/utils/html-diff.ts`):
43
+
44
+ ```ts
45
+ describe('findAppearedSubRoot', () => {
46
+ const bigForm = Array.from({ length: 200 }, (_, i) => `<div><label>Field ${i}</label><input name="field-${i}" placeholder="value ${i}"></div>`).join('');
47
+ const basePage = '<html><body><div id="app"><h1>Users</h1><ul><li><a href="/users/1">First User</a></li></ul></div></body></html>';
48
+ const pageWithDrawer = `<html><body><div id="app"><h1>Users</h1><ul><li><a href="/users/1">First User</a></li></ul></div><div class="drawer"><h2>Edit User</h2><form>${bigForm}</form></div></body></html>`;
49
+
50
+ it('finds a large appeared element with container and element xpath', async () => {
51
+ const diff = await htmlDiff(basePage, pageWithDrawer);
52
+ const subRoot = findAppearedSubRoot(diff.parts);
53
+ expect(subRoot).not.toBeNull();
54
+ expect(subRoot!.size).toBeGreaterThanOrEqual(10_000);
55
+ expect(subRoot!.container).toBe('body');
56
+ expect(subRoot!.elementXPath).toBe('//body/div[2]');
57
+ expect(subRoot!.subtree).toContain('Edit User');
58
+ });
59
+
60
+ it('returns null when the appeared content is below the threshold', async () => {
61
+ const before = '<html><body><div id="app"><h1>Users</h1></div></body></html>';
62
+ const after = '<html><body><div id="app"><h1>Users</h1></div><div class="toast">Saved successfully</div></body></html>';
63
+ const diff = await htmlDiff(before, after);
64
+ expect(findAppearedSubRoot(diff.parts)).toBeNull();
65
+ });
66
+
67
+ it('returns null when nothing appeared', async () => {
68
+ const diff = await htmlDiff(basePage, basePage);
69
+ expect(findAppearedSubRoot(diff.parts)).toBeNull();
70
+ });
71
+ });
72
+
73
+ describe('Overlay.fromSubRoot', () => {
74
+ const subRoot = {
75
+ container: 'aside.detail-panel',
76
+ elementXPath: '//body/div[2]',
77
+ subtree: '<aside class="detail-panel"><h2>Edit User</h2><form><input name="name"></form></aside>',
78
+ size: 12000,
79
+ };
80
+
81
+ it('overlaying with full coverage becomes a modal named by headings', () => {
82
+ const overlay = Overlay.fromSubRoot(subRoot, { overlays: true, coverage: 0.95 });
83
+ expect(overlay.type).toBe('modal');
84
+ expect(overlay.name).toBe('Edit User');
85
+ expect(overlay.root).toBe('aside.detail-panel');
86
+ expect(overlay.detected).toBe(true);
87
+ expect(overlay.present).toBe(true);
88
+ });
89
+
90
+ it('overlaying with partial coverage becomes a drawer', () => {
91
+ expect(Overlay.fromSubRoot(subRoot, { overlays: true, coverage: 0.3 }).type).toBe('drawer');
92
+ });
93
+
94
+ it('inline verdict becomes a region: present but not detected', () => {
95
+ const overlay = Overlay.fromSubRoot(subRoot, { overlays: false, coverage: 0.3 });
96
+ expect(overlay.type).toBe('region');
97
+ expect(overlay.detected).toBe(false);
98
+ expect(overlay.present).toBe(true);
99
+ });
100
+
101
+ it('body container falls back to the element xpath as root', () => {
102
+ const overlay = Overlay.fromSubRoot({ ...subRoot, container: 'body' }, { overlays: true, coverage: 1 });
103
+ expect(overlay.root).toBe('//body/div[2]');
104
+ });
105
+ });
106
+ ```
107
+
108
+ - [x] **Step 2: Run tests to verify they fail**
109
+
110
+ Run: `bun test tests/unit/overlay-detection.test.ts`
111
+ Expected: FAIL — `findAppearedSubRoot` / `fromSubRoot` do not exist.
112
+
113
+ - [x] **Step 3: Export `pathToXPath` from html-diff**
114
+
115
+ In `src/utils/html-diff.ts` change `function pathToXPath(treePath: string): string {` to `export function pathToXPath(treePath: string): string {`. Nothing else in that file changes.
116
+
117
+ - [x] **Step 4: Extend Overlay and add `findAppearedSubRoot`**
118
+
119
+ Rewrite `src/utils/overlay.ts` (keep `OVERLAY_SELECTORS`, `fromAria`, `resolve`, `fromHtml`, `captureConfig` bodies verbatim for now — they are deleted in Task 11, not here; `fromHtml` delegates to the new `nameFromHtml`):
120
+
121
+ ```ts
122
+ import { detectFocusArea } from './aria.js';
123
+ import { type HtmlDiffPart, pathToXPath } from './html-diff.js';
124
+ import { HTML_EXTRACTION_LIMITS, HTML_SELECTORS, HTML_VISIBILITY_LIMITS, type VisibleOverlayExtractionConfig, extractHeadings } from './html.js';
125
+
126
+ export const OVERLAY_SELECTORS = { /* unchanged */ } as const;
127
+
128
+ export type OverlayType = 'dialog' | 'modal' | 'drawer' | 'region';
129
+ export type OverlayData = { type?: OverlayType | null; name?: string | null; root?: string | null };
130
+
131
+ export class Overlay {
132
+ readonly type: OverlayType | null;
133
+ readonly name: string | null;
134
+ readonly root: string | null;
135
+
136
+ constructor(data: OverlayData = {}) {
137
+ this.type = data.type ?? null;
138
+ this.name = data.name ?? null;
139
+ this.root = data.root ?? null;
140
+ }
141
+
142
+ get detected(): boolean {
143
+ return this.type !== null && this.type !== 'region';
144
+ }
145
+
146
+ get present(): boolean {
147
+ return this.type !== null;
148
+ }
149
+
150
+ static fromHtml(html: string): Overlay {
151
+ return new Overlay({ type: 'modal', name: Overlay.nameFromHtml(html) });
152
+ }
153
+
154
+ static fromSubRoot(subRoot: AppearedSubRoot, verdict: RegionVerdict): Overlay {
155
+ let type: OverlayType = 'region';
156
+ if (verdict.overlays) {
157
+ type = 'drawer';
158
+ if (verdict.coverage >= FULL_COVERAGE_RATIO) type = 'modal';
159
+ }
160
+ let root = subRoot.container;
161
+ if (root === 'body') root = subRoot.elementXPath;
162
+ return new Overlay({ type, name: Overlay.nameFromHtml(subRoot.subtree), root });
163
+ }
164
+
165
+ static fromAria(snapshot: string | null): Overlay { /* unchanged */ }
166
+ static resolve(data: { overlayHtml?: string; overlay?: OverlayData | null; ariaSnapshot?: string | null }): Overlay { /* unchanged */ }
167
+ static captureConfig(): VisibleOverlayExtractionConfig { /* unchanged */ }
168
+
169
+ private static nameFromHtml(html: string): string | null {
170
+ const headings = extractHeadings(html);
171
+ return [headings.h1, headings.h2, headings.h3, headings.h4].filter(Boolean).join(' ') || null;
172
+ }
173
+ }
174
+
175
+ const SUBROOT_MIN_HTML = 10_000;
176
+ const FULL_COVERAGE_RATIO = 0.8;
177
+
178
+ export function findAppearedSubRoot(parts: HtmlDiffPart[]): AppearedSubRoot | null {
179
+ let best: AppearedSubRoot | null = null;
180
+ for (const part of parts) {
181
+ const appeared = part.added.find((line) => line.startsWith('ELEMENT:'));
182
+ if (!appeared) continue;
183
+ if (part.subtree.length < SUBROOT_MIN_HTML) continue;
184
+ if (best && part.subtree.length <= best.size) continue;
185
+ best = {
186
+ container: part.container,
187
+ elementXPath: pathToXPath(appeared.slice('ELEMENT:'.length)),
188
+ subtree: part.subtree,
189
+ size: part.subtree.length,
190
+ };
191
+ }
192
+ return best;
193
+ }
194
+
195
+ export interface AppearedSubRoot {
196
+ container: string;
197
+ elementXPath: string;
198
+ subtree: string;
199
+ size: number;
200
+ }
201
+
202
+ export interface RegionVerdict {
203
+ overlays: boolean;
204
+ coverage: number;
205
+ }
206
+ ```
207
+
208
+ `/* unchanged */` markers mean: keep the existing bodies verbatim — do not retype them. Cycle check holds: overlay → html-diff → html, overlay → html, overlay → aria; nothing imports overlay from those three.
209
+
210
+ - [x] **Step 5: Run tests to verify they pass**
211
+
212
+ Run: `bun test tests/unit/overlay-detection.test.ts && bun test tests/unit/html-diff.test.ts && bun test tests/unit/aria.test.ts && bun test tests/unit/state-manager.test.ts`
213
+ Expected: PASS — `detected` semantics for `dialog`/`modal` are unchanged, and the pre-existing `extractVisibleOverlayHtml`/resolve tests still pass because that path is untouched until Task 11. If the first `findAppearedSubRoot` test's `container` assertion fails, inspect the actual value — `findStableContainer` returns `body` for top-level appended nodes because `html[1]/body[1]` is in `IGNORED_PATHS`.
214
+
215
+ - [x] **Step 6: Format and commit**
216
+
217
+ ```bash
218
+ bun run format
219
+ git add src/utils/overlay.ts src/utils/html-diff.ts tests/unit/overlay-detection.test.ts
220
+ git commit -m "feat: Overlay carries region types and root; detect appeared subroots from diff"
221
+ ```
222
+
223
+ ---
224
+
225
+ ### Task 2: Coverage probe and classifier in overlay.ts
226
+
227
+ **Files:**
228
+ - Modify: `src/utils/overlay.ts`
229
+ - Test: `tests/unit/overlay-detection.test.ts`
230
+
231
+ **Interfaces:**
232
+ - Consumes: `RegionVerdict` (Task 1).
233
+ - Produces (Task 4 relies on): `classifyRegionCoverage(samples: RegionCoverageSamples | null): RegionVerdict`; `probeRegionCoverage(config: { xpath: string }): RegionCoverageSamples` (runs inside the browser); `getRegionCoverageProbeSource(): string`; `export interface RegionCoverageSamples { found: boolean; rect: { x: number; y: number; width: number; height: number }; viewport: { width: number; height: number }; position: string; zIndex: number; outsideHits: Array<'inside' | 'blocked' | 'page'>; siblingsInert: boolean; bodyScrollLocked: boolean }` at end of `overlay.ts`.
234
+
235
+ - [x] **Step 1: Write the failing tests**
236
+
237
+ Append to `tests/unit/overlay-detection.test.ts` (import `classifyRegionCoverage`, `getRegionCoverageProbeSource` and type `RegionCoverageSamples` from `../../src/utils/overlay.ts`):
238
+
239
+ ```ts
240
+ const samplesBase = (): RegionCoverageSamples => ({
241
+ found: true,
242
+ rect: { x: 0, y: 0, width: 1280, height: 720 },
243
+ viewport: { width: 1280, height: 720 },
244
+ position: 'fixed',
245
+ zIndex: 100,
246
+ outsideHits: [],
247
+ siblingsInert: false,
248
+ bodyScrollLocked: false,
249
+ });
250
+
251
+ describe('classifyRegionCoverage', () => {
252
+ it('full viewport coverage is overlaying', () => {
253
+ const verdict = classifyRegionCoverage(samplesBase());
254
+ expect(verdict.overlays).toBe(true);
255
+ expect(verdict.coverage).toBeCloseTo(1);
256
+ });
257
+
258
+ it('partial floating region with all outside points blocked is overlaying', () => {
259
+ const samples = samplesBase();
260
+ samples.rect = { x: 880, y: 0, width: 400, height: 720 };
261
+ samples.outsideHits = ['blocked', 'blocked', 'blocked', 'blocked'];
262
+ const verdict = classifyRegionCoverage(samples);
263
+ expect(verdict.overlays).toBe(true);
264
+ expect(verdict.coverage).toBeLessThan(0.8);
265
+ });
266
+
267
+ it('inert siblings mean overlaying regardless of geometry', () => {
268
+ const samples = samplesBase();
269
+ samples.rect = { x: 0, y: 0, width: 400, height: 400 };
270
+ samples.siblingsInert = true;
271
+ expect(classifyRegionCoverage(samples).overlays).toBe(true);
272
+ });
273
+
274
+ it('static in-flow region with page hits outside is inline', () => {
275
+ const samples = samplesBase();
276
+ samples.rect = { x: 200, y: 100, width: 800, height: 500 };
277
+ samples.position = 'static';
278
+ samples.zIndex = 0;
279
+ samples.outsideHits = ['page', 'page', 'page'];
280
+ expect(classifyRegionCoverage(samples).overlays).toBe(false);
281
+ });
282
+
283
+ it('missing element or null samples is inline with zero coverage', () => {
284
+ expect(classifyRegionCoverage(null)).toEqual({ overlays: false, coverage: 0 });
285
+ const samples = samplesBase();
286
+ samples.found = false;
287
+ expect(classifyRegionCoverage(samples)).toEqual({ overlays: false, coverage: 0 });
288
+ });
289
+ });
290
+
291
+ describe('getRegionCoverageProbeSource', () => {
292
+ it('serializes to a reconstructible function', () => {
293
+ const source = getRegionCoverageProbeSource();
294
+ const fn = new Function(`return ${source}`)();
295
+ expect(typeof fn).toBe('function');
296
+ });
297
+ });
298
+ ```
299
+
300
+ Run: `bun test tests/unit/overlay-detection.test.ts` — expected FAIL.
301
+
302
+ - [x] **Step 2: Implement classifier and probe**
303
+
304
+ In `src/utils/overlay.ts`, below `findAppearedSubRoot`:
305
+
306
+ ```ts
307
+ export function classifyRegionCoverage(samples: RegionCoverageSamples | null): RegionVerdict {
308
+ if (!samples?.found) return { overlays: false, coverage: 0 };
309
+ const viewportArea = samples.viewport.width * samples.viewport.height;
310
+ if (!viewportArea) return { overlays: false, coverage: 0 };
311
+
312
+ const rect = samples.rect;
313
+ const visibleWidth = Math.min(rect.x + rect.width, samples.viewport.width) - Math.max(rect.x, 0);
314
+ const visibleHeight = Math.min(rect.y + rect.height, samples.viewport.height) - Math.max(rect.y, 0);
315
+ const coverage = (Math.max(0, visibleWidth) * Math.max(0, visibleHeight)) / viewportArea;
316
+
317
+ if (coverage >= FULL_COVERAGE_RATIO) return { overlays: true, coverage };
318
+ if (samples.siblingsInert) return { overlays: true, coverage };
319
+
320
+ const floating = samples.position === 'fixed' || samples.position === 'absolute' || samples.zIndex > 0;
321
+ if (!floating) return { overlays: false, coverage };
322
+
323
+ const outside = samples.outsideHits;
324
+ if (outside.length > 0 && outside.every((hit) => hit !== 'page')) return { overlays: true, coverage };
325
+ if (samples.bodyScrollLocked && outside.length > 0 && outside.filter((hit) => hit !== 'page').length * 2 >= outside.length) return { overlays: true, coverage };
326
+
327
+ return { overlays: false, coverage };
328
+ }
329
+
330
+ export function probeRegionCoverage(config: { xpath: string }): RegionCoverageSamples {
331
+ const samples: RegionCoverageSamples = {
332
+ found: false,
333
+ rect: { x: 0, y: 0, width: 0, height: 0 },
334
+ viewport: { width: window.innerWidth, height: window.innerHeight },
335
+ position: 'static',
336
+ zIndex: 0,
337
+ outsideHits: [],
338
+ siblingsInert: false,
339
+ bodyScrollLocked: false,
340
+ };
341
+
342
+ const result = document.evaluate(config.xpath, document, null, 9, null);
343
+ const node = result.singleNodeValue;
344
+ if (!node || node.nodeType !== 1) return samples;
345
+ const element = node as HTMLElement;
346
+ const rect = element.getBoundingClientRect();
347
+ if (rect.width === 0 && rect.height === 0) return samples;
348
+
349
+ const style = window.getComputedStyle(element);
350
+ samples.found = true;
351
+ samples.rect = { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
352
+ samples.position = style.position;
353
+ samples.zIndex = Number.parseInt(style.zIndex || '0', 10) || 0;
354
+
355
+ const bodyStyle = window.getComputedStyle(document.body);
356
+ samples.bodyScrollLocked = bodyStyle.overflow === 'hidden' || bodyStyle.overflowY === 'hidden';
357
+
358
+ for (const sibling of Array.from(element.parentElement?.children || [])) {
359
+ if (sibling === element) continue;
360
+ if (!sibling.hasAttribute('inert') && sibling.getAttribute('aria-hidden') !== 'true') continue;
361
+ samples.siblingsInert = true;
362
+ break;
363
+ }
364
+
365
+ function classifyHit(hit: Element | null): 'inside' | 'blocked' | 'page' {
366
+ if (!hit) return 'page';
367
+ if (element.contains(hit)) return 'inside';
368
+ let current: Element | null = hit;
369
+ for (let depth = 0; current && depth < 4; depth++) {
370
+ const hitStyle = window.getComputedStyle(current as HTMLElement);
371
+ const hitZ = Number.parseInt(hitStyle.zIndex || '0', 10) || 0;
372
+ if ((hitStyle.position === 'fixed' || hitStyle.position === 'absolute') && hitZ > 0) return 'blocked';
373
+ current = current.parentElement;
374
+ }
375
+ return 'page';
376
+ }
377
+
378
+ const inset = 10;
379
+ const width = window.innerWidth;
380
+ const height = window.innerHeight;
381
+ const points: Array<[number, number]> = [
382
+ [inset, inset],
383
+ [width - inset, inset],
384
+ [inset, height - inset],
385
+ [width - inset, height - inset],
386
+ [width / 2, inset],
387
+ [width / 2, height - inset],
388
+ [inset, height / 2],
389
+ [width - inset, height / 2],
390
+ ];
391
+ for (const [x, y] of points) {
392
+ if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) continue;
393
+ samples.outsideHits.push(classifyHit(document.elementFromPoint(x, y)));
394
+ }
395
+
396
+ return samples;
397
+ }
398
+
399
+ export function getRegionCoverageProbeSource(): string {
400
+ return probeRegionCoverage.toString();
401
+ }
402
+ ```
403
+
404
+ Add `RegionCoverageSamples` to the end-of-file types block. The probe runs in the browser via `new Function`, so it must stay self-contained — no imports, no outer-scope references; type annotations erase at runtime so `toString()` stays valid. `9` is `XPathResult.FIRST_ORDERED_NODE_TYPE` as a literal.
405
+
406
+ - [x] **Step 3: Run tests to verify they pass**
407
+
408
+ Run: `bun test tests/unit/overlay-detection.test.ts`
409
+ Expected: PASS.
410
+
411
+ - [x] **Step 4: Format and commit**
412
+
413
+ ```bash
414
+ bun run format
415
+ git add src/utils/overlay.ts tests/unit/overlay-detection.test.ts
416
+ git commit -m "feat: region coverage probe and classifier in overlay module"
417
+ ```
418
+
419
+ ---
420
+
421
+ ### Task 3: ActionResult — baseHash, region hash, diff memoization, tool-result payoff
422
+
423
+ **Files:**
424
+ - Modify: `src/action-result.ts`
425
+ - Test: `tests/unit/action-result.test.ts`, `tests/unit/action-result-diff.test.ts`
426
+
427
+ **Interfaces:**
428
+ - Consumes: `Overlay.present`, `Overlay.root` (Task 1).
429
+ - Produces (Tasks 4–10 rely on): `get baseHash(): string`; `getStateHash()` including `region_<name>` for named present regions; memoized `diff(previous)` (same `previous.id` → same `Diff` instance); `public regionSubtree: string | undefined`; `PageDiff.areaOfInterest?: string`.
430
+
431
+ - [x] **Step 1: Write the failing tests**
432
+
433
+ Append to `tests/unit/action-result.test.ts`:
434
+
435
+ ```ts
436
+ describe('region state hash', () => {
437
+ const html = '<html><body><h1>Users</h1></body></html>';
438
+
439
+ it('named region forks the hash; baseHash stays the page hash', () => {
440
+ const plain = new ActionResult({ url: 'https://app.example.com/users', html });
441
+ const withRegion = new ActionResult({
442
+ url: 'https://app.example.com/users',
443
+ html,
444
+ overlay: { type: 'drawer', name: 'Edit User', root: 'aside.panel' },
445
+ });
446
+ expect(withRegion.hash).not.toBe(plain.hash);
447
+ expect(withRegion.hash).toContain('region_edit_user');
448
+ expect(withRegion.baseHash).toBe(plain.hash);
449
+ });
450
+
451
+ it('unnamed region does not fork the hash', () => {
452
+ const plain = new ActionResult({ url: 'https://app.example.com/users', html });
453
+ const unnamed = new ActionResult({ url: 'https://app.example.com/users', html, overlay: { type: 'modal' } });
454
+ expect(unnamed.hash).toBe(plain.hash);
455
+ });
456
+ });
457
+ ```
458
+
459
+ Append to `tests/unit/action-result-diff.test.ts` (reuse that file's existing helpers for building states):
460
+
461
+ ```ts
462
+ describe('diff memoization and areaOfInterest', () => {
463
+ it('returns the same Diff instance for the same previous state', async () => {
464
+ const previous = new ActionResult({ id: 1, url: 'https://app.example.com/users', html: '<html><body><h1>Users</h1></body></html>' });
465
+ const current = new ActionResult({ id: 2, url: 'https://app.example.com/users', html: '<html><body><h1>Users</h1><p>changed</p></body></html>' });
466
+ const first = await current.diff(previous);
467
+ const second = await current.diff(previous);
468
+ expect(second).toBe(first);
469
+ });
470
+
471
+ it('reports the appeared region instead of a collapsed dump', async () => {
472
+ const previous = new ActionResult({ id: 1, url: 'https://app.example.com/users', html: '<html><body><h1>Users</h1></body></html>' });
473
+ const current = new ActionResult({
474
+ id: 2,
475
+ url: 'https://app.example.com/users',
476
+ html: '<html><body><h1>Users</h1><aside class="panel"><h2>Edit User</h2></aside></body></html>',
477
+ overlay: { type: 'drawer', name: 'Edit User', root: 'aside.panel' },
478
+ });
479
+ current.regionSubtree = '<aside class="panel"><h2>Edit User</h2><form><input name="name"><button>Save</button></form></aside>';
480
+ const result = await current.toToolResult(previous, 'aside.panel');
481
+ expect(result.pageDiff?.areaOfInterest).toBe('drawer "Edit User" opened, scope: aside.panel');
482
+ expect(result.pageDiff?.htmlParts).toHaveLength(1);
483
+ expect(result.pageDiff?.htmlParts?.[0].container).toBe('aside.panel');
484
+ expect(result.pageDiff?.htmlParts?.[0].subtree).toContain('Edit User');
485
+ });
486
+ });
487
+ ```
488
+
489
+ Run: `bun test tests/unit/action-result.test.ts tests/unit/action-result-diff.test.ts` — expected FAIL.
490
+
491
+ - [x] **Step 2: Implement hash changes**
492
+
493
+ In `src/action-result.ts` replace `getStateHash()` (currently at :478) with:
494
+
495
+ ```ts
496
+ getStateHash(): string {
497
+ return this.computeStateHash(true);
498
+ }
499
+
500
+ get baseHash(): string {
501
+ return this.computeStateHash(false);
502
+ }
503
+ ```
504
+
505
+ and add the private method (after the public methods, near `consoleErrors`):
506
+
507
+ ```ts
508
+ private computeStateHash(includeRegion: boolean): string {
509
+ const parts: string[] = [];
510
+
511
+ parts.push(this.relativeUrl || this.url || '/');
512
+
513
+ this.extractHeadings(this.html);
514
+
515
+ if (this.h1) parts.push(`h1_${this.h1}`);
516
+ if (this.h2) parts.push(`h2_${this.h2}`);
517
+ if (includeRegion && this.overlay.present && this.overlay.name) parts.push(`region_${this.overlay.name}`);
518
+
519
+ let stateString = slugify(parts.map((part) => part.substring(0, 100)).join('_'));
520
+
521
+ if (stateString.length > 200) {
522
+ stateString = stateString.substring(0, 200);
523
+ if (stateString.endsWith('_')) {
524
+ stateString = stateString.slice(0, -1);
525
+ }
526
+ }
527
+
528
+ return stateString;
529
+ }
530
+ ```
531
+
532
+ `get hash()` already delegates to `getStateHash()` — leave it.
533
+
534
+ - [x] **Step 3: Implement diff memoization and regionSubtree**
535
+
536
+ Add fields next to `overlay`:
537
+
538
+ ```ts
539
+ public regionSubtree: string | undefined = undefined;
540
+ private _diffCache: { previousId: number | undefined; diff: Diff } | null = null;
541
+ ```
542
+
543
+ Replace `diff()` (currently `return Diff.create(this, previousState)`):
544
+
545
+ ```ts
546
+ async diff(previousState: ActionResult | null): Promise<Diff> {
547
+ if (this._diffCache && this._diffCache.previousId === previousState?.id) return this._diffCache.diff;
548
+ const diff = await Diff.create(this, previousState);
549
+ this._diffCache = { previousId: previousState?.id, diff };
550
+ return diff;
551
+ }
552
+ ```
553
+
554
+ - [x] **Step 4: Implement the tool-result payoff**
555
+
556
+ Add to `PageDiff` interface: `areaOfInterest?: string;`
557
+
558
+ In `toToolResult`, replace the block
559
+
560
+ ```ts
561
+ if (diff.htmlParts.length > 0) {
562
+ const collapsed = collapseHtmlParts(await diff.cleanedHtmlParts());
563
+ if (collapsed.length > 0) {
564
+ pageDiff.htmlParts = collapsed;
565
+ }
566
+ }
567
+ ```
568
+
569
+ with:
570
+
571
+ ```ts
572
+ if (this.overlay.present && !previousState.overlay.present) {
573
+ let area = `${this.overlay.type} "${this.overlay.name || 'unnamed'}" opened`;
574
+ if (this.overlay.root) area += `, scope: ${this.overlay.root}`;
575
+ pageDiff.areaOfInterest = area;
576
+ }
577
+
578
+ if (pageDiff.areaOfInterest && this.regionSubtree && this.overlay.root) {
579
+ const htmlConfig = ConfigParser.getInstance().getConfig().html;
580
+ let subtree = await minifyHtml(htmlCombinedSnapshot(this.regionSubtree, htmlConfig?.combined));
581
+ if (subtree.length > HTML_PART_SUBTREE_BUDGET) {
582
+ subtree = `${subtree.slice(0, HTML_PART_SUBTREE_BUDGET)}...<!-- truncated -->`;
583
+ }
584
+ pageDiff.htmlParts = [{ container: this.overlay.root, subtree, added: [], removed: [] }];
585
+ } else if (diff.htmlParts.length > 0) {
586
+ const collapsed = collapseHtmlParts(await diff.cleanedHtmlParts());
587
+ if (collapsed.length > 0) {
588
+ pageDiff.htmlParts = collapsed;
589
+ }
590
+ }
591
+ ```
592
+
593
+ (`minifyHtml`, `htmlCombinedSnapshot`, `ConfigParser` are already imported in this file.)
594
+
595
+ - [x] **Step 5: Run tests**
596
+
597
+ Run: `bun test tests/unit/action-result.test.ts tests/unit/action-result-diff.test.ts tests/unit/action-result-memo.test.ts tests/unit/state-manager.test.ts`
598
+ Expected: PASS. If the `region_edit_user` assertion fails on slug shape, print the hash and adjust the expectation to the actual `slugify` output of `region_Edit User` — the invariant under test is fork + containment, not the separator.
599
+
600
+ - [x] **Step 6: Format and commit**
601
+
602
+ ```bash
603
+ bun run format
604
+ git add src/action-result.ts tests/unit/action-result.test.ts tests/unit/action-result-diff.test.ts
605
+ git commit -m "feat: region-aware state hash, baseHash, memoized diff and areaOfInterest tool results"
606
+ ```
607
+
608
+ ---
609
+
610
+ ### Task 4: Detection pipeline in Action
611
+
612
+ **Files:**
613
+ - Modify: `src/action.ts`
614
+
615
+ **Interfaces:**
616
+ - Consumes: `findAppearedSubRoot`, `classifyRegionCoverage`, `Overlay.fromSubRoot`, `getRegionCoverageProbeSource`, type `RegionCoverageSamples` — all from `./utils/overlay.ts` (Tasks 1–2); `result.diff` memoization + `regionSubtree` (Task 3).
617
+ - Produces: every captured `ActionResult` may now carry a diff-detected `overlay` (`modal`/`drawer`/`region`) and `regionSubtree` before `stateManager.updateState` runs. No new exports.
618
+
619
+ - [x] **Step 1: Wire imports**
620
+
621
+ In `src/action.ts` extend the existing `./utils/overlay.ts` import (currently `import { Overlay } from './utils/overlay.js';` or similar — check) to also bring `classifyRegionCoverage`, `findAppearedSubRoot`, `getRegionCoverageProbeSource` and type `RegionCoverageSamples`.
622
+
623
+ - [x] **Step 2: Hook detection before updateState**
624
+
625
+ In `capturePageState` (src/action.ts:170-188), between `const result = new ActionResult({...})` and `this.stateManager.updateState(result, codeBlock)`:
626
+
627
+ ```ts
628
+ if (!frame) await this.detectRegionOfInterest(result).catch((err: Error) => debugLog('Region detection failed:', err.message));
629
+ this.stateManager.updateState(result, codeBlock);
630
+ ```
631
+
632
+ - [x] **Step 3: Implement the private methods**
633
+
634
+ After the existing private `captureOverlayHtml` (private methods stay after public ones):
635
+
636
+ ```ts
637
+ private async detectRegionOfInterest(result: ActionResult): Promise<void> {
638
+ if (result.overlay.detected) return;
639
+ const previousState = this.stateManager.getCurrentState();
640
+ if (!previousState) return;
641
+ const previous = ActionResult.fromState(previousState);
642
+ if (!previous.html || previous.html === result.html) return;
643
+ if (!result.isSameUrl({ url: previous.url })) return;
644
+
645
+ const diff = await result.diff(previous);
646
+ const subRoot = findAppearedSubRoot(diff.htmlParts);
647
+ if (!subRoot) return;
648
+
649
+ const samples = await this.probeRegion(subRoot.elementXPath);
650
+ const verdict = classifyRegionCoverage(samples);
651
+ result.overlay = Overlay.fromSubRoot(subRoot, verdict);
652
+ result.regionSubtree = subRoot.subtree;
653
+ debugLog(`Region of interest: ${result.overlay.type} "${result.overlay.name}" root=${result.overlay.root} coverage=${verdict.coverage.toFixed(2)}`);
654
+ }
655
+
656
+ private async probeRegion(xpath: string): Promise<RegionCoverageSamples | null> {
657
+ return this.playwrightHelper.page
658
+ .evaluate(
659
+ ({ probeSource, config }: { probeSource: string; config: any }) => {
660
+ const probe = new Function(`return ${probeSource}`)() as (config: any) => any;
661
+ return probe(config);
662
+ },
663
+ { probeSource: getRegionCoverageProbeSource(), config: { xpath } }
664
+ )
665
+ .catch((err: Error) => {
666
+ debugLog('Region coverage probe failed:', err.message);
667
+ return null;
668
+ });
669
+ }
670
+ ```
671
+
672
+ Two guards matter and must not be dropped: `result.overlay.detected` (an ARIA-detected overlay already owns the state) and `isSameUrl` (URL changes are already full state changes with research; the diff path is only for in-place swaps).
673
+
674
+ - [x] **Step 4: Verify nothing regressed**
675
+
676
+ Run: `bun test tests/unit/`
677
+ Expected: PASS (the glue has no unit test — its pure parts are covered by Tasks 1–3; end-to-end behavior is exercised by the local regression harness, which only the user runs).
678
+
679
+ - [x] **Step 5: Format and commit**
680
+
681
+ ```bash
682
+ bun run format
683
+ git add src/action.ts
684
+ git commit -m "feat: detect region of interest from page diff during capture"
685
+ ```
686
+
687
+ ---
688
+
689
+ ### Task 5: StateManager records region states
690
+
691
+ **Files:**
692
+ - Modify: `src/state-manager.ts`
693
+ - Test: `tests/unit/state-manager.test.ts`
694
+
695
+ **Interfaces:**
696
+ - Consumes: `Overlay.present` (Task 1); region-aware `hash` (Task 3).
697
+ - Produces: transitions recorded for region open/close; `tag('data').log('state', …)` payload gains `region` when a region is present. Rename `hasDialogAppeared` → `hasRegionAppeared` (private — no external consumers).
698
+
699
+ - [x] **Step 1: Write the failing tests**
700
+
701
+ Append to `tests/unit/state-manager.test.ts` (reuse that file's existing StateManager construction):
702
+
703
+ ```ts
704
+ describe('region state transitions', () => {
705
+ const html = '<html><body><h1>Users</h1></body></html>';
706
+
707
+ it('records a transition when a named region opens and when it closes', () => {
708
+ const base = new ActionResult({ url: '/users', html });
709
+ stateManager.updateState(base);
710
+ const historyAfterBase = stateManager.getStateHistory().length;
711
+
712
+ const withDrawer = new ActionResult({ url: '/users', html, overlay: { type: 'drawer', name: 'Edit User', root: 'aside.panel' } });
713
+ stateManager.updateState(withDrawer);
714
+ expect(stateManager.getStateHistory().length).toBe(historyAfterBase + 1);
715
+
716
+ const closed = new ActionResult({ url: '/users', html });
717
+ stateManager.updateState(closed);
718
+ expect(stateManager.getStateHistory().length).toBe(historyAfterBase + 2);
719
+ });
720
+
721
+ it('records a transition for an unnamed region via hasRegionAppeared', () => {
722
+ const base = new ActionResult({ url: '/users', html });
723
+ stateManager.updateState(base);
724
+ const historyAfterBase = stateManager.getStateHistory().length;
725
+
726
+ const unnamed = new ActionResult({ url: '/users', html, overlay: { type: 'modal' } });
727
+ stateManager.updateState(unnamed);
728
+ expect(stateManager.getStateHistory().length).toBe(historyAfterBase + 1);
729
+ });
730
+ });
731
+ ```
732
+
733
+ Run: `bun test tests/unit/state-manager.test.ts` — observe which assertions already pass (named open/close comes from the Task 3 hash fork); the tests pin the behavior either way.
734
+
735
+ - [x] **Step 2: Generalize the check**
736
+
737
+ In `src/state-manager.ts`:
738
+
739
+ ```ts
740
+ const hashChanged = actionResult.hash !== previousHash;
741
+ const regionAppeared = !hashChanged && this.hasRegionAppeared(previousState, newState);
742
+
743
+ if (hashChanged || regionAppeared) {
744
+ ```
745
+
746
+ and rename/adjust the private method:
747
+
748
+ ```ts
749
+ private hasRegionAppeared(previousState: WebPageState | null, newState: WebPageState): boolean {
750
+ const prevFocus = previousState?.overlay ?? Overlay.fromAria(previousState?.ariaSnapshot ?? null);
751
+ const newFocus = newState.overlay ?? Overlay.fromAria(newState.ariaSnapshot ?? null);
752
+ return !prevFocus.present && newFocus.present;
753
+ }
754
+ ```
755
+
756
+ Update the debug line inside the branch to `debugLog('State change detected: region of interest appeared');`.
757
+
758
+ - [x] **Step 3: Extend the remote state frame**
759
+
760
+ In `emitStateChange`:
761
+
762
+ ```ts
763
+ const payload: Record<string, unknown> = { url: state.fullUrl || state.url, path: state.url, title: state.title, h1: state.h1 };
764
+ if (state.overlay?.present) payload.region = state.overlay.name || state.overlay.type;
765
+ tag('data').log('state', payload);
766
+ ```
767
+
768
+ - [x] **Step 4: Run tests**
769
+
770
+ Run: `bun test tests/unit/state-manager.test.ts tests/unit/state-manager-events.test.ts`
771
+ Expected: PASS.
772
+
773
+ - [x] **Step 5: Format and commit**
774
+
775
+ ```bash
776
+ bun run format
777
+ git add src/state-manager.ts tests/unit/state-manager.test.ts
778
+ git commit -m "feat: record region-of-interest transitions in state manager"
779
+ ```
780
+
781
+ ---
782
+
783
+ ### Task 6: Experience `root:` envelope
784
+
785
+ **Files:**
786
+ - Modify: `src/experience-tracker.ts`, `src/action-result.ts`, `CLAUDE.md`
787
+ - Test: `tests/unit/experience-tracker.test.ts`
788
+
789
+ **Interfaces:**
790
+ - Consumes: `Overlay.present` / `Overlay.root` (Task 1), region-hashed states (Task 3).
791
+ - Produces: experience frontmatter key `root` (single writer: `ExperienceTracker.ensureExperienceFile`); retrieval gate in `ActionResult.isRelevantExperienceRecord(record: WebPageState & { root?: string }, …)`.
792
+
793
+ - [x] **Step 1: Write the failing tests**
794
+
795
+ Append to `tests/unit/experience-tracker.test.ts`, reusing that file's existing `beforeEach` setup (temp experience dir, tracker construction). The tests need only the `tracker` it already builds:
796
+
797
+ ```ts
798
+ describe('region experience root', () => {
799
+ const html = '<html><body><h1>Users</h1></body></html>';
800
+ const regionOverlay = { type: 'drawer' as const, name: 'Edit User', root: 'aside.panel' };
801
+
802
+ it('writes root frontmatter for a region state', () => {
803
+ const regionState = new ActionResult({ url: '/users', html, overlay: regionOverlay });
804
+ tracker.writeAction(regionState, { title: 'Save the edit form', code: 'I.click("Save")', explanation: '' });
805
+ const { data } = tracker.readExperienceFile(regionState.getStateHash());
806
+ expect(data.root).toBe('aside.panel');
807
+ });
808
+
809
+ it('skips root-scoped records when no region is open, loads them when it matches', () => {
810
+ const regionState = new ActionResult({ url: '/users', html, overlay: regionOverlay });
811
+ tracker.writeAction(regionState, { title: 'Save the edit form', code: 'I.click("Save")', explanation: '' });
812
+
813
+ const baseState = new ActionResult({ url: '/users', html });
814
+ const baseContents = tracker.getRelevantExperience(baseState).map((e) => e.content);
815
+ expect(baseContents.join('\n')).not.toContain('Save the edit form');
816
+
817
+ const openState = new ActionResult({ url: '/users', html, overlay: regionOverlay });
818
+ const openContents = tracker.getRelevantExperience(openState).map((e) => e.content);
819
+ expect(openContents.join('\n')).toContain('Save the edit form');
820
+
821
+ const otherRegion = new ActionResult({ url: '/users', html, overlay: { type: 'drawer' as const, name: 'Filters', root: 'div.filters' } });
822
+ const otherContents = tracker.getRelevantExperience(otherRegion).map((e) => e.content);
823
+ expect(otherContents.join('\n')).not.toContain('Save the edit form');
824
+ });
825
+ });
826
+ ```
827
+
828
+ Run: `bun test tests/unit/experience-tracker.test.ts` — expected FAIL.
829
+
830
+ - [x] **Step 2: Implement the writer**
831
+
832
+ In `src/experience-tracker.ts` `ensureExperienceFile` (currently :118), replace the frontmatter literal:
833
+
834
+ ```ts
835
+ if (!existsSync(filePath)) {
836
+ const frontmatter: Record<string, unknown> = {
837
+ url: state.url ? extractStatePath(state.url) : '',
838
+ title: state.title,
839
+ };
840
+ if (state.overlay.present && state.overlay.root) {
841
+ frontmatter.root = state.overlay.root;
842
+ }
843
+ this.writeExperienceFile(stateHash, '', frontmatter);
844
+ }
845
+ ```
846
+
847
+ Plain `if` — no conditional spread.
848
+
849
+ - [x] **Step 3: Implement the retrieval gate**
850
+
851
+ In `src/action-result.ts` `isRelevantExperienceRecord` (currently :261), widen the signature and add the gate as the first check after the null guard:
852
+
853
+ ```ts
854
+ isRelevantExperienceRecord(record: WebPageState & { root?: string }, options?: { includeDescendantExperience?: boolean }): boolean {
855
+ if (!record.url || !this.url) return false;
856
+ if (record.root) {
857
+ if (!this.overlay.present) return false;
858
+ if (this.overlay.root && this.overlay.root !== record.root) return false;
859
+ }
860
+ if (this.isMatchedBy(record)) return true;
861
+ ```
862
+
863
+ (rest of the method unchanged). A record without `root` behaves exactly as today — envelope rule 3. The `root` gate comes first so behavior does not depend on heading coincidences between region and page states.
864
+
865
+ - [x] **Step 4: Document the envelope key**
866
+
867
+ In `CLAUDE.md`, "Data Envelope Formats" table, Experience row: change the envelope cell from `sparse frontmatter` to `sparse frontmatter: url, title, optional root (region scoping selector — record loads only while a matching region is open)`.
868
+
869
+ - [x] **Step 5: Run tests**
870
+
871
+ Run: `bun test tests/unit/experience-tracker.test.ts tests/unit/experience-compactor.test.ts tests/unit/historian-experience.test.ts`
872
+ Expected: PASS.
873
+
874
+ - [x] **Step 6: Format and commit**
875
+
876
+ ```bash
877
+ bun run format
878
+ git add src/experience-tracker.ts src/action-result.ts CLAUDE.md tests/unit/experience-tracker.test.ts
879
+ git commit -m "feat: root selector envelope key scopes experience to open regions"
880
+ ```
881
+
882
+ ---
883
+
884
+ ### Task 7: Researcher — baseHash keys, widened overlay research
885
+
886
+ **Files:**
887
+ - Modify: `src/ai/researcher.ts`, `src/ai/researcher/deep-analysis.ts`
888
+
889
+ **Interfaces:**
890
+ - Consumes: `baseHash` (Task 3), `Overlay.present` (Task 1).
891
+ - Produces: research cache keyed by `baseHash` (region states share the page's research); `researchOverlay` fires for any named present region, not only dialog/modal.
892
+
893
+ - [x] **Step 1: Key the cache by baseHash**
894
+
895
+ In `src/ai/researcher.ts`:
896
+
897
+ At :78-80 replace the static helper body:
898
+
899
+ ```ts
900
+ static getCachedResearch(state: WebPageState): string {
901
+ return getCachedResearch(ActionResult.fromState(state).baseHash);
902
+ }
903
+ ```
904
+
905
+ At :99 replace `const stateHash = state.hash || this.actionResult.getStateHash();` with:
906
+
907
+ ```ts
908
+ const stateHash = this.actionResult.baseHash;
909
+ ```
910
+
911
+ Then run `grep -n "\.hash" src/ai/researcher.ts src/ai/researcher/*.ts` and audit each hit: cache reads/writes (`getCachedResearch`, `saveResearch`, `getPreviousResearch`, `researchPath` keys) move to `baseHash`; state-equality comparisons (e.g. `getStateHash() === getCurrentState()?.hash` at :154 and :317) stay full-hash — both sides use the same computation, so they remain consistent.
912
+
913
+ - [x] **Step 2: Widen researchOverlay**
914
+
915
+ In `src/ai/researcher/deep-analysis.ts` at :93-95 replace:
916
+
917
+ ```ts
918
+ const focusArea = current.overlay;
919
+ if (!focusArea.detected || !focusArea.name) return null;
920
+ if (focusArea.type !== 'dialog' && focusArea.type !== 'modal') return null;
921
+ ```
922
+
923
+ with:
924
+
925
+ ```ts
926
+ const focusArea = current.overlay;
927
+ if (!focusArea.present || !focusArea.name) return null;
928
+ ```
929
+
930
+ - [x] **Step 3: Run tests**
931
+
932
+ Run: `bun test tests/unit/ && bun test tests/integration/`
933
+ Expected: PASS. Failures here mean a cache-key call site was converted that should not have been (or vice versa) — re-audit the grep list before changing anything else.
934
+
935
+ - [x] **Step 4: Format and commit**
936
+
937
+ ```bash
938
+ bun run format
939
+ git add src/ai/researcher.ts src/ai/researcher/deep-analysis.ts
940
+ git commit -m "feat: key research by base page hash and research any named region"
941
+ ```
942
+
943
+ ---
944
+
945
+ ### Task 8: Tester context — focus scope root, area of interest
946
+
947
+ **Files:**
948
+ - Modify: `src/ai/tester.ts`
949
+
950
+ **Interfaces:**
951
+ - Consumes: `Overlay.present`/`root` (Task 1), `baseHash` (Task 3), widened `researchOverlay` (Task 7).
952
+ - Produces: `<focus_scope>` carries the root selector; new `<area_of_interest>` block for inline regions injected once per state change; `pageStateHash` holds `baseHash`.
953
+
954
+ - [x] **Step 1: Track state-change trigger**
955
+
956
+ In `reinjectContextIfNeeded` (src/ai/tester.ts:528), replace the tracking prologue:
957
+
958
+ ```ts
959
+ const isNewUrl = this.previousUrl !== currentUrl;
960
+
961
+ this.previousUrl = currentUrl;
962
+ this.previousStateHash = currentStateHash;
963
+ ```
964
+
965
+ with:
966
+
967
+ ```ts
968
+ const isNewUrl = this.previousUrl !== currentUrl;
969
+ const isNewState = !isNewUrl && this.previousStateHash !== null && this.previousStateHash !== currentStateHash;
970
+
971
+ this.previousUrl = currentUrl;
972
+ this.previousStateHash = currentStateHash;
973
+ ```
974
+
975
+ - [x] **Step 2: Root selector in focus_scope**
976
+
977
+ In the `if (focusArea.detected)` block (currently :558), add before `context +=`:
978
+
979
+ ```ts
980
+ let rootHint = '';
981
+ if (focusArea.root) rootHint = `\nIts content lives inside \`${focusArea.root}\` — scope locators to it.`;
982
+ ```
983
+
984
+ and change the first line of the dedent block to:
985
+
986
+ ```
987
+ A ${focusArea.type}${areaName} is currently open above the page.${rootHint}
988
+ ```
989
+
990
+ (the rest of the block unchanged — the strict "not actionable outside" wording stays, and stays gated on `detected`, i.e. on a probe-verified or ARIA-verified overlay).
991
+
992
+ - [x] **Step 3: Inline area_of_interest block**
993
+
994
+ Immediately after the `if (focusArea.detected) { ... }` block add:
995
+
996
+ ```ts
997
+ if (!focusArea.detected && focusArea.present && isNewState) {
998
+ let rootHint = '';
999
+ if (focusArea.root) rootHint = `\nIt lives inside \`${focusArea.root}\`.`;
1000
+ context += dedent`
1001
+ <area_of_interest>
1002
+ A large new area "${focusArea.name || 'unnamed area'}" appeared on this page without navigation.${rootHint}
1003
+ The scenario most likely continues inside this area — prefer its elements for your next actions.
1004
+ The rest of the page (navigation, menus, filters) is still interactive and remains available.
1005
+ </area_of_interest>
1006
+ `;
1007
+ }
1008
+ ```
1009
+
1010
+ General wording only — no element names, no site specifics.
1011
+
1012
+ - [x] **Step 4: baseHash for research keys and widened overlay-research gate**
1013
+
1014
+ At :592 replace `this.pageStateHash = currentStateHash;` with:
1015
+
1016
+ ```ts
1017
+ this.pageStateHash = currentState.baseHash;
1018
+ ```
1019
+
1020
+ At :630 replace the condition `if (focusArea.detected && focusArea.name && this.pageStateHash && this.pageActionResult)` with:
1021
+
1022
+ ```ts
1023
+ if (focusArea.present && focusArea.name && this.pageStateHash && this.pageActionResult) {
1024
+ ```
1025
+
1026
+ - [x] **Step 5: Run tests**
1027
+
1028
+ Run: `bun test tests/unit/ && bun test tests/integration/`
1029
+ Expected: PASS (prompt changes must go through the integration suite before pushing — house rule).
1030
+
1031
+ - [x] **Step 6: Format and commit**
1032
+
1033
+ ```bash
1034
+ bun run format
1035
+ git add src/ai/tester.ts
1036
+ git commit -m "feat: tester context carries region root and inline area of interest"
1037
+ ```
1038
+
1039
+ ---
1040
+
1041
+ ### Task 9: Pilot state context and prompt
1042
+
1043
+ **Files:**
1044
+ - Modify: `src/ai/pilot.ts`
1045
+ - Test: `tests/unit/pilot-state-context.test.ts`
1046
+
1047
+ **Interfaces:**
1048
+ - Consumes: `Overlay.present`/`root` (Task 1).
1049
+ - Produces: `<state>` shows `modal: <name> (root: <selector>)` for verified overlays and `region: <name> (inline, root: <selector>)` for inline regions; one general diagnostic bullet in the Pilot system prompt.
1050
+
1051
+ **Note:** `src/ai/pilot.ts` and this test file carry uncommitted in-flight changes — read both fully before editing and integrate, do not revert anything.
1052
+
1053
+ - [x] **Step 1: Write the failing tests**
1054
+
1055
+ Append to `tests/unit/pilot-state-context.test.ts`, following that file's existing pattern for building an `ActionResult` and reading `buildStateContext` output:
1056
+
1057
+ ```ts
1058
+ it('shows verified overlay with its root', () => {
1059
+ const state = new ActionResult({ url: '/users', html: '<html><body><h1>Users</h1></body></html>', overlay: { type: 'drawer', name: 'Edit User', root: 'aside.panel' } });
1060
+ const context = buildContext(state);
1061
+ expect(context).toContain('modal: Edit User (root: aside.panel)');
1062
+ });
1063
+
1064
+ it('shows inline region distinctly from a modal', () => {
1065
+ const state = new ActionResult({ url: '/users', html: '<html><body><h1>Users</h1></body></html>', overlay: { type: 'region', name: 'User Details', root: 'section.details' } });
1066
+ const context = buildContext(state);
1067
+ expect(context).toContain('region: User Details (inline, root: section.details)');
1068
+ expect(context).not.toContain('modal: User Details');
1069
+ });
1070
+ ```
1071
+
1072
+ (`buildContext` here stands for however the existing tests invoke `buildStateContext` — reuse their helper verbatim.)
1073
+
1074
+ Run: `bun test tests/unit/pilot-state-context.test.ts` — expected FAIL.
1075
+
1076
+ - [x] **Step 2: Implement the state lines**
1077
+
1078
+ In `src/ai/pilot.ts` `buildStateContext` (currently :828-834) replace:
1079
+
1080
+ ```ts
1081
+ const focusArea = state.overlay;
1082
+ if (focusArea.detected) {
1083
+ lines.push(`modal: ${focusArea.name || focusArea.type}`);
1084
+ } else {
1085
+ lines.push('modal: none');
1086
+ }
1087
+ ```
1088
+
1089
+ with:
1090
+
1091
+ ```ts
1092
+ const focusArea = state.overlay;
1093
+ if (focusArea.detected) {
1094
+ let line = `modal: ${focusArea.name || focusArea.type}`;
1095
+ if (focusArea.root) line += ` (root: ${focusArea.root})`;
1096
+ lines.push(line);
1097
+ } else if (focusArea.present) {
1098
+ let line = `region: ${focusArea.name || 'unnamed'} (inline`;
1099
+ if (focusArea.root) line += `, root: ${focusArea.root}`;
1100
+ lines.push(`${line})`);
1101
+ } else {
1102
+ lines.push('modal: none');
1103
+ }
1104
+ ```
1105
+
1106
+ - [x] **Step 3: One general prompt bullet**
1107
+
1108
+ In `getSystemPrompt`, in the "Diagnostic patterns" list, add one line:
1109
+
1110
+ ```
1111
+ - "region:" in <state> → a large area appeared in place without navigation (subview, wizard step, panel). Direct Tester to act inside it; the rest of the page is still usable.
1112
+ ```
1113
+
1114
+ Nothing else in the prompt changes.
1115
+
1116
+ - [x] **Step 4: Run tests**
1117
+
1118
+ Run: `bun test tests/unit/pilot-state-context.test.ts && bun test tests/integration/`
1119
+ Expected: PASS.
1120
+
1121
+ - [x] **Step 5: Format and commit**
1122
+
1123
+ ```bash
1124
+ bun run format
1125
+ git add src/ai/pilot.ts tests/unit/pilot-state-context.test.ts
1126
+ git commit -m "feat: pilot state context distinguishes overlaying modals from inline regions"
1127
+ ```
1128
+
1129
+ ---
1130
+
1131
+ ### Task 10: Driller — nested overlay context from pageDiff
1132
+
1133
+ **Files:**
1134
+ - Modify: `src/ai/driller.ts`
1135
+ - Test: `tests/unit/driller.test.ts` (run, extend only if it covers `detectNestedOverlayContext`)
1136
+
1137
+ **Interfaces:**
1138
+ - Consumes: `pageDiff.htmlParts` / `pageDiff.areaOfInterest` from tool results (Task 3).
1139
+ - Produces: `detectNestedOverlayContext` no longer queries the live DOM; `Driller.getVisibleOverlayHtml` is deleted along with its imports (`getVisibleOverlayHtmlExtractorSource`, `OVERLAY_SELECTORS`, and any `HTML_*` config constants imported only for it).
1140
+
1141
+ - [x] **Step 1: Replace the DOM query with the diff the result already carries**
1142
+
1143
+ In `src/ai/driller.ts` `detectNestedOverlayContext` (currently :648), replace the overlay-fetch prologue:
1144
+
1145
+ ```ts
1146
+ if (!result?.pageDiff?.ariaChanges || result.pageDiff.urlChanged) return null;
1147
+
1148
+ const overlayHtml = await this.getVisibleOverlayHtml();
1149
+ if (!overlayHtml) return null;
1150
+ ```
1151
+
1152
+ with:
1153
+
1154
+ ```ts
1155
+ if (!result?.pageDiff?.ariaChanges || result.pageDiff.urlChanged) return null;
1156
+
1157
+ const parts = result.pageDiff.htmlParts ?? [];
1158
+ let appeared = parts.filter((part: any) => part.added?.length > 0);
1159
+ if (result.pageDiff.areaOfInterest) appeared = parts;
1160
+ const overlayHtml = appeared.map((part: any) => part.subtree).join('\n');
1161
+ if (!overlayHtml) return null;
1162
+ ```
1163
+
1164
+ The rest of the method (the `<nested_overlay>` dedent block) is unchanged — `overlayHtml` keeps its name and role in the prompt.
1165
+
1166
+ - [x] **Step 2: Delete the private extractor**
1167
+
1168
+ Remove the whole `private async getVisibleOverlayHtml()` method (currently :674-692). Then remove from the imports at the top of `driller.ts`: `getVisibleOverlayHtmlExtractorSource`, `OVERLAY_SELECTORS`, and each of `HTML_SELECTORS` / `HTML_EXTRACTION_LIMITS` / `HTML_VISIBILITY_LIMITS` **only if** `grep -n "<name>" src/ai/driller.ts` shows no remaining use in this file.
1169
+
1170
+ - [x] **Step 3: Run tests**
1171
+
1172
+ Run: `bun test tests/unit/driller.test.ts && bun test tests/unit/`
1173
+ Expected: PASS.
1174
+
1175
+ - [x] **Step 4: Format and commit**
1176
+
1177
+ ```bash
1178
+ bun run format
1179
+ git add src/ai/driller.ts
1180
+ git commit -m "refactor: driller reads nested overlays from page diff instead of DOM queries"
1181
+ ```
1182
+
1183
+ ---
1184
+
1185
+ ### Task 11: Delete the selector-based overlay path
1186
+
1187
+ **Files:**
1188
+ - Modify: `src/action.ts`, `src/action-result.ts`, `src/utils/overlay.ts`, `src/utils/html.ts`
1189
+ - Test: `tests/unit/overlay-detection.test.ts`
1190
+
1191
+ **Interfaces:**
1192
+ - Consumes: everything new from Tasks 1–10 (the replacements must be in place first).
1193
+ - Produces: `Overlay.resolve(data: { overlay?: OverlayData | null; ariaSnapshot?: string | null })` — narrowed signature, no `overlayHtml`. Deleted symbols (per the spec's "Removed code" table): `Action.captureOverlayHtml`, `ActionResultData.overlayHtml`, `Overlay.fromHtml`, `Overlay.captureConfig`, `OVERLAY_SELECTORS`, `extractVisibleOverlayHtml`, `getVisibleOverlayHtmlExtractorSource`, `VisibleOverlayExtractionConfig`.
1194
+
1195
+ - [x] **Step 1: Update the tests first**
1196
+
1197
+ In `tests/unit/overlay-detection.test.ts`:
1198
+ - Delete the `describe('extractVisibleOverlayHtml', …)` block and the `overlayConfig` helper plus the now-unused imports (`extractVisibleOverlayHtml`, `VisibleOverlayExtractionConfig`, `OVERLAY_SELECTORS`, `HTML_*` constants — keep any that other tests in the file still use).
1199
+ - Rewrite the `Overlay.resolve` tests that pass `overlayHtml` (currently around :129 and :153) to assert the narrowed behavior:
1200
+
1201
+ ```ts
1202
+ it('resolve prefers stored overlay data over aria', () => {
1203
+ const overlay = Overlay.resolve({ overlay: { type: 'modal', name: 'Stored' }, ariaSnapshot: aria });
1204
+ expect(overlay.name).toBe('Stored');
1205
+ });
1206
+
1207
+ it('resolve falls back to aria detection', () => {
1208
+ expect(Overlay.resolve({ ariaSnapshot: aria }).detected).toBe(true);
1209
+ });
1210
+ ```
1211
+
1212
+ (adapt the `aria` fixture to whatever the file already defines).
1213
+
1214
+ Run: `bun test tests/unit/overlay-detection.test.ts` — expected FAIL (resolve still accepts overlayHtml, extractor still exists — the failures confirm the tests now demand the deletion).
1215
+
1216
+ - [x] **Step 2: Delete in overlay.ts**
1217
+
1218
+ Remove `OVERLAY_SELECTORS`, `Overlay.fromHtml`, `Overlay.captureConfig`, and the `overlayHtml` branch of `resolve`:
1219
+
1220
+ ```ts
1221
+ static resolve(data: { overlay?: OverlayData | null; ariaSnapshot?: string | null }): Overlay {
1222
+ if (data.overlay) return new Overlay(data.overlay);
1223
+ return Overlay.fromAria(data.ariaSnapshot ?? null);
1224
+ }
1225
+ ```
1226
+
1227
+ Prune imports that only served the deleted code (`HTML_EXTRACTION_LIMITS`, `HTML_SELECTORS`, `HTML_VISIBILITY_LIMITS`, `VisibleOverlayExtractionConfig`). `nameFromHtml` stays — `fromSubRoot` uses it.
1228
+
1229
+ - [x] **Step 3: Delete in action.ts and action-result.ts**
1230
+
1231
+ - `src/action.ts`: remove the `captureOverlayHtml` method; remove `let overlayHtml = '';`, `if (!frame) overlayHtml = await this.captureOverlayHtml();` and the `overlayHtml: overlayHtml || undefined,` constructor line in `capturePageState`; drop `getVisibleOverlayHtmlExtractorSource` from imports.
1232
+ - `src/action-result.ts`: remove `overlayHtml?: string;` from `ActionResultData`.
1233
+
1234
+ - [x] **Step 4: Delete in html.ts**
1235
+
1236
+ Remove `extractVisibleOverlayHtml`, `getVisibleOverlayHtmlExtractorSource`, and the `VisibleOverlayExtractionConfig` interface. For each limit field used only by them (`overlayHtmlLength`, `maxOverlayCount`, `minOverlayWidth`, `minOverlayHeight`, `maxViewportOverlayRatio`, `minOpacity`): run `grep -rn "<field>" src/` and delete the field only when the extractor was its sole consumer — shared visibility limits used by other extractors stay.
1237
+
1238
+ - [x] **Step 5: Verify the path is gone**
1239
+
1240
+ ```bash
1241
+ grep -rn "extractVisibleOverlayHtml\|getVisibleOverlayHtmlExtractorSource\|OVERLAY_SELECTORS\|captureConfig\|overlayHtml\|Overlay.fromHtml" src/ tests/
1242
+ ```
1243
+
1244
+ Expected: no hits in `src/` (test-fixture prose mentioning "overlay" is fine; symbol references are not).
1245
+
1246
+ - [x] **Step 6: Run tests**
1247
+
1248
+ Run: `bun test tests/unit/ && bun test tests/integration/`
1249
+ Expected: PASS.
1250
+
1251
+ - [x] **Step 7: Format and commit**
1252
+
1253
+ ```bash
1254
+ bun run format
1255
+ git add src/action.ts src/action-result.ts src/utils/overlay.ts src/utils/html.ts tests/unit/overlay-detection.test.ts
1256
+ git commit -m "refactor: remove selector-based overlay detection; overlay.ts is the single detection module"
1257
+ ```
1258
+
1259
+ ---
1260
+
1261
+ ### Task 12: Finalization
1262
+
1263
+ **Files:**
1264
+ - Modify: `CHANGELOG.md` (via the `/changelog` skill)
1265
+
1266
+ - [x] **Step 1: Full verification**
1267
+
1268
+ ```bash
1269
+ bun run format
1270
+ bun run lint:fix
1271
+ bun test tests/unit/
1272
+ bun test tests/integration/
1273
+ ```
1274
+
1275
+ Expected: everything green. Fix regressions before proceeding; do not skip failing tests.
1276
+
1277
+ - [x] **Step 2: Dedup pass**
1278
+
1279
+ Run the code-duplication-detector agent over the changed files (house rule after major changes). Apply only findings that touch code introduced by this plan.
1280
+
1281
+ - [x] **Step 3: Changelog**
1282
+
1283
+ Invoke the `/changelog` skill to add the entry for this feature, then commit:
1284
+
1285
+ ```bash
1286
+ git add CHANGELOG.md
1287
+ git commit -m "docs: changelog for region-of-interest states"
1288
+ ```
1289
+
1290
+ - [x] **Step 4: Report**
1291
+
1292
+ Report to the user: what was built, what was deleted (the spec's "Removed code" table), test results, and that end-to-end validation against a real app is available via the local `regression:*` bunosh commands — which only the user decides to run. Never trigger the regression CI workflow.