@tritard/waterbrother 0.8.14 → 0.8.16

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.14",
3
+ "version": "0.8.16",
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
@@ -673,6 +673,13 @@ function createProgressSpinner(initialLabel = "thinking...") {
673
673
  let label = initialLabel;
674
674
  let frameIndex = 0;
675
675
  let stopped = false;
676
+ const renderLabel = () => {
677
+ if (typeof label === "function") {
678
+ const next = label();
679
+ return String(next || "");
680
+ }
681
+ return String(label || "");
682
+ };
676
683
  const clearLine = () => {
677
684
  process.stdout.write("\r\x1b[2K");
678
685
  };
@@ -686,7 +693,7 @@ function createProgressSpinner(initialLabel = "thinking...") {
686
693
  const bar = renderFlowingBar(frameIndex, 14);
687
694
  frameIndex += 1;
688
695
  clearLine();
689
- process.stdout.write(`${styleAssistantPrefix()} ${bar} ${label}`);
696
+ process.stdout.write(`${styleAssistantPrefix()} ${bar} ${renderLabel()}`);
690
697
  }, 70);
691
698
 
692
699
  let activeInterval = interval;
@@ -714,7 +721,7 @@ function createProgressSpinner(initialLabel = "thinking...") {
714
721
  const bar = renderFlowingBar(frameIndex, 14);
715
722
  frameIndex += 1;
716
723
  clearLine();
717
- process.stdout.write(`${styleAssistantPrefix()} ${bar} ${label}`);
724
+ process.stdout.write(`${styleAssistantPrefix()} ${bar} ${renderLabel()}`);
718
725
  }, 70);
719
726
  activeSpinnerController = controller;
720
727
  }
@@ -724,6 +731,17 @@ function createProgressSpinner(initialLabel = "thinking...") {
724
731
  return controller;
725
732
  }
726
733
 
734
+ function formatElapsedShort(ms) {
735
+ const totalSeconds = Math.max(0, Math.floor(Number(ms || 0) / 1000));
736
+ if (totalSeconds < 60) return `${totalSeconds}s`;
737
+ const minutes = Math.floor(totalSeconds / 60);
738
+ const seconds = totalSeconds % 60;
739
+ if (minutes < 60) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
740
+ const hours = Math.floor(minutes / 60);
741
+ const remMinutes = minutes % 60;
742
+ return remMinutes > 0 ? `${hours}h ${remMinutes}m` : `${hours}h`;
743
+ }
744
+
727
745
  function parseToolResultShape(resultText) {
728
746
  try {
729
747
  const parsed = JSON.parse(String(resultText || ""));
@@ -864,6 +882,15 @@ function printReceiptSummary(receipt) {
864
882
  if (receipt.designRevision?.triggered) {
865
883
  console.log(`${styleSystemPrefix()} ${dim("design pass")} ${yellow("auto-revised")} ${receipt.designRevision.initialSummary || ""}`.trim());
866
884
  }
885
+ if (receipt.screenshotReview?.verdict) {
886
+ const verdict =
887
+ receipt.screenshotReview.verdict === "strong"
888
+ ? green("strong")
889
+ : receipt.screenshotReview.verdict === "weak"
890
+ ? red("weak")
891
+ : yellow("caution");
892
+ console.log(`${styleSystemPrefix()} ${dim("render")} ${verdict} ${receipt.screenshotReview.summary || ""}`.trim());
893
+ }
867
894
  }
868
895
 
869
896
  function printImpactMap(impact) {
@@ -3400,14 +3427,12 @@ async function runTextTurnInteractive({
3400
3427
  const idleMs = Date.now() - lastProgressAt;
3401
3428
  if (!heartbeatFired && idleMs >= 2000) {
3402
3429
  heartbeatFired = true;
3430
+ spinner.setLabel(() => `• Working (${formatElapsedShort(Date.now() - turnSummary.startedAt)} • esc to interrupt)`);
3403
3431
  printLiveTrace(`state=${currentState}...`, context.runtime.traceMode, { verboseOnly: true });
3404
3432
  }
3405
3433
  if (!stalledNotified && idleMs >= 8000) {
3406
3434
  stalledNotified = true;
3407
- if (activeSpinnerController?.clear) {
3408
- activeSpinnerController.clear();
3409
- }
3410
- console.log(`${styleSystemPrefix()} ${yellow(`still working (${currentState}) — press Ctrl+C to interrupt`)}`);
3435
+ spinner.setLabel(() => `• Working (${formatElapsedShort(Date.now() - turnSummary.startedAt)} • esc to interrupt)`);
3411
3436
  }
3412
3437
  }, 500);
3413
3438
 
@@ -5263,8 +5288,7 @@ async function promptLoop(agent, session, context) {
5263
5288
  const idleMs = Date.now() - lastProgressAt;
5264
5289
  if (!stalledNotified && idleMs >= 8000) {
5265
5290
  stalledNotified = true;
5266
- if (activeSpinnerController?.clear) activeSpinnerController.clear();
5267
- console.log(`${styleSystemPrefix()} ${yellow(`still working (${currentState}) — press Ctrl+C to interrupt`)}`);
5291
+ spinner.setLabel(() => `• Working (${formatElapsedShort(Date.now() - turnSummary.startedAt)} • esc to interrupt)`);
5268
5292
  }
5269
5293
  }, 500);
5270
5294
 
@@ -5387,6 +5411,11 @@ async function promptLoop(agent, session, context) {
5387
5411
  const vc = v === "strong" ? green(v) : v === "weak" ? red(v) : yellow(v);
5388
5412
  lines.push(`${dim("design:")} ${vc} — ${buildResult.designReview.summary}`);
5389
5413
  }
5414
+ if (buildResult.screenshotReview) {
5415
+ const v = buildResult.screenshotReview.verdict;
5416
+ const vc = v === "strong" ? green(v) : v === "weak" ? red(v) : yellow(v);
5417
+ lines.push(`${dim("render:")} ${vc} — ${buildResult.screenshotReview.summary}`);
5418
+ }
5390
5419
  if (buildResult.designRevision?.triggered) {
5391
5420
  lines.push(`${dim("design pass:")} ${yellow("auto-revised")} — ${buildResult.designRevision.initialSummary || "first pass revised"}`);
5392
5421
  }
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],
@@ -102,6 +126,21 @@ function normalizeContent(content) {
102
126
  return "";
103
127
  }
104
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
+
105
144
  function parseJsonObject(text) {
106
145
  const raw = String(text || "").trim();
107
146
  if (!raw) return null;
@@ -237,17 +276,22 @@ export function shouldAutoReviseFrontend({ designReview = null, slop = null, rev
237
276
  export function buildFrontendRevisionPrompt({
238
277
  originalPrompt = "",
239
278
  designReview = null,
240
- slop = null
279
+ slop = null,
280
+ screenshotReview = null
241
281
  } = {}) {
242
282
  const issues = Array.isArray(designReview?.issues) ? designReview.issues.slice(0, 6) : [];
243
283
  const nextPass = Array.isArray(designReview?.nextPass) ? designReview.nextPass.slice(0, 6) : [];
244
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) : [];
245
287
  const blocks = [
246
288
  `Revise the generated frontend to address the design problems from the first pass.`,
247
289
  `Original task: ${String(originalPrompt || "").trim()}`,
248
290
  issues.length > 0 ? `Problems to fix:\n- ${issues.join("\n- ")}` : "",
291
+ visualIssues.length > 0 ? `Visible screenshot problems:\n- ${visualIssues.join("\n- ")}` : "",
249
292
  slopFlags.length > 0 ? `Deterministic slop flags:\n- ${slopFlags.join("\n- ")}` : "",
250
293
  nextPass.length > 0 ? `Revision priorities:\n- ${nextPass.join("\n- ")}` : "",
294
+ visualNextPass.length > 0 ? `Screenshot revision priorities:\n- ${visualNextPass.join("\n- ")}` : "",
251
295
  "Do not add new filler sections.",
252
296
  "Do not add fake prestige, fake testimonials, fake brands, or placeholder-image services.",
253
297
  "Simplify the page if needed. Stronger direction with fewer elements is preferred over busier generic output.",
@@ -267,6 +311,17 @@ function normalizeFrontendReview(review) {
267
311
  };
268
312
  }
269
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
+
270
325
  export async function reviewFrontendTurn({
271
326
  apiKey,
272
327
  baseUrl,
@@ -300,3 +355,93 @@ export async function reviewFrontendTurn({
300
355
  const parsed = parseJsonObject(normalizeContent(completion?.message?.content));
301
356
  return normalizeFrontendReview(parsed || {});
302
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
@@ -4,8 +4,11 @@ import { reviewTurn, challengeReceipt } from "./reviewer.js";
4
4
  import {
5
5
  buildFrontendExecutionContext,
6
6
  buildFrontendRevisionPrompt,
7
+ captureFrontendScreenshot,
7
8
  detectFrontendSlop,
9
+ findFrontendPreviewEntry,
8
10
  reviewFrontendTurn,
11
+ reviewFrontendScreenshot,
9
12
  shouldAutoReviseFrontend,
10
13
  shouldRunFrontendReview
11
14
  } from "./frontend.js";
@@ -152,14 +155,37 @@ export async function runBuildWorkflow({
152
155
  }
153
156
  }
154
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
+
155
181
  const designSlop = designReview
156
182
  ? detectFrontendSlop({ promptText, assistantText: activeResponse.content || "", receipt: activeReceipt, designReview })
157
183
  : null;
158
184
 
159
- return { impact, review, designReview, designSlop };
185
+ return { impact, review, designReview, designSlop, screenshotReview, screenshotPath };
160
186
  }
161
187
 
162
- let { impact, review, designReview, designSlop } = await analyze(receipt, response);
188
+ let { impact, review, designReview, designSlop, screenshotReview, screenshotPath } = await analyze(receipt, response);
163
189
  let designRevision = null;
164
190
 
165
191
  if (shouldAutoReviseFrontend({ designReview, slop: designSlop, revisionCount: 0 })) {
@@ -169,7 +195,8 @@ export async function runBuildWorkflow({
169
195
  const revisionPrompt = buildFrontendRevisionPrompt({
170
196
  originalPrompt: promptText,
171
197
  designReview,
172
- slop: designSlop
198
+ slop: designSlop,
199
+ screenshotReview
173
200
  });
174
201
  const revisionCtx = {
175
202
  ...executionCtx,
@@ -187,7 +214,7 @@ export async function runBuildWorkflow({
187
214
  const revisedReceipt = await agent.toolRuntime.completeTurn({ signal: handlers.signal });
188
215
  if (revisedReceipt) {
189
216
  receipt = revisedReceipt;
190
- ({ impact, review, designReview, designSlop } = await analyze(receipt, response));
217
+ ({ impact, review, designReview, designSlop, screenshotReview, screenshotPath } = await analyze(receipt, response));
191
218
  designRevision = {
192
219
  triggered: true,
193
220
  firstPassVerdict,
@@ -203,6 +230,8 @@ export async function runBuildWorkflow({
203
230
  if (review) updates.review = review;
204
231
  if (designReview) updates.designReview = designReview;
205
232
  if (designSlop) updates.designSlop = designSlop;
233
+ if (screenshotReview) updates.screenshotReview = screenshotReview;
234
+ if (screenshotPath) updates.screenshotPath = screenshotPath;
206
235
  if (designRevision) updates.designRevision = designRevision;
207
236
  let finalReceipt = receipt;
208
237
  if (Object.keys(updates).length > 0) {
@@ -229,6 +258,7 @@ export async function runBuildWorkflow({
229
258
  impact,
230
259
  review,
231
260
  designReview,
261
+ screenshotReview,
232
262
  impactSummary: impact ? summarizeImpactMap(impact) : null
233
263
  };
234
264
  }