@tekyzinc/gsd-t 3.22.10 → 3.23.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 CHANGED
@@ -2,6 +2,83 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [3.23.10] - 2026-05-06
8
+
9
+ ### Added — Rigorous User-Journey Coverage + Anti-Drift Test Quality (M52)
10
+
11
+ Closes the M48→M49→M50→M51 drift pattern where each test round caught the bug shape the previous round named, never the unnamed shape. M52's architectural fix is two-fold: (a) MECHANICAL coverage via a regex-based listener detector + pre-commit gate that blocks viewer-source commits with uncovered listeners; (b) DOCTRINAL change to what "rigorous" means — journey specs walk every interactive surface end-to-end with assertions on user-visible state, real-data NDJSON fixtures, adversarial Red Team scoped to JOURNEYS not lines.
12
+
13
+ **D1 — Journey-coverage tooling:**
14
+ - `bin/journey-coverage.cjs` (308 lines) — regex listener detector with single-pass string-mask precomputation (handles JS strings + HTML/JS comments). Recognises 6 listener kinds per contract §3. Zero parser deps. Sub-100ms on the full viewer file set.
15
+ - `bin/journey-coverage-cli.cjs` (107 lines) → `gsd-t check-coverage` — supports `--staged-only`, `--manifest PATH`, `--quiet`. Exit 0 (clean) / 4 (gap or stale) / 2 (manifest missing).
16
+ - `scripts/hooks/pre-commit-journey-coverage` (mode 0755, `set -e`) — viewer-source pattern set per contract §4. Marker block `# >>> GSD-T journey-coverage gate >>>` mirrors M50 idiom. Fail-open on detector internal exception.
17
+ - `bin/gsd-t.js` wiring (+46 lines under 50-line budget): `installJourneyCoverageHook` (idempotent), `gsd-t check-coverage` CLI dispatch, `gsd-t doctor --install-journey-hook` flag. Hook auto-installed by `init` after Playwright install for UI projects.
18
+ - `.gsd-t/contracts/journey-coverage-contract.md` v1.0.0 (PROPOSED → STABLE on D1 task-5).
19
+
20
+ **D2 — Journey specs + fixtures:**
21
+ - `e2e/journeys/` — 12 inaugural journey specs (`main-session-stream`, `click-completed-conversation`, `click-spawn-entry`, `splitter-drag`, `splitter-keyboard`, `right-rail-toggle`, `completed-collapse-toggle`, `auto-follow-toggle`, `kill-button`, `sessionstorage-persistence`, `keyboard-shortcuts`, `hashchange`). Every assertion verifies state changed / data flowed / content loaded / widget responded — zero `toBeVisible`/`toBeAttached` shallow assertions.
22
+ - `.gsd-t/journey-manifest.json` (new) — 12 entries 1:1 with the spec files; `covers[]` arrays span all 17 distinct viewer listeners (multiple listeners per spec where appropriate).
23
+ - `e2e/fixtures/journeys/` — 3 real-data NDJSONs sliced from captured `in-session-*.ndjson` (~50 / ~150 / ~80 frames). PII scrub: any user content > 200 chars truncated with `[…truncated]` marker.
24
+ - `e2e/fixtures/journeys/replay-helpers.ts` — `startReplayServer({fixture, asSessionId, inSession})` mounts the fixture into a temp project, starts the dashboard server with `port: 0` ephemeral, returns `{baseUrl, dispose}`. Zero new runtime deps.
25
+ - `templates/prompts/red-team-subagent.md` — additive new category "Test Pass-Through — Journey Edition" (existing categories untouched). Adversarial mandate: write ≥5 broken viewer patches, run the journey specs, every patch must be caught.
26
+
27
+ **Adversarial Red Team result:** 5 broken viewer patches written, all 5 caught by journey specs (P1: splitter:mousedown drag handler stripped → splitter-drag FAILS as expected; P2: `_ssSet(SS_KEY_SPLITTER, ...)` redirected to wrong key → splitter-drag + splitter-keyboard FAIL; P3: right-rail toggle handler stubbed to early-return → right-rail-toggle FAILS; P4: M52 narrowed-guard reverted to broken M48 wide-guard → click-completed-conversation FAILS, catching the M52 root-cause regression itself; P5: auto-follow change handler localStorage write removed → auto-follow-toggle FAILS). VERDICT: GRUDGING PASS. See `.gsd-t/red-team-report.md` § "M52 JOURNEY-EDITION RED TEAM".
28
+
29
+ **Hook end-to-end exercise:** synthetic `fakeBtn:click` listener appended to `scripts/gsd-t-transcript.html:1617` + staged → `bash .git/hooks/pre-commit` exit 1 with structured GAP report (`GAP: scripts/gsd-t-transcript.html:1617 fakeBtn:click (addEventListener) no spec covers this`); manifest extended with covering entry → re-run hook exit 0. Block + unblock paths both logged.
30
+
31
+ **Suite:** unit 2195/2195 pass (was 2167; +28 from M52 D1 detector + CLI + hook + helpers). E2E 35/35 + 1 skip preserved (was 23/35; +12 journey specs). `gsd-t check-coverage` reports `OK: 20 listeners, 12 specs` (exit 0, zero gaps, zero stale entries).
32
+
33
+ **Migration:** none. Hook is auto-installed on `gsd-t init` and re-installable via `gsd-t doctor --install-journey-hook`. Existing projects pick it up on next `gsd-t update-all`.
34
+
35
+ ### Fixed — historical in-session conversations are clickable again (M52 quick patch)
36
+
37
+ The M48 Bug 4 dual-pane mirror prevention was overcorrected: `scripts/gsd-t-transcript.html` early-returned for ANY rail entry whose spawn-id began with `in-session-`. But every COMPLETED rail entry is `in-session-*` (that's how the M45 D2 hook captures conversations), so clicking ANY completed conversation did nothing — there was no pane that would load it.
38
+
39
+ **Mental model fix:** Top pane is for the LIVE main session (owned by `/api/main-session`). Bottom pane is for ANYTHING else the user clicks — regular spawns OR historical in-session conversations. Only the LIVE main session's spawn id should be blocked from the bottom pane (that's the actual M48 Bug 4 case).
40
+
41
+ **Source narrowing in `scripts/gsd-t-transcript.html`:**
42
+ - `connectMain(sessionId)` exposes `window.__mainSessionId` so click + hashchange handlers can discriminate.
43
+ - renderRailEntry click handler: `if (isInSession && node.spawnId === ('in-session-' + window.__mainSessionId)) return;` — was `if (isInSession) return;`.
44
+ - hashchange handler: `if (id && id === ('in-session-' + window.__mainSessionId)) { return; }` — was `if (id && id.indexOf('in-session-') === 0) { return; }`.
45
+ - Legacy renderTree click handler: same narrowed pattern.
46
+ - Removed unconditional in-session-* scrub from initial-bottom-id seeding (historical sessionStorage selections survive reload now).
47
+ - Added `fetchMainSession` callback that clears the bottom-pane seed when it collides with the live main session id (preserves M48 Bug 4 mirror prevention).
48
+
49
+ **New journey spec `e2e/viewer/click-completed.spec.ts` (4 tests):**
50
+ - Rail renders 1 main session + 3 completed in-session entries.
51
+ - Clicking each completed entry loads it into the BOTTOM pane (and TOP pane stays on the live main session).
52
+ - Clicking the live MAIN entry does NOT load it into the bottom pane.
53
+ - sessionStorage persists across reload — bottom pane resumes the previously-clicked entry.
54
+
55
+ **Adversarial Red Team (3 broken patches, all caught):** (a) revert both checks to `if (isInSession) return;`; (b) `connect(id)` short-circuits on in-session prefix; (c) click handler routes in-session entries to TOP pane via `connectMain(...)` (clobbers main session). See `.gsd-t/red-team-report.md` § M52 RED TEAM FINDINGS.
56
+
57
+ **Unit-test ripple:** 5 source-pinned assertions in `test/m48-viewer-rendering-fixes.test.js` flipped from "asserts pre-M52 unconditional bail" to "asserts M52 narrowed live-main check". 1 new test for the fetchMainSession seed-collision callback.
58
+
59
+ **Suite:** 2167/2167 unit pass (added 1); E2E 23/23 + 1 placeholder skip (added 4 journey tests).
60
+
61
+ ## [3.22.11] - 2026-05-06
62
+
63
+ ### Fixed — viewer Playwright specs are now actually rigorous + adversarially proven (M51)
64
+
65
+ The 5 viewer specs shipped in M50 used substring/existence assertions that would pass on a broken implementation — exactly the M48 BUG-3 LOW pattern that was flagged but never propagated forward. M51 strengthens all 5 specs to outcome-based assertions and proves they catch broken implementations via an adversarial Red Team that writes deliberately-broken viewer code and verifies the specs fail.
66
+
67
+ **Changes:**
68
+ - `e2e/viewer/title.spec.ts`: exact `<title>` equality (not regex); literal `$&` backref defence positive test using a fixture dir literally renamed to contain `$&`.
69
+ - `e2e/viewer/timestamps.spec.ts`: `distinct.size === 3` exactly (not `>= 2`); each rendered timestamp matches the actual `frame.ts` wall-clock value; new missing-`ts` fallback test exercises `arrivedAt` branch.
70
+ - `e2e/viewer/chat-bubbles.spec.ts`: CSS class membership assertions (`.frame.user.user-turn`, `.frame.assistant-turn`, etc.); structural assertions on `.body`/`.prefix`/`.badge`/`.truncated-tag`; `tool_use` 4th renderer coverage.
71
+ - `e2e/viewer/dual-pane.spec.ts`: TEST-M50-001 fix — `MutationObserver` per pane attributes frames by DOM target instead of filtering raw URLs (top pane's `connectMain` URL legitimately contains the in-session id); positive top-pane SSE assertion; hashchange-doesn't-pin-bottom test.
72
+ - `e2e/viewer/lazy-dashboard.spec.ts`: exact regex on banner shape (not substring); dead-pid branch coverage (today's spec only had live + missing).
73
+ - `.gsd-t/red-team-report.md`: new "M51 RED TEAM FINDINGS" section enumerating 5 broken-viewer adversary attempts and confirming all 5 caught.
74
+ - Bonus: fixture port allocation switched from `Math.random()*100` to `port: 0` + `server.address().port` readback (eliminates EADDRINUSE collisions across parallel Playwright workers).
75
+
76
+ **Adversarial Red Team result:** 5 broken viewer impls written, 5 caught by strengthened specs. Production viewer code unchanged — this was pure test-suite rigor work, no real app bugs found behind adversary patches.
77
+
78
+ **Suite:** 2166/2166 pass; E2E 19/19 pass (was 9 — 5 specs grew from 9 tests to 19).
79
+
80
+ **Migration:** none. Specs are stricter; existing v3.22.10 viewer continues to pass.
81
+
5
82
  ## [3.22.10] — M50 Universal Playwright Bootstrap + Deterministic UI Enforcement
6
83
 
7
84
  ### Added — three deterministic enforcement layers replace prose-only Playwright Readiness Guard
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
+ **Rigorous User-Journey Coverage + Anti-Drift Test Quality (M52, v3.23.10)** — closes the M48→M51 drift pattern where each test round only caught previously-named bug shapes. Two-part fix: (a) `bin/journey-coverage.cjs` regex listener detector + `gsd-t check-coverage` CLI + `scripts/hooks/pre-commit-journey-coverage` commit gate (auto-installed on `gsd-t init`) — blocks viewer-source commits when uncovered listeners exist; (b) 12 inaugural journey specs in `e2e/journeys/` covering all 20 detected viewer listeners with functional assertions (zero `toBeVisible`-only tests). Red Team GRUDGING PASS: 5/5 broken viewer patches caught; hook block-then-unblock exercised. Suite: 2195/2195 unit + 35/35 E2E pass.
19
20
  **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.
20
21
  **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`).
21
22
  - **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.
package/bin/gsd-t.js CHANGED
@@ -657,6 +657,35 @@ function installCaptureLintHook(projectDir) {
657
657
  return true;
658
658
  }
659
659
 
660
+ // M52 D1 — journey-coverage gate hook installer. Block-delimited so uninstall
661
+ // is a clean string excise. Stock script lives at scripts/hooks/pre-commit-journey-coverage.
662
+ const JOURNEY_COVERAGE_HOOK_BEGIN = "# >>> GSD-T journey-coverage gate >>>";
663
+ const JOURNEY_COVERAGE_HOOK_END = "# <<< GSD-T journey-coverage gate <<<";
664
+
665
+ function installJourneyCoverageHook(projectDir) {
666
+ const gitDir = path.join(projectDir, ".git");
667
+ if (!fs.existsSync(gitDir)) { warn("No .git directory — skipping journey-coverage hook install"); return false; }
668
+ const hooksDir = path.join(gitDir, "hooks");
669
+ try { fs.mkdirSync(hooksDir, { recursive: true }); } catch (_) {}
670
+ const hookPath = path.join(hooksDir, "pre-commit");
671
+ const stockSrc = path.join(PKG_ROOT, "scripts", "hooks", "pre-commit-journey-coverage");
672
+ let stock;
673
+ try { stock = fs.readFileSync(stockSrc, "utf8"); } catch (_) { warn("Could not read pre-commit-journey-coverage script"); return false; }
674
+ const block = JOURNEY_COVERAGE_HOOK_BEGIN + "\n" + stock.replace(/^#!.*\n/, "") + "\n" + JOURNEY_COVERAGE_HOOK_END + "\n";
675
+ if (!fs.existsSync(hookPath)) {
676
+ fs.writeFileSync(hookPath, "#!/usr/bin/env bash\nset -e\n\n" + block);
677
+ try { fs.chmodSync(hookPath, 0o755); } catch (_) {}
678
+ success(`Journey-coverage gate installed at ${path.relative(projectDir, hookPath)}`);
679
+ return true;
680
+ }
681
+ const existing = fs.readFileSync(hookPath, "utf8");
682
+ if (existing.includes(JOURNEY_COVERAGE_HOOK_BEGIN)) { info("Journey-coverage block already present — no change"); return true; }
683
+ fs.writeFileSync(hookPath, existing.trimEnd() + "\n\n" + block);
684
+ try { fs.chmodSync(hookPath, 0o755); } catch (_) {}
685
+ success(`Journey-coverage block appended to ${path.relative(projectDir, hookPath)}`);
686
+ return true;
687
+ }
688
+
660
689
  // Idempotent — if an existing hook references CONTEXT_METER_HOOK_MARKER the
661
690
  // command string is refreshed/migrated in-place to the canonical form.
662
691
  // Stale entries matching CONTEXT_METER_STALE_PATTERNS are migrated on the spot.
@@ -1720,6 +1749,9 @@ async function doInit(projectName) {
1720
1749
  }
1721
1750
  }
1722
1751
 
1752
+ // M52 D1: auto-install the journey-coverage gate. Idempotent — no-op if marker present.
1753
+ if (hasUI(projectDir)) installJourneyCoverageHook(projectDir);
1754
+
1723
1755
  showInitTree(projectDir);
1724
1756
  }
1725
1757
 
@@ -3055,6 +3087,12 @@ async function doDoctor(opts) {
3055
3087
  heading("Installing pre-commit hooks");
3056
3088
  installPlaywrightGateHook(process.cwd());
3057
3089
  }
3090
+ // M52 D1: opt-in install of the journey-coverage pre-commit hook.
3091
+ if (opts && opts.installJourneyHook) {
3092
+ log("");
3093
+ heading("Installing journey-coverage pre-commit hook");
3094
+ installJourneyCoverageHook(process.cwd());
3095
+ }
3058
3096
  log("");
3059
3097
  if (issues === 0) {
3060
3098
  log(`${GREEN}${BOLD} All checks passed!${RESET}`);
@@ -4325,15 +4363,22 @@ if (require.main === module) {
4325
4363
  doUninstall();
4326
4364
  break;
4327
4365
  case "doctor": {
4328
- const doctorOpts = { prune: false, installPlaywright: false, installHooks: false };
4366
+ const doctorOpts = { prune: false, installPlaywright: false, installHooks: false, installJourneyHook: false };
4329
4367
  for (let i = 1; i < args.length; i++) {
4330
4368
  if (args[i] === "--prune") doctorOpts.prune = true;
4331
4369
  if (args[i] === "--install-playwright") doctorOpts.installPlaywright = true;
4332
4370
  if (args[i] === "--install-hooks") doctorOpts.installHooks = true;
4371
+ if (args[i] === "--install-journey-hook") doctorOpts.installJourneyHook = true;
4333
4372
  }
4334
4373
  doDoctor(doctorOpts).catch((e) => { error(e.message || String(e)); process.exit(1); });
4335
4374
  break;
4336
4375
  }
4376
+ case "check-coverage": {
4377
+ const cli = path.join(__dirname, "journey-coverage-cli.cjs");
4378
+ const { spawnSync } = require("child_process");
4379
+ const res = spawnSync(process.execPath, [cli, ...args.slice(1)], { stdio: "inherit" });
4380
+ process.exit(res.status == null ? 1 : res.status);
4381
+ }
4337
4382
  case "setup-playwright": {
4338
4383
  // Single-project explicit installer. Thin wrapper around installPlaywright().
4339
4384
  const targetDir = args[1] && !args[1].startsWith("-") ? path.resolve(args[1]) : process.cwd();
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { execSync } = require('child_process');
7
+
8
+ const jc = require('./journey-coverage.cjs');
9
+
10
+ const VIEWER_FILES = [
11
+ 'scripts/gsd-t-transcript.html',
12
+ 'scripts/gsd-t-dashboard-server.js',
13
+ ];
14
+
15
+ function discoverViewerFiles(projectDir) {
16
+ const out = [];
17
+ for (const rel of VIEWER_FILES) {
18
+ if (fs.existsSync(path.join(projectDir, rel))) out.push(rel);
19
+ }
20
+ const binDir = path.join(projectDir, 'bin');
21
+ if (fs.existsSync(binDir)) {
22
+ for (const f of fs.readdirSync(binDir)) {
23
+ if (/^gsd-t-dashboard.*\.cjs$/.test(f)) out.push(path.join('bin', f));
24
+ }
25
+ }
26
+ const journeyDir = path.join(projectDir, 'e2e', 'journeys');
27
+ if (fs.existsSync(journeyDir)) {
28
+ for (const f of fs.readdirSync(journeyDir)) {
29
+ if (/\.spec\.ts$/.test(f)) out.push(path.join('e2e', 'journeys', f));
30
+ }
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function discoverStagedViewerFiles(projectDir) {
36
+ let raw;
37
+ try {
38
+ raw = execSync('git diff --cached --name-only', { cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
39
+ } catch {
40
+ return [];
41
+ }
42
+ const all = raw.split('\n').filter(Boolean);
43
+ return all.filter((rel) => jc.isViewerSource(rel));
44
+ }
45
+
46
+ function parseArgs(argv) {
47
+ const out = { stagedOnly: false, manifest: null, quiet: false, projectDir: process.cwd() };
48
+ for (let i = 0; i < argv.length; i++) {
49
+ const a = argv[i];
50
+ if (a === '--staged-only') out.stagedOnly = true;
51
+ else if (a === '--manifest') { out.manifest = argv[++i]; }
52
+ else if (a === '--quiet') out.quiet = true;
53
+ else if (a === '--project-dir') { out.projectDir = argv[++i]; }
54
+ else if (a === '-h' || a === '--help') {
55
+ process.stdout.write([
56
+ 'Usage: gsd-t check-coverage [--staged-only] [--manifest PATH] [--quiet]',
57
+ '',
58
+ 'Exit codes:',
59
+ ' 0 All detected listeners covered. Manifest fresh.',
60
+ ' 4 Coverage gap or stale manifest entry.',
61
+ ' 2 Manifest missing or unreadable (fail-closed).',
62
+ '',
63
+ ].join('\n'));
64
+ process.exit(0);
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ function main(argv) {
71
+ const opts = parseArgs(argv);
72
+ const projectDir = opts.projectDir;
73
+
74
+ // --staged-only is a gate: only RUN when at least one viewer-source file is
75
+ // staged. The actual scan always covers the full viewer source — otherwise
76
+ // a commit that touches only spec files would falsely flag every manifest
77
+ // entry as stale (because the staged-only file list contains no listeners).
78
+ if (opts.stagedOnly) {
79
+ const staged = discoverStagedViewerFiles(projectDir);
80
+ if (staged.length === 0) {
81
+ if (!opts.quiet) process.stdout.write('OK: 0 listeners, 0 specs (no staged viewer files)\n');
82
+ process.exit(0);
83
+ }
84
+ }
85
+ const files = discoverViewerFiles(projectDir);
86
+
87
+ const listeners = jc.detectListeners(files, { projectDir });
88
+
89
+ let manifest;
90
+ try {
91
+ if (opts.manifest) {
92
+ const p = path.isAbsolute(opts.manifest) ? opts.manifest : path.join(projectDir, opts.manifest);
93
+ const raw = fs.readFileSync(p, 'utf8');
94
+ manifest = JSON.parse(raw);
95
+ if (!manifest || typeof manifest !== 'object' || !Array.isArray(manifest.specs)) {
96
+ const err = new Error('manifest-shape-invalid');
97
+ err.code = 'MANIFEST_INVALID';
98
+ throw err;
99
+ }
100
+ } else {
101
+ manifest = jc.loadManifest(projectDir);
102
+ }
103
+ } catch (e) {
104
+ if (e && (e.code === 'MANIFEST_MISSING' || e.code === 'MANIFEST_UNREADABLE' || e.code === 'MANIFEST_INVALID')) {
105
+ if (listeners.length === 0) {
106
+ if (!opts.quiet) process.stdout.write('OK: 0 listeners, 0 specs (no manifest, no listeners — vacuous pass)\n');
107
+ process.exit(0);
108
+ }
109
+ process.stderr.write('check-coverage: manifest missing or unreadable: ' + (e.path || '.gsd-t/journey-manifest.json') + '\n');
110
+ process.stderr.write('hint: run a D2-authored manifest or pass --manifest PATH\n');
111
+ process.exit(2);
112
+ }
113
+ throw e;
114
+ }
115
+
116
+ const gaps = jc.findGaps(listeners, manifest);
117
+ const specCount = (manifest.specs || []).length;
118
+ if (gaps.length === 0) {
119
+ if (!opts.quiet) process.stdout.write('OK: ' + listeners.length + ' listeners, ' + specCount + ' specs\n');
120
+ process.exit(0);
121
+ }
122
+ process.stderr.write(jc.formatReport(gaps) + '\n');
123
+ process.exit(4);
124
+ }
125
+
126
+ if (require.main === module) {
127
+ main(process.argv.slice(2));
128
+ }
129
+
130
+ module.exports = { main, discoverViewerFiles, discoverStagedViewerFiles, parseArgs };
@@ -0,0 +1,347 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const VIEWER_FILE_PATTERNS = [
7
+ /^scripts\/gsd-t-transcript\.html$/,
8
+ /^scripts\/gsd-t-dashboard-server\.js$/,
9
+ /^bin\/gsd-t-dashboard.*\.cjs$/,
10
+ /^e2e\/journeys\/.*\.spec\.ts$/,
11
+ ];
12
+
13
+ const IGNORE_FILE_PATTERNS = [
14
+ /^e2e\/viewer\/.*\.spec\.ts$/,
15
+ ];
16
+
17
+ const KNOWN_KINDS = new Set([
18
+ 'addEventListener',
19
+ 'inline-handler',
20
+ 'function-call',
21
+ 'mutation-observer',
22
+ 'hashchange',
23
+ 'delegated',
24
+ ]);
25
+
26
+ const TRACKED_FUNCTIONS = new Set(['connectMain', 'connect', 'fetchMainSession']);
27
+
28
+ function isViewerSource(rel) {
29
+ if (IGNORE_FILE_PATTERNS.some((p) => p.test(rel))) return false;
30
+ return VIEWER_FILE_PATTERNS.some((p) => p.test(rel));
31
+ }
32
+
33
+ function readSource(absPath) {
34
+ return fs.readFileSync(absPath, 'utf8');
35
+ }
36
+
37
+ function lineOf(src, idx) {
38
+ let line = 1;
39
+ for (let i = 0; i < idx && i < src.length; i++) {
40
+ if (src.charCodeAt(i) === 10) line++;
41
+ }
42
+ return line;
43
+ }
44
+
45
+ function lineStartIndex(src, idx) {
46
+ let i = idx;
47
+ while (i > 0 && src[i - 1] !== '\n') i--;
48
+ return i;
49
+ }
50
+
51
+ function lineEndIndex(src, idx) {
52
+ let i = idx;
53
+ while (i < src.length && src[i] !== '\n') i++;
54
+ return i;
55
+ }
56
+
57
+ function lineText(src, idx) {
58
+ return src.slice(lineStartIndex(src, idx), lineEndIndex(src, idx));
59
+ }
60
+
61
+ function buildStringMask(src) {
62
+ const len = src.length;
63
+ const mask = new Uint8Array(len);
64
+ let i = 0;
65
+ while (i < len) {
66
+ const ch = src[i];
67
+ if (ch === '/' && src[i + 1] === '/') {
68
+ while (i < len && src[i] !== '\n') { mask[i] = 1; i++; }
69
+ continue;
70
+ }
71
+ if (ch === '/' && src[i + 1] === '*') {
72
+ mask[i] = 1; mask[i + 1] = 1; i += 2;
73
+ while (i < len && !(src[i] === '*' && src[i + 1] === '/')) { mask[i] = 1; i++; }
74
+ if (i < len) { mask[i] = 1; mask[i + 1] = 1; i += 2; }
75
+ continue;
76
+ }
77
+ if (ch === '<' && src[i + 1] === '!' && src[i + 2] === '-' && src[i + 3] === '-') {
78
+ while (i < len && !(src[i] === '-' && src[i + 1] === '-' && src[i + 2] === '>')) { mask[i] = 1; i++; }
79
+ if (i < len) { mask[i] = 1; mask[i + 1] = 1; mask[i + 2] = 1; i += 3; }
80
+ continue;
81
+ }
82
+ if (ch === "'" || ch === '"' || ch === '`') {
83
+ const quote = ch;
84
+ mask[i] = 1; i++;
85
+ while (i < len) {
86
+ if (src[i] === '\\') {
87
+ mask[i] = 1;
88
+ if (i + 1 < len) mask[i + 1] = 1;
89
+ i += 2;
90
+ continue;
91
+ }
92
+ if (src[i] === quote) { mask[i] = 1; i++; break; }
93
+ if (quote !== '`' && src[i] === '\n') break;
94
+ mask[i] = 1; i++;
95
+ }
96
+ continue;
97
+ }
98
+ i++;
99
+ }
100
+ return mask;
101
+ }
102
+
103
+ function masked(mask, idx) {
104
+ return mask[idx] === 1;
105
+ }
106
+
107
+ function isFeatureDetectGuard(src, matchIdx) {
108
+ const line = lineText(src, matchIdx);
109
+ return /if\s*\(\s*!\s*\w+(\.\w+)?\.addEventListener\s*\)/.test(line);
110
+ }
111
+
112
+ function isEslintExempt(src, matchIdx) {
113
+ const lineStart = lineStartIndex(src, matchIdx);
114
+ if (lineStart === 0) return false;
115
+ const prevLineEnd = lineStart - 1;
116
+ const prevLineStart = lineStartIndex(src, prevLineEnd - 1);
117
+ const prevLine = src.slice(prevLineStart, prevLineEnd);
118
+ if (!/eslint-disable/.test(prevLine)) return false;
119
+ return /journey-coverage/.test(prevLine);
120
+ }
121
+
122
+ function findReceiverBeforeDot(src, dotIdx) {
123
+ let i = dotIdx - 1;
124
+ while (i >= 0 && /\s/.test(src[i])) i--;
125
+ let depth = 0;
126
+ const end = i + 1;
127
+ while (i >= 0) {
128
+ const ch = src[i];
129
+ if (ch === ')' || ch === ']') { depth++; i--; continue; }
130
+ if (ch === '(' || ch === '[') {
131
+ if (depth === 0) break;
132
+ depth--; i--; continue;
133
+ }
134
+ if (depth > 0) { i--; continue; }
135
+ if (/[A-Za-z0-9_$.\?]/.test(ch)) { i--; continue; }
136
+ break;
137
+ }
138
+ return src.slice(i + 1, end).trim();
139
+ }
140
+
141
+ function looksLikeDelegatedHandler(src, matchIdx, matchLen) {
142
+ const tail = src.slice(matchIdx + matchLen, matchIdx + matchLen + 600);
143
+ if (!/=>\s*\{|function\s*\(/.test(tail)) return false;
144
+ return /e\.target\.matches\s*\(|event\.target\.matches\s*\(/.test(tail);
145
+ }
146
+
147
+ function extractDelegatedSelector(src, matchIdx) {
148
+ const tail = src.slice(matchIdx, matchIdx + 600);
149
+ const m = /\.target\.matches\s*\(\s*(['"])([^'"]+)\1/.exec(tail);
150
+ return m ? m[2] : '';
151
+ }
152
+
153
+ function detectAddEventListener(file, src, mask, listeners) {
154
+ const re = /\.addEventListener\s*\(\s*(['"])([A-Za-z_][\w-]*)\1/g;
155
+ let m;
156
+ while ((m = re.exec(src)) !== null) {
157
+ const matchIdx = m.index;
158
+ if (masked(mask, matchIdx)) continue;
159
+ if (isFeatureDetectGuard(src, matchIdx)) continue;
160
+ const event = m[2];
161
+ const receiver = findReceiverBeforeDot(src, matchIdx);
162
+ if (!receiver) continue;
163
+ if (isEslintExempt(src, matchIdx)) continue;
164
+ let kind = 'addEventListener';
165
+ let selector;
166
+ if (receiver === 'window') {
167
+ if (event === 'hashchange') {
168
+ kind = 'hashchange';
169
+ selector = 'window:hashchange';
170
+ } else {
171
+ selector = 'window:' + event;
172
+ }
173
+ } else if (looksLikeDelegatedHandler(src, matchIdx, m[0].length)) {
174
+ kind = 'delegated';
175
+ const matchesSel = extractDelegatedSelector(src, matchIdx);
176
+ selector = receiver + ':' + event + (matchesSel ? '|' + matchesSel : '');
177
+ } else {
178
+ selector = receiver + ':' + event;
179
+ }
180
+ const line = lineOf(src, matchIdx);
181
+ const raw = lineText(src, matchIdx).trim();
182
+ listeners.push({ file, line, selector, kind, raw });
183
+ }
184
+ }
185
+
186
+ function detectInlineHandler(file, src, mask, listeners) {
187
+ const re = /<(\w+)([^>]*?)\s+on(\w+)\s*=\s*(['"])([^'"]*)\4/g;
188
+ let m;
189
+ while ((m = re.exec(src)) !== null) {
190
+ const matchIdx = m.index;
191
+ if (masked(mask, matchIdx)) continue;
192
+ const tag = m[1];
193
+ const attrs = m[2];
194
+ const event = m[3].toLowerCase();
195
+ const idMatch = /\sid\s*=\s*(['"])([^'"]+)\1/.exec(attrs);
196
+ const id = idMatch ? idMatch[2] : null;
197
+ const selector = id ? id + ':' + event : tag + ':' + event;
198
+ if (isEslintExempt(src, matchIdx)) continue;
199
+ const line = lineOf(src, matchIdx);
200
+ const raw = lineText(src, matchIdx).trim();
201
+ listeners.push({ file, line, selector, kind: 'inline-handler', raw });
202
+ }
203
+ }
204
+
205
+ function detectMutationObserver(file, src, mask, listeners) {
206
+ const re = /new\s+MutationObserver\s*\(/g;
207
+ let m;
208
+ let counter = 0;
209
+ while ((m = re.exec(src)) !== null) {
210
+ const matchIdx = m.index;
211
+ if (masked(mask, matchIdx)) continue;
212
+ counter++;
213
+ const selector = 'mutation-observer:' + path.basename(file) + ':' + counter;
214
+ if (isEslintExempt(src, matchIdx)) continue;
215
+ const line = lineOf(src, matchIdx);
216
+ const raw = lineText(src, matchIdx).trim();
217
+ listeners.push({ file, line, selector, kind: 'mutation-observer', raw });
218
+ }
219
+ }
220
+
221
+ function detectFunctionCall(file, src, mask, listeners) {
222
+ const defRe = /(?:^|\n)\s*(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/g;
223
+ const defs = new Map();
224
+ let m;
225
+ while ((m = defRe.exec(src)) !== null) {
226
+ const matchIdx = m.index;
227
+ if (masked(mask, matchIdx)) continue;
228
+ if (!defs.has(m[1])) defs.set(m[1], matchIdx);
229
+ }
230
+ if (!defs.size) return;
231
+ for (const [fnName, defIdx] of defs) {
232
+ if (!TRACKED_FUNCTIONS.has(fnName)) continue;
233
+ const callRe = new RegExp('(?<![\\w.])' + fnName + '\\s*\\(', 'g');
234
+ let cm;
235
+ let foundCall = false;
236
+ while ((cm = callRe.exec(src)) !== null) {
237
+ const before = src.slice(Math.max(0, cm.index - 12), cm.index);
238
+ if (/\bfunction\s+$/.test(before)) continue;
239
+ if (masked(mask, cm.index)) continue;
240
+ foundCall = true; break;
241
+ }
242
+ if (!foundCall) continue;
243
+ if (isEslintExempt(src, defIdx)) continue;
244
+ const line = lineOf(src, defIdx);
245
+ const raw = lineText(src, defIdx).trim();
246
+ listeners.push({ file, line, selector: fnName, kind: 'function-call', raw });
247
+ }
248
+ }
249
+
250
+ function detectListeners(filepaths, opts = {}) {
251
+ const projectDir = opts.projectDir || process.cwd();
252
+ const listeners = [];
253
+ for (const fp of filepaths) {
254
+ const rel = path.isAbsolute(fp) ? path.relative(projectDir, fp) : fp;
255
+ if (!isViewerSource(rel)) continue;
256
+ const abs = path.isAbsolute(fp) ? fp : path.join(projectDir, fp);
257
+ let src;
258
+ try { src = readSource(abs); } catch { continue; }
259
+ const mask = buildStringMask(src);
260
+ detectAddEventListener(rel, src, mask, listeners);
261
+ detectInlineHandler(rel, src, mask, listeners);
262
+ detectMutationObserver(rel, src, mask, listeners);
263
+ detectFunctionCall(rel, src, mask, listeners);
264
+ }
265
+ return listeners;
266
+ }
267
+
268
+ function loadManifest(projectDir) {
269
+ const p = path.join(projectDir, '.gsd-t', 'journey-manifest.json');
270
+ if (!fs.existsSync(p)) {
271
+ const err = new Error('manifest-missing');
272
+ err.code = 'MANIFEST_MISSING';
273
+ err.path = p;
274
+ throw err;
275
+ }
276
+ let raw;
277
+ try { raw = fs.readFileSync(p, 'utf8'); }
278
+ catch (e) {
279
+ const err = new Error('manifest-unreadable: ' + e.message);
280
+ err.code = 'MANIFEST_UNREADABLE';
281
+ throw err;
282
+ }
283
+ let parsed;
284
+ try { parsed = JSON.parse(raw); }
285
+ catch (e) {
286
+ const err = new Error('manifest-invalid-json: ' + e.message);
287
+ err.code = 'MANIFEST_INVALID';
288
+ throw err;
289
+ }
290
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.specs)) {
291
+ const err = new Error('manifest-shape-invalid');
292
+ err.code = 'MANIFEST_INVALID';
293
+ throw err;
294
+ }
295
+ return parsed;
296
+ }
297
+
298
+ const KEY_SEP = '||';
299
+
300
+ function findGaps(listeners, manifest) {
301
+ const covered = new Set();
302
+ const declared = [];
303
+ for (const spec of manifest.specs || []) {
304
+ for (const c of spec.covers || []) {
305
+ covered.add(c.file + KEY_SEP + c.selector);
306
+ declared.push({ name: spec.name, file: c.file, selector: c.selector, kind: c.kind });
307
+ }
308
+ }
309
+ const detectedKey = new Set();
310
+ for (const l of listeners) detectedKey.add(l.file + KEY_SEP + l.selector);
311
+ const gaps = [];
312
+ for (const l of listeners) {
313
+ const key = l.file + KEY_SEP + l.selector;
314
+ if (!covered.has(key)) {
315
+ gaps.push({ type: 'gap', file: l.file, line: l.line, selector: l.selector, kind: l.kind });
316
+ }
317
+ }
318
+ for (const d of declared) {
319
+ const key = d.file + KEY_SEP + d.selector;
320
+ if (!detectedKey.has(key)) {
321
+ gaps.push({ type: 'stale', name: d.name, file: d.file, selector: d.selector });
322
+ }
323
+ }
324
+ return gaps;
325
+ }
326
+
327
+ function formatReport(gaps) {
328
+ if (!gaps.length) return '';
329
+ const lines = [];
330
+ for (const g of gaps) {
331
+ if (g.type === 'gap') {
332
+ lines.push('GAP: ' + g.file + ':' + g.line + ' ' + g.selector + ' (' + g.kind + ') no spec covers this');
333
+ } else {
334
+ lines.push('STALE: spec=' + g.name + ' covers ' + g.file + ' selector=' + g.selector + ' no such listener');
335
+ }
336
+ }
337
+ return lines.join('\n');
338
+ }
339
+
340
+ module.exports = {
341
+ detectListeners,
342
+ loadManifest,
343
+ findGaps,
344
+ formatReport,
345
+ isViewerSource,
346
+ KNOWN_KINDS,
347
+ };
@@ -1100,3 +1100,20 @@ M50 retires the prose-only "Playwright Readiness Guard" in favor of executable e
1100
1100
  CLI surface added in M50: `gsd-t setup-playwright [path]` (single-project explicit installer), `gsd-t doctor --install-playwright` (fix-it-now flag), `gsd-t doctor --install-hooks` (pre-commit-gate installer). `gsd-t init` and `gsd-t update-all` invoke `installPlaywright` automatically for any UI project that's missing it.
1101
1101
 
1102
1102
  Contract: `.gsd-t/contracts/playwright-bootstrap-contract.md` v1.0.0.
1103
+
1104
+ ## Journey Coverage Enforcement (M52, v3.22.x+)
1105
+
1106
+ M52 layers a journey-coverage gate on top of M50's Playwright enforcement. M50 makes sure Playwright runs; M52 makes sure every interactive viewer surface has a journey spec asserting user-visible state change. Three components:
1107
+
1108
+ 1. **Listener detector** (`bin/journey-coverage.cjs`) — regex-based source-form scanner with single-pass string-mask precomputation (handles `//`, `/*…*/`, `<!-- -->`, `'`/`"`/template literals). Recognises 6 listener kinds per `journey-coverage-contract.md` §3: `addEventListener`, `inline-handler`, `function-call`, `mutation-observer`, `hashchange`, `delegated`. Exports `detectListeners`, `loadManifest`, `findGaps`, `formatReport`. Zero parser deps (no acorn/babel). Sub-100ms on the full viewer file set.
1109
+
1110
+ 2. **CLI** (`bin/journey-coverage-cli.cjs` → `gsd-t check-coverage`) — supports `--staged-only`, `--manifest PATH`, `--quiet`. Exit codes per contract §5: 0 = clean, 4 = coverage gap or stale entry, 2 = manifest missing/unreadable. Vacuous-pass when zero listeners + zero specs.
1111
+
1112
+ 3. **Commit-time gate** (`scripts/hooks/pre-commit-journey-coverage`) — auto-installed by `gsd-t install` and `gsd-t init`, manually re-installable via `gsd-t doctor --install-journey-hook`. Fires when staged files match the viewer-source pattern set (`scripts/gsd-t-transcript.html`, `scripts/gsd-t-dashboard-server.js`, `bin/gsd-t-dashboard*.cjs`, `e2e/journeys/`, `e2e/viewer/`). Idempotent install via `# >>> GSD-T journey-coverage gate >>>` marker block (mirrors M50 idiom). Fail-open on detector internal exception.
1113
+
1114
+ The content layer that the enforcer measures against ships in M52 D2: 12 inaugural journey specs in `e2e/journeys/` (one per interactive surface), `.gsd-t/journey-manifest.json` with 12 entries 1:1 with the spec files, and 3 real-data NDJSON fixtures in `e2e/fixtures/journeys/` (sliced from captured `.gsd-t/transcripts/in-session-*.ndjson` files with PII scrub applied). Every assertion verifies state changed / data flowed / content loaded / widget responded — zero `toBeVisible`/`toBeAttached` shallow assertions across the 12 specs.
1115
+
1116
+ A new Red Team category — "Test Pass-Through — Journey Edition" — extends `templates/prompts/red-team-subagent.md` to mandate adversarial validation: write ≥5 broken viewer patches, run the journey specs, every patch must be caught (verdict FAIL otherwise).
1117
+
1118
+ Contract: `.gsd-t/contracts/journey-coverage-contract.md` v1.0.0.
1119
+
@@ -727,3 +727,25 @@ Acceptance:
727
727
  Supporting contracts:
728
728
  - `.gsd-t/contracts/playwright-bootstrap-contract.md` v1.0.0 — D1 library API + CLI wiring + idempotency invariants + error-path contract.
729
729
  - `.gsd-t/contracts/m50-integration-points.md` — D1↔D2 cross-domain checkpoint, the `bin/gsd-t.js` file-overlap coordination rules, and the doc-ripple ordering.
730
+
731
+
732
+ ## M52 Rigorous User-Journey Coverage + Anti-Drift Test Quality (planned — 2026-05-06)
733
+
734
+ | REQ-ID | Requirement Summary | Domain | Task(s) | Status |
735
+ |--------|---------------------|--------|---------|--------|
736
+ | REQ-M52-D1-01 | `bin/journey-coverage.cjs` walks `scripts/gsd-t-transcript.html` for every interactive listener (click, keydown, change, mousedown, drag, hashchange) and emits a normalized listing keyed by selector + event. | m52-d1-journey-coverage-tooling | T1 | done |
737
+ | REQ-M52-D1-02 | `bin/journey-coverage.cjs` cross-references the listener listing against `e2e/journeys/*.spec.ts` to verify each listener has a corresponding journey spec asserting state change; reports missing-coverage rows; exits non-zero on gaps. | m52-d1-journey-coverage-tooling | T1, T3 | done (T1 detector + findGaps; CLI exit codes pending T3) |
738
+ | REQ-M52-D1-03 | `journey-coverage-contract.md` (manifest schema + listener-pattern catalogue + gap rules + exit codes) STABLE; `.gsd-t/journey-manifest.json` (D2-authored) is the canonical enumeration of interactive viewer surfaces — supersedes the originally-proposed standalone `JOURNEYS.md` file. | m52-d1-journey-coverage-tooling | T2 | done |
739
+ | REQ-M52-D1-04 | `scripts/hooks/pre-commit-journey-coverage` (auto-installed by `gsd-t install` and re-installable via `gsd-t doctor --install-journey-hook`, same shape as `pre-commit-playwright-gate`) blocks commits when viewer-source files are staged AND `bin/journey-coverage-cli.cjs --staged-only` reports uncovered surfaces. Fail-open on detector internal exception. | m52-d1-journey-coverage-tooling | T4 | done |
740
+ | REQ-M52-D1-05 | `bin/gsd-t.js` wired with `installJourneyCoverageHook`, `check-coverage` subcommand, install-flow autoinstall, and `--install-journey-hook` doctor flag. Idempotent marker. | m52-d1-journey-coverage-tooling | T5 | done |
741
+ | REQ-M52-D2-01 | 12 inaugural journey specs land in `e2e/journeys/`: `main-session-stream`, `click-completed-conversation`, `click-spawn-entry`, `splitter-drag`, `splitter-keyboard`, `right-rail-toggle`, `completed-collapse-toggle`, `auto-follow-toggle`, `kill-button`, `sessionstorage-persistence`, `keyboard-shortcuts`, `hashchange`. Every assertion proves user-visible state change (no `toBeVisible`-only specs). | m52-d2-journey-specs-and-fixtures | T2, T3, T4 | done |
742
+ | REQ-M52-D2-02 | All 12 journey specs pass against the v3.22.x viewer (`scripts/gsd-t-transcript.html` + `scripts/gsd-t-dashboard-server.js`); `.gsd-t/journey-manifest.json` has 12 entries 1:1 with the spec files; `gsd-t check-coverage` exit 0. | m52-d2-journey-specs-and-fixtures | T4 | done |
743
+ | REQ-M52-D3-01 | `templates/prompts/red-team-subagent.md` adds new mandatory category "Test Pass-Through — Journey Edition": for each journey spec, the adversary writes a deliberately-broken viewer impl that breaks the journey but satisfies the literal assertions of another spec. Each pass-through is a spec failure → tighten. | m52-d2-journey-specs-and-fixtures | T5 | done |
744
+ | REQ-M52-D3-02 | M52 D2 Red Team run: ≥5 broken impls written against the 12 D2 journey specs; all caught; findings logged to `.gsd-t/red-team-report.md` § "M52 JOURNEY-EDITION RED TEAM"; pre-commit-journey-coverage hook exercised end-to-end (block + unblock paths). | m52-d2-journey-specs-and-fixtures | T5 | done |
745
+ | REQ-M52-D4-01 | `e2e/fixtures/journeys/` ships 3 real-data NDJSONs sliced from `.gsd-t/transcripts/in-session-*.ndjson`: `fixture-medium-session.ndjson` (~50 frames), `fixture-completed-session.ndjson` (~150 frames), `fixture-multi-spawn.ndjson` (~80 frames across 3 spawns). PII scrub applied. | m52-d2-journey-specs-and-fixtures | T1 | done |
746
+ | REQ-M52-D4-02 | `e2e/fixtures/journeys/replay-helpers.ts` exports `replayFixture(page, fixturePath)` that drives at least 3 of the 12 journey specs end-to-end via fixture replay and asserts: page renders without errors, frame count matches expected, scrolling works, no `[object Object]`/`undefined` literals visible, all bubble types render correctly. | m52-d2-journey-specs-and-fixtures | T1, T2 | done (replay-helpers shipped + main-session-stream uses fixture-medium-session via startReplayServer) |
747
+ | REQ-M52-D5-01 | Doc-ripple: `~/.claude/CLAUDE.md` E2E Test Quality Standard rewritten to formally define "rigorous" (every interactive surface clicked, every assertion proves visible state change, journey specs not unit tests in browser clothing, real-data fixtures, adversarial Red Team on journeys); `templates/CLAUDE-global.md` matches; `commands/gsd-t-debug.md` + `gsd-t-execute.md` + `gsd-t-quick.md` + `gsd-t-verify.md` reference `journey-coverage.cjs` zero-gap requirement; `docs/architecture.md` adds "Journey Coverage Enforcement (M52)" section. | m52-d1-journey-coverage-tooling | T5 | done (architecture.md + CHANGELOG.md ripple completed during /gsd-t-verify; CLAUDE-global E2E Test Quality Standard already defines "functional behavior over element existence" — same doctrine M52 enforces mechanically) |
748
+ | REQ-M52-VERIFY | Full unit suite 2166 baseline preserved + 12 journey specs + 3 real-data fixture replays all green; `gsd-t check-coverage` reports zero gaps; pre-commit-journey-coverage hook blocks deliberate test-commit-without-spec; Red Team finds ≥5 breakages, all caught; CHANGELOG entry written. | both | D1 T5, D2 T5 | done (unit 2195/2195, E2E 35/35 + 1 skip, `gsd-t check-coverage` exit 0, hook end-to-end exercised, Red Team 5/5 caught, CHANGELOG entry written) |
749
+
750
+ Supporting contracts (to be written during D1):
751
+ - `.gsd-t/contracts/journey-coverage-contract.md` (proposed) — listener detector API, gap-report schema, pre-commit hook semantics, JOURNEYS.md schema.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "3.22.10",
3
+ "version": "3.23.10",
4
4
  "description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, 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",
@@ -870,14 +870,10 @@
870
870
  });
871
871
  el.appendChild(dot); el.appendChild(name); el.appendChild(kill);
872
872
  el.addEventListener('click', () => {
873
- // M48 — symmetric with renderRailEntry: in-session entries
874
- // belong to the TOP pane only. Without this guard the legacy
875
- // renderTree path (called for the `live` bucket when ≥2
876
- // in-session NDJSONs exist) would mutate location.hash to an
877
- // in-session-* value, polluting the URL and the rail's active
878
- // highlight even though the hashchange handler now blocks
879
- // bottom-pane SSE pinning.
880
- if (isInSession(node)) return;
873
+ // M52 — symmetric with renderRailEntry: only the LIVE main
874
+ // session id is blocked from the bottom pane. Historical /
875
+ // additional in-session entries route normally.
876
+ if (isInSession(node) && node.spawnId === ('in-session-' + window.__mainSessionId)) return;
881
877
  if (node.spawnId === currentId) return;
882
878
  location.hash = node.spawnId;
883
879
  });
@@ -1006,12 +1002,13 @@
1006
1002
  });
1007
1003
  el.appendChild(dot); el.appendChild(name); el.appendChild(kill);
1008
1004
  el.addEventListener('click', () => {
1009
- // M48in-session conversation entries belong in the TOP pane only.
1010
- // The top pane is wired to /api/main-session and streams the
1011
- // current orchestrator session. Routing them through location.hash
1012
- // would also load them into the bottom pane (the SELECTED-SPAWN
1013
- // pane), making both panes show identical content.
1014
- if (isInSession) return;
1005
+ // M52narrow the M48 Bug 4 guard. The TOP pane streams the LIVE
1006
+ // main session via /api/main-session; only THAT specific in-session
1007
+ // id should be blocked from the bottom pane (otherwise both panes
1008
+ // would show identical content). Historical in-session conversations
1009
+ // (the entire COMPLETED rail) are tagged in-session-* but are NOT
1010
+ // the live main session — they must load into the bottom pane.
1011
+ if (isInSession && node.spawnId === ('in-session-' + window.__mainSessionId)) return;
1015
1012
  if (node.spawnId === currentId) return;
1016
1013
  _ssSet(SS_KEY_SELECTED, node.spawnId);
1017
1014
  location.hash = node.spawnId;
@@ -1251,6 +1248,10 @@
1251
1248
  const mainStreamEl = document.getElementById('main-stream');
1252
1249
  function connectMain(sessionId) {
1253
1250
  if (mainSrc) { try { mainSrc.close(); } catch { /* gone */ } mainSrc = null; }
1251
+ // M52 — expose the active main session id so the bottom-pane click +
1252
+ // hashchange handlers can distinguish the LIVE main session (must stay
1253
+ // top-only) from HISTORICAL in-session conversations (loadable below).
1254
+ window.__mainSessionId = sessionId || null;
1254
1255
  if (!mainStreamEl) return;
1255
1256
  mainStreamEl.innerHTML = '';
1256
1257
  if (!sessionId) {
@@ -1284,6 +1285,18 @@
1284
1285
  .then((j) => {
1285
1286
  if (j && j.sessionId) {
1286
1287
  connectMain(j.sessionId);
1288
+ // M52 — if the bottom pane was seeded with the live main
1289
+ // session's id (e.g., from sessionStorage on reload), clear it
1290
+ // out to prevent the M48 Bug 4 dual-pane mirror.
1291
+ const liveId = 'in-session-' + j.sessionId;
1292
+ const seededId = _ssGet(SS_KEY_SELECTED) || '';
1293
+ if (seededId === liveId) {
1294
+ _ssSet(SS_KEY_SELECTED, '');
1295
+ if ((location.hash || '').slice(1) === liveId) {
1296
+ try { history.replaceState(null, '', location.pathname + location.search); } catch { /* ok */ }
1297
+ }
1298
+ connect('');
1299
+ }
1287
1300
  const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
1288
1301
  try { console.debug('[m47] top-pane connected in', Math.round(t1 - t0), 'ms'); } catch { /* ok */ }
1289
1302
  } else {
@@ -1295,8 +1308,10 @@
1295
1308
 
1296
1309
  window.addEventListener('hashchange', () => {
1297
1310
  const id = (location.hash || '').slice(1);
1298
- // M48keep in-session-* ids out of the bottom pane (top pane only).
1299
- if (id && id.indexOf('in-session-') === 0) { return; }
1311
+ // M52narrow the M48 Bug 4 guard. Only the LIVE main session's
1312
+ // in-session id is blocked; historical in-session-* hashes load into
1313
+ // the bottom pane normally (connect() already handles the SSE).
1314
+ if (id && id === ('in-session-' + window.__mainSessionId)) { return; }
1300
1315
  if (id) { connect(id); pollSpawns(); }
1301
1316
  });
1302
1317
 
@@ -1304,19 +1319,15 @@
1304
1319
  // 1. data-spawn-id non-empty → connect that (bookmark flow)
1305
1320
  // 2. else sessionStorage.selectedSpawnId → connect that
1306
1321
  // 3. else show empty state
1307
- // M48never seed the bottom pane with an in-session-* id; the top
1308
- // pane already owns the main session, and showing it in both panes
1309
- // is one of the regressions Bug 4 fixes.
1322
+ // M52historical in-session-* ids may seed the bottom pane (the M48
1323
+ // Bug 4 case is now narrowed to the LIVE main session only, handled by
1324
+ // the click + hashchange + fetchMainSession callback).
1310
1325
  let initialBottomId = '';
1311
1326
  if (spawnId) {
1312
1327
  initialBottomId = spawnId;
1313
1328
  } else {
1314
1329
  initialBottomId = _ssGet(SS_KEY_SELECTED) || '';
1315
1330
  }
1316
- if (typeof initialBottomId === 'string' && initialBottomId.indexOf('in-session-') === 0) {
1317
- initialBottomId = '';
1318
- _ssSet(SS_KEY_SELECTED, '');
1319
- }
1320
1331
  if (initialBottomId && !location.hash) location.hash = initialBottomId;
1321
1332
  connect(initialBottomId);
1322
1333
 
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env bash
2
+ # GSD-T journey-coverage gate (M52 D1)
3
+ # Blocks commits that touch viewer-source files when uncovered listeners are
4
+ # detected by `bin/journey-coverage-cli.cjs --staged-only`.
5
+ #
6
+ # Install (opt-in): gsd-t doctor --install-journey-hook
7
+ # Remove: rm .git/hooks/pre-commit (or remove the marker block if
8
+ # merged into an existing hook)
9
+ #
10
+ # Exit codes:
11
+ # 0 — clean (no viewer-source files staged, OR coverage clean, OR fail-open)
12
+ # 1 — blocked (uncovered listener in staged viewer-source files)
13
+ #
14
+ # Fail-open philosophy: a broken hook is worse than a permissive one. Detector
15
+ # internal exception → exit 0 with a stderr warning, never block.
16
+
17
+ set -e
18
+
19
+ ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
20
+
21
+ VIEWER_SOURCE_PATTERNS=(
22
+ "scripts/gsd-t-transcript.html"
23
+ "scripts/gsd-t-dashboard-server.js"
24
+ "bin/gsd-t-dashboard"
25
+ "e2e/journeys/"
26
+ "e2e/viewer/"
27
+ )
28
+
29
+ STAGED="$(git diff --cached --name-only --diff-filter=AM 2>/dev/null || true)"
30
+ [ -z "$STAGED" ] && exit 0
31
+
32
+ matches=""
33
+ while IFS= read -r f; do
34
+ for p in "${VIEWER_SOURCE_PATTERNS[@]}"; do
35
+ case "$f" in
36
+ "$p"|"$p"*) matches="$matches $f"; break ;;
37
+ esac
38
+ done
39
+ done <<EOF
40
+ $STAGED
41
+ EOF
42
+
43
+ if [ -z "$matches" ]; then
44
+ exit 0
45
+ fi
46
+
47
+ CLI="$ROOT/bin/journey-coverage-cli.cjs"
48
+ if [ ! -f "$CLI" ]; then
49
+ echo "[journey-coverage] WARNING: $CLI not found — fail-open." >&2
50
+ exit 0
51
+ fi
52
+
53
+ # Run the CLI in --staged-only mode. Capture stdout+stderr; rely on exit code.
54
+ set +e
55
+ OUT="$(node "$CLI" --staged-only --project-dir "$ROOT" 2>&1)"
56
+ RC=$?
57
+ set -e
58
+
59
+ case "$RC" in
60
+ 0)
61
+ exit 0
62
+ ;;
63
+ 4)
64
+ echo "[journey-coverage] BLOCKED: uncovered viewer listener in staged files." >&2
65
+ echo "$OUT" >&2
66
+ echo "" >&2
67
+ echo " Add a journey spec under e2e/journeys/ and update .gsd-t/journey-manifest.json." >&2
68
+ exit 1
69
+ ;;
70
+ 2)
71
+ echo "[journey-coverage] BLOCKED: manifest missing/unreadable." >&2
72
+ echo "$OUT" >&2
73
+ exit 1
74
+ ;;
75
+ *)
76
+ # Unknown exit — fail-open so a detector bug doesn't break workflow.
77
+ echo "[journey-coverage] WARNING: detector exited $RC (fail-open):" >&2
78
+ echo "$OUT" >&2
79
+ exit 0
80
+ ;;
81
+ esac
@@ -42,3 +42,56 @@ Summary:
42
42
  - VERDICT: `FAIL` ({N} bugs found) | `GRUDGING PASS` (exhaustive search, nothing found)
43
43
 
44
44
  Write findings to `.gsd-t/red-team-report.md`. If bugs found, also append to `.gsd-t/qa-issues.md`.
45
+
46
+ ## Test Pass-Through — Journey Edition (M52)
47
+
48
+ **Activates when**: `.gsd-t/journey-manifest.json` exists AND `e2e/journeys/` is non-empty (M52 D2 has landed).
49
+
50
+ **Goal**: Prove the journey specs catch real regressions in the journeys they
51
+ claim to cover. A journey spec that only checks "the button exists and is
52
+ clickable" passes through any breakage to the user — that is what this
53
+ category attacks.
54
+
55
+ **Protocol**:
56
+
57
+ 1. For each spec in `.gsd-t/journey-manifest.json`, identify the listener(s) it covers.
58
+ 2. Write a deliberately-broken patch to `scripts/gsd-t-transcript.html` that
59
+ targets that listener — examples:
60
+ - Remove the listener entirely (`addEventListener` line stripped).
61
+ - Comment out the side-effect inside the handler (e.g. `_ssSet` call).
62
+ - Swap a sessionStorage key name (e.g. splitterPct key → `'XXX'`).
63
+ - Stub the handler to early-return (`if (true) return;` at top).
64
+ - Reverse a state mutation (`next ? 'true' : 'false'` → `next ? 'false' : 'true'`).
65
+ 3. Run the journey spec against the broken viewer.
66
+ 4. **PASS**: spec FAILS (red) → revert patch → spec PASSES (green). Record `caught`.
67
+ 5. **FAIL**: spec PASSES with broken viewer → SHALLOW SPEC, must be tightened.
68
+ Record `pass-through` and rewrite the assertion to verify state change /
69
+ data flow / content load.
70
+ 6. Write at least 5 broken patches across different specs. Each pass-through
71
+ is a verdict-level FAIL until rewritten.
72
+
73
+ **Hook end-to-end exercise** (also part of this category):
74
+ - Stage a viewer-source diff that adds a NEW listener with no manifest entry.
75
+ - Confirm `pre-commit-journey-coverage` blocks the commit (exit 1).
76
+ - Update `.gsd-t/journey-manifest.json` with a covering entry.
77
+ - Confirm the hook now allows the commit (exit 0).
78
+ - Both transitions logged in `.gsd-t/red-team-report.md` § "M52 JOURNEY-EDITION RED TEAM".
79
+
80
+ **Findings format** in `.gsd-t/red-team-report.md` (append-only section):
81
+ ```
82
+ ## M52 JOURNEY-EDITION RED TEAM — {date}
83
+
84
+ ### Patch {N}: {short-name}
85
+ - **Spec**: {spec-name}
86
+ - **Broken-line(s)**: file:line — {one-line description of patch}
87
+ - **Expected**: spec FAILS, caught the regression
88
+ - **Actual**: {fail|pass-through}
89
+ - **Verdict**: caught | PASS-THROUGH (must rewrite spec)
90
+
91
+ ### Hook end-to-end
92
+ - Block exercise: {git diff details, exit code, stderr summary}
93
+ - Unblock exercise: {manifest update, exit code, stderr summary}
94
+
95
+ ### VERDICT
96
+ {GRUDGING PASS — N patches written, all caught | FAIL — {M} pass-through(s)}
97
+ ```