@tekyzinc/gsd-t 2.71.13 → 2.71.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,682 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * GSD-T Workflow Orchestrator — Base Engine
5
+ *
6
+ * Abstract pipeline engine that runs deterministic, multi-phase workflows.
7
+ * Each phase: spawn Claude → measure → gate (human review) → feedback → next phase.
8
+ *
9
+ * Workflow definitions plug into this engine by providing:
10
+ * - phases: ordered list of phase names
11
+ * - discoverWork(projectDir): returns { [phase]: items[] }
12
+ * - buildPrompt(phase, items, previousResults, projectDir): returns string
13
+ * - measure(projectDir, phase, items, ports): returns measurements
14
+ * - buildQueueItem(phase, item, measurements): returns queue item object
15
+ * - processFeedback(projectDir, phase, items): returns { approved[], needsWork[] }
16
+ * - buildFixPrompt(phase, needsWork): returns string
17
+ * - guessPaths(phase, item): returns source path
18
+ * - formatSummary(phase, result): returns string for final report
19
+ *
20
+ * Usage:
21
+ * const { Orchestrator } = require("./orchestrator.js");
22
+ * const workflow = require("./workflows/design-build.js");
23
+ * new Orchestrator(workflow).run(process.argv.slice(2));
24
+ */
25
+
26
+ const fs = require("fs");
27
+ const path = require("path");
28
+ const { execFileSync, spawn: cpSpawn } = require("child_process");
29
+
30
+ // ─── ANSI Colors ────────────────────────────────────────────────────────────
31
+
32
+ const BOLD = "\x1b[1m";
33
+ const GREEN = "\x1b[32m";
34
+ const YELLOW = "\x1b[33m";
35
+ const RED = "\x1b[31m";
36
+ const CYAN = "\x1b[36m";
37
+ const DIM = "\x1b[2m";
38
+ const RESET = "\x1b[0m";
39
+
40
+ // ─── Helpers (exported for workflows) ───────────────────────────────────────
41
+
42
+ function log(msg) { console.log(msg); }
43
+ function heading(msg) { log(`\n${BOLD}${msg}${RESET}\n`); }
44
+ function success(msg) { log(`${GREEN} ✓${RESET} ${msg}`); }
45
+ function warn(msg) { log(`${YELLOW} ⚠${RESET} ${msg}`); }
46
+ function error(msg) { log(`${RED} ✗${RESET} ${msg}`); }
47
+ function info(msg) { log(`${CYAN} ℹ${RESET} ${msg}`); }
48
+ function dim(msg) { log(`${DIM} ${msg}${RESET}`); }
49
+
50
+ function ensureDir(dir) {
51
+ try { fs.mkdirSync(dir, { recursive: true }); } catch { /* exists */ }
52
+ }
53
+
54
+ function syncSleep(ms) {
55
+ try {
56
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
57
+ } catch {
58
+ try { execFileSync("sleep", [String(ms / 1000)], { stdio: "pipe" }); } catch { /* ignore */ }
59
+ }
60
+ }
61
+
62
+ function openBrowser(url) {
63
+ try {
64
+ if (process.platform === "darwin") {
65
+ cpSpawn("open", [url], { detached: true, stdio: "ignore" }).unref();
66
+ } else {
67
+ cpSpawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
68
+ }
69
+ } catch { /* user can open manually */ }
70
+ }
71
+
72
+ function isPortInUse(port) {
73
+ try {
74
+ execFileSync("curl", ["-sf", "-o", "/dev/null", `http://localhost:${port}`], {
75
+ timeout: 2000, stdio: "pipe"
76
+ });
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ // ─── Orchestrator Class ────────────────────────────────────────────────────
84
+
85
+ class Orchestrator {
86
+ /**
87
+ * @param {object} workflow — workflow definition object
88
+ * @param {string} workflow.name — display name (e.g., "Design Build")
89
+ * @param {string[]} workflow.phases — ordered phase names (e.g., ["elements", "widgets", "pages"])
90
+ * @param {object} [workflow.defaults] — default port/timeout overrides
91
+ * @param {string} [workflow.reviewDir] — review directory relative to project (default: ".gsd-t/design-review")
92
+ * @param {string} [workflow.stateFile] — state file relative to project
93
+ * @param {Function} workflow.discoverWork — (projectDir) => { [phase]: items[] }
94
+ * @param {Function} workflow.buildPrompt — (phase, items, prevResults, projectDir) => string
95
+ * @param {Function} [workflow.measure] — (projectDir, phase, items, ports) => measurements
96
+ * @param {Function} [workflow.buildQueueItem] — (phase, item, measurements) => queue item
97
+ * @param {Function} [workflow.processFeedback] — (projectDir, phase, items) => { approved[], needsWork[] }
98
+ * @param {Function} [workflow.buildFixPrompt] — (phase, needsWork) => string
99
+ * @param {Function} [workflow.guessPaths] — (phase, item) => sourcePath
100
+ * @param {Function} [workflow.formatSummary] — (phase, result) => string
101
+ * @param {Function} [workflow.parseArgs] — (argv, defaults) => opts (extend base arg parsing)
102
+ * @param {Function} [workflow.showUsage] — () => void (custom usage text)
103
+ * @param {Function} [workflow.startServers] — (projectDir, opts) => { pids[], devPort, reviewPort }
104
+ * @param {Function} [workflow.validate] — (projectDir) => void (pre-flight checks, may exit)
105
+ */
106
+ constructor(workflow) {
107
+ this.wf = workflow;
108
+ this.pids = [];
109
+ }
110
+
111
+ // ─── CLI ─────────────────────────────────────────────────────────────
112
+
113
+ parseBaseArgs(argv) {
114
+ const defaults = this.wf.defaults || {};
115
+ const opts = {
116
+ projectDir: process.cwd(),
117
+ resume: false,
118
+ startPhase: null,
119
+ devPort: defaults.devPort || 5173,
120
+ reviewPort: defaults.reviewPort || 3456,
121
+ timeout: defaults.timeout || 600_000,
122
+ skipMeasure: false,
123
+ };
124
+
125
+ for (let i = 0; i < argv.length; i++) {
126
+ switch (argv[i]) {
127
+ case "--resume": opts.resume = true; break;
128
+ case "--phase":
129
+ case "--tier": opts.startPhase = argv[++i]; break;
130
+ case "--project": opts.projectDir = path.resolve(argv[++i]); break;
131
+ case "--dev-port": opts.devPort = parseInt(argv[++i], 10); break;
132
+ case "--review-port": opts.reviewPort = parseInt(argv[++i], 10); break;
133
+ case "--timeout": opts.timeout = parseInt(argv[++i], 10) * 1000; break;
134
+ case "--skip-measure": opts.skipMeasure = true; break;
135
+ case "--help":
136
+ case "-h":
137
+ if (this.wf.showUsage) this.wf.showUsage();
138
+ else this._showDefaultUsage();
139
+ process.exit(0);
140
+ }
141
+ }
142
+
143
+ return opts;
144
+ }
145
+
146
+ _showDefaultUsage() {
147
+ log(`
148
+ ${BOLD}GSD-T ${this.wf.name} Orchestrator${RESET}
149
+
150
+ ${BOLD}Usage:${RESET}
151
+ gsd-t ${this.wf.command || "orchestrate"} [options]
152
+
153
+ ${BOLD}Options:${RESET}
154
+ --resume Resume from last saved state
155
+ --phase <name> Start from specific phase (${this.wf.phases.join(", ")})
156
+ --project <dir> Project directory (default: cwd)
157
+ --dev-port <N> Dev server port (default: ${this.wf.defaults?.devPort || 5173})
158
+ --review-port <N> Review server port (default: ${this.wf.defaults?.reviewPort || 3456})
159
+ --timeout <sec> Claude timeout per phase in seconds (default: 600)
160
+ --skip-measure Skip automated measurement (human-review only)
161
+ --help Show this help
162
+
163
+ ${BOLD}Phases:${RESET} ${this.wf.phases.join(" → ")}
164
+ `);
165
+ }
166
+
167
+ // ─── Claude ──────────────────────────────────────────────────────────
168
+
169
+ verifyClaude() {
170
+ try {
171
+ execFileSync("claude", ["--version"], {
172
+ encoding: "utf8", timeout: 5000,
173
+ stdio: ["pipe", "pipe", "pipe"],
174
+ });
175
+ return true;
176
+ } catch {
177
+ error("claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code");
178
+ return false;
179
+ }
180
+ }
181
+
182
+ spawnClaude(projectDir, prompt, timeout) {
183
+ const start = Date.now();
184
+ let output = "";
185
+ let exitCode = 0;
186
+
187
+ try {
188
+ output = execFileSync("claude", ["-p", prompt], {
189
+ encoding: "utf8",
190
+ timeout: timeout || this.wf.defaults?.timeout || 600_000,
191
+ stdio: ["pipe", "pipe", "pipe"],
192
+ cwd: projectDir,
193
+ maxBuffer: 10 * 1024 * 1024,
194
+ });
195
+ } catch (e) {
196
+ output = (e.stdout || "") + (e.stderr || "");
197
+ exitCode = e.status || 1;
198
+ if (e.killed) warn(`Claude timed out after ${(timeout || 600_000) / 1000}s`);
199
+ }
200
+
201
+ const duration = Math.round((Date.now() - start) / 1000);
202
+ return { output, exitCode, duration };
203
+ }
204
+
205
+ // ─── Server Management ───────────────────────────────────────────────
206
+
207
+ startDevServer(projectDir, port) {
208
+ if (isPortInUse(port)) {
209
+ success(`Dev server already running on port ${port}`);
210
+ return { pid: null, port, alreadyRunning: true };
211
+ }
212
+
213
+ const pkgPath = path.join(projectDir, "package.json");
214
+ if (!fs.existsSync(pkgPath)) {
215
+ error("No package.json found — cannot start dev server");
216
+ process.exit(1);
217
+ }
218
+
219
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
220
+ if (!pkg.scripts?.dev) {
221
+ error("No 'dev' script in package.json — cannot start dev server");
222
+ process.exit(1);
223
+ }
224
+
225
+ info("Starting dev server: npm run dev");
226
+ const child = cpSpawn("npm", ["run", "dev"], {
227
+ cwd: projectDir,
228
+ detached: true,
229
+ stdio: "ignore",
230
+ env: { ...process.env, PORT: String(port) },
231
+ });
232
+ child.unref();
233
+
234
+ const start = Date.now();
235
+ const timeout = this.wf.defaults?.devServerTimeout || 30_000;
236
+ while (Date.now() - start < timeout) {
237
+ if (isPortInUse(port)) {
238
+ success(`Dev server ready on port ${port} (PID: ${child.pid})`);
239
+ return { pid: child.pid, port, alreadyRunning: false };
240
+ }
241
+ syncSleep(1000);
242
+ }
243
+
244
+ error(`Dev server failed to start within ${timeout / 1000}s`);
245
+ process.exit(1);
246
+ }
247
+
248
+ startReviewServer(projectDir, devPort, reviewPort) {
249
+ if (isPortInUse(reviewPort)) {
250
+ success(`Review server already running on port ${reviewPort}`);
251
+ return { pid: null, port: reviewPort, alreadyRunning: true };
252
+ }
253
+
254
+ const pkgRoot = path.resolve(__dirname, "..");
255
+ const reviewScript = path.join(pkgRoot, "scripts", "gsd-t-design-review-server.js");
256
+ if (!fs.existsSync(reviewScript)) {
257
+ error(`Review server script not found: ${reviewScript}`);
258
+ process.exit(1);
259
+ }
260
+
261
+ info(`Starting review server on port ${reviewPort}`);
262
+ const child = cpSpawn("node", [
263
+ reviewScript,
264
+ "--port", String(reviewPort),
265
+ "--target", `http://localhost:${devPort}`,
266
+ "--project", projectDir,
267
+ ], {
268
+ cwd: projectDir,
269
+ detached: true,
270
+ stdio: "ignore",
271
+ });
272
+ child.unref();
273
+
274
+ const start = Date.now();
275
+ while (Date.now() - start < 10_000) {
276
+ if (isPortInUse(reviewPort)) {
277
+ success(`Review server ready on port ${reviewPort} (PID: ${child.pid})`);
278
+ return { pid: child.pid, port: reviewPort, alreadyRunning: false };
279
+ }
280
+ syncSleep(1000);
281
+ }
282
+
283
+ error("Review server failed to start within 10s");
284
+ process.exit(1);
285
+ }
286
+
287
+ // ─── Review Queue ────────────────────────────────────────────────────
288
+
289
+ getReviewDir(projectDir) {
290
+ return path.join(projectDir, this.wf.reviewDir || ".gsd-t/design-review");
291
+ }
292
+
293
+ writeQueueItem(projectDir, item) {
294
+ const queueDir = path.join(this.getReviewDir(projectDir), "queue");
295
+ ensureDir(queueDir);
296
+ fs.writeFileSync(path.join(queueDir, `${item.id}.json`), JSON.stringify(item, null, 2));
297
+ }
298
+
299
+ clearQueue(projectDir) {
300
+ const reviewDir = this.getReviewDir(projectDir);
301
+ for (const sub of ["queue", "feedback", "rejected"]) {
302
+ const dir = path.join(reviewDir, sub);
303
+ if (fs.existsSync(dir)) {
304
+ for (const f of fs.readdirSync(dir)) {
305
+ try { fs.unlinkSync(path.join(dir, f)); } catch { /* ignore */ }
306
+ }
307
+ }
308
+ }
309
+ // Remove review-complete signal
310
+ try { fs.unlinkSync(path.join(reviewDir, "review-complete.json")); } catch { /* ignore */ }
311
+ }
312
+
313
+ updateStatus(projectDir, phase, state) {
314
+ const statusPath = path.join(this.getReviewDir(projectDir), "status.json");
315
+ fs.writeFileSync(statusPath, JSON.stringify({
316
+ phase,
317
+ state,
318
+ startedAt: new Date().toISOString(),
319
+ }, null, 2));
320
+ }
321
+
322
+ queuePhaseItems(projectDir, phase, items, measurements) {
323
+ this.clearQueue(projectDir);
324
+ let order = 1;
325
+
326
+ for (const item of items) {
327
+ const queueItem = this.wf.buildQueueItem
328
+ ? this.wf.buildQueueItem(phase, item, measurements)
329
+ : this._defaultQueueItem(phase, item, measurements, order);
330
+ queueItem.order = order++;
331
+ this.writeQueueItem(projectDir, queueItem);
332
+ }
333
+
334
+ this.updateStatus(projectDir, phase, "review");
335
+ return order - 1;
336
+ }
337
+
338
+ _defaultQueueItem(phase, item, measurements, order) {
339
+ return {
340
+ id: `${phase}-${item.id}`,
341
+ name: item.name || item.id,
342
+ type: phase,
343
+ order,
344
+ selector: item.selector || `.${item.id}`,
345
+ sourcePath: item.sourcePath || "",
346
+ route: item.route || "/",
347
+ measurements: (measurements && measurements[item.id]) || [],
348
+ };
349
+ }
350
+
351
+ // ─── Review Gate ─────────────────────────────────────────────────────
352
+
353
+ waitForReview(projectDir, phase, queueCount, reviewPort) {
354
+ const signalPath = path.join(this.getReviewDir(projectDir), "review-complete.json");
355
+
356
+ heading(`⏸ Waiting for human review of ${phase}`);
357
+ log(` ${queueCount} items queued for review`);
358
+ log(` ${BOLD}Review UI:${RESET} http://localhost:${reviewPort}/review`);
359
+ log(` ${DIM}Submit your review in the browser to continue...${RESET}`);
360
+ log("");
361
+
362
+ openBrowser(`http://localhost:${reviewPort}/review`);
363
+
364
+ // IRONCLAD GATE — JavaScript polling loop
365
+ while (true) {
366
+ if (fs.existsSync(signalPath)) {
367
+ try {
368
+ const data = JSON.parse(fs.readFileSync(signalPath, "utf8"));
369
+ success("Review submitted!");
370
+ return data;
371
+ } catch { /* malformed — wait for rewrite */ }
372
+ }
373
+ syncSleep(3000);
374
+ }
375
+ }
376
+
377
+ // ─── Feedback ────────────────────────────────────────────────────────
378
+
379
+ defaultProcessFeedback(projectDir, phase, items) {
380
+ const fbDir = path.join(this.getReviewDir(projectDir), "feedback");
381
+ const approved = [];
382
+ const needsWork = [];
383
+
384
+ if (!fs.existsSync(fbDir)) {
385
+ return { approved: items.map(c => c.id), needsWork: [] };
386
+ }
387
+
388
+ const fbFiles = fs.readdirSync(fbDir).filter(f => f.endsWith(".json"));
389
+
390
+ for (const f of fbFiles) {
391
+ try {
392
+ const fb = JSON.parse(fs.readFileSync(path.join(fbDir, f), "utf8"));
393
+ if (fb.verdict === "approved" || (!fb.changes?.length && !fb.comment)) {
394
+ approved.push(fb.id);
395
+ } else {
396
+ needsWork.push(fb);
397
+ }
398
+ } catch { /* skip malformed */ }
399
+ }
400
+
401
+ // Items without feedback are approved by default
402
+ const fbIds = new Set([...approved, ...needsWork.map(w => w.id)]);
403
+ for (const item of items) {
404
+ const queueId = `${phase}-${item.id}`;
405
+ if (!fbIds.has(queueId) && !fbIds.has(item.id)) {
406
+ approved.push(item.id);
407
+ }
408
+ }
409
+
410
+ if (needsWork.length > 0) {
411
+ warn(`${needsWork.length} items need changes`);
412
+ for (const item of needsWork) {
413
+ dim(`${item.id}: ${item.comment || "property changes requested"}`);
414
+ }
415
+ }
416
+
417
+ return { approved, needsWork };
418
+ }
419
+
420
+ // ─── State Persistence ───────────────────────────────────────────────
421
+
422
+ getStatePath(projectDir) {
423
+ const stateFile = this.wf.stateFile || ".gsd-t/design-review/orchestrator-state.json";
424
+ return path.join(projectDir, stateFile);
425
+ }
426
+
427
+ saveState(projectDir, state) {
428
+ const statePath = this.getStatePath(projectDir);
429
+ ensureDir(path.dirname(statePath));
430
+ state.lastUpdated = new Date().toISOString();
431
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
432
+ }
433
+
434
+ loadState(projectDir) {
435
+ const statePath = this.getStatePath(projectDir);
436
+ if (!fs.existsSync(statePath)) return null;
437
+ try {
438
+ return JSON.parse(fs.readFileSync(statePath, "utf8"));
439
+ } catch {
440
+ return null;
441
+ }
442
+ }
443
+
444
+ // ─── Cleanup ─────────────────────────────────────────────────────────
445
+
446
+ cleanup(projectDir) {
447
+ const shutdownPath = path.join(this.getReviewDir(projectDir), "shutdown.json");
448
+ try {
449
+ fs.writeFileSync(shutdownPath, JSON.stringify({ shutdown: true, at: new Date().toISOString() }));
450
+ } catch { /* ignore */ }
451
+
452
+ for (const pid of this.pids) {
453
+ if (pid) {
454
+ try { process.kill(pid); } catch { /* already dead */ }
455
+ try { process.kill(-pid); } catch { /* ignore */ }
456
+ }
457
+ }
458
+ dim("Servers stopped");
459
+ }
460
+
461
+ // ─── Main Pipeline ──────────────────────────────────────────────────
462
+
463
+ run(argv) {
464
+ const opts = this.wf.parseArgs
465
+ ? this.wf.parseArgs(argv, this.parseBaseArgs.bind(this))
466
+ : this.parseBaseArgs(argv || []);
467
+
468
+ const { projectDir, resume, startPhase, devPort, reviewPort, skipMeasure } = opts;
469
+ const phases = this.wf.phases;
470
+ const maxReviewCycles = this.wf.defaults?.maxReviewCycles || 3;
471
+
472
+ heading(`GSD-T ${this.wf.name} Orchestrator`);
473
+ log(` Project: ${projectDir}`);
474
+ log(` Ports: dev=${devPort} review=${reviewPort}`);
475
+ log(` Phases: ${phases.join(" → ")}`);
476
+ log("");
477
+
478
+ // 1. Verify prerequisites
479
+ if (!this.verifyClaude()) process.exit(1);
480
+ if (this.wf.validate) this.wf.validate(projectDir);
481
+
482
+ // 2. Discover work items
483
+ info("Discovering work...");
484
+ const work = this.wf.discoverWork(projectDir);
485
+ const counts = phases.map(p => `${p}: ${(work[p] || []).length}`).join(", ");
486
+ success(`Found: ${counts}`);
487
+
488
+ // 3. Load/create state
489
+ let state;
490
+ if (resume) {
491
+ state = this.loadState(projectDir);
492
+ if (state) {
493
+ info(`Resuming from: ${state.currentPhase || "start"} (completed: ${state.completedPhases.join(", ") || "none"})`);
494
+ } else {
495
+ warn("No saved state found — starting fresh");
496
+ state = this._createState();
497
+ }
498
+ } else {
499
+ state = this._createState();
500
+ }
501
+
502
+ // 4. Start servers
503
+ heading("Starting Infrastructure");
504
+ if (this.wf.startServers) {
505
+ const serverInfo = this.wf.startServers(projectDir, opts, this);
506
+ this.pids = serverInfo.pids || [];
507
+ } else {
508
+ const devInfo = this.startDevServer(projectDir, devPort);
509
+ const reviewInfo = this.startReviewServer(projectDir, devPort, reviewPort);
510
+ this.pids = [devInfo.pid, reviewInfo.pid].filter(Boolean);
511
+ }
512
+
513
+ // Register cleanup on exit
514
+ process.on("SIGINT", () => { this.cleanup(projectDir); process.exit(0); });
515
+ process.on("SIGTERM", () => { this.cleanup(projectDir); process.exit(0); });
516
+
517
+ // 5. Determine starting phase
518
+ let startIdx = 0;
519
+ if (startPhase) {
520
+ startIdx = phases.indexOf(startPhase);
521
+ if (startIdx < 0) {
522
+ error(`Unknown phase: ${startPhase}. Use: ${phases.join(", ")}`);
523
+ this.cleanup(projectDir);
524
+ process.exit(1);
525
+ }
526
+ } else if (state.completedPhases.length > 0) {
527
+ const lastCompleted = state.completedPhases[state.completedPhases.length - 1];
528
+ startIdx = phases.indexOf(lastCompleted) + 1;
529
+ }
530
+
531
+ // 6. Phase loop — THE MAIN PIPELINE
532
+ for (let i = startIdx; i < phases.length; i++) {
533
+ const phase = phases[i];
534
+ const items = work[phase] || [];
535
+
536
+ if (items.length === 0) {
537
+ info(`No ${phase} items — skipping`);
538
+ state.completedPhases.push(phase);
539
+ continue;
540
+ }
541
+
542
+ heading(`Phase ${i + 1}/${phases.length}: ${phase} (${items.length} items)`);
543
+
544
+ state.currentPhase = phase;
545
+ this.saveState(projectDir, state);
546
+
547
+ // 6a. Collect results from previous phases
548
+ const prevResults = {};
549
+ for (let j = 0; j < i; j++) {
550
+ const prevPhase = phases[j];
551
+ if (state.phaseResults[prevPhase]) {
552
+ prevResults[prevPhase] = state.phaseResults[prevPhase];
553
+ }
554
+ }
555
+
556
+ // 6b. Spawn Claude for this phase
557
+ const prompt = this.wf.buildPrompt(phase, items, prevResults, projectDir);
558
+ log(`\n${CYAN} ⚙${RESET} Spawning Claude to build ${items.length} ${phase}...`);
559
+ dim(`Timeout: ${(opts.timeout || 600_000) / 1000}s`);
560
+
561
+ const buildResult = this.spawnClaude(projectDir, prompt, opts.timeout);
562
+ if (buildResult.exitCode === 0) {
563
+ success(`Claude finished building ${phase} in ${buildResult.duration}s`);
564
+ } else {
565
+ warn(`Claude exited with code ${buildResult.exitCode} after ${buildResult.duration}s`);
566
+ }
567
+
568
+ // 6c. Collect built paths
569
+ const builtPaths = items.map(item =>
570
+ item.sourcePath || (this.wf.guessPaths ? this.wf.guessPaths(phase, item) : "")
571
+ );
572
+
573
+ // 6d. Measure
574
+ let measurements = {};
575
+ if (!skipMeasure && this.wf.measure) {
576
+ measurements = this.wf.measure(projectDir, phase, items, { devPort, reviewPort }) || {};
577
+ }
578
+
579
+ // 6e. Review cycle
580
+ let reviewCycle = 0;
581
+ let allApproved = false;
582
+
583
+ while (reviewCycle < maxReviewCycles && !allApproved) {
584
+ const queueCount = this.queuePhaseItems(projectDir, phase, items, measurements);
585
+ this.waitForReview(projectDir, phase, queueCount, reviewPort);
586
+
587
+ const feedback = this.wf.processFeedback
588
+ ? this.wf.processFeedback(projectDir, phase, items)
589
+ : this.defaultProcessFeedback(projectDir, phase, items);
590
+
591
+ if (feedback.needsWork.length === 0) {
592
+ allApproved = true;
593
+ success(`All ${phase} approved!`);
594
+ } else {
595
+ reviewCycle++;
596
+ if (reviewCycle < maxReviewCycles) {
597
+ info(`Review cycle ${reviewCycle + 1}/${maxReviewCycles} — applying fixes...`);
598
+ const fixPrompt = this.wf.buildFixPrompt
599
+ ? this.wf.buildFixPrompt(phase, feedback.needsWork)
600
+ : this._defaultFixPrompt(phase, feedback.needsWork);
601
+ info(`Spawning Claude to apply ${feedback.needsWork.length} fixes...`);
602
+ const fixResult = this.spawnClaude(projectDir, fixPrompt, 120_000);
603
+ if (fixResult.exitCode === 0) success("Fixes applied");
604
+ else warn(`Fix attempt returned code ${fixResult.exitCode}`);
605
+ } else {
606
+ warn(`Max review cycles reached for ${phase} — proceeding with remaining issues`);
607
+ allApproved = true;
608
+ }
609
+ }
610
+ }
611
+
612
+ // 6f. Record phase completion
613
+ state.phaseResults[phase] = {
614
+ completed: true,
615
+ builtPaths,
616
+ reviewCycles: reviewCycle + 1,
617
+ completedAt: new Date().toISOString(),
618
+ };
619
+ state.completedPhases.push(phase);
620
+ this.clearQueue(projectDir);
621
+ this.saveState(projectDir, state);
622
+
623
+ success(`${phase} phase complete`);
624
+ }
625
+
626
+ // 7. Cleanup & report
627
+ heading(`${this.wf.name} Complete`);
628
+
629
+ for (const phase of phases) {
630
+ const result = state.phaseResults[phase];
631
+ if (result?.completed) {
632
+ const summary = this.wf.formatSummary
633
+ ? this.wf.formatSummary(phase, result)
634
+ : `${phase}: ${result.builtPaths.length} items built (${result.reviewCycles} review cycle${result.reviewCycles > 1 ? "s" : ""})`;
635
+ success(summary);
636
+ }
637
+ }
638
+
639
+ log("");
640
+ this.cleanup(projectDir);
641
+
642
+ // Remove state file on success
643
+ try { fs.unlinkSync(this.getStatePath(projectDir)); } catch { /* ignore */ }
644
+
645
+ success(this.wf.completionMessage || "All done. Run your app to verify: npm run dev");
646
+ }
647
+
648
+ _createState() {
649
+ return {
650
+ startedAt: new Date().toISOString(),
651
+ lastUpdated: new Date().toISOString(),
652
+ currentPhase: null,
653
+ completedPhases: [],
654
+ phaseResults: {},
655
+ };
656
+ }
657
+
658
+ _defaultFixPrompt(phase, needsWork) {
659
+ const fixes = needsWork.map(item => {
660
+ const parts = [`Fix ${item.id}:`];
661
+ if (item.changes?.length) {
662
+ for (const c of item.changes) {
663
+ parts.push(` - ${c.property}: change from ${c.oldValue} to ${c.newValue} in ${c.path || "the component file"}`);
664
+ }
665
+ }
666
+ if (item.comment) parts.push(` - Additional: ${item.comment}`);
667
+ return parts.join("\n");
668
+ }).join("\n\n");
669
+
670
+ return `Apply these specific fixes to ${phase} components:\n\n${fixes}\n\nApply the changes and EXIT. Do not rebuild anything else.`;
671
+ }
672
+ }
673
+
674
+ // ─── Exports ────────────────────────────────────────────────────────────────
675
+
676
+ module.exports = {
677
+ Orchestrator,
678
+ // Export helpers for workflow definitions to use
679
+ log, heading, success, warn, error, info, dim,
680
+ ensureDir, syncSleep, openBrowser, isPortInUse,
681
+ BOLD, GREEN, YELLOW, RED, CYAN, DIM, RESET,
682
+ };