@tekyzinc/gsd-t 3.22.11 → 3.23.11

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.
@@ -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
+ };
@@ -1008,6 +1008,15 @@ defined in `.gsd-t/contracts/parallelism-report-contract.md` v1.0.0.
1008
1008
  Per-spawn timeline, Per-gate decisions, Per-worker Gantt, Token cost, and
1009
1009
  Notes sections.
1010
1010
 
1011
+ **Install location**: the dashboard server (installed at
1012
+ `~/.claude/scripts/gsd-t-dashboard-server.js`) resolves
1013
+ `require(path.join(__dirname, "..", "bin", "parallelism-report.cjs"))` at
1014
+ request time, so the module must live at **`~/.claude/bin/parallelism-report.cjs`**.
1015
+ The installer handles this via `installGlobalBinTools()` (driven by
1016
+ `GLOBAL_BIN_TOOLS` in `bin/gsd-t.js`), and `gsd-t doctor` flags any missing
1017
+ entry. This is distinct from `PROJECT_BIN_TOOLS`, which copies into each
1018
+ registered project's local `bin/`.
1019
+
1011
1020
  **Data flow**:
1012
1021
 
1013
1022
  ```
@@ -1100,3 +1109,20 @@ M50 retires the prose-only "Playwright Readiness Guard" in favor of executable e
1100
1109
  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
1110
 
1102
1111
  Contract: `.gsd-t/contracts/playwright-bootstrap-contract.md` v1.0.0.
1112
+
1113
+ ## Journey Coverage Enforcement (M52, v3.22.x+)
1114
+
1115
+ 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:
1116
+
1117
+ 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.
1118
+
1119
+ 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.
1120
+
1121
+ 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.
1122
+
1123
+ 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.
1124
+
1125
+ 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).
1126
+
1127
+ Contract: `.gsd-t/contracts/journey-coverage-contract.md` v1.0.0.
1128
+
@@ -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.11",
3
+ "version": "3.23.11",
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