@tritard/waterbrother 0.10.2 → 0.12.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tritard/waterbrother",
3
- "version": "0.10.2",
3
+ "version": "0.12.0",
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
@@ -33,6 +33,9 @@ import { runBuildWorkflow, startFeatureTask, runChallengeWorkflow } from "./work
33
33
  import { createPanelRenderer, buildPanelState } from "./panel.js";
34
34
  import { deriveTaskNameFromPrompt, nextActionsForState, routeNaturalInput } from "./router.js";
35
35
  import { compressEpisode, compressSessionEpisode, saveEpisode, loadRecentEpisodes, findRelevantEpisodes, buildEpisodicMemoryBlock, buildReminderBlock } from "./episodic.js";
36
+ import { createProduct, loadProduct, saveProduct, hasProduct, generateBlueprint, buildProductContext, detectProductRequest, parseProductIntent, addSurface, createCampaign, getActiveCampaign, matchTemplate, applyTemplate, startPreview, killPreview } from "./product.js";
37
+ import { runQualityChecks, formatQualityFindings, buildQualityFixPrompt } from "./quality.js";
38
+ import { scanForInitiatives, formatInitiatives, buildInitiativeFixPrompt } from "./initiative.js";
36
39
  import { formatPlanForDisplay } from "./planner.js";
37
40
  import { parseCharterFromGoal, runExperimentLoop, formatExperimentSummary, gitReturnToBranch } from "./experiment.js";
38
41
 
@@ -4762,20 +4765,44 @@ async function promptLoop(agent, session, context) {
4762
4765
  context.runtime.projectMemory = await readProjectMemory(context.cwd);
4763
4766
  }
4764
4767
 
4765
- // Load episodic memory and combine with project memory
4768
+ // Load episodic memory and product state, combine with project memory
4766
4769
  try {
4767
4770
  const recentEpisodes = await loadRecentEpisodes({ cwd: context.cwd, limit: 5 });
4768
4771
  if (recentEpisodes.length > 0) {
4769
- const episodicBlock = buildEpisodicMemoryBlock(recentEpisodes);
4770
- context.runtime.episodicMemory = episodicBlock;
4771
- const fullMemory = [
4772
- context.runtime.projectMemory?.promptText || "",
4773
- episodicBlock
4774
- ].filter(Boolean).join("\n\n");
4775
- agent.setMemory(fullMemory);
4772
+ context.runtime.episodicMemory = buildEpisodicMemoryBlock(recentEpisodes);
4776
4773
  }
4777
4774
  } catch {}
4778
4775
 
4776
+ // Load product if it exists
4777
+ try {
4778
+ const product = await loadProduct(context.cwd);
4779
+ if (product) {
4780
+ context.runtime.product = product;
4781
+ // Show product context and run initiative scan
4782
+ const hasBuilt = product.surfaces.some((s) => s.status === "built");
4783
+ if (hasBuilt) {
4784
+ console.log(dim(`product: ${product.name}`));
4785
+ try {
4786
+ const initiatives = await scanForInitiatives({ cwd: context.cwd });
4787
+ if (initiatives.length > 0) {
4788
+ context.runtime.pendingInitiatives = initiatives;
4789
+ console.log(formatInitiatives(initiatives));
4790
+ }
4791
+ } catch {}
4792
+ }
4793
+ }
4794
+ } catch {}
4795
+
4796
+ // Combine all memory sources
4797
+ const memoryParts = [
4798
+ context.runtime.projectMemory?.promptText || "",
4799
+ context.runtime.episodicMemory || "",
4800
+ context.runtime.product ? buildProductContext(context.runtime.product) : ""
4801
+ ].filter(Boolean);
4802
+ if (memoryParts.length > 0) {
4803
+ agent.setMemory(memoryParts.join("\n\n"));
4804
+ }
4805
+
4779
4806
  if (!Array.isArray(context.runtime.lastSearchResults)) {
4780
4807
  context.runtime.lastSearchResults = [];
4781
4808
  }
@@ -4982,6 +5009,326 @@ async function promptLoop(agent, session, context) {
4982
5009
  }
4983
5010
 
4984
5011
  async function handleNaturalInput(line) {
5012
+ // Product builder intake: detect "I want a recipe app" in any mode
5013
+ if (detectProductRequest(line)) {
5014
+ const intent = parseProductIntent(line);
5015
+ const spinner = createProgressSpinner("understanding your product...");
5016
+
5017
+ try {
5018
+ // Ask the model to extract product details via structured JSON
5019
+ const { createJsonCompletion } = await import("./grok-client.js");
5020
+ const model = context.runtime.plannerModel || agent.getModel();
5021
+ const completion = await createJsonCompletion({
5022
+ apiKey: context.runtime.apiKey,
5023
+ baseUrl: context.runtime.baseUrl,
5024
+ model,
5025
+ messages: [
5026
+ { role: "system", content: `You are a product strategist. Extract a product brief from the user's request. Respond with JSON only:
5027
+ {
5028
+ "name": "short product name",
5029
+ "description": "one-sentence description",
5030
+ "audience": "who is this for",
5031
+ "type": "web|mobile|api|cli|desktop",
5032
+ "surfaces": ["Landing", "Login", "Dashboard", "Settings"],
5033
+ "stack": { "framework": "Next.js", "styling": "Tailwind", "backend": "Supabase", "auth": "email", "deploy": "Vercel" },
5034
+ "taste": "visual style in 3-5 words",
5035
+ "features": ["feature 1", "feature 2"]
5036
+ }
5037
+ Infer reasonable defaults. Keep it practical.` },
5038
+ { role: "user", content: line }
5039
+ ],
5040
+ temperature: 0.3
5041
+ });
5042
+
5043
+ spinner.stop();
5044
+ const brief = completion.json;
5045
+ if (!brief) { return false; }
5046
+
5047
+ // Create the product
5048
+ const product = createProduct({
5049
+ name: brief.name || intent.name || "My Product",
5050
+ description: brief.description || line,
5051
+ audience: brief.audience || "",
5052
+ type: brief.type || intent.type
5053
+ });
5054
+
5055
+ // Apply template if detected
5056
+ const templateType = matchTemplate(line);
5057
+ if (templateType) {
5058
+ applyTemplate(product, templateType);
5059
+ }
5060
+
5061
+ // Fill in details from the brief (overrides template defaults)
5062
+ if (brief.stack) {
5063
+ product.stack = { ...product.stack, ...brief.stack };
5064
+ }
5065
+ if (brief.taste) product.qualityBar.taste = brief.taste;
5066
+ if (Array.isArray(brief.surfaces)) {
5067
+ for (const s of brief.surfaces) {
5068
+ addSurface(product, { name: s, type: "page", status: "planned" });
5069
+ }
5070
+ }
5071
+
5072
+ // Create initial campaign
5073
+ createCampaign(product, { name: "Launch V1", goal: `Ship MVP: ${brief.description || product.description}` });
5074
+
5075
+ // Save and display
5076
+ await saveProduct(context.cwd, product);
5077
+ context.runtime.product = product;
5078
+
5079
+ // Inject product context into agent memory
5080
+ const productCtx = buildProductContext(product);
5081
+ const fullMemory = [
5082
+ context.runtime.projectMemory?.promptText || "",
5083
+ context.runtime.episodicMemory || "",
5084
+ productCtx
5085
+ ].filter(Boolean).join("\n\n");
5086
+ agent.setMemory(fullMemory);
5087
+
5088
+ console.log(generateBlueprint(product));
5089
+
5090
+ } catch (error) {
5091
+ spinner.stop();
5092
+ console.log(`product intake failed: ${error instanceof Error ? error.message : String(error)}`);
5093
+ }
5094
+ return true;
5095
+ }
5096
+
5097
+ // Product builder actions: "build", "deploy", "adjust", "show surfaces"
5098
+ const product = context.runtime.product || await loadProduct(context.cwd);
5099
+ if (product) {
5100
+ context.runtime.product = product;
5101
+ const lower = line.trim().toLowerCase();
5102
+
5103
+ if (/^(build|build it|build the product|scaffold|go)$/.test(lower)) {
5104
+ const productCtx = buildProductContext(product);
5105
+ const planned = product.surfaces.filter((s) => s.status === "planned").map((s) => s.name);
5106
+
5107
+ const spinner = createProgressSpinner("building product...");
5108
+ try {
5109
+ const prevAutonomy = agent.toolRuntime.getAutonomyMode();
5110
+ agent.toolRuntime.setAutonomyMode("auto");
5111
+
5112
+ // Phase 1: Scaffold foundation (package.json, config, shared components)
5113
+ const scaffoldPrompt = `${productCtx}\n\nFirst, set up the project foundation:\n1. Create package.json with all dependencies\n2. Create config files (tailwind, next.config, etc.)\n3. Create shared layout/components (nav, footer)\n4. Set up auth if needed\n5. Run npm install\n\nDo NOT build individual pages yet — just the skeleton.`;
5114
+
5115
+ spinner.setLabel("scaffolding...");
5116
+ await agent.runTurn(scaffoldPrompt, {
5117
+ onAssistantDelta() {},
5118
+ onToolStart(tc) { spinner.setLabel(`${tc?.function?.name || "tool"}...`); },
5119
+ onToolEnd() { spinner.setLabel("scaffolding..."); }
5120
+ });
5121
+ await agent.toolRuntime.completeTurn({});
5122
+
5123
+ // Phase 2: Build each surface individually
5124
+ for (let i = 0; i < planned.length; i++) {
5125
+ const surface = planned[i];
5126
+ spinner.setLabel(`building ${surface} (${i + 1}/${planned.length})...`);
5127
+ const surfacePrompt = `${productCtx}\n\nBuild the "${surface}" page/flow. Create all components needed for this surface. Make it complete and polished — real content, proper styling, working interactions. Follow the product's style: ${product.qualityBar.taste || "clean and modern"}.`;
5128
+ await agent.runTurn(surfacePrompt, {
5129
+ onAssistantDelta() {},
5130
+ onToolStart(tc) { spinner.setLabel(`${surface}: ${tc?.function?.name || "tool"}...`); },
5131
+ onToolEnd() { spinner.setLabel(`building ${surface}...`); }
5132
+ });
5133
+ await agent.toolRuntime.completeTurn({});
5134
+ console.log(dim(` ✓ ${surface}`));
5135
+ }
5136
+
5137
+ agent.toolRuntime.setAutonomyMode(prevAutonomy);
5138
+ spinner.stop();
5139
+
5140
+ // Quality checks
5141
+ try {
5142
+ const findings = await runQualityChecks({ cwd: context.cwd });
5143
+ const qualityOutput = formatQualityFindings(findings);
5144
+ if (qualityOutput) {
5145
+ console.log("");
5146
+ console.log(qualityOutput);
5147
+ }
5148
+ } catch {}
5149
+
5150
+ // Mark surfaces as built
5151
+ for (const s of product.surfaces) {
5152
+ s.status = "built";
5153
+ }
5154
+ await saveProduct(context.cwd, product);
5155
+
5156
+ // Start preview server and open browser
5157
+ console.log("");
5158
+ console.log("PREVIEW ▸ ready");
5159
+ const builtSurfaces = product.surfaces.filter((s) => s.status === "built").map((s) => s.name);
5160
+ if (builtSurfaces.length > 0) console.log(` ${builtSurfaces.join(", ")}`);
5161
+
5162
+ try {
5163
+ const preview = await startPreview(context.cwd);
5164
+ if (preview.ok) {
5165
+ console.log(` ${preview.url}`);
5166
+ context.runtime.previewPid = preview.pid;
5167
+ } else {
5168
+ console.log(" http://localhost:3000 (start dev server manually)");
5169
+ }
5170
+ } catch {
5171
+ console.log(" http://localhost:3000 (start dev server manually)");
5172
+ }
5173
+
5174
+ console.log("");
5175
+ console.log(dim(" refine · add feature · deploy"));
5176
+
5177
+ } catch (error) {
5178
+ spinner.stop();
5179
+ console.log(`build failed: ${error instanceof Error ? error.message : String(error)}`);
5180
+ }
5181
+ return true;
5182
+ }
5183
+
5184
+ if (/^(show surfaces|surfaces|pages|show pages)$/.test(lower)) {
5185
+ console.log(generateBlueprint(product));
5186
+ return true;
5187
+ }
5188
+
5189
+ if (/^(adjust|change|modify)$/.test(lower)) {
5190
+ console.log(generateBlueprint(product));
5191
+ console.log("\nDescribe what to adjust (e.g. 'add a settings page' or 'change auth to Google')");
5192
+ return true;
5193
+ }
5194
+
5195
+ // Deploy with milestone gate
5196
+ if (/^(deploy|ship|publish|launch)$/.test(lower)) {
5197
+ const deployTarget = product.stack.deploy || "Vercel";
5198
+ try {
5199
+ const confirm = await promptYesNo(`Deploy to ${deployTarget}?`, { input: process.stdin, output: process.stdout });
5200
+ if (!confirm) {
5201
+ console.log("deploy canceled");
5202
+ return true;
5203
+ }
5204
+ } catch {
5205
+ console.log("deploy canceled");
5206
+ return true;
5207
+ }
5208
+
5209
+ const spinner = createProgressSpinner(`deploying to ${deployTarget}...`);
5210
+ try {
5211
+ const prevAutonomy = agent.toolRuntime.getAutonomyMode();
5212
+ agent.toolRuntime.setAutonomyMode("auto");
5213
+ await agent.runTurn(
5214
+ `Deploy this project to ${deployTarget}. Run the necessary CLI commands (e.g. vercel, netlify deploy, gh-pages). If not set up, initialize it first.`,
5215
+ { onAssistantDelta() {}, onToolStart() { spinner.setLabel("deploying..."); }, onToolEnd() {} }
5216
+ );
5217
+ await agent.toolRuntime.completeTurn({});
5218
+ agent.toolRuntime.setAutonomyMode(prevAutonomy);
5219
+ spinner.stop();
5220
+ console.log(`\nSHIP ▸ deployed`);
5221
+ console.log(dim(" share link · add feature · view code"));
5222
+ } catch (error) {
5223
+ spinner.stop();
5224
+ console.log(`deploy failed: ${error instanceof Error ? error.message : String(error)}`);
5225
+ }
5226
+ return true;
5227
+ }
5228
+
5229
+ // Refine: natural language changes to a built product
5230
+ if (/^(fix these|fix quality|fix initiatives|fix product)$/.test(lower) && !context.runtime.activeTask) {
5231
+ // Only handle product fixes when no task is active — otherwise let router handle "fix these" for task reviews
5232
+ if (context.runtime.pendingInitiatives?.length > 0) {
5233
+ const spinner = createProgressSpinner("fixing product gaps...");
5234
+ try {
5235
+ const fixPrompt = buildInitiativeFixPrompt(context.runtime.pendingInitiatives);
5236
+ const prevAutonomy = agent.toolRuntime.getAutonomyMode();
5237
+ agent.toolRuntime.setAutonomyMode("auto");
5238
+ const productCtx = buildProductContext(product);
5239
+ await agent.runTurn(`${productCtx}\n\n${fixPrompt}`, {
5240
+ onAssistantDelta() {}, onToolStart() { spinner.setLabel("fixing..."); }, onToolEnd() {}
5241
+ });
5242
+ await agent.toolRuntime.completeTurn({});
5243
+ agent.toolRuntime.setAutonomyMode(prevAutonomy);
5244
+ spinner.stop();
5245
+ context.runtime.pendingInitiatives = [];
5246
+ console.log("product gaps addressed");
5247
+ console.log(dim(" refine · add feature · deploy"));
5248
+ } catch (error) {
5249
+ spinner.stop();
5250
+ console.log(`fix failed: ${error instanceof Error ? error.message : String(error)}`);
5251
+ }
5252
+ return true;
5253
+ }
5254
+ }
5255
+
5256
+ if (/^(fix these|fix quality)$/.test(lower) && !context.runtime.activeTask) {
5257
+ const spinner = createProgressSpinner("fixing quality issues...");
5258
+ try {
5259
+ const findings = await runQualityChecks({ cwd: context.cwd });
5260
+ const fixPrompt = buildQualityFixPrompt(findings);
5261
+ if (!fixPrompt) { spinner.stop(); console.log("no quality issues to fix"); return true; }
5262
+ const prevAutonomy = agent.toolRuntime.getAutonomyMode();
5263
+ agent.toolRuntime.setAutonomyMode("auto");
5264
+ const productCtx = buildProductContext(product);
5265
+ await agent.runTurn(`${productCtx}\n\n${fixPrompt}`, {
5266
+ onAssistantDelta() {}, onToolStart() { spinner.setLabel("fixing..."); }, onToolEnd() {}
5267
+ });
5268
+ await agent.toolRuntime.completeTurn({});
5269
+ agent.toolRuntime.setAutonomyMode(prevAutonomy);
5270
+ spinner.stop();
5271
+ console.log("quality issues addressed");
5272
+ console.log(dim(" refine · add feature · deploy"));
5273
+ } catch (error) {
5274
+ spinner.stop();
5275
+ console.log(`fix failed: ${error instanceof Error ? error.message : String(error)}`);
5276
+ }
5277
+ return true;
5278
+ }
5279
+
5280
+ // Refine: any other refinement when product has built surfaces
5281
+ const hasBuilt = product.surfaces.some((s) => s.status === "built");
5282
+ if (hasBuilt && !lower.startsWith("/") && !context.runtime.activeTask) {
5283
+ // Negative gate: exclude things that look like new product requests or work requests
5284
+ const isNewProduct = detectProductRequest(line);
5285
+ const isWorkRequest = /^(add|build|create|implement|fix|refactor|wire|debug)\b/.test(lower) && /\b(to the|for the|in the|api|endpoint|middleware|service|module|auth|database)\b/.test(lower);
5286
+ // Positive gate: must reference visual/UI elements or the existing product
5287
+ const isRefinement = !isNewProduct && !isWorkRequest && /\b(make|change|update|prettier|simpler|bigger|smaller|darker|lighter|color|font|style|dark\s*mode|light\s*mode|resize|move|header|footer|nav|button|logo|image|background|padding|margin|spacing|border|rounded|shadow|gradient|animation|hover|responsive|mobile|desktop|center|align|layout)\b/i.test(lower);
5288
+ if (isRefinement) {
5289
+ const spinner = createProgressSpinner("refining...");
5290
+ try {
5291
+ const productCtx = buildProductContext(product);
5292
+
5293
+ // Check if adding a new surface
5294
+ const addMatch = lower.match(/add\s+(?:a\s+)?(.+?)(?:\s+page|\s+screen|\s+section)?$/);
5295
+ if (addMatch) {
5296
+ const surfaceName = addMatch[1].trim().replace(/^(a|an|the)\s+/i, "");
5297
+ addSurface(product, { name: surfaceName, type: "page", status: "planned" });
5298
+ await saveProduct(context.cwd, product);
5299
+ }
5300
+
5301
+ const prevAutonomy = agent.toolRuntime.getAutonomyMode();
5302
+ agent.toolRuntime.setAutonomyMode("auto");
5303
+ await agent.runTurn(
5304
+ `${productCtx}\n\nRefine the product: ${line}\n\nMake the change while keeping the existing product quality and style consistent.`,
5305
+ {
5306
+ onAssistantDelta() {},
5307
+ onToolStart(tc) { spinner.setLabel(`${tc?.function?.name || "editing"}...`); },
5308
+ onToolEnd() { spinner.setLabel("refining..."); }
5309
+ }
5310
+ );
5311
+ await agent.toolRuntime.completeTurn({});
5312
+ agent.toolRuntime.setAutonomyMode(prevAutonomy);
5313
+ spinner.stop();
5314
+
5315
+ // Mark any new surfaces as built
5316
+ for (const s of product.surfaces) {
5317
+ if (s.status === "planned") s.status = "built";
5318
+ }
5319
+ await saveProduct(context.cwd, product);
5320
+
5321
+ console.log("refined");
5322
+ console.log(dim(" refine · add feature · deploy"));
5323
+ } catch (error) {
5324
+ spinner.stop();
5325
+ console.log(`refine failed: ${error instanceof Error ? error.message : String(error)}`);
5326
+ }
5327
+ return true;
5328
+ }
5329
+ }
5330
+ }
5331
+
4985
5332
  const routed = routeNaturalInput(line, { task: context.runtime.activeTask });
4986
5333
  if (!routed || routed.kind === "none" || routed.kind === "chat") return false;
4987
5334
 
@@ -6976,6 +7323,9 @@ async function promptLoop(agent, session, context) {
6976
7323
  }
6977
7324
  }
6978
7325
 
7326
+ // Kill any running preview server
7327
+ killPreview();
7328
+
6979
7329
  // Save CC-mode session episode on exit (if any files were touched)
6980
7330
  if (!context.runtime.activeTask) {
6981
7331
  try {
@@ -0,0 +1,141 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const IGNORE_DIRS = new Set(["node_modules", ".git", ".waterbrother", "dist", "build", ".next", ".nuxt"]);
5
+
6
+ async function fileExists(filePath) {
7
+ try { await fs.access(filePath); return true; } catch { return false; }
8
+ }
9
+
10
+ async function findFiles(dir, pattern, maxDepth = 4, depth = 0) {
11
+ if (depth > maxDepth) return [];
12
+ const matches = [];
13
+ try {
14
+ const entries = await fs.readdir(dir, { withFileTypes: true });
15
+ for (const entry of entries) {
16
+ if (IGNORE_DIRS.has(entry.name)) continue;
17
+ const full = path.join(dir, entry.name);
18
+ if (entry.isDirectory()) {
19
+ matches.push(...await findFiles(full, pattern, maxDepth, depth + 1));
20
+ } else if (pattern.test(entry.name)) {
21
+ matches.push(full);
22
+ }
23
+ }
24
+ } catch {}
25
+ return matches;
26
+ }
27
+
28
+ async function grepFiles(dir, textPattern, extensions, maxDepth = 4) {
29
+ const files = await findFiles(dir, new RegExp(`\\.(${extensions.join("|")})$`), maxDepth);
30
+ const matches = [];
31
+ for (const f of files) {
32
+ try {
33
+ const content = await fs.readFile(f, "utf8");
34
+ if (textPattern.test(content)) matches.push(path.relative(dir, f).replace(/\\/g, "/"));
35
+ } catch {}
36
+ }
37
+ return matches;
38
+ }
39
+
40
+ const INITIATIVES = [
41
+ {
42
+ id: "no-404",
43
+ label: "No 404 page",
44
+ check: async (cwd) => {
45
+ const has404 = await findFiles(cwd, /404\.(jsx?|tsx?|html|vue|svelte)$/);
46
+ return has404.length === 0;
47
+ },
48
+ suggestion: "Add a 404 page so users see a friendly message on broken links"
49
+ },
50
+ {
51
+ id: "no-favicon",
52
+ label: "No favicon",
53
+ check: async (cwd) => {
54
+ const hasFavicon = await fileExists(path.join(cwd, "public", "favicon.ico"))
55
+ || await fileExists(path.join(cwd, "public", "favicon.svg"))
56
+ || await fileExists(path.join(cwd, "app", "favicon.ico"));
57
+ return !hasFavicon;
58
+ },
59
+ suggestion: "Add a favicon — it's the first thing users notice in their browser tab"
60
+ },
61
+ {
62
+ id: "no-meta-tags",
63
+ label: "Missing SEO meta tags",
64
+ check: async (cwd) => {
65
+ const htmlFiles = await findFiles(cwd, /\.(html|jsx|tsx)$/);
66
+ for (const f of htmlFiles) {
67
+ try {
68
+ const content = await fs.readFile(f, "utf8");
69
+ if (/meta.*description/i.test(content) || /metadata/i.test(content)) return false;
70
+ } catch {}
71
+ }
72
+ return htmlFiles.length > 0;
73
+ },
74
+ suggestion: "Add meta description tags for SEO — helps your site show up in search"
75
+ },
76
+ {
77
+ id: "no-loading-states",
78
+ label: "No loading indicators",
79
+ check: async (cwd) => {
80
+ const hasLoading = await grepFiles(cwd, /loading|spinner|skeleton|isLoading|isPending/i, ["jsx", "tsx", "vue", "svelte"]);
81
+ return hasLoading.length === 0;
82
+ },
83
+ suggestion: "Add loading states to async content — prevents blank screens while data loads"
84
+ },
85
+ {
86
+ id: "no-error-handling",
87
+ label: "Forms without error handling",
88
+ check: async (cwd) => {
89
+ const forms = await grepFiles(cwd, /<form|onSubmit|handleSubmit/i, ["jsx", "tsx", "vue", "svelte"]);
90
+ if (forms.length === 0) return false;
91
+ const errorHandling = await grepFiles(cwd, /error|catch|validation|invalid/i, ["jsx", "tsx", "vue", "svelte"]);
92
+ return errorHandling.length === 0;
93
+ },
94
+ suggestion: "Add error handling to forms — show validation messages and handle failures"
95
+ },
96
+ {
97
+ id: "no-responsive",
98
+ label: "No responsive design hints",
99
+ check: async (cwd) => {
100
+ const hasResponsive = await grepFiles(cwd, /@media|sm:|md:|lg:|xl:|responsive|mobile/i, ["css", "scss", "jsx", "tsx", "html"]);
101
+ return hasResponsive.length === 0;
102
+ },
103
+ suggestion: "Add responsive breakpoints — your app should look good on phones"
104
+ }
105
+ ];
106
+
107
+ export async function scanForInitiatives({ cwd }) {
108
+ const found = [];
109
+ for (const initiative of INITIATIVES) {
110
+ try {
111
+ const applies = await initiative.check(cwd);
112
+ if (applies) {
113
+ found.push({
114
+ id: initiative.id,
115
+ label: initiative.label,
116
+ suggestion: initiative.suggestion
117
+ });
118
+ }
119
+ } catch {}
120
+ }
121
+ return found;
122
+ }
123
+
124
+ export function formatInitiatives(initiatives) {
125
+ if (!initiatives || initiatives.length === 0) return "";
126
+ const lines = ["Product improvements available:"];
127
+ for (const init of initiatives.slice(0, 5)) {
128
+ lines.push(` → ${init.suggestion}`);
129
+ }
130
+ lines.push("\n fix these · ignore");
131
+ return lines.join("\n");
132
+ }
133
+
134
+ export function buildInitiativeFixPrompt(initiatives) {
135
+ const lines = ["Fix these product quality gaps:"];
136
+ for (const init of initiatives) {
137
+ lines.push(`- ${init.label}: ${init.suggestion}`);
138
+ }
139
+ lines.push("\nImplement each fix. Keep changes minimal and focused.");
140
+ return lines.join("\n");
141
+ }
package/src/product.js ADDED
@@ -0,0 +1,452 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const PRODUCT_FILE = "product.json";
5
+
6
+ function productDir(cwd) {
7
+ return path.join(cwd, ".waterbrother");
8
+ }
9
+
10
+ function productPath(cwd) {
11
+ return path.join(productDir(cwd), PRODUCT_FILE);
12
+ }
13
+
14
+ /**
15
+ * Product: the persistent object at the center of builder mode.
16
+ *
17
+ * {
18
+ * name: "Family Recipe Book",
19
+ * description: "A web app for sharing family recipes",
20
+ * audience: "My family — 10 people, non-technical",
21
+ * type: "web", // web | mobile | api | cli | desktop
22
+ * stack: {
23
+ * framework: "Next.js",
24
+ * styling: "Tailwind",
25
+ * backend: "Supabase",
26
+ * auth: "email magic link",
27
+ * deploy: "Vercel"
28
+ * },
29
+ * surfaces: [
30
+ * { name: "Landing", type: "page", status: "built" },
31
+ * { name: "Login", type: "flow", status: "built" },
32
+ * { name: "Recipe Feed", type: "page", status: "planned" },
33
+ * { name: "Create Recipe", type: "form", status: "planned" }
34
+ * ],
35
+ * qualityBar: {
36
+ * taste: "clean, warm, simple",
37
+ * trust: ["no broken links", "real content not lorem ipsum"],
38
+ * ux: ["mobile-first", "< 3 clicks to core action"],
39
+ * performance: ["< 3s load time"]
40
+ * },
41
+ * tradeoffs: [
42
+ * "No real-time features — polling is fine",
43
+ * "Email auth only — no OAuth complexity"
44
+ * ],
45
+ * campaigns: [
46
+ * { id: "launch-v1", name: "Launch V1", status: "active", goal: "Ship MVP with 4 core pages" }
47
+ * ],
48
+ * deployUrl: null,
49
+ * repoUrl: null,
50
+ * createdAt: "2026-03-15T...",
51
+ * updatedAt: "2026-03-15T..."
52
+ * }
53
+ */
54
+
55
+ export function createProduct({ name, description, audience, type }) {
56
+ return {
57
+ name: name || "Untitled Product",
58
+ description: description || "",
59
+ audience: audience || "",
60
+ type: type || "web",
61
+ stack: {
62
+ framework: null,
63
+ styling: null,
64
+ backend: null,
65
+ auth: null,
66
+ deploy: null
67
+ },
68
+ surfaces: [],
69
+ qualityBar: {
70
+ taste: "",
71
+ trust: [],
72
+ ux: [],
73
+ performance: []
74
+ },
75
+ tradeoffs: [],
76
+ campaigns: [],
77
+ deployUrl: null,
78
+ repoUrl: null,
79
+ createdAt: new Date().toISOString(),
80
+ updatedAt: new Date().toISOString()
81
+ };
82
+ }
83
+
84
+ export async function loadProduct(cwd) {
85
+ try {
86
+ const raw = await fs.readFile(productPath(cwd), "utf8");
87
+ return JSON.parse(raw);
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ export async function saveProduct(cwd, product) {
94
+ product.updatedAt = new Date().toISOString();
95
+ await fs.mkdir(productDir(cwd), { recursive: true });
96
+ await fs.writeFile(productPath(cwd), `${JSON.stringify(product, null, 2)}\n`, "utf8");
97
+ }
98
+
99
+ export async function hasProduct(cwd) {
100
+ try {
101
+ await fs.access(productPath(cwd));
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ // --- Surface management ---
109
+
110
+ export function addSurface(product, { name, type = "page", status = "planned" }) {
111
+ const existing = product.surfaces.find((s) => s.name.toLowerCase() === name.toLowerCase());
112
+ if (existing) {
113
+ existing.status = status;
114
+ existing.type = type;
115
+ return existing;
116
+ }
117
+ const surface = { name, type, status };
118
+ product.surfaces.push(surface);
119
+ return surface;
120
+ }
121
+
122
+ export function markSurfaceBuilt(product, name) {
123
+ const surface = product.surfaces.find((s) => s.name.toLowerCase() === name.toLowerCase());
124
+ if (surface) surface.status = "built";
125
+ return surface;
126
+ }
127
+
128
+ export function getPlannedSurfaces(product) {
129
+ return product.surfaces.filter((s) => s.status === "planned");
130
+ }
131
+
132
+ export function getBuiltSurfaces(product) {
133
+ return product.surfaces.filter((s) => s.status === "built");
134
+ }
135
+
136
+ // --- Campaign management ---
137
+
138
+ export function createCampaign(product, { name, goal }) {
139
+ const id = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40);
140
+ const campaign = { id, name, status: "active", goal, createdAt: new Date().toISOString() };
141
+ // Deactivate other campaigns
142
+ for (const c of product.campaigns) {
143
+ if (c.status === "active") c.status = "paused";
144
+ }
145
+ product.campaigns.push(campaign);
146
+ return campaign;
147
+ }
148
+
149
+ export function getActiveCampaign(product) {
150
+ return product.campaigns.find((c) => c.status === "active") || null;
151
+ }
152
+
153
+ export function completeCampaign(product, campaignId) {
154
+ const campaign = product.campaigns.find((c) => c.id === campaignId);
155
+ if (campaign) campaign.status = "completed";
156
+ return campaign;
157
+ }
158
+
159
+ // --- Blueprint generation ---
160
+
161
+ export function generateBlueprint(product) {
162
+ const lines = [];
163
+ const rule = "─".repeat(40);
164
+
165
+ lines.push(rule);
166
+ lines.push(`BLUEPRINT: ${product.name}`);
167
+ lines.push(rule);
168
+
169
+ if (product.description) lines.push(` ${product.description}`);
170
+ if (product.audience) lines.push(` For: ${product.audience}`);
171
+ lines.push("");
172
+
173
+ if (product.surfaces.length > 0) {
174
+ const planned = product.surfaces.filter((s) => s.status === "planned").map((s) => s.name);
175
+ const built = product.surfaces.filter((s) => s.status === "built").map((s) => s.name);
176
+ if (planned.length > 0) lines.push(` Pages: ${planned.join(", ")}`);
177
+ if (built.length > 0) lines.push(` Built: ${built.join(", ")}`);
178
+ }
179
+
180
+ const stack = product.stack;
181
+ const stackParts = [];
182
+ if (stack.framework) stackParts.push(stack.framework);
183
+ if (stack.styling) stackParts.push(stack.styling);
184
+ if (stack.backend) stackParts.push(stack.backend);
185
+ if (stackParts.length > 0) lines.push(` Stack: ${stackParts.join(" + ")}`);
186
+ if (stack.auth) lines.push(` Auth: ${stack.auth}`);
187
+ if (stack.deploy) lines.push(` Deploy: ${stack.deploy}`);
188
+
189
+ if (product.qualityBar.taste) lines.push(` Style: ${product.qualityBar.taste}`);
190
+
191
+ if (product.tradeoffs.length > 0) {
192
+ lines.push("");
193
+ for (const t of product.tradeoffs) lines.push(` ◦ ${t}`);
194
+ }
195
+
196
+ const campaign = getActiveCampaign(product);
197
+ if (campaign) {
198
+ lines.push("");
199
+ lines.push(` Campaign: ${campaign.name} — ${campaign.goal}`);
200
+ }
201
+
202
+ lines.push("");
203
+ lines.push(" build · adjust · show surfaces · deploy");
204
+ lines.push(rule);
205
+
206
+ return lines.join("\n");
207
+ }
208
+
209
+ // --- Product context for system prompt ---
210
+
211
+ export function buildProductContext(product) {
212
+ if (!product) return "";
213
+ const parts = [`Product: ${product.name}`];
214
+ if (product.description) parts.push(`Description: ${product.description}`);
215
+ if (product.audience) parts.push(`Audience: ${product.audience}`);
216
+
217
+ const stack = product.stack;
218
+ const stackParts = [];
219
+ if (stack.framework) stackParts.push(stack.framework);
220
+ if (stack.styling) stackParts.push(stack.styling);
221
+ if (stack.backend) stackParts.push(stack.backend);
222
+ if (stackParts.length > 0) parts.push(`Stack: ${stackParts.join(" + ")}`);
223
+
224
+ if (product.qualityBar.taste) parts.push(`Style: ${product.qualityBar.taste}`);
225
+ if (product.qualityBar.ux.length > 0) parts.push(`UX rules: ${product.qualityBar.ux.join(", ")}`);
226
+ if (product.qualityBar.trust.length > 0) parts.push(`Trust rules: ${product.qualityBar.trust.join(", ")}`);
227
+
228
+ if (product.tradeoffs.length > 0) parts.push(`Tradeoffs: ${product.tradeoffs.join("; ")}`);
229
+
230
+ const planned = product.surfaces.filter((s) => s.status === "planned").map((s) => s.name);
231
+ const built = product.surfaces.filter((s) => s.status === "built").map((s) => s.name);
232
+ if (planned.length > 0) parts.push(`Planned surfaces: ${planned.join(", ")}`);
233
+ if (built.length > 0) parts.push(`Built surfaces: ${built.join(", ")}`);
234
+
235
+ const campaign = getActiveCampaign(product);
236
+ if (campaign) parts.push(`Active campaign: ${campaign.name} — ${campaign.goal}`);
237
+
238
+ parts.push("You are building this product. Every change should serve the product's goals, audience, and quality bar.");
239
+
240
+ return parts.join("\n");
241
+ }
242
+
243
+ // --- Brief intake: parse natural language into product fields ---
244
+
245
+ export function detectProductRequest(text) {
246
+ const lower = text.toLowerCase().trim();
247
+ return /^(i want|build me|make me|create|i need|can you build|help me build|let's build|we need)\b/.test(lower)
248
+ && /\b(app|site|website|page|platform|tool|dashboard|store|shop|blog|portfolio|saas|api|bot|game)\b/.test(lower);
249
+ }
250
+
251
+ export function parseProductIntent(text) {
252
+ const lower = text.toLowerCase();
253
+ let type = "web";
254
+ if (/\b(mobile|ios|android|iphone|phone)\b/.test(lower)) type = "mobile";
255
+ else if (/\b(api|backend|server|microservice)\b/.test(lower)) type = "api";
256
+ else if (/\b(cli|command.?line|terminal)\b/.test(lower)) type = "cli";
257
+ else if (/\b(desktop|electron)\b/.test(lower)) type = "desktop";
258
+
259
+ // Extract name hint
260
+ const nameMatch = text.match(/(?:called?|named?)\s+"?([^"]+)"?/i);
261
+ const name = nameMatch ? nameMatch[1].trim() : "";
262
+
263
+ return { type, name, raw: text };
264
+ }
265
+
266
+ // --- Templates ---
267
+
268
+ const TEMPLATES = {
269
+ "landing-page": {
270
+ surfaces: [
271
+ { name: "Landing", type: "page" }
272
+ ],
273
+ stack: { framework: "Next.js", styling: "Tailwind", backend: null, auth: null, deploy: "Vercel" },
274
+ taste: "bold, modern, conversion-focused",
275
+ qualityBar: { ux: ["single page", "clear CTA above fold", "< 3s load time"], trust: ["real testimonials", "no stock photos"] }
276
+ },
277
+ "saas": {
278
+ surfaces: [
279
+ { name: "Landing", type: "page" },
280
+ { name: "Pricing", type: "page" },
281
+ { name: "Login", type: "flow" },
282
+ { name: "Signup", type: "flow" },
283
+ { name: "Dashboard", type: "page" },
284
+ { name: "Settings", type: "page" }
285
+ ],
286
+ stack: { framework: "Next.js", styling: "Tailwind", backend: "Supabase", auth: "email + OAuth", deploy: "Vercel" },
287
+ taste: "clean, professional, trustworthy",
288
+ qualityBar: { ux: ["mobile-first", "onboarding under 60s", "clear pricing"], trust: ["SSL", "privacy policy link", "real company info"] }
289
+ },
290
+ "blog": {
291
+ surfaces: [
292
+ { name: "Home / Post List", type: "page" },
293
+ { name: "Post Page", type: "page" },
294
+ { name: "About", type: "page" }
295
+ ],
296
+ stack: { framework: "Next.js", styling: "Tailwind", backend: null, auth: null, deploy: "Vercel" },
297
+ taste: "readable, minimal, typographic",
298
+ qualityBar: { ux: ["< 2s load", "good reading experience", "responsive"], trust: ["author info", "real dates"] }
299
+ },
300
+ "store": {
301
+ surfaces: [
302
+ { name: "Home / Product List", type: "page" },
303
+ { name: "Product Detail", type: "page" },
304
+ { name: "Cart", type: "page" },
305
+ { name: "Checkout", type: "flow" }
306
+ ],
307
+ stack: { framework: "Next.js", styling: "Tailwind", backend: "Supabase", auth: "email", deploy: "Vercel" },
308
+ taste: "clean, trustworthy, product-focused",
309
+ qualityBar: { ux: ["fast product browsing", "< 3 clicks to checkout"], trust: ["secure checkout badge", "return policy visible"] }
310
+ },
311
+ "portfolio": {
312
+ surfaces: [
313
+ { name: "Home / Projects Grid", type: "page" },
314
+ { name: "Project Detail", type: "page" },
315
+ { name: "About", type: "page" },
316
+ { name: "Contact", type: "page" }
317
+ ],
318
+ stack: { framework: "Next.js", styling: "Tailwind", backend: null, auth: null, deploy: "Vercel" },
319
+ taste: "creative, visual, spacious",
320
+ qualityBar: { ux: ["fast image loading", "smooth transitions", "mobile-friendly"], trust: ["real work samples", "contact info visible"] }
321
+ }
322
+ };
323
+
324
+ export function matchTemplate(text) {
325
+ const lower = text.toLowerCase();
326
+ if (/\b(landing\s*page|one.?page|single.?page|marketing)\b/.test(lower)) return "landing-page";
327
+ if (/\b(saas|sass|software.?as|subscription|b2b)\b/.test(lower)) return "saas";
328
+ if (/\b(blog|journal|writing|articles|posts)\b/.test(lower)) return "blog";
329
+ if (/\b(store|shop|ecommerce|e.?commerce|marketplace|products?\s+for\s+sale)\b/.test(lower)) return "store";
330
+ if (/\b(portfolio|showcase|gallery|my\s+work|personal\s+site)\b/.test(lower)) return "portfolio";
331
+ return null;
332
+ }
333
+
334
+ export function getTemplate(type) {
335
+ return TEMPLATES[type] || null;
336
+ }
337
+
338
+ export function applyTemplate(product, templateName) {
339
+ const template = TEMPLATES[templateName];
340
+ if (!template) return;
341
+ for (const s of template.surfaces) {
342
+ addSurface(product, { name: s.name, type: s.type, status: "planned" });
343
+ }
344
+ product.stack = { ...product.stack, ...template.stack };
345
+ if (template.taste) product.qualityBar.taste = template.taste;
346
+ if (template.qualityBar?.ux) product.qualityBar.ux = template.qualityBar.ux;
347
+ if (template.qualityBar?.trust) product.qualityBar.trust = template.qualityBar.trust;
348
+ }
349
+
350
+ // --- Preview ---
351
+
352
+ // Track active preview server for cleanup
353
+ let activePreviewServer = null;
354
+
355
+ export function killPreview() {
356
+ if (!activePreviewServer) return;
357
+ try {
358
+ if (process.platform === "win32") {
359
+ const { execSync } = require("node:child_process");
360
+ execSync(`taskkill /PID ${activePreviewServer.pid} /T /F`, { stdio: "ignore" });
361
+ } else {
362
+ process.kill(-activePreviewServer.pid);
363
+ }
364
+ } catch {}
365
+ activePreviewServer = null;
366
+ }
367
+
368
+ function detectPort(cwd) {
369
+ try {
370
+ const pkg = JSON.parse(require("node:fs").readFileSync(path.join(cwd, "package.json"), "utf8"));
371
+ const devScript = pkg.scripts?.dev || "";
372
+ // Check for --port or -p flags
373
+ const portMatch = devScript.match(/(?:--port|-p)\s+(\d+)/);
374
+ if (portMatch) return parseInt(portMatch[1], 10);
375
+ // Check for PORT env in dev script
376
+ const envMatch = devScript.match(/PORT=(\d+)/);
377
+ if (envMatch) return parseInt(envMatch[1], 10);
378
+ } catch {}
379
+ return 3000;
380
+ }
381
+
382
+ async function pollServer(url, maxWaitMs = 30000) {
383
+ const start = Date.now();
384
+ while (Date.now() - start < maxWaitMs) {
385
+ try {
386
+ const { request } = await import("node:http");
387
+ const ok = await new Promise((resolve) => {
388
+ const req = request(url, { timeout: 1000 }, (res) => { resolve(res.statusCode < 500); });
389
+ req.on("error", () => resolve(false));
390
+ req.on("timeout", () => { req.destroy(); resolve(false); });
391
+ req.end();
392
+ });
393
+ if (ok) return true;
394
+ } catch {}
395
+ await new Promise((r) => setTimeout(r, 1000));
396
+ }
397
+ return false;
398
+ }
399
+
400
+ export async function startPreview(cwd) {
401
+ const { spawn } = await import("node:child_process");
402
+ const pkgPath = path.join(cwd, "package.json");
403
+ let hasDevScript = false;
404
+ try {
405
+ const pkg = JSON.parse(await fs.readFile(pkgPath, "utf8"));
406
+ hasDevScript = Boolean(pkg.scripts?.dev);
407
+ } catch {}
408
+ if (!hasDevScript) return { ok: false, reason: "no dev script in package.json" };
409
+
410
+ // Kill any existing preview server
411
+ killPreview();
412
+
413
+ // Install deps first
414
+ try {
415
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
416
+ const install = spawn(npmCmd, ["install"], { cwd, stdio: "ignore" });
417
+ await new Promise((resolve) => install.on("close", resolve));
418
+ } catch {}
419
+
420
+ // Detect port
421
+ const port = detectPort(cwd);
422
+ const url = `http://localhost:${port}`;
423
+
424
+ // Start dev server
425
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
426
+ const server = spawn(npmCmd, ["run", "dev"], {
427
+ cwd,
428
+ stdio: "ignore",
429
+ detached: process.platform !== "win32"
430
+ });
431
+ server.unref();
432
+ activePreviewServer = server;
433
+
434
+ // Poll until server responds (up to 30s)
435
+ const ready = await pollServer(url);
436
+
437
+ if (!ready) {
438
+ return { ok: true, pid: server.pid, url, warning: "server started but not responding yet — may still be compiling" };
439
+ }
440
+
441
+ // Open browser
442
+ try {
443
+ if (process.platform === "win32") {
444
+ spawn("cmd", ["/c", "start", url], { stdio: "ignore" });
445
+ } else {
446
+ const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
447
+ spawn(openCmd, [url], { stdio: "ignore" });
448
+ }
449
+ } catch {}
450
+
451
+ return { ok: true, pid: server.pid, url: "http://localhost:3000" };
452
+ }
package/src/quality.js ADDED
@@ -0,0 +1,140 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const CHECKS = [
5
+ {
6
+ name: "placeholder-content",
7
+ severity: "warning",
8
+ pattern: /lorem ipsum|placeholder|sample text|example\.com|test@test/i,
9
+ extensions: [".html", ".jsx", ".tsx", ".vue", ".svelte", ".md"],
10
+ message: "Placeholder content found"
11
+ },
12
+ {
13
+ name: "broken-links",
14
+ severity: "warning",
15
+ pattern: /href=["']#["']|href=["']["']/,
16
+ extensions: [".html", ".jsx", ".tsx", ".vue", ".svelte"],
17
+ message: "Broken or empty link"
18
+ },
19
+ {
20
+ name: "missing-alt",
21
+ severity: "warning",
22
+ pattern: /<img(?![^>]*alt=)[^>]*>/i,
23
+ extensions: [".html", ".jsx", ".tsx", ".vue", ".svelte"],
24
+ message: "Image missing alt text"
25
+ },
26
+ {
27
+ name: "console-log",
28
+ severity: "info",
29
+ pattern: /console\.(log|debug|info)\(/,
30
+ extensions: [".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte"],
31
+ message: "console.log left in code"
32
+ },
33
+ {
34
+ name: "todo-fixme",
35
+ severity: "info",
36
+ pattern: /\/\/\s*(TODO|FIXME|HACK|XXX)\b/i,
37
+ extensions: [".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte", ".css"],
38
+ message: "TODO/FIXME comment"
39
+ }
40
+ ];
41
+
42
+ const IGNORE_DIRS = new Set(["node_modules", ".git", ".waterbrother", "dist", "build", ".next", ".nuxt"]);
43
+
44
+ async function walkFiles(dir, extensions, maxDepth = 5, depth = 0) {
45
+ if (depth > maxDepth) return [];
46
+ const files = [];
47
+ try {
48
+ const entries = await fs.readdir(dir, { withFileTypes: true });
49
+ for (const entry of entries) {
50
+ if (IGNORE_DIRS.has(entry.name)) continue;
51
+ const full = path.join(dir, entry.name);
52
+ if (entry.isDirectory()) {
53
+ files.push(...await walkFiles(full, extensions, maxDepth, depth + 1));
54
+ } else if (extensions.some((ext) => entry.name.endsWith(ext))) {
55
+ files.push(full);
56
+ }
57
+ }
58
+ } catch {}
59
+ return files;
60
+ }
61
+
62
+ export async function runQualityChecks({ cwd }) {
63
+ const findings = [];
64
+ const allExtensions = [...new Set(CHECKS.flatMap((c) => c.extensions))];
65
+ const files = await walkFiles(cwd, allExtensions);
66
+
67
+ for (const filePath of files) {
68
+ let content;
69
+ try {
70
+ content = await fs.readFile(filePath, "utf8");
71
+ } catch {
72
+ continue;
73
+ }
74
+
75
+ const lines = content.split("\n");
76
+ const rel = path.relative(cwd, filePath).replace(/\\/g, "/");
77
+
78
+ for (const check of CHECKS) {
79
+ if (!check.extensions.some((ext) => filePath.endsWith(ext))) continue;
80
+ for (let i = 0; i < lines.length; i++) {
81
+ if (check.pattern.test(lines[i])) {
82
+ findings.push({
83
+ check: check.name,
84
+ severity: check.severity,
85
+ file: rel,
86
+ line: i + 1,
87
+ message: check.message,
88
+ snippet: lines[i].trim().slice(0, 80)
89
+ });
90
+ }
91
+ }
92
+ }
93
+
94
+ // Check for empty files (< 5 lines of real content)
95
+ const realLines = lines.filter((l) => l.trim().length > 0 && !l.trim().startsWith("//") && !l.trim().startsWith("/*"));
96
+ if (realLines.length < 5 && allExtensions.some((ext) => filePath.endsWith(ext))) {
97
+ findings.push({
98
+ check: "empty-file",
99
+ severity: "warning",
100
+ file: rel,
101
+ line: 1,
102
+ message: "File has very little content",
103
+ snippet: `${realLines.length} non-empty lines`
104
+ });
105
+ }
106
+ }
107
+
108
+ return findings;
109
+ }
110
+
111
+ export function formatQualityFindings(findings) {
112
+ if (!findings || findings.length === 0) return "";
113
+ const warnings = findings.filter((f) => f.severity === "warning");
114
+ const infos = findings.filter((f) => f.severity === "info");
115
+ const lines = [];
116
+
117
+ if (warnings.length > 0) {
118
+ lines.push(`⚠ ${warnings.length} quality issue${warnings.length > 1 ? "s" : ""}:`);
119
+ for (const f of warnings.slice(0, 5)) {
120
+ lines.push(` ${f.file}:${f.line} — ${f.message}`);
121
+ }
122
+ if (warnings.length > 5) lines.push(` ... and ${warnings.length - 5} more`);
123
+ }
124
+ if (infos.length > 0) {
125
+ lines.push(`ℹ ${infos.length} note${infos.length > 1 ? "s" : ""}: ${[...new Set(infos.map((f) => f.message))].join(", ")}`);
126
+ }
127
+
128
+ return lines.join("\n");
129
+ }
130
+
131
+ export function buildQualityFixPrompt(findings) {
132
+ const fixable = findings.filter((f) => f.severity === "warning").slice(0, 10);
133
+ if (fixable.length === 0) return "";
134
+ const lines = ["Fix these product quality issues:"];
135
+ for (const f of fixable) {
136
+ lines.push(`- ${f.file}:${f.line} — ${f.message} (${f.snippet})`);
137
+ }
138
+ lines.push("\nReplace placeholder content with real content. Fix broken links. Add alt text to images.");
139
+ return lines.join("\n");
140
+ }