@tekyzinc/gsd-t 2.71.13 → 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 CHANGED
@@ -2,6 +2,17 @@
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
+
5
16
  ## [2.71.13] - 2026-04-08
6
17
 
7
18
  ### Fixed (design-decompose — successor hint)
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "2.71.13",
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",