Skip to content
1 change: 1 addition & 0 deletions packages/domscribe-core/src/lib/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const API_PATHS = {
ANNOTATION_RESPONSE: `/annotations/:id/response`,
ANNOTATION_PROCESS: `/annotations/process`,
ANNOTATION_SEARCH: `/annotations/search`,
ANNOTATION_VERIFY: `/annotations/:id/verify`,

// Manifest endpoints
MANIFEST: `/manifest`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,15 @@ describe('migrateAnnotation', () => {
expect(result.metadata.schemaVersion).toBe(ANNOTATION_SCHEMA_VERSION);
});

it('should default to version 1 when metadata has no schemaVersion', () => {
// With ANNOTATION_SCHEMA_VERSION === 1 and no field, readVersion returns 1.
// Since 1 === ANNOTATION_SCHEMA_VERSION, no migration steps run — it just stamps.
const raw = buildRawAnnotation();
delete (raw['metadata'] as Record<string, unknown>)['schemaVersion'];
it('should migrate v1 annotations forward (verifyHistory stays absent — additive only)', () => {
const raw = buildRawAnnotation({
metadata: { schemaVersion: 1 },
});

const result = migrateAnnotation(raw);

expect(result.metadata.schemaVersion).toBe(1);
expect(result.metadata.schemaVersion).toBe(ANNOTATION_SCHEMA_VERSION);
expect(result.verifyHistory).toBeUndefined();
});

it('should default to version 1 when metadata is missing entirely', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,19 @@ import {
/**
* Registry of migration functions keyed by the version they migrate FROM.
* e.g. migrationSteps[1] migrates v1 → v2.
*
* Currently empty — only v1 exists. When a v2 schema is introduced, add:
* migrationSteps[1] = (data: Record<string, unknown>) => { … mutate … };
*/
const migrationSteps: Record<number, (data: Record<string, unknown>) => void> =
{};
{
// v1 → v2: introduce optional `verifyHistory: VerifyResult[]` on the
// Annotation root (RFC 0002). Migration is a pure stamp — older
// annotations had no verify data, so no field needs to be synthesized
// and consumers MUST treat `verifyHistory` as optional.
1: () => {
// Intentionally no-op: the field is optional and additive. The
// schemaVersion bump alone is sufficient; we keep the slot so
// migrateAnnotation does not throw at version 1.
},
};

/**
* Read `metadata.schemaVersion` from raw JSON, defaulting to 1 for
Expand Down
106 changes: 105 additions & 1 deletion packages/domscribe-core/src/lib/types/annotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,12 @@ export const AnnotationIdSchema = z

/**
* Current annotation schema version. Bump when the Annotation shape changes.
*
* v1 → v2: introduce optional `verifyHistory: VerifyResult[]` on Annotation
* for the RFC 0002 verify_after_edit workflow. Older clients ignore
* the field; the migration is a pure stamp (additive change).
*/
export const ANNOTATION_SCHEMA_VERSION = 1;
export const ANNOTATION_SCHEMA_VERSION = 2;

export const AnnotationMetadataSchema = z.object({
id: AnnotationIdSchema,
Expand Down Expand Up @@ -188,13 +192,113 @@ export const AgentResponseSchema = z.object({
message: z.string().optional().describe('Message from the agent'),
});

/**
* Verdict produced by `verify_after_edit` comparing pre/post-edit captures.
*
* match — visual + computed-style + boundingRect within tolerance
* partial — some axes match, some drifted (agent should reconcile)
* no_change — post-edit capture is indistinguishable from pre-edit
* baseline; almost always means the edit did not land in
* the rendered output the user is looking at
* regression — measurable backslide on at least one axis vs. baseline
*
* See RFC 0002 §Decision for the verdict semantics.
*/
export const VerifyVerdictSchema = z.enum([
'match',
'partial',
'no_change',
'regression',
]);

/**
* Per-property delta keyed by CSS property name. Values are the
* `[before, after]` pair; absence of a key means "unchanged". Bounded
* by the StyleCapturer allowlist so the payload stays well under 4 KB.
*/
export const ComponentStylesDeltaSchema = z.record(
z.string(),
z.tuple([z.string(), z.string()]),
);

/**
* Bounding-rect delta — only the four edges plus dimensions are surfaced,
* matching `BoundingRectSchema`. Each axis is `[before, after]`. Keys
* absent from this record were unchanged. Keys are constrained at the
* comparator level (see `@domscribe/verify`), kept as `string` here so
* the record stays partial without per-key optional bookkeeping.
*/
export const BoundingRectDeltaSchema = z.record(
z.string(),
z.tuple([z.number(), z.number()]),
);

/**
* Result of `verify_after_edit` — the structured verdict the agent
* reconciles against on retry. Built on RFC 0001's componentStyles surface;
* `screenshotRef` is a relay-blob reference (never the raw bytes — the
* 4 KB-per-element serialization budget assumes screenshots are external).
*/
export const VerifyResultSchema = z.object({
annotationId: AnnotationIdSchema.describe(
'Annotation this verify result is bound to',
),
verdict: VerifyVerdictSchema.describe(
'Overall verdict — see VerifyVerdictSchema for semantics',
),
pixelDiffRatio: z
.number()
.min(0)
.max(1)
.describe(
'Fraction of element-scoped pixels that differ between pre/post screenshots in [0, 1]',
),
pixelDiffPixels: z
.number()
.int()
.nonnegative()
.describe('Absolute pixel-count diff (companion to pixelDiffRatio)'),
componentStylesDelta: ComponentStylesDeltaSchema.describe(
'Per-CSS-property [before, after] pairs for properties that changed',
),
boundingRectDelta: BoundingRectDeltaSchema.describe(
'Per-axis [before, after] pairs for boundingRect entries that changed',
),
screenshotRef: z
.string()
.optional()
.describe(
'Opaque relay-blob reference for the post-edit element screenshot. NEVER raw bytes — fetch via the relay if the agent needs the image.',
),
capturedAt: z
.string()
.describe('ISO 8601 timestamp when the post-edit capture was taken'),
reason: z
.string()
.optional()
.describe(
'Human-readable explanation when the verdict is not "match" — surface in agent retry prompts',
),
});

export type VerifyVerdict = z.infer<typeof VerifyVerdictSchema>;
export type ComponentStylesDelta = z.infer<typeof ComponentStylesDeltaSchema>;
export type BoundingRectDelta = z.infer<typeof BoundingRectDeltaSchema>;
export type VerifyResult = z.infer<typeof VerifyResultSchema>;

export const AnnotationSchema = z.object({
metadata: AnnotationMetadataSchema.describe('Annotation metadata'),
interaction: AnnotationInteractionSchema.describe('User interaction details'),
context: AnnotationContextSchema.describe('Context at time of interaction'),
agentResponse: AgentResponseSchema.optional().describe(
"Agent's response if processed",
),
verifyHistory: z
.array(VerifyResultSchema)
.optional()
.describe(
'Verify-after-edit results, appended in call order. Optional — older clients ignore. Soft-recommended; not gated by the annotation lifecycle.',
),
});

export const AnnotationSummarySchema = z.object({
Expand Down
7 changes: 5 additions & 2 deletions packages/domscribe-next/tsconfig.lib.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,16 @@
],
"references": [
{
"path": "../domscribe-transform/tsconfig.lib.json"
"path": "../domscribe-overlay/tsconfig.lib.json"
},
{
"path": "../domscribe-react/tsconfig.lib.json"
},
{
"path": "../domscribe-runtime/tsconfig.lib.json"
},
{
"path": "../domscribe-react/tsconfig.lib.json"
"path": "../domscribe-transform/tsconfig.lib.json"
}
]
}
7 changes: 5 additions & 2 deletions packages/domscribe-nuxt/tsconfig.lib.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,16 @@
],
"references": [
{
"path": "../domscribe-transform/tsconfig.lib.json"
"path": "../domscribe-overlay/tsconfig.lib.json"
},
{
"path": "../domscribe-vue/tsconfig.lib.json"
},
{
"path": "../domscribe-runtime/tsconfig.lib.json"
},
{
"path": "../domscribe-vue/tsconfig.lib.json"
"path": "../domscribe-transform/tsconfig.lib.json"
},
{
"path": "../domscribe-relay/tsconfig.lib.json"
Expand Down
1 change: 1 addition & 0 deletions packages/domscribe-relay/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@clack/prompts": "^1.1.0",
"@domscribe/core": "workspace:*",
"@domscribe/manifest": "workspace:*",
"@domscribe/verify": "workspace:*",
"@fastify/cors": "^10.0.0",
"@fastify/websocket": "^11.0.0",
"@modelcontextprotocol/sdk": "^1.0.0",
Expand Down
34 changes: 34 additions & 0 deletions packages/domscribe-relay/src/client/relay-http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
AnnotationInteraction,
AnnotationStatus,
API_PATHS,
BoundingRect,
ComponentStyles,
DomscribeError,
DomscribeErrorCode,
InteractionMode,
Expand All @@ -37,6 +39,8 @@ import {
AnnotationUpdateResponseResponseSchema,
AnnotationUpdateStatusResponse,
AnnotationUpdateStatusResponseSchema,
AnnotationVerifyResponse,
AnnotationVerifyResponseSchema,
HealthResponse,
HealthResponseSchema,
ManifestBatchResolveResponse,
Expand Down Expand Up @@ -218,6 +222,36 @@ export class RelayHttpClient {
return AnnotationUpdateResponseResponseSchema.parse(await response.json());
}

/**
* Grade a post-edit capture against the annotation's pre-edit baseline
* via the relay's verify_after_edit endpoint (RFC 0002).
*
* `postEdit.screenshotRef` is an opaque blob reference managed by the
* overlay; raw image bytes never traverse this client.
*/
async verifyAnnotation(
annotationId: AnnotationId,
postEdit: {
componentStyles?: ComponentStyles;
boundingRect?: BoundingRect;
screenshotRef?: string;
},
): Promise<AnnotationVerifyResponse> {
const apiPath = `${API_PATHS.BASE.replace(':version', 'v1')}${API_PATHS.ANNOTATION_VERIFY.replace(':id', annotationId)}`;
const url = new URL(apiPath, this.baseUrl);
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ postEdit }),
});
if (!response.ok) {
throw await this.parseError(response);
}
return AnnotationVerifyResponseSchema.parse(await response.json());
}

async updateAnnotationStatus(
annotationId: AnnotationId,
status: AnnotationStatus,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function createMockRelayClient(
processAnnotation: vi.fn(),
updateAnnotationStatus: vi.fn(),
updateAnnotationResponse: vi.fn(),
verifyAnnotation: vi.fn(),
createAnnotation: vi.fn(),
deleteAnnotation: vi.fn(),
patchAnnotation: vi.fn(),
Expand Down
6 changes: 5 additions & 1 deletion packages/domscribe-relay/src/mcp/mcp-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ describe('McpAdapter', () => {

// Assert
const server = getServer(adapter);
expect(server.registeredTools.size).toBe(12);
// RFC 0002 added the verify_after_edit tool — the active count is 13.
expect(server.registeredTools.size).toBe(13);
expect(server.registeredTools.has('domscribe.resolve')).toBe(true);
expect(server.registeredTools.has('domscribe.resolve.batch')).toBe(true);
expect(server.registeredTools.has('domscribe.manifest.stats')).toBe(true);
Expand All @@ -83,6 +84,9 @@ describe('McpAdapter', () => {
);
expect(server.registeredTools.has('domscribe.status')).toBe(true);
expect(server.registeredTools.has('domscribe.query.bySource')).toBe(true);
expect(server.registeredTools.has('domscribe.verify.afterEdit')).toBe(
true,
);
});

it('should register all 4 prompts', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/domscribe-relay/src/mcp/mcp-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { AnnotationsRespondTool } from './tools/annotation-respond.tool.js';
import { AnnotationsSearchTool } from './tools/annotation-search.tool.js';
import { StatusTool } from './tools/status.tool.js';
import { QueryBySourceTool } from './tools/query-by-source.tool.js';
import { VerifyAfterEditTool } from './tools/verify-after-edit.tool.js';

// Prompt classes
import { ProcessNextPrompt } from './prompts/process-next.prompt.js';
Expand Down Expand Up @@ -113,6 +114,7 @@ export class McpAdapter {
new AnnotationsSearchTool(relayHttpClient),
new StatusTool(relayHttpClient),
new QueryBySourceTool(relayHttpClient),
new VerifyAfterEditTool(relayHttpClient),
];

for (const tool of tools) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ If an annotation is found:
2. Navigate to the source file and understand the context
3. Implement the requested change
4. Use domscribe.annotation.respond to store your response
5. Use domscribe.annotation.updateStatus to mark it as 'processed'
5. RECOMMENDED: call domscribe.verify.afterEdit with your post-edit ComponentStyles / boundingRect (and a screenshotRef when the overlay supplied one). The tool grades your edit against the pre-edit baseline and returns a verdict (match | partial | no_change | regression) plus per-axis deltas. If the verdict is no_change or regression, reconcile the deltas and retry your edit before moving on.
6. Use domscribe.annotation.updateStatus to mark the annotation 'processed'. (NOTE: updateStatus does NOT require verify — it is a soft-recommended diagnostic, not a lifecycle gate.)

If no annotation is found, inform the user that the queue is empty.`,
},
Expand Down
5 changes: 5 additions & 0 deletions packages/domscribe-relay/src/mcp/prompts/prompts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ describe('ProcessNextPrompt', () => {
expect(messages[0].content.text).toContain(
'domscribe.annotation.updateStatus',
);
// RFC 0002: prompt should recommend verify_after_edit between respond
// and updateStatus, but NOT gate the lifecycle on it.
expect(messages[0].content.text).toContain('domscribe.verify.afterEdit');
expect(messages[0].content.text).toMatch(/RECOMMENDED/i);
expect(messages[0].content.text).toMatch(/not a lifecycle gate/i);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,15 @@ describe('AnnotationsRespondTool', () => {
'ann_123',
'Changed button color to blue',
);
expect(result.structuredContent).toEqual({
expect(result.structuredContent).toMatchObject({
success: true,
annotationId: 'ann_123',
nextStep:
'Call domscribe.annotation.updateStatus with annotationId "ann_123" and status "processed" to complete the lifecycle.',
});
const structured = result.structuredContent as { nextStep: string };
expect(structured.nextStep).toContain('domscribe.verify.afterEdit');
expect(structured.nextStep).toContain(
'domscribe.annotation.updateStatus',
);
});

it('should default message to empty string when not provided', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ export class AnnotationsRespondTool implements McpToolDefinition<
description =
"Store the agent's response to an annotation including explanation message and code patches. " +
'Use after implementing changes to record what was done so users can review in the overlay. ' +
'IMPORTANT: After calling this, you MUST call domscribe.annotation.updateStatus with status "processed" (or "failed") to complete the lifecycle.';
'RECOMMENDED next step: call domscribe.verify.afterEdit to grade your edit against the pre-edit baseline (verdict + per-axis deltas you can reconcile on retry). ' +
'IMPORTANT: After verify (or directly, if you skip verify), call domscribe.annotation.updateStatus with status "processed" (or "failed") to complete the lifecycle.';
inputSchema = AnnotationsRespondToolInputSchema;
outputSchema = AnnotationsRespondToolOutputSchema;

Expand All @@ -60,7 +61,7 @@ export class AnnotationsRespondTool implements McpToolDefinition<
success: response.success,
annotationId: response.annotation.metadata.id,
nextStep: response.success
? `Call domscribe.annotation.updateStatus with annotationId "${response.annotation.metadata.id}" and status "processed" to complete the lifecycle.`
? `RECOMMENDED: call domscribe.verify.afterEdit with annotationId "${response.annotation.metadata.id}" and your post-edit ComponentStyles / boundingRect to grade the edit. Then call domscribe.annotation.updateStatus with status "processed" to complete the lifecycle.`
: undefined,
};

Expand Down
5 changes: 5 additions & 0 deletions packages/domscribe-relay/src/mcp/tools/tool.defs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ describe('tool.defs', () => {
expect(MCP_TOOLS.ANNOTATION_RESPOND).toBe('domscribe.annotation.respond');
expect(MCP_TOOLS.ANNOTATION_SEARCH).toBe('domscribe.annotation.search');
expect(MCP_TOOLS.STATUS).toBe('domscribe.status');
expect(MCP_TOOLS.VERIFY_AFTER_EDIT).toBe('domscribe.verify.afterEdit');
});

it('declares 13 active tools (RFC 0002 added verify_after_edit)', () => {
expect(Object.keys(MCP_TOOLS)).toHaveLength(13);
});
});

Expand Down
Loading
Loading