Skip to content

Commit f3e5836

Browse files
Narratorclaude
andauthored
feat(relay,verify): verify_after_edit MCP tools with deterministic style deltas (RFC 0002) (#58)
* feat(relay,verify): verify_after_edit MCP tools with deterministic style deltas (RFC 0002) Implements the post-edit verification loop the v3 annotation schema was built for. Two new MCP tools on the relay: - domscribe.verify.baseline — captures a pre-edit snapshot (RFC 0001 computed-style allowlist + bounding rect) of a rendered element over the existing WS context channel; baselines are in-memory and session-scoped (max 100, oldest evicted) - domscribe.verify.afterEdit — re-captures, computes per-property style deltas and geometry deltas, and returns a deterministic VerifyResult verdict (match/partial/no_change/regression) derived from optional declared expectedChanges; optionally appends to an annotation's verifyHistory Verdicts are deltas-first by design: computed-style deltas are exact, noise-free, and directly actionable, unlike pixel diffs or vision-model judgment. The pixelDiffRatio/screenshotRef path in the schema stays reserved for a future revision. - @domscribe/verify: new pure delta module (diffStyleMaps, diffBoundingRects, resolveVerdict) alongside the pixel comparator - @domscribe/relay: VerifyService, POST /v1/verify/baseline and /v1/verify/check routes, HTTP client methods, MCP tool registration - @domscribe/overlay: WS context responses now include the element's bounding rect - docs: reconstructed RFC 0002 (fixes the dead link from the sprint 3071 baseline doc); tool tables updated in root and mcp READMEs Degrades gracefully when runtime captureStyles is off: geometry-only change detection with an explanatory note and a hint to enable styles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdBkwcY2Wx1KSCgPWV5xtS * fix(test-fixtures): pin @vitejs/devtools to unbreak nuxt fixture installs npm install fails for the nuxt fixtures with an arborist crash ("Cannot read properties of null (reading 'edgesOut')" in #loadPeerSet) that reproduces with a bare 'npm install nuxt' and no @domscribe packages at all: nuxt -> @nuxt/devtools -> @vitejs/devtools@* resolves to the new 0.4.x devtools family, whose peer topology triggers a known npm arborist bug. Pre-existing on main; surfaced on this PR because CI reinstalls fixtures on every run. Pin @vitejs/devtools to ^0.3.0 via npm overrides in both nuxt fixtures and the generator template until upstream npm/devtools resolve it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdBkwcY2Wx1KSCgPWV5xtS --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c3959ea commit f3e5836

32 files changed

Lines changed: 2313 additions & 4 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,8 @@ The agent-facing surface — tools, prompts, wire schemas, and error envelope
412412
| `domscribe.annotation.get` | Retrieve annotation by ID |
413413
| `domscribe.annotation.list` | List annotations with status/filter options |
414414
| `domscribe.annotation.search` | Full-text search across annotation content |
415+
| `domscribe.verify.baseline` | Capture a pre-edit style/geometry snapshot of a rendered element |
416+
| `domscribe.verify.afterEdit` | Compare the element against the baseline — deterministic verdict + per-property deltas |
415417
| `domscribe.status` | Relay daemon health, manifest stats, queue counts |
416418

417419
See the [`@domscribe/mcp` README](./packages/domscribe-mcp/README.md) for detailed tool schemas, response formats, and prompt definitions.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# RFC 0002 — Post-Edit Verification MCP Tools
2+
3+
- **Status:** Implemented (v1: delta-based verification)
4+
- **Packages:** `@domscribe/core`, `@domscribe/verify`, `@domscribe/relay`, `@domscribe/overlay`
5+
- **Depends on:** RFC 0001 (component-style capture)
6+
7+
> This document reconstructs the RFC 0002 spec that earlier commits cite
8+
> (annotation schema v3, `verifyHistory`, `VerifyResult`) and records the
9+
> design as implemented. The original draft was removed in a repo cleanup;
10+
> the normative constraints below match the code.
11+
12+
## Problem
13+
14+
Coding agents editing UI code cannot reliably tell whether their edit took
15+
effect. The universal failure modes:
16+
17+
1. **Silent no-ops** — the agent edits the wrong file, a non-applying style
18+
path (specificity, conditional class), or HMR fails, and the agent
19+
declares success anyway.
20+
2. **Vision-blind regressions** — agents that verify by screenshot rely on
21+
a vision model to compare images, and vision models are demonstrably
22+
unreliable at exactly the deltas that matter for styling work (small
23+
offsets, near-identical shades, spacing changes).
24+
25+
Every mainstream agent stack verifies by "screenshot + eyeball" as of
26+
mid-2026. None offers a deterministic, element-scoped comparison in the dev
27+
inner loop.
28+
29+
## Design
30+
31+
Two MCP tools on the relay close the loop deterministically:
32+
33+
1. **`domscribe.verify.baseline`** — called *before* the edit. Captures a
34+
snapshot of the target element from the live page over the existing
35+
relay↔browser WS channel: the RFC 0001 computed-style allowlist
36+
(≤32 properties) plus the element's bounding rect. Returns an opaque
37+
`baselineId`. Baselines are in-memory and session-scoped (max 100,
38+
oldest evicted) — they describe the live page *now*, so persisting them
39+
would only produce stale comparisons.
40+
41+
2. **`domscribe.verify.afterEdit`** — called *after* the edit and HMR.
42+
Re-captures the same element, computes deltas against the baseline, and
43+
returns a `VerifyResult` (core schema, annotation schema v3):
44+
- `verdict`: `match` | `partial` | `no_change` | `regression`
45+
- `componentStylesDelta`: per-property `{ property, before, after }`
46+
- `boundingRectDelta`: per-field `{ field, before, after }` (0.5 px
47+
epsilon for sub-pixel jitter)
48+
- `notes`: deterministic explanation
49+
When an `annotationId` is supplied, the result is appended to that
50+
annotation's `context.verifyHistory` (append-only).
51+
52+
### Deltas first, pixels later
53+
54+
The v1 verdict is computed from **style and geometry deltas only** — not
55+
pixel diffs. Grounds for this ordering:
56+
57+
- Computed-style deltas are exact, cheap (no screenshot pipeline), immune
58+
to anti-aliasing noise, and directly actionable in code
59+
(`padding: 8px → 12px` tells the agent what to fix; a red pixel overlay
60+
does not).
61+
- The research consensus (Design2Code's low-level metrics, UI2Code^N's
62+
finding that CLIP-similarity rewards *degrade* refinement, the
63+
VLM-blindness benchmarks) is that element-level structured deltas are the
64+
reliable regression oracle, while holistic visual judgment belongs to
65+
the calling agent.
66+
- The pixel path stays open: `VerifyResultSchema.pixelDiffRatio` and
67+
`screenshotRef` are reserved, and `@domscribe/verify` already ships the
68+
pixelmatch comparator used by the falsifier harness. A future revision
69+
can add element-scoped screenshot capture without changing the contract.
70+
71+
### Verdict semantics (deterministic, intent-agnostic)
72+
73+
The tool measures; the agent judges intent. The caller may declare
74+
`expectedChanges: [{ property, value? }]` — the style changes the edit was
75+
meant to make (values compare as `getComputedStyle` strings).
76+
77+
| Situation | Verdict |
78+
| --- | --- |
79+
| No style and no geometry delta | `no_change` |
80+
| No expectations declared, something changed | `match` + caveat note (change detection only) |
81+
| All expectations met, nothing unexpected | `match` |
82+
| All expectations met, extra properties changed | `partial` (unexpected properties listed) |
83+
| Some expectations met (incl. wrong-value changes) | `partial` |
84+
| No expectation met, other properties changed | `regression` |
85+
86+
Geometry deltas never downgrade a verdict on their own — rect movement is
87+
usually a consequence of an intended style change (padding grows the box).
88+
They are always reported.
89+
90+
### Degradation without `captureStyles`
91+
92+
Style capture is gated on the runtime's `captureStyles` flag (default off
93+
in v0.x per RFC 0001). Without it, verification falls back to geometry-only
94+
change detection: `expectedChanges` cannot be evaluated and the result
95+
notes say so. The baseline response reports `hasComponentStyles` so agents
96+
can prompt the user to enable the flag when full deltas matter.
97+
98+
## Wire surface
99+
100+
- `POST /api/v1/verify/baseline` `{ entryId }`
101+
`{ captured, baselineId?, browserConnected, hasComponentStyles?, hasBoundingRect?, error? }`
102+
- `POST /api/v1/verify/check` `{ baselineId, expectedChanges?, annotationId? }`
103+
`{ verified, browserConnected, result?: VerifyResult, error? }`
104+
105+
The WS `context:response` payload gains an optional
106+
`elementInfo.boundingRect` (serialized `DOMRect`), captured by the overlay's
107+
relay service.
108+
109+
## Delta engine
110+
111+
`@domscribe/verify` exports the pure functions the relay uses (no DOM, no
112+
I/O — unit-testable in isolation):
113+
114+
- `diffStyleMaps(before, after): StylePropertyDelta[]`
115+
- `diffBoundingRects(before, after, epsilon?): BoundingRectDelta[]`
116+
- `resolveVerdict({ styleDeltas, rectDeltas, expectedChanges? })`
117+
118+
## Falsifier gate
119+
120+
RFC 0002's success criterion is a ≥60% retry-resolution rate on the styling
121+
falsifier corpus: after a failed first attempt, an agent given the
122+
`VerifyResult` deltas should resolve the task on retry at least 60% of the
123+
time. Measuring this requires the agent-driving falsifier mode
124+
(`--mode=agent`), which is tracked separately — see
125+
`docs/sprints/3071-rfc-0001-baseline.md` for the harness gap analysis.
126+
127+
## Agent workflow
128+
129+
```
130+
1. domscribe.query.bySource / domscribe.resolve → entryId
131+
2. domscribe.verify.baseline { entryId } → baselineId
132+
3. edit source, wait for HMR
133+
4. domscribe.verify.afterEdit { baselineId, expectedChanges }
134+
5. verdict = no_change? → the edit did not land; fix and repeat 3–4
135+
verdict = partial/regression? → consult deltas; fix and repeat 3–4
136+
verdict = match? → done (agent confirms intent visually if it can)
137+
```

packages/domscribe-core/src/lib/constants/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export const API_PATHS = {
2626
MANIFEST_RESOLVE_BATCH: '/manifest/resolve/batch',
2727
MANIFEST_RESOLVE_BY_SOURCE: '/manifest/resolve-by-source',
2828

29+
// Verify endpoints (RFC 0002)
30+
VERIFY_BASELINE: '/verify/baseline',
31+
VERIFY_CHECK: '/verify/check',
32+
2933
// System endpoints
3034
STATUS: `/status`,
3135
HEALTH: `/health`,

packages/domscribe-mcp/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@ Annotations are created when a developer clicks an element in the Domscribe over
6868
| `domscribe.annotation.list` | List annotations with status and filter options |
6969
| `domscribe.annotation.search` | Full-text search across annotation content |
7070

71+
### Post-Edit Verification
72+
73+
Deterministic verification that a UI edit actually took effect (RFC 0002). Capture a baseline before editing, edit, then verify — the verdict and per-property deltas are exact measurements, not vision-model judgments.
74+
75+
| Tool | Description |
76+
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
77+
| `domscribe.verify.baseline` | Capture a pre-edit snapshot (computed styles + geometry) of a rendered element; returns a `baselineId` |
78+
| `domscribe.verify.afterEdit` | Re-capture and compare against the baseline; returns a verdict (`match`/`partial`/`no_change`/`regression`) with style deltas |
79+
7180
### System
7281

7382
| Tool | Description |

packages/domscribe-overlay/src/services/relay-service.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,9 @@ export class RelayService {
159159
),
160160
),
161161
innerText: elementInfo.element?.innerText?.slice(0, 500),
162+
boundingRect: this.serializeBoundingRect(
163+
elementInfo.element,
164+
),
162165
}
163166
: undefined,
164167
};
@@ -188,6 +191,38 @@ export class RelayService {
188191
return true;
189192
}
190193

194+
/**
195+
* Serialize an element's bounding rect into a plain JSON-safe object.
196+
* DOMRect is not JSON-serializable directly (its fields are getters).
197+
*/
198+
private serializeBoundingRect(element: HTMLElement | undefined):
199+
| {
200+
x: number;
201+
y: number;
202+
width: number;
203+
height: number;
204+
top: number;
205+
right: number;
206+
bottom: number;
207+
left: number;
208+
}
209+
| undefined {
210+
if (!element || typeof element.getBoundingClientRect !== 'function') {
211+
return undefined;
212+
}
213+
const rect = element.getBoundingClientRect();
214+
return {
215+
x: rect.x,
216+
y: rect.y,
217+
width: rect.width,
218+
height: rect.height,
219+
top: rect.top,
220+
right: rect.right,
221+
bottom: rect.bottom,
222+
left: rect.left,
223+
};
224+
}
225+
191226
/**
192227
* Refresh annotations from server
193228
*/

packages/domscribe-relay/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@clack/prompts": "^1.1.0",
2525
"@domscribe/core": "workspace:*",
2626
"@domscribe/manifest": "workspace:*",
27+
"@domscribe/verify": "workspace:*",
2728
"@fastify/cors": "^10.0.0",
2829
"@fastify/websocket": "^11.0.0",
2930
"@modelcontextprotocol/sdk": "^1.0.0",

packages/domscribe-relay/src/client/relay-http-client.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ import {
5151
ShutdownResponseSchema,
5252
StatusResponse,
5353
StatusResponseSchema,
54+
ExpectedChangeInput,
55+
VerifyBaselineResponse,
56+
VerifyBaselineResponseSchema,
57+
VerifyCheckResponse,
58+
VerifyCheckResponseSchema,
5459
} from '../schema.js';
5560
import {
5661
RelayErrorResponse,
@@ -354,6 +359,44 @@ export class RelayHttpClient {
354359
return QueryBySourceResponseSchema.parse(await response.json());
355360
}
356361

362+
async verifyBaseline(params: {
363+
entryId: ManifestEntryId;
364+
}): Promise<VerifyBaselineResponse> {
365+
const apiPath = `${API_PATHS.BASE.replace(':version', 'v1')}${API_PATHS.VERIFY_BASELINE}`;
366+
const url = new URL(apiPath, this.baseUrl);
367+
const response = await fetch(url.toString(), {
368+
method: 'POST',
369+
headers: {
370+
'Content-Type': 'application/json',
371+
},
372+
body: JSON.stringify(params),
373+
});
374+
if (!response.ok) {
375+
throw await this.parseError(response);
376+
}
377+
return VerifyBaselineResponseSchema.parse(await response.json());
378+
}
379+
380+
async verifyCheck(params: {
381+
baselineId: string;
382+
expectedChanges?: ExpectedChangeInput[];
383+
annotationId?: string;
384+
}): Promise<VerifyCheckResponse> {
385+
const apiPath = `${API_PATHS.BASE.replace(':version', 'v1')}${API_PATHS.VERIFY_CHECK}`;
386+
const url = new URL(apiPath, this.baseUrl);
387+
const response = await fetch(url.toString(), {
388+
method: 'POST',
389+
headers: {
390+
'Content-Type': 'application/json',
391+
},
392+
body: JSON.stringify(params),
393+
});
394+
if (!response.ok) {
395+
throw await this.parseError(response);
396+
}
397+
return VerifyCheckResponseSchema.parse(await response.json());
398+
}
399+
357400
async getManifestStats(): Promise<ManifestStatsResponse> {
358401
const apiPath = `${API_PATHS.BASE.replace(':version', 'v1')}${API_PATHS.MANIFEST_STATS}`;
359402
const url = new URL(apiPath, this.baseUrl);

packages/domscribe-relay/src/mcp/__test-utils__/mock-relay-client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export function createMockRelayClient(
2424
deleteAnnotation: vi.fn(),
2525
patchAnnotation: vi.fn(),
2626
queryBySource: vi.fn(),
27+
verifyBaseline: vi.fn(),
28+
verifyCheck: vi.fn(),
2729
getStatus: vi.fn(),
2830
getHealth: vi.fn(),
2931
shutdown: vi.fn(),

packages/domscribe-relay/src/mcp/mcp-adapter.spec.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ function getServer(adapter: McpAdapter) {
5050

5151
describe('McpAdapter', () => {
5252
describe('active mode', () => {
53-
it('should register all 12 tools', () => {
53+
it('should register all 14 tools', () => {
5454
// Act
5555
const adapter = new McpAdapter({
5656
mode: 'active',
@@ -60,7 +60,7 @@ describe('McpAdapter', () => {
6060

6161
// Assert
6262
const server = getServer(adapter);
63-
expect(server.registeredTools.size).toBe(12);
63+
expect(server.registeredTools.size).toBe(14);
6464
expect(server.registeredTools.has('domscribe.resolve')).toBe(true);
6565
expect(server.registeredTools.has('domscribe.resolve.batch')).toBe(true);
6666
expect(server.registeredTools.has('domscribe.manifest.stats')).toBe(true);
@@ -83,6 +83,12 @@ describe('McpAdapter', () => {
8383
);
8484
expect(server.registeredTools.has('domscribe.status')).toBe(true);
8585
expect(server.registeredTools.has('domscribe.query.bySource')).toBe(true);
86+
expect(server.registeredTools.has('domscribe.verify.baseline')).toBe(
87+
true,
88+
);
89+
expect(server.registeredTools.has('domscribe.verify.afterEdit')).toBe(
90+
true,
91+
);
8692
});
8793

8894
it('should register all 4 prompts', () => {

packages/domscribe-relay/src/mcp/mcp-adapter.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import { AnnotationsRespondTool } from './tools/annotation-respond.tool.js';
2525
import { AnnotationsSearchTool } from './tools/annotation-search.tool.js';
2626
import { StatusTool } from './tools/status.tool.js';
2727
import { QueryBySourceTool } from './tools/query-by-source.tool.js';
28+
import { VerifyBaselineTool } from './tools/verify-baseline.tool.js';
29+
import { VerifyAfterEditTool } from './tools/verify-after-edit.tool.js';
2830

2931
// Prompt classes
3032
import { ProcessNextPrompt } from './prompts/process-next.prompt.js';
@@ -113,6 +115,8 @@ export class McpAdapter {
113115
new AnnotationsSearchTool(relayHttpClient),
114116
new StatusTool(relayHttpClient),
115117
new QueryBySourceTool(relayHttpClient),
118+
new VerifyBaselineTool(relayHttpClient),
119+
new VerifyAfterEditTool(relayHttpClient),
116120
];
117121

118122
for (const tool of tools) {

0 commit comments

Comments
 (0)