@tritard/waterbrother 0.8.13 → 0.8.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tritard/waterbrother",
3
- "version": "0.8.13",
3
+ "version": "0.8.15",
4
4
  "description": "Waterbrother: Grok-powered coding CLI with local tools, sessions, operator modes, and approval controls",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -861,6 +861,18 @@ function printReceiptSummary(receipt) {
861
861
  : yellow("caution");
862
862
  console.log(`${styleSystemPrefix()} ${dim("design")} ${verdict} ${receipt.designReview.summary || ""}`.trim());
863
863
  }
864
+ if (receipt.designRevision?.triggered) {
865
+ console.log(`${styleSystemPrefix()} ${dim("design pass")} ${yellow("auto-revised")} ${receipt.designRevision.initialSummary || ""}`.trim());
866
+ }
867
+ if (receipt.screenshotReview?.verdict) {
868
+ const verdict =
869
+ receipt.screenshotReview.verdict === "strong"
870
+ ? green("strong")
871
+ : receipt.screenshotReview.verdict === "weak"
872
+ ? red("weak")
873
+ : yellow("caution");
874
+ console.log(`${styleSystemPrefix()} ${dim("render")} ${verdict} ${receipt.screenshotReview.summary || ""}`.trim());
875
+ }
864
876
  }
865
877
 
866
878
  function printImpactMap(impact) {
@@ -5384,6 +5396,14 @@ async function promptLoop(agent, session, context) {
5384
5396
  const vc = v === "strong" ? green(v) : v === "weak" ? red(v) : yellow(v);
5385
5397
  lines.push(`${dim("design:")} ${vc} — ${buildResult.designReview.summary}`);
5386
5398
  }
5399
+ if (buildResult.screenshotReview) {
5400
+ const v = buildResult.screenshotReview.verdict;
5401
+ const vc = v === "strong" ? green(v) : v === "weak" ? red(v) : yellow(v);
5402
+ lines.push(`${dim("render:")} ${vc} — ${buildResult.screenshotReview.summary}`);
5403
+ }
5404
+ if (buildResult.designRevision?.triggered) {
5405
+ lines.push(`${dim("design pass:")} ${yellow("auto-revised")} — ${buildResult.designRevision.initialSummary || "first pass revised"}`);
5406
+ }
5387
5407
 
5388
5408
  // Task state
5389
5409
  lines.push(`${dim("task:")} ${task.name} → ${cyan("review-ready")}`);
package/src/frontend.js CHANGED
@@ -1,5 +1,13 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { promisify } from "node:util";
1
7
  import { createChatCompletion } from "./grok-client.js";
2
8
 
9
+ const execFileAsync = promisify(execFile);
10
+
3
11
  const FRONTEND_REVIEW_SYSTEM_PROMPT = `You are Waterbrother Art Director, a strict frontend design critic.
4
12
  You review generated websites, landing pages, blogs, and UI surfaces after implementation.
5
13
  Judge the work against these standards:
@@ -23,6 +31,22 @@ Rules:
23
31
  - Call out fake credibility badges, placeholder brands, stock-image dependence, Tailwind CDN template styling, and overused serif/sans premium-blog tropes when present.
24
32
  - Do not wrap JSON in markdown.`;
25
33
 
34
+ const FRONTEND_SCREENSHOT_REVIEW_SYSTEM_PROMPT = `You are Waterbrother Art Director reviewing a rendered website screenshot.
35
+ Judge the actual visual result on screen, not the code alone.
36
+
37
+ Return strict JSON with keys:
38
+ - verdict: one of strong, caution, weak
39
+ - summary: short string
40
+ - wins: array of strings
41
+ - visualIssues: array of strings
42
+ - nextPass: array of strings
43
+
44
+ Rules:
45
+ - Use weak for obvious template output, weak hierarchy, awkward spacing, generic composition, or visually incoherent pages.
46
+ - Use caution for competent pages that still feel safe, generic, or under-directed.
47
+ - Be concrete about visible layout, spacing, typography, contrast, composition, and interaction cues.
48
+ - Do not wrap JSON in markdown.`;
49
+
26
50
  const SITE_TYPES = [
27
51
  ["blog", /\b(blog|journal|essays?|articles?|publication|editorial)\b/i],
28
52
  ["landing", /\b(landing page|homepage|home page|marketing site|product site|launch page|hero section)\b/i],
@@ -47,6 +71,50 @@ const AUDIENCE_HINTS = [
47
71
  ["operators", /\b(founders?|operators?|engineers?|designers?)\b/i]
48
72
  ];
49
73
 
74
+ const ARCHETYPE_RULES = {
75
+ "editorial-minimal": [
76
+ "Use restrained editorial hierarchy with fewer, larger blocks of content.",
77
+ "Favor margins, type rhythm, and restraint over decorative UI.",
78
+ "Keep the palette quiet and avoid loud CTA-heavy marketing patterns."
79
+ ],
80
+ "luxury-magazine": [
81
+ "Use strong type contrast and asymmetry, but avoid fake prestige or fashion cliché overload.",
82
+ "Make one or two sections do the visual heavy lifting instead of many repetitive cards.",
83
+ "Let spacing and composition carry the premium feel more than gradients or gimmicks."
84
+ ],
85
+ "founder-journal": [
86
+ "Keep the voice direct and personal rather than generic lifestyle-editorial.",
87
+ "Use sparse structure and strong notebook-like pacing.",
88
+ "Avoid fake magazine tropes and unnecessary promotional UI."
89
+ ],
90
+ "brutalist-culture": [
91
+ "Prioritize bold hierarchy, high contrast, and deliberate rawness.",
92
+ "Use fewer colors and stronger typographic tension.",
93
+ "Avoid soft premium-blog aesthetics."
94
+ ],
95
+ "high-contrast-tech": [
96
+ "Use a colder palette, harder edges, and precise spacing.",
97
+ "Prefer technical clarity over lifestyle editorial softness.",
98
+ "Avoid generic startup landing-page sections unless they are explicitly requested."
99
+ ],
100
+ "quiet-portfolio": [
101
+ "Let work and case-study structure carry the page, not decorative chrome.",
102
+ "Use calm spacing and image framing with minimal interface noise.",
103
+ "Avoid blog-style editorial filler."
104
+ ]
105
+ };
106
+
107
+ const SLOP_PATTERNS = [
108
+ { key: "placeholder_images", label: "placeholder image service", pattern: /\b(?:picsum\.photos|placehold\.co|placeholder\.com)\b/i, weight: 3 },
109
+ { key: "tailwind_cdn", label: "Tailwind CDN starter styling", pattern: /cdn\.tailwindcss\.com/i, weight: 2 },
110
+ { key: "inter_playfair", label: "generic Inter/Playfair premium pairing", pattern: /Inter|Playfair\s+Display/i, weight: 2 },
111
+ { key: "fake_prestige", label: "fake prestige or publication badge", pattern: /\b(?:featured in|as seen in|forbes|the atlantic|wall street journal|award-winning)\b/i, weight: 3 },
112
+ { key: "fake_founder_lore", label: "fake founder or studio lore", pattern: /\b(?:founded in|est\s+20\d{2}|from the studio|published from a small studio|founder\s*&\s*essayist)\b/i, weight: 2 },
113
+ { key: "newsletter_cliche", label: "generic newsletter promise copy", pattern: /\b(?:no spam, ever|respect your inbox|join the newsletter|subscribe to the journal)\b/i, weight: 1 },
114
+ { key: "fake_ui_chrome", label: "fake low-value UI chrome", pattern: /\b(?:search|filterCategory|showPostModal|toggleSearch|Latest Stories|Recent Dispatches)\b/i, weight: 1 },
115
+ { key: "premium_blog_trope", label: "generic premium-blog editorial trope", pattern: /\b(?:thoughtful living|slow living|curated reflections|crafted with intention|made with intention)\b/i, weight: 2 }
116
+ ];
117
+
50
118
  function normalizeContent(content) {
51
119
  if (typeof content === "string") return content;
52
120
  if (Array.isArray(content)) {
@@ -58,6 +126,21 @@ function normalizeContent(content) {
58
126
  return "";
59
127
  }
60
128
 
129
+ function inferMimeType(filePath) {
130
+ const lower = String(filePath || "").toLowerCase();
131
+ if (lower.endsWith(".png")) return "image/png";
132
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
133
+ if (lower.endsWith(".webp")) return "image/webp";
134
+ if (lower.endsWith(".gif")) return "image/gif";
135
+ return "application/octet-stream";
136
+ }
137
+
138
+ async function loadImageAsDataUrl(filePath) {
139
+ const raw = await fs.readFile(filePath);
140
+ const mime = inferMimeType(filePath);
141
+ return `data:${mime};base64,${raw.toString("base64")}`;
142
+ }
143
+
61
144
  function parseJsonObject(text) {
62
145
  const raw = String(text || "").trim();
63
146
  if (!raw) return null;
@@ -127,7 +210,8 @@ export function buildFrontendExecutionContext({ promptText = "", profile = "code
127
210
  "Prefer hand-authored CSS variables and layout rules over generic template utility sprawl when feasible.",
128
211
  "Cut fake credibility elements, fake brands, fake testimonials, and filler interface chrome unless explicitly requested.",
129
212
  "Avoid placeholder image services, Inter/Playfair default pairings, Tailwind CDN starter aesthetics, and generic premium-blog tropes.",
130
- "Prefer fewer sections with stronger hierarchy over a long page full of low-value widgets."
213
+ "Prefer fewer sections with stronger hierarchy over a long page full of low-value widgets.",
214
+ ...(ARCHETYPE_RULES[archetype] || [])
131
215
  ].join("\n");
132
216
 
133
217
  return {
@@ -156,6 +240,66 @@ export function shouldRunFrontendReview({ promptText = "", receipt = null, profi
156
240
  return changedFiles.some((filePath) => isFrontendFile(filePath));
157
241
  }
158
242
 
243
+ export function detectFrontendSlop({ promptText = "", assistantText = "", receipt = null, designReview = null } = {}) {
244
+ const haystack = [
245
+ String(promptText || ""),
246
+ String(assistantText || ""),
247
+ String(receipt?.diff || ""),
248
+ Array.isArray(receipt?.changedFiles) ? receipt.changedFiles.join("\n") : ""
249
+ ].join("\n");
250
+ const flags = [];
251
+ let score = 0;
252
+ for (const item of SLOP_PATTERNS) {
253
+ if (item.pattern.test(haystack)) {
254
+ flags.push(item.label);
255
+ score += item.weight;
256
+ }
257
+ }
258
+ if (designReview?.verdict === "weak") score += 3;
259
+ else if (designReview?.verdict === "caution") score += 1;
260
+ return {
261
+ score,
262
+ flags,
263
+ severe: score >= 5,
264
+ summary: flags.length > 0 ? `frontend slop flags: ${flags.join(", ")}` : "no deterministic frontend slop flags"
265
+ };
266
+ }
267
+
268
+ export function shouldAutoReviseFrontend({ designReview = null, slop = null, revisionCount = 0 } = {}) {
269
+ if (revisionCount >= 1) return false;
270
+ if (!designReview) return false;
271
+ if (designReview.verdict === "weak") return true;
272
+ if (designReview.verdict === "caution" && (slop?.score || 0) >= 3) return true;
273
+ return false;
274
+ }
275
+
276
+ export function buildFrontendRevisionPrompt({
277
+ originalPrompt = "",
278
+ designReview = null,
279
+ slop = null,
280
+ screenshotReview = null
281
+ } = {}) {
282
+ const issues = Array.isArray(designReview?.issues) ? designReview.issues.slice(0, 6) : [];
283
+ const nextPass = Array.isArray(designReview?.nextPass) ? designReview.nextPass.slice(0, 6) : [];
284
+ const slopFlags = Array.isArray(slop?.flags) ? slop.flags.slice(0, 6) : [];
285
+ const visualIssues = Array.isArray(screenshotReview?.visualIssues) ? screenshotReview.visualIssues.slice(0, 6) : [];
286
+ const visualNextPass = Array.isArray(screenshotReview?.nextPass) ? screenshotReview.nextPass.slice(0, 6) : [];
287
+ const blocks = [
288
+ `Revise the generated frontend to address the design problems from the first pass.`,
289
+ `Original task: ${String(originalPrompt || "").trim()}`,
290
+ issues.length > 0 ? `Problems to fix:\n- ${issues.join("\n- ")}` : "",
291
+ visualIssues.length > 0 ? `Visible screenshot problems:\n- ${visualIssues.join("\n- ")}` : "",
292
+ slopFlags.length > 0 ? `Deterministic slop flags:\n- ${slopFlags.join("\n- ")}` : "",
293
+ nextPass.length > 0 ? `Revision priorities:\n- ${nextPass.join("\n- ")}` : "",
294
+ visualNextPass.length > 0 ? `Screenshot revision priorities:\n- ${visualNextPass.join("\n- ")}` : "",
295
+ "Do not add new filler sections.",
296
+ "Do not add fake prestige, fake testimonials, fake brands, or placeholder-image services.",
297
+ "Simplify the page if needed. Stronger direction with fewer elements is preferred over busier generic output.",
298
+ "Rewrite the weakest sections rather than making superficial tweaks."
299
+ ].filter(Boolean);
300
+ return blocks.join("\n\n");
301
+ }
302
+
159
303
  function normalizeFrontendReview(review) {
160
304
  const verdict = String(review?.verdict || "caution").trim().toLowerCase();
161
305
  return {
@@ -167,6 +311,17 @@ function normalizeFrontendReview(review) {
167
311
  };
168
312
  }
169
313
 
314
+ function normalizeScreenshotReview(review) {
315
+ const verdict = String(review?.verdict || "caution").trim().toLowerCase();
316
+ return {
317
+ verdict: ["strong", "caution", "weak"].includes(verdict) ? verdict : "caution",
318
+ summary: String(review?.summary || "No screenshot review summary returned.").trim(),
319
+ wins: Array.isArray(review?.wins) ? review.wins.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 8) : [],
320
+ visualIssues: Array.isArray(review?.visualIssues) ? review.visualIssues.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 8) : [],
321
+ nextPass: Array.isArray(review?.nextPass) ? review.nextPass.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 8) : []
322
+ };
323
+ }
324
+
170
325
  export async function reviewFrontendTurn({
171
326
  apiKey,
172
327
  baseUrl,
@@ -200,3 +355,93 @@ export async function reviewFrontendTurn({
200
355
  const parsed = parseJsonObject(normalizeContent(completion?.message?.content));
201
356
  return normalizeFrontendReview(parsed || {});
202
357
  }
358
+
359
+ export async function findFrontendPreviewEntry({ cwd, receipt = null } = {}) {
360
+ const changedFiles = Array.isArray(receipt?.changedFiles) ? receipt.changedFiles : [];
361
+ for (const filePath of changedFiles) {
362
+ const raw = String(filePath || "").trim();
363
+ if (!raw) continue;
364
+ const absolute = path.isAbsolute(raw) ? raw : path.resolve(cwd || process.cwd(), raw);
365
+ if (!absolute.toLowerCase().endsWith(".html")) continue;
366
+ try {
367
+ await fs.access(absolute);
368
+ return absolute;
369
+ } catch {}
370
+ }
371
+ const fallback = path.resolve(cwd || process.cwd(), "index.html");
372
+ try {
373
+ await fs.access(fallback);
374
+ return fallback;
375
+ } catch {
376
+ return null;
377
+ }
378
+ }
379
+
380
+ export async function captureFrontendScreenshot({ entryPath } = {}) {
381
+ if (!entryPath || process.platform !== "darwin") return null;
382
+ const screenshotPath = path.join(os.tmpdir(), `waterbrother-frontend-${Date.now()}.png`);
383
+ const targetUrl = pathToFileURL(entryPath).toString();
384
+ const script = `
385
+ on run argv
386
+ set targetUrl to item 1 of argv
387
+ set outputPath to item 2 of argv
388
+ set leftPos to 72
389
+ set topPos to 56
390
+ set rightPos to 1512
391
+ set bottomPos to 1120
392
+ tell application "Safari"
393
+ activate
394
+ make new document with properties {URL:targetUrl}
395
+ delay 2
396
+ try
397
+ set bounds of front window to {leftPos, topPos, rightPos, bottomPos}
398
+ end try
399
+ delay 1
400
+ set winBounds to bounds of front window
401
+ end tell
402
+ set captureLeft to item 1 of winBounds
403
+ set captureTop to item 2 of winBounds
404
+ set captureWidth to (item 3 of winBounds) - (item 1 of winBounds)
405
+ set captureHeight to (item 4 of winBounds) - (item 2 of winBounds)
406
+ do shell script "/usr/sbin/screencapture -x -R" & quoted form of ((captureLeft as string) & "," & (captureTop as string) & "," & (captureWidth as string) & "," & (captureHeight as string)) & " " & quoted form of outputPath
407
+ tell application "Safari"
408
+ try
409
+ close front document saving no
410
+ end try
411
+ end tell
412
+ return outputPath
413
+ end run`;
414
+ await execFileAsync("/usr/bin/osascript", ["-e", script, targetUrl, screenshotPath], { timeout: 12000 });
415
+ return screenshotPath;
416
+ }
417
+
418
+ export async function reviewFrontendScreenshot({
419
+ apiKey,
420
+ baseUrl,
421
+ model,
422
+ screenshotPath,
423
+ promptText = "",
424
+ signal
425
+ } = {}) {
426
+ if (!screenshotPath) return null;
427
+ const imageUrl = await loadImageAsDataUrl(screenshotPath);
428
+ const completion = await createChatCompletion({
429
+ apiKey,
430
+ baseUrl,
431
+ model,
432
+ signal,
433
+ messages: [
434
+ { role: "system", content: FRONTEND_SCREENSHOT_REVIEW_SYSTEM_PROMPT },
435
+ {
436
+ role: "user",
437
+ content: [
438
+ { type: "text", text: `Audit this rendered frontend screenshot for the task: ${String(promptText || "").slice(0, 4000)}` },
439
+ { type: "image_url", image_url: { url: imageUrl } }
440
+ ]
441
+ }
442
+ ]
443
+ });
444
+
445
+ const parsed = parseJsonObject(normalizeContent(completion?.message?.content));
446
+ return normalizeScreenshotReview(parsed || {});
447
+ }
package/src/workflow.js CHANGED
@@ -1,7 +1,17 @@
1
1
  import { createTask, findTaskByName, saveTask, slugify } from "./task-store.js";
2
2
  import { computeImpactMap, summarizeImpactMap } from "./impact.js";
3
3
  import { reviewTurn, challengeReceipt } from "./reviewer.js";
4
- import { buildFrontendExecutionContext, reviewFrontendTurn, shouldRunFrontendReview } from "./frontend.js";
4
+ import {
5
+ buildFrontendExecutionContext,
6
+ buildFrontendRevisionPrompt,
7
+ captureFrontendScreenshot,
8
+ detectFrontendSlop,
9
+ findFrontendPreviewEntry,
10
+ reviewFrontendTurn,
11
+ reviewFrontendScreenshot,
12
+ shouldAutoReviseFrontend,
13
+ shouldRunFrontendReview
14
+ } from "./frontend.js";
5
15
  import { runPlannerPass, formatPlanForExecutor, formatPlanForDisplay } from "./planner.js";
6
16
 
7
17
  export async function runBuildWorkflow({
@@ -78,70 +88,138 @@ export async function runBuildWorkflow({
78
88
  : promptText;
79
89
 
80
90
  // Run the turn
81
- const response = await agent.runBuildTurn(executorPrompt, handlers);
91
+ let response = await agent.runBuildTurn(executorPrompt, handlers);
82
92
 
83
93
  // Complete turn and get receipt
84
- const receipt = await agent.toolRuntime.completeTurn({ signal: handlers.signal });
94
+ let receipt = await agent.toolRuntime.completeTurn({ signal: handlers.signal });
85
95
 
86
96
  if (!receipt) {
87
97
  return { response, receipt: null, impact: null, review: null };
88
98
  }
89
99
 
90
- // Run impact analysis
91
- let impact = null;
92
- if (receipt.mutated && context.runtime.impact?.enabled !== false) {
93
- impact = await computeImpactMap({
94
- cwd: context.cwd,
95
- changedFiles: receipt.changedFiles || [],
96
- maxRelated: context.runtime.impact?.maxRelated,
97
- maxTests: context.runtime.impact?.maxTests
98
- });
99
- }
100
-
101
- // Run sentinel review
102
- let review = null;
103
- if (receipt.mutated && context.runtime.reviewer?.enabled !== false) {
104
- try {
105
- review = await reviewTurn({
106
- apiKey: context.runtime.apiKey,
107
- baseUrl: context.runtime.baseUrl,
108
- model: context.runtime.reviewer?.model || agent.getModel(),
109
- promptText,
110
- assistantText: response.content || "",
111
- receipt: { ...receipt, diff: receipt.diff || "" },
112
- impact,
113
- maxDiffChars: context.runtime.reviewer?.maxDiffChars,
114
- signal: handlers.signal
100
+ async function analyze(activeReceipt, activeResponse) {
101
+ let impact = null;
102
+ if (activeReceipt.mutated && context.runtime.impact?.enabled !== false) {
103
+ impact = await computeImpactMap({
104
+ cwd: context.cwd,
105
+ changedFiles: activeReceipt.changedFiles || [],
106
+ maxRelated: context.runtime.impact?.maxRelated,
107
+ maxTests: context.runtime.impact?.maxTests
115
108
  });
116
- } catch (error) {
117
- review = {
118
- verdict: "caution",
119
- summary: `review failed: ${error instanceof Error ? error.message : String(error)}`,
120
- concerns: ["Sentinel reviewer could not complete."],
121
- followups: []
122
- };
123
109
  }
110
+
111
+ let review = null;
112
+ if (activeReceipt.mutated && context.runtime.reviewer?.enabled !== false) {
113
+ try {
114
+ review = await reviewTurn({
115
+ apiKey: context.runtime.apiKey,
116
+ baseUrl: context.runtime.baseUrl,
117
+ model: context.runtime.reviewer?.model || agent.getModel(),
118
+ promptText,
119
+ assistantText: activeResponse.content || "",
120
+ receipt: { ...activeReceipt, diff: activeReceipt.diff || "" },
121
+ impact,
122
+ maxDiffChars: context.runtime.reviewer?.maxDiffChars,
123
+ signal: handlers.signal
124
+ });
125
+ } catch (error) {
126
+ review = {
127
+ verdict: "caution",
128
+ summary: `review failed: ${error instanceof Error ? error.message : String(error)}`,
129
+ concerns: ["Sentinel reviewer could not complete."],
130
+ followups: []
131
+ };
132
+ }
133
+ }
134
+
135
+ let designReview = null;
136
+ if (shouldRunFrontendReview({ promptText, receipt: activeReceipt, profile: agent.getProfile() })) {
137
+ try {
138
+ designReview = await reviewFrontendTurn({
139
+ apiKey: context.runtime.apiKey,
140
+ baseUrl: context.runtime.baseUrl,
141
+ model: context.runtime.reviewer?.model || agent.getModel(),
142
+ promptText,
143
+ assistantText: activeResponse.content || "",
144
+ receipt: { ...activeReceipt, diff: activeReceipt.diff || "" },
145
+ signal: handlers.signal
146
+ });
147
+ } catch (error) {
148
+ designReview = {
149
+ verdict: "caution",
150
+ summary: `design review failed: ${error instanceof Error ? error.message : String(error)}`,
151
+ strengths: [],
152
+ issues: ["Frontend design reviewer could not complete."],
153
+ nextPass: []
154
+ };
155
+ }
156
+ }
157
+
158
+ let screenshotReview = null;
159
+ let screenshotPath = null;
160
+ if (designReview) {
161
+ try {
162
+ const previewEntry = await findFrontendPreviewEntry({ cwd: context.cwd, receipt: activeReceipt });
163
+ if (previewEntry) {
164
+ screenshotPath = await captureFrontendScreenshot({ entryPath: previewEntry });
165
+ if (screenshotPath) {
166
+ screenshotReview = await reviewFrontendScreenshot({
167
+ apiKey: context.runtime.apiKey,
168
+ baseUrl: context.runtime.baseUrl,
169
+ model: context.runtime.reviewer?.model || agent.getModel(),
170
+ screenshotPath,
171
+ promptText,
172
+ signal: handlers.signal
173
+ });
174
+ }
175
+ }
176
+ } catch {
177
+ screenshotReview = null;
178
+ }
179
+ }
180
+
181
+ const designSlop = designReview
182
+ ? detectFrontendSlop({ promptText, assistantText: activeResponse.content || "", receipt: activeReceipt, designReview })
183
+ : null;
184
+
185
+ return { impact, review, designReview, designSlop, screenshotReview, screenshotPath };
124
186
  }
125
187
 
126
- let designReview = null;
127
- if (shouldRunFrontendReview({ promptText, receipt, profile: agent.getProfile() })) {
128
- try {
129
- designReview = await reviewFrontendTurn({
130
- apiKey: context.runtime.apiKey,
131
- baseUrl: context.runtime.baseUrl,
132
- model: context.runtime.reviewer?.model || agent.getModel(),
133
- promptText,
134
- assistantText: response.content || "",
135
- receipt: { ...receipt, diff: receipt.diff || "" },
136
- signal: handlers.signal
137
- });
138
- } catch (error) {
139
- designReview = {
140
- verdict: "caution",
141
- summary: `design review failed: ${error instanceof Error ? error.message : String(error)}`,
142
- strengths: [],
143
- issues: ["Frontend design reviewer could not complete."],
144
- nextPass: []
188
+ let { impact, review, designReview, designSlop, screenshotReview, screenshotPath } = await analyze(receipt, response);
189
+ let designRevision = null;
190
+
191
+ if (shouldAutoReviseFrontend({ designReview, slop: designSlop, revisionCount: 0 })) {
192
+ const firstPassVerdict = designReview?.verdict || null;
193
+ const firstPassSummary = String(designReview?.summary || "").trim();
194
+ const firstPassSlopFlags = Array.isArray(designSlop?.flags) ? [...designSlop.flags] : [];
195
+ const revisionPrompt = buildFrontendRevisionPrompt({
196
+ originalPrompt: promptText,
197
+ designReview,
198
+ slop: designSlop,
199
+ screenshotReview
200
+ });
201
+ const revisionCtx = {
202
+ ...executionCtx,
203
+ phase: "design-revision",
204
+ reminders: [
205
+ executionCtx.reminders || "",
206
+ "Automatic second pass: fix the flagged frontend design issues without widening scope."
207
+ ].filter(Boolean).join("\n")
208
+ };
209
+ agent.setExecutionContext(revisionCtx);
210
+ if (task.activeContract) {
211
+ agent.toolRuntime.setCurrentContract(task.activeContract);
212
+ }
213
+ response = await agent.runBuildTurn(revisionPrompt, handlers);
214
+ const revisedReceipt = await agent.toolRuntime.completeTurn({ signal: handlers.signal });
215
+ if (revisedReceipt) {
216
+ receipt = revisedReceipt;
217
+ ({ impact, review, designReview, designSlop, screenshotReview, screenshotPath } = await analyze(receipt, response));
218
+ designRevision = {
219
+ triggered: true,
220
+ firstPassVerdict,
221
+ initialSummary: firstPassSummary,
222
+ slopFlags: firstPassSlopFlags
145
223
  };
146
224
  }
147
225
  }
@@ -151,6 +229,10 @@ export async function runBuildWorkflow({
151
229
  if (impact) updates.impact = impact;
152
230
  if (review) updates.review = review;
153
231
  if (designReview) updates.designReview = designReview;
232
+ if (designSlop) updates.designSlop = designSlop;
233
+ if (screenshotReview) updates.screenshotReview = screenshotReview;
234
+ if (screenshotPath) updates.screenshotPath = screenshotPath;
235
+ if (designRevision) updates.designRevision = designRevision;
154
236
  let finalReceipt = receipt;
155
237
  if (Object.keys(updates).length > 0) {
156
238
  finalReceipt = await agent.toolRuntime.updateReceipt(receipt.id, updates) || receipt;
@@ -176,6 +258,7 @@ export async function runBuildWorkflow({
176
258
  impact,
177
259
  review,
178
260
  designReview,
261
+ screenshotReview,
179
262
  impactSummary: impact ? summarizeImpactMap(impact) : null
180
263
  };
181
264
  }