edsger 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -897,46 +897,57 @@ You MUST end your response with a JSON object containing the code refine results
897
897
  \`\`\`
898
898
  `,
899
899
  'screen-flow': `
900
- **CRITICAL — Output Format**:
900
+ **CRITICAL — How to return the result**:
901
901
 
902
- After finishing your investigation, emit a single fenced code block tagged \`screen_flow\` containing the structured extraction. Do not emit any other JSON blocks.
902
+ Return the extraction by calling the MCP tool
903
+ \`mcp__screen-flow__submit_screen_flow\` **exactly once** with three arguments:
903
904
 
904
- \`\`\`screen_flow
905
- {
906
- "summary": "1-3 sentence narrative of what kind of app this is and its primary user flows",
907
- "nodes": [
908
- {
909
- "slug": "login",
910
- "name": "Login",
911
- "route": "/signin",
912
- "file": "src/pages/Login.tsx",
913
- "kind": "page",
914
- "layout": "centered",
915
- "header": { "title": "Sign in", "actions": [{ "label": "Sign up", "variant": "ghost" }] },
916
- "body": [
917
- {
918
- "type": "form",
919
- "submitLabel": "Sign in",
920
- "fields": [
921
- { "label": "Email", "kind": "email", "required": true },
922
- { "label": "Password", "kind": "password", "required": true }
923
- ]
924
- }
925
- ]
926
- }
905
+ - \`summary\` — 1-3 sentence narrative of what kind of app this is and its primary user flows
906
+ - \`nodes\` — array of ScreenSchema objects (every user-facing screen, modal, drawer, tab, or named state)
907
+ - \`edges\` array of ScreenEdge objects (transitions between screens)
908
+
909
+ The tool validates the arguments against the schema. If it returns an error,
910
+ fix the issue it describes and call the tool again. After a successful call,
911
+ end your turn — do not also paste the same data as a fenced text block.
912
+
913
+ You can also call \`mcp__screen-flow__record_progress({ phase, message })\` at
914
+ each phase boundary (detection / routing / screens / transitions / submission)
915
+ to keep the user informed during long runs. This is observability only — it
916
+ does not affect the extraction.
917
+
918
+ ScreenSchema fields:
919
+ - \`slug\` (unique within the flow), \`name\`, \`route?\`, \`file?\`
920
+ - \`kind\`: one of \`page\`, \`modal\`, \`drawer\`, \`tab\`, \`state\`
921
+ - \`layout\`: one of \`centered\`, \`sidebar\`, \`split\`, \`list-detail\`, \`tabs\`, \`stacked\`
922
+ - \`header?\`: \`{ title, subtitle?, back?, actions?: [{ label, variant?, icon? }] }\`
923
+ - \`body\`: array of sections; each section \`type\` is one of \`form\`, \`list\`, \`card-grid\`, \`table\`, \`kanban\`, \`text\`, \`image\`, \`chart\`, \`stats\`, \`empty-state\`, \`tabs\`, \`custom\`
924
+
925
+ ScreenEdge fields:
926
+ - \`fromSlug\`, \`toSlug\` (both MUST appear in nodes), \`triggerLabel\`, \`triggerFile?\`
927
+ - \`kind\`: one of \`navigate\`, \`modal\`, \`redirect\`, \`back\`
928
+
929
+ Schematic example of the tool call:
930
+
931
+ \`\`\`
932
+ submit_screen_flow({
933
+ summary: "Two-screen demo: sign in then land on home.",
934
+ nodes: [
935
+ { slug: "login", name: "Login", route: "/signin", file: "src/pages/Login.tsx",
936
+ kind: "page", layout: "centered",
937
+ header: { title: "Sign in", actions: [{ label: "Sign up", variant: "ghost" }] },
938
+ body: [{ type: "form", submitLabel: "Sign in", fields: [
939
+ { label: "Email", kind: "email", required: true },
940
+ { label: "Password", kind: "password", required: true }
941
+ ]}]
942
+ },
943
+ { slug: "home", name: "Home", route: "/", file: "src/pages/Home.tsx",
944
+ kind: "page", layout: "sidebar", body: [] }
927
945
  ],
928
- "edges": [
929
- {
930
- "fromSlug": "login",
931
- "toSlug": "home",
932
- "triggerLabel": "Submit credentials",
933
- "triggerFile": "src/pages/Login.tsx",
934
- "kind": "navigate"
935
- }
946
+ edges: [
947
+ { fromSlug: "login", toSlug: "home", triggerLabel: "Submit credentials",
948
+ triggerFile: "src/pages/Login.tsx", kind: "navigate" }
936
949
  ]
937
- }
950
+ })
938
951
  \`\`\`
939
-
940
- All node \`slug\` values must be unique. Every \`fromSlug\` / \`toSlug\` in edges must reference a slug that appears in \`nodes\`. Section \`type\` values are restricted to: \`form\`, \`list\`, \`card-grid\`, \`table\`, \`kanban\`, \`text\`, \`image\`, \`chart\`, \`stats\`, \`empty-state\`, \`tabs\`, \`custom\`. Edge \`kind\` values are restricted to: \`navigate\`, \`modal\`, \`redirect\`, \`back\`.
941
952
  `,
942
953
  };
@@ -24,16 +24,24 @@ export declare function createPromptGenerator(prompt: string): AsyncGenerator<{
24
24
  }>;
25
25
  /**
26
26
  * Extract text content from assistant message content array.
27
+ *
28
+ * When `verbose`, also surfaces tool_use / tool_result blocks via
29
+ * logDebug so it's visible whether the agent is making MCP / file /
30
+ * bash calls — without these, a long-running session looks frozen
31
+ * between text emissions.
27
32
  */
28
33
  export declare function extractTextFromContent(content: any[], verbose?: boolean): string;
29
34
  /**
30
35
  * Try to parse a JSON result from agent response text.
31
- * Looks for ```json code blocks first, then falls back to raw JSON parsing.
32
- * Returns the parsed object or null on failure.
36
+ * Tries a custom fenceTag (e.g. ```screen_flow) first when provided, then
37
+ * ```json, then falls back to raw JSON parsing. Returns the parsed object or
38
+ * null on failure.
33
39
  */
34
- export declare function tryParseJsonFromResponse(responseText: string): unknown | null;
40
+ export declare function tryParseJsonFromResponse(responseText: string, fenceTag?: string): unknown | null;
35
41
  /**
36
42
  * Extract a specific keyed result from agent response.
37
43
  * e.g., tryExtractResult(text, 'review_result') extracts the review_result key.
44
+ * The key is also tried as the fenced code-block tag so phases whose output
45
+ * contract uses a custom fence (e.g. ```screen_flow) parse correctly.
38
46
  */
39
47
  export declare function tryExtractResult(responseText: string, key: string): unknown | null;
@@ -23,6 +23,11 @@ export async function* createPromptGenerator(prompt) {
23
23
  }
24
24
  /**
25
25
  * Extract text content from assistant message content array.
26
+ *
27
+ * When `verbose`, also surfaces tool_use / tool_result blocks via
28
+ * logDebug so it's visible whether the agent is making MCP / file /
29
+ * bash calls — without these, a long-running session looks frozen
30
+ * between text emissions.
26
31
  */
27
32
  export function extractTextFromContent(
28
33
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -33,16 +38,50 @@ content, verbose) {
33
38
  text += `${item.text}\n`;
34
39
  logDebug(item.text, verbose);
35
40
  }
41
+ else if (verbose && item.type === 'tool_use') {
42
+ logDebug(`→ ${item.name}(${previewJson(item.input)})`, verbose);
43
+ }
44
+ else if (verbose && item.type === 'tool_result') {
45
+ const preview = Array.isArray(item.content)
46
+ ? item.content
47
+ .filter((c) => c?.type === 'text')
48
+ .map((c) => c.text ?? '')
49
+ .join(' ')
50
+ : String(item.content ?? '');
51
+ const flag = item.is_error ? '✗' : '←';
52
+ logDebug(`${flag} ${truncate(preview, 200)}`, verbose);
53
+ }
36
54
  }
37
55
  return text;
38
56
  }
57
+ function previewJson(value, max = 200) {
58
+ try {
59
+ return truncate(JSON.stringify(value), max);
60
+ }
61
+ catch {
62
+ return truncate(String(value), max);
63
+ }
64
+ }
65
+ function truncate(text, max) {
66
+ if (text.length <= max) {
67
+ return text;
68
+ }
69
+ return `${text.slice(0, max - 1)}…`;
70
+ }
39
71
  /**
40
72
  * Try to parse a JSON result from agent response text.
41
- * Looks for ```json code blocks first, then falls back to raw JSON parsing.
42
- * Returns the parsed object or null on failure.
73
+ * Tries a custom fenceTag (e.g. ```screen_flow) first when provided, then
74
+ * ```json, then falls back to raw JSON parsing. Returns the parsed object or
75
+ * null on failure.
43
76
  */
44
- export function tryParseJsonFromResponse(responseText) {
77
+ export function tryParseJsonFromResponse(responseText, fenceTag = 'json') {
45
78
  try {
79
+ if (fenceTag !== 'json') {
80
+ const taggedMatch = responseText.match(new RegExp(`\`\`\`${escapeRegExp(fenceTag)}\\s*\\n([\\s\\S]*?)\\n\\s*\`\`\``));
81
+ if (taggedMatch) {
82
+ return JSON.parse(taggedMatch[1]);
83
+ }
84
+ }
46
85
  const jsonBlockMatch = responseText.match(/```json\s*\n([\s\S]*?)\n\s*```/);
47
86
  return jsonBlockMatch
48
87
  ? JSON.parse(jsonBlockMatch[1])
@@ -55,9 +94,11 @@ export function tryParseJsonFromResponse(responseText) {
55
94
  /**
56
95
  * Extract a specific keyed result from agent response.
57
96
  * e.g., tryExtractResult(text, 'review_result') extracts the review_result key.
97
+ * The key is also tried as the fenced code-block tag so phases whose output
98
+ * contract uses a custom fence (e.g. ```screen_flow) parse correctly.
58
99
  */
59
100
  export function tryExtractResult(responseText, key) {
60
- const parsed = tryParseJsonFromResponse(responseText);
101
+ const parsed = tryParseJsonFromResponse(responseText, key);
61
102
  if (parsed &&
62
103
  typeof parsed === 'object' &&
63
104
  key in parsed) {
@@ -66,3 +107,6 @@ export function tryExtractResult(responseText, key) {
66
107
  // If top-level has the expected shape, return the whole thing
67
108
  return parsed;
68
109
  }
110
+ function escapeRegExp(value) {
111
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
112
+ }
@@ -15,6 +15,7 @@ import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js
15
15
  import { cleanupIssueRepo, cloneIssueRepo, ensureWorkspaceDir, } from '../../workspace/workspace-manager.js';
16
16
  import { fetchProductBasics } from '../find-shared/mcp.js';
17
17
  import { createPromptGenerator, extractTextFromContent, tryExtractResult, } from '../pr-shared/agent-utils.js';
18
+ import { createScreenFlowCaptureState, createScreenFlowMcpServer, validateConsistency, } from './mcp-server.js';
18
19
  import { createScreenFlowSystemPrompt, createScreenFlowUserPrompt, } from './prompts.js';
19
20
  import { extractTheme } from './theme.js';
20
21
  import { isScreenFlowExtraction, } from './types.js';
@@ -61,6 +62,17 @@ export async function runScreenFlowPhase(options) {
61
62
  guidance,
62
63
  });
63
64
  logInfo('Running Claude screen-flow extraction...');
65
+ // The agent submits the extraction by calling submit_screen_flow on the
66
+ // in-process MCP server. The handler validates with Zod + cross-field
67
+ // checks and stores the result in `captureState.captured`. If the agent
68
+ // never calls the tool, we fall back to parsing a fenced screen_flow
69
+ // block out of the assistant text.
70
+ const captureState = createScreenFlowCaptureState();
71
+ const mcpServer = createScreenFlowMcpServer(captureState, {
72
+ onProgress: ({ phase, message }) => {
73
+ logInfo(`[${phase}] ${message}`);
74
+ },
75
+ });
64
76
  let lastAssistantResponse = '';
65
77
  let extraction = null;
66
78
  for await (const message of query({
@@ -75,28 +87,19 @@ export async function runScreenFlowPhase(options) {
75
87
  maxTurns: MAX_TURNS,
76
88
  permissionMode: 'bypassPermissions',
77
89
  cwd: repoPath,
90
+ mcpServers: {
91
+ 'screen-flow': mcpServer,
92
+ },
78
93
  },
79
94
  })) {
80
- if (message.type === 'assistant') {
81
- lastAssistantResponse += extractTextFromContent(message.message?.content ?? [], verbose);
82
- continue;
83
- }
84
- if (message.type !== 'result') {
85
- continue;
86
- }
87
- const responseText = message.subtype === 'success'
88
- ? message.result || lastAssistantResponse
89
- : lastAssistantResponse;
90
- const parsed = tryExtractResult(responseText, 'screen_flow');
91
- if (isScreenFlowExtraction(parsed)) {
92
- extraction = parsed;
93
- }
94
- else if (message.subtype !== 'success') {
95
- logError(`Extraction incomplete: ${message.subtype}`);
95
+ const { assistantBuffer, extraction: nextExtraction } = processSdkMessage(message, lastAssistantResponse, captureState, verbose);
96
+ lastAssistantResponse = assistantBuffer;
97
+ if (nextExtraction) {
98
+ extraction = nextExtraction;
96
99
  }
97
100
  }
98
101
  if (!extraction) {
99
- const msg = 'Screen flow extraction failed: could not parse a screen_flow result from the agent';
102
+ const msg = 'Screen flow extraction failed: agent did not call submit_screen_flow and no parseable screen_flow block was found in the response';
100
103
  await markFlowFailed(supabase, flowId, msg);
101
104
  return { status: 'error', message: msg };
102
105
  }
@@ -125,6 +128,59 @@ export async function runScreenFlowPhase(options) {
125
128
  }
126
129
  }
127
130
  }
131
+ // Per-message handler — extracted out of the SDK loop to keep
132
+ // runScreenFlowPhase under the eslint complexity ceiling.
133
+ //
134
+ function processSdkMessage(
135
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
136
+ message, assistantBuffer, captureState, verbose) {
137
+ if (message.type === 'assistant') {
138
+ const next = assistantBuffer +
139
+ extractTextFromContent(message.message?.content ?? [], verbose);
140
+ return { assistantBuffer: next, extraction: null };
141
+ }
142
+ if (message.type === 'user' && verbose) {
143
+ // Surface tool_result blocks (incl. submit_screen_flow validation
144
+ // errors) so verbose mode shows the round-trip.
145
+ const userContent = message.message?.content;
146
+ if (Array.isArray(userContent)) {
147
+ extractTextFromContent(userContent, verbose);
148
+ }
149
+ return { assistantBuffer, extraction: null };
150
+ }
151
+ if (message.type !== 'result') {
152
+ return { assistantBuffer, extraction: null };
153
+ }
154
+ if (captureState.captured) {
155
+ return { assistantBuffer, extraction: captureState.captured };
156
+ }
157
+ const fallback = tryFallbackParse(message, assistantBuffer);
158
+ if (fallback) {
159
+ logWarning('Agent emitted a fenced screen_flow block instead of calling submit_screen_flow; using the parsed text as a fallback.');
160
+ return { assistantBuffer, extraction: fallback };
161
+ }
162
+ if (message.subtype !== 'success') {
163
+ logError(`Extraction incomplete: ${message.subtype}`);
164
+ }
165
+ return { assistantBuffer, extraction: null };
166
+ }
167
+ // Fallback parser: extract a screen_flow JSON block from the final assistant
168
+ // text if the agent skipped the submit_screen_flow tool call.
169
+ function tryFallbackParse(resultMessage, assistantText) {
170
+ const responseText = resultMessage.subtype === 'success'
171
+ ? resultMessage.result || assistantText
172
+ : assistantText;
173
+ const parsed = tryExtractResult(responseText, 'screen_flow');
174
+ if (!isScreenFlowExtraction(parsed)) {
175
+ return null;
176
+ }
177
+ const { error } = validateConsistency(parsed);
178
+ if (error) {
179
+ logWarning(`Fallback extraction failed consistency check: ${error}`);
180
+ return null;
181
+ }
182
+ return parsed;
183
+ }
128
184
  // ============================================================================
129
185
  // Persistence
130
186
  // ============================================================================
@@ -0,0 +1,195 @@
1
+ /**
2
+ * In-process MCP server exposing a single tool — `submit_screen_flow` —
3
+ * that the Claude Agent SDK session calls to return the structured
4
+ * extraction.
5
+ *
6
+ * Using a tool call instead of parsing a fenced text block lets the SDK
7
+ * enforce the schema (via Zod) and lets the agent self-correct when
8
+ * validation fails — the validation error is returned to the agent as
9
+ * the tool result and it can re-call the tool with corrected data.
10
+ *
11
+ * The capture pattern: callers pass in a `ScreenFlowCaptureState`. The
12
+ * tool handler stores the validated args on `state.captured` and the
13
+ * orchestrator reads it after the SDK loop ends. If the agent never
14
+ * calls the tool, `state.captured` stays null and the caller can fall
15
+ * back to parsing the assistant text.
16
+ */
17
+ import { z } from 'zod';
18
+ import type { ScreenFlowExtraction } from './types.js';
19
+ export interface ScreenFlowCaptureState {
20
+ captured: ScreenFlowExtraction | null;
21
+ }
22
+ export declare function createScreenFlowCaptureState(): ScreenFlowCaptureState;
23
+ /** Optional sink for streaming progress messages from the agent. */
24
+ export type ScreenFlowProgressSink = (event: {
25
+ phase: 'detection' | 'routing' | 'screens' | 'transitions' | 'submission';
26
+ message: string;
27
+ }) => void;
28
+ export declare function validateConsistency(extraction: ScreenFlowExtraction): {
29
+ error: string | null;
30
+ };
31
+ /**
32
+ * Build the `submit_screen_flow` tool. Exported separately from the server
33
+ * so tests can exercise the handler directly without going through the
34
+ * MCP transport.
35
+ */
36
+ export declare function createSubmitScreenFlowTool(state: ScreenFlowCaptureState): import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<{
37
+ summary: z.ZodString;
38
+ nodes: z.ZodArray<z.ZodObject<{
39
+ slug: z.ZodString;
40
+ name: z.ZodString;
41
+ route: z.ZodOptional<z.ZodString>;
42
+ file: z.ZodOptional<z.ZodString>;
43
+ kind: z.ZodEnum<{
44
+ page: "page";
45
+ modal: "modal";
46
+ drawer: "drawer";
47
+ tab: "tab";
48
+ state: "state";
49
+ }>;
50
+ layout: z.ZodEnum<{
51
+ split: "split";
52
+ centered: "centered";
53
+ sidebar: "sidebar";
54
+ "list-detail": "list-detail";
55
+ tabs: "tabs";
56
+ stacked: "stacked";
57
+ }>;
58
+ header: z.ZodOptional<z.ZodObject<{
59
+ title: z.ZodString;
60
+ subtitle: z.ZodOptional<z.ZodString>;
61
+ back: z.ZodOptional<z.ZodBoolean>;
62
+ actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
63
+ label: z.ZodString;
64
+ variant: z.ZodOptional<z.ZodEnum<{
65
+ primary: "primary";
66
+ secondary: "secondary";
67
+ ghost: "ghost";
68
+ destructive: "destructive";
69
+ }>>;
70
+ icon: z.ZodOptional<z.ZodString>;
71
+ }, z.core.$strip>>>;
72
+ }, z.core.$strip>>;
73
+ body: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
74
+ type: z.ZodLiteral<"form">;
75
+ fields: z.ZodArray<z.ZodObject<{
76
+ label: z.ZodString;
77
+ kind: z.ZodEnum<{
78
+ number: "number";
79
+ text: "text";
80
+ date: "date";
81
+ select: "select";
82
+ email: "email";
83
+ password: "password";
84
+ textarea: "textarea";
85
+ checkbox: "checkbox";
86
+ }>;
87
+ placeholder: z.ZodOptional<z.ZodString>;
88
+ value: z.ZodOptional<z.ZodString>;
89
+ required: z.ZodOptional<z.ZodBoolean>;
90
+ }, z.core.$strip>>;
91
+ submitLabel: z.ZodString;
92
+ secondaryLabel: z.ZodOptional<z.ZodString>;
93
+ }, z.core.$strip>, z.ZodObject<{
94
+ type: z.ZodLiteral<"list">;
95
+ items: z.ZodArray<z.ZodObject<{
96
+ title: z.ZodString;
97
+ subtitle: z.ZodOptional<z.ZodString>;
98
+ meta: z.ZodOptional<z.ZodString>;
99
+ icon: z.ZodOptional<z.ZodString>;
100
+ }, z.core.$strip>>;
101
+ emptyMessage: z.ZodOptional<z.ZodString>;
102
+ }, z.core.$strip>, z.ZodObject<{
103
+ type: z.ZodLiteral<"card-grid">;
104
+ cards: z.ZodArray<z.ZodObject<{
105
+ title: z.ZodString;
106
+ subtitle: z.ZodOptional<z.ZodString>;
107
+ meta: z.ZodOptional<z.ZodString>;
108
+ }, z.core.$strip>>;
109
+ columns: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<2>, z.ZodLiteral<3>, z.ZodLiteral<4>]>>;
110
+ }, z.core.$strip>, z.ZodObject<{
111
+ type: z.ZodLiteral<"table">;
112
+ columns: z.ZodArray<z.ZodString>;
113
+ rows: z.ZodArray<z.ZodArray<z.ZodString>>;
114
+ }, z.core.$strip>, z.ZodObject<{
115
+ type: z.ZodLiteral<"kanban">;
116
+ columns: z.ZodArray<z.ZodObject<{
117
+ title: z.ZodString;
118
+ cards: z.ZodArray<z.ZodObject<{
119
+ title: z.ZodString;
120
+ meta: z.ZodOptional<z.ZodString>;
121
+ }, z.core.$strip>>;
122
+ }, z.core.$strip>>;
123
+ }, z.core.$strip>, z.ZodObject<{
124
+ type: z.ZodLiteral<"text">;
125
+ content: z.ZodString;
126
+ }, z.core.$strip>, z.ZodObject<{
127
+ type: z.ZodLiteral<"image">;
128
+ alt: z.ZodString;
129
+ aspect: z.ZodOptional<z.ZodEnum<{
130
+ video: "video";
131
+ square: "square";
132
+ wide: "wide";
133
+ }>>;
134
+ }, z.core.$strip>, z.ZodObject<{
135
+ type: z.ZodLiteral<"chart">;
136
+ chartKind: z.ZodEnum<{
137
+ line: "line";
138
+ bar: "bar";
139
+ pie: "pie";
140
+ }>;
141
+ label: z.ZodOptional<z.ZodString>;
142
+ }, z.core.$strip>, z.ZodObject<{
143
+ type: z.ZodLiteral<"stats">;
144
+ items: z.ZodArray<z.ZodObject<{
145
+ label: z.ZodString;
146
+ value: z.ZodString;
147
+ delta: z.ZodOptional<z.ZodString>;
148
+ }, z.core.$strip>>;
149
+ }, z.core.$strip>, z.ZodObject<{
150
+ type: z.ZodLiteral<"empty-state">;
151
+ title: z.ZodString;
152
+ message: z.ZodOptional<z.ZodString>;
153
+ cta: z.ZodOptional<z.ZodString>;
154
+ }, z.core.$strip>, z.ZodObject<{
155
+ type: z.ZodLiteral<"tabs">;
156
+ tabs: z.ZodArray<z.ZodString>;
157
+ activeIndex: z.ZodOptional<z.ZodNumber>;
158
+ }, z.core.$strip>, z.ZodObject<{
159
+ type: z.ZodLiteral<"custom">;
160
+ label: z.ZodString;
161
+ }, z.core.$strip>], "type">>;
162
+ }, z.core.$strip>>;
163
+ edges: z.ZodArray<z.ZodObject<{
164
+ fromSlug: z.ZodString;
165
+ toSlug: z.ZodString;
166
+ triggerLabel: z.ZodString;
167
+ triggerFile: z.ZodOptional<z.ZodString>;
168
+ kind: z.ZodEnum<{
169
+ modal: "modal";
170
+ navigate: "navigate";
171
+ redirect: "redirect";
172
+ back: "back";
173
+ }>;
174
+ }, z.core.$strip>>;
175
+ }>;
176
+ /**
177
+ * Build the `record_progress` tool. A side-channel that lets the agent
178
+ * push a human-readable status message to the CLI / desktop UI so a
179
+ * multi-minute extraction doesn't look frozen between text emissions.
180
+ * Returning `{ ok: true }` keeps it cheap — it has no semantic effect
181
+ * on the extraction.
182
+ */
183
+ export declare function createRecordProgressTool(sink?: ScreenFlowProgressSink): import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<{
184
+ phase: z.ZodEnum<{
185
+ detection: "detection";
186
+ routing: "routing";
187
+ screens: "screens";
188
+ transitions: "transitions";
189
+ submission: "submission";
190
+ }>;
191
+ message: z.ZodString;
192
+ }>;
193
+ export declare function createScreenFlowMcpServer(state: ScreenFlowCaptureState, options?: {
194
+ onProgress?: ScreenFlowProgressSink;
195
+ }): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
@@ -0,0 +1,262 @@
1
+ /**
2
+ * In-process MCP server exposing a single tool — `submit_screen_flow` —
3
+ * that the Claude Agent SDK session calls to return the structured
4
+ * extraction.
5
+ *
6
+ * Using a tool call instead of parsing a fenced text block lets the SDK
7
+ * enforce the schema (via Zod) and lets the agent self-correct when
8
+ * validation fails — the validation error is returned to the agent as
9
+ * the tool result and it can re-call the tool with corrected data.
10
+ *
11
+ * The capture pattern: callers pass in a `ScreenFlowCaptureState`. The
12
+ * tool handler stores the validated args on `state.captured` and the
13
+ * orchestrator reads it after the SDK loop ends. If the agent never
14
+ * calls the tool, `state.captured` stays null and the caller can fall
15
+ * back to parsing the assistant text.
16
+ */
17
+ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
18
+ import { z } from 'zod';
19
+ export function createScreenFlowCaptureState() {
20
+ return { captured: null };
21
+ }
22
+ // ---------------------------------------------------------------------------
23
+ // Zod schemas (mirror types.ts — kept in sync by tests)
24
+ // ---------------------------------------------------------------------------
25
+ const formFieldSchema = z.object({
26
+ label: z.string(),
27
+ kind: z.enum([
28
+ 'text',
29
+ 'email',
30
+ 'password',
31
+ 'textarea',
32
+ 'select',
33
+ 'checkbox',
34
+ 'date',
35
+ 'number',
36
+ ]),
37
+ placeholder: z.string().optional(),
38
+ value: z.string().optional(),
39
+ required: z.boolean().optional(),
40
+ });
41
+ const listItemSchema = z.object({
42
+ title: z.string(),
43
+ subtitle: z.string().optional(),
44
+ meta: z.string().optional(),
45
+ icon: z.string().optional(),
46
+ });
47
+ const cardItemSchema = z.object({
48
+ title: z.string(),
49
+ subtitle: z.string().optional(),
50
+ meta: z.string().optional(),
51
+ });
52
+ const kanbanColumnSchema = z.object({
53
+ title: z.string(),
54
+ cards: z.array(z.object({ title: z.string(), meta: z.string().optional() })),
55
+ });
56
+ const sectionSchema = z.discriminatedUnion('type', [
57
+ z.object({
58
+ type: z.literal('form'),
59
+ fields: z.array(formFieldSchema),
60
+ submitLabel: z.string(),
61
+ secondaryLabel: z.string().optional(),
62
+ }),
63
+ z.object({
64
+ type: z.literal('list'),
65
+ items: z.array(listItemSchema),
66
+ emptyMessage: z.string().optional(),
67
+ }),
68
+ z.object({
69
+ type: z.literal('card-grid'),
70
+ cards: z.array(cardItemSchema),
71
+ columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).optional(),
72
+ }),
73
+ z.object({
74
+ type: z.literal('table'),
75
+ columns: z.array(z.string()),
76
+ rows: z.array(z.array(z.string())),
77
+ }),
78
+ z.object({
79
+ type: z.literal('kanban'),
80
+ columns: z.array(kanbanColumnSchema),
81
+ }),
82
+ z.object({
83
+ type: z.literal('text'),
84
+ content: z.string(),
85
+ }),
86
+ z.object({
87
+ type: z.literal('image'),
88
+ alt: z.string(),
89
+ aspect: z.enum(['video', 'square', 'wide']).optional(),
90
+ }),
91
+ z.object({
92
+ type: z.literal('chart'),
93
+ chartKind: z.enum(['line', 'bar', 'pie']),
94
+ label: z.string().optional(),
95
+ }),
96
+ z.object({
97
+ type: z.literal('stats'),
98
+ items: z.array(z.object({
99
+ label: z.string(),
100
+ value: z.string(),
101
+ delta: z.string().optional(),
102
+ })),
103
+ }),
104
+ z.object({
105
+ type: z.literal('empty-state'),
106
+ title: z.string(),
107
+ message: z.string().optional(),
108
+ cta: z.string().optional(),
109
+ }),
110
+ z.object({
111
+ type: z.literal('tabs'),
112
+ tabs: z.array(z.string()),
113
+ activeIndex: z.number().optional(),
114
+ }),
115
+ z.object({
116
+ type: z.literal('custom'),
117
+ label: z.string(),
118
+ }),
119
+ ]);
120
+ const screenActionSchema = z.object({
121
+ label: z.string(),
122
+ variant: z.enum(['primary', 'secondary', 'ghost', 'destructive']).optional(),
123
+ icon: z.string().optional(),
124
+ });
125
+ const screenHeaderSchema = z.object({
126
+ title: z.string(),
127
+ subtitle: z.string().optional(),
128
+ back: z.boolean().optional(),
129
+ actions: z.array(screenActionSchema).optional(),
130
+ });
131
+ const screenNodeSchema = z.object({
132
+ slug: z.string().min(1),
133
+ name: z.string().min(1),
134
+ route: z.string().optional(),
135
+ file: z.string().optional(),
136
+ kind: z.enum(['page', 'modal', 'drawer', 'tab', 'state']),
137
+ layout: z.enum([
138
+ 'centered',
139
+ 'sidebar',
140
+ 'split',
141
+ 'list-detail',
142
+ 'tabs',
143
+ 'stacked',
144
+ ]),
145
+ header: screenHeaderSchema.optional(),
146
+ body: z.array(sectionSchema),
147
+ });
148
+ const screenEdgeSchema = z.object({
149
+ fromSlug: z.string().min(1),
150
+ toSlug: z.string().min(1),
151
+ triggerLabel: z.string(),
152
+ triggerFile: z.string().optional(),
153
+ kind: z.enum(['navigate', 'modal', 'redirect', 'back']),
154
+ });
155
+ // ---------------------------------------------------------------------------
156
+ // Cross-field consistency (Zod can't express this)
157
+ // ---------------------------------------------------------------------------
158
+ export function validateConsistency(extraction) {
159
+ const slugs = new Set();
160
+ for (const node of extraction.nodes) {
161
+ if (slugs.has(node.slug)) {
162
+ return {
163
+ error: `Duplicate node slug "${node.slug}". Each node.slug MUST be unique within the flow. Re-call submit_screen_flow with deduplicated nodes.`,
164
+ };
165
+ }
166
+ slugs.add(node.slug);
167
+ }
168
+ for (const edge of extraction.edges) {
169
+ if (!slugs.has(edge.fromSlug)) {
170
+ return {
171
+ error: `Edge fromSlug "${edge.fromSlug}" → "${edge.toSlug}" does not match any node slug. Either add the missing node or drop the edge, then re-call submit_screen_flow.`,
172
+ };
173
+ }
174
+ if (!slugs.has(edge.toSlug)) {
175
+ return {
176
+ error: `Edge fromSlug "${edge.fromSlug}" → toSlug "${edge.toSlug}" does not match any node slug. Either add the missing node or drop the edge, then re-call submit_screen_flow.`,
177
+ };
178
+ }
179
+ }
180
+ return { error: null };
181
+ }
182
+ // ---------------------------------------------------------------------------
183
+ // Tool factory + server factory
184
+ // ---------------------------------------------------------------------------
185
+ /**
186
+ * Build the `submit_screen_flow` tool. Exported separately from the server
187
+ * so tests can exercise the handler directly without going through the
188
+ * MCP transport.
189
+ */
190
+ export function createSubmitScreenFlowTool(state) {
191
+ return tool('submit_screen_flow', [
192
+ 'Submit the final screen flow extraction. Call this EXACTLY once,',
193
+ 'when you have finished mapping every screen and transition. Pass the',
194
+ 'full structured flow as the argument. After this call succeeds, end',
195
+ 'your turn — do NOT also paste the same data as a fenced code block.',
196
+ 'If validation fails, the error message tells you what to fix; call',
197
+ 'the tool again with corrected data.',
198
+ ].join(' '), {
199
+ summary: z
200
+ .string()
201
+ .min(1)
202
+ .describe('1-3 sentence narrative of what kind of app this is and its primary user flows.'),
203
+ nodes: z
204
+ .array(screenNodeSchema)
205
+ .describe('Every user-facing screen, modal, drawer, tab, or named state. node.slug MUST be unique within the flow.'),
206
+ edges: z
207
+ .array(screenEdgeSchema)
208
+ .describe('Transitions between screens. Every fromSlug / toSlug MUST reference a slug present in nodes; drop edges whose endpoints you did not emit.'),
209
+ }, async (args) => {
210
+ const extraction = {
211
+ summary: args.summary,
212
+ nodes: args.nodes,
213
+ edges: args.edges,
214
+ };
215
+ const { error } = validateConsistency(extraction);
216
+ if (error) {
217
+ return {
218
+ content: [{ type: 'text', text: error }],
219
+ isError: true,
220
+ };
221
+ }
222
+ state.captured = extraction;
223
+ return {
224
+ content: [
225
+ {
226
+ type: 'text',
227
+ text: `Captured ${extraction.nodes.length} screens / ${extraction.edges.length} transitions. End your turn now.`,
228
+ },
229
+ ],
230
+ };
231
+ });
232
+ }
233
+ /**
234
+ * Build the `record_progress` tool. A side-channel that lets the agent
235
+ * push a human-readable status message to the CLI / desktop UI so a
236
+ * multi-minute extraction doesn't look frozen between text emissions.
237
+ * Returning `{ ok: true }` keeps it cheap — it has no semantic effect
238
+ * on the extraction.
239
+ */
240
+ export function createRecordProgressTool(sink) {
241
+ return tool('record_progress', 'Send a short status update to the user. Does not affect the extraction. Call it at each phase boundary (after detecting the framework, after enumerating routes, while mapping screens, when about to submit) so the user sees progress.', {
242
+ phase: z
243
+ .enum(['detection', 'routing', 'screens', 'transitions', 'submission'])
244
+ .describe('Which phase the message belongs to.'),
245
+ message: z.string().min(1).describe('Human-readable status update.'),
246
+ }, async (args) => {
247
+ sink?.({ phase: args.phase, message: args.message });
248
+ return {
249
+ content: [{ type: 'text', text: 'ok' }],
250
+ };
251
+ });
252
+ }
253
+ export function createScreenFlowMcpServer(state, options) {
254
+ return createSdkMcpServer({
255
+ name: 'screen-flow',
256
+ version: '1.0.0',
257
+ tools: [
258
+ createSubmitScreenFlowTool(state),
259
+ createRecordProgressTool(options?.onProgress),
260
+ ],
261
+ });
262
+ }
@@ -35,5 +35,7 @@ export function createScreenFlowUserPrompt(args) {
35
35
 
36
36
  Start by detecting the framework (check package.json / pubspec.yaml / Package.swift), then locate the router definition or pages directory. Read just enough source per screen to fill in a useful ScreenSchema — do not need to read everything.
37
37
 
38
- When done, emit the single \`screen_flow\` JSON block.`;
38
+ Call \`mcp__screen-flow__record_progress\` at each phase boundary so the user can see your progress (otherwise the CLI looks frozen).
39
+
40
+ When you are done, return the result by **calling the \`mcp__screen-flow__submit_screen_flow\` tool exactly once** with \`summary\`, \`nodes\`, and \`edges\` as arguments. Do not paste the JSON as a fenced text block — the tool call is the deliverable. If the tool returns an error, fix the issue it describes and call the tool again.`;
39
41
  }
@@ -94,19 +94,23 @@ function parseTailwindColors(source) {
94
94
  // either a single string ("primary: '#ff0066'") or an object containing
95
95
  // a 500 key (the Tailwind convention).
96
96
  const primaryString = matchColorEntry(source, 'primary');
97
- if (primaryString)
97
+ if (primaryString) {
98
98
  theme.primary = primaryString;
99
+ }
99
100
  const neutralString = matchColorEntry(source, 'neutral');
100
- if (neutralString)
101
+ if (neutralString) {
101
102
  theme.neutral = neutralString;
103
+ }
102
104
  // Pull a radius default if defined under theme.borderRadius
103
105
  const radiusMatch = source.match(/borderRadius\s*:\s*{[^}]*?(?:DEFAULT|md|lg)\s*:\s*['"]([^'"]+)['"]/);
104
- if (radiusMatch)
106
+ if (radiusMatch) {
105
107
  theme.radius = radiusMatch[1];
108
+ }
106
109
  // Pull the default sans font family
107
110
  const fontMatch = source.match(/fontFamily\s*:\s*{[^}]*?sans\s*:\s*\[?\s*['"]([^'"]+)['"]/);
108
- if (fontMatch)
111
+ if (fontMatch) {
109
112
  theme.font = fontMatch[1];
113
+ }
110
114
  return theme;
111
115
  }
112
116
  function matchColorEntry(source, key) {
@@ -132,22 +136,26 @@ function parseTokensJson(json) {
132
136
  const colors = (json.colors ?? json.color);
133
137
  if (colors && typeof colors === 'object') {
134
138
  const primary = colors.primary;
135
- if (typeof primary === 'string')
139
+ if (typeof primary === 'string') {
136
140
  theme.primary = primary;
141
+ }
137
142
  else if (primary && typeof primary === 'object' && '500' in primary) {
138
143
  theme.primary = primary['500'];
139
144
  }
140
145
  const neutral = colors.neutral;
141
- if (typeof neutral === 'string')
146
+ if (typeof neutral === 'string') {
142
147
  theme.neutral = neutral;
148
+ }
143
149
  else if (neutral && typeof neutral === 'object' && '500' in neutral) {
144
150
  theme.neutral = neutral['500'];
145
151
  }
146
152
  }
147
- if (typeof json.radius === 'string')
153
+ if (typeof json.radius === 'string') {
148
154
  theme.radius = json.radius;
149
- if (typeof json.font === 'string')
155
+ }
156
+ if (typeof json.font === 'string') {
150
157
  theme.font = json.font;
158
+ }
151
159
  return theme;
152
160
  }
153
161
  function parseGlobalCss(source) {
@@ -155,14 +163,17 @@ function parseGlobalCss(source) {
155
163
  const primaryMatch = source.match(/--primary\s*:\s*([^;]+);/);
156
164
  if (primaryMatch) {
157
165
  const value = primaryMatch[1].trim();
158
- if (isColorish(value))
166
+ if (isColorish(value)) {
159
167
  theme.primary = value;
160
- else
161
- theme.primary = `hsl(${value})`; // common shadcn pattern
168
+ }
169
+ else {
170
+ theme.primary = `hsl(${value})`;
171
+ } // common shadcn pattern
162
172
  }
163
173
  const radiusMatch = source.match(/--radius\s*:\s*([^;]+);/);
164
- if (radiusMatch)
174
+ if (radiusMatch) {
165
175
  theme.radius = radiusMatch[1].trim();
176
+ }
166
177
  return theme;
167
178
  }
168
179
  function isColorish(value) {
@@ -20,47 +20,62 @@ function isRecord(value) {
20
20
  return typeof value === 'object' && value !== null;
21
21
  }
22
22
  function isScreenSchema(value) {
23
- if (!isRecord(value))
23
+ if (!isRecord(value)) {
24
24
  return false;
25
- if (typeof value.slug !== 'string' || value.slug.length === 0)
25
+ }
26
+ if (typeof value.slug !== 'string' || value.slug.length === 0) {
26
27
  return false;
27
- if (typeof value.name !== 'string' || value.name.length === 0)
28
+ }
29
+ if (typeof value.name !== 'string' || value.name.length === 0) {
28
30
  return false;
31
+ }
29
32
  if (typeof value.kind !== 'string' || !SCREEN_KINDS.has(value.kind)) {
30
33
  return false;
31
34
  }
32
- if (typeof value.layout !== 'string')
35
+ if (typeof value.layout !== 'string') {
33
36
  return false;
34
- if (!Array.isArray(value.body))
37
+ }
38
+ if (!Array.isArray(value.body)) {
35
39
  return false;
40
+ }
36
41
  return true;
37
42
  }
38
43
  function isScreenEdge(value) {
39
- if (!isRecord(value))
44
+ if (!isRecord(value)) {
40
45
  return false;
41
- if (typeof value.fromSlug !== 'string')
46
+ }
47
+ if (typeof value.fromSlug !== 'string') {
42
48
  return false;
43
- if (typeof value.toSlug !== 'string')
49
+ }
50
+ if (typeof value.toSlug !== 'string') {
44
51
  return false;
45
- if (typeof value.triggerLabel !== 'string')
52
+ }
53
+ if (typeof value.triggerLabel !== 'string') {
46
54
  return false;
55
+ }
47
56
  if (typeof value.kind !== 'string' || !EDGE_KINDS.has(value.kind)) {
48
57
  return false;
49
58
  }
50
59
  return true;
51
60
  }
52
61
  export function isScreenFlowExtraction(value) {
53
- if (!isRecord(value))
62
+ if (!isRecord(value)) {
54
63
  return false;
55
- if (typeof value.summary !== 'string')
64
+ }
65
+ if (typeof value.summary !== 'string') {
56
66
  return false;
57
- if (!Array.isArray(value.nodes))
67
+ }
68
+ if (!Array.isArray(value.nodes)) {
58
69
  return false;
59
- if (!Array.isArray(value.edges))
70
+ }
71
+ if (!Array.isArray(value.edges)) {
60
72
  return false;
61
- if (!value.nodes.every(isScreenSchema))
73
+ }
74
+ if (!value.nodes.every(isScreenSchema)) {
62
75
  return false;
63
- if (!value.edges.every(isScreenEdge))
76
+ }
77
+ if (!value.edges.every(isScreenEdge)) {
64
78
  return false;
79
+ }
65
80
  return true;
66
81
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.57.0",
3
+ "version": "0.58.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"