explorbot 0.3.5 → 0.4.1
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.
- package/bin/explorbot-cli.ts +23 -16
- package/boat/api-tester/src/ai/chief.ts +7 -1
- package/boat/api-tester/src/ai/curler.ts +7 -1
- package/boat/api-tester/src/apibot.ts +10 -4
- package/boat/api-tester/src/cli.ts +12 -2
- package/boat/api-tester/src/config.ts +28 -8
- package/boat/doc-collector/bin/doc-collector-cli.ts +3 -2
- package/boat/doc-collector/src/cli.ts +6 -1
- package/boat/doc-collector/src/docbot.ts +4 -2
- package/boat/prima/bin/prima-cli.ts +2 -0
- package/boat/prima/src/cli.ts +23 -8
- package/boat/prima/src/envelope.ts +35 -9
- package/boat/prima/src/prima.ts +23 -10
- package/dist/bin/explorbot-cli.js +24 -16
- package/dist/boat/api-tester/bin/apibot-cli.js +3 -2
- package/dist/boat/api-tester/src/ai/chief.js +6 -1
- package/dist/boat/api-tester/src/ai/curler.js +6 -1
- package/dist/boat/api-tester/src/apibot.js +7 -3
- package/dist/boat/api-tester/src/cli.js +12 -2
- package/dist/boat/api-tester/src/config.js +31 -8
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +3 -2
- package/dist/boat/doc-collector/src/cli.js +6 -1
- package/dist/boat/doc-collector/src/docbot.js +4 -2
- package/dist/boat/prima/bin/prima-cli.js +2 -0
- package/dist/boat/prima/src/cli.js +22 -8
- package/dist/boat/prima/src/envelope.js +24 -6
- package/dist/boat/prima/src/prima.js +23 -11
- package/dist/package.json +2 -2
- package/dist/rules/planner/styles/normal.md +1 -1
- package/dist/src/action-result.d.ts +9 -1
- package/dist/src/action-result.js +57 -18
- package/dist/src/action.d.ts +1 -1
- package/dist/src/action.js +87 -12
- package/dist/src/ai/captain.js +1 -1
- package/dist/src/ai/driller.d.ts +0 -1
- package/dist/src/ai/driller.js +8 -20
- package/dist/src/ai/fisherman-tools.d.ts +9 -0
- package/dist/src/ai/fisherman-tools.js +52 -6
- package/dist/src/ai/fisherman.d.ts +4 -2
- package/dist/src/ai/fisherman.js +48 -27
- package/dist/src/ai/historian/codeceptjs.js +1 -1
- package/dist/src/ai/historian/playwright.js +1 -1
- package/dist/src/ai/navigator.js +1 -1
- package/dist/src/ai/pilot.d.ts +1 -0
- package/dist/src/ai/pilot.js +13 -1
- package/dist/src/ai/planner.js +9 -7
- package/dist/src/ai/provider.js +20 -3
- package/dist/src/ai/researcher/deep-analysis.js +1 -3
- package/dist/src/ai/researcher.js +5 -3
- package/dist/src/ai/rules.js +3 -3
- package/dist/src/ai/tester.d.ts +3 -0
- package/dist/src/ai/tester.js +40 -3
- package/dist/src/ai/tools.d.ts +1 -0
- package/dist/src/ai/tools.js +13 -6
- package/dist/src/api/request-result.d.ts +2 -0
- package/dist/src/api/request-result.js +8 -2
- package/dist/src/api/request-store.d.ts +3 -2
- package/dist/src/api/request-store.js +66 -14
- package/dist/src/api/spec-reader.js +1 -1
- package/dist/src/commands/config-command.js +1 -1
- package/dist/src/commands/drill-command.js +1 -1
- package/dist/src/commands/explore-command.d.ts +6 -0
- package/dist/src/commands/explore-command.js +39 -3
- package/dist/src/commands/freesail-command.js +10 -1
- package/dist/src/commands/options/base-option.d.ts +8 -0
- package/dist/src/commands/options/base-option.js +12 -0
- package/dist/src/commands/options/index.d.ts +5 -0
- package/dist/src/commands/options/index.js +5 -0
- package/dist/src/commands/options/knowledge-option.d.ts +7 -0
- package/dist/src/commands/options/knowledge-option.js +12 -0
- package/dist/src/commands/options/ws-option.d.ts +7 -0
- package/dist/src/commands/options/ws-option.js +21 -0
- package/dist/src/commands/plans-command.js +6 -6
- package/dist/src/config.d.ts +1 -0
- package/dist/src/config.js +12 -0
- package/dist/src/experience-tracker.js +5 -0
- package/dist/src/explorbot.d.ts +0 -1
- package/dist/src/explorbot.js +24 -37
- package/dist/src/knowledge-tracker.d.ts +20 -7
- package/dist/src/knowledge-tracker.js +69 -31
- package/dist/src/remote.d.ts +0 -3
- package/dist/src/remote.js +0 -18
- package/dist/src/state-manager.d.ts +5 -1
- package/dist/src/state-manager.js +10 -7
- package/dist/src/test-plan.d.ts +3 -0
- package/dist/src/test-plan.js +27 -0
- package/dist/src/utils/aria.d.ts +1 -1
- package/dist/src/utils/aria.js +6 -42
- package/dist/src/utils/html-diff.d.ts +4 -0
- package/dist/src/utils/html-diff.js +62 -7
- package/dist/src/utils/html.d.ts +5 -15
- package/dist/src/utils/html.js +14 -85
- package/dist/src/utils/overlay.d.ts +56 -11
- package/dist/src/utils/overlay.js +191 -21
- package/dist/src/utils/request-map.d.ts +7 -0
- package/dist/src/utils/request-map.js +16 -0
- package/dist/src/utils/url-matcher.js +4 -2
- package/docs/api-testing/basics.md +15 -0
- package/docs/api-testing/planning.md +10 -1
- package/docs/reference/commands.md +32 -5
- package/docs/reference/websocket.md +1 -0
- package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
- package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
- package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
- package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
- package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
- package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
- package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
- package/docs/workflow/agentic-usage.md +12 -2
- package/docs/workflow/ci.md +1 -0
- package/docs/workflow/knowledge.md +46 -2
- package/package.json +2 -2
- package/rules/planner/styles/normal.md +1 -1
- package/src/action-result.ts +61 -22
- package/src/action.ts +87 -14
- package/src/ai/captain.ts +1 -1
- package/src/ai/driller.ts +7 -39
- package/src/ai/fisherman-tools.ts +56 -7
- package/src/ai/fisherman.ts +48 -28
- package/src/ai/historian/codeceptjs.ts +1 -1
- package/src/ai/historian/playwright.ts +1 -1
- package/src/ai/navigator.ts +1 -1
- package/src/ai/pilot.ts +9 -1
- package/src/ai/planner.ts +9 -8
- package/src/ai/provider.ts +21 -3
- package/src/ai/researcher/deep-analysis.ts +1 -2
- package/src/ai/researcher.ts +4 -3
- package/src/ai/rules.ts +3 -3
- package/src/ai/tester.ts +40 -3
- package/src/ai/tools.ts +17 -9
- package/src/api/request-result.ts +10 -2
- package/src/api/request-store.ts +60 -13
- package/src/api/spec-reader.ts +1 -1
- package/src/commands/config-command.ts +1 -1
- package/src/commands/drill-command.ts +1 -1
- package/src/commands/explore-command.ts +37 -3
- package/src/commands/freesail-command.ts +7 -1
- package/src/commands/options/base-option.ts +18 -0
- package/src/commands/options/index.ts +7 -0
- package/src/commands/options/knowledge-option.ts +14 -0
- package/src/commands/options/ws-option.ts +24 -0
- package/src/commands/plans-command.ts +6 -6
- package/src/config.ts +12 -0
- package/src/experience-tracker.ts +5 -1
- package/src/explorbot.ts +21 -37
- package/src/knowledge-tracker.ts +94 -36
- package/src/remote.ts +0 -20
- package/src/state-manager.ts +13 -7
- package/src/test-plan.ts +29 -0
- package/src/utils/aria.ts +7 -44
- package/src/utils/html-diff.ts +62 -7
- package/src/utils/html.ts +14 -91
- package/src/utils/overlay.ts +226 -23
- package/src/utils/request-map.ts +19 -0
- package/src/utils/url-matcher.ts +3 -2
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
# Fisherman Reliability 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:** Make Fisherman (the API test-data preparation agent) produce trustworthy results in replicate mode: real request examples, scope-correct endpoint lists, and a `finish` report derived from what was actually executed.
|
|
6
|
+
|
|
7
|
+
**Architecture:** All fixes are deterministic-tier changes in the data layer (`RequestStore`, `RequestResult`) and the tool glue (`fisherman-tools.ts`), following the escalation ladder in CLAUDE.md: deterministic filters gate what the model sees, and a ledger of actually-made requests verifies what the model claims (generate-then-verify). One prompt line and one loop guard land in `fisherman.ts`. No new agents, no new envelope keys, no new files except one integration test.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Bun, TypeScript, Vercel AI SDK tools, `@copilotkit/aimock` for integration tests.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md` (trace evidence, root causes, and design decisions; the Findings section below is the working summary).
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- Bun only, never Node.js; run `bun run format` after each code change
|
|
16
|
+
- No code comments; avoid ternaries; private methods after public; premature exit over if/else
|
|
17
|
+
- Prompts and rules stay general — never encode a specific failing input from the trace review into a prompt, rule, or validator
|
|
18
|
+
- Never start the regression CI run (`regression` label / `gh workflow run`) — only the user applies the label
|
|
19
|
+
- `bun test tests/unit tests/integration` must pass before every commit
|
|
20
|
+
- Work on a fresh branch off `main` (project convention: `bunosh worktree:create fisherman-reliability`); do not build on `fix/skip-planning-on-error-pages`
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Findings (why each task exists)
|
|
25
|
+
|
|
26
|
+
From Langfuse traces of the only real Fisherman episodes (2026-04-30, 2026-06-01), verified against the current code:
|
|
27
|
+
|
|
28
|
+
1. **Self-poisoning store.** `addMadeRequest()` saves every request Fisherman itself makes — 400s included — into `output/requests/`. `loadFromDisk()` reads that whole directory back as `capturedRequests`, indistinguishable from browser XHR. A run is handed its predecessor's rejected bodies as "the captured example". Provenance is *already* encoded in the file id prefix: browser captures are `xhr_*` (`xhr-capture.ts:72`), Fisherman's own calls are unprefixed (`api-client.ts:58`), and `fail_*` records are never saved to disk (`addFailedRequest` doesn't call `save()`).
|
|
29
|
+
2. **First-match spec lookup.** `findCapturedRequest()` is `find(method && path.startsWith(prefix))` — no status ranking, no exactness: a stale 400 displaces a good 200, and `/suites` matches `/suites/123/move`.
|
|
30
|
+
3. **Scope filter never matches.** `getWriteRequestsForScope()` prefix-matches a page URL (`/projects/…`) against API paths (`/api/…`), always falls through to `'/'` — every captured write from every project, which produced a cross-project endpoint in the Apr 30 prompt. Degradation is silent.
|
|
31
|
+
4. **`finish` is unconditional success.** It writes the model's own `created` array through verbatim (`fisherman-tools.ts:150`). A run that created nothing — or created a *test* when a *milestone* was asked for — reports success, and Pilot passes it on as a satisfied precondition.
|
|
32
|
+
5. **Silent exhaustion & clobbered status.** Hitting max iterations without `finish` returns `summary: ''`, so Pilot logs nothing and the vision check is told the reason is "unknown". In the `request` tool, `...extractKeyFields()` spreads *after* the `status` key, so a body field named `status` overwrites the HTTP status, and the depth-5 first-id scan surfaces ids the run never created.
|
|
33
|
+
|
|
34
|
+
Design decisions that differ from the earlier proposed plan:
|
|
35
|
+
|
|
36
|
+
- **No new `source:` envelope key.** The `xhr_` id prefix already has a single writer (`generateRequestId`) and a closed vocabulary; filtering on it in `loadFromDisk()` is the whole fix and migrates poisoned directories for free.
|
|
37
|
+
- **Reuse `isDynamicSegment()`** from `src/utils/url-matcher.ts` for id-shaped path segments instead of a new classifier.
|
|
38
|
+
- **Scope = most selective shared segment**, not max-shared-segment count (which would drop same-project endpoints that don't match the deepest page path, and a shared literal like `projects` must not win over a project slug).
|
|
39
|
+
- **`finish` is gated, not replaced.** The model still names types and summarizes; the ledger verifies ids and vetoes success with zero successful writes.
|
|
40
|
+
|
|
41
|
+
Behavioral change to state in the CHANGELOG: a `output/requests/` directory containing only Fisherman-made files now yields zero captures, so replicate mode reports itself disabled — correct, since there was never real browser traffic to replicate.
|
|
42
|
+
|
|
43
|
+
Blast radius (verified by grep, 2026-08-29): `loadFromDisk`, `getCapturedRequests`, `toEndpointList`, `findCapturedRequest`, and `getWriteRequestsForScope` are consumed only by `fisherman.ts`, `fisherman-tools.ts`, and tests — nothing in `boat/` or `bin/` touches them, so the behavior changes in Tasks 1–3 affect Fisherman alone.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
### Task 1: `loadFromDisk` admits only browser captures
|
|
48
|
+
|
|
49
|
+
**Files:**
|
|
50
|
+
- Modify: `src/api/request-store.ts:120-136`
|
|
51
|
+
- Test: `tests/unit/request-store.test.ts`
|
|
52
|
+
|
|
53
|
+
**Interfaces:**
|
|
54
|
+
- Consumes: existing id prefixes — `xhr_` from `xhr-capture.ts`, unprefixed from `api-client.ts`
|
|
55
|
+
- Produces: `loadFromDisk(): void` unchanged signature; `capturedRequests` now contains only browser-captured requests. Task 2/3 ranking and scoping rely on this.
|
|
56
|
+
|
|
57
|
+
- [ ] **Step 1: Write the failing test**
|
|
58
|
+
|
|
59
|
+
Extend the `makeRequest` helper in `tests/unit/request-store.test.ts` with an optional id:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
function makeRequest(method: string, path: string, status: number, id?: string): RequestResult {
|
|
63
|
+
counter++;
|
|
64
|
+
return new RequestResult({
|
|
65
|
+
id: id || `req_${counter}`,
|
|
66
|
+
method,
|
|
67
|
+
path,
|
|
68
|
+
fullUrl: path,
|
|
69
|
+
requestHeaders: {},
|
|
70
|
+
status,
|
|
71
|
+
statusText: String(status),
|
|
72
|
+
responseHeaders: {},
|
|
73
|
+
timing: 0,
|
|
74
|
+
timestamp: new Date(),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Add a new describe block (reuses the existing `outputDir`/`store` beforeEach):
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
describe('RequestStore loadFromDisk', () => {
|
|
83
|
+
it('loads only browser-captured requests from disk', () => {
|
|
84
|
+
makeRequest('POST', '/api/suites', 201, 'xhr_001_POST_api_suites').save(outputDir);
|
|
85
|
+
makeRequest('POST', '/api/suites', 400, '001_POST_api_suites').save(outputDir);
|
|
86
|
+
|
|
87
|
+
const fresh = new RequestStore(outputDir);
|
|
88
|
+
fresh.loadFromDisk();
|
|
89
|
+
|
|
90
|
+
expect(fresh.getCapturedRequests()).toHaveLength(1);
|
|
91
|
+
expect(fresh.getCapturedRequests()[0].id).toBe('xhr_001_POST_api_suites');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
97
|
+
|
|
98
|
+
Run: `bun test tests/unit/request-store.test.ts`
|
|
99
|
+
Expected: FAIL — `getCapturedRequests()` has length 2.
|
|
100
|
+
|
|
101
|
+
- [ ] **Step 3: Implement the filter**
|
|
102
|
+
|
|
103
|
+
In `src/api/request-store.ts` `loadFromDisk()`, change the file filter line to:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
const files = readdirSync(requestsDir).filter((f) => f.startsWith('xhr_') && f.endsWith('.request.yaml'));
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
110
|
+
|
|
111
|
+
Run: `bun test tests/unit/request-store.test.ts`
|
|
112
|
+
Expected: PASS (all, including the pre-existing failure-listener tests).
|
|
113
|
+
|
|
114
|
+
- [ ] **Step 5: Format and commit**
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
bun run format
|
|
118
|
+
git add src/api/request-store.ts tests/unit/request-store.test.ts
|
|
119
|
+
git commit -m "fix(fisherman): stop replicate mode reading its own past requests as captures"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
### Task 2: Rank captured examples in `findCapturedRequest`
|
|
125
|
+
|
|
126
|
+
**Files:**
|
|
127
|
+
- Modify: `src/api/request-store.ts:111-114` and `normalizePathPattern` at `src/api/request-store.ts:150-152`
|
|
128
|
+
- Test: `tests/unit/request-store.test.ts`
|
|
129
|
+
|
|
130
|
+
**Interfaces:**
|
|
131
|
+
- Consumes: `isDynamicSegment(segment: string): boolean` from `src/utils/url-matcher.ts`
|
|
132
|
+
- Produces: `findCapturedRequest(method: string, searchPath: string): RequestResult | undefined` — same signature, now ranked. The search path may contain literal `{id}` segments (as printed by Task 3's endpoint list) or concrete ids; both match stored requests. Contract for Task 3: endpoint lists print `normalizePathPattern` output, and this method accepts exactly those strings.
|
|
133
|
+
|
|
134
|
+
Ranking: candidates share the method and their normalized path starts with the normalized search path at segment boundaries. Exact segment-count match beats a deeper sub-path; within a tier, `status < 400` beats a rejection; within that, newest `timestamp` wins. An exact-path rejection deliberately beats a sub-path success — `getEndpointSpec`'s existing `usable: false` branch then explains it to the model.
|
|
135
|
+
|
|
136
|
+
Accepted over-generalization: `isDynamicSegment` also fires on short mixed-alphanumeric segments like API version prefixes, so `/api/v2/posts` lists as `POST /api/{id}/posts`. That is recoverable by design — `getEndpointSpec` returns the stored request's *concrete* `path`, and the WORKFLOW already mandates a spec lookup before first use — so do not "fix" it by narrowing the normalization; that would reintroduce the weak dedup.
|
|
137
|
+
|
|
138
|
+
- [ ] **Step 1: Write the failing tests**
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
describe('findCapturedRequest ranking', () => {
|
|
142
|
+
it('prefers a successful capture over a rejected one for the same endpoint', () => {
|
|
143
|
+
store.addCapturedRequest(makeRequest('POST', '/api/suites', 400));
|
|
144
|
+
store.addCapturedRequest(makeRequest('POST', '/api/suites', 201));
|
|
145
|
+
|
|
146
|
+
expect(store.findCapturedRequest('POST', '/api/suites')?.status).toBe(201);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('prefers the exact endpoint over a deeper sub-path', () => {
|
|
150
|
+
store.addCapturedRequest(makeRequest('POST', '/api/suites/42/move', 200));
|
|
151
|
+
store.addCapturedRequest(makeRequest('POST', '/api/suites', 400));
|
|
152
|
+
|
|
153
|
+
expect(store.findCapturedRequest('POST', '/api/suites')?.path).toBe('/api/suites');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('matches {id} patterns and concrete ids against stored ids', () => {
|
|
157
|
+
store.addCapturedRequest(makeRequest('PATCH', '/api/suites/1a2b3c4d', 200));
|
|
158
|
+
|
|
159
|
+
expect(store.findCapturedRequest('PATCH', '/api/suites/{id}')?.status).toBe(200);
|
|
160
|
+
expect(store.findCapturedRequest('PATCH', '/api/suites/9f8e7d6c')?.status).toBe(200);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('prefers the newest among otherwise equal candidates', () => {
|
|
164
|
+
const older = makeRequest('POST', '/api/suites', 201);
|
|
165
|
+
older.timestamp = new Date('2026-01-01');
|
|
166
|
+
const newer = makeRequest('POST', '/api/suites', 201);
|
|
167
|
+
newer.timestamp = new Date('2026-02-01');
|
|
168
|
+
store.addCapturedRequest(older);
|
|
169
|
+
store.addCapturedRequest(newer);
|
|
170
|
+
|
|
171
|
+
expect(store.findCapturedRequest('POST', '/api/suites')?.id).toBe(newer.id);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
177
|
+
|
|
178
|
+
Run: `bun test tests/unit/request-store.test.ts`
|
|
179
|
+
Expected: FAIL — first-match behavior returns the 400 / the sub-path / the older entry.
|
|
180
|
+
|
|
181
|
+
- [ ] **Step 3: Implement normalization and ranking**
|
|
182
|
+
|
|
183
|
+
In `src/api/request-store.ts`, add the import and rewrite `normalizePathPattern` on top of the existing shared classifier:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { isDynamicSegment } from '../utils/url-matcher.ts';
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
function normalizePathPattern(urlPath: string): string {
|
|
191
|
+
return urlPath
|
|
192
|
+
.split('/')
|
|
193
|
+
.map((segment) => (segment && isDynamicSegment(segment) ? '{id}' : segment))
|
|
194
|
+
.join('/');
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Replace `findCapturedRequest`:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
findCapturedRequest(method: string, searchPath: string): RequestResult | undefined {
|
|
202
|
+
const upper = method.toUpperCase();
|
|
203
|
+
const search = normalizePathPattern(searchPath).split('/').filter(Boolean);
|
|
204
|
+
|
|
205
|
+
let best: RequestResult | undefined;
|
|
206
|
+
let bestScore = -1;
|
|
207
|
+
|
|
208
|
+
for (const req of this.capturedRequests) {
|
|
209
|
+
if (req.method !== upper) continue;
|
|
210
|
+
const segments = normalizePathPattern(req.path).split('/').filter(Boolean);
|
|
211
|
+
if (segments.length < search.length) continue;
|
|
212
|
+
if (!search.every((segment, i) => segment === segments[i])) continue;
|
|
213
|
+
|
|
214
|
+
let score = 0;
|
|
215
|
+
if (segments.length === search.length) score += 4;
|
|
216
|
+
if (req.status < 400) score += 2;
|
|
217
|
+
if (score < bestScore) continue;
|
|
218
|
+
if (score === bestScore && best && req.timestamp <= best.timestamp) continue;
|
|
219
|
+
best = req;
|
|
220
|
+
bestScore = score;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return best;
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
228
|
+
|
|
229
|
+
Run: `bun test tests/unit/request-store.test.ts tests/unit/fisherman-tools.test.ts`
|
|
230
|
+
Expected: PASS. (`fisherman-tools.test.ts` duck-types `findCapturedRequest`, so it is unaffected; running it confirms.)
|
|
231
|
+
|
|
232
|
+
- [ ] **Step 5: Format and commit**
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
bun run format
|
|
236
|
+
git add src/api/request-store.ts tests/unit/request-store.test.ts
|
|
237
|
+
git commit -m "fix(fisherman): rank captured examples — exact path, then success, then recency"
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
### Task 3: Scope the endpoint list by shared path segment, own it in RequestStore
|
|
243
|
+
|
|
244
|
+
**Files:**
|
|
245
|
+
- Modify: `src/api/request-store.ts:80-93` (`toEndpointList`), `src/api/request-store.ts:138-141` (`getWriteRequestsForScope`)
|
|
246
|
+
- Modify: `src/ai/fisherman.ts:163-185` (`buildEndpointList`), `src/ai/fisherman.ts:187-217` (`buildSystemPrompt`)
|
|
247
|
+
- Test: `tests/unit/request-store.test.ts`
|
|
248
|
+
|
|
249
|
+
**Interfaces:**
|
|
250
|
+
- Consumes: Task 2's `normalizePathPattern`
|
|
251
|
+
- Produces: `getWriteRequestsForScope(scopePath: string): RequestResult[]` — same signature; returns `[]` (not everything) when nothing matches the scope. `toEndpointList(scopePath?: string): string` — optional scope parameter; lines are `METHOD normalized-path`, deduplicated. Fisherman's `buildEndpointList` delegates to it and no longer builds lines itself.
|
|
252
|
+
|
|
253
|
+
Scope rule (spec sentence): score each scope-URL path segment by how many captured writes contain it in their path; the scope key is the non-zero segment with the fewest matches, leftmost on ties; a request is in scope when its path contains that segment. A root or empty scope returns all writes. This matches a page URL's project/tenant slug to API paths without any site-specific knowledge, and a shared generic literal (matching everything) loses to the selective slug.
|
|
254
|
+
|
|
255
|
+
- [ ] **Step 1: Write the failing tests**
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
describe('RequestStore scope filtering', () => {
|
|
259
|
+
it('scopes writes by the most selective segment shared with the page URL', () => {
|
|
260
|
+
store.addCapturedRequest(makeRequest('POST', '/api/alpha-shop/suites', 201));
|
|
261
|
+
store.addCapturedRequest(makeRequest('PATCH', '/api/other-shop/suites/5', 200));
|
|
262
|
+
|
|
263
|
+
const scoped = store.getWriteRequestsForScope('/projects/alpha-shop/suites');
|
|
264
|
+
|
|
265
|
+
expect(scoped).toHaveLength(1);
|
|
266
|
+
expect(scoped[0].path).toBe('/api/alpha-shop/suites');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('returns nothing when the scope shares no segment with any write', () => {
|
|
270
|
+
store.addCapturedRequest(makeRequest('POST', '/api/alpha-shop/suites', 201));
|
|
271
|
+
|
|
272
|
+
expect(store.getWriteRequestsForScope('/dashboard')).toHaveLength(0);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('returns all writes for the root scope', () => {
|
|
276
|
+
store.addCapturedRequest(makeRequest('POST', '/api/alpha-shop/suites', 201));
|
|
277
|
+
store.addCapturedRequest(makeRequest('POST', '/api/other-shop/labels', 201));
|
|
278
|
+
|
|
279
|
+
expect(store.getWriteRequestsForScope('/')).toHaveLength(2);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('deduplicates endpoint list lines by id pattern', () => {
|
|
283
|
+
store.addCapturedRequest(makeRequest('PATCH', '/api/suites/1a2b3c4d', 200));
|
|
284
|
+
store.addCapturedRequest(makeRequest('PATCH', '/api/suites/9f8e7d6c', 200));
|
|
285
|
+
|
|
286
|
+
expect(store.toEndpointList()).toBe('PATCH /api/suites/{id}');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('scopes the endpoint list when a scope path is given', () => {
|
|
290
|
+
store.addCapturedRequest(makeRequest('POST', '/api/alpha-shop/suites', 201));
|
|
291
|
+
store.addCapturedRequest(makeRequest('POST', '/api/other-shop/suites', 201));
|
|
292
|
+
|
|
293
|
+
expect(store.toEndpointList('/projects/alpha-shop')).toBe('POST /api/alpha-shop/suites');
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
299
|
+
|
|
300
|
+
Run: `bun test tests/unit/request-store.test.ts`
|
|
301
|
+
Expected: FAIL — prefix matching returns 0 for page URLs, and dedup keeps both id-bearing paths.
|
|
302
|
+
|
|
303
|
+
- [ ] **Step 3: Implement scope + scoped list in RequestStore**
|
|
304
|
+
|
|
305
|
+
Replace `getWriteRequestsForScope` and `toEndpointList` in `src/api/request-store.ts`:
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
getWriteRequestsForScope(scopePath: string): RequestResult[] {
|
|
309
|
+
const writeMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
310
|
+
const writes = this.capturedRequests.filter((r) => writeMethods.has(r.method));
|
|
311
|
+
const scopeSegments = scopePath.split('/').filter(Boolean);
|
|
312
|
+
if (scopeSegments.length === 0) return writes;
|
|
313
|
+
|
|
314
|
+
let scopeKey = '';
|
|
315
|
+
let fewest = Number.POSITIVE_INFINITY;
|
|
316
|
+
for (const segment of scopeSegments) {
|
|
317
|
+
const matches = writes.filter((r) => r.path.split('/').includes(segment)).length;
|
|
318
|
+
if (matches === 0 || matches >= fewest) continue;
|
|
319
|
+
scopeKey = segment;
|
|
320
|
+
fewest = matches;
|
|
321
|
+
}
|
|
322
|
+
if (!scopeKey) return [];
|
|
323
|
+
|
|
324
|
+
return writes.filter((r) => r.path.split('/').includes(scopeKey));
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
toEndpointList(scopePath?: string): string {
|
|
330
|
+
let requests = this.capturedRequests;
|
|
331
|
+
if (scopePath) requests = this.getWriteRequestsForScope(scopePath);
|
|
332
|
+
|
|
333
|
+
const seen = new Set<string>();
|
|
334
|
+
const lines: string[] = [];
|
|
335
|
+
|
|
336
|
+
for (const req of requests) {
|
|
337
|
+
const key = `${req.method} ${normalizePathPattern(req.path)}`;
|
|
338
|
+
if (seen.has(key)) continue;
|
|
339
|
+
seen.add(key);
|
|
340
|
+
lines.push(key);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return lines.join('\n');
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
- [ ] **Step 4: Delegate from Fisherman and surface degradation**
|
|
348
|
+
|
|
349
|
+
In `src/ai/fisherman.ts`, add a field next to the other privates:
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
private scopeDegraded = false;
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Replace `buildEndpointList`:
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
private buildEndpointList(scopeUrl?: string): string {
|
|
359
|
+
this.scopeDegraded = false;
|
|
360
|
+
if (this.mode === 'achieve' && this.spec) {
|
|
361
|
+
const specEndpoints = listAllEndpoints(this.spec, this.baseEndpoint);
|
|
362
|
+
if (specEndpoints) return specEndpoints;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const scoped = this.requestStore.toEndpointList(scopeUrl || '/');
|
|
366
|
+
if (scoped) return scoped;
|
|
367
|
+
|
|
368
|
+
this.scopeDegraded = true;
|
|
369
|
+
return this.requestStore.toEndpointList();
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
In `buildSystemPrompt`, replace the `scopeBlock` line with:
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
let scopeBlock = '';
|
|
377
|
+
if (scopeUrl) {
|
|
378
|
+
scopeBlock = `\n\nSCOPE: You are operating within ${scopeUrl}.\nAll created items must belong to this scope.`;
|
|
379
|
+
if (this.scopeDegraded) scopeBlock += '\nThe endpoint list could not be narrowed to this scope and may include endpoints belonging to other scopes. Before writing, confirm the target belongs to this scope.';
|
|
380
|
+
}
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
- [ ] **Step 5: Run tests to verify they pass**
|
|
384
|
+
|
|
385
|
+
Run: `bun test tests/unit/ tests/integration/`
|
|
386
|
+
Expected: PASS.
|
|
387
|
+
|
|
388
|
+
- [ ] **Step 6: Format and commit**
|
|
389
|
+
|
|
390
|
+
```bash
|
|
391
|
+
bun run format
|
|
392
|
+
git add src/api/request-store.ts src/ai/fisherman.ts tests/unit/request-store.test.ts
|
|
393
|
+
git commit -m "fix(fisherman): scope endpoint list by shared URL segment, announce degradation"
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
### Task 4: Keep the HTTP status authoritative in the `request` tool
|
|
399
|
+
|
|
400
|
+
**Files:**
|
|
401
|
+
- Modify: `src/ai/fisherman-tools.ts:76-125` (`request` tool)
|
|
402
|
+
- Test: `tests/unit/fisherman-tools.test.ts`
|
|
403
|
+
|
|
404
|
+
**Interfaces:**
|
|
405
|
+
- Consumes: existing `extractKeyFields`
|
|
406
|
+
- Produces: successful `request` tool results are `{ success: true, status: number, extracted: Record<string, any> }` — extraction is namespaced so a response-body field can never clobber the HTTP `status`, and the model sees clearly which part is the transport verdict and which is body data.
|
|
407
|
+
|
|
408
|
+
- [ ] **Step 1: Write the failing test**
|
|
409
|
+
|
|
410
|
+
Add to `tests/unit/fisherman-tools.test.ts`:
|
|
411
|
+
|
|
412
|
+
```ts
|
|
413
|
+
it('keeps the HTTP status authoritative over response body fields', async () => {
|
|
414
|
+
const apiClient = {
|
|
415
|
+
request: async () => ({ status: 201, statusText: 'Created', rawResponseBody: '', responseBody: { id: 7, status: 'draft' } }),
|
|
416
|
+
};
|
|
417
|
+
const { tools } = createFishermanTools(apiClient as any, store(), {});
|
|
418
|
+
|
|
419
|
+
const result: any = await tools.request.execute({ method: 'POST', path: '/items' }, {} as any);
|
|
420
|
+
|
|
421
|
+
expect(result.status).toBe(201);
|
|
422
|
+
expect(result.extracted).toEqual({ id: 7, status: 'draft' });
|
|
423
|
+
});
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
427
|
+
|
|
428
|
+
Run: `bun test tests/unit/fisherman-tools.test.ts`
|
|
429
|
+
Expected: FAIL — `result.status` is `'draft'` and `result.extracted` is undefined.
|
|
430
|
+
|
|
431
|
+
- [ ] **Step 3: Namespace the extraction**
|
|
432
|
+
|
|
433
|
+
In the `request` tool's success return, replace the spread:
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
const extracted = extractKeyFields(reqResult.responseBody);
|
|
437
|
+
tag('success').log(`Fisherman: ${input.method} ${input.path} > ${statusLine}`);
|
|
438
|
+
return {
|
|
439
|
+
success: true,
|
|
440
|
+
status: reqResult.status,
|
|
441
|
+
extracted,
|
|
442
|
+
};
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
Update the tool description's second line to: `Returns status, plus IDs and names auto-extracted from the response under 'extracted'.`
|
|
446
|
+
|
|
447
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
448
|
+
|
|
449
|
+
Run: `bun test tests/unit/fisherman-tools.test.ts`
|
|
450
|
+
Expected: PASS.
|
|
451
|
+
|
|
452
|
+
- [ ] **Step 5: Format and commit**
|
|
453
|
+
|
|
454
|
+
```bash
|
|
455
|
+
bun run format
|
|
456
|
+
git add src/ai/fisherman-tools.ts tests/unit/fisherman-tools.test.ts
|
|
457
|
+
git commit -m "fix(fisherman): namespace extracted response fields so body data cannot clobber HTTP status"
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
### Task 5: Derive the result from the request ledger
|
|
463
|
+
|
|
464
|
+
**Files:**
|
|
465
|
+
- Modify: `src/ai/fisherman-tools.ts` (`createFishermanTools`, `finish` tool, `FishermanResult` type)
|
|
466
|
+
- Modify: `src/api/request-result.ts` (add `isWrite` getter)
|
|
467
|
+
- Modify: `src/ai/pilot.ts:764-768` (show `via` in the precondition step text)
|
|
468
|
+
- Test: `tests/unit/fisherman-tools.test.ts`
|
|
469
|
+
|
|
470
|
+
**Interfaces:**
|
|
471
|
+
- Consumes: `RequestStore.getMadeRequests(): RequestResult[]`, `RequestResult.extractIdAndTitle(): { id?, title? }`, `RequestResult.toSummary(): string`
|
|
472
|
+
- Produces: `RequestResult` gains `get isWrite(): boolean` (method is POST/PUT/PATCH/DELETE). `FishermanResult.created` items gain optional `via?: string` (`"POST /api/suites"`). `createFishermanTools` snapshots the made-request count at creation; everything after that index is "this run". `finish` with zero successful writes in this run returns `{ finished: false, error }` and does not end the loop. `getResult()` without a `finish`/`stop` synthesizes an honest summary and ledger-derived `created` instead of `summary: ''`.
|
|
473
|
+
|
|
474
|
+
This is the generate-then-verify ladder from CLAUDE.md: the model still names types and writes the summary (judgment), while success and ids are checked against what HTTP actually returned (deterministic, loud).
|
|
475
|
+
|
|
476
|
+
- [ ] **Step 1: Write the failing tests**
|
|
477
|
+
|
|
478
|
+
Replace the `store()` helper at the bottom of `tests/unit/fisherman-tools.test.ts` and add a made-request factory:
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
function store(captured?: any, made: any[] = []): any {
|
|
482
|
+
return {
|
|
483
|
+
findCapturedRequest: () => captured,
|
|
484
|
+
addMadeRequest: (r: any) => made.push(r),
|
|
485
|
+
getMadeRequests: () => made,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function madeWrite(method: string, path: string, status: number, body: Record<string, any> = {}): any {
|
|
490
|
+
return {
|
|
491
|
+
method,
|
|
492
|
+
path,
|
|
493
|
+
status,
|
|
494
|
+
error: undefined,
|
|
495
|
+
isWrite: true,
|
|
496
|
+
extractIdAndTitle: () => body,
|
|
497
|
+
toSummary: () => `${method} ${path} → ${status} (0ms)`,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
Add the tests:
|
|
503
|
+
|
|
504
|
+
```ts
|
|
505
|
+
describe('ledger-derived results', () => {
|
|
506
|
+
it('rejects finish when no successful write was made in this run', async () => {
|
|
507
|
+
const { tools, isFinished } = createFishermanTools({} as any, store(), {});
|
|
508
|
+
|
|
509
|
+
const result: any = await tools.finish.execute({ summary: 'done', created: [{ type: 'suite', id: '1' }] }, {} as any);
|
|
510
|
+
|
|
511
|
+
expect(result.finished).toBe(false);
|
|
512
|
+
expect(result.error).toContain('No successful write');
|
|
513
|
+
expect(isFinished()).toBe(false);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('ignores writes made before this run started', async () => {
|
|
517
|
+
const made = [madeWrite('POST', '/api/suites', 201, { id: 's1' })];
|
|
518
|
+
const { tools, isFinished } = createFishermanTools({} as any, store(undefined, made), {});
|
|
519
|
+
|
|
520
|
+
const result: any = await tools.finish.execute({ summary: 'done', created: [{ type: 'suite', id: 's1' }] }, {} as any);
|
|
521
|
+
|
|
522
|
+
expect(result.finished).toBe(false);
|
|
523
|
+
expect(isFinished()).toBe(false);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('drops created items whose id no write response returned, keeps verified ones with via', async () => {
|
|
527
|
+
const made: any[] = [];
|
|
528
|
+
const { tools, getResult } = createFishermanTools({} as any, store(undefined, made), {});
|
|
529
|
+
made.push(madeWrite('POST', '/api/suites', 201, { id: 's1', title: 'Suite A' }));
|
|
530
|
+
|
|
531
|
+
await tools.finish.execute({ summary: 'done', created: [{ type: 'suite', id: 's1' }, { type: 'milestone', id: 'm9' }] }, {} as any);
|
|
532
|
+
|
|
533
|
+
const result = getResult();
|
|
534
|
+
expect(result.success).toBe(true);
|
|
535
|
+
expect(result.created).toEqual([{ type: 'suite', id: 's1', via: 'POST /api/suites' }]);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('synthesizes an honest summary when the loop ends without finish', async () => {
|
|
539
|
+
const made: any[] = [];
|
|
540
|
+
const { getResult } = createFishermanTools({} as any, store(undefined, made), {});
|
|
541
|
+
made.push(madeWrite('POST', '/api/suites', 201, { id: 's1', title: 'Suite A' }));
|
|
542
|
+
made.push(madeWrite('POST', '/api/tests', 400));
|
|
543
|
+
|
|
544
|
+
const result = getResult();
|
|
545
|
+
expect(result.success).toBe(true);
|
|
546
|
+
expect(result.summary).toContain('1 successful write');
|
|
547
|
+
expect(result.summary).toContain('POST /api/tests → 400');
|
|
548
|
+
expect(result.created[0].id).toBe('s1');
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('reports failure with a reason when the loop ends with no successful writes', async () => {
|
|
552
|
+
const made: any[] = [];
|
|
553
|
+
const { getResult } = createFishermanTools({} as any, store(undefined, made), {});
|
|
554
|
+
made.push(madeWrite('POST', '/api/tests', 400));
|
|
555
|
+
|
|
556
|
+
const result = getResult();
|
|
557
|
+
expect(result.success).toBe(false);
|
|
558
|
+
expect(result.summary).not.toBe('');
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
564
|
+
|
|
565
|
+
Run: `bun test tests/unit/fisherman-tools.test.ts`
|
|
566
|
+
Expected: FAIL — `finish` currently always succeeds and `getResult()` returns `summary: ''`. (The four pre-existing tests must still pass — the updated `store()` helper keeps their behavior.)
|
|
567
|
+
|
|
568
|
+
- [ ] **Step 3: Add the `isWrite` getter**
|
|
569
|
+
|
|
570
|
+
In `src/api/request-result.ts`, after the `responseBody` getter:
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
get isWrite(): boolean {
|
|
574
|
+
return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(this.method);
|
|
575
|
+
}
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
- [ ] **Step 4: Implement the ledger in `createFishermanTools`**
|
|
579
|
+
|
|
580
|
+
In `src/ai/fisherman-tools.ts`, replace the head of `createFishermanTools` and the `finish` tool:
|
|
581
|
+
|
|
582
|
+
```ts
|
|
583
|
+
export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, opts: { spec?: any; baseEndpoint?: string }) {
|
|
584
|
+
let finished = false;
|
|
585
|
+
let result: FishermanResult | null = null;
|
|
586
|
+
const ledgerStart = requestStore.getMadeRequests().length;
|
|
587
|
+
|
|
588
|
+
const runRequests = () => requestStore.getMadeRequests().slice(ledgerStart);
|
|
589
|
+
const successfulWrites = () => runRequests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
|
|
590
|
+
const getResult = () => result ?? synthesizeResult(runRequests(), successfulWrites());
|
|
591
|
+
const isFinished = () => finished;
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
Replace the `finish` tool's `execute`:
|
|
595
|
+
|
|
596
|
+
```ts
|
|
597
|
+
execute: async ({ summary, created, failed }) => {
|
|
598
|
+
const writes = successfulWrites();
|
|
599
|
+
if (writes.length === 0) {
|
|
600
|
+
tag('warning').log('Fisherman: finish rejected — no successful write request in this run');
|
|
601
|
+
return { finished: false, error: 'No successful write request was made in this run, so nothing was created. Keep working, or call stop if the data cannot be prepared.' };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const viaById = new Map<string, string>();
|
|
605
|
+
for (const write of writes) {
|
|
606
|
+
const { id } = write.extractIdAndTitle();
|
|
607
|
+
if (id === undefined) continue;
|
|
608
|
+
viaById.set(String(id), `${write.method} ${write.path}`);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const verified: FishermanResult['created'] = [];
|
|
612
|
+
for (const item of created) {
|
|
613
|
+
if (item.id === undefined) {
|
|
614
|
+
verified.push(item);
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
const via = viaById.get(String(item.id));
|
|
618
|
+
if (!via) {
|
|
619
|
+
tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`);
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
verified.push({ ...item, via });
|
|
623
|
+
}
|
|
624
|
+
if (verified.length === 0) verified.push(...writes.map(toCreatedItem));
|
|
625
|
+
|
|
626
|
+
tag('success').log(`Fisherman done: ${summary}`);
|
|
627
|
+
finished = true;
|
|
628
|
+
result = { success: true, summary, created: verified, failed: failed || [] };
|
|
629
|
+
return { finished: true };
|
|
630
|
+
},
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
Add the module-private helpers after the exported function, and extend the type at the end of the file:
|
|
634
|
+
|
|
635
|
+
```ts
|
|
636
|
+
function synthesizeResult(made: RequestResult[], writes: RequestResult[]): FishermanResult {
|
|
637
|
+
const failures = made.filter((r) => r.status >= 400 || r.error);
|
|
638
|
+
let summary = `Stopped before finishing: ${made.length} requests, ${writes.length} successful writes, ${failures.length} failed`;
|
|
639
|
+
const lastFailure = failures[failures.length - 1];
|
|
640
|
+
if (lastFailure) summary += `; last failure: ${lastFailure.toSummary()}`;
|
|
641
|
+
return { success: writes.length > 0, summary, created: writes.map(toCreatedItem), failed: [] };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function toCreatedItem(write: RequestResult): FishermanResult['created'][number] {
|
|
645
|
+
const { id, title } = write.extractIdAndTitle();
|
|
646
|
+
const segments = write.path.split('/').filter((s) => s && !isDynamicSegment(s));
|
|
647
|
+
return { type: segments[segments.length - 1] || 'item', id, title, via: `${write.method} ${write.path}` };
|
|
648
|
+
}
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
```ts
|
|
652
|
+
export interface FishermanResult {
|
|
653
|
+
success: boolean;
|
|
654
|
+
summary: string;
|
|
655
|
+
created: Array<{ type: string; id?: string | number; title?: string; via?: string }>;
|
|
656
|
+
failed: Array<{ type: string; reason: string }>;
|
|
657
|
+
}
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
Add the imports at the top: `import type { RequestResult } from '../api/request-result.ts';` and `import { isDynamicSegment } from '../utils/url-matcher.ts';`
|
|
661
|
+
|
|
662
|
+
- [ ] **Step 5: Show `via` in Pilot's precondition step**
|
|
663
|
+
|
|
664
|
+
In `src/ai/pilot.ts` `buildPreconditionTool`, extend the item formatting (currently lines 764-768):
|
|
665
|
+
|
|
666
|
+
```ts
|
|
667
|
+
const parts = [c.type];
|
|
668
|
+
if (c.title) parts.push(`"${c.title}"`);
|
|
669
|
+
if (c.id) parts.push(`(id: ${c.id})`);
|
|
670
|
+
if (c.via) parts.push(`via ${c.via}`);
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
- [ ] **Step 6: Run tests to verify they pass**
|
|
674
|
+
|
|
675
|
+
Run: `bun test tests/unit/ tests/integration/`
|
|
676
|
+
Expected: PASS.
|
|
677
|
+
|
|
678
|
+
- [ ] **Step 7: Format and commit**
|
|
679
|
+
|
|
680
|
+
```bash
|
|
681
|
+
bun run format
|
|
682
|
+
git add src/ai/fisherman-tools.ts src/api/request-result.ts src/ai/pilot.ts tests/unit/fisherman-tools.test.ts
|
|
683
|
+
git commit -m "fix(fisherman): verify finish against the request ledger, synthesize result on exhaustion"
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
---
|
|
687
|
+
|
|
688
|
+
### Task 6: Repeated-failure guard and no-substitution rule
|
|
689
|
+
|
|
690
|
+
**Files:**
|
|
691
|
+
- Modify: `src/ai/fisherman.ts` (`prepareData` loop, system prompt RULES, new private method)
|
|
692
|
+
|
|
693
|
+
**Interfaces:**
|
|
694
|
+
- Consumes: `requestStore.getMadeRequests()`, Task 5's honest `getResult()` (which turns an early stop into an accurate report)
|
|
695
|
+
- Produces: the run ends after `REPEATED_FAILURE_LIMIT` consecutive failures against one endpoint instead of burning 15 iterations × 5 roundtrips on body guesses; one general prompt rule against creating substitute resource types.
|
|
696
|
+
|
|
697
|
+
This mirrors the deterministic dead-loop detection StateManager does for navigation: identical repeats are a structural signal, not a judgment call.
|
|
698
|
+
|
|
699
|
+
- [ ] **Step 1: Add the constant and the guard**
|
|
700
|
+
|
|
701
|
+
In `src/ai/fisherman.ts` next to `MAX_ITERATIONS`:
|
|
702
|
+
|
|
703
|
+
```ts
|
|
704
|
+
const REPEATED_FAILURE_LIMIT = 4;
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
In `prepareData`, capture the ledger start before the loop (after `createFishermanTools`):
|
|
708
|
+
|
|
709
|
+
```ts
|
|
710
|
+
const ledgerStart = this.requestStore.getMadeRequests().length;
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
In the loop callback, after the `isFinished()` check:
|
|
714
|
+
|
|
715
|
+
```ts
|
|
716
|
+
if (this.isStuckOnEndpoint(ledgerStart)) {
|
|
717
|
+
tag('warning').log('Fisherman: repeated failures on the same endpoint — stopping');
|
|
718
|
+
stop();
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
Add the private method after the other private methods:
|
|
724
|
+
|
|
725
|
+
```ts
|
|
726
|
+
private isStuckOnEndpoint(ledgerStart: number): boolean {
|
|
727
|
+
const made = this.requestStore.getMadeRequests().slice(ledgerStart);
|
|
728
|
+
if (made.length < REPEATED_FAILURE_LIMIT) return false;
|
|
729
|
+
const recent = made.slice(-REPEATED_FAILURE_LIMIT);
|
|
730
|
+
const first = recent[0];
|
|
731
|
+
return recent.every((r) => (r.status >= 400 || r.error) && r.method === first.method && r.path === first.path);
|
|
732
|
+
}
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
- [ ] **Step 2: Add the prompt rule**
|
|
736
|
+
|
|
737
|
+
In `buildSystemPrompt`'s RULES block, add one line after the retry rule:
|
|
738
|
+
|
|
739
|
+
```
|
|
740
|
+
- Create only the resource types that were requested. If no endpoint creates a requested type, call stop — never create a different type as a substitute
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
- [ ] **Step 3: Run tests**
|
|
744
|
+
|
|
745
|
+
Run: `bun test tests/unit/ tests/integration/`
|
|
746
|
+
Expected: PASS.
|
|
747
|
+
|
|
748
|
+
- [ ] **Step 4: Format and commit**
|
|
749
|
+
|
|
750
|
+
```bash
|
|
751
|
+
bun run format
|
|
752
|
+
git add src/ai/fisherman.ts
|
|
753
|
+
git commit -m "fix(fisherman): stop after repeated failures on one endpoint, forbid substitute types"
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
---
|
|
757
|
+
|
|
758
|
+
### Task 7: Integration test — the full replicate-mode loop
|
|
759
|
+
|
|
760
|
+
**Files:**
|
|
761
|
+
- Create: `tests/integration/fisherman.test.ts`
|
|
762
|
+
|
|
763
|
+
**Interfaces:**
|
|
764
|
+
- Consumes: everything above — scoped prompt (Task 3), ledger-gated `finish` (Task 5); the aimock pattern from `tests/integration/prima-do.test.ts` (tool-call fixtures) and `tests/integration/planner.test.ts` (Provider setup, `extractPromptText`)
|
|
765
|
+
- Produces: end-to-end proof that a run's system prompt is scope-filtered and that an empty-handed `finish` is rejected and converted to an honest failure.
|
|
766
|
+
|
|
767
|
+
- [ ] **Step 1: Write the integration test**
|
|
768
|
+
|
|
769
|
+
```ts
|
|
770
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
|
771
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
772
|
+
import { tmpdir } from 'node:os';
|
|
773
|
+
import path from 'node:path';
|
|
774
|
+
import { createOpenAI } from '@ai-sdk/openai';
|
|
775
|
+
import { LLMock } from '@copilotkit/aimock';
|
|
776
|
+
import { Fisherman } from '../../src/ai/fisherman.ts';
|
|
777
|
+
import { Provider } from '../../src/ai/provider.ts';
|
|
778
|
+
import { RequestResult } from '../../src/api/request-result.ts';
|
|
779
|
+
import { RequestStore } from '../../src/api/request-store.ts';
|
|
780
|
+
import { ConfigParser } from '../../src/config.ts';
|
|
781
|
+
|
|
782
|
+
let counter = 0;
|
|
783
|
+
function requestResult(id: string, method: string, urlPath: string, status: number, body?: any): RequestResult {
|
|
784
|
+
counter++;
|
|
785
|
+
const result = new RequestResult({
|
|
786
|
+
id,
|
|
787
|
+
method,
|
|
788
|
+
path: urlPath,
|
|
789
|
+
fullUrl: urlPath,
|
|
790
|
+
requestHeaders: {},
|
|
791
|
+
requestBody: body,
|
|
792
|
+
status,
|
|
793
|
+
statusText: String(status),
|
|
794
|
+
responseHeaders: {},
|
|
795
|
+
timing: 0,
|
|
796
|
+
timestamp: new Date(),
|
|
797
|
+
});
|
|
798
|
+
return result;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function toolCall(id: string, name: string, args: Record<string, any>) {
|
|
802
|
+
return { id, name, arguments: JSON.stringify(args) };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function extractPromptText(entry: any): string {
|
|
806
|
+
if (!entry?.body?.messages) return '';
|
|
807
|
+
return entry.body.messages
|
|
808
|
+
.map((message: any) => {
|
|
809
|
+
if (typeof message.content === 'string') return message.content;
|
|
810
|
+
if (Array.isArray(message.content)) {
|
|
811
|
+
return message.content
|
|
812
|
+
.filter((part: any) => part.type === 'text')
|
|
813
|
+
.map((part: any) => part.text || '')
|
|
814
|
+
.join('\n');
|
|
815
|
+
}
|
|
816
|
+
return '';
|
|
817
|
+
})
|
|
818
|
+
.join('\n');
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
describe('Fisherman with aimock', () => {
|
|
822
|
+
let mock: LLMock;
|
|
823
|
+
let provider: Provider;
|
|
824
|
+
let outputDir: string;
|
|
825
|
+
let requestStore: RequestStore;
|
|
826
|
+
let apiResponses: RequestResult[];
|
|
827
|
+
|
|
828
|
+
beforeAll(async () => {
|
|
829
|
+
mock = new LLMock({ port: 0, logLevel: 'silent' });
|
|
830
|
+
await mock.start();
|
|
831
|
+
|
|
832
|
+
const openai = createOpenAI({ baseURL: `${mock.url}/v1`, apiKey: 'test-key', compatibility: 'compatible' });
|
|
833
|
+
ConfigParser.resetForTesting();
|
|
834
|
+
ConfigParser.setupTestConfig();
|
|
835
|
+
provider = new Provider({ model: openai.chat('test-model'), config: {} });
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
beforeEach(() => {
|
|
839
|
+
mock.clearRequests();
|
|
840
|
+
mock.resetMatchCounts();
|
|
841
|
+
mock.clearFixtures();
|
|
842
|
+
|
|
843
|
+
outputDir = mkdtempSync(path.join(tmpdir(), 'fisherman-'));
|
|
844
|
+
requestStore = new RequestStore(outputDir);
|
|
845
|
+
requestStore.addCapturedRequest(requestResult('xhr_001_POST_api_alpha-shop_suites', 'POST', '/api/alpha-shop/suites', 201, { title: 'Suite' }));
|
|
846
|
+
requestStore.addCapturedRequest(requestResult('xhr_002_POST_api_other-shop_suites', 'POST', '/api/other-shop/suites', 201, { title: 'Suite' }));
|
|
847
|
+
apiResponses = [];
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
afterAll(async () => {
|
|
851
|
+
await mock.stop();
|
|
852
|
+
ConfigParser.cleanupAllTestDirectories();
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
function createFisherman(): Fisherman {
|
|
856
|
+
const apiClient = {
|
|
857
|
+
request: async () => apiResponses.shift(),
|
|
858
|
+
setHeaders: () => {},
|
|
859
|
+
getHeaders: () => ({}),
|
|
860
|
+
};
|
|
861
|
+
return new Fisherman(provider, apiClient as any, requestStore, async () => null, 'https://example.test/api', async () => ({}));
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
it('scopes the prompt and reports verified created items with via', async () => {
|
|
865
|
+
const created = requestResult('made_1', 'POST', '/api/alpha-shop/suites', 201);
|
|
866
|
+
created.rawResponseBodyValue = JSON.stringify({ data: { id: 's1', title: 'Suite A' } });
|
|
867
|
+
apiResponses.push(created);
|
|
868
|
+
|
|
869
|
+
mock.on({ sequenceIndex: 0 }, { toolCalls: [toolCall('c1', 'request', { method: 'POST', path: '/api/alpha-shop/suites', body: { title: 'Suite A' } })] });
|
|
870
|
+
mock.on({ sequenceIndex: 1 }, { toolCalls: [toolCall('c2', 'finish', { summary: '1 suite created', created: [{ type: 'suite', id: 's1', title: 'Suite A' }] })] });
|
|
871
|
+
mock.on({}, { content: 'done' });
|
|
872
|
+
|
|
873
|
+
const result = await createFisherman().prepareData('1 suite', '/projects/alpha-shop/suites');
|
|
874
|
+
|
|
875
|
+
expect(result.success).toBe(true);
|
|
876
|
+
expect(result.created).toEqual([{ type: 'suite', id: 's1', title: 'Suite A', via: 'POST /api/alpha-shop/suites' }]);
|
|
877
|
+
|
|
878
|
+
const systemPrompt = extractPromptText(mock.getRequests()[0]);
|
|
879
|
+
expect(systemPrompt).toContain('POST /api/alpha-shop/suites');
|
|
880
|
+
expect(systemPrompt).not.toContain('other-shop');
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
it('rejects an empty-handed finish and returns an honest failure', async () => {
|
|
884
|
+
mock.on({ sequenceIndex: 0 }, { toolCalls: [toolCall('c1', 'finish', { summary: 'all done', created: [{ type: 'suite', id: '99' }] })] });
|
|
885
|
+
mock.on({}, { toolCalls: [toolCall('c2', 'stop', { reason: 'The data cannot be created' })] });
|
|
886
|
+
|
|
887
|
+
const result = await createFisherman().prepareData('1 suite', '/projects/alpha-shop/suites');
|
|
888
|
+
|
|
889
|
+
expect(result.success).toBe(false);
|
|
890
|
+
expect(result.created).toHaveLength(0);
|
|
891
|
+
expect(result.summary).toBe('The data cannot be created');
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
```
|
|
895
|
+
|
|
896
|
+
Note: `addCapturedRequest` saves into the temp `outputDir`; `detectMode`'s `loadFromDisk` dedups by id, so replicate mode activates from the seeded captures. The `beforeAll` mirrors `tests/integration/prima-do.test.ts`.
|
|
897
|
+
|
|
898
|
+
- [ ] **Step 2: Run the test**
|
|
899
|
+
|
|
900
|
+
Run: `bun test tests/integration/fisherman.test.ts`
|
|
901
|
+
Expected: PASS. If the first fixture assertion fails on prompt content, print `extractPromptText(mock.getRequests()[0])` to see the actual endpoint list — the scoping from Task 3 must have filtered it.
|
|
902
|
+
|
|
903
|
+
- [ ] **Step 3: Run the full suite, format, and commit**
|
|
904
|
+
|
|
905
|
+
```bash
|
|
906
|
+
bun test tests/unit/ tests/integration/
|
|
907
|
+
bun run format
|
|
908
|
+
git add tests/integration/fisherman.test.ts
|
|
909
|
+
git commit -m "test(fisherman): integration coverage for scoped prompts and ledger-gated finish"
|
|
910
|
+
```
|
|
911
|
+
|
|
912
|
+
---
|
|
913
|
+
|
|
914
|
+
### Task 8: Rollout — regression fixture and changelog
|
|
915
|
+
|
|
916
|
+
**Files:**
|
|
917
|
+
- Modify: `tests/regression/fixture/explorbot.config.js:50-52`
|
|
918
|
+
- Modify: `CHANGELOG.md` (via the `/changelog` skill)
|
|
919
|
+
|
|
920
|
+
**Interfaces:**
|
|
921
|
+
- Consumes: `explorbot.ts:329` — replicate mode requires `fisherman: { enabled: true }` when no `api` config block exists
|
|
922
|
+
- Produces: the next user-triggered regression run exercises Fisherman in replicate mode for the first time since the fixes.
|
|
923
|
+
|
|
924
|
+
- [ ] **Step 1: Verify the fixture can feed replicate mode**
|
|
925
|
+
|
|
926
|
+
Replicate mode needs browser-captured JSON write XHRs before any `precondition()` fires. Inspect the Trackly fixture scenarios and knowledge (`tests/regression/fixture/`, `tests/regression/seeds/`) and confirm at least one scenario performs a create/edit through the UI. If no scenario produces a write before preconditions are wanted, report that to the user in the PR description instead of silently flipping the flag — the flip would prove nothing.
|
|
927
|
+
|
|
928
|
+
- [ ] **Step 2: Enable Fisherman in the fixture**
|
|
929
|
+
|
|
930
|
+
In `tests/regression/fixture/explorbot.config.js`:
|
|
931
|
+
|
|
932
|
+
```js
|
|
933
|
+
fisherman: {
|
|
934
|
+
enabled: true,
|
|
935
|
+
},
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
- [ ] **Step 3: Update the changelog**
|
|
939
|
+
|
|
940
|
+
Invoke the `/changelog` skill. The entry must mention the behavioral change: request directories containing only Fisherman-made files (no `xhr_*` captures) no longer activate replicate mode, and `finish` now fails when no successful write request was made.
|
|
941
|
+
|
|
942
|
+
- [ ] **Step 4: Final check and commit**
|
|
943
|
+
|
|
944
|
+
```bash
|
|
945
|
+
bun test tests/unit/ tests/integration/
|
|
946
|
+
bun run format
|
|
947
|
+
git add tests/regression/fixture/explorbot.config.js CHANGELOG.md
|
|
948
|
+
git commit -m "chore(fisherman): enable replicate mode in the regression fixture"
|
|
949
|
+
```
|
|
950
|
+
|
|
951
|
+
- [ ] **Step 5: Hand regression to the user**
|
|
952
|
+
|
|
953
|
+
Do not start the regression workflow. Tell the user the branch is ready for a regression run and that they can apply the `regression` label when they want it. Acceptance criterion for the whole plan: one trace where `precondition()` returns created ids and those ids are visible on the page the Tester then acts on.
|