@tekyzinc/gsd-t 2.71.12 → 2.71.14
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-decompose.md +4 -4
- package/package.json +1 -1
- package/templates/CLAUDE-global.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [2.71.14] - 2026-04-08
|
|
6
|
+
|
|
7
|
+
### Added (design-build orchestrator)
|
|
8
|
+
- **Abstract workflow orchestrator** (`bin/orchestrator.js`) — base engine for deterministic multi-phase pipelines. Handles Claude spawning, review queue management, ironclad JS polling gates, state persistence/resume, server lifecycle, and cleanup. Workflow definitions plug in via a simple interface (phases, prompts, measurement, feedback). Zero external dependencies.
|
|
9
|
+
- **Design-build workflow** (`bin/design-orchestrator.js`) — first workflow implementation: elements → widgets → pages. Discovers contracts from `.gsd-t/contracts/design/`, builds per-tier Claude prompts, Playwright measurement, and review queue items. Plugs into the base orchestrator.
|
|
10
|
+
- **CLI subcommand** — `gsd-t design-build [--resume] [--tier] [--dev-port] [--review-port]` delegates to the orchestrator. Integrated into `bin/gsd-t.js` help and switch statement.
|
|
11
|
+
- **Resume capability** — orchestrator persists state to `orchestrator-state.json`, supports `--resume` to continue from where it left off after interruption.
|
|
12
|
+
|
|
13
|
+
### Why
|
|
14
|
+
Three separate attempts to enforce review gates via prompt instructions all failed — Claude Code agents optimize for task completion and skip any instruction to pause indefinitely. The orchestrator moves flow control out of prompts entirely into deterministic JavaScript.
|
|
15
|
+
|
|
16
|
+
## [2.71.13] - 2026-04-08
|
|
17
|
+
|
|
18
|
+
### Fixed (design-decompose — successor hint)
|
|
19
|
+
- **Next Up points to design-build** — `design-decompose` was recommending `partition` as the next step. The natural successor after decomposing contracts is `design-build` (which handles the tiered build with review gates), not `partition`. Updated the command's Step 9 hint and added `design-decompose → design-build` to the successor mapping table in CLAUDE-global template and live CLAUDE.md.
|
|
20
|
+
|
|
5
21
|
## [2.71.12] - 2026-04-08
|
|
6
22
|
|
|
7
23
|
### Changed (smart router — design-to-code pipeline)
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GSD-T Design Build Workflow
|
|
5
|
+
*
|
|
6
|
+
* Workflow definition for the design-build pipeline (elements → widgets → pages).
|
|
7
|
+
* Plugs into the base Orchestrator engine for deterministic flow control.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* gsd-t design-build [--resume] [--tier elements|widgets|pages] [--dev-port N] [--review-port N]
|
|
11
|
+
*
|
|
12
|
+
* Or directly:
|
|
13
|
+
* node bin/design-orchestrator.js [options]
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const { execFileSync } = require("child_process");
|
|
19
|
+
const { Orchestrator, info, warn, success, error, log, BOLD, RESET, DIM } = require("./orchestrator.js");
|
|
20
|
+
|
|
21
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const PHASES = ["elements", "widgets", "pages"];
|
|
24
|
+
const PHASE_SINGULAR = { elements: "element", widgets: "widget", pages: "page" };
|
|
25
|
+
const CONTRACTS_DIR = ".gsd-t/contracts/design";
|
|
26
|
+
|
|
27
|
+
// ─── Contract Discovery ─────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function discoverWork(projectDir) {
|
|
30
|
+
const contractsDir = path.join(projectDir, CONTRACTS_DIR);
|
|
31
|
+
const indexPath = path.join(contractsDir, "INDEX.md");
|
|
32
|
+
|
|
33
|
+
if (!fs.existsSync(indexPath)) {
|
|
34
|
+
error("No design contracts found. Run /user:gsd-t-design-decompose first.");
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const result = { elements: [], widgets: [], pages: [] };
|
|
39
|
+
const indexContent = fs.readFileSync(indexPath, "utf8");
|
|
40
|
+
const linkRegex = /^\s*-\s+\[([^\]]+)\]\(([^)]+)\)/gm;
|
|
41
|
+
let match;
|
|
42
|
+
|
|
43
|
+
while ((match = linkRegex.exec(indexContent)) !== null) {
|
|
44
|
+
const name = match[1];
|
|
45
|
+
const relPath = match[2];
|
|
46
|
+
const fullPath = path.join(contractsDir, relPath);
|
|
47
|
+
|
|
48
|
+
if (!fs.existsSync(fullPath)) {
|
|
49
|
+
warn(`Contract file missing: ${relPath}`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let phase = null;
|
|
54
|
+
for (const p of PHASES) {
|
|
55
|
+
if (relPath.startsWith(`${p}/`)) { phase = p; break; }
|
|
56
|
+
}
|
|
57
|
+
if (!phase) continue;
|
|
58
|
+
|
|
59
|
+
const contract = parseContract(fullPath, name);
|
|
60
|
+
contract.contractPath = relPath;
|
|
61
|
+
contract.fullContractPath = fullPath;
|
|
62
|
+
result[phase].push(contract);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseContract(filePath, fallbackName) {
|
|
69
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
70
|
+
const contract = {
|
|
71
|
+
id: fallbackName,
|
|
72
|
+
name: fallbackName,
|
|
73
|
+
sourcePath: null,
|
|
74
|
+
selector: null,
|
|
75
|
+
route: "/",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const nameMatch = content.match(/\|\s*(?:element|widget|page)\s*\|\s*(.+?)\s*\|/i);
|
|
79
|
+
if (nameMatch) contract.id = nameMatch[1].trim();
|
|
80
|
+
|
|
81
|
+
const sourceMatch = content.match(/\|\s*source_path\s*\|\s*(.+?)\s*\|/i);
|
|
82
|
+
if (sourceMatch) contract.sourcePath = sourceMatch[1].trim();
|
|
83
|
+
|
|
84
|
+
const selectorMatch = content.match(/\|\s*selector\s*\|\s*(.+?)\s*\|/i);
|
|
85
|
+
if (selectorMatch) contract.selector = selectorMatch[1].trim();
|
|
86
|
+
|
|
87
|
+
const routeMatch = content.match(/\|\s*route\s*\|\s*(.+?)\s*\|/i);
|
|
88
|
+
if (routeMatch) contract.route = routeMatch[1].trim();
|
|
89
|
+
|
|
90
|
+
contract.componentName = contract.id
|
|
91
|
+
.split("-")
|
|
92
|
+
.map(s => s.charAt(0).toUpperCase() + s.slice(1))
|
|
93
|
+
.join("");
|
|
94
|
+
|
|
95
|
+
return contract;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Prompt Building ────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
function buildPrompt(phase, items, prevResults, projectDir) {
|
|
101
|
+
const singular = PHASE_SINGULAR[phase];
|
|
102
|
+
const contractList = items.map(c => {
|
|
103
|
+
const sourcePath = c.sourcePath || guessPaths(phase, c);
|
|
104
|
+
return `- ${c.componentName}: read contract at ${c.fullContractPath}, write to ${sourcePath}`;
|
|
105
|
+
}).join("\n");
|
|
106
|
+
|
|
107
|
+
const prevPaths = [];
|
|
108
|
+
for (const [, result] of Object.entries(prevResults)) {
|
|
109
|
+
if (result.builtPaths) prevPaths.push(...result.builtPaths);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const importInstructions = prevPaths.length > 0
|
|
113
|
+
? `\n## Imports from Previous Tier\nImport these already-built components — do NOT rebuild their functionality inline:\n${prevPaths.map(p => `- ${p}`).join("\n")}\n`
|
|
114
|
+
: "";
|
|
115
|
+
|
|
116
|
+
return `You are building ${singular} components for a Vue 3 + TypeScript project.
|
|
117
|
+
|
|
118
|
+
## Task
|
|
119
|
+
Build ONLY the following ${items.length} ${phase} components from their design contracts.
|
|
120
|
+
Read each contract file for exact visual specs — do NOT approximate values.
|
|
121
|
+
|
|
122
|
+
## Components to Build
|
|
123
|
+
${contractList}
|
|
124
|
+
|
|
125
|
+
${importInstructions}
|
|
126
|
+
## Rules
|
|
127
|
+
- Read each contract file (the full path is given above) for exact property values
|
|
128
|
+
- Write components to the specified source paths
|
|
129
|
+
- Follow the project's existing code conventions (check existing files in src/)
|
|
130
|
+
- Use the project's existing dependencies (check package.json)
|
|
131
|
+
- When ALL ${items.length} components are complete, STOP. Do not start a dev server, do not ask for review, do not build components from other tiers.
|
|
132
|
+
|
|
133
|
+
## Important
|
|
134
|
+
This is a FINITE task. Build the ${items.length} ${phase} listed above, then EXIT.`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Measurement ────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
function hasPlaywright(projectDir) {
|
|
140
|
+
try {
|
|
141
|
+
return fs.existsSync(path.join(projectDir, "node_modules", "@playwright", "test", "package.json"));
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function measure(projectDir, phase, items, ports) {
|
|
148
|
+
if (!hasPlaywright(projectDir)) {
|
|
149
|
+
warn("Playwright not found — skipping automated measurement. Human review only.");
|
|
150
|
+
const results = {};
|
|
151
|
+
for (const c of items) results[c.id] = [];
|
|
152
|
+
return results;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
info(`Measuring ${items.length} ${phase} with Playwright...`);
|
|
156
|
+
|
|
157
|
+
const measureScript = buildMeasureScript(items, ports.reviewPort);
|
|
158
|
+
const scriptDir = path.join(projectDir, ".gsd-t", "design-review");
|
|
159
|
+
try { fs.mkdirSync(scriptDir, { recursive: true }); } catch { /* exists */ }
|
|
160
|
+
const scriptPath = path.join(scriptDir, "_measure.mjs");
|
|
161
|
+
fs.writeFileSync(scriptPath, measureScript);
|
|
162
|
+
|
|
163
|
+
const results = {};
|
|
164
|
+
try {
|
|
165
|
+
const output = execFileSync("node", [scriptPath], {
|
|
166
|
+
encoding: "utf8",
|
|
167
|
+
timeout: 60_000,
|
|
168
|
+
cwd: projectDir,
|
|
169
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
170
|
+
});
|
|
171
|
+
try {
|
|
172
|
+
const parsed = JSON.parse(output.trim());
|
|
173
|
+
for (const [id, measurements] of Object.entries(parsed)) {
|
|
174
|
+
results[id] = measurements;
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
warn("Could not parse measurement output — proceeding without measurements");
|
|
178
|
+
}
|
|
179
|
+
} catch (e) {
|
|
180
|
+
warn(`Measurement failed: ${(e.message || "").slice(0, 100)}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try { fs.unlinkSync(scriptPath); } catch { /* ignore */ }
|
|
184
|
+
|
|
185
|
+
let passing = 0, failing = 0;
|
|
186
|
+
for (const measurements of Object.values(results)) {
|
|
187
|
+
for (const m of measurements) {
|
|
188
|
+
if (m.pass) passing++; else failing++;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (failing > 0) warn(`Measurements: ${passing} pass, ${failing} fail`);
|
|
192
|
+
else if (passing > 0) success(`Measurements: ${passing} pass, 0 fail`);
|
|
193
|
+
|
|
194
|
+
return results;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function buildMeasureScript(items, reviewPort) {
|
|
198
|
+
const selectors = items
|
|
199
|
+
.filter(c => c.selector)
|
|
200
|
+
.map(c => ` "${c.id}": "${c.selector}"`)
|
|
201
|
+
.join(",\n");
|
|
202
|
+
|
|
203
|
+
return `
|
|
204
|
+
import { chromium } from "playwright";
|
|
205
|
+
|
|
206
|
+
const SELECTORS = {
|
|
207
|
+
${selectors}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
async function measure() {
|
|
211
|
+
const browser = await chromium.launch({ headless: true });
|
|
212
|
+
const page = await browser.newPage();
|
|
213
|
+
await page.setViewportSize({ width: 1440, height: 900 });
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
await page.goto("http://localhost:${reviewPort}/", { waitUntil: "networkidle", timeout: 15000 });
|
|
217
|
+
} catch {
|
|
218
|
+
await page.goto("http://localhost:${reviewPort}/", { waitUntil: "load", timeout: 10000 });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
await page.waitForTimeout(2000);
|
|
222
|
+
|
|
223
|
+
const results = {};
|
|
224
|
+
|
|
225
|
+
for (const [id, selector] of Object.entries(SELECTORS)) {
|
|
226
|
+
try {
|
|
227
|
+
const measurements = await page.evaluate((sel) => {
|
|
228
|
+
const el = document.querySelector(sel);
|
|
229
|
+
if (!el) return [{ property: "element exists", expected: "yes", actual: "no", pass: false, severity: "critical" }];
|
|
230
|
+
|
|
231
|
+
const s = getComputedStyle(el);
|
|
232
|
+
const rect = el.getBoundingClientRect();
|
|
233
|
+
|
|
234
|
+
return [
|
|
235
|
+
{ property: "element exists", expected: "yes", actual: "yes", pass: true },
|
|
236
|
+
{ property: "display", expected: "visible", actual: s.display === "none" ? "hidden" : "visible", pass: s.display !== "none" },
|
|
237
|
+
{ property: "width", expected: ">0", actual: String(Math.round(rect.width)) + "px", pass: rect.width > 0 },
|
|
238
|
+
{ property: "height", expected: ">0", actual: String(Math.round(rect.height)) + "px", pass: rect.height > 0 },
|
|
239
|
+
];
|
|
240
|
+
}, selector);
|
|
241
|
+
|
|
242
|
+
results[id] = measurements;
|
|
243
|
+
} catch {
|
|
244
|
+
results[id] = [{ property: "measurement", expected: "success", actual: "error", pass: false, severity: "critical" }];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await browser.close();
|
|
249
|
+
process.stdout.write(JSON.stringify(results));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
measure().catch(e => {
|
|
253
|
+
process.stderr.write(e.message);
|
|
254
|
+
process.exit(1);
|
|
255
|
+
});
|
|
256
|
+
`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Queue Items ────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
function buildQueueItem(phase, item, measurements) {
|
|
262
|
+
const singular = PHASE_SINGULAR[phase];
|
|
263
|
+
const sourcePath = item.sourcePath || guessPaths(phase, item);
|
|
264
|
+
return {
|
|
265
|
+
id: `${singular}-${item.id}`,
|
|
266
|
+
name: item.componentName,
|
|
267
|
+
type: singular,
|
|
268
|
+
selector: item.selector || `.${item.id}`,
|
|
269
|
+
sourcePath,
|
|
270
|
+
route: item.route || "/",
|
|
271
|
+
measurements: (measurements && measurements[item.id]) || [],
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ─── Path Guessing ──────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
function guessPaths(phase, item) {
|
|
278
|
+
const dirMap = {
|
|
279
|
+
elements: "src/components/elements",
|
|
280
|
+
widgets: "src/components/widgets",
|
|
281
|
+
pages: "src/views",
|
|
282
|
+
};
|
|
283
|
+
return `${dirMap[phase]}/${item.componentName}.vue`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── Fix Prompt ─────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
function buildFixPrompt(phase, needsWork) {
|
|
289
|
+
const fixes = needsWork.map(item => {
|
|
290
|
+
const parts = [`Fix ${item.id}:`];
|
|
291
|
+
if (item.changes?.length) {
|
|
292
|
+
for (const c of item.changes) {
|
|
293
|
+
parts.push(` - ${c.property}: change from ${c.oldValue} to ${c.newValue} in ${c.path || "the component file"}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (item.comment) parts.push(` - Additional: ${item.comment}`);
|
|
297
|
+
return parts.join("\n");
|
|
298
|
+
}).join("\n\n");
|
|
299
|
+
|
|
300
|
+
return `Apply these specific fixes to ${phase} components:\n\n${fixes}\n\nApply the changes and EXIT. Do not rebuild anything else.`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ─── Summary ────────────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
function formatSummary(phase, result) {
|
|
306
|
+
return `${phase}: ${result.builtPaths.length} components built (${result.reviewCycles} review cycle${result.reviewCycles > 1 ? "s" : ""})`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ─── Usage ──────────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
function showUsage() {
|
|
312
|
+
log(`
|
|
313
|
+
${BOLD}GSD-T Design Build Orchestrator${RESET}
|
|
314
|
+
|
|
315
|
+
${BOLD}Usage:${RESET}
|
|
316
|
+
gsd-t design-build [options]
|
|
317
|
+
|
|
318
|
+
${BOLD}Options:${RESET}
|
|
319
|
+
--resume Resume from last saved state
|
|
320
|
+
--tier <name> Start from specific tier (elements, widgets, pages)
|
|
321
|
+
--project <dir> Project directory (default: cwd)
|
|
322
|
+
--dev-port <N> Dev server port (default: 5173)
|
|
323
|
+
--review-port <N> Review server port (default: 3456)
|
|
324
|
+
--timeout <sec> Claude timeout per tier in seconds (default: 600)
|
|
325
|
+
--skip-measure Skip Playwright measurement (human-review only)
|
|
326
|
+
--help Show this help
|
|
327
|
+
|
|
328
|
+
${BOLD}Pipeline:${RESET}
|
|
329
|
+
1. Read contracts from .gsd-t/contracts/design/
|
|
330
|
+
2. Start dev server + review server
|
|
331
|
+
3. For each tier (elements → widgets → pages):
|
|
332
|
+
a. Spawn Claude to build components
|
|
333
|
+
b. Measure with Playwright
|
|
334
|
+
c. Queue for human review
|
|
335
|
+
d. Wait for review submission (blocks until human approves)
|
|
336
|
+
e. Process feedback, proceed to next tier
|
|
337
|
+
`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ─── Workflow Definition ────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
const designBuildWorkflow = {
|
|
343
|
+
name: "Design Build",
|
|
344
|
+
command: "design-build",
|
|
345
|
+
phases: PHASES,
|
|
346
|
+
reviewDir: ".gsd-t/design-review",
|
|
347
|
+
stateFile: ".gsd-t/design-review/orchestrator-state.json",
|
|
348
|
+
defaults: {
|
|
349
|
+
devPort: 5173,
|
|
350
|
+
reviewPort: 3456,
|
|
351
|
+
timeout: 600_000,
|
|
352
|
+
devServerTimeout: 30_000,
|
|
353
|
+
maxReviewCycles: 3,
|
|
354
|
+
},
|
|
355
|
+
completionMessage: "All done. Run your app to verify: npm run dev",
|
|
356
|
+
|
|
357
|
+
discoverWork,
|
|
358
|
+
buildPrompt,
|
|
359
|
+
measure,
|
|
360
|
+
buildQueueItem,
|
|
361
|
+
buildFixPrompt,
|
|
362
|
+
guessPaths,
|
|
363
|
+
formatSummary,
|
|
364
|
+
showUsage,
|
|
365
|
+
|
|
366
|
+
// Use --tier as alias for --phase (design-build convention)
|
|
367
|
+
parseArgs(argv, parseBase) {
|
|
368
|
+
return parseBase(argv);
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// ─── Entry Point ────────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
function run(args) {
|
|
375
|
+
new Orchestrator(designBuildWorkflow).run(args || []);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (require.main === module) {
|
|
379
|
+
run(process.argv.slice(2));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
module.exports = { run, workflow: designBuildWorkflow };
|
package/bin/gsd-t.js
CHANGED
|
@@ -2562,6 +2562,7 @@ function showHelp() {
|
|
|
2562
2562
|
log(` ${CYAN}changelog${RESET} Open changelog in the browser`);
|
|
2563
2563
|
log(` ${CYAN}graph${RESET} Code graph operations (index, status, query)`);
|
|
2564
2564
|
log(` ${CYAN}headless${RESET} Non-interactive execution via claude -p + fast state queries`);
|
|
2565
|
+
log(` ${CYAN}design-build${RESET} Deterministic design→code pipeline (elements → widgets → pages)`);
|
|
2565
2566
|
log(` ${CYAN}help${RESET} Show this help\n`);
|
|
2566
2567
|
log(`${BOLD}Examples:${RESET}`);
|
|
2567
2568
|
log(` ${DIM}$${RESET} npx @tekyzinc/gsd-t install`);
|
|
@@ -2696,6 +2697,11 @@ if (require.main === module) {
|
|
|
2696
2697
|
case "headless":
|
|
2697
2698
|
doHeadless(args.slice(1));
|
|
2698
2699
|
break;
|
|
2700
|
+
case "design-build": {
|
|
2701
|
+
const orchestrator = require("./design-orchestrator.js");
|
|
2702
|
+
orchestrator.run(args.slice(1));
|
|
2703
|
+
break;
|
|
2704
|
+
}
|
|
2699
2705
|
case "scan": {
|
|
2700
2706
|
const exportFlag = args.find(a => a.startsWith('--export='));
|
|
2701
2707
|
const exportFormat = exportFlag ? exportFlag.split('=')[1] : null;
|
|
@@ -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
|
+
};
|
|
@@ -468,13 +468,13 @@ Display:
|
|
|
468
468
|
|
|
469
469
|
## ▶ Next Up
|
|
470
470
|
|
|
471
|
-
**
|
|
471
|
+
**Design Build** — build UI from contracts with tiered review gates (elements → widgets → pages)
|
|
472
472
|
|
|
473
|
-
`/user:gsd-t-
|
|
473
|
+
`/user:gsd-t-design-build`
|
|
474
474
|
|
|
475
475
|
**Also available:**
|
|
476
|
-
- `/user:gsd-t-
|
|
477
|
-
- `/user:gsd-t-plan` —
|
|
476
|
+
- `/user:gsd-t-partition` — if you need domain boundaries before building
|
|
477
|
+
- `/user:gsd-t-plan` — if you need task lists before building
|
|
478
478
|
|
|
479
479
|
───────────────────────────────────────────────────────────────
|
|
480
480
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "2.71.
|
|
3
|
+
"version": "2.71.14",
|
|
4
4
|
"description": "GSD-T: Contract-Driven Development for Claude Code — 56 slash commands with headless CI/CD mode, graph-powered code analysis, real-time agent dashboard, execution intelligence, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
|
|
5
5
|
"author": "Tekyz, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -567,6 +567,7 @@ Successor mapping:
|
|
|
567
567
|
| `gap-analysis` | `milestone` | `feature` |
|
|
568
568
|
| `populate` | `status` | |
|
|
569
569
|
| `setup` | `status` | |
|
|
570
|
+
| `design-decompose` | `design-build` | `partition` (if domains needed first) |
|
|
570
571
|
|
|
571
572
|
Commands with no successor (standalone): `quick`, `debug`, `brainstorm`, `status`, `help`, `resume`, `prompt`, `log`, `health`, `pause`, backlog commands.
|
|
572
573
|
|