@tekyzinc/gsd-t 3.21.10 → 3.21.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [3.21.11] - 2026-05-06
6
+
7
+ ### Fixed — viewer: 4 rendering regressions surfaced post-M47
8
+
9
+ The M47 viewer redesign shipped four user-visible rendering bugs that only became apparent when a project's in-session conversation was actually being viewed against a non-GSD-T project. Discovered when the dashboard for `Move-Zoom-Recordings-to-GDrive` showed three captured `in-session-*.ndjson` files but rendered them with a hardcoded "GSD-T Transcript" header, identical timestamps on every frame, raw `JSON.stringify` dumps in place of chat turns, and the same content in both top and bottom panes.
10
+
11
+ **Changes:**
12
+ - `scripts/gsd-t-dashboard-server.js`: `<title>` and `.title` div now carry a `__PROJECT_NAME__` placeholder substituted server-side via `path.basename(path.resolve(projectDir))` in both `handleTranscriptsList` and `handleTranscriptPage`. New `_escapeHtml()` helper escapes `<` / `&` / `"` in basenames; the substitution uses the function form of `replace` to defuse `$&` / `$1` / `$$` backreference patterns in basenames (Red Team BUG-1).
13
+ - `scripts/gsd-t-transcript.html`:
14
+ - `frameTs(frame, fallback)` parses each frame's ISO `ts` field and only falls back to the SSE-handler-captured `arrivedAt` when absent or invalid. `connect()` and `connectMain()` now thread `renderAt = frameTs(frame, arrivedAt)` to `renderFrame`. Initial-replay batches no longer collapse 200 distinct timestamps into one.
15
+ - 4 new render helpers (`renderUserTurn` / `renderAssistantTurn` / `renderSessionStart` / `renderToolUseLine`) plus dispatch arms in `renderFrameInner` BEFORE the `JSON.stringify` fallback. New CSS for `.frame.assistant-turn` (green border-left), `.frame.session-start` (small inline badge), `.frame.tool-call-line`, `.frame.truncated-tag`. `user_turn` reuses `.frame.user` bubble styling. Truncated content gets a "(truncated)" tag.
16
+ - 5 separate guards keep `in-session-*` ids out of the bottom pane: `renderRailEntry` click handler returns early on `isInSession`; initial bottom-pane resolution scrubs `in-session-*` from `SS_KEY_SELECTED` sessionStorage before `connect()`; `hashchange` handler returns early; `maybeAutoFollow` filters in-session spawns out; legacy `renderTree` click handler in the live-bucket fallback path also gets the guard (Red Team BUG-2).
17
+ - `test/m48-viewer-rendering-fixes.test.js`: 23 new regression tests — 5 Bug-1, 5 Bug-2 (incl. functional `frameTs` eval-extract probe), 7 Bug-3, 5 Bug-4, 1 functional probe (Red Team test-quality concern). Includes explicit `$&` and `$1` regression tests for the BUG-1 fix.
18
+ - `test/m44-transcript-timestamp.test.js`: updated for the `renderAt` / `arrivedAt` rename — semantics preserved (`arrivedAt` is now the fallback layer beneath parsed `frame.ts`).
19
+
20
+ **Migration:** existing dashboards pick up the new code on next refresh after `gsd-t update-all` propagates the package; the per-project transcript page reflects the project's directory basename automatically. No state migration.
21
+
22
+ **Suite:** 2083/2083 pass — both pre-existing M47-baseline flakes resolved on the release run.
23
+
24
+ **Red Team adversarial QA (opus):** initial sweep found 1 MEDIUM (`$&`-corruption in basename → fixed via function-form replace) + 1 LOW (legacy `renderTree` click handler → fixed via `isInSession` guard) + 1 test-quality recommendation (addressed via functional `frameTs` probe). Re-verification: GRUDGING PASS — no new bugs introduced.
25
+
5
26
  ## [3.20.13] - 2026-05-05
6
27
 
7
28
  ### Fixed — visualizer: surface in-session NDJSONs when `.index.json` is empty
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "3.21.10",
3
+ "version": "3.21.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",
@@ -273,7 +273,14 @@ function handleTranscriptsList(req, res, projectDir, transcriptHtmlPath) {
273
273
  // viewer's initialId logic falls through to location.hash (also empty)
274
274
  // and connect('') is a no-op beyond a 404 SSE attempt — harmless, since
275
275
  // the left rail polls /api/spawns-index independently.
276
- const html = data.toString("utf8").replace(/__SPAWN_ID__/g, "");
276
+ const projectName = path.basename(path.resolve(projectDir || "."));
277
+ // Function-form replacement: a string replacement would interpret
278
+ // `$&`, `$1`, `$$`, etc. in the project basename as backreferences,
279
+ // re-injecting the placeholder or fragments of it (Red Team BUG-1).
280
+ const escapedName = _escapeHtml(projectName);
281
+ const html = data.toString("utf8")
282
+ .replace(/__SPAWN_ID__/g, () => "")
283
+ .replace(/__PROJECT_NAME__/g, () => escapedName);
277
284
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
278
285
  res.end(html);
279
286
  });
@@ -283,18 +290,36 @@ function handleTranscriptsList(req, res, projectDir, transcriptHtmlPath) {
283
290
  res.end(JSON.stringify({ spawns: sorted }));
284
291
  }
285
292
 
286
- function handleTranscriptPage(req, res, spawnId, transcriptHtmlPath) {
293
+ function handleTranscriptPage(req, res, spawnId, transcriptHtmlPath, projectDir) {
287
294
  if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
288
295
  fs.readFile(transcriptHtmlPath, (err, data) => {
289
296
  if (err) { res.writeHead(404); res.end("Transcript UI not found"); return; }
290
297
  // Inject the spawn-id as a data attribute on <body> by string replacement;
291
298
  // the HTML ships with a placeholder `data-spawn-id="__SPAWN_ID__"`.
292
- const html = data.toString("utf8").replace(/__SPAWN_ID__/g, spawnId);
299
+ const projectName = path.basename(path.resolve(projectDir || "."));
300
+ // Function-form replacement: see comment in handleTranscriptsList. Even
301
+ // though isValidSpawnId guards spawnId against `$`, defence in depth.
302
+ const escapedName = _escapeHtml(projectName);
303
+ const html = data.toString("utf8")
304
+ .replace(/__SPAWN_ID__/g, () => spawnId)
305
+ .replace(/__PROJECT_NAME__/g, () => escapedName);
293
306
  res.writeHead(200, { "Content-Type": "text/html" });
294
307
  res.end(html);
295
308
  });
296
309
  }
297
310
 
311
+ // HTML-escape just enough to make a directory basename safe in <title> and
312
+ // <div class="title">. Project basenames effectively never contain quotes or
313
+ // angle brackets, but we still escape to keep the surface tight.
314
+ function _escapeHtml(s) {
315
+ return String(s == null ? "" : s)
316
+ .replace(/&/g, "&amp;")
317
+ .replace(/</g, "&lt;")
318
+ .replace(/>/g, "&gt;")
319
+ .replace(/"/g, "&quot;")
320
+ .replace(/'/g, "&#39;");
321
+ }
322
+
298
323
  function tailTranscriptFile(filePath, callback) {
299
324
  let offset = 0;
300
325
  let buf = "";
@@ -783,7 +808,7 @@ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath)
783
808
  if (streamMatch) return handleTranscriptStream(req, res, decodeURIComponent(streamMatch[1]), projDir);
784
809
  // /transcript/:spawnId — HTML viewer page
785
810
  const pageMatch = url.match(/^\/transcript\/([^/]+)$/);
786
- if (pageMatch) return handleTranscriptPage(req, res, decodeURIComponent(pageMatch[1]), tHtmlPath);
811
+ if (pageMatch) return handleTranscriptPage(req, res, decodeURIComponent(pageMatch[1]), tHtmlPath, projDir);
787
812
  res.writeHead(404); res.end("Not found");
788
813
  });
789
814
  server.listen(port);
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>GSD-T Transcript</title>
6
+ <title>__PROJECT_NAME__</title>
7
7
  <style>
8
8
  :root {
9
9
  --bg: #0d1117;
@@ -183,6 +183,17 @@
183
183
  .frame.boundary.done .label { color: var(--green); }
184
184
  .frame.boundary.failed .label { color: var(--red); }
185
185
  .frame.boundary .meta { color: var(--fg-dim); font-family: var(--mono); font-size: 12px; }
186
+ /* M48 — chat-bubble frame types emitted by the in-session conversation
187
+ capture hook. user_turn reuses .frame.user styling; assistant_turn
188
+ gets a softer right-aligned-feel bubble; session_start is a tiny badge. */
189
+ .frame.assistant-turn { border-left: 3px solid var(--green); padding: 6px 12px; background: var(--bg-raised); border-radius: 0 4px 4px 0; }
190
+ .frame.assistant-turn .prefix { color: var(--green); font-weight: 600; margin-right: 6px; font-family: var(--mono); }
191
+ .frame.assistant-turn .body { white-space: pre-wrap; word-break: break-word; }
192
+ .frame.user-turn .body { white-space: pre-wrap; word-break: break-word; }
193
+ .frame.session-start { display: inline-flex; align-items: center; gap: 6px; margin: 8px 0; padding: 3px 10px; background: rgba(88,166,255,0.10); border: 1px solid var(--border); border-radius: 12px; font-family: var(--mono); font-size: 11px; color: var(--fg-dim); }
194
+ .frame.session-start .badge { color: var(--accent); font-weight: 600; }
195
+ .frame.tool-call-line { font-family: var(--mono); font-size: 12px; color: var(--accent-warm); padding: 2px 0; }
196
+ .frame.truncated-tag { color: var(--fg-xdim); font-style: italic; font-size: 11px; margin-left: 6px; }
186
197
 
187
198
  .jump-to-live { position: fixed; bottom: 24px; right: 24px; background: var(--accent); color: #fff; border: none; padding: 10px 16px; border-radius: 20px; cursor: pointer; font-size: 13px; font-family: var(--sans); font-weight: 600; box-shadow: 0 4px 12px rgba(0,0,0,0.4); display: none; z-index: 20; }
188
199
  .jump-to-live.visible { display: block; }
@@ -202,7 +213,7 @@
202
213
  </head>
203
214
  <body data-spawn-id="__SPAWN_ID__">
204
215
  <header>
205
- <div class="title">GSD-T Transcript</div>
216
+ <div class="title">__PROJECT_NAME__</div>
206
217
  <div class="spawn-id" id="hdr-spawn-id"></div>
207
218
  <label class="auto-follow" title="When ON, snap focus to the most recent live spawn as soon as it appears."><input type="checkbox" id="auto-follow" checked> auto-follow latest</label>
208
219
  <div class="status" id="hdr-status"><span class="dot"></span><span class="label">connecting…</span></div>
@@ -440,17 +451,26 @@
440
451
  }
441
452
  window.__gsdtFmtTs = fmtTs;
442
453
 
443
- // Per-frame arrival timestamp. The renderer captures Date.now() once
444
- // per SSE message and threads it through; everything else falls back
445
- // to "now" so manually-rendered frames stay visible too. Spotting a
446
- // stuck stream is then trivial adjacent frames will show identical
447
- // or far-apart timestamps in the left-margin pill.
454
+ // Per-frame timestamp. We prefer the producer-side `frame.ts`
455
+ // (ISO string written by the conversation-capture hook) when it
456
+ // parses cleanly, and only fall back to SSE arrival time when no
457
+ // `ts` field is present. Without this, an initial-load replay of
458
+ // N frames all arrives within one millisecond and every row shows
459
+ // the same HH:MM:SS — useless for spotting stuck or stale streams.
448
460
  //
449
461
  // M47 D1: appendFrame writes to a module-scope `renderTarget`. The
450
462
  // renderFrame entry point swaps it per-call so the same code paths
451
463
  // render into either the top pane (#main-stream — main-conversation
452
464
  // SSE) or the bottom pane (#stream inside #spawn-stream — selected
453
465
  // spawn SSE).
466
+ function frameTs(frame, fallback) {
467
+ if (frame && typeof frame.ts === 'string') {
468
+ const d = new Date(frame.ts);
469
+ if (!isNaN(d.getTime())) return d;
470
+ }
471
+ return fallback instanceof Date ? fallback : new Date();
472
+ }
473
+ window.__gsdtFrameTs = frameTs;
454
474
  let renderTarget = stream;
455
475
  function appendFrame(el, arrivedAt) {
456
476
  const ts = document.createElement('span');
@@ -604,6 +624,74 @@
604
624
  appendFrame(div, arrivedAt);
605
625
  }
606
626
 
627
+ // M48 — chat-bubble renderers for the in-session conversation NDJSON.
628
+ // The conversation-capture hook emits frames of type
629
+ // `user_turn`, `assistant_turn`, `session_start`, and `tool_use`.
630
+ // Without explicit handling these fall through to renderRaw and the
631
+ // user sees a JSON.stringify dump per row.
632
+ function _appendTruncatedTag(div, frame) {
633
+ if (!frame || !frame.truncated) return;
634
+ const tag = document.createElement('span');
635
+ tag.className = 'truncated-tag';
636
+ tag.textContent = '(truncated)';
637
+ div.appendChild(tag);
638
+ }
639
+ function renderUserTurn(frame, arrivedAt) {
640
+ const div = document.createElement('div');
641
+ div.className = 'frame user user-turn';
642
+ const p = document.createElement('span');
643
+ p.className = 'prefix';
644
+ p.textContent = '>';
645
+ div.appendChild(p);
646
+ const body = document.createElement('span');
647
+ body.className = 'body';
648
+ body.textContent = (frame && typeof frame.content === 'string') ? frame.content : '';
649
+ div.appendChild(body);
650
+ _appendTruncatedTag(div, frame);
651
+ appendFrame(div, arrivedAt);
652
+ }
653
+ function renderAssistantTurn(frame, arrivedAt) {
654
+ const div = document.createElement('div');
655
+ div.className = 'frame assistant-turn';
656
+ const p = document.createElement('span');
657
+ p.className = 'prefix';
658
+ p.textContent = '⏺';
659
+ div.appendChild(p);
660
+ const body = document.createElement('span');
661
+ body.className = 'body';
662
+ body.textContent = (frame && typeof frame.content === 'string') ? frame.content : '';
663
+ div.appendChild(body);
664
+ _appendTruncatedTag(div, frame);
665
+ appendFrame(div, arrivedAt);
666
+ }
667
+ function renderSessionStart(frame, arrivedAt) {
668
+ const div = document.createElement('div');
669
+ div.className = 'frame session-start';
670
+ const badge = document.createElement('span');
671
+ badge.className = 'badge';
672
+ badge.textContent = '◆ session';
673
+ div.appendChild(badge);
674
+ if (frame && typeof frame.session_id === 'string') {
675
+ const sid = document.createElement('span');
676
+ sid.textContent = frame.session_id.slice(0, 8);
677
+ div.appendChild(sid);
678
+ }
679
+ appendFrame(div, arrivedAt);
680
+ }
681
+ function renderToolUseLine(frame, arrivedAt) {
682
+ const div = document.createElement('div');
683
+ div.className = 'frame tool-call-line';
684
+ const span = document.createElement('span');
685
+ const name = (frame && typeof frame.name === 'string') ? frame.name : 'tool';
686
+ span.textContent = '⎿ ' + name + '()';
687
+ div.appendChild(span);
688
+ appendFrame(div, arrivedAt);
689
+ }
690
+ window.__gsdtRenderUserTurn = renderUserTurn;
691
+ window.__gsdtRenderAssistantTurn = renderAssistantTurn;
692
+ window.__gsdtRenderSessionStart = renderSessionStart;
693
+ window.__gsdtRenderToolUseLine = renderToolUseLine;
694
+
607
695
  function renderCompactMarker(frame, arrivedAt) {
608
696
  const div = document.createElement('div');
609
697
  div.className = 'frame compact-marker';
@@ -646,12 +734,23 @@
646
734
  }
647
735
  function renderFrameInner(frame, arrivedAt) {
648
736
  if (!frame || typeof frame !== 'object') return;
649
- const ts = arrivedAt instanceof Date ? arrivedAt : new Date();
737
+ // Defense in depth: if a caller passes a non-Date `arrivedAt`, still
738
+ // try to derive a real timestamp from frame.ts before falling back
739
+ // to "now". Without this guard, every renderFrame() call from a
740
+ // non-SSE path (e.g. tests, manual dispatch) collapses to one Date.
741
+ const ts = (arrivedAt instanceof Date && !isNaN(arrivedAt.getTime()))
742
+ ? arrivedAt
743
+ : frameTs(frame, new Date());
650
744
  const type = frame.type;
651
745
  if (type === 'compact_marker') { renderCompactMarker(frame, ts); return; }
652
746
  if (type === 'system') { renderSystem(frame, ts); return; }
653
747
  if (type === 'task-boundary') { renderBoundary(frame, ts); return; }
654
748
  if (type === 'raw') { renderRaw(frame.line || '', ts); return; }
749
+ // M48 — in-session conversation-capture frame types.
750
+ if (type === 'session_start') { renderSessionStart(frame, ts); return; }
751
+ if (type === 'user_turn') { renderUserTurn(frame, ts); return; }
752
+ if (type === 'assistant_turn') { renderAssistantTurn(frame, ts); return; }
753
+ if (type === 'tool_use') { renderToolUseLine(frame, ts); return; }
655
754
  if (type === 'assistant' && frame.message && Array.isArray(frame.message.content)) {
656
755
  for (const b of frame.message.content) {
657
756
  if (b.type === 'text') renderAssistantText(b.text || '', ts);
@@ -771,6 +870,14 @@
771
870
  });
772
871
  el.appendChild(dot); el.appendChild(name); el.appendChild(kill);
773
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;
774
881
  if (node.spawnId === currentId) return;
775
882
  location.hash = node.spawnId;
776
883
  });
@@ -822,9 +929,14 @@
822
929
  function maybeAutoFollow(spawns) {
823
930
  if (!autoFollowEl.checked) return;
824
931
  const currentId = (location.hash || '').slice(1) || spawnId;
932
+ // M48 — auto-follow drives the BOTTOM pane (selected-spawn). The
933
+ // top pane is owned by /api/main-session and shows in-session-*
934
+ // entries automatically. Excluding them here prevents the auto-
935
+ // follow loop from also pinning them into the bottom pane.
825
936
  // Most recent running spawn by startedAt (descending).
826
937
  const running = spawns
827
938
  .filter((s) => s.status === 'running')
939
+ .filter((s) => !(typeof s.spawnId === 'string' && s.spawnId.indexOf('in-session-') === 0))
828
940
  .sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0));
829
941
  if (!running.length) return;
830
942
  const latest = running[0];
@@ -894,6 +1006,12 @@
894
1006
  });
895
1007
  el.appendChild(dot); el.appendChild(name); el.appendChild(kill);
896
1008
  el.addEventListener('click', () => {
1009
+ // M48 — in-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;
897
1015
  if (node.spawnId === currentId) return;
898
1016
  _ssSet(SS_KEY_SELECTED, node.spawnId);
899
1017
  location.hash = node.spawnId;
@@ -1102,14 +1220,15 @@
1102
1220
  src.onerror = () => { setStatus('error', 'disconnected'); setToolCostLive(false); };
1103
1221
  src.onmessage = (ev) => {
1104
1222
  if (!ev.data) return;
1105
- // Capture arrival time once per SSE message every render call for
1106
- // this frame stamps the same wall-clock value, so the user can spot
1107
- // a stuck stream by adjacent identical/far-apart timestamps.
1223
+ // Arrival time is the FALLBACK; we prefer the producer-side
1224
+ // frame.ts so a 200-frame initial replay shows actual event
1225
+ // times (spread out over minutes), not the same millisecond.
1108
1226
  const arrivedAt = new Date();
1109
1227
  try {
1110
1228
  const frame = JSON.parse(ev.data);
1229
+ const renderAt = frameTs(frame, arrivedAt);
1111
1230
  // Bottom pane = default renderTarget = #stream.
1112
- renderFrame(frame, arrivedAt);
1231
+ renderFrame(frame, renderAt);
1113
1232
  // M43 D6 — refresh tool-cost on turn-complete frames (debounced).
1114
1233
  // Various producers emit different turn-complete markers; accept
1115
1234
  // any of them.
@@ -1146,7 +1265,8 @@
1146
1265
  const arrivedAt = new Date();
1147
1266
  try {
1148
1267
  const frame = JSON.parse(ev.data);
1149
- renderFrame(frame, arrivedAt, mainStreamEl);
1268
+ const renderAt = frameTs(frame, arrivedAt);
1269
+ renderFrame(frame, renderAt, mainStreamEl);
1150
1270
  } catch {
1151
1271
  const prev = renderTarget; renderTarget = mainStreamEl;
1152
1272
  try { renderRaw(ev.data, arrivedAt); } finally { renderTarget = prev; }
@@ -1175,6 +1295,8 @@
1175
1295
 
1176
1296
  window.addEventListener('hashchange', () => {
1177
1297
  const id = (location.hash || '').slice(1);
1298
+ // M48 — keep in-session-* ids out of the bottom pane (top pane only).
1299
+ if (id && id.indexOf('in-session-') === 0) { return; }
1178
1300
  if (id) { connect(id); pollSpawns(); }
1179
1301
  });
1180
1302
 
@@ -1182,12 +1304,19 @@
1182
1304
  // 1. data-spawn-id non-empty → connect that (bookmark flow)
1183
1305
  // 2. else sessionStorage.selectedSpawnId → connect that
1184
1306
  // 3. else show empty state
1307
+ // M48 — never 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.
1185
1310
  let initialBottomId = '';
1186
1311
  if (spawnId) {
1187
1312
  initialBottomId = spawnId;
1188
1313
  } else {
1189
1314
  initialBottomId = _ssGet(SS_KEY_SELECTED) || '';
1190
1315
  }
1316
+ if (typeof initialBottomId === 'string' && initialBottomId.indexOf('in-session-') === 0) {
1317
+ initialBottomId = '';
1318
+ _ssSet(SS_KEY_SELECTED, '');
1319
+ }
1191
1320
  if (initialBottomId && !location.hash) location.hash = initialBottomId;
1192
1321
  connect(initialBottomId);
1193
1322