@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.
- package/CHANGELOG.md +16 -0
- package/bin/design-orchestrator.js +382 -0
- package/bin/gsd-t.js +6 -0
- package/bin/orchestrator.js +682 -0
- package/commands/gsd-t-design-build.md +30 -373
- package/package.json +1 -1
|
@@ -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
|
+
};
|