@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 +1 -1
- package/src/cli.js +20 -0
- package/src/frontend.js +246 -1
- package/src/workflow.js +137 -54
package/package.json
CHANGED
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 {
|
|
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
|
-
|
|
91
|
+
let response = await agent.runBuildTurn(executorPrompt, handlers);
|
|
82
92
|
|
|
83
93
|
// Complete turn and get receipt
|
|
84
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 =
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
}
|