@tekyzinc/gsd-t 3.21.11 → 3.22.10
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 +67 -0
- package/README.md +1 -0
- package/bin/gsd-t.js +350 -17
- package/bin/headless-auto-spawn.cjs +205 -19
- package/bin/playwright-bootstrap.cjs +315 -0
- package/bin/ui-detection.cjs +151 -0
- package/commands/gsd-t-init.md +17 -19
- package/docs/architecture.md +16 -2
- package/docs/requirements.md +27 -0
- package/package.json +6 -1
- package/scripts/gsd-t-dashboard-server.js +137 -7
- package/scripts/hooks/pre-commit-playwright-gate +94 -0
- package/templates/CLAUDE-global.md +11 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,73 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [3.22.10] — M50 Universal Playwright Bootstrap + Deterministic UI Enforcement
|
|
6
|
+
|
|
7
|
+
### Added — three deterministic enforcement layers replace prose-only Playwright Readiness Guard
|
|
8
|
+
|
|
9
|
+
Closes the gap that allowed M48 viewer fixes to ship without Playwright tests despite the existing prose Readiness Guard. Prior pattern was prose in `~/.claude/CLAUDE.md` + per-command-file reminders that agents could read and decide to skip. M50 converts every layer to executable code so agents cannot self-approve their way around it.
|
|
10
|
+
|
|
11
|
+
**Layer 1 — Bootstrap library (D1):**
|
|
12
|
+
- `bin/playwright-bootstrap.cjs` (new) — `hasPlaywright`, `detectPackageManager`, `installPlaywright`, `installPlaywrightSync`, `verifyPlaywrightHealth`. Idempotent installer: detects package manager (npm/pnpm/yarn/bun), installs `@playwright/test` + chromium, writes `playwright.config.ts` from a single-source template (contract §6), scaffolds `e2e/__placeholder.spec.ts`. Preserves existing config + e2e contents on re-run. Error classifier maps subprocess stderr to `{package-manager-not-found, network, chromium, disk}` with caller-actionable hints; chromium-failure surfaces `partial: true`. Zero external runtime deps.
|
|
13
|
+
- `bin/ui-detection.cjs` (new) — `hasUI`, `detectUIFlavor`. Synchronous, depth-bounded ≤3 BFS, never throws. Detects React/Vue/Svelte/Next/Angular/Flutter/Tailwind via `package.json`, `pubspec.yaml`, `tailwind.config.{js,ts,mjs,cjs}`, or any UI extension (`.tsx`/`.jsx`/`.vue`/`.svelte`/`.css`/`.scss`).
|
|
14
|
+
- `bin/gsd-t.js`: inline `hasPlaywright` (was lines 201-204) replaced with `require('./playwright-bootstrap.cjs')`. `init` flow auto-installs Playwright when `hasUI && !hasPlaywright`. `update-all` auto-installs across all registered UI projects + reports counts (`Auto-installed Playwright in: N project(s)`). New `gsd-t setup-playwright [path] [--force]` subcommand. New `gsd-t doctor --install-playwright` flag. New `gsd-t doctor --install-hooks` flag (D2).
|
|
15
|
+
|
|
16
|
+
**Layer 2 — Spawn-time gate (D2):**
|
|
17
|
+
- `bin/headless-auto-spawn.cjs::autoSpawnHeadless()`: new gate runs before the spawn. When the command is in the `TESTING_OR_UI_COMMANDS` whitelist (9 commands: execute, test-sync, verify, quick, wave, milestone, complete-milestone, debug, integrate) AND `hasUI(projectDir)` AND `!hasPlaywright(projectDir)`, the gate calls `installPlaywrightSync(projectDir)` synchronously. On install failure, writes `mode: 'blocked-needs-human'` + `reason: 'playwright-install-failed'` to the headless session-state file and exits 4. Hot path: three filesystem checks; no install attempt when gate doesn't fire.
|
|
18
|
+
|
|
19
|
+
**Layer 3 — Commit-time gate (D2):**
|
|
20
|
+
- `scripts/hooks/pre-commit-playwright-gate` (new, executable bash) — opt-in via `gsd-t doctor --install-hooks`. Reads `.gsd-t/.last-playwright-pass` (Unix epoch ms in a single line). Detects staged viewer-source files (`scripts/gsd-t-transcript.html`, `scripts/gsd-t-dashboard-server.js`, `e2e/viewer/**`); if any file's mtime exceeds the last-pass timestamp, blocks the commit (exit 1 + clear stderr message). Fails open on missing/corrupt timestamps — broken hook is worse than a permissive one.
|
|
21
|
+
|
|
22
|
+
**E2E specs delivered (the M47/M48/M49 viewer specs we owed):**
|
|
23
|
+
- `playwright.config.ts` (root) — testDir `./e2e`, chromium project, `webServer` omitted (specs manage their own server lifecycle).
|
|
24
|
+
- `e2e/viewer/title.spec.ts` — M48 Bug 1 regression (project basename in `<title>` + header `.title` for `/transcripts` and `/transcripts/{spawn-id}`).
|
|
25
|
+
- `e2e/viewer/timestamps.spec.ts` — M48 Bug 2 regression (per-frame `frame.ts`, not per-batch `new Date()`).
|
|
26
|
+
- `e2e/viewer/chat-bubbles.spec.ts` — M48 Bug 3 regression (`user_turn`/`assistant_turn`/`session_start`/`tool_use_line` render as bubbles, not JSON.stringify dumps).
|
|
27
|
+
- `e2e/viewer/dual-pane.spec.ts` — M48 Bug 4 regression (bottom pane never connects to in-session-* SSE).
|
|
28
|
+
- `e2e/viewer/lazy-dashboard.spec.ts` — M49 banner regression (file-path + visualize hint when no dashboard; URL when alive).
|
|
29
|
+
|
|
30
|
+
**Tests:**
|
|
31
|
+
- `test/m50-d1-ui-detection.test.js` — 18 unit tests (8 mandatory fixtures + 4 hardening + 6 Red Team regressions).
|
|
32
|
+
- `test/m50-d1-playwright-bootstrap.test.js` — 20 unit tests (`hasPlaywright`, `detectPackageManager`, `verifyPlaywrightHealth`, 9 install-path branches).
|
|
33
|
+
- `test/m50-d1-cli-integration.test.js` — 5 CLI wire-up tests (re-export, init gate, doctor flag, setup-playwright).
|
|
34
|
+
- `test/m50-d2-viewer-specs-smoke.test.js` — 4 meta-tests (config + scripts + zero-runtime-dep invariant).
|
|
35
|
+
- `test/m50-d2-spawn-gate.test.js` — 9 gate-firing matrix tests (whitelist + 5 firing scenarios + hot-path overhead).
|
|
36
|
+
- `test/m50-d2-pre-commit-hook.test.js` — 6 hook-behavior tests (clean / blocked / fresh / missing / corrupt / e2e/viewer pattern).
|
|
37
|
+
|
|
38
|
+
Total: 62 new M50 tests; full suite 2163/2166 (3 pre-existing env-flakes preserved). Zero regressions.
|
|
39
|
+
|
|
40
|
+
**Doc-ripple:**
|
|
41
|
+
- `~/.claude/CLAUDE.md` § Playwright Readiness Guard — collapsed to a layered referral.
|
|
42
|
+
- `templates/CLAUDE-global.md` — mirror.
|
|
43
|
+
- `commands/gsd-t-init.md` Step 11 — points at `installPlaywright()` instead of carrying inline package-manager commands.
|
|
44
|
+
- `docs/architecture.md` — new "Playwright Deterministic Enforcement (M50)" subsection.
|
|
45
|
+
- `.gsd-t/contracts/playwright-bootstrap-contract.md` v1.0.0 — new contract.
|
|
46
|
+
- `.gsd-t/contracts/m50-integration-points.md` — D1→D2 checkpoint flipped to PUBLISHED.
|
|
47
|
+
|
|
48
|
+
## [3.21.12] - 2026-05-06
|
|
49
|
+
|
|
50
|
+
### Fixed — dashboard orphan accumulation (M49 lazy autostart + idle-TTL + doctor-prune)
|
|
51
|
+
|
|
52
|
+
88 dead `gsd-t-dashboard-server.js` processes accumulated under v3.21.11 (164 under v3.20.13). Root cause: `bin/headless-auto-spawn.cjs::autoSpawnHeadless()` called `ensureDashboardRunning()` on every spawn, fork-detaching a fresh dashboard for every gsd-t-execute / gsd-t-debug / gsd-t-wave invocation across any project across any session. 99% of those autostarted dashboards are never opened by the user (the live-transcript URL banner is just-in-case observability), so they accumulated on the project-scoped port range 7433–7532 until the user manually killed them.
|
|
53
|
+
|
|
54
|
+
**Changes:**
|
|
55
|
+
- `bin/headless-auto-spawn.cjs::autoSpawnHeadless()`: removed the `ensureDashboardRunning()` call. Spawns no longer autostart dashboards. New synchronous `_probeDashboardLazy(projectDir)` reads `.gsd-t/.dashboard.pid` and verifies the pid is alive via `process.kill(pid, 0)` (cheap; runs on every spawn). Banner is now conditional:
|
|
56
|
+
- Dashboard running: `▶ Live transcript: http://127.0.0.1:{port}/transcript/{spawn-id}` (existing M43 D6-T3 shape).
|
|
57
|
+
- No dashboard: `▶ Transcript file: {logPath}\n (to view live: gsd-t-visualize)` — points at the on-disk log + tells the user how to open the dashboard if they want it.
|
|
58
|
+
- `scripts/gsd-t-dashboard-server.js`: idle-TTL self-shutdown. Default 4 hours, configurable via env `GSD_T_DASHBOARD_IDLE_TTL_MS` or `--idle-ttl-ms` flag. "Idle" means zero HTTP requests AND zero active SSE connections for the full TTL window. setInterval check every 60s; on shutdown, removes `.gsd-t/.dashboard.pid` so the lazy probe sees a clean state. SSE-active dashboards never exit — `_wrapSseHandler` increments/decrements an active-connection counter on req/res `close` events.
|
|
59
|
+
- `bin/gsd-t.js doctor`: new `Dashboard Orphans` check + `--prune` flag. Scans for live `gsd-t-dashboard-server.js` processes via `ps -eo pid,command`; cross-references each pid against pidfiles in cwd, `GSD_T_PROJECT_DIR`, and the registered-projects list. Reports orphans (process running, pidfile missing or mismatched). With `--prune`, sends SIGTERM to each orphan. Recovery for any orphans that piled up under earlier versions.
|
|
60
|
+
- `commands/gsd-t-visualize.md` (unchanged): the explicit user opt-in path still calls `ensureDashboardRunning()` via `--detach` — the dashboard starts when (and only when) the user runs `/gsd-t-visualize`.
|
|
61
|
+
|
|
62
|
+
**Tests:**
|
|
63
|
+
- `test/m49-lazy-dashboard.test.js` (9): probe correctness across 5 pidfile states (missing / dead / live / garbage / empty), probe speed (< 50ms for 100 calls), `autoSpawnHeadless` does NOT invoke `ensureDashboardRunning` (require-cache stub), URL banner shape when running, file-path banner shape when not running.
|
|
64
|
+
- `test/m49-dashboard-idle-ttl.test.js` (7): `tracker.bump` resets `lastActivity`, SSE connect/disconnect counter, TTL fires when window elapses with no SSE, TTL does NOT fire while `activeSseConnections > 0`, recent `bump` prevents fire, `_wrapSseHandler` tracks idempotently on close, `startServer` accepts `idleTtlMs` opt without crashing.
|
|
65
|
+
- `test/m49-doctor-orphan-check.test.js` (4): no-process baseline, fake-dashboard process detected as orphan, `--prune` actually kills the orphan PID, tracked dashboard (pidfile lists pid) is NOT an orphan.
|
|
66
|
+
- `test/m43-url-banner.test.js`: updated for M49 — file-path banner expected by default; URL banner exercised with a pre-written pidfile pointing at the test runner's pid (proxy for "live").
|
|
67
|
+
|
|
68
|
+
**Migration:** existing autostarted dashboards stay running until they hit the 4h idle-TTL or are pruned via `gsd-t doctor --prune`. New spawns no longer add to the count. Re-running `/gsd-t-visualize` continues to work as before.
|
|
69
|
+
|
|
70
|
+
**Suite:** 2103/2105 (2 pre-existing env-sensitive flakes preserved per M47/M48 baseline). +20 new M49 tests, 0 regressions.
|
|
71
|
+
|
|
5
72
|
## [3.21.11] - 2026-05-06
|
|
6
73
|
|
|
7
74
|
### Fixed — viewer: 4 rendering regressions surfaced post-M47
|
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ A methodology for reliable, parallelizable development using Claude Code with op
|
|
|
16
16
|
**External Task Orchestrator + Streaming Watcher UI (M40, v3.14.10)** — JS orchestrator drives `claude -p` one task per spawn: short-lived, fresh context, architecturally compaction-free. Benchmarks 0.72× wall-clock vs in-session on 20-task/3-wave workloads. Paired with a zero-Claude-cost local streaming UI at `127.0.0.1:7842` that renders all workers' stream-json output as a continuous claude.ai-style feed — task/wave banners, duration + usage chips, token corner bar, localStorage filters, replay via `WS /feed?from=N`. Recovery: `--resume` reconciles interrupted runs using commit + progress.md evidence; ambiguous tasks (commit without progress entry) are flagged for operator triage, never silently claimed done. CLI: `gsd-t orchestrate`, `gsd-t benchmark-orchestrator`, `gsd-t stream-feed`. Contracts: `stream-json-sink-contract.md` v1.1.0, `wave-join-contract.md`, `completion-signal-contract.md`, `metrics-schema-contract.md`.
|
|
17
17
|
**Always-Headless Spawn (M43 D4, v3.16.x+) — Channel Separation** — every GSD-T command spawns detached, unconditionally. No `--watch`, no `--in-session`, no `--headless` opt-in, no context-meter threshold that reroutes. The dialog channel is reserved for human↔Claude conversation; every workflow turn is a detached headless child. Interactive session shows a launch banner + live-transcript URL + event-stream path, then exits — results surface via the read-back banner on the user's next message. Detached workers emit JSONL events to `.gsd-t/events/YYYY-MM-DD.jsonl` at every phase boundary — shared by dashboard and (historically) the watch command. The only in-session surface is the `/gsd` router (for dialog-only exploratory turns). See `.gsd-t/contracts/headless-default-contract.md` v2.0.0 and `unattended-event-stream-contract.md` v1.0.0.
|
|
18
18
|
**Live Transcript as Primary Surface (M43 D6, v3.16.13 — extended in M47, v3.21.10)** — every detached spawn prints a one-line banner (`▶ Live transcript: http://127.0.0.1:{port}/transcript/{id}`) pointing at a browser viewer that SSE-streams the child's stdout and renders a collapsible "Tool Cost" sidebar panel showing per-tool attributed tokens and cost (sourced from `/transcript/:id/tool-cost`, which proxies to the M43 D2 tool-attribution library). The dashboard server auto-starts (`scripts/gsd-t-dashboard-autostart.cjs`) idempotently on each spawn — a port probe backs off when a server is already running, otherwise a fork-detach writes `.gsd-t/.dashboard.pid`. Port is project-scoped via `projectScopedDefaultPort(projectDir)` so multi-project workflows don't clobber each other.
|
|
19
|
+
**Universal Playwright Bootstrap + Deterministic UI Enforcement (M50, v3.22.10)** — converts the prose-only "Playwright Readiness Guard" into three executable enforcement layers so agents cannot skip UI tests. Layer 1: `bin/playwright-bootstrap.cjs` + `bin/ui-detection.cjs` wired into `init`/`update-all`/`doctor`/new `gsd-t setup-playwright [path]` subcommand — idempotent installer detects package manager, installs `@playwright/test` + chromium, writes config from a contract-locked template, scaffolds `e2e/`. Layer 2: `bin/headless-auto-spawn.cjs::autoSpawnHeadless()` auto-installs before any of 9 whitelisted testing/UI commands when `hasUI && !hasPlaywright`; install failure → `mode: 'blocked-needs-human'` exit-4. Layer 3: `scripts/hooks/pre-commit-playwright-gate` (opt-in via `gsd-t doctor --install-hooks`) reads `.gsd-t/.last-playwright-pass` and blocks viewer-source commits when staged files are newer than the last playwright test pass. Also ships the M47/M48/M49 E2E viewer specs (`e2e/viewer/title`, `timestamps`, `chat-bubbles`, `dual-pane`, `lazy-dashboard`). 62 new unit tests; E2E 8/9 pass.
|
|
19
20
|
**Focused Visualizer Redesign (M47, v3.21.10)** — `/transcripts` opens directly to a dual-pane focused view: the **top pane** auto-streams the orchestrator's main in-session conversation (zero clicks — fetched via new `GET /api/main-session`), and the **bottom pane** streams whichever spawn the user clicks. A keyboard- and mouse-resizable splitter sits between them, with position persisted in `sessionStorage` (along with selection, completed-section toggle, and right-rail collapsed state). The left rail splits into three sections — `★ Main Session`, `Live Spawns`, and `Completed` (last 100 spawns, newest first, status-badged, collapsible). When a spawn transitions running → completed it moves rail sections live without a full reload, and stays selected if focused. The right rail (Spawn Plan / Parallelism / Tool Cost) is preserved under a new collapsible toggle. Bookmarks to `/transcript/:spawnId` continue to land that spawn pre-selected in the bottom pane. Contract: `dashboard-server-contract.md` v1.3.0 (additive — `status: 'active' \| 'completed'` field on in-session entries, derived from a 30s mtime window; `/api/main-session` endpoint with path-traversal guard + `Cache-Control: no-store`).
|
|
20
21
|
- **Surgical model selection** — `bin/model-selector.js` assigns haiku/sonnet/opus per phase via a declarative rules table; `/advisor` escalation path with convention-based fallback.
|
|
21
22
|
- **Per-spawn token telemetry** — `.gsd-t/token-metrics.jsonl` records one 18-field row per Task subagent spawn.
|
package/bin/gsd-t.js
CHANGED
|
@@ -198,10 +198,15 @@ function copyFile(src, dest, label) {
|
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
201
|
+
// M50 D1: hasPlaywright migrated to bin/playwright-bootstrap.cjs.
|
|
202
|
+
// Re-exported here for back-compat with any caller still requiring it via
|
|
203
|
+
// bin/gsd-t.js. See .gsd-t/contracts/playwright-bootstrap-contract.md §3.
|
|
204
|
+
const {
|
|
205
|
+
hasPlaywright,
|
|
206
|
+
installPlaywright,
|
|
207
|
+
detectPackageManager: _detectPlaywrightPackageManager,
|
|
208
|
+
} = require("./playwright-bootstrap.cjs");
|
|
209
|
+
const { hasUI } = require("./ui-detection.cjs");
|
|
205
210
|
|
|
206
211
|
function readProjectDeps(projectDir) {
|
|
207
212
|
const pkgPath = path.join(projectDir, "package.json");
|
|
@@ -571,6 +576,47 @@ function installContextMeter(projectDir) {
|
|
|
571
576
|
// to .git/hooks/pre-commit; if the file doesn't exist, copies our stock
|
|
572
577
|
// script. Never overwrites an existing hook.
|
|
573
578
|
const CAPTURE_LINT_HOOK_MARKER = "# GSD-T capture lint";
|
|
579
|
+
// M50 D2 — Playwright pre-commit gate marker. Installed via
|
|
580
|
+
// `gsd-t doctor --install-hooks`. Idempotent: appends a delimited block to
|
|
581
|
+
// `.git/hooks/pre-commit` if the marker isn't already present.
|
|
582
|
+
const PLAYWRIGHT_GATE_HOOK_MARKER = "# GSD-T playwright gate";
|
|
583
|
+
|
|
584
|
+
function installPlaywrightGateHook(projectDir) {
|
|
585
|
+
const gitDir = path.join(projectDir, ".git");
|
|
586
|
+
if (!fs.existsSync(gitDir)) {
|
|
587
|
+
warn("No .git directory — not a git repo; skipping playwright-gate install");
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
const hooksDir = path.join(gitDir, "hooks");
|
|
591
|
+
try { fs.mkdirSync(hooksDir, { recursive: true }); } catch (_) {}
|
|
592
|
+
const hookPath = path.join(hooksDir, "pre-commit");
|
|
593
|
+
const stockSrc = path.join(PKG_ROOT, "scripts", "hooks", "pre-commit-playwright-gate");
|
|
594
|
+
let stock = "";
|
|
595
|
+
try { stock = fs.readFileSync(stockSrc, "utf8"); } catch (_) {
|
|
596
|
+
warn("Could not read pre-commit-playwright-gate script from package");
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
if (!fs.existsSync(hookPath)) {
|
|
600
|
+
fs.writeFileSync(hookPath, stock);
|
|
601
|
+
try { fs.chmodSync(hookPath, 0o755); } catch (_) {}
|
|
602
|
+
success(`Playwright gate installed at ${path.relative(projectDir, hookPath)}`);
|
|
603
|
+
info("The hook reads .gsd-t/.last-playwright-pass to gate viewer-source commits");
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
const existing = fs.readFileSync(hookPath, "utf8");
|
|
607
|
+
if (existing.includes(PLAYWRIGHT_GATE_HOOK_MARKER)) {
|
|
608
|
+
info("Playwright-gate block already present in pre-commit hook — no change");
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
const appended = existing.trimEnd() +
|
|
612
|
+
"\n\n" + PLAYWRIGHT_GATE_HOOK_MARKER + "\n" +
|
|
613
|
+
stock.replace(/^#!.*\n/, "") + "\n";
|
|
614
|
+
fs.writeFileSync(hookPath, appended);
|
|
615
|
+
try { fs.chmodSync(hookPath, 0o755); } catch (_) {}
|
|
616
|
+
success(`Playwright-gate block appended to ${path.relative(projectDir, hookPath)}`);
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
|
|
574
620
|
function installCaptureLintHook(projectDir) {
|
|
575
621
|
const gitDir = path.join(projectDir, ".git");
|
|
576
622
|
if (!fs.existsSync(gitDir)) {
|
|
@@ -1655,6 +1701,25 @@ async function doInit(projectName) {
|
|
|
1655
1701
|
|
|
1656
1702
|
if (registerProject(projectDir)) success("Registered in ~/.claude/.gsd-t-projects");
|
|
1657
1703
|
|
|
1704
|
+
// M50 D1: Universal Playwright bootstrap. If the project has UI signal but
|
|
1705
|
+
// no playwright.config.*, install @playwright/test + chromium and scaffold
|
|
1706
|
+
// playwright.config.ts + e2e/. See playwright-bootstrap-contract.md §5.
|
|
1707
|
+
if (hasUI(projectDir) && !hasPlaywright(projectDir)) {
|
|
1708
|
+
info("Installing Playwright (chromium)…");
|
|
1709
|
+
try {
|
|
1710
|
+
const r = await installPlaywright(projectDir);
|
|
1711
|
+
if (r.ok) {
|
|
1712
|
+
success("Playwright installed (playwright.config.ts + e2e/ scaffold)");
|
|
1713
|
+
} else {
|
|
1714
|
+
warn(`Playwright install failed: ${r.err}`);
|
|
1715
|
+
if (r.hint) info(r.hint);
|
|
1716
|
+
}
|
|
1717
|
+
} catch (e) {
|
|
1718
|
+
warn(`Playwright install errored: ${e.message || e}`);
|
|
1719
|
+
info("Re-run with: gsd-t doctor --install-playwright");
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1658
1723
|
showInitTree(projectDir);
|
|
1659
1724
|
}
|
|
1660
1725
|
|
|
@@ -1968,10 +2033,12 @@ function createProjectChangelog(projectDir, projectName) {
|
|
|
1968
2033
|
}
|
|
1969
2034
|
}
|
|
1970
2035
|
|
|
1971
|
-
function checkProjectHealth(projects) {
|
|
2036
|
+
async function checkProjectHealth(projects) {
|
|
1972
2037
|
heading("Project Health");
|
|
1973
2038
|
const playwrightMissing = [];
|
|
1974
2039
|
const swaggerMissing = [];
|
|
2040
|
+
const playwrightAutoInstalled = [];
|
|
2041
|
+
const playwrightInstallFailed = [];
|
|
1975
2042
|
|
|
1976
2043
|
for (const projectDir of projects) {
|
|
1977
2044
|
if (!fs.existsSync(projectDir)) continue;
|
|
@@ -1980,19 +2047,56 @@ function checkProjectHealth(projects) {
|
|
|
1980
2047
|
if (hasApi(projectDir) && !hasSwagger(projectDir)) swaggerMissing.push(name);
|
|
1981
2048
|
}
|
|
1982
2049
|
|
|
2050
|
+
// M50 D1: auto-install Playwright for any UI project that's missing it.
|
|
2051
|
+
// Non-UI projects stay missing — they don't need Playwright.
|
|
2052
|
+
for (const projectDir of projects) {
|
|
2053
|
+
if (!fs.existsSync(projectDir)) continue;
|
|
2054
|
+
if (hasPlaywright(projectDir)) continue;
|
|
2055
|
+
if (!hasUI(projectDir)) continue;
|
|
2056
|
+
const name = path.basename(projectDir);
|
|
2057
|
+
try {
|
|
2058
|
+
const r = await installPlaywright(projectDir);
|
|
2059
|
+
if (r.ok) playwrightAutoInstalled.push(name);
|
|
2060
|
+
else playwrightInstallFailed.push({ name, err: r.err, hint: r.hint });
|
|
2061
|
+
} catch (e) {
|
|
2062
|
+
playwrightInstallFailed.push({ name, err: e.message || String(e) });
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
|
|
1983
2066
|
if (playwrightMissing.length === 0 && swaggerMissing.length === 0) {
|
|
1984
2067
|
success("All projects have Playwright and Swagger configured");
|
|
1985
2068
|
} else {
|
|
1986
2069
|
if (playwrightMissing.length > 0) {
|
|
1987
2070
|
warn(`Playwright missing: ${playwrightMissing.join(", ")}`);
|
|
1988
|
-
|
|
2071
|
+
if (playwrightAutoInstalled.length > 0) {
|
|
2072
|
+
success(`Auto-installed Playwright in: ${playwrightAutoInstalled.join(", ")}`);
|
|
2073
|
+
}
|
|
2074
|
+
if (playwrightInstallFailed.length > 0) {
|
|
2075
|
+
for (const f of playwrightInstallFailed) {
|
|
2076
|
+
warn(` ${f.name} — install failed: ${f.err}`);
|
|
2077
|
+
if (f.hint) info(` ${f.hint}`);
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
const stillMissing = playwrightMissing.filter(
|
|
2081
|
+
(n) => !playwrightAutoInstalled.includes(n),
|
|
2082
|
+
);
|
|
2083
|
+
if (stillMissing.length > 0) {
|
|
2084
|
+
info(
|
|
2085
|
+
`Remaining (no UI signal — skipped): ${stillMissing.join(", ")}`,
|
|
2086
|
+
);
|
|
2087
|
+
}
|
|
1989
2088
|
}
|
|
1990
2089
|
if (swaggerMissing.length > 0) {
|
|
1991
2090
|
warn(`Swagger/OpenAPI missing (API detected): ${swaggerMissing.join(", ")}`);
|
|
1992
2091
|
info("Swagger will be auto-configured when an API endpoint is created or modified");
|
|
1993
2092
|
}
|
|
1994
2093
|
}
|
|
1995
|
-
return {
|
|
2094
|
+
return {
|
|
2095
|
+
playwrightMissing,
|
|
2096
|
+
swaggerMissing,
|
|
2097
|
+
playwrightAutoInstalled,
|
|
2098
|
+
playwrightInstallFailed,
|
|
2099
|
+
};
|
|
1996
2100
|
}
|
|
1997
2101
|
|
|
1998
2102
|
// ── Global Rule Sync (M27) ──────────────────────────────────────────────────
|
|
@@ -2163,8 +2267,19 @@ async function doUpdateAll() {
|
|
|
2163
2267
|
// Global rule sync — propagate proven rules across projects
|
|
2164
2268
|
const syncCount = syncGlobalRules(projects);
|
|
2165
2269
|
|
|
2166
|
-
const {
|
|
2167
|
-
|
|
2270
|
+
const {
|
|
2271
|
+
playwrightMissing,
|
|
2272
|
+
swaggerMissing,
|
|
2273
|
+
playwrightAutoInstalled,
|
|
2274
|
+
} = await checkProjectHealth(projects);
|
|
2275
|
+
showUpdateAllSummary(
|
|
2276
|
+
projects.length,
|
|
2277
|
+
counts,
|
|
2278
|
+
playwrightMissing,
|
|
2279
|
+
swaggerMissing,
|
|
2280
|
+
syncCount,
|
|
2281
|
+
playwrightAutoInstalled,
|
|
2282
|
+
);
|
|
2168
2283
|
}
|
|
2169
2284
|
|
|
2170
2285
|
// Upgrade the globally-installed @tekyzinc/gsd-t to @latest. Returns
|
|
@@ -2510,7 +2625,14 @@ function ensureUnattendedGitignore(projectDir, projectName) {
|
|
|
2510
2625
|
return added;
|
|
2511
2626
|
}
|
|
2512
2627
|
|
|
2513
|
-
function showUpdateAllSummary(
|
|
2628
|
+
function showUpdateAllSummary(
|
|
2629
|
+
total,
|
|
2630
|
+
counts,
|
|
2631
|
+
playwrightMissing,
|
|
2632
|
+
swaggerMissing,
|
|
2633
|
+
syncCount,
|
|
2634
|
+
playwrightAutoInstalled,
|
|
2635
|
+
) {
|
|
2514
2636
|
log("");
|
|
2515
2637
|
heading("Update All Complete");
|
|
2516
2638
|
log(` Projects registered: ${total}`);
|
|
@@ -2519,6 +2641,9 @@ function showUpdateAllSummary(total, counts, playwrightMissing, swaggerMissing,
|
|
|
2519
2641
|
if (counts.missing > 0) log(` Not found: ${counts.missing}`);
|
|
2520
2642
|
if (counts.errors > 0) log(` Errors: ${counts.errors}`);
|
|
2521
2643
|
if (playwrightMissing.length > 0) log(` Missing Playwright: ${playwrightMissing.length}`);
|
|
2644
|
+
if (Array.isArray(playwrightAutoInstalled) && playwrightAutoInstalled.length > 0) {
|
|
2645
|
+
log(` Auto-installed Playwright in: ${playwrightAutoInstalled.length} project(s)`);
|
|
2646
|
+
}
|
|
2522
2647
|
if (swaggerMissing.length > 0) log(` Missing Swagger: ${swaggerMissing.length}`);
|
|
2523
2648
|
if (syncCount > 0) log(` Global rules synced: ${syncCount}`);
|
|
2524
2649
|
log("");
|
|
@@ -2604,14 +2729,32 @@ function checkDoctorEncoding(installed) {
|
|
|
2604
2729
|
return 0;
|
|
2605
2730
|
}
|
|
2606
2731
|
|
|
2607
|
-
function checkDoctorProject() {
|
|
2732
|
+
async function checkDoctorProject(opts) {
|
|
2608
2733
|
let issues = 0;
|
|
2609
2734
|
const cwd = process.cwd();
|
|
2735
|
+
const installFlag = !!(opts && opts.installPlaywright);
|
|
2610
2736
|
if (hasPlaywright(cwd)) {
|
|
2611
2737
|
success("Playwright configured");
|
|
2738
|
+
} else if (installFlag && hasUI(cwd)) {
|
|
2739
|
+
info("Installing Playwright (chromium)…");
|
|
2740
|
+
try {
|
|
2741
|
+
const r = await installPlaywright(cwd);
|
|
2742
|
+
if (r.ok) {
|
|
2743
|
+
success("Playwright installed (playwright.config.ts + e2e/ scaffold)");
|
|
2744
|
+
} else {
|
|
2745
|
+
error(`Playwright install failed: ${r.err}`);
|
|
2746
|
+
if (r.hint) info(r.hint);
|
|
2747
|
+
issues++;
|
|
2748
|
+
}
|
|
2749
|
+
} catch (e) {
|
|
2750
|
+
error(`Playwright install errored: ${e.message || e}`);
|
|
2751
|
+
issues++;
|
|
2752
|
+
}
|
|
2753
|
+
} else if (installFlag && !hasUI(cwd)) {
|
|
2754
|
+
info("Playwright skipped — no UI signal in this project");
|
|
2612
2755
|
} else {
|
|
2613
2756
|
warn("Playwright not configured in this project");
|
|
2614
|
-
info("
|
|
2757
|
+
info("Re-run with --install-playwright, or run any GSD-T testing command (auto-install)");
|
|
2615
2758
|
issues++;
|
|
2616
2759
|
}
|
|
2617
2760
|
if (hasApi(cwd)) {
|
|
@@ -2758,15 +2901,160 @@ async function checkDoctorContextMeter(projectDir) {
|
|
|
2758
2901
|
return issues;
|
|
2759
2902
|
}
|
|
2760
2903
|
|
|
2761
|
-
|
|
2904
|
+
// M49 — Detect dashboard server processes whose pidfile is missing or
|
|
2905
|
+
// mismatched ("orphans"). The lazy-dashboard + idle-TTL changes prevent new
|
|
2906
|
+
// orphans from accumulating, but recovery is still needed for any that piled
|
|
2907
|
+
// up under earlier versions. With `--prune`, kills the orphan PIDs.
|
|
2908
|
+
//
|
|
2909
|
+
// "Orphan" = a `gsd-t-dashboard-server.js` process whose pidfile (at
|
|
2910
|
+
// `{projectDir}/.gsd-t/.dashboard.pid` resolved from the process's cwd / the
|
|
2911
|
+
// `GSD_T_PROJECT_DIR` env / the registered-projects list) doesn't list the pid.
|
|
2912
|
+
//
|
|
2913
|
+
// Detection is best-effort across platforms — `ps` shape varies. We use:
|
|
2914
|
+
// - macOS / Linux: `ps -eo pid,command` (no `=`-stripped header)
|
|
2915
|
+
// - Windows: unsupported here (logged as N/A)
|
|
2916
|
+
//
|
|
2917
|
+
// Returns an issue count (0 if clean, 1 if orphans found and not pruned).
|
|
2918
|
+
function checkDoctorDashboardOrphans(opts) {
|
|
2919
|
+
const prune = !!(opts && opts.prune);
|
|
2920
|
+
let issues = 0;
|
|
2921
|
+
heading("Dashboard Orphans");
|
|
2922
|
+
|
|
2923
|
+
if (process.platform === "win32") {
|
|
2924
|
+
info("Skipping (Windows process inventory not yet supported)");
|
|
2925
|
+
return 0;
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
let psOut;
|
|
2929
|
+
try {
|
|
2930
|
+
psOut = execFileSync("ps", ["-eo", "pid,command"], {
|
|
2931
|
+
encoding: "utf8",
|
|
2932
|
+
timeout: 5000,
|
|
2933
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2934
|
+
});
|
|
2935
|
+
} catch {
|
|
2936
|
+
warn("Could not run `ps -eo pid,command` — orphan detection skipped");
|
|
2937
|
+
return 0;
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
const dashPids = [];
|
|
2941
|
+
for (const line of psOut.split("\n")) {
|
|
2942
|
+
const t = line.trim();
|
|
2943
|
+
if (!t) continue;
|
|
2944
|
+
if (!t.includes("gsd-t-dashboard-server.js")) continue;
|
|
2945
|
+
if (t.includes("ps -eo")) continue; // never list ourselves
|
|
2946
|
+
if (t.includes("grep")) continue;
|
|
2947
|
+
const m = t.match(/^(\d+)\s+(.+)$/);
|
|
2948
|
+
if (!m) continue;
|
|
2949
|
+
const pid = parseInt(m[1], 10);
|
|
2950
|
+
if (!Number.isFinite(pid) || pid <= 0) continue;
|
|
2951
|
+
// Red Team — only treat as a dashboard process if the first argv token is
|
|
2952
|
+
// a `node`-flavored binary AND the second token ends with the script
|
|
2953
|
+
// filename. Filters out e.g. `cat gsd-t-dashboard-server.js`,
|
|
2954
|
+
// `vim gsd-t-dashboard-server.js`, etc. that happen to mention the path.
|
|
2955
|
+
const argv = m[2].trim().split(/\s+/);
|
|
2956
|
+
if (argv.length < 2) continue;
|
|
2957
|
+
const exe = argv[0];
|
|
2958
|
+
const script = argv[1] || "";
|
|
2959
|
+
const exeIsNode = /(^|\/)node([0-9.]+)?$/.test(exe);
|
|
2960
|
+
const scriptMatches = script.endsWith("gsd-t-dashboard-server.js");
|
|
2961
|
+
if (!exeIsNode || !scriptMatches) continue;
|
|
2962
|
+
dashPids.push({ pid, cmd: m[2] });
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
if (dashPids.length === 0) {
|
|
2966
|
+
success("No dashboard processes running");
|
|
2967
|
+
return 0;
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
// Collect candidate pidfiles. We probe (a) cwd + GSD_T_PROJECT_DIR for the
|
|
2971
|
+
// current shell, (b) each registered project. Each contributes one
|
|
2972
|
+
// {projectDir → pidfile pid} mapping. An orphan is a live dashboard pid
|
|
2973
|
+
// that doesn't match any pidfile we found.
|
|
2974
|
+
const projects = new Set();
|
|
2975
|
+
projects.add(process.cwd());
|
|
2976
|
+
if (process.env.GSD_T_PROJECT_DIR) projects.add(process.env.GSD_T_PROJECT_DIR);
|
|
2977
|
+
try {
|
|
2978
|
+
for (const p of getRegisteredProjects()) projects.add(p);
|
|
2979
|
+
} catch { /* registry may not exist */ }
|
|
2980
|
+
|
|
2981
|
+
const knownPids = new Set();
|
|
2982
|
+
for (const proj of projects) {
|
|
2983
|
+
const pidFile = path.join(proj, ".gsd-t", ".dashboard.pid");
|
|
2984
|
+
try {
|
|
2985
|
+
const raw = fs.readFileSync(pidFile, "utf8").trim();
|
|
2986
|
+
const pid = parseInt(raw, 10);
|
|
2987
|
+
if (Number.isFinite(pid) && pid > 0) knownPids.add(pid);
|
|
2988
|
+
} catch { /* missing or unreadable */ }
|
|
2989
|
+
// Also accept the older M38 pidfile (no leading dot).
|
|
2990
|
+
const legacyPidFile = path.join(proj, ".gsd-t", "dashboard.pid");
|
|
2991
|
+
try {
|
|
2992
|
+
const raw = fs.readFileSync(legacyPidFile, "utf8").trim();
|
|
2993
|
+
const pid = parseInt(raw, 10);
|
|
2994
|
+
if (Number.isFinite(pid) && pid > 0) knownPids.add(pid);
|
|
2995
|
+
} catch { /* missing */ }
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
const orphans = dashPids.filter((d) => !knownPids.has(d.pid));
|
|
2999
|
+
|
|
3000
|
+
log(` Detected ${dashPids.length} dashboard process${dashPids.length === 1 ? "" : "es"} ` +
|
|
3001
|
+
`(${knownPids.size} tracked via pidfile, ${orphans.length} orphan${orphans.length === 1 ? "" : "s"})`);
|
|
3002
|
+
|
|
3003
|
+
if (orphans.length === 0) {
|
|
3004
|
+
success("No orphans");
|
|
3005
|
+
return 0;
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
for (const o of orphans) {
|
|
3009
|
+
log(` ${YELLOW}orphan${RESET} pid=${o.pid}`);
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
if (prune) {
|
|
3013
|
+
let killed = 0;
|
|
3014
|
+
let failed = 0;
|
|
3015
|
+
for (const o of orphans) {
|
|
3016
|
+
try {
|
|
3017
|
+
process.kill(o.pid, "SIGTERM");
|
|
3018
|
+
killed++;
|
|
3019
|
+
} catch (err) {
|
|
3020
|
+
if (err && err.code === "ESRCH") {
|
|
3021
|
+
// Already gone — count as success.
|
|
3022
|
+
killed++;
|
|
3023
|
+
} else {
|
|
3024
|
+
failed++;
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
if (failed === 0) {
|
|
3029
|
+
success(`Pruned ${killed} orphan${killed === 1 ? "" : "s"}`);
|
|
3030
|
+
} else {
|
|
3031
|
+
warn(`Pruned ${killed}, failed ${failed}`);
|
|
3032
|
+
issues++;
|
|
3033
|
+
}
|
|
3034
|
+
} else {
|
|
3035
|
+
info("Run `gsd-t doctor --prune` to kill orphans");
|
|
3036
|
+
issues++;
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
return issues;
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
async function doDoctor(opts) {
|
|
2762
3043
|
heading("GSD-T Doctor");
|
|
2763
3044
|
log("");
|
|
2764
3045
|
let issues = 0;
|
|
2765
3046
|
issues += checkDoctorEnvironment();
|
|
2766
3047
|
issues += checkDoctorInstallation();
|
|
2767
|
-
issues += checkDoctorProject();
|
|
3048
|
+
issues += await checkDoctorProject(opts);
|
|
2768
3049
|
issues += checkDoctorCgc();
|
|
2769
3050
|
issues += await checkDoctorContextMeter(process.cwd());
|
|
3051
|
+
issues += checkDoctorDashboardOrphans(opts);
|
|
3052
|
+
// M50 D2: opt-in install of the playwright-gate pre-commit hook.
|
|
3053
|
+
if (opts && opts.installHooks) {
|
|
3054
|
+
log("");
|
|
3055
|
+
heading("Installing pre-commit hooks");
|
|
3056
|
+
installPlaywrightGateHook(process.cwd());
|
|
3057
|
+
}
|
|
2770
3058
|
log("");
|
|
2771
3059
|
if (issues === 0) {
|
|
2772
3060
|
log(`${GREEN}${BOLD} All checks passed!${RESET}`);
|
|
@@ -3881,7 +4169,7 @@ function showHelp() {
|
|
|
3881
4169
|
log(` ${CYAN}register${RESET} Register current directory as a GSD-T project`);
|
|
3882
4170
|
log(` ${CYAN}status${RESET} Show installation status + check for updates`);
|
|
3883
4171
|
log(` ${CYAN}uninstall${RESET} Remove GSD-T commands (keeps project files)`);
|
|
3884
|
-
log(` ${CYAN}doctor${RESET} Diagnose common issues`);
|
|
4172
|
+
log(` ${CYAN}doctor${RESET} Diagnose common issues (use --prune to kill dashboard orphans)`);
|
|
3885
4173
|
log(` ${CYAN}changelog${RESET} Open changelog in the browser`);
|
|
3886
4174
|
log(` ${CYAN}graph${RESET} Code graph operations (index, status, query)`);
|
|
3887
4175
|
log(` ${CYAN}headless${RESET} Non-interactive execution via claude -p + fast state queries`);
|
|
@@ -3946,6 +4234,8 @@ module.exports = {
|
|
|
3946
4234
|
checkDoctorClaudeMd,
|
|
3947
4235
|
checkDoctorSettings,
|
|
3948
4236
|
checkDoctorEncoding,
|
|
4237
|
+
checkDoctorDashboardOrphans,
|
|
4238
|
+
doDoctor,
|
|
3949
4239
|
mergeGsdtSection,
|
|
3950
4240
|
migrateToMarkers,
|
|
3951
4241
|
appendGsdtToClaudeMd,
|
|
@@ -4034,9 +4324,52 @@ if (require.main === module) {
|
|
|
4034
4324
|
case "uninstall":
|
|
4035
4325
|
doUninstall();
|
|
4036
4326
|
break;
|
|
4037
|
-
case "doctor":
|
|
4038
|
-
|
|
4327
|
+
case "doctor": {
|
|
4328
|
+
const doctorOpts = { prune: false, installPlaywright: false, installHooks: false };
|
|
4329
|
+
for (let i = 1; i < args.length; i++) {
|
|
4330
|
+
if (args[i] === "--prune") doctorOpts.prune = true;
|
|
4331
|
+
if (args[i] === "--install-playwright") doctorOpts.installPlaywright = true;
|
|
4332
|
+
if (args[i] === "--install-hooks") doctorOpts.installHooks = true;
|
|
4333
|
+
}
|
|
4334
|
+
doDoctor(doctorOpts).catch((e) => { error(e.message || String(e)); process.exit(1); });
|
|
4335
|
+
break;
|
|
4336
|
+
}
|
|
4337
|
+
case "setup-playwright": {
|
|
4338
|
+
// Single-project explicit installer. Thin wrapper around installPlaywright().
|
|
4339
|
+
const targetDir = args[1] && !args[1].startsWith("-") ? path.resolve(args[1]) : process.cwd();
|
|
4340
|
+
heading(`Setup Playwright: ${targetDir}`);
|
|
4341
|
+
log("");
|
|
4342
|
+
if (!fs.existsSync(targetDir)) {
|
|
4343
|
+
error(`Path not found: ${targetDir}`);
|
|
4344
|
+
process.exit(1);
|
|
4345
|
+
}
|
|
4346
|
+
if (hasPlaywright(targetDir)) {
|
|
4347
|
+
info("Playwright already configured (playwright.config.* present)");
|
|
4348
|
+
process.exit(0);
|
|
4349
|
+
}
|
|
4350
|
+
if (!hasUI(targetDir)) {
|
|
4351
|
+
warn("No UI signal detected. Installing anyway? Re-run with --force to install regardless.");
|
|
4352
|
+
const force = args.includes("--force");
|
|
4353
|
+
if (!force) {
|
|
4354
|
+
info("Skipping. Use --force to install Playwright in a non-UI project.");
|
|
4355
|
+
process.exit(0);
|
|
4356
|
+
}
|
|
4357
|
+
}
|
|
4358
|
+
info("Installing Playwright (chromium)…");
|
|
4359
|
+
installPlaywright(targetDir)
|
|
4360
|
+
.then((r) => {
|
|
4361
|
+
if (r.ok) {
|
|
4362
|
+
success("Playwright installed (playwright.config.ts + e2e/ scaffold)");
|
|
4363
|
+
process.exit(0);
|
|
4364
|
+
} else {
|
|
4365
|
+
error(`Playwright install failed: ${r.err}`);
|
|
4366
|
+
if (r.hint) info(r.hint);
|
|
4367
|
+
process.exit(1);
|
|
4368
|
+
}
|
|
4369
|
+
})
|
|
4370
|
+
.catch((e) => { error(e.message || String(e)); process.exit(1); });
|
|
4039
4371
|
break;
|
|
4372
|
+
}
|
|
4040
4373
|
case "changelog":
|
|
4041
4374
|
doChangelog();
|
|
4042
4375
|
break;
|