claudeboard 2.13.0 → 2.15.1
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/agents/architect.js +72 -7
- package/agents/claude-api.js +1 -1
- package/agents/developer.js +33 -5
- package/agents/expo-health.js +235 -5
- package/agents/orchestrator.js +65 -3
- package/agents/qa.js +65 -3
- package/dashboard/index.html +25 -14
- package/dashboard/server.js +5 -1
- package/package.json +1 -1
package/agents/architect.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { callClaudeJSON } from "./claude-api.js";
|
|
2
|
-
import { createEpic, createTask
|
|
2
|
+
import { createEpic, createTask } from "./board-client.js";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
3
5
|
|
|
4
6
|
const SYSTEM = `You are a senior mobile app architect specializing in React Native / Expo apps.
|
|
5
7
|
Your job is to read a PRD and produce a complete, ordered task breakdown.
|
|
@@ -10,16 +12,79 @@ Rules:
|
|
|
10
12
|
- Be specific — tasks like "implement auth" are too vague. Break into: "create login screen UI", "implement Supabase auth hook", "add protected route navigation"
|
|
11
13
|
- Always include: project setup, navigation, data layer, each feature screen, error handling, loading states, and final QA tasks
|
|
12
14
|
- Priority: high = blocking/core, medium = main features, low = polish/nice-to-have
|
|
13
|
-
- Types: config (setup/deps), feature (new screen or functionality), bug (fix), refactor, test (QA task)
|
|
15
|
+
- Types: config (setup/deps), feature (new screen or functionality), bug (fix), refactor, test (QA task)
|
|
16
|
+
- When building on an existing project: do NOT recreate files that already exist — create tasks that extend or integrate with them`;
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Build a concise snapshot of the existing project:
|
|
20
|
+
* - Top-level file/folder structure
|
|
21
|
+
* - package.json dependencies
|
|
22
|
+
* - app.json expo config (if present)
|
|
23
|
+
*/
|
|
24
|
+
function getProjectSnapshot(projectPath) {
|
|
25
|
+
const lines = [];
|
|
26
|
+
|
|
27
|
+
// Top-level structure (exclude hidden files and node_modules)
|
|
28
|
+
try {
|
|
29
|
+
const entries = fs.readdirSync(projectPath)
|
|
30
|
+
.filter(f => !f.startsWith('.') && f !== 'node_modules')
|
|
31
|
+
.sort();
|
|
32
|
+
lines.push("### Project structure (top level)");
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const fullPath = path.join(projectPath, entry);
|
|
35
|
+
const isDir = fs.statSync(fullPath).isDirectory();
|
|
36
|
+
if (isDir) {
|
|
37
|
+
// List one level deep for key folders
|
|
38
|
+
const children = fs.readdirSync(fullPath)
|
|
39
|
+
.filter(f => !f.startsWith('.') && f !== 'node_modules')
|
|
40
|
+
.slice(0, 15);
|
|
41
|
+
lines.push(`${entry}/`);
|
|
42
|
+
for (const child of children) lines.push(` ${entry}/${child}`);
|
|
43
|
+
} else {
|
|
44
|
+
lines.push(entry);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch {}
|
|
48
|
+
|
|
49
|
+
// package.json
|
|
50
|
+
try {
|
|
51
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(projectPath, "package.json"), "utf8"));
|
|
52
|
+
lines.push("\n### package.json dependencies");
|
|
53
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
54
|
+
for (const [name, version] of Object.entries(deps)) {
|
|
55
|
+
lines.push(` ${name}: ${version}`);
|
|
56
|
+
}
|
|
57
|
+
} catch {}
|
|
58
|
+
|
|
59
|
+
// app.json
|
|
60
|
+
try {
|
|
61
|
+
const appJson = JSON.parse(fs.readFileSync(path.join(projectPath, "app.json"), "utf8"));
|
|
62
|
+
lines.push("\n### app.json (expo config)");
|
|
63
|
+
lines.push(JSON.stringify(appJson.expo || appJson, null, 2).slice(0, 1000));
|
|
64
|
+
} catch {}
|
|
65
|
+
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function runArchitectAgent(prdContent, projectName, options = {}) {
|
|
70
|
+
const { buildOnExisting = false, projectPath = null } = options;
|
|
71
|
+
|
|
72
|
+
console.log(" Architect analyzing PRD...");
|
|
73
|
+
|
|
74
|
+
const projectSnapshot = (buildOnExisting && projectPath)
|
|
75
|
+
? getProjectSnapshot(projectPath)
|
|
76
|
+
: null;
|
|
77
|
+
|
|
78
|
+
const existingProjectSection = projectSnapshot
|
|
79
|
+
? `\n\nEXISTING PROJECT SNAPSHOT:\n${projectSnapshot}\n\nIMPORTANT: Create tasks that BUILD ON top of what already exists. Do NOT recreate files or setup that is already in place. Analyze the snapshot carefully and only create tasks for what is missing or needs to be extended.`
|
|
80
|
+
: "";
|
|
17
81
|
|
|
18
82
|
const result = await callClaudeJSON(SYSTEM, `
|
|
19
83
|
Project: ${projectName}
|
|
20
84
|
|
|
21
85
|
PRD:
|
|
22
86
|
${prdContent}
|
|
87
|
+
${existingProjectSection}
|
|
23
88
|
|
|
24
89
|
Return this JSON structure:
|
|
25
90
|
{
|
|
@@ -49,7 +114,7 @@ Return this JSON structure:
|
|
|
49
114
|
}
|
|
50
115
|
|
|
51
116
|
Order epics from first to last in implementation order.
|
|
52
|
-
Include
|
|
117
|
+
Include as many tasks as needed for a complete, production-ready mobile app — do not artificially limit the count.`, { maxTokens: 16000 });
|
|
53
118
|
|
|
54
119
|
console.log(` ✓ Architect created ${result.epics.length} epics`);
|
|
55
120
|
|
|
@@ -57,11 +122,11 @@ Include 25-50 tasks total for a complete mobile app.`);
|
|
|
57
122
|
let totalTasks = 0;
|
|
58
123
|
for (let i = 0; i < result.epics.length; i++) {
|
|
59
124
|
const epicData = result.epics[i];
|
|
60
|
-
const
|
|
125
|
+
const epicId = await createEpic(epicData.name);
|
|
61
126
|
|
|
62
127
|
for (const task of epicData.tasks) {
|
|
63
128
|
await createTask({
|
|
64
|
-
epicId
|
|
129
|
+
epicId,
|
|
65
130
|
title: task.title,
|
|
66
131
|
description: `${task.description}\n\nAcceptance: ${task.acceptanceCriteria || ""}`,
|
|
67
132
|
priority: task.priority,
|
package/agents/claude-api.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const MODEL = "claude-sonnet-4-20250514";
|
|
7
|
-
const MAX_TOKENS =
|
|
7
|
+
const MAX_TOKENS = 16000; // Max output tokens — input context window is 200k, no limits there
|
|
8
8
|
|
|
9
9
|
function getHeaders() {
|
|
10
10
|
const key = process.env.ANTHROPIC_API_KEY;
|
package/agents/developer.js
CHANGED
|
@@ -67,14 +67,15 @@ RULES:
|
|
|
67
67
|
- Use TypeScript if the project uses it
|
|
68
68
|
- Read existing files first to follow the project's patterns
|
|
69
69
|
- Install packages with: npx expo install <package>
|
|
70
|
-
-
|
|
70
|
+
- If tsconfig.json exists, run: npx tsc --noEmit after writing files and fix any errors
|
|
71
71
|
- If you hit an error, read it carefully and fix it — iterate until it works
|
|
72
72
|
- Do NOT ask questions or ask for confirmation. Make your best judgment.
|
|
73
|
+
- Do NOT run npx expo run:ios or npx expo run:android — the orchestrator handles native builds
|
|
73
74
|
- When fully done, print EXACTLY this line: TASK_COMPLETE: <one sentence summary>
|
|
74
75
|
`;
|
|
75
76
|
|
|
76
77
|
// ── Main export ───────────────────────────────────────────────────────────────
|
|
77
|
-
export async function runDeveloperAgent(task, projectPath, techStack, retryContext = null) {
|
|
78
|
+
export async function runDeveloperAgent(task, projectPath, techStack, allTasks = [], retryContext = null) {
|
|
78
79
|
console.log(` 🤖 Claude Code working on: ${task.title}`);
|
|
79
80
|
|
|
80
81
|
if (!CLAUDE_PATH) {
|
|
@@ -96,6 +97,27 @@ export async function runDeveloperAgent(task, projectPath, techStack, retryConte
|
|
|
96
97
|
? `\nKnown tech stack: ${JSON.stringify(techStack)}`
|
|
97
98
|
: "";
|
|
98
99
|
|
|
100
|
+
// Build task context for mini-plan
|
|
101
|
+
const doneTasks = allTasks
|
|
102
|
+
.filter(t => t.status === "done")
|
|
103
|
+
.map(t => ` ✓ ${t.title}`)
|
|
104
|
+
.join("\n");
|
|
105
|
+
const upcomingTasks = allTasks
|
|
106
|
+
.filter(t => t.status === "todo" && t.id !== task.id)
|
|
107
|
+
.slice(0, 12)
|
|
108
|
+
.map(t => ` ○ ${t.title}`)
|
|
109
|
+
.join("\n");
|
|
110
|
+
|
|
111
|
+
const taskContextSection = (doneTasks || upcomingTasks) ? `
|
|
112
|
+
## Implementation Context
|
|
113
|
+
|
|
114
|
+
**Tasks already completed:**
|
|
115
|
+
${doneTasks || " None yet — this is the first task"}
|
|
116
|
+
|
|
117
|
+
**Upcoming tasks (do NOT implement these, just be aware to avoid conflicts):**
|
|
118
|
+
${upcomingTasks || " None"}
|
|
119
|
+
` : "";
|
|
120
|
+
|
|
99
121
|
const prompt = `## Task to implement
|
|
100
122
|
|
|
101
123
|
**Title:** ${task.title}
|
|
@@ -103,14 +125,20 @@ export async function runDeveloperAgent(task, projectPath, techStack, retryConte
|
|
|
103
125
|
**Description:**
|
|
104
126
|
${task.description || "No additional description provided."}
|
|
105
127
|
${techNote}
|
|
128
|
+
${taskContextSection}
|
|
129
|
+
## Project Context
|
|
130
|
+
Read CLAUDEBOARD_CONTEXT.md first if it exists — it contains key architectural decisions and patterns from previous tasks.
|
|
131
|
+
After completing this task, UPDATE CLAUDEBOARD_CONTEXT.md with any new decisions, patterns, file structure changes, or naming conventions introduced.
|
|
106
132
|
${retryNote}
|
|
107
133
|
|
|
108
134
|
## Steps
|
|
135
|
+
0. Read CLAUDEBOARD_CONTEXT.md if it exists. Then write a brief implementation plan: which files you will create/modify and how this fits with the done/upcoming tasks above.
|
|
109
136
|
1. Explore the project structure to understand existing patterns
|
|
110
|
-
2. Implement the task completely
|
|
137
|
+
2. Implement the task completely — follow patterns already established
|
|
111
138
|
3. Install any missing dependencies with: npx expo install <pkg>
|
|
112
|
-
4.
|
|
113
|
-
5.
|
|
139
|
+
4. If tsconfig.json exists, run: npx tsc --noEmit and fix any errors
|
|
140
|
+
5. Update CLAUDEBOARD_CONTEXT.md with key decisions made
|
|
141
|
+
6. When done, print: TASK_COMPLETE: <summary>`;
|
|
114
142
|
|
|
115
143
|
try {
|
|
116
144
|
let toolCallCount = 0;
|
package/agents/expo-health.js
CHANGED
|
@@ -4,12 +4,15 @@ import { createTask, createEpic, addLog } from "./board-client.js";
|
|
|
4
4
|
import chalk from "chalk";
|
|
5
5
|
import fs from "fs";
|
|
6
6
|
import path from "path";
|
|
7
|
+
import crypto from "crypto";
|
|
7
8
|
import { createRequire } from "module";
|
|
8
9
|
import { createConnection } from "net";
|
|
9
10
|
import { spawn as _spawn, execSync } from "child_process";
|
|
10
11
|
|
|
11
12
|
const require = createRequire(import.meta.url);
|
|
12
13
|
const MAX_FIX_ATTEMPTS = 5;
|
|
14
|
+
const BUILD_HASH_FILE = ".claudeboard-build-hash.txt";
|
|
15
|
+
const BUILD_TIMEOUT_MS = 10 * 60 * 1000; // 10 min max for expo run:ios
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* Expo Health Check Agent — runs BEFORE the development loop.
|
|
@@ -17,9 +20,10 @@ const MAX_FIX_ATTEMPTS = 5;
|
|
|
17
20
|
* Strategy:
|
|
18
21
|
* 1. Try npm install (--legacy-peer-deps, then --force if needed)
|
|
19
22
|
* 2. If install fails with ETARGET/missing version → ask Claude for fix → apply → retry
|
|
20
|
-
* 3.
|
|
21
|
-
* 4.
|
|
22
|
-
* 5. If
|
|
23
|
+
* 3. If iOS mode: ensure a dev build exists in the simulator (builds if needed)
|
|
24
|
+
* 4. Start Expo, wait for Metro to be truly ready (not just port open)
|
|
25
|
+
* 5. If Metro crashes with module/dep errors → ask Claude → apply → retry
|
|
26
|
+
* 6. If still broken after MAX attempts → inject a BLOCKER task into the board
|
|
23
27
|
* so the developer agent fixes it before any other task runs
|
|
24
28
|
*
|
|
25
29
|
* Returns { ready: boolean, process: ChildProcess|null }
|
|
@@ -44,7 +48,22 @@ export async function runExpoHealthCheck(projectPath, port = 8081) {
|
|
|
44
48
|
return { ready: false, process: null };
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
// ── 2.
|
|
51
|
+
// ── 2. Dev build (iOS mode only) ───────────────────────────────────────────
|
|
52
|
+
const useIOS = process.env.CLAUDEBOARD_IOS === "1" || isSimulatorAvailableSync();
|
|
53
|
+
if (useIOS) {
|
|
54
|
+
const buildOk = await ensureDevBuild(projectPath);
|
|
55
|
+
if (!buildOk) {
|
|
56
|
+
await injectFixTask(projectPath,
|
|
57
|
+
"FIX: Dev build failed — fix native compilation errors",
|
|
58
|
+
"npx expo run:ios failed. Fix any native dependency or Podfile issues so the app compiles.\n" +
|
|
59
|
+
"Run: npx expo run:ios --simulator and fix all errors."
|
|
60
|
+
);
|
|
61
|
+
console.log(chalk.yellow(" ✗ Dev build failed — injected fix task into board\n"));
|
|
62
|
+
return { ready: false, process: null };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── 3. Start Expo + verify Metro is error-free ─────────────────────────────
|
|
48
67
|
for (let attempt = 1; attempt <= MAX_FIX_ATTEMPTS; attempt++) {
|
|
49
68
|
console.log(chalk.dim(` Starting Expo (attempt ${attempt}/${MAX_FIX_ATTEMPTS})...`));
|
|
50
69
|
|
|
@@ -84,6 +103,217 @@ export async function runExpoHealthCheck(projectPath, port = 8081) {
|
|
|
84
103
|
return { ready: false, process: null };
|
|
85
104
|
}
|
|
86
105
|
|
|
106
|
+
// ── Dev Build management ───────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Compute a short hash of package.json to detect dependency changes.
|
|
110
|
+
*/
|
|
111
|
+
export function packageJsonHash(projectPath) {
|
|
112
|
+
try {
|
|
113
|
+
const content = fs.readFileSync(path.join(projectPath, "package.json"), "utf8");
|
|
114
|
+
return crypto.createHash("md5").update(content).digest("hex").slice(0, 12);
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Returns true if a rebuild is needed:
|
|
122
|
+
* - No saved hash (never built), OR
|
|
123
|
+
* - package.json changed since last build
|
|
124
|
+
*/
|
|
125
|
+
export function needsRebuild(projectPath) {
|
|
126
|
+
const hashFile = path.join(projectPath, BUILD_HASH_FILE);
|
|
127
|
+
if (!fs.existsSync(hashFile)) return true;
|
|
128
|
+
try {
|
|
129
|
+
const saved = fs.readFileSync(hashFile, "utf8").trim();
|
|
130
|
+
return saved !== packageJsonHash(projectPath);
|
|
131
|
+
} catch {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Persist the current package.json hash after a successful build.
|
|
138
|
+
*/
|
|
139
|
+
function saveBuildHash(projectPath) {
|
|
140
|
+
try {
|
|
141
|
+
const hash = packageJsonHash(projectPath);
|
|
142
|
+
if (hash) fs.writeFileSync(path.join(projectPath, BUILD_HASH_FILE), hash, "utf8");
|
|
143
|
+
} catch {}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Check if the app's dev build is already installed in the booted simulator.
|
|
148
|
+
*/
|
|
149
|
+
function isDevBuildInstalled(projectPath) {
|
|
150
|
+
try {
|
|
151
|
+
const bundleId = getBundleId(projectPath);
|
|
152
|
+
if (!bundleId) return false;
|
|
153
|
+
const output = execSync(`xcrun simctl get_app_container booted "${bundleId}" 2>&1`, { encoding: "utf8" });
|
|
154
|
+
return output.trim().length > 0 && !output.includes("No such file");
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Read the iOS bundle identifier from app.json / app.config.js.
|
|
162
|
+
* Falls back to deriving it from the project slug.
|
|
163
|
+
*/
|
|
164
|
+
function getBundleId(projectPath) {
|
|
165
|
+
try {
|
|
166
|
+
const appJsonPath = path.join(projectPath, "app.json");
|
|
167
|
+
if (fs.existsSync(appJsonPath)) {
|
|
168
|
+
const cfg = JSON.parse(fs.readFileSync(appJsonPath, "utf8"));
|
|
169
|
+
const bundleId = cfg?.expo?.ios?.bundleIdentifier;
|
|
170
|
+
if (bundleId) return bundleId;
|
|
171
|
+
// Derive from slug if no explicit bundleIdentifier
|
|
172
|
+
const slug = cfg?.expo?.slug;
|
|
173
|
+
if (slug) return `com.anonymous.${slug.replace(/[^a-zA-Z0-9]/g, "")}`;
|
|
174
|
+
}
|
|
175
|
+
} catch {}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Boot the first available iOS simulator if none is booted.
|
|
181
|
+
*/
|
|
182
|
+
function ensureSimulatorBooted() {
|
|
183
|
+
try {
|
|
184
|
+
const booted = execSync("xcrun simctl list devices booted 2>&1", { encoding: "utf8" });
|
|
185
|
+
if (booted.includes("Booted")) return true;
|
|
186
|
+
|
|
187
|
+
// Find a suitable device to boot
|
|
188
|
+
const devices = execSync("xcrun simctl list devices available 2>&1", { encoding: "utf8" });
|
|
189
|
+
const match = devices.match(/iPhone \d[^(]*\(([A-F0-9-]{36})\)/i);
|
|
190
|
+
if (!match) return false;
|
|
191
|
+
|
|
192
|
+
console.log(chalk.dim(` Booting simulator ${match[1]}...`));
|
|
193
|
+
execSync(`xcrun simctl boot "${match[1]}" 2>&1`, { encoding: "utf8" });
|
|
194
|
+
// Give the simulator a moment to fully boot
|
|
195
|
+
execSync("sleep 5");
|
|
196
|
+
return true;
|
|
197
|
+
} catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Build and install the dev build in the simulator using `expo run:ios`.
|
|
204
|
+
* Only rebuilds when package.json changed or the app is not installed.
|
|
205
|
+
* Returns true on success, false on failure.
|
|
206
|
+
*/
|
|
207
|
+
export async function ensureDevBuild(projectPath) {
|
|
208
|
+
const installed = isDevBuildInstalled(projectPath);
|
|
209
|
+
const rebuild = needsRebuild(projectPath);
|
|
210
|
+
|
|
211
|
+
if (installed && !rebuild) {
|
|
212
|
+
console.log(chalk.dim(" ✓ Dev build up to date — skipping rebuild"));
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const reason = !installed ? "app not installed" : "dependencies changed";
|
|
217
|
+
console.log(chalk.cyan(` Building dev build (${reason})...`));
|
|
218
|
+
console.log(chalk.dim(" This may take several minutes on first run."));
|
|
219
|
+
|
|
220
|
+
// Make sure a simulator is running
|
|
221
|
+
const simReady = ensureSimulatorBooted();
|
|
222
|
+
if (!simReady) {
|
|
223
|
+
console.log(chalk.yellow(" ✗ No iOS simulator available"));
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return new Promise((resolve) => {
|
|
228
|
+
let output = "";
|
|
229
|
+
let resolved = false;
|
|
230
|
+
|
|
231
|
+
const done = (ok) => {
|
|
232
|
+
if (resolved) return;
|
|
233
|
+
resolved = true;
|
|
234
|
+
if (ok) {
|
|
235
|
+
saveBuildHash(projectPath);
|
|
236
|
+
console.log(chalk.green(" ✓ Dev build installed in simulator"));
|
|
237
|
+
}
|
|
238
|
+
resolve(ok);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const timeout = setTimeout(() => {
|
|
242
|
+
console.log(chalk.yellow(" ✗ Dev build timed out after 10 minutes"));
|
|
243
|
+
done(false);
|
|
244
|
+
}, BUILD_TIMEOUT_MS);
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const proc = _spawn("npx", ["expo", "run:ios", "--simulator"], {
|
|
248
|
+
cwd: projectPath,
|
|
249
|
+
env: { ...process.env, CI: "1", EXPO_NO_INTERACTIVE: "1" },
|
|
250
|
+
stdio: "pipe",
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const onData = (d) => {
|
|
254
|
+
const text = d.toString();
|
|
255
|
+
output += text;
|
|
256
|
+
// Log progress lines that are meaningful
|
|
257
|
+
const lines = text.split("\n").filter(l => l.trim() && !l.includes("\u001b["));
|
|
258
|
+
for (const line of lines) {
|
|
259
|
+
if (
|
|
260
|
+
line.includes("Installing") ||
|
|
261
|
+
line.includes("Building") ||
|
|
262
|
+
line.includes("Compiling") ||
|
|
263
|
+
line.includes("Linking") ||
|
|
264
|
+
line.includes("error:") ||
|
|
265
|
+
line.includes("warning:")
|
|
266
|
+
) {
|
|
267
|
+
console.log(chalk.dim(` ${line.trim().slice(0, 100)}`));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (
|
|
272
|
+
text.includes("Installed on") ||
|
|
273
|
+
text.includes("Opening on") ||
|
|
274
|
+
text.includes("Successfully built") ||
|
|
275
|
+
text.includes("BUILD SUCCEEDED")
|
|
276
|
+
) {
|
|
277
|
+
clearTimeout(timeout);
|
|
278
|
+
done(true);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (
|
|
282
|
+
text.includes("BUILD FAILED") ||
|
|
283
|
+
text.includes("error: ") ||
|
|
284
|
+
text.includes("Command failed")
|
|
285
|
+
) {
|
|
286
|
+
// Wait a bit to collect full error output
|
|
287
|
+
setTimeout(() => {
|
|
288
|
+
clearTimeout(timeout);
|
|
289
|
+
saveLastError(projectPath, output.slice(-3000));
|
|
290
|
+
done(false);
|
|
291
|
+
}, 2000);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
proc.stdout.on("data", onData);
|
|
296
|
+
proc.stderr.on("data", onData);
|
|
297
|
+
|
|
298
|
+
proc.on("close", (code) => {
|
|
299
|
+
clearTimeout(timeout);
|
|
300
|
+
if (!resolved) {
|
|
301
|
+
if (code === 0) {
|
|
302
|
+
done(true);
|
|
303
|
+
} else {
|
|
304
|
+
saveLastError(projectPath, output.slice(-3000));
|
|
305
|
+
done(false);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
} catch (e) {
|
|
311
|
+
clearTimeout(timeout);
|
|
312
|
+
done(false);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
87
317
|
// ── npm install with progressive fallback ─────────────────────────────────
|
|
88
318
|
async function installDeps(projectPath) {
|
|
89
319
|
const pkg = JSON.parse(fs.readFileSync(path.join(projectPath, "package.json"), "utf8"));
|
|
@@ -150,7 +380,7 @@ async function tryStartExpo(projectPath, port) {
|
|
|
150
380
|
done(false, `Expo did not become ready within 60s.\n${output.slice(-800)}`), 60000);
|
|
151
381
|
|
|
152
382
|
try {
|
|
153
|
-
//
|
|
383
|
+
// iOS mode: use dev build (--go is intentionally omitted so the installed dev build opens)
|
|
154
384
|
const useIOS = process.env.CLAUDEBOARD_IOS === "1" || isSimulatorAvailableSync();
|
|
155
385
|
const expoArgs = useIOS
|
|
156
386
|
? ["expo", "start", "--ios", "--port", String(port)]
|
package/agents/orchestrator.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from "path";
|
|
|
5
5
|
import { runArchitectAgent } from "./architect.js";
|
|
6
6
|
import { runDeveloperAgent } from "./developer.js";
|
|
7
7
|
import { runQAAgent, runFullAppQA } from "./qa.js";
|
|
8
|
-
import { runExpoHealthCheck } from "./expo-health.js";
|
|
8
|
+
import { runExpoHealthCheck, ensureDevBuild, packageJsonHash } from "./expo-health.js";
|
|
9
9
|
import { createConnection } from "net";
|
|
10
10
|
|
|
11
11
|
function isPortOpen(port) {
|
|
@@ -89,7 +89,39 @@ export async function runOrchestrator(config) {
|
|
|
89
89
|
console.log(chalk.dim(" Force restart — skipping existing tasks (they remain in board)\n"));
|
|
90
90
|
}
|
|
91
91
|
console.log(chalk.bold.cyan("[ PHASE 1: ARCHITECTURE ]\n"));
|
|
92
|
-
|
|
92
|
+
|
|
93
|
+
// ── Detect existing project files (besides the PRD) ───────────────────────
|
|
94
|
+
let buildOnExisting = false;
|
|
95
|
+
const EXISTING_CODE_MARKERS = ["package.json", "app.json", "src", "app", "components", "screens"];
|
|
96
|
+
try {
|
|
97
|
+
const prdBasename = path.basename(prdPath);
|
|
98
|
+
const projectFiles = fs.readdirSync(projectPath)
|
|
99
|
+
.filter(f => !f.startsWith(".") && f !== prdBasename && f !== "node_modules");
|
|
100
|
+
const hasExistingCode = projectFiles.some(f => EXISTING_CODE_MARKERS.includes(f));
|
|
101
|
+
|
|
102
|
+
if (hasExistingCode && !config.forceRestart) {
|
|
103
|
+
console.log(chalk.yellow(" ⚠ This project already has files:\n"));
|
|
104
|
+
projectFiles.slice(0, 12).forEach(f => console.log(chalk.dim(` ${f}`)));
|
|
105
|
+
if (projectFiles.length > 12) console.log(chalk.dim(` ... and ${projectFiles.length - 12} more`));
|
|
106
|
+
console.log();
|
|
107
|
+
|
|
108
|
+
const { default: Enquirer } = await import("enquirer");
|
|
109
|
+
const enquirer = new Enquirer();
|
|
110
|
+
const answer = await enquirer.prompt({
|
|
111
|
+
type: "confirm",
|
|
112
|
+
name: "buildOnExisting",
|
|
113
|
+
message: "Build tasks on top of the existing project (instead of starting from scratch)?",
|
|
114
|
+
initial: true,
|
|
115
|
+
});
|
|
116
|
+
buildOnExisting = answer.buildOnExisting;
|
|
117
|
+
console.log();
|
|
118
|
+
}
|
|
119
|
+
} catch {}
|
|
120
|
+
|
|
121
|
+
const archResult = await runArchitectAgent(prdContent, projectName, {
|
|
122
|
+
buildOnExisting,
|
|
123
|
+
projectPath,
|
|
124
|
+
});
|
|
93
125
|
techStack = archResult.techStack;
|
|
94
126
|
console.log(chalk.green(`\n ✓ ${archResult.totalTasks} tasks created across ${archResult.epics.length} epics\n`));
|
|
95
127
|
}
|
|
@@ -112,6 +144,9 @@ export async function runOrchestrator(config) {
|
|
|
112
144
|
let consecutiveFailures = 0;
|
|
113
145
|
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
114
146
|
|
|
147
|
+
// Track package.json hash to detect native dependency changes that need a rebuild
|
|
148
|
+
let lastPkgHash = packageJsonHash(projectPath);
|
|
149
|
+
|
|
115
150
|
while (true) {
|
|
116
151
|
const task = await getNextTask();
|
|
117
152
|
|
|
@@ -127,8 +162,11 @@ export async function runOrchestrator(config) {
|
|
|
127
162
|
console.log(chalk.bold(`\n → ${task.title}`));
|
|
128
163
|
console.log(chalk.dim(` Epic: ${task.cb_epics?.name || "—"} | ${task.priority} | ${task.type}`));
|
|
129
164
|
|
|
165
|
+
// Fetch all tasks for mini-plan context (done + upcoming)
|
|
166
|
+
const allTasks = await getAllTasks();
|
|
167
|
+
|
|
130
168
|
// Run developer agent
|
|
131
|
-
const devResult = await runDeveloperAgent(task, projectPath, techStack);
|
|
169
|
+
const devResult = await runDeveloperAgent(task, projectPath, techStack, allTasks);
|
|
132
170
|
|
|
133
171
|
if (!devResult.success) {
|
|
134
172
|
consecutiveFailures++;
|
|
@@ -153,6 +191,29 @@ export async function runOrchestrator(config) {
|
|
|
153
191
|
expoProcess = recheck.process;
|
|
154
192
|
}
|
|
155
193
|
|
|
194
|
+
// ── Detect native dependency changes → rebuild dev build if needed ────────
|
|
195
|
+
if (useIOS) {
|
|
196
|
+
const currentHash = packageJsonHash(projectPath);
|
|
197
|
+
if (currentHash && currentHash !== lastPkgHash) {
|
|
198
|
+
lastPkgHash = currentHash;
|
|
199
|
+
console.log(chalk.cyan(" 📦 package.json changed — checking if dev build rebuild is needed..."));
|
|
200
|
+
if (expoProcess) {
|
|
201
|
+
try { expoProcess.kill(); } catch {}
|
|
202
|
+
expoProcess = null;
|
|
203
|
+
expoReady = false;
|
|
204
|
+
}
|
|
205
|
+
const buildOk = await ensureDevBuild(projectPath);
|
|
206
|
+
if (buildOk) {
|
|
207
|
+
// Restart Metro after rebuild
|
|
208
|
+
const recheck = await runExpoHealthCheck(projectPath, expoPort);
|
|
209
|
+
expoReady = recheck.ready;
|
|
210
|
+
expoProcess = recheck.process;
|
|
211
|
+
} else {
|
|
212
|
+
console.log(chalk.yellow(" ✗ Rebuild failed — continuing without live preview"));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
156
217
|
// ── Re-check Expo health if it's supposed to be running but isn't ────────
|
|
157
218
|
if (expoReady) {
|
|
158
219
|
const portOpen = await isPortOpen(expoPort);
|
|
@@ -182,6 +243,7 @@ export async function runOrchestrator(config) {
|
|
|
182
243
|
{ ...task, title: `FIX: ${task.title}`, description: qaResult.fixInstructions },
|
|
183
244
|
projectPath,
|
|
184
245
|
techStack,
|
|
246
|
+
allTasks,
|
|
185
247
|
qaResult.fixInstructions
|
|
186
248
|
);
|
|
187
249
|
|
package/agents/qa.js
CHANGED
|
@@ -3,8 +3,10 @@ import { addLog, completeTask, failTask } from "./board-client.js";
|
|
|
3
3
|
import { runCommand } from "../tools/terminal.js";
|
|
4
4
|
import { screenshotExpoWeb } from "../tools/screenshot.js";
|
|
5
5
|
import { screenshotSimulator } from "./expo-health.js";
|
|
6
|
+
import { callClaudeWithImage } from "./claude-api.js";
|
|
6
7
|
import { listFiles, readFile } from "../tools/filesystem.js";
|
|
7
8
|
import { execSync } from "child_process";
|
|
9
|
+
import chalk from "chalk";
|
|
8
10
|
import path from "path";
|
|
9
11
|
import fs from "fs";
|
|
10
12
|
import { createConnection } from "net";
|
|
@@ -245,15 +247,75 @@ export async function runFullAppQA(projectPath, prdContent, expoPort = 8081) {
|
|
|
245
247
|
.map(f => "/" + path.relative(appDir, f).replace(/\.(tsx|ts)$/, "").replace(/index$/, ""));
|
|
246
248
|
|
|
247
249
|
const screenshotDir = path.join(projectPath, ".claudeboard-screenshots", "full-qa");
|
|
250
|
+
if (!fs.existsSync(screenshotDir)) fs.mkdirSync(screenshotDir, { recursive: true });
|
|
251
|
+
|
|
248
252
|
const screenshots = [];
|
|
253
|
+
const issues = [];
|
|
254
|
+
const prdSummary = prdContent.slice(0, 1200);
|
|
249
255
|
|
|
250
256
|
for (const route of routeFiles) {
|
|
251
|
-
|
|
252
|
-
|
|
257
|
+
// Try iOS simulator screenshot first, fallback to Expo Web
|
|
258
|
+
const useIOS = process.env.CLAUDEBOARD_IOS === "1";
|
|
259
|
+
let shot = null;
|
|
260
|
+
|
|
261
|
+
if (useIOS) {
|
|
262
|
+
const simPath = path.join(screenshotDir, `sim_fullqa_${route.replace(/\//g, "_")}_${Date.now()}.png`);
|
|
263
|
+
const simShot = await screenshotSimulator(simPath);
|
|
264
|
+
if (simShot.success) shot = simShot;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!shot) {
|
|
268
|
+
const webShot = await screenshotExpoWeb(expoPort, screenshotDir, route);
|
|
269
|
+
if (webShot.success) shot = webShot;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!shot) {
|
|
273
|
+
await new Promise(r => setTimeout(r, 800));
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
screenshots.push({ route, ...shot });
|
|
278
|
+
console.log(chalk.dim(` Screenshot: ${route}`));
|
|
279
|
+
|
|
280
|
+
// ── Visual analysis with Claude ───────────────────────────────────────
|
|
281
|
+
try {
|
|
282
|
+
const { text } = await callClaudeWithImage(
|
|
283
|
+
"You are a senior mobile QA engineer reviewing screenshots of a React Native / Expo app. Be concise and critical.",
|
|
284
|
+
`Screen route: ${route}
|
|
285
|
+
|
|
286
|
+
PRD context (what the app should do):
|
|
287
|
+
${prdSummary}
|
|
288
|
+
|
|
289
|
+
Review this screenshot and identify ONLY real issues such as:
|
|
290
|
+
- Broken or overlapping layout
|
|
291
|
+
- Empty states that should have content
|
|
292
|
+
- Placeholder text (lorem ipsum, "TODO", "Coming soon", etc.)
|
|
293
|
+
- Visible error messages or red screens
|
|
294
|
+
- Missing navigation or broken UI components
|
|
295
|
+
- Text cut off or unreadable
|
|
296
|
+
|
|
297
|
+
If the screen looks complete and correct, respond with: OK
|
|
298
|
+
If there are issues, respond with: ISSUE: <brief description>
|
|
299
|
+
Do NOT flag minor styling preferences. Only flag broken or clearly incomplete functionality.`,
|
|
300
|
+
shot.base64
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const trimmed = text.trim();
|
|
304
|
+
if (trimmed.startsWith("ISSUE:")) {
|
|
305
|
+
const note = trimmed.replace("ISSUE:", "").trim();
|
|
306
|
+
issues.push({ route, note });
|
|
307
|
+
console.log(chalk.yellow(` ⚠ ${route}: ${note}`));
|
|
308
|
+
} else {
|
|
309
|
+
console.log(chalk.dim(` ✓ ${route}: OK`));
|
|
310
|
+
}
|
|
311
|
+
} catch (e) {
|
|
312
|
+
console.log(chalk.dim(` ⚠ Visual analysis failed for ${route}: ${e.message?.slice(0, 60)}`));
|
|
313
|
+
}
|
|
314
|
+
|
|
253
315
|
await new Promise(r => setTimeout(r, 800));
|
|
254
316
|
}
|
|
255
317
|
|
|
256
|
-
return { passed:
|
|
318
|
+
return { passed: issues.length === 0, routes: routeFiles, screenshotsCaptures: screenshots.length, issues };
|
|
257
319
|
}
|
|
258
320
|
|
|
259
321
|
// ── Detect truncated files ─────────────────────────────────────────────────────
|
package/dashboard/index.html
CHANGED
|
@@ -1364,15 +1364,17 @@ function patchTaskInMemory(patch) {
|
|
|
1364
1364
|
// We track order in JS so we don't depend on DOM state during drag
|
|
1365
1365
|
let dragColumnOrder = {}; // { status: [id, id, ...] } — snapshot at dragstart
|
|
1366
1366
|
function onDragStart(e, id) {
|
|
1367
|
-
|
|
1367
|
+
// Normalize to string so comparisons with dataset values are consistent
|
|
1368
|
+
draggedId = String(id);
|
|
1368
1369
|
|
|
1369
1370
|
// Snapshot current order of all columns from memory (reliable — not DOM-dependent)
|
|
1371
|
+
// IDs are stored as strings to match dataset.id comparisons
|
|
1370
1372
|
dragColumnOrder = {};
|
|
1371
1373
|
for (const status of ['todo', 'in_progress', 'done', 'error']) {
|
|
1372
1374
|
dragColumnOrder[status] = allTasks()
|
|
1373
1375
|
.filter(t => t.status === status)
|
|
1374
1376
|
.sort((a, b) => (a.priority_order ?? 99) - (b.priority_order ?? 99))
|
|
1375
|
-
.map(t => t.id);
|
|
1377
|
+
.map(t => String(t.id));
|
|
1376
1378
|
}
|
|
1377
1379
|
|
|
1378
1380
|
setTimeout(() => {
|
|
@@ -1432,30 +1434,39 @@ async function onDrop(e, status) {
|
|
|
1432
1434
|
if (!draggedId) return;
|
|
1433
1435
|
|
|
1434
1436
|
const col = e.currentTarget;
|
|
1437
|
+
// Read insertBefore from the column body's data attribute (set by onDragOver)
|
|
1435
1438
|
const insertBeforeId = col.dataset.insertBefore || '';
|
|
1436
1439
|
delete col.dataset.insertBefore;
|
|
1437
1440
|
|
|
1441
|
+
// Normalize IDs to strings for consistent comparison (dataset values are always strings)
|
|
1442
|
+
const draggedIdStr = String(draggedId);
|
|
1443
|
+
const insertBeforeIdStr = insertBeforeId ? String(insertBeforeId) : '';
|
|
1444
|
+
|
|
1438
1445
|
// Use the JS snapshot (taken at dragstart) — not DOM which has .dragging gaps
|
|
1439
|
-
const sourceStatus = allTasks().find(t => t.id ===
|
|
1440
|
-
|
|
1446
|
+
const sourceStatus = allTasks().find(t => String(t.id) === draggedIdStr)?.status || status;
|
|
1447
|
+
|
|
1448
|
+
// columnIds is already an array of strings (set in onDragStart)
|
|
1449
|
+
const columnIds = (dragColumnOrder[status] || []).filter(id => id !== draggedIdStr);
|
|
1441
1450
|
|
|
1442
1451
|
let newOrderedIds;
|
|
1443
|
-
if (!
|
|
1444
|
-
|
|
1452
|
+
if (!insertBeforeIdStr) {
|
|
1453
|
+
// Dropped at the end of the column
|
|
1454
|
+
newOrderedIds = [...columnIds, draggedIdStr];
|
|
1445
1455
|
} else {
|
|
1446
|
-
const insertIdx = columnIds.indexOf(
|
|
1456
|
+
const insertIdx = columnIds.indexOf(insertBeforeIdStr);
|
|
1447
1457
|
if (insertIdx === -1) {
|
|
1448
|
-
|
|
1458
|
+
// insertBeforeId not found in snapshot — fall back to appending at end
|
|
1459
|
+
newOrderedIds = [...columnIds, draggedIdStr];
|
|
1449
1460
|
} else {
|
|
1450
|
-
newOrderedIds = [...columnIds.slice(0, insertIdx),
|
|
1461
|
+
newOrderedIds = [...columnIds.slice(0, insertIdx), draggedIdStr, ...columnIds.slice(insertIdx)];
|
|
1451
1462
|
}
|
|
1452
1463
|
}
|
|
1453
1464
|
|
|
1454
1465
|
// Optimistic update in memory so re-render is instant
|
|
1455
|
-
const task = allTasks().find(t => t.id ===
|
|
1466
|
+
const task = allTasks().find(t => String(t.id) === draggedIdStr);
|
|
1456
1467
|
if (task) task.status = status;
|
|
1457
|
-
newOrderedIds.forEach((
|
|
1458
|
-
const t = allTasks().find(t => t.id ===
|
|
1468
|
+
newOrderedIds.forEach((idStr, i) => {
|
|
1469
|
+
const t = allTasks().find(t => String(t.id) === idStr);
|
|
1459
1470
|
if (t) t.priority_order = i + 1;
|
|
1460
1471
|
});
|
|
1461
1472
|
renderKanban();
|
|
@@ -1463,14 +1474,14 @@ async function onDrop(e, status) {
|
|
|
1463
1474
|
|
|
1464
1475
|
// Update status if column changed
|
|
1465
1476
|
if (sourceStatus !== status) {
|
|
1466
|
-
await fetch(`/api/tasks/${
|
|
1477
|
+
await fetch(`/api/tasks/${draggedIdStr}`, {
|
|
1467
1478
|
method: 'PATCH',
|
|
1468
1479
|
headers: { 'Content-Type': 'application/json' },
|
|
1469
1480
|
body: JSON.stringify({ status }),
|
|
1470
1481
|
});
|
|
1471
1482
|
}
|
|
1472
1483
|
|
|
1473
|
-
// Persist new order
|
|
1484
|
+
// Persist new order (send as-is — server handles string or numeric IDs)
|
|
1474
1485
|
await fetch('/api/tasks/reorder', {
|
|
1475
1486
|
method: 'POST',
|
|
1476
1487
|
headers: { 'Content-Type': 'application/json' },
|
package/dashboard/server.js
CHANGED
|
@@ -59,7 +59,11 @@ function broadcastExpoStatus() {
|
|
|
59
59
|
// ── SUPABASE REALTIME ─────────────────────────────────────────────────────────
|
|
60
60
|
supabase
|
|
61
61
|
.channel("cb_changes")
|
|
62
|
-
.on("postgres_changes", { event: "*", schema: "public", table: "cb_tasks" }, (p) =>
|
|
62
|
+
.on("postgres_changes", { event: "*", schema: "public", table: "cb_tasks" }, (p) => {
|
|
63
|
+
if (p.eventType === "UPDATE" && p.new) broadcast("task_update", p.new);
|
|
64
|
+
else if (p.eventType === "INSERT" && p.new) broadcast("task_added", p.new);
|
|
65
|
+
else if (p.eventType === "DELETE") broadcast("task_deleted", { id: p.old?.id });
|
|
66
|
+
})
|
|
63
67
|
.on("postgres_changes", { event: "*", schema: "public", table: "cb_logs" }, (p) => broadcast("log", p.new))
|
|
64
68
|
.subscribe();
|
|
65
69
|
|