explorbot 0.2.3 → 0.2.4

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 (156) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +7 -1
  3. package/boat/api-tester/src/config.ts +4 -2
  4. package/boat/doc-collector/bin/doc-collector-cli.ts +2 -0
  5. package/boat/doc-collector/src/ai/documentarian.ts +61 -31
  6. package/boat/doc-collector/src/config.ts +4 -2
  7. package/boat/prima/bin/prima-cli.ts +0 -0
  8. package/boat/prima/src/activity-line.ts +33 -0
  9. package/boat/prima/src/cli.ts +115 -86
  10. package/boat/prima/src/envelope.ts +80 -52
  11. package/boat/prima/src/prima.ts +547 -128
  12. package/boat/prima/src/pw-parser.ts +11 -1
  13. package/boat/prima/src/pw-registry.ts +4 -5
  14. package/boat/prima/src/session-log.ts +126 -0
  15. package/dist/bin/explorbot-cli.js +7 -1
  16. package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
  17. package/dist/boat/api-tester/src/config.js +4 -2
  18. package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -0
  19. package/dist/boat/doc-collector/src/ai/documentarian.js +44 -19
  20. package/dist/boat/doc-collector/src/config.js +4 -2
  21. package/dist/boat/prima/src/activity-line.js +30 -0
  22. package/dist/boat/prima/src/cli.js +97 -77
  23. package/dist/boat/prima/src/envelope.js +70 -44
  24. package/dist/boat/prima/src/prima.js +515 -119
  25. package/dist/boat/prima/src/pw-parser.js +13 -1
  26. package/dist/boat/prima/src/pw-registry.js +4 -5
  27. package/dist/boat/prima/src/session-log.js +108 -0
  28. package/dist/package.json +3 -2
  29. package/dist/rules/navigator/verification-actions.md +20 -0
  30. package/dist/src/action-result.d.ts +7 -0
  31. package/dist/src/action-result.js +4 -0
  32. package/dist/src/action.d.ts +2 -0
  33. package/dist/src/action.js +36 -1
  34. package/dist/src/ai/captain/web-mode.js +6 -3
  35. package/dist/src/ai/captain.js +2 -0
  36. package/dist/src/ai/navigator.d.ts +7 -0
  37. package/dist/src/ai/navigator.js +10 -6
  38. package/dist/src/ai/pilot.d.ts +4 -0
  39. package/dist/src/ai/pilot.js +48 -2
  40. package/dist/src/ai/provider.js +2 -2
  41. package/dist/src/ai/rerunner.js +2 -1
  42. package/dist/src/ai/researcher.js +1 -1
  43. package/dist/src/ai/rules.js +17 -10
  44. package/dist/src/ai/task-agent.js +4 -1
  45. package/dist/src/ai/tester.d.ts +1 -1
  46. package/dist/src/ai/tester.js +33 -33
  47. package/dist/src/ai/tools.d.ts +14 -0
  48. package/dist/src/ai/tools.js +113 -36
  49. package/dist/src/config.d.ts +1 -0
  50. package/dist/src/config.js +13 -4
  51. package/dist/src/execution-controller.d.ts +2 -0
  52. package/dist/src/execution-controller.js +6 -0
  53. package/dist/src/explorbot.d.ts +2 -1
  54. package/dist/src/explorbot.js +3 -1
  55. package/dist/src/explorer.js +2 -3
  56. package/dist/src/playwright-recorder.js +30 -0
  57. package/dist/src/remote.d.ts +54 -0
  58. package/dist/src/remote.js +229 -0
  59. package/dist/src/reporter.d.ts +1 -0
  60. package/dist/src/reporter.js +7 -1
  61. package/dist/src/state-manager.d.ts +2 -1
  62. package/dist/src/stats.d.ts +1 -0
  63. package/dist/src/stats.js +1 -0
  64. package/dist/src/test-plan.d.ts +2 -0
  65. package/dist/src/test-plan.js +7 -0
  66. package/dist/src/utils/aria.d.ts +2 -8
  67. package/dist/src/utils/aria.js +69 -40
  68. package/dist/src/utils/html.js +1 -0
  69. package/dist/src/utils/logger.d.ts +6 -0
  70. package/dist/src/utils/logger.js +24 -0
  71. package/dist/src/utils/page-readiness.js +18 -1
  72. package/dist/src/utils/url-matcher.js +3 -0
  73. package/dist/src/utils/web-element.d.ts +2 -0
  74. package/dist/src/utils/web-element.js +8 -0
  75. package/dist/src/utils/web-sandbox.d.ts +1 -1
  76. package/dist/src/utils/web-sandbox.js +2 -3
  77. package/docs/api-testing/basics.md +90 -0
  78. package/docs/api-testing/planning.md +57 -0
  79. package/docs/api-testing/running-tests.md +55 -0
  80. package/docs/assets/cloud-report.png +0 -0
  81. package/docs/assets/html-report.png +0 -0
  82. package/docs/assets/langfuse-trace.png +0 -0
  83. package/docs/assets/successful-explore-run.png +0 -0
  84. package/docs/basics/getting-started.md +140 -0
  85. package/docs/basics/prerequisites.md +63 -0
  86. package/docs/basics/providers.md +362 -0
  87. package/docs/basics/running.md +78 -0
  88. package/docs/contributing/ai-integration-tests.md +57 -0
  89. package/docs/contributing/contributing.md +90 -0
  90. package/docs/contributing/demo-videos.md +36 -0
  91. package/docs/contributing/npm-package.md +138 -0
  92. package/docs/contributing/observability.md +227 -0
  93. package/docs/contributing/regression-tests.md +103 -0
  94. package/docs/contributing/testing.md +95 -0
  95. package/docs/doc-collection/basics.md +128 -0
  96. package/docs/doc-collection/crawling.md +67 -0
  97. package/docs/doc-collection/interactive-mode.md +99 -0
  98. package/docs/index.json +86 -0
  99. package/docs/reference/commands.md +994 -0
  100. package/docs/reference/configuration.md +569 -0
  101. package/docs/reference/scripting.md +303 -0
  102. package/docs/superpowers/plans/2026-08-01-actor-boat.md +925 -0
  103. package/docs/superpowers/plans/2026-08-01-prima-boat.md +1120 -0
  104. package/docs/superpowers/reviews/2026-08-06-prima-vs-playwright-cli.md +268 -0
  105. package/docs/superpowers/specs/2026-08-01-actor-boat-design.md +204 -0
  106. package/docs/superpowers/specs/2026-08-01-prima-boat-design.md +242 -0
  107. package/docs/superpowers/specs/2026-08-03-global-config-design.md +138 -0
  108. package/docs/superpowers/specs/2026-08-07-prima-fixes-design.md +394 -0
  109. package/docs/web-testing/agents.md +158 -0
  110. package/docs/web-testing/automated-tests.md +134 -0
  111. package/docs/web-testing/basics.md +91 -0
  112. package/docs/web-testing/customization.md +131 -0
  113. package/docs/web-testing/hooks.md +238 -0
  114. package/docs/web-testing/page-interaction.md +84 -0
  115. package/docs/web-testing/planner.md +122 -0
  116. package/docs/web-testing/rerun.md +164 -0
  117. package/docs/web-testing/researcher.md +380 -0
  118. package/docs/workflow/agentic-usage.md +233 -0
  119. package/docs/workflow/application-spec.md +73 -0
  120. package/docs/workflow/ci.md +202 -0
  121. package/docs/workflow/knowledge.md +310 -0
  122. package/docs/workflow/planning-styles.md +67 -0
  123. package/docs/workflow/reporting.md +133 -0
  124. package/docs/workflow/test-plans.md +90 -0
  125. package/package.json +3 -2
  126. package/rules/navigator/verification-actions.md +20 -0
  127. package/src/action-result.ts +11 -0
  128. package/src/action.ts +38 -2
  129. package/src/ai/captain/web-mode.ts +6 -3
  130. package/src/ai/captain.ts +3 -0
  131. package/src/ai/navigator.ts +14 -8
  132. package/src/ai/pilot.ts +53 -2
  133. package/src/ai/provider.ts +2 -4
  134. package/src/ai/rerunner.ts +2 -1
  135. package/src/ai/researcher.ts +1 -1
  136. package/src/ai/rules.ts +17 -10
  137. package/src/ai/task-agent.ts +3 -1
  138. package/src/ai/tester.ts +32 -33
  139. package/src/ai/tools.ts +132 -36
  140. package/src/config.ts +15 -4
  141. package/src/execution-controller.ts +8 -0
  142. package/src/explorbot.ts +4 -2
  143. package/src/explorer.ts +2 -2
  144. package/src/playwright-recorder.ts +23 -0
  145. package/src/remote.ts +238 -0
  146. package/src/reporter.ts +7 -1
  147. package/src/state-manager.ts +2 -1
  148. package/src/stats.ts +1 -0
  149. package/src/test-plan.ts +9 -0
  150. package/src/utils/aria.ts +65 -45
  151. package/src/utils/html.ts +1 -0
  152. package/src/utils/logger.ts +24 -1
  153. package/src/utils/page-readiness.ts +24 -1
  154. package/src/utils/url-matcher.ts +3 -0
  155. package/src/utils/web-element.ts +9 -0
  156. package/src/utils/web-sandbox.ts +3 -4
@@ -0,0 +1,925 @@
1
+ # Actor Boat 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:** Ship `boat/actor` — an intent-level browser driver CLI (`explorbot act ...`) that lets an orchestrating agent act on pages via Playwright calls or natural language while explorbot's cheap models handle perception, healing, and evidence.
6
+
7
+ **Architecture:** A boat following the `boat/doc-collector` pattern: `Actor` class wraps `ExplorBot`, reuses Navigator/Researcher/Explorer/StateManager, adds a pure envelope renderer and a pw function wrapper. Core changes are minimal: named browser-server instances, a Navigator heal-attempt hook, and a global-config/per-host-state ladder in config loading.
8
+
9
+ **Tech Stack:** Bun (never Node), TypeScript, commander, CodeceptJS/Playwright via Explorer, `bun:test`, Biome.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-08-01-actor-boat-design.md` — read it first.
12
+
13
+ **Execution model:** Run implementation subagents on Opus (user directive).
14
+
15
+ ## Global Constraints
16
+
17
+ - Bun only; never Node.js APIs where Bun equivalents exist; tests via `bun:test`.
18
+ - No code comments unless explicitly specified; premature exit over if/else; no ternary operators; no `...(cond ? {} : {})` spreads; `?.` over `&&` chains; private methods after public; types at end of file; `dedent` for prompts.
19
+ - Prompts and tool descriptions must be GENERAL — never encode a specific failing example.
20
+ - Business logic lives in the `Actor` class / agents; CLI handlers only parse options and delegate (repo rule).
21
+ - Run `bun run format` after each code change; `bun run lint:fix` after big ones.
22
+ - Clean stdout: respect `EXPLORBOT_NO_BANNER`; envelope output goes to `console.log`, logs go through `tag()` logger.
23
+ - DO NOT duplicate existing code — reuse `src/utils/aria.ts`, `src/action-result.ts`, `src/browser-server.ts`, Historian converters.
24
+
25
+ ## File Structure
26
+
27
+ ```
28
+ boat/actor/
29
+ ├── package.json # name "actbot", bin actbot
30
+ ├── bin/actbot-cli.ts # standalone CLI entry
31
+ ├── src/
32
+ │ ├── cli.ts # createActCommands(name = 'act')
33
+ │ ├── actor.ts # Actor class (all business logic)
34
+ │ ├── envelope.ts # EnvelopeData type, renderEnvelope(), writeArtifacts()
35
+ │ └── pw-parser.ts # isFunctionExpression(), toCodeceptWrapper()
36
+ └── tests/
37
+ ├── envelope.test.ts
38
+ ├── pw-parser.test.ts
39
+ └── actor.test.ts # duck-typed ExplorBot mocks
40
+ Core modifications:
41
+ ├── src/browser-server.ts # named instances (endpoint file per instance)
42
+ ├── src/ai/navigator.ts # resolveState onAttempt hook (small)
43
+ ├── src/config.ts # global config + global .env + per-host state dir
44
+ └── bin/explorbot-cli.ts # program.addCommand(createActCommands('act'))
45
+ ```
46
+
47
+ ---
48
+
49
+ ### Task 1: Envelope module
50
+
51
+ **Files:**
52
+ - Create: `boat/actor/package.json`, `boat/actor/src/envelope.ts`
53
+ - Test: `boat/actor/tests/envelope.test.ts`
54
+
55
+ **Interfaces:**
56
+ - Produces (used by Tasks 3–7 and 9):
57
+
58
+ ```typescript
59
+ export interface InstanceInfo {
60
+ name: string;
61
+ tabs: number;
62
+ startedAgo?: string;
63
+ others: Array<{ name: string; tabs: number }>;
64
+ }
65
+ export interface HealAttempt {
66
+ code: string;
67
+ outcome: string;
68
+ }
69
+ export interface EnvelopeData {
70
+ ok: boolean;
71
+ command: string;
72
+ healed?: boolean;
73
+ healNote?: string;
74
+ used?: string[];
75
+ page: { url: string; previousUrl?: string; title: string; state: string; visits: number };
76
+ changes?: string | null;
77
+ answer?: string;
78
+ research?: string;
79
+ verdict?: { passed: boolean; evidence: string; code: string };
80
+ failure?: { error: string; attempts: HealAttempt[]; reasoning?: string; compactAria?: string };
81
+ instance: InstanceInfo;
82
+ artifacts?: { aria: string; html: string; network: string };
83
+ }
84
+ export function renderEnvelope(data: EnvelopeData): string;
85
+ export function writeArtifacts(dir: string, snapshot: { aria: string | null; html: string | null; requests: unknown[] }): { aria: string; html: string; network: string };
86
+ ```
87
+
88
+ - [ ] **Step 1: Scaffold the boat package**
89
+
90
+ `boat/actor/package.json` (mirror `boat/api-tester/package.json` shape):
91
+
92
+ ```json
93
+ {
94
+ "name": "actbot",
95
+ "version": "1.0.0",
96
+ "description": "High-level browser driver CLI for orchestrating agents",
97
+ "type": "module",
98
+ "bin": { "actbot": "./bin/actbot-cli.ts" },
99
+ "scripts": {
100
+ "format": "biome format --write .",
101
+ "lint:fix": "biome lint --write .",
102
+ "check:fix": "biome check --write ."
103
+ },
104
+ "dependencies": {
105
+ "commander": "^14.0.1",
106
+ "dedent": "^1.6.0"
107
+ }
108
+ }
109
+ ```
110
+
111
+ - [ ] **Step 2: Write failing envelope tests**
112
+
113
+ `boat/actor/tests/envelope.test.ts`:
114
+
115
+ ```typescript
116
+ import { describe, expect, test } from 'bun:test';
117
+ import { mkdtempSync } from 'node:fs';
118
+ import { tmpdir } from 'node:os';
119
+ import path from 'node:path';
120
+ import { readFileSync } from 'node:fs';
121
+ import { type EnvelopeData, renderEnvelope, writeArtifacts } from '../src/envelope.ts';
122
+
123
+ const base: EnvelopeData = {
124
+ ok: true,
125
+ command: "pw ({ page }) => page.click('text=Login')",
126
+ used: ["I.click('Login')"],
127
+ page: { url: 'https://app.example.com/dashboard', previousUrl: 'https://app.example.com/login', title: 'Dashboard', state: 'dashboard_h1_dashboard', visits: 1 },
128
+ changes: 'ariaDiff:\n added:\n - heading "Dashboard"',
129
+ instance: { name: 'default', tabs: 3, startedAgo: '12m', others: [{ name: 'auth-test', tabs: 1 }] },
130
+ artifacts: { aria: '/tmp/x/aria.yml', html: '/tmp/x/page.html', network: '/tmp/x/network.jsonl' },
131
+ };
132
+
133
+ describe('renderEnvelope', () => {
134
+ test('success envelope contains all sections in order', () => {
135
+ const out = renderEnvelope(base);
136
+ const sections = ['### Result', '### Page', '### Changes', '### Instance', '### Artifacts'];
137
+ const positions = sections.map((s) => out.indexOf(s));
138
+ expect(positions.every((p) => p >= 0)).toBe(true);
139
+ expect([...positions].sort((a, b) => a - b)).toEqual(positions);
140
+ expect(out).toContain('ok: true');
141
+ expect(out).toContain("used: I.click('Login')");
142
+ expect(out).toContain('(changed: https://app.example.com/login → https://app.example.com/dashboard)');
143
+ expect(out).toContain('instance: default (3 tabs) | other instances: auth-test (1 tab)');
144
+ });
145
+
146
+ test('unchanged url renders without changed marker', () => {
147
+ const out = renderEnvelope({ ...base, page: { ...base.page, previousUrl: base.page.url } });
148
+ expect(out).not.toContain('(changed:');
149
+ });
150
+
151
+ test('failure envelope renders attempts, reasoning and compact aria', () => {
152
+ const out = renderEnvelope({
153
+ ...base,
154
+ ok: false,
155
+ failure: {
156
+ error: "locator 'text=Login' not found",
157
+ attempts: [{ code: "I.click('Login')", outcome: 'not visible' }, { code: 'scroll + retry', outcome: 'covered by cookie banner' }],
158
+ reasoning: 'element hidden behind consent overlay',
159
+ compactAria: '- button "Accept all"',
160
+ },
161
+ });
162
+ expect(out).toContain('### Failure');
163
+ expect(out).toContain('### Healing attempts (2)');
164
+ expect(out).toContain("1. I.click('Login')");
165
+ expect(out).toContain('→ not visible');
166
+ expect(out).toContain('### Current page (compact ARIA)');
167
+ expect(out).toContain('- button "Accept all"');
168
+ });
169
+
170
+ test('answer replaces changes for ask', () => {
171
+ const out = renderEnvelope({ ...base, changes: undefined, answer: 'A login form with email and password fields' });
172
+ expect(out).toContain('### Answer');
173
+ expect(out).not.toContain('### Changes');
174
+ });
175
+
176
+ test('verdict replaces changes for verify', () => {
177
+ const out = renderEnvelope({ ...base, changes: undefined, verdict: { passed: true, evidence: 'heading "Dashboard" present', code: "I.see('Dashboard')" } });
178
+ expect(out).toContain('### Verdict');
179
+ expect(out).toContain('passed: true');
180
+ expect(out).toContain("I.see('Dashboard')");
181
+ });
182
+
183
+ test('healed success carries note', () => {
184
+ const out = renderEnvelope({ ...base, healed: true, healNote: 'dismissed overlay first' });
185
+ expect(out).toContain('healed: true (dismissed overlay first)');
186
+ });
187
+ });
188
+
189
+ describe('writeArtifacts', () => {
190
+ test('writes aria, html and network files and returns absolute paths', () => {
191
+ const dir = mkdtempSync(path.join(tmpdir(), 'act-'));
192
+ const result = writeArtifacts(dir, { aria: '- button "Login"', html: '<html></html>', requests: [{ url: '/api/user', status: 200 }] });
193
+ expect(readFileSync(result.aria, 'utf-8')).toContain('button "Login"');
194
+ expect(readFileSync(result.html, 'utf-8')).toContain('<html>');
195
+ expect(readFileSync(result.network, 'utf-8')).toContain('/api/user');
196
+ expect(path.isAbsolute(result.aria)).toBe(true);
197
+ });
198
+ });
199
+ ```
200
+
201
+ - [ ] **Step 3: Run tests, verify they fail**
202
+
203
+ Run: `bun test boat/actor/tests/envelope.test.ts`
204
+ Expected: FAIL — cannot resolve `../src/envelope.ts`.
205
+
206
+ - [ ] **Step 4: Implement `boat/actor/src/envelope.ts`**
207
+
208
+ Pure string building. Rules:
209
+ - Sections always in order: Result, Page, then exactly one of Changes/Answer/Verdict (Changes only when `changes` is a non-empty string), Failure + Healing attempts + Current page (only when `failure` set), Instance, Artifacts (only when set).
210
+ - `healed: true (note)` on one line when healNote present, plain `healed: false` otherwise; omit line when `healed` is undefined.
211
+ - `used:` joins multiple codes with `; `.
212
+ - Page line: `url: <url> (changed: <prev> → <url>)` only when previousUrl differs.
213
+ - State line: `state: <state> (visit #<visits>)`.
214
+ - Instance line exactly as tested; `others` empty → `| other instances: none`.
215
+ - Network artifact written as JSONL (one `JSON.stringify` per request).
216
+ - Every attempt line: `<n>. <code>` padded, then `→ <outcome>`.
217
+
218
+ Keep it one exported function plus small private helpers below it; no classes.
219
+
220
+ - [ ] **Step 5: Run tests, verify pass**
221
+
222
+ Run: `bun test boat/actor/tests/envelope.test.ts`
223
+ Expected: PASS (7 tests).
224
+
225
+ - [ ] **Step 6: Format and commit**
226
+
227
+ ```bash
228
+ bun run format
229
+ git add boat/actor
230
+ git commit -m "feat(actor): boat scaffold and result envelope"
231
+ ```
232
+
233
+ ---
234
+
235
+ ### Task 2: pw function wrapper
236
+
237
+ The `pw` argument is a function expression in the exact shape `I.usePlaywrightTo` accepts — `({ page, browserContext, browser }) => ...` — so callers destructure whichever Playwright objects they need. The parser only answers "is this a parseable function expression" (for clean `tool:` errors) and interpolates it verbatim into the CodeceptJS call that `Action.execute` expects.
238
+
239
+ **Files:**
240
+ - Create: `boat/actor/src/pw-parser.ts`
241
+ - Test: `boat/actor/tests/pw-parser.test.ts`
242
+
243
+ **Interfaces:**
244
+ - Produces (used by Task 4):
245
+
246
+ ```typescript
247
+ export function isFunctionExpression(expr: string): { valid: boolean; error?: string };
248
+ export function toCodeceptWrapper(expr: string): string;
249
+ ```
250
+
251
+ - [ ] **Step 1: Write failing tests**
252
+
253
+ `boat/actor/tests/pw-parser.test.ts`:
254
+
255
+ ```typescript
256
+ import { describe, expect, test } from 'bun:test';
257
+ import { isFunctionExpression, toCodeceptWrapper } from '../src/pw-parser.ts';
258
+
259
+ describe('isFunctionExpression', () => {
260
+ test.each([
261
+ "({ page }) => page.click('text=Login')",
262
+ "async ({ page }) => { await page.fill('#email', 'user@example.com'); await page.keyboard.press('Enter'); }",
263
+ "({ browserContext }) => browserContext.clearCookies()",
264
+ "({ page, browser }) => browser.version()",
265
+ "function ({ page }) { return page.title() }",
266
+ ])('accepts %s', (expr) => {
267
+ expect(isFunctionExpression(expr).valid).toBe(true);
268
+ });
269
+
270
+ test.each([
271
+ "page.click('text=Login')",
272
+ "({ page }) => page.click('a'",
273
+ "just some text",
274
+ "",
275
+ ])('rejects %s', (expr) => {
276
+ const result = isFunctionExpression(expr);
277
+ expect(result.valid).toBe(false);
278
+ expect(result.error).toBeTruthy();
279
+ });
280
+ });
281
+
282
+ describe('toCodeceptWrapper', () => {
283
+ test('interpolates the function verbatim into usePlaywrightTo', () => {
284
+ const code = toCodeceptWrapper("({ page }) => page.click('text=Login')");
285
+ expect(code).toBe("I.usePlaywrightTo('pw', ({ page }) => page.click('text=Login'))");
286
+ });
287
+ });
288
+ ```
289
+
290
+ - [ ] **Step 2: Run tests, verify fail**
291
+
292
+ Run: `bun test boat/actor/tests/pw-parser.test.ts`
293
+ Expected: FAIL — module not found.
294
+
295
+ - [ ] **Step 3: Implement `boat/actor/src/pw-parser.ts`**
296
+
297
+ ```typescript
298
+ const FUNCTION_SHAPE = /^(async\s+)?(function\b|\()/;
299
+
300
+ export function isFunctionExpression(expr: string): { valid: boolean; error?: string } {
301
+ const trimmed = expr.trim();
302
+ if (!trimmed) return { valid: false, error: 'empty expression; pass a function like ({ page }) => ...' };
303
+ if (!FUNCTION_SHAPE.test(trimmed)) return { valid: false, error: 'expression must be a function like ({ page }) => ... destructuring the playwright objects it needs' };
304
+ try {
305
+ new Function(`return (${trimmed})`);
306
+ } catch (e) {
307
+ return { valid: false, error: `not a valid function expression: ${(e as Error).message}` };
308
+ }
309
+ return { valid: true };
310
+ }
311
+
312
+ export function toCodeceptWrapper(expr: string): string {
313
+ return `I.usePlaywrightTo('pw', ${expr.trim()})`;
314
+ }
315
+ ```
316
+
317
+ `new Function` is construction-only — the user's code is never invoked here (invoking would execute non-function inputs). The shape regex rejects bare call chains like `page.click(...)` with an error pointing at the expected form; the construction catch turns syntax errors (unbalanced brackets, garbage text) into the `tool:` error in the envelope. Note the shape regex also requires arrow parameters to be parenthesized — acceptable since the destructured `({ page })` form is the documented contract.
318
+
319
+ - [ ] **Step 4: Run tests, verify pass**
320
+
321
+ Run: `bun test boat/actor/tests/pw-parser.test.ts`
322
+ Expected: PASS.
323
+
324
+ - [ ] **Step 5: Commit**
325
+
326
+ ```bash
327
+ bun run format
328
+ git add boat/actor/src/pw-parser.ts boat/actor/tests/pw-parser.test.ts
329
+ git commit -m "feat(actor): pw function wrapper"
330
+ ```
331
+
332
+ ---
333
+
334
+ ### Task 3: Named browser instances in browser-server
335
+
336
+ **Files:**
337
+ - Modify: `src/browser-server.ts` (exports at line 88: `readEndpoint, removeEndpointFile, isServerRunning, launchServer, getEndpointFilePath, getAliveEndpoint`)
338
+ - Modify: `bin/explorbot-cli.ts` browser start/stop/status handlers (lines ~736-821) to pass instance through
339
+ - Test: `tests/unit/browser-server-instances.test.ts`
340
+
341
+ **Interfaces:**
342
+ - Produces (used by Tasks 4 and 7): every exported function gains an optional trailing `instance = 'default'` parameter; endpoint file becomes `.browser-endpoint` for `default` and `.browser-endpoint-<name>` otherwise. New export:
343
+
344
+ ```typescript
345
+ export function listInstances(): Array<{ name: string; endpoint: string }>;
346
+ ```
347
+
348
+ - [ ] **Step 1: Read `src/browser-server.ts` fully** — understand `getEndpointFilePath`, `writeEndpoint`, `getAliveEndpoint` before touching anything.
349
+
350
+ - [ ] **Step 2: Write failing test**
351
+
352
+ `tests/unit/browser-server-instances.test.ts`:
353
+
354
+ ```typescript
355
+ import { describe, expect, test } from 'bun:test';
356
+ import path from 'node:path';
357
+ import { getEndpointFilePath } from '../../src/browser-server.ts';
358
+
359
+ describe('named instances', () => {
360
+ test('default instance keeps legacy filename', () => {
361
+ expect(path.basename(getEndpointFilePath())).toBe('.browser-endpoint');
362
+ expect(path.basename(getEndpointFilePath('default'))).toBe('.browser-endpoint');
363
+ });
364
+
365
+ test('named instance gets suffixed filename', () => {
366
+ expect(path.basename(getEndpointFilePath('staging'))).toBe('.browser-endpoint-staging');
367
+ });
368
+ });
369
+ ```
370
+
371
+ Add a test for `listInstances()` writing two endpoint files into a temp output dir if `getEndpointFilePath` resolves from a configurable root; if the output root comes from config at import time, keep `listInstances` scanning `path.dirname(getEndpointFilePath())` for files matching `.browser-endpoint*` and test via that dir.
372
+
373
+ - [ ] **Step 3: Run test, verify fail**
374
+
375
+ Run: `bun test tests/unit/browser-server-instances.test.ts`
376
+ Expected: FAIL — `getEndpointFilePath` does not accept an argument (or wrong filename).
377
+
378
+ - [ ] **Step 4: Implement**
379
+
380
+ Thread `instance = 'default'` through `getEndpointFilePath`, `readEndpoint`, `writeEndpoint`, `removeEndpointFile`, `isServerRunning`, `getAliveEndpoint`, `launchServer`. Filename: `default` → `.browser-endpoint` (backward compatible), else `.browser-endpoint-${instance}`. Sanitize instance to `[a-z0-9-]` and reject others with a thrown Error. `listInstances()` scans the endpoint dir with `readdirSync`, maps filenames back to names.
381
+
382
+ - [ ] **Step 5: Run full unit suite**
383
+
384
+ Run: `bun test tests/unit/`
385
+ Expected: PASS, no regressions.
386
+
387
+ - [ ] **Step 6: Commit**
388
+
389
+ ```bash
390
+ bun run format
391
+ git add src/browser-server.ts tests/unit/browser-server-instances.test.ts bin/explorbot-cli.ts
392
+ git commit -m "feat: named browser server instances"
393
+ ```
394
+
395
+ ---
396
+
397
+ ### Task 4: Actor class core — lifecycle and pw command
398
+
399
+ **Files:**
400
+ - Create: `boat/actor/src/actor.ts`
401
+ - Test: `boat/actor/tests/actor.test.ts`
402
+
403
+ **Interfaces:**
404
+ - Consumes: `renderEnvelope`/`writeArtifacts`/`EnvelopeData`/`InstanceInfo` (Task 1), `isFunctionExpression`/`toCodeceptWrapper` (Task 2), `getAliveEndpoint(instance)`/`launchServer` (Task 3), `ExplorBot` API (`src/explorbot.ts`: `start()`, `stop()`, `visit(url)`, `getExplorer()`, `stateManager()`, `getCurrentState()`, `agentNavigator()`, `agentResearcher()`, `agentHistorian()`, `requestStore()`), `Explorer.action(): Action` (`src/explorer.ts:151`), `Action.execute(code)` / `Action.capturePageState()` (`src/action.ts:64-68`), `ActionResult` (`getStateHash()`, `ariaSnapshot`, `combinedHtml()`, `url`, `title`), `compactAriaSnapshot` (`src/utils/aria.ts`), `Diff`/`PageDiff` via `ActionResult` (`src/action-result.ts`).
405
+ - Produces (used by Tasks 5–7, 9):
406
+
407
+ ```typescript
408
+ export interface ActorOptions {
409
+ verbose?: boolean;
410
+ config?: string;
411
+ path?: string;
412
+ instance?: string;
413
+ session?: string;
414
+ heal?: boolean;
415
+ ephemeral?: boolean;
416
+ framework?: 'codeceptjs' | 'playwright';
417
+ vision?: boolean;
418
+ url?: string;
419
+ }
420
+ export class Actor {
421
+ constructor(options?: ActorOptions);
422
+ async start(): Promise<void>;
423
+ async stop(): Promise<void>;
424
+ async pw(expression: string): Promise<EnvelopeData>;
425
+ async instanceInfo(): Promise<InstanceInfo>;
426
+ }
427
+ ```
428
+
429
+ - [ ] **Step 1: Study `boat/doc-collector/src/docbot.ts`** — the Actor mirrors how DocBot wraps ExplorBot (constructor builds `new ExplorBot({...})`, `start()` boots it, agents accessed lazily).
430
+
431
+ - [ ] **Step 2: Write failing tests with duck-typed mocks**
432
+
433
+ `boat/actor/tests/actor.test.ts` — follow the duck-type-mock style of `tests/integration/` (mock Explorer/StateManager, no real browser):
434
+
435
+ ```typescript
436
+ import { describe, expect, test } from 'bun:test';
437
+ import { Actor } from '../src/actor.ts';
438
+
439
+ function fakeState(over: Record<string, unknown> = {}) {
440
+ return {
441
+ url: 'https://app.example.com/login',
442
+ title: 'Login',
443
+ getStateHash: () => 'login_h1_login',
444
+ ariaSnapshot: '- textbox "Email"\n- button "Sign in"',
445
+ combinedHtml: () => '<form></form>',
446
+ ...over,
447
+ };
448
+ }
449
+
450
+ function fakeActor() {
451
+ const actor = new Actor({ instance: 'default' });
452
+ const executed: string[] = [];
453
+ const after = fakeState({ url: 'https://app.example.com/dashboard', title: 'Dashboard', getStateHash: () => 'dashboard_h1_dashboard' });
454
+ (actor as any).bot = {
455
+ getExplorer: () => ({
456
+ action: () => ({
457
+ execute: async (code: string) => {
458
+ executed.push(code);
459
+ return { actionResult: after, lastError: null };
460
+ },
461
+ }),
462
+ capture: async () => after,
463
+ }),
464
+ stateManager: () => ({
465
+ getCurrentState: () => fakeState(),
466
+ getVisitCount: () => 1,
467
+ }),
468
+ requestStore: () => ({ getRequests: () => [] }),
469
+ };
470
+ (actor as any).artifactsDir = '/tmp/act-test';
471
+ return { actor, executed };
472
+ }
473
+
474
+ describe('Actor.pw', () => {
475
+ test('rejects non-function argument as tool error without executing', async () => {
476
+ const { actor, executed } = fakeActor();
477
+ const envelope = await actor.pw("page.click('text=Login')");
478
+ expect(envelope.ok).toBe(false);
479
+ expect(envelope.failure?.error).toContain('function');
480
+ expect(executed.length).toBe(0);
481
+ });
482
+
483
+ test('executes wrapped function and returns success envelope data', async () => {
484
+ const { actor, executed } = fakeActor();
485
+ const envelope = await actor.pw("({ page }) => page.click('text=Login')");
486
+ expect(executed[0]).toContain("I.usePlaywrightTo");
487
+ expect(envelope.ok).toBe(true);
488
+ expect(envelope.used).toEqual(["({ page }) => page.click('text=Login')"]);
489
+ expect(envelope.page.url).toBe('https://app.example.com/dashboard');
490
+ expect(envelope.page.previousUrl).toBe('https://app.example.com/login');
491
+ });
492
+ });
493
+ ```
494
+
495
+ - [ ] **Step 3: Run tests, verify fail**
496
+
497
+ Run: `bun test boat/actor/tests/actor.test.ts`
498
+ Expected: FAIL — module not found.
499
+
500
+ - [ ] **Step 4: Implement Actor core**
501
+
502
+ `boat/actor/src/actor.ts` responsibilities in this task:
503
+ - Constructor stores options, builds `ExplorBot` options (`config`, `path`, `verbose`, `session`, `headless: true`) — but do NOT boot in constructor (DocBot pattern).
504
+ - `start()`: resolve instance endpoint via `getAliveEndpoint(this.options.instance ?? 'default')`; when absent, launch via `launchServer` equivalent used by `explorbot browser start` (reuse, do not reimplement); then `await this.bot.start()`. When `options.url` is set and no current state exists, `await this.bot.visit(options.url)`.
505
+ - `pw(expression)`:
506
+ 1. `isFunctionExpression` — invalid → return tool-error envelope (`ok: false`, `failure.error` prefixed `tool:`), never execute.
507
+ 2. Capture `before = stateManager.getCurrentState()`.
508
+ 3. `const action = explorer.action(); await action.execute(toCodeceptWrapper(expression))`.
509
+ 4. On success build `EnvelopeData` with `used: [expression]`, page block from resulting `ActionResult` (`previousUrl` from `before`), `changes` from the pageDiff ariaChanges the Action pipeline computed (see `ActionResult.toToolResult` usage in `src/ai/tools.ts:1122` for how diffs are obtained — reuse the same path, do not recompute).
510
+ 5. Write artifacts via `writeArtifacts(this.nextArtifactDir(), { aria: result.ariaSnapshot, html: result.combinedHtml(), requests: this.bot.requestStore().getRequests() })`.
511
+ 6. On execution error: this task returns a plain failure envelope (heal comes in Task 5).
512
+ - `instanceInfo()`: name from options; tabs from `explorer` playwright context pages count (add a small public accessor if none exists — check `src/explorer.ts:79` `playwrightHelper?.page`); others from `listInstances()` (Task 3) excluding self; tabs for others may be reported as 0 when unreachable — do not connect to other instances.
513
+ - `nextArtifactDir()`: `<output>/act/<ISO-timestamp>/`, one per command invocation.
514
+
515
+ Mockability rule: everything the tests stub lives behind `this.bot` — keep all ExplorBot access via that single field.
516
+
517
+ - [ ] **Step 5: Run tests, verify pass**
518
+
519
+ Run: `bun test boat/actor/tests/`
520
+ Expected: PASS.
521
+
522
+ - [ ] **Step 6: Commit**
523
+
524
+ ```bash
525
+ bun run format
526
+ git add boat/actor
527
+ git commit -m "feat(actor): Actor core with pw execution and instance info"
528
+ ```
529
+
530
+ ---
531
+
532
+ ### Task 5: Heal loop
533
+
534
+ **Files:**
535
+ - Modify: `src/ai/navigator.ts:192` (`resolveState` signature), `boat/actor/src/actor.ts`
536
+ - Test: `boat/actor/tests/actor.test.ts` (extend), `tests/integration/` untouched
537
+
538
+ **Interfaces:**
539
+ - Consumes: `navigator.resolveState(message, actionResult, opts)` (`src/ai/navigator.ts:192`).
540
+ - Produces: `resolveState` opts gains `onAttempt?: (attempt: { code: string; error?: string }) => void`, invoked once per executed recovery attempt with the exact code string and the error message when it failed. Actor gains private `heal(...)` used by pw (and Task 6 commands).
541
+
542
+ - [ ] **Step 1: Extend `resolveState` with the attempt hook**
543
+
544
+ Read `src/ai/navigator.ts` `resolveState` implementation; find where recovery code executes (each `action.execute`/attempt site). Add `opts.onAttempt` invocation at each attempt completion with `{ code, error: lastError?.message }`. Smallest change possible; no behavior change when the callback is absent.
545
+
546
+ - [ ] **Step 2: Write failing Actor heal test**
547
+
548
+ Extend `boat/actor/tests/actor.test.ts`; stub `agentNavigator` on the fake bot:
549
+
550
+ ```typescript
551
+ test('failed pw heals via navigator and reports healed envelope', async () => {
552
+ const { actor } = fakeActor();
553
+ (actor as any).bot.getExplorer = () => ({
554
+ action: () => ({
555
+ execute: async () => {
556
+ throw new Error("locator 'text=Login' not found");
557
+ },
558
+ }),
559
+ capture: async () => fakeState(),
560
+ });
561
+ (actor as any).bot.agentNavigator = () => ({
562
+ resolveState: async (_msg: string, _result: unknown, opts: any) => {
563
+ opts?.onAttempt?.({ code: "I.click('Login')", error: 'not visible' });
564
+ opts?.onAttempt?.({ code: "I.click('#login-btn')" });
565
+ return true;
566
+ },
567
+ });
568
+ const envelope = await actor.pw("({ page }) => page.click('text=Login')");
569
+ expect(envelope.ok).toBe(true);
570
+ expect(envelope.healed).toBe(true);
571
+ expect(envelope.used).toEqual(["I.click('#login-btn')"]);
572
+ });
573
+
574
+ test('exhausted heal returns failure envelope with attempts and compact aria', async () => {
575
+ const { actor } = fakeActor();
576
+ (actor as any).bot.getExplorer = () => ({
577
+ action: () => ({
578
+ execute: async () => {
579
+ throw new Error("locator 'text=Login' not found");
580
+ },
581
+ }),
582
+ capture: async () => fakeState(),
583
+ });
584
+ (actor as any).bot.agentNavigator = () => ({
585
+ resolveState: async (_msg: string, _result: unknown, opts: any) => {
586
+ opts?.onAttempt?.({ code: "I.click('Login')", error: 'not visible' });
587
+ return false;
588
+ },
589
+ });
590
+ const envelope = await actor.pw("({ page }) => page.click('text=Login')");
591
+ expect(envelope.ok).toBe(false);
592
+ expect(envelope.failure?.attempts.length).toBe(1);
593
+ expect(envelope.failure?.compactAria).toContain('button');
594
+ });
595
+ ```
596
+
597
+ - [ ] **Step 3: Run tests, verify fail**
598
+
599
+ Run: `bun test boat/actor/tests/actor.test.ts`
600
+ Expected: new tests FAIL (heal not implemented).
601
+
602
+ - [ ] **Step 4: Implement heal in Actor**
603
+
604
+ Private `heal(errorMessage, actionResult, originalCode)`:
605
+ - Skip entirely when `options.heal === false` — go straight to failure envelope.
606
+ - Collect attempts array via `onAttempt`; call `navigator.resolveState(errorMessage, actionResult, { onAttempt })`.
607
+ - `true` → success envelope: `healed: true`, `healNote` = last attempt outcome summary, `used` = codes of successful attempts (last attempt without error), current state re-read from `stateManager.getCurrentState()`.
608
+ - `false` → failure envelope: `error`, `attempts` (map error→outcome, success→'ok'), `compactAria` from `compactAriaSnapshot(state.ariaSnapshot, true)` (`src/utils/aria.ts`), reasoning left to Task 8's compaction if trivial — set `reasoning` to a one-line join of distinct outcomes for now (general, not model-generated).
609
+ - Failure envelopes still include artifacts and instance blocks.
610
+
611
+ - [ ] **Step 5: Run boat tests and repo unit tests**
612
+
613
+ Run: `bun test boat/actor/tests/ tests/unit/`
614
+ Expected: PASS.
615
+
616
+ - [ ] **Step 6: Commit**
617
+
618
+ ```bash
619
+ bun run format
620
+ git add src/ai/navigator.ts boat/actor
621
+ git commit -m "feat(actor): heal loop over navigator recovery with attempt trace"
622
+ ```
623
+
624
+ ---
625
+
626
+ ### Task 6: do, click, fill, ask, verify, research commands
627
+
628
+ **Files:**
629
+ - Modify: `boat/actor/src/actor.ts`
630
+ - Test: `boat/actor/tests/actor.test.ts` (extend)
631
+
632
+ **Interfaces:**
633
+ - Consumes: `collectInteractiveNodes(snapshot)` (`src/utils/aria.ts`), `createCodeceptJSTools` (`src/ai/tools.ts:30`) — tool objects expose `.execute(input)`; `provider.invokeConversation` with `maxToolRoundtrips` (Driller pattern, `src/ai/driller.ts:325-329`); `researcher.answerQuestionAboutScreenshot(state, question)` and `researcher.summary(state)` (`src/ai/researcher.ts:544, 659`); `navigator.verifyState(message, actionResult)` (`src/ai/navigator.ts:618`) returning `{ verified, successfulCodes, assertionSteps, totalAttempted }`.
634
+ - Produces:
635
+
636
+ ```typescript
637
+ async do(instruction: string): Promise<EnvelopeData>;
638
+ async click(target: string): Promise<EnvelopeData>;
639
+ async fill(field: string, value: string): Promise<EnvelopeData>;
640
+ async ask(question: string): Promise<EnvelopeData>;
641
+ async verify(assertion: string): Promise<EnvelopeData>;
642
+ async research(opts?: { data?: boolean; deep?: boolean; fresh?: boolean }): Promise<EnvelopeData>;
643
+ ```
644
+
645
+ `EnvelopeData` gains an optional `research?: string` field (Task 1's renderer: `### Research` replaces `### Changes` when set, mutually exclusive with `answer`/`verdict` — add a render test alongside the answer/verdict ones).
646
+
647
+ - [ ] **Step 1: Write failing tests for the deterministic fast path**
648
+
649
+ ```typescript
650
+ test('do with unambiguous role+name executes without AI', async () => {
651
+ const { actor, executed } = fakeActor();
652
+ let aiCalled = false;
653
+ (actor as any).bot.getProvider = () => ({ invokeConversation: async () => { aiCalled = true; } });
654
+ const envelope = await actor.do('click the "Sign in" button');
655
+ expect(aiCalled).toBe(false);
656
+ expect(executed.some((code) => code.includes("I.click"))).toBe(true);
657
+ expect(envelope.ok).toBe(true);
658
+ });
659
+
660
+ test('verify returns verdict with assertion code', async () => {
661
+ const { actor } = fakeActor();
662
+ (actor as any).bot.agentNavigator = () => ({
663
+ verifyState: async () => ({ verified: true, successfulCodes: ["I.see('Dashboard')"], assertionSteps: [], totalAttempted: 1 }),
664
+ });
665
+ const envelope = await actor.verify('user sees the dashboard');
666
+ expect(envelope.verdict?.passed).toBe(true);
667
+ expect(envelope.verdict?.code).toBe("I.see('Dashboard')");
668
+ });
669
+
670
+ test('research returns UI map in envelope', async () => {
671
+ const { actor } = fakeActor();
672
+ (actor as any).bot.agentResearcher = () => ({ research: async () => '## Section: Login Form\n| Element | ARIA | CSS |' });
673
+ const envelope = await actor.research({ data: true });
674
+ expect(envelope.research).toContain('Login Form');
675
+ expect(envelope.ok).toBe(true);
676
+ });
677
+
678
+ test('ask without vision answers from researcher summary', async () => {
679
+ const { actor } = fakeActor();
680
+ (actor as any).bot.agentResearcher = () => ({ summary: async () => 'Login form with email and password' });
681
+ (actor as any).bot.getProvider = () => ({ chat: async () => ({ text: 'A login page with an email form' }) });
682
+ const envelope = await actor.ask('what do I see?');
683
+ expect(envelope.answer).toContain('login');
684
+ });
685
+ ```
686
+
687
+ - [ ] **Step 2: Run tests, verify fail** — `bun test boat/actor/tests/actor.test.ts`.
688
+
689
+ - [ ] **Step 3: Implement**
690
+
691
+ - `do(instruction)`:
692
+ 1. Fast path: quote-extract or word-match the instruction against `collectInteractiveNodes(state.ariaSnapshot)`; when exactly one node matches by name (case-insensitive) and the instruction's verb maps to that node's default interaction, execute `I.click('<name>')` / `I.fillField(...)` via `explorer.action().execute(...)` with zero AI. The matching must be generic (role vocabulary from `INTERACTIVE_ROLES`), never keyed to specific words from any one app.
693
+ 2. Otherwise: bounded agentic call — `provider.invokeConversation(conversation, createCodeceptJSTools(explorer, ...), { maxToolRoundtrips: 3, toolChoice: 'required' })` with a dedent system prompt: current compact ARIA + the instruction + rule to perform exactly the instructed interaction and stop. Collect executed codes from the tool results (same shape the Driller reads).
694
+ 3. Failures feed `heal(...)` from Task 5.
695
+ - `click(target)` / `fill(field, value)`: call the corresponding tool object from `createCodeceptJSTools` directly (`tools.click.execute({ locator: target })` — read the tool's exact input schema in `src/ai/tools.ts` first and match it); ladder failures feed `heal`.
696
+ - `ask(question)`: `options.vision` → `researcher.answerQuestionAboutScreenshot(state, question)`; otherwise `provider.chat` over dedent prompt containing `researcher.summary(state)` + compact ARIA + the question. Non-mutating: envelope has `answer`, no `changes`, artifacts still written.
697
+ - `verify(assertion)`: `explorer.capture()` then `navigator.verifyState(assertion, actionResult)`; verdict `{ passed: verified, evidence: <first successful step or failure note>, code: successfulCodes.join('\n') }`.
698
+ - `research(opts)`: `researcher.research(state, { screenshot: true, data: opts.data, deep: opts.deep, force: opts.fresh })` (`src/ai/researcher.ts:94`); envelope `research` = returned UI map verbatim (staleness banner included when cached), no `changes`. Non-mutating; artifacts still written.
699
+ - All command methods end by building `used` from actually executed code (never the requested input when they differ).
700
+
701
+ - [ ] **Step 4: Run tests, verify pass** — `bun test boat/actor/tests/`.
702
+
703
+ - [ ] **Step 5: Commit**
704
+
705
+ ```bash
706
+ bun run format
707
+ git add boat/actor
708
+ git commit -m "feat(actor): do, click, fill, ask, verify commands"
709
+ ```
710
+
711
+ ---
712
+
713
+ ### Task 7: go command and browser instance management
714
+
715
+ **Files:**
716
+ - Modify: `boat/actor/src/actor.ts`
717
+ - Test: `boat/actor/tests/actor.test.ts` (extend)
718
+
719
+ **Interfaces:**
720
+ - Consumes: `navigator.visit(destination)` (`src/ai/navigator.ts:140` — handles both URLs and NL/state destinations, same call the TUI `/navigate` uses per `src/commands/navigate-command.ts`), Task 3 instance functions.
721
+ - Produces:
722
+
723
+ ```typescript
724
+ async go(target: string): Promise<EnvelopeData>;
725
+ async browserStart(): Promise<void>;
726
+ async browserStop(all?: boolean): Promise<void>;
727
+ async browserStatus(): Promise<string>;
728
+ ```
729
+
730
+ - [ ] **Step 1: Write failing tests**
731
+
732
+ ```typescript
733
+ test('go delegates to navigator.visit and returns envelope', async () => {
734
+ const { actor } = fakeActor();
735
+ const visited: string[] = [];
736
+ (actor as any).bot.agentNavigator = () => ({ visit: async (dest: string) => { visited.push(dest); } });
737
+ const envelope = await actor.go('billing settings');
738
+ expect(visited).toEqual(['billing settings']);
739
+ expect(envelope.ok).toBe(true);
740
+ expect(envelope.page.url).toBeTruthy();
741
+ });
742
+ ```
743
+
744
+ - [ ] **Step 2: Run, verify fail.**
745
+
746
+ - [ ] **Step 3: Implement**
747
+
748
+ - `go(target)`: `navigator.visit(target)` (it resolves URL vs intent internally); envelope from resulting state; navigation errors feed `heal`.
749
+ - `browserStart/Stop/Status`: thin delegation to Task 3's browser-server functions with `options.instance`; `browserStop(true)` iterates `listInstances()`. Status string includes what `instanceInfo()` knows.
750
+ - Autostart: already in `start()` (Task 4) — verify `go` path hits it when no daemon runs; with `options.session` set, the launched context loads storage state (Explorer already honors `session` — `src/explorer.ts:106,337`).
751
+
752
+ - [ ] **Step 4: Run tests, verify pass; commit**
753
+
754
+ ```bash
755
+ bun run format
756
+ git add boat/actor
757
+ git commit -m "feat(actor): go command and instance management"
758
+ ```
759
+
760
+ ---
761
+
762
+ ### Task 8: Config ladder — global config, global .env, per-host state dir
763
+
764
+ **Files:**
765
+ - Modify: `src/config.ts` (`ConfigParser.loadConfig` at line 324, `buildEnvConfig` at line 489, `resolveOutputRoot` at line 667)
766
+ - Test: `tests/unit/config-ladder.test.ts`
767
+
768
+ **Interfaces:**
769
+ - Produces: `loadConfig` resolution order becomes: explicit `--config` path → project `explorbot.config.js|ts` in cwd → `~/.config/explorbot/config.js|ts` → env-var config (`buildEnvConfig`). `.env` loading order: cwd `.env` (existing behavior) then `~/.config/explorbot/.env` (only for keys not already set). New exported helper:
770
+
771
+ ```typescript
772
+ export function resolveStateRoot(baseUrl: string, ephemeral?: boolean): string;
773
+ ```
774
+
775
+ returning `~/.local/state/explorbot/<host>/` (created), or a `mkdtempSync` temp dir when `ephemeral`.
776
+
777
+ - [ ] **Step 1: Read `src/config.ts` load path fully** (lines 297-560) before changing anything.
778
+
779
+ - [ ] **Step 2: Write failing tests**
780
+
781
+ `tests/unit/config-ladder.test.ts`:
782
+
783
+ ```typescript
784
+ import { afterEach, describe, expect, test } from 'bun:test';
785
+ import { existsSync } from 'node:fs';
786
+ import path from 'node:path';
787
+ import os from 'node:os';
788
+ import { resolveStateRoot } from '../../src/config.ts';
789
+
790
+ describe('resolveStateRoot', () => {
791
+ test('derives persistent per-host dir', () => {
792
+ const dir = resolveStateRoot('https://app.example.com/login');
793
+ expect(dir).toBe(path.join(os.homedir(), '.local', 'state', 'explorbot', 'app.example.com'));
794
+ expect(existsSync(dir)).toBe(true);
795
+ });
796
+
797
+ test('ephemeral returns fresh temp dir', () => {
798
+ const a = resolveStateRoot('https://app.example.com', true);
799
+ const b = resolveStateRoot('https://app.example.com', true);
800
+ expect(a).not.toBe(b);
801
+ expect(a).toContain('explorbot');
802
+ });
803
+ });
804
+ ```
805
+
806
+ Global-config precedence test: point `ConfigParser.loadConfig` at a temp `HOME` (set `process.env.HOME` in the test, restore in `afterEach`), write `~/.config/explorbot/config.js` exporting a marker value, assert it loads when cwd has no project config and that a project config wins when both exist. Follow existing config tests in `tests/unit/` for how ConfigParser is instantiated.
807
+
808
+ - [ ] **Step 3: Run, verify fail.**
809
+
810
+ - [ ] **Step 4: Implement**
811
+
812
+ - `resolveStateRoot`: host from `new URL(baseUrl).host`; `mkdirSync(..., { recursive: true })`; ephemeral via `mkdtempSync(path.join(os.tmpdir(), 'explorbot-'))`.
813
+ - In `loadConfig`: after project-config lookup misses, try `path.join(os.homedir(), '.config', 'explorbot', 'config.js')` then `.ts` through the existing `loadConfigModule`.
814
+ - `.env`: where the existing cwd `.env` loads, additionally load `~/.config/explorbot/.env` without overwriting already-set keys.
815
+ - In `buildEnvConfig` (config-free mode): when no project config, set `dirs` (knowledge/experience/output) under `resolveStateRoot(baseUrl, ephemeralFlag)`; ephemeral flag arrives via new `EXPLORBOT_EPHEMERAL` env var so the boat can pass it without new plumbing.
816
+
817
+ - [ ] **Step 5: Run full unit suite** — `bun test tests/unit/` — PASS, no regressions.
818
+
819
+ - [ ] **Step 6: Commit**
820
+
821
+ ```bash
822
+ bun run format
823
+ git add src/config.ts tests/unit/config-ladder.test.ts
824
+ git commit -m "feat: global config ladder and per-host state dirs"
825
+ ```
826
+
827
+ ---
828
+
829
+ ### Task 9: CLI wiring and --help contract
830
+
831
+ **Files:**
832
+ - Create: `boat/actor/src/cli.ts`, `boat/actor/bin/actbot-cli.ts`
833
+ - Modify: `bin/explorbot-cli.ts` (add near line 874: `program.addCommand(createActCommands('act'))`)
834
+ - Test: manual smoke via `--help` (Step 4)
835
+
836
+ **Interfaces:**
837
+ - Consumes: `Actor`/`ActorOptions` (Tasks 4-7), `renderEnvelope` (Task 1).
838
+ - Produces: `export function createActCommands(name = 'act'): Command`.
839
+
840
+ - [ ] **Step 1: Implement `boat/actor/src/cli.ts`**
841
+
842
+ Mirror `boat/doc-collector/src/cli.ts` structure (`addCommonOptions`, `buildOptions`, subcommands). Subcommands: `pw <fn>`, `do <instruction>`, `click <target>`, `fill <field> <value>`, `ask <question>`, `verify <assertion>` (alias `assert`), `research` (flags `--data`, `--deep`, `--fresh`), `go <target>`, `browser <start|stop|status|list>`. Common options:
843
+
844
+ ```
845
+ -v, --verbose --debug
846
+ -c, --config <path> -p, --path <path>
847
+ -i, --instance <name> --session [file]
848
+ --no-heal --ephemeral
849
+ --framework <name> --vision (ask only)
850
+ --url <url> (start page for config-free mode)
851
+ ```
852
+
853
+ Every action handler: `setPreserveConsoleLogs(true)`, build `Actor`, `await actor.start()`, run the method, `console.log(renderEnvelope(result))`, `await actor.stop()`, `process.exit(result.ok ? 0 : 1)`. `--ephemeral` sets `process.env.EXPLORBOT_EPHEMERAL = '1'` before Actor construction. Zero business logic in handlers.
854
+
855
+ - [ ] **Step 2: Write the --help contract text**
856
+
857
+ The command description plus `addHelpText('after', ...)` on the `act` group is the sole teaching surface for orchestrating agents. It must compactly cover (dedent block, ~40 lines): the tiering (pw = precise, click/fill = ladder, do = intent), the recommended loop (research once for verified locators → drive with pw → verify), the envelope sections and their meaning, `used:` as reusable verified code, heal semantics and `--no-heal`, failure = inline compact ARIA + artifact file paths for deep dives, `--instance` vs `--session`, autostart behavior, the close-when-finished convention driven by `### Instance`, and one usage example per tier. General shapes only — no app-specific examples.
858
+
859
+ - [ ] **Step 3: Wire into main CLI and standalone bin**
860
+
861
+ - `bin/explorbot-cli.ts`: `import { createActCommands } from '../boat/actor/src/cli.ts';` + `program.addCommand(createActCommands('act'));` next to the existing api/docs registrations (line ~874).
862
+ - `boat/actor/bin/actbot-cli.ts`: mirror `boat/api-tester/bin` entry — a commander program that mounts the same subcommands at top level.
863
+
864
+ - [ ] **Step 4: Smoke the help output**
865
+
866
+ Run: `bun bin/explorbot-cli.ts act --help` and `bun boat/actor/bin/actbot-cli.ts --help`
867
+ Expected: full contract text, all 8 subcommands listed, no banner noise with `EXPLORBOT_NO_BANNER=1`.
868
+
869
+ - [ ] **Step 5: Commit**
870
+
871
+ ```bash
872
+ bun run format && bun run lint:fix
873
+ git add boat/actor bin/explorbot-cli.ts
874
+ git commit -m "feat(actor): act CLI namespace and standalone actbot bin"
875
+ ```
876
+
877
+ ---
878
+
879
+ ### Task 10: End-to-end smoke, changelog, docs
880
+
881
+ **Files:**
882
+ - Create: `tests/node/actor-smoke.test.ts` (or `tests/regression/` — match where existing browser-driving tests live; inspect both dirs first)
883
+ - Modify: `docs/reference/commands.md`, `CHANGELOG.md` (via `/changelog` skill at commit time)
884
+
885
+ - [ ] **Step 1: Inspect existing e2e/browser test setup** — `tests/node/` and `tests/regression/` — reuse their fixture-server pattern for a local page (a form with a button and an input; fictional content only).
886
+
887
+ - [ ] **Step 2: Write the smoke test**
888
+
889
+ Scenarios, driven through the `Actor` class directly against the local fixture (real browser, no AI provider needed for these paths):
890
+ 1. `pw "({ page }) => page.click('text=Submit')"` on the fixture → `ok: true`, envelope contains `### Changes` and artifact files exist on disk.
891
+ 2. `pw` with a non-function argument → `ok: false`, `tool:`-prefixed error, exit path returns without browser action.
892
+ 3. `do 'click the "Submit" button'` → fast path, zero AI (assert no provider configured and it still works).
893
+ 4. Instance autostart honored: run without a pre-started daemon; assert endpoint file appears.
894
+
895
+ Heal-path e2e requires an AI provider — cover it with an aimock integration test in `tests/integration/actor-heal.test.ts` following `tests/integration/planner.test.ts` (mock provider returns a recovery instruction; assert `healed: true` envelope and `onAttempt` trace).
896
+
897
+ - [ ] **Step 3: Run everything**
898
+
899
+ Run: `bun test boat/actor/tests/ tests/unit/ tests/integration/` plus the smoke file.
900
+ Expected: all PASS.
901
+
902
+ - [ ] **Step 4: Document**
903
+
904
+ Add an "Actor boat" section to `docs/reference/commands.md`: command table, envelope sample, tiering guidance, instance/session flags, config-free example (`EXPLORBOT_AI_PROVIDER=groq explorbot act go https://app.example.com`).
905
+
906
+ - [ ] **Step 5: Final checks and commit**
907
+
908
+ ```bash
909
+ bun run format && bun run check:fix
910
+ ```
911
+
912
+ Invoke the `/changelog` skill, then:
913
+
914
+ ```bash
915
+ git add -A
916
+ git commit -m "feat(actor): e2e smoke, docs and changelog"
917
+ ```
918
+
919
+ ---
920
+
921
+ ## Self-Review Notes
922
+
923
+ - Spec coverage: pw/do/click/fill/ask/verify+assert/research/go (Tasks 4-7, 9), envelope + used code (1, 4), heal + attempt trace + `--no-heal` (5), instances + autostart + `--session` reuse (3, 4, 7), config-free ladder + per-host state + `--ephemeral` (8), `--help`-only discovery (9), testing incl. aimock heal test (10). Framework flag (`--framework`) is parsed (9) and stored (4); Historian-based conversion of `used:` into Playwright dialect is deliberately deferred until `used` collection stabilizes — v1 emits the executed CodeceptJS (pw commands echo the Playwright expression itself), which satisfies "actual used locator" for both dialect inputs. If reviewers want full conversion in v1, extend Task 6 with `historian.toPlaywrightCode` per `src/ai/historian/playwright.ts:21`.
924
+ - Vision `ask` degrades to text path when no `visionModel` configured (`provider.hasVision()`, `src/ai/provider.ts:635`) — implementer: guard in `ask`.
925
+ - Type consistency: `EnvelopeData`/`InstanceInfo`/`HealAttempt` defined once in Task 1 and only consumed elsewhere; `ActorOptions` defined in Task 4 and consumed by 9.