@tekyzinc/gsd-t 3.18.13 → 3.19.0

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.
@@ -176,102 +176,37 @@ function isValidSpawnId(id) {
176
176
  return typeof id === "string" && /^[a-zA-Z0-9._-]+$/.test(id) && id.length <= 200;
177
177
  }
178
178
 
179
- function handleTranscriptsList(req, res, projectDir) {
179
+ function handleTranscriptsList(req, res, projectDir, transcriptHtmlPath) {
180
180
  const idx = readTranscriptsIndex(projectDir);
181
181
  const sorted = idx.spawns
182
182
  .slice()
183
183
  .sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0));
184
184
 
185
185
  // Content negotiation: browser navigations send Accept: text/html, fetch()
186
- // defaults to */*. We serve HTML only when the client explicitly asks for it,
187
- // so the existing dashboard fetch (which expects JSON) stays unaffected.
186
+ // defaults to */*. For text/html we serve the viewer (same HTML as
187
+ // /transcript/:id) with an empty spawn-id placeholder the viewer's left
188
+ // rail populates from /api/spawns-index and the main pane defers until the
189
+ // user clicks a spawn. Programmatic clients (fetch's default */* or explicit
190
+ // application/json) continue to get the JSON shape the dashboard JS already
191
+ // consumes.
188
192
  const accept = String(req.headers["accept"] || "");
189
- if (accept.includes("text/html")) {
190
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
191
- res.end(renderTranscriptsHtml(sorted));
193
+ if (accept.includes("text/html") && transcriptHtmlPath) {
194
+ fs.readFile(transcriptHtmlPath, (err, data) => {
195
+ if (err) { res.writeHead(404); res.end("Transcript UI not found"); return; }
196
+ // Substitute the __SPAWN_ID__ placeholder with an empty string; the
197
+ // viewer's initialId logic falls through to location.hash (also empty)
198
+ // and connect('') is a no-op beyond a 404 SSE attempt — harmless, since
199
+ // the left rail polls /api/spawns-index independently.
200
+ const html = data.toString("utf8").replace(/__SPAWN_ID__/g, "");
201
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
202
+ res.end(html);
203
+ });
192
204
  return;
193
205
  }
194
206
  res.writeHead(200, { "Content-Type": "application/json" });
195
207
  res.end(JSON.stringify({ spawns: sorted }));
196
208
  }
197
209
 
198
- function renderTranscriptsHtml(spawns) {
199
- const escape = (s) => String(s == null ? "" : s)
200
- .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
201
- .replace(/"/g, "&quot;");
202
- const fmtDuration = (a, b) => {
203
- const start = Date.parse(a); const end = Date.parse(b || "") || Date.now();
204
- if (!Number.isFinite(start)) return "—";
205
- const ms = Math.max(0, end - start);
206
- const s = Math.floor(ms / 1000); const m = Math.floor(s / 60); const r = s % 60;
207
- return m > 0 ? `${m}m ${r}s` : `${r}s`;
208
- };
209
- const fmtTime = (s) => { const d = new Date(s); return Number.isFinite(d.getTime()) ? d.toLocaleString() : "—"; };
210
- const liveStatuses = new Set(["initializing", "running"]);
211
- const isLive = (s) => liveStatuses.has(s);
212
-
213
- const rows = spawns.map((s) => {
214
- const live = isLive(s.status);
215
- const statusBadge = `<span class="status status-${escape(s.status || 'unknown')}">${escape(s.status || 'unknown')}</span>`;
216
- return `<tr class="${live ? 'row-live' : ''}">
217
- <td><a href="/transcript/${encodeURIComponent(s.spawnId)}">${escape(s.spawnId)}</a></td>
218
- <td>${escape(s.command || '—')}</td>
219
- <td>${escape(s.description || '—')}</td>
220
- <td>${statusBadge}</td>
221
- <td>${escape(fmtTime(s.startedAt))}</td>
222
- <td>${escape(fmtDuration(s.startedAt, s.endedAt))}</td>
223
- </tr>`;
224
- }).join("");
225
-
226
- const empty = `<div class="empty">
227
- <h2>No spawn transcripts yet</h2>
228
- <p>Transcripts appear here as soon as the first agent spawns. Run any GSD-T command (for example <code>/gsd-t-quick</code>) to generate one.</p>
229
- <p><a href="/">← Back to dashboard</a></p>
230
- </div>`;
231
-
232
- const table = spawns.length ? `<table>
233
- <thead><tr><th>Spawn ID</th><th>Command</th><th>Description</th><th>Status</th><th>Started</th><th>Duration</th></tr></thead>
234
- <tbody>${rows}</tbody>
235
- </table>` : empty;
236
-
237
- return `<!DOCTYPE html>
238
- <html lang="en"><head><meta charset="UTF-8"><title>GSD-T Transcripts</title>
239
- <style>
240
- :root{--bg:#0d1117;--surface:#161b22;--border:#30363d;--text:#e6edf3;--muted:#7d8590;
241
- --green:#3fb950;--green-bg:#1a3a1e;--blue:#388bfd;--blue-bg:#1f3a5f;--yellow:#d29922;--red:#f85149;
242
- --font:'SF Mono','Fira Code',Menlo,monospace;}
243
- *{box-sizing:border-box;margin:0;padding:0}
244
- body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:13px;padding:20px;line-height:1.5}
245
- .hdr{display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:12px;border-bottom:1px solid var(--border)}
246
- .logo{color:var(--blue);font-weight:bold;font-size:14px}
247
- .hright{margin-left:auto;color:var(--muted);font-size:11px}
248
- a{color:var(--blue);text-decoration:none}a:hover{text-decoration:underline}
249
- table{width:100%;border-collapse:collapse;background:var(--surface);border:1px solid var(--border);border-radius:6px;overflow:hidden}
250
- th,td{padding:8px 12px;text-align:left;border-bottom:1px solid var(--border);font-size:12px}
251
- th{background:#1c2128;color:var(--muted);text-transform:uppercase;font-size:10px;letter-spacing:0.5px}
252
- tbody tr:last-child td{border-bottom:none}
253
- tbody tr:hover{background:#1c2128}
254
- tr.row-live{background:var(--green-bg)}
255
- .status{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;text-transform:uppercase;letter-spacing:0.5px;border:1px solid}
256
- .status-running,.status-initializing{background:var(--green-bg);color:var(--green);border-color:var(--green)}
257
- .status-done{background:var(--blue-bg);color:var(--blue);border-color:var(--blue)}
258
- .status-stopped{background:#2a2a2a;color:var(--muted);border-color:var(--border)}
259
- .status-failed,.status-crashed{background:#3a1a1a;color:var(--red);border-color:var(--red)}
260
- .status-unknown{color:var(--muted);border-color:var(--border)}
261
- .empty{text-align:center;padding:60px 20px;color:var(--muted);background:var(--surface);border:1px solid var(--border);border-radius:6px}
262
- .empty h2{color:var(--text);margin-bottom:12px;font-size:16px}
263
- .empty p{margin-bottom:8px}
264
- .empty code{background:#1c2128;padding:2px 6px;border-radius:3px;color:var(--green)}
265
- </style></head><body>
266
- <div class="hdr">
267
- <span class="logo">GSD-T Transcripts</span>
268
- <a href="/" style="font-size:11px">← Dashboard</a>
269
- <span class="hright">${spawns.length} spawn${spawns.length === 1 ? '' : 's'}</span>
270
- </div>
271
- ${table}
272
- </body></html>`;
273
- }
274
-
275
210
  function handleTranscriptPage(req, res, spawnId, transcriptHtmlPath) {
276
211
  if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
277
212
  fs.readFile(transcriptHtmlPath, (err, data) => {
@@ -578,6 +513,116 @@ function listActiveSpawnPlans(projectDir) {
578
513
  return listAllSpawnPlans(projectDir).filter((p) => p && p.endedAt == null);
579
514
  }
580
515
 
516
+ // ── M44 D9 — Parallelism observability ──────────────────────────────────────
517
+ // Additive endpoints powered by bin/parallelism-report.cjs (v1.0.0 contract).
518
+ // Pure read-only observer; never writes, never spawns. 5-second per-response
519
+ // cache so rapid panel polls don't hammer the filesystem.
520
+
521
+ const PARALLELISM_CACHE_MS = 5000;
522
+ const _parallelismCache = { metrics: { at: 0, body: null, wave: null }, report: new Map() };
523
+
524
+ function _loadParallelismReporter() {
525
+ try {
526
+ // Resolve at call-time so tests that don't install the module don't break
527
+ // unrelated endpoints. Require is cached by Node after first success.
528
+ return require(path.join(__dirname, "..", "bin", "parallelism-report.cjs"));
529
+ } catch (err) {
530
+ return { _loadError: err && err.message || String(err) };
531
+ }
532
+ }
533
+
534
+ function handleParallelism(req, res, projectDir) {
535
+ const urlObj = req.url ? req.url.split("?") : ["", ""];
536
+ const qs = urlObj[1] || "";
537
+ const params = new URLSearchParams(qs);
538
+ const wave = params.get("wave") || null;
539
+ const now = Date.now();
540
+ const cache = _parallelismCache.metrics;
541
+ if (cache.body && cache.wave === wave && (now - cache.at) < PARALLELISM_CACHE_MS) {
542
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "X-Cache": "hit" });
543
+ res.end(cache.body);
544
+ return;
545
+ }
546
+ const reporter = _loadParallelismReporter();
547
+ if (reporter._loadError) {
548
+ res.writeHead(500, { "Content-Type": "application/json" });
549
+ res.end(JSON.stringify({ error: "parallelism-report module unavailable", detail: reporter._loadError }));
550
+ return;
551
+ }
552
+ let metrics;
553
+ try {
554
+ metrics = reporter.computeParallelismMetrics({ projectDir, wave: wave || undefined });
555
+ } catch (err) {
556
+ // Contract says silent-fail; a thrown error here means contract regression.
557
+ res.writeHead(500, { "Content-Type": "application/json" });
558
+ res.end(JSON.stringify({ error: "computeParallelismMetrics threw", detail: err && err.message || String(err) }));
559
+ return;
560
+ }
561
+ const body = JSON.stringify(metrics);
562
+ cache.at = now;
563
+ cache.wave = wave;
564
+ cache.body = body;
565
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "X-Cache": "miss" });
566
+ res.end(body);
567
+ }
568
+
569
+ function handleParallelismReport(req, res, projectDir) {
570
+ const urlObj = req.url ? req.url.split("?") : ["", ""];
571
+ const qs = urlObj[1] || "";
572
+ const params = new URLSearchParams(qs);
573
+ const wave = params.get("wave") || null;
574
+ const cacheKey = wave || "__all__";
575
+ const now = Date.now();
576
+ const cached = _parallelismCache.report.get(cacheKey);
577
+ if (cached && (now - cached.at) < PARALLELISM_CACHE_MS) {
578
+ res.writeHead(200, { "Content-Type": "text/markdown; charset=utf-8", "Access-Control-Allow-Origin": "*", "X-Cache": "hit" });
579
+ res.end(cached.body);
580
+ return;
581
+ }
582
+ const reporter = _loadParallelismReporter();
583
+ if (reporter._loadError) {
584
+ res.writeHead(500, { "Content-Type": "text/plain" });
585
+ res.end("parallelism-report module unavailable: " + reporter._loadError);
586
+ return;
587
+ }
588
+ let md;
589
+ try {
590
+ md = reporter.buildFullReport({ projectDir, wave: wave || undefined });
591
+ } catch (err) {
592
+ res.writeHead(500, { "Content-Type": "text/plain" });
593
+ res.end("buildFullReport threw: " + (err && err.message || String(err)));
594
+ return;
595
+ }
596
+ _parallelismCache.report.set(cacheKey, { at: now, body: md });
597
+ res.writeHead(200, { "Content-Type": "text/markdown; charset=utf-8", "Access-Control-Allow-Origin": "*", "X-Cache": "miss" });
598
+ res.end(md);
599
+ }
600
+
601
+ // POST /api/unattended-stop — proxies to the existing stop-sentinel flow so
602
+ // the transcript panel's "Stop Supervisor" button reuses the canonical
603
+ // kill path. Writes `.gsd-t/.unattended/stop` sentinel; supervisor polls
604
+ // it and self-exits. Contract reminder: D9 does NOT implement its own
605
+ // stop logic, does NOT PID-kill.
606
+ function handleUnattendedStop(req, res, projectDir) {
607
+ if (req.method !== "POST") {
608
+ res.writeHead(405, { "Content-Type": "application/json", "Allow": "POST" });
609
+ res.end(JSON.stringify({ error: "method not allowed" }));
610
+ return;
611
+ }
612
+ const stopDir = path.join(projectDir, ".gsd-t", ".unattended");
613
+ const stopFile = path.join(stopDir, "stop");
614
+ try {
615
+ fs.mkdirSync(stopDir, { recursive: true });
616
+ fs.writeFileSync(stopFile, new Date().toISOString() + "\n");
617
+ } catch (err) {
618
+ res.writeHead(500, { "Content-Type": "application/json" });
619
+ res.end(JSON.stringify({ error: "failed to write stop sentinel", detail: err && err.message || String(err) }));
620
+ return;
621
+ }
622
+ res.writeHead(200, { "Content-Type": "application/json" });
623
+ res.end(JSON.stringify({ ok: true, sentinel: stopFile }));
624
+ }
625
+
581
626
  function handleSpawnPlans(req, res, projectDir) {
582
627
  const plans = listActiveSpawnPlans(projectDir).sort((a, b) => {
583
628
  const ta = Date.parse(a.startedAt) || 0;
@@ -637,10 +682,15 @@ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath)
637
682
  if (url === "/metrics") return handleMetrics(req, res, projDir);
638
683
  if (url === "/ping") return handlePing(req, res, port);
639
684
  if (url === "/stop") return handleStop(req, res, server);
640
- if (url === "/transcripts") return handleTranscriptsList(req, res, projDir);
685
+ if (url === "/transcripts") return handleTranscriptsList(req, res, projDir, tHtmlPath);
641
686
  // M44 D8 — spawn plans: GET list + SSE change stream
642
687
  if (url === "/api/spawn-plans") return handleSpawnPlans(req, res, projDir);
643
688
  if (url === "/api/spawn-plans/stream") return handleSpawnPlanUpdates(req, res, projDir);
689
+ // M44 D9 — parallelism observability (additive, read-only)
690
+ if (url === "/api/parallelism") return handleParallelism(req, res, projDir);
691
+ if (url === "/api/parallelism/report") return handleParallelismReport(req, res, projDir);
692
+ // M44 D9 — stop-supervisor proxy (POST only; reuses existing sentinel flow)
693
+ if (url === "/api/unattended-stop") return handleUnattendedStop(req, res, projDir);
644
694
  // POST /transcript/:spawnId/kill — SIGTERM the recorded workerPid
645
695
  const killMatch = url.match(/^\/transcript\/([^/]+)\/kill$/);
646
696
  if (killMatch && req.method === "POST") return handleTranscriptKill(req, res, decodeURIComponent(killMatch[1]), projDir);
@@ -674,7 +724,6 @@ module.exports = {
674
724
  readIndexEntry,
675
725
  isValidSpawnId,
676
726
  handleTranscriptsList,
677
- renderTranscriptsHtml,
678
727
  handleTranscriptStream,
679
728
  handleTranscriptPage,
680
729
  handleTranscriptKill,
@@ -692,6 +741,10 @@ module.exports = {
692
741
  handleSpawnPlanUpdates,
693
742
  readSpawnPlanFile,
694
743
  spawnsDir,
744
+ // M44 D9 — parallelism observability
745
+ handleParallelism,
746
+ handleParallelismReport,
747
+ handleUnattendedStop,
695
748
  };
696
749
 
697
750
  if (require.main === module) {
@@ -45,6 +45,27 @@
45
45
  .spawn-panel .empty { color: var(--fg-xdim); font-style: italic; padding: 4px 0; font-size: 11px; }
46
46
  .spawn-panel .wave-group { border-top: 1px dashed var(--border); padding-top: 4px; margin-top: 4px; }
47
47
  .spawn-panel .wave-group:first-child { border-top: none; padding-top: 0; margin-top: 0; }
48
+ /* M44 D9 — parallelism panel (additive; scoped to its own class). */
49
+ .parallelism-panel { margin-top: 14px; border: 1px solid var(--border); border-radius: 4px; padding: 10px 12px; background: var(--bg); transition: border-color 200ms ease; }
50
+ .parallelism-panel.color-green { border-color: #10b981; }
51
+ .parallelism-panel.color-yellow { border-color: #f59e0b; }
52
+ .parallelism-panel.color-red { border-color: #ef4444; }
53
+ .parallelism-panel.color-dimmed { border-color: #374151; opacity: 0.5; }
54
+ .parallelism-panel h3 { margin: 0 0 6px 0; font-size: 11px; text-transform: uppercase; color: var(--fg-xdim); letter-spacing: 0.08em; display: flex; justify-content: space-between; align-items: baseline; }
55
+ .parallelism-panel h3 .pp-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: currentColor; margin-right: 6px; vertical-align: middle; }
56
+ .parallelism-panel .pp-dot.dot-green { color: #10b981; }
57
+ .parallelism-panel .pp-dot.dot-yellow { color: #f59e0b; }
58
+ .parallelism-panel .pp-dot.dot-red { color: #ef4444; }
59
+ .parallelism-panel .pp-dot.dot-dimmed { color: #374151; }
60
+ .parallelism-panel .pp-row { display: flex; justify-content: space-between; font-size: 11px; padding: 2px 0; font-family: var(--mono); }
61
+ .parallelism-panel .pp-row .pp-k { color: var(--fg-xdim); }
62
+ .parallelism-panel .pp-row .pp-v { color: var(--fg); }
63
+ .parallelism-panel .pp-gates { margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border); font-size: 11px; font-family: var(--mono); color: var(--fg-dim); }
64
+ .parallelism-panel .pp-gates span { margin-right: 8px; }
65
+ .parallelism-panel .pp-actions { display: flex; gap: 6px; margin-top: 8px; }
66
+ .parallelism-panel .pp-actions button { flex: 1; padding: 4px 8px; font-size: 11px; background: var(--bg-raised); color: var(--fg); border: 1px solid var(--border); border-radius: 3px; cursor: pointer; font-family: var(--sans); }
67
+ .parallelism-panel .pp-actions button:hover { border-color: var(--accent); }
68
+ .parallelism-panel .pp-actions button.danger:hover { border-color: #ef4444; color: #ef4444; }
48
69
  .spawn-panel .wave-label { font-size: 10px; color: var(--fg-xdim); font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.05em; margin: 4px 0 2px 0; }
49
70
  aside h3 { margin: 0 12px 8px 12px; font-size: 11px; text-transform: uppercase; color: var(--fg-xdim); letter-spacing: 0.08em; }
50
71
  aside .tree { font-size: 13px; }
@@ -76,6 +97,10 @@
76
97
  aside .tool-row .tool-tokens { color: var(--fg-dim); flex: 0 0 auto; }
77
98
  aside .tool-empty { color: var(--fg-xdim); font-size: 11px; font-style: italic; }
78
99
  aside .tool-error { color: var(--yellow); font-size: 11px; font-style: italic; }
100
+ /* M45 D2 — in-session conversation left-rail badge */
101
+ aside .node.in-session .name { color: var(--yellow); }
102
+ aside .node.in-session.active .name { color: var(--accent-warm, var(--yellow)); }
103
+ aside .node .label-in-session { color: var(--yellow); margin-right: 4px; }
79
104
  @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
80
105
  /* 10s attention-pulse for a freshly-arrived spawn — fires once when
81
106
  auto-follow snaps focus to a new session, then self-removes. */
@@ -170,6 +195,20 @@
170
195
  <div class="sec-totals" id="active-totals">—</div>
171
196
  <div id="active-tasks"><div class="empty">no active spawn plan</div></div>
172
197
  </section>
198
+ <!-- M44 D9 — parallelism panel (additive). Reads /api/parallelism every 5s.
199
+ Color border reflects color_state (green/yellow/red/dimmed). -->
200
+ <div class="parallelism-panel color-dimmed" id="parallelism-panel">
201
+ <h3><span><span class="pp-dot dot-dimmed" id="pp-dot"></span>Parallelism</span><span id="pp-mode" style="font-size:10px;color:var(--fg-xdim);">idle</span></h3>
202
+ <div class="pp-row"><span class="pp-k">active workers</span><span class="pp-v" id="pp-active">—</span></div>
203
+ <div class="pp-row"><span class="pp-k">ready tasks</span><span class="pp-v" id="pp-ready">—</span></div>
204
+ <div class="pp-row"><span class="pp-k">parallelism factor</span><span class="pp-v" id="pp-factor">—</span></div>
205
+ <div class="pp-row"><span class="pp-k">oldest spawn</span><span class="pp-v" id="pp-oldest">—</span></div>
206
+ <div class="pp-gates" id="pp-gates"><span>dep: —</span><span>disj: —</span><span>eco: —</span></div>
207
+ <div class="pp-actions">
208
+ <button id="pp-report-btn" title="Download full parallelism post-mortem for the current wave">📄 Full Report</button>
209
+ <button id="pp-stop-btn" class="danger" title="Write .gsd-t/.unattended/stop sentinel to halt an active supervisor">Stop Supervisor</button>
210
+ </div>
211
+ </div>
173
212
  </aside>
174
213
  <button class="jump-to-live" id="jump-btn">↓ Jump to live</button>
175
214
 
@@ -483,14 +522,33 @@
483
522
  return;
484
523
  }
485
524
  const currentId = (location.hash || '#').slice(1) || spawnId;
525
+ // M45 D2: in-session conversation NDJSONs are distinguished by the
526
+ // `in-session-` spawn-id prefix. The viewer labels them with
527
+ // `💬 conversation` instead of the default `▶ spawn`. This is a
528
+ // front-end-only discriminator — no server-side type field is
529
+ // required; the filename prefix (== spawn-id) is the contract.
530
+ function isInSession(node) {
531
+ return typeof node.spawnId === 'string' && node.spawnId.indexOf('in-session-') === 0;
532
+ }
486
533
  function render(node, depth) {
487
534
  const el = document.createElement('div');
488
535
  el.className = 'node ' + statusClass(node);
536
+ if (isInSession(node)) el.classList.add('in-session');
489
537
  if (node.spawnId === currentId) el.classList.add('active');
490
538
  el.style.paddingLeft = (12 + depth * 14) + 'px';
491
539
  const dot = document.createElement('span'); dot.className = 'dot';
492
540
  const name = document.createElement('span'); name.className = 'name';
493
- name.textContent = (node.command || 'spawn') + ' · ' + node.spawnId.slice(-8);
541
+ if (isInSession(node)) {
542
+ const badge = document.createElement('span');
543
+ badge.className = 'label-in-session';
544
+ badge.textContent = '💬 conversation';
545
+ name.appendChild(badge);
546
+ const tail = document.createElement('span');
547
+ tail.textContent = ' · ' + node.spawnId.slice(-8);
548
+ name.appendChild(tail);
549
+ } else {
550
+ name.textContent = (node.command || 'spawn') + ' · ' + node.spawnId.slice(-8);
551
+ }
494
552
  name.title = (node.description || node.spawnId) + '\n' + (node.startedAt || '');
495
553
  const kill = document.createElement('button');
496
554
  kill.className = 'kill';
@@ -919,6 +977,99 @@
919
977
  subscribePlanUpdates();
920
978
  // Light poll every 10s as a safety net if SSE is disconnected.
921
979
  setInterval(fetchInitialPlans, 10000);
980
+
981
+ // ── M44 D9 — Parallelism panel ─────────────────────────────────────────
982
+ // Polls /api/parallelism every 5s. Color border reflects color_state.
983
+ // Full Report button fetches /api/parallelism/report and downloads the
984
+ // markdown. Stop Supervisor POSTs /api/unattended-stop (writes sentinel).
985
+ (function wireParallelismPanel() {
986
+ const panel = document.getElementById('parallelism-panel');
987
+ if (!panel) return;
988
+ const dot = document.getElementById('pp-dot');
989
+ const modeEl = document.getElementById('pp-mode');
990
+ const activeEl = document.getElementById('pp-active');
991
+ const readyEl = document.getElementById('pp-ready');
992
+ const factorEl = document.getElementById('pp-factor');
993
+ const oldestEl = document.getElementById('pp-oldest');
994
+ const gatesEl = document.getElementById('pp-gates');
995
+ const reportBtn = document.getElementById('pp-report-btn');
996
+ const stopBtn = document.getElementById('pp-stop-btn');
997
+
998
+ function fmtAge(s) {
999
+ if (s == null || !isFinite(s)) return '—';
1000
+ if (s < 60) return s + 's';
1001
+ const m = Math.floor(s / 60), rs = s % 60;
1002
+ if (m < 60) return m + 'm ' + rs + 's';
1003
+ const h = Math.floor(m / 60), rm = m % 60;
1004
+ return h + 'h ' + rm + 'm';
1005
+ }
1006
+
1007
+ function applyColor(state) {
1008
+ const colors = ['color-green', 'color-yellow', 'color-red', 'color-dimmed'];
1009
+ const dots = ['dot-green', 'dot-yellow', 'dot-red', 'dot-dimmed'];
1010
+ for (const c of colors) panel.classList.remove(c);
1011
+ for (const d of dots) dot.classList.remove(d);
1012
+ const cls = 'color-' + (state || 'dimmed');
1013
+ const dcls = 'dot-' + (state || 'dimmed');
1014
+ panel.classList.add(cls);
1015
+ dot.classList.add(dcls);
1016
+ }
1017
+
1018
+ function renderGates(g) {
1019
+ if (!g) { gatesEl.textContent = 'gates: —'; return; }
1020
+ const dep = g.dep_gate_veto || { count: 0 };
1021
+ const dis = g.disjointness_fallback || { count: 0 };
1022
+ const eco = g.economics_decision || { count: 0 };
1023
+ function tally(c) { return c === 0 ? '✓ 0' : (c <= 3 ? '⚠ ' + c : '❌ ' + c); }
1024
+ gatesEl.innerHTML = '<span>dep: ' + tally(dep.count) + '</span>' +
1025
+ '<span>disj: ' + tally(dis.count) + '</span>' +
1026
+ '<span>eco: ' + eco.count + '</span>';
1027
+ }
1028
+
1029
+ async function poll() {
1030
+ try {
1031
+ const r = await fetch('/api/parallelism');
1032
+ if (!r.ok) return;
1033
+ const m = await r.json();
1034
+ applyColor(m.color_state);
1035
+ modeEl.textContent = m.parallelism_factor_mode || 'idle';
1036
+ activeEl.textContent = String(m.activeWorkers);
1037
+ readyEl.textContent = String(m.readyTasks);
1038
+ factorEl.textContent = (typeof m.parallelism_factor === 'number' ? m.parallelism_factor.toFixed(1) : '—') + '×';
1039
+ const oldest = (m.activeSpawnAges_s && m.activeSpawnAges_s.length) ? m.activeSpawnAges_s[0] : null;
1040
+ oldestEl.textContent = fmtAge(oldest);
1041
+ renderGates(m.gate_decisions);
1042
+ } catch { /* keep last state on transient failure */ }
1043
+ }
1044
+
1045
+ async function downloadReport() {
1046
+ try {
1047
+ const r = await fetch('/api/parallelism/report');
1048
+ if (!r.ok) return;
1049
+ const md = await r.text();
1050
+ const blob = new Blob([md], { type: 'text/markdown' });
1051
+ const url = URL.createObjectURL(blob);
1052
+ const a = document.createElement('a');
1053
+ a.href = url;
1054
+ a.download = 'parallelism-report-' + new Date().toISOString().slice(0, 19).replace(/:/g, '-') + '.md';
1055
+ document.body.appendChild(a); a.click(); document.body.removeChild(a);
1056
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
1057
+ } catch { /* swallow */ }
1058
+ }
1059
+
1060
+ async function stopSupervisor() {
1061
+ if (!confirm('Write .gsd-t/.unattended/stop sentinel? Active supervisor will exit on next poll.')) return;
1062
+ try {
1063
+ const r = await fetch('/api/unattended-stop', { method: 'POST' });
1064
+ if (r.ok) { stopBtn.textContent = 'Stop sent ✓'; setTimeout(() => { stopBtn.textContent = 'Stop Supervisor'; }, 3000); }
1065
+ } catch { /* swallow */ }
1066
+ }
1067
+
1068
+ if (reportBtn) reportBtn.addEventListener('click', downloadReport);
1069
+ if (stopBtn) stopBtn.addEventListener('click', stopSupervisor);
1070
+ poll();
1071
+ setInterval(poll, 5000);
1072
+ })();
922
1073
  })();
923
1074
  </script>
924
1075
  </body>
@@ -14,6 +14,15 @@ const CACHE_FILE = path.join(CLAUDE_DIR, ".gsd-t-update-check");
14
14
  const CHANGELOG = "https://github.com/Tekyz-Inc/get-stuff-done-teams/blob/main/CHANGELOG.md";
15
15
  const SEMVER_RE = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/;
16
16
 
17
+ // Local-time date stamp prefix for the version banner: "Tue: Mar 26, 2026, "
18
+ // The trailing two spaces are intentional — separates the date from the [GSD-T*] tag.
19
+ function dateStamp(now = new Date()) {
20
+ const day = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][now.getDay()];
21
+ const mon = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
22
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][now.getMonth()];
23
+ return `${day}: ${mon} ${now.getDate()}, ${now.getFullYear()}, `;
24
+ }
25
+
17
26
  function isNewer(a, b) {
18
27
  const ap = a.split(".").map(Number);
19
28
  const bp = b.split(".").map(Number);
@@ -47,9 +56,9 @@ function doAutoUpdate(latest, installed) {
47
56
  const updated = fs.existsSync(VERSION_FILE)
48
57
  ? fs.readFileSync(VERSION_FILE, "utf8").trim()
49
58
  : latest;
50
- console.log(`[GSD-T AUTO-UPDATE] v${installed} → v${updated}. Changelog: ${CHANGELOG}`);
59
+ console.log(`${dateStamp()}[GSD-T AUTO-UPDATE] v${installed} → v${updated}. Changelog: ${CHANGELOG}`);
51
60
  } catch {
52
- console.log(`[GSD-T UPDATE] v${installed} — update available (v${installed} → v${latest}). Auto-update failed — run manually: /gsd-t-version-update-all. Changelog: ${CHANGELOG}`);
61
+ console.log(`${dateStamp()}[GSD-T UPDATE] v${installed} — update available (v${installed} → v${latest}). Auto-update failed — run manually: /gsd-t-version-update-all. Changelog: ${CHANGELOG}`);
53
62
  }
54
63
  }
55
64
 
@@ -86,7 +95,7 @@ function run() {
86
95
  if (cached && cached.latest && isNewer(cached.latest, installed)) {
87
96
  doAutoUpdate(cached.latest, installed);
88
97
  } else {
89
- console.log(`[GSD-T] v${installed} — up to date. Changelog: ${CHANGELOG}`);
98
+ console.log(`${dateStamp()}[GSD-T] v${installed} — CURRENT. Changelog: ${CHANGELOG}`);
90
99
  }
91
100
  }
92
101
 
@@ -95,4 +104,4 @@ if (require.main === module) {
95
104
  try { run(); } catch { /* graceful failure — don't block session start */ }
96
105
  }
97
106
 
98
- module.exports = { isNewer, fetchLatestVersion, doAutoUpdate, run };
107
+ module.exports = { isNewer, fetchLatestVersion, doAutoUpdate, run, dateStamp };