@tritard/waterbrother 0.11.0 → 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 +1 -1
- package/src/cli.js +195 -17
- package/src/initiative.js +141 -0
- package/src/product.js +188 -0
- package/src/quality.js +140 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -33,7 +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 } from "./product.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";
|
|
37
39
|
import { formatPlanForDisplay } from "./planner.js";
|
|
38
40
|
import { parseCharterFromGoal, runExperimentLoop, formatExperimentSummary, gitReturnToBranch } from "./experiment.js";
|
|
39
41
|
|
|
@@ -4776,6 +4778,18 @@ async function promptLoop(agent, session, context) {
|
|
|
4776
4778
|
const product = await loadProduct(context.cwd);
|
|
4777
4779
|
if (product) {
|
|
4778
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
|
+
}
|
|
4779
4793
|
}
|
|
4780
4794
|
} catch {}
|
|
4781
4795
|
|
|
@@ -5038,7 +5052,13 @@ Infer reasonable defaults. Keep it practical.` },
|
|
|
5038
5052
|
type: brief.type || intent.type
|
|
5039
5053
|
});
|
|
5040
5054
|
|
|
5041
|
-
//
|
|
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)
|
|
5042
5062
|
if (brief.stack) {
|
|
5043
5063
|
product.stack = { ...product.stack, ...brief.stack };
|
|
5044
5064
|
}
|
|
@@ -5083,41 +5103,76 @@ Infer reasonable defaults. Keep it practical.` },
|
|
|
5083
5103
|
if (/^(build|build it|build the product|scaffold|go)$/.test(lower)) {
|
|
5084
5104
|
const productCtx = buildProductContext(product);
|
|
5085
5105
|
const planned = product.surfaces.filter((s) => s.status === "planned").map((s) => s.name);
|
|
5086
|
-
const buildPrompt = `Build the entire product from the blueprint. Create all files needed.\n\n${productCtx}\n\nSurfaces to build: ${planned.join(", ") || "all planned pages"}\n\nScaffold the full project: package.json, all pages/components, styling, schema, auth config, and a working dev server. Do everything in one pass.`;
|
|
5087
5106
|
|
|
5088
5107
|
const spinner = createProgressSpinner("building product...");
|
|
5089
5108
|
try {
|
|
5090
|
-
// Set autonomy to auto for the build — no file-by-file approvals
|
|
5091
5109
|
const prevAutonomy = agent.toolRuntime.getAutonomyMode();
|
|
5092
5110
|
agent.toolRuntime.setAutonomyMode("auto");
|
|
5093
5111
|
|
|
5094
|
-
|
|
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, {
|
|
5095
5117
|
onAssistantDelta() {},
|
|
5096
|
-
onToolStart(tc) {
|
|
5097
|
-
|
|
5098
|
-
spinner.setLabel(`${name}...`);
|
|
5099
|
-
},
|
|
5100
|
-
onToolEnd() { spinner.setLabel("building..."); }
|
|
5118
|
+
onToolStart(tc) { spinner.setLabel(`${tc?.function?.name || "tool"}...`); },
|
|
5119
|
+
onToolEnd() { spinner.setLabel("scaffolding..."); }
|
|
5101
5120
|
});
|
|
5102
5121
|
await agent.toolRuntime.completeTurn({});
|
|
5103
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
|
+
|
|
5104
5137
|
agent.toolRuntime.setAutonomyMode(prevAutonomy);
|
|
5105
5138
|
spinner.stop();
|
|
5106
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
|
+
|
|
5107
5150
|
// Mark surfaces as built
|
|
5108
5151
|
for (const s of product.surfaces) {
|
|
5109
5152
|
s.status = "built";
|
|
5110
5153
|
}
|
|
5111
5154
|
await saveProduct(context.cwd, product);
|
|
5112
5155
|
|
|
5113
|
-
//
|
|
5156
|
+
// Start preview server and open browser
|
|
5114
5157
|
console.log("");
|
|
5115
5158
|
console.log("PREVIEW ▸ ready");
|
|
5116
|
-
const
|
|
5117
|
-
if (
|
|
5118
|
-
|
|
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
|
+
|
|
5119
5174
|
console.log("");
|
|
5120
|
-
console.log(dim("
|
|
5175
|
+
console.log(dim(" refine · add feature · deploy"));
|
|
5121
5176
|
|
|
5122
5177
|
} catch (error) {
|
|
5123
5178
|
spinner.stop();
|
|
@@ -5137,21 +5192,141 @@ Infer reasonable defaults. Keep it practical.` },
|
|
|
5137
5192
|
return true;
|
|
5138
5193
|
}
|
|
5139
5194
|
|
|
5195
|
+
// Deploy with milestone gate
|
|
5140
5196
|
if (/^(deploy|ship|publish|launch)$/.test(lower)) {
|
|
5141
5197
|
const deployTarget = product.stack.deploy || "Vercel";
|
|
5142
|
-
console.log(`deploying to ${deployTarget}...`);
|
|
5143
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");
|
|
5144
5213
|
await agent.runTurn(
|
|
5145
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.`,
|
|
5146
|
-
{ onAssistantDelta() {}, onToolStart() {}, onToolEnd() {} }
|
|
5215
|
+
{ onAssistantDelta() {}, onToolStart() { spinner.setLabel("deploying..."); }, onToolEnd() {} }
|
|
5147
5216
|
);
|
|
5148
5217
|
await agent.toolRuntime.completeTurn({});
|
|
5218
|
+
agent.toolRuntime.setAutonomyMode(prevAutonomy);
|
|
5219
|
+
spinner.stop();
|
|
5149
5220
|
console.log(`\nSHIP ▸ deployed`);
|
|
5221
|
+
console.log(dim(" share link · add feature · view code"));
|
|
5150
5222
|
} catch (error) {
|
|
5223
|
+
spinner.stop();
|
|
5151
5224
|
console.log(`deploy failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
5152
5225
|
}
|
|
5153
5226
|
return true;
|
|
5154
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
|
+
}
|
|
5155
5330
|
}
|
|
5156
5331
|
|
|
5157
5332
|
const routed = routeNaturalInput(line, { task: context.runtime.activeTask });
|
|
@@ -7148,6 +7323,9 @@ Infer reasonable defaults. Keep it practical.` },
|
|
|
7148
7323
|
}
|
|
7149
7324
|
}
|
|
7150
7325
|
|
|
7326
|
+
// Kill any running preview server
|
|
7327
|
+
killPreview();
|
|
7328
|
+
|
|
7151
7329
|
// Save CC-mode session episode on exit (if any files were touched)
|
|
7152
7330
|
if (!context.runtime.activeTask) {
|
|
7153
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
CHANGED
|
@@ -262,3 +262,191 @@ export function parseProductIntent(text) {
|
|
|
262
262
|
|
|
263
263
|
return { type, name, raw: text };
|
|
264
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
|
+
}
|