@tekyzinc/gsd-t 3.18.13 → 3.18.17

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.
@@ -184,24 +184,20 @@ Proceed.
184
184
  node scripts/gsd-t-watch-state.js advance --agent-id "$GSD_T_AGENT_ID" --parent-id "${GSD_T_PARENT_AGENT_ID:-null}" --command gsd-t-quick --step 3 --step-label "Execute" 2>/dev/null || true
185
185
  ```
186
186
 
187
- ### Optional — Parallel Dispatch (M44, lightweight · conditional-only)
187
+ ### Parallel Dispatch (MANDATORY single instrument)
188
188
 
189
- **This block is a no-op for the typical quick invocation.** `gsd-t-quick` is designed for single-focus work; forcing parallel on every quick invocation would add gate overhead without benefit.
189
+ Delegate to `gsd-t parallel --command` do NOT re-implement probe-and-branch logic here (M44 D9 Step 3: "create 1 instrument that accomplishes this instead of implementing it in all the commands").
190
190
 
191
- **Trigger conditions (BOTH must hold)**:
192
- 1. `.gsd-t/domains/` contains more than one pending task, AND
193
- 2. All three gates (D4 depgraph + D5 file-disjointness + D6 economics) pass for the candidate batch.
194
-
195
- **If either condition fails** — and that includes the common case of a single-task quick invocation — skip this block entirely. The sequential single-subagent path in Step 0.1 remains unchanged.
196
-
197
- **If BOTH conditions hold** — dispatch the ready batch via `gsd-t parallel` (mode auto-detected from `GSD_T_UNATTENDED=1`; do not hardcode `--mode`):
191
+ ```bash
192
+ node bin/gsd-t.js parallel --command gsd-t-quick && exit 0 || true
193
+ # Exit 0 → fan-out happened; detached children handle the work.
194
+ # Exit 2+ → sequential (N<2 or quick is single-task — the common case).
195
+ # Fall through to the single-subagent path in Step 0.1.
196
+ ```
198
197
 
199
- - Fallback is silent any gate veto drops the affected tasks back to the sequential quick path.
200
- - D2 owns the spawn observability; the parallel path writes the same `.gsd-t/events/YYYY-MM-DD.jsonl` records and `.gsd-t/token-log.md` rows via `captureSpawn`. D3 adds no new spawn machinery.
201
- - `[unattended]` — D2 enforces the zero-compaction contract by splitting tasks when D6 estimates > 60% per-worker CW.
202
- - `[in-session]` — NEVER interrupts the user with a pause/resume prompt. If headroom is tight, D2 reduces the worker count (floor N=1). No opt-out flag exists (consistent with M43 D4: `--in-session` / `--headless` were never shipped).
198
+ `runDispatch` inside `bin/gsd-t-parallel.cjs` owns the probe + D4/D5/D6 gate math + disjoint task-id partitioning + `autoSpawnHeadless()` fan-out. Mode auto-detects from `GSD_T_UNATTENDED=1`. No user prompt. Parallel-when-safe + headless-when-possible are both the default (per headless-default-contract v2.0.0).
203
199
 
204
- Contract: `.gsd-t/contracts/wave-join-contract.md` v1.1.0.
200
+ Contract: `.gsd-t/contracts/wave-join-contract.md` v1.1.0; `.gsd-t/contracts/headless-default-contract.md` v2.0.0.
205
201
 
206
202
  ### Deviation Rules
207
203
 
@@ -207,7 +207,64 @@ out('DOMAIN_SUMMARY', domainSummary.join(' | '));
207
207
 
208
208
  If `PID_FILE_EXISTS=false`:
209
209
 
210
- The supervisor has finalized cleanly and removed its own PID file. Read the final `state.json` status and iter, then print the terminal report:
210
+ The supervisor has finalized cleanly and removed its own PID file.
211
+
212
+ ### Step 3a: Reconciliation probe
213
+
214
+ Before emitting a report, check whether the supervisor's final `status` agrees with the on-disk milestone evidence. A `status=failed` with a **fresh matching milestone archive** is the M45-class false-failed-marker pathology: the worker completed the milestone (including the archive rename under `.gsd-t/milestones/`) but a polarity-unsound exit-code matcher mapped the worker's clean exit to `1`, which the supervisor then finalized as `status=failed`.
215
+
216
+ ```bash
217
+ node -e "
218
+ const fs = require('fs');
219
+ const path = require('path');
220
+ const state = JSON.parse(fs.readFileSync('.gsd-t/.unattended/state.json', 'utf8'));
221
+ const milestone = (state.milestone || '').toLowerCase();
222
+ let reconciled = false;
223
+ if (state.status === 'failed' && milestone) {
224
+ // Look for a fresh archive directory matching the milestone slug.
225
+ // Pattern: .gsd-t/milestones/{slug}-YYYY-MM-DD where slug contains the
226
+ // milestone identifier (e.g. 'M45' → 'm45-conversation-stream-observability-2026-04-23').
227
+ try {
228
+ const archives = fs.readdirSync('.gsd-t/milestones')
229
+ .filter(n => n.toLowerCase().includes(milestone));
230
+ if (archives.length > 0) {
231
+ // Check the most recent archive's mtime is within the supervisor's run window.
232
+ const recent = archives
233
+ .map(n => ({ n, mtime: fs.statSync(path.join('.gsd-t/milestones', n)).mtimeMs }))
234
+ .sort((a, b) => b.mtime - a.mtime)[0];
235
+ const runStart = Date.parse(state.startedAt || 0);
236
+ if (recent && recent.mtime >= runStart) {
237
+ reconciled = true;
238
+ console.log('RECONCILED=true');
239
+ console.log('RECONCILED_ARCHIVE=' + recent.n);
240
+ }
241
+ }
242
+ } catch (_) {}
243
+ }
244
+ if (!reconciled) console.log('RECONCILED=false');
245
+ "
246
+ ```
247
+
248
+ ### Step 3b: Render reconciled or raw report
249
+
250
+ If `RECONCILED=true` AND `STATUS=failed`, the archive is the authority — render the reconciled success report:
251
+
252
+ ```
253
+ ✅ Unattended supervisor finalized — milestone completed (auto-reconciled).
254
+
255
+ Session: {SESSION}
256
+ Milestone: {MILESTONE} — archived as {RECONCILED_ARCHIVE}
257
+ Supervisor flag: status=failed (not trusted — archive present)
258
+ Iterations: {ITER} / {MAX_ITER}
259
+ Total elapsed: {Hh Mm — formatted from ELAPSED_MS}
260
+ Log: {LOG_PATH}
261
+ State: .gsd-t/.unattended/state.json
262
+
263
+ Note: the worker completed the milestone, but a polarity-unsound exit-code
264
+ matcher mapped clean exit → failed. Treat the archive as the source of truth.
265
+ ```
266
+
267
+ Otherwise (no reconciliation — `STATUS` is genuinely terminal `done`, `failed` without archive, or `stopped`), render the raw final report:
211
268
 
212
269
  ```
213
270
  ✅ Unattended supervisor finalized cleanly.
@@ -55,49 +55,52 @@ If `.gsd-t/graph/index.json` exists, the dashboard can render entity-relationshi
55
55
 
56
56
  If `$ARGUMENTS` contains "stop", skip to **Step 5**. Otherwise continue to Step 3.
57
57
 
58
- ## Step 3: Check if Server is Already Running
58
+ ## Step 3: Resolve Port + Check if Server is Already Running
59
+
60
+ The dashboard server binds to a **per-project hashed port** in `[7433, 7532]` (see `projectScopedDefaultPort` in `gsd-t-dashboard-server.js`). Resolve it once and reuse:
59
61
 
60
- Run via Bash:
61
62
  ```bash
63
+ PORT=$(node -e "console.log(require('$HOME/.claude/scripts/gsd-t-dashboard-server.js').resolvePort({ projectDir: process.cwd() }))")
62
64
  if [ -f .gsd-t/dashboard.pid ]; then
63
65
  PID=$(cat .gsd-t/dashboard.pid)
64
- curl -sf http://localhost:7433/ping 2>/dev/null | grep -q '"ok"' && echo "SERVER_RUNNING=true" || echo "SERVER_RUNNING=false"
66
+ curl -sf http://localhost:$PORT/ping 2>/dev/null | grep -q '"ok"' && echo "SERVER_RUNNING=true" || echo "SERVER_RUNNING=false"
65
67
  else
66
68
  echo "SERVER_RUNNING=false"
67
69
  fi
68
70
  ```
69
71
 
70
- If output is `SERVER_RUNNING=true`, skip Step 3a and go directly to Step 4.
72
+ If output is `SERVER_RUNNING=true`, skip Step 3a and go directly to Step 4. Carry `$PORT` forward to subsequent steps.
71
73
 
72
74
  ### Step 3a: Start Server if Not Running
73
75
 
74
- Run via Bash:
76
+ Run via Bash (reuses `$PORT` from Step 3):
75
77
  ```bash
76
78
  node ~/.claude/scripts/gsd-t-dashboard-server.js --detach || true
77
79
  for i in 1 2 3 4 5; do
78
- curl -sf http://localhost:7433/ping 2>/dev/null | grep -q '"ok"' && break
80
+ curl -sf http://localhost:$PORT/ping 2>/dev/null | grep -q '"ok"' && break
79
81
  sleep 1
80
82
  done
81
83
  ```
82
84
 
83
85
  ## Step 4: Open Browser
84
86
 
85
- Run via Bash:
87
+ Run via Bash (reuses `$PORT` from Step 3):
86
88
  ```bash
87
- node -e "const {execFileSync}=require('child_process'); const url='http://localhost:7433'; try { if(process.platform==='win32'){execFileSync('cmd',['/c','start','',url],{stdio:'ignore'})}else{execFileSync(process.platform==='darwin'?'open':'xdg-open',[url],{stdio:'ignore'})} } catch(e) { console.error('Could not open browser:', e.message); }" || true
89
+ URL="http://localhost:$PORT"
90
+ node -e "const {execFileSync}=require('child_process'); const url=process.argv[1]; try { if(process.platform==='win32'){execFileSync('cmd',['/c','start','',url],{stdio:'ignore'})}else{execFileSync(process.platform==='darwin'?'open':'xdg-open',[url],{stdio:'ignore'})} } catch(e) { console.error('Could not open browser:', e.message); }" "$URL" || true
88
91
  ```
89
92
 
90
- Report to the user: "Dashboard is running at http://localhost:7433 — browser opened."
93
+ Report to the user: "Dashboard is running at $URL — browser opened."
91
94
 
92
95
  ## Step 5: Stop Handler
93
96
 
94
- Run only when `$ARGUMENTS` contains "stop".
97
+ Run only when `$ARGUMENTS` contains "stop". Resolves the same hashed port used at start:
95
98
 
96
- Run via Bash:
97
99
  ```bash
100
+ PORT=$(node -e "console.log(require('$HOME/.claude/scripts/gsd-t-dashboard-server.js').resolvePort({ projectDir: process.cwd() }))")
98
101
  if [ -f .gsd-t/dashboard.pid ]; then
99
102
  PID=$(cat .gsd-t/dashboard.pid)
100
- curl -sf http://localhost:7433/stop 2>/dev/null || kill $PID 2>/dev/null || true
103
+ curl -sf http://localhost:$PORT/stop 2>/dev/null || kill $PID 2>/dev/null || true
101
104
  rm -f .gsd-t/dashboard.pid
102
105
  echo "Dashboard server stopped"
103
106
  else
@@ -211,18 +211,9 @@ Spawn agent → `commands/gsd-t-impact.md`
211
211
  Spawn agent → `commands/gsd-t-execute.md`
212
212
  - This is the heaviest phase. The execute agent uses **task-level dispatch** (fresh-dispatch-contract.md): one Task subagent per task within each domain, each receiving only scope.md + relevant contracts + single task + graph context + up to 5 prior summaries. The execute agent handles domain task-dispatching and QA internally.
213
213
 
214
- ##### Optional — Parallel Dispatch (M44)
214
+ ##### Parallel Dispatch (M44 D9, single instrument)
215
215
 
216
- The spawned execute agent will itself decide whether to dispatch parallel workers via `gsd-t parallel` (see `commands/gsd-t-execute.md` Step 3 → "Optional — Parallel Dispatch (M44)"). The wave orchestrator does not need to configure this — it is automatic:
217
-
218
- - If `.gsd-t/domains/` contains more than one pending task passing D4/D5/D6 gates, the execute agent dispatches via `gsd-t parallel` instead of sequentially.
219
- - Mode is auto-detected from `GSD_T_UNATTENDED=1` — the wave orchestrator inherits this env from its own spawn. Do not hardcode `--mode`.
220
- - Fallback is silent: any gate veto, unprovable disjointness, or single-task scope drops back to the sequential execute path without a user prompt.
221
- - D2 owns the spawn observability; the parallel path writes the same `.gsd-t/events/YYYY-MM-DD.jsonl` records and `.gsd-t/token-log.md` rows as the sequential path via `captureSpawn`. D3 adds no new spawn machinery.
222
- - `[unattended]` — D2 enforces the zero-compaction contract by splitting tasks when D6 estimates per-worker CW > 60%.
223
- - `[in-session]` — NEVER interrupts the user with pause/resume. If headroom is tight, D2 reduces the worker count (floor N=1) and emits `parallelism_reduced`.
224
-
225
- No wave-orchestrator-level flag exists to opt out; the parallel-vs-sequential decision is owned by the execute agent and the D2 gating math. Contract: `.gsd-t/contracts/wave-join-contract.md` v1.1.0.
216
+ The spawned execute agent delegates the parallel-vs-sequential decision to the single instrument `gsd-t parallel --command gsd-t-execute` (see `commands/gsd-t-execute.md` → "Parallel Dispatch"). The wave orchestrator does not need to configure this — it is automatic, deterministic, and inherited via `GSD_T_UNATTENDED` env. Contract: `.gsd-t/contracts/wave-join-contract.md` v1.2.0 §Single Instrument.
226
217
 
227
218
  - **Adaptive replanning**: After each domain completes, the execute agent runs a replan check (per `adaptive-replan-contract.md`). If a completed domain's task summaries reveal new constraints (e.g., deprecated API, wrong column name, incompatible library), the execute agent checks remaining domains' `tasks.md` files for invalidated assumptions and revises them on disk before dispatching the next domain. Maximum 2 replan cycles per execute run — if exceeded, execution pauses for user input. All replan decisions are logged to the Decision Log in `progress.md`. The wave phase summary includes any replan actions taken.
228
219
  - **Team/parallel mode**: If the plan defines parallel domains (same wave), the execute agent dispatches each domain teammate with `isolation: "worktree"` (per worktree-isolation-contract.md). Each domain works in an isolated git worktree. After all domains complete, the execute agent runs the Sequential Merge Protocol: merge domain A → test → merge domain B → test. Per-domain rollback if tests fail. Worktrees are cleaned up after all merges complete.
@@ -978,6 +978,72 @@ means in-flight.
978
978
  `test/m44-d8-dashboard-spawn-plans-endpoint.test.js`,
979
979
  `test/m44-d8-transcript-renderer-panel.test.js` — 36 tests total.
980
980
 
981
+ ## Parallelism Panel (M44 D9, v3.19.0+)
982
+
983
+ Pure-observer readout answering two questions:
984
+
985
+ > *Is the orchestrator actually fanning out, or serializing despite parallelism being available?*
986
+
987
+ > *When this wave finishes, did it hit the parallelism factor D6 estimated?*
988
+
989
+ Zero added LLM token cost — every number is derived from files the other
990
+ M44 domains already write: D8 spawn-plan files, D4/D5/D6 event rows, and
991
+ D7 `cw_id` columns in `.gsd-t/token-log.md`.
992
+
993
+ **Module**: `bin/parallelism-report.cjs` —
994
+ `computeParallelismMetrics({projectDir, wave?, now?})` returns the shape
995
+ defined in `.gsd-t/contracts/parallelism-report-contract.md` v1.0.0.
996
+ `buildFullReport(...)` returns a post-mortem markdown string with Summary,
997
+ Per-spawn timeline, Per-gate decisions, Per-worker Gantt, Token cost, and
998
+ Notes sections.
999
+
1000
+ **Data flow**:
1001
+
1002
+ ```
1003
+ .gsd-t/spawns/*.json ─┐ (D8 writer)
1004
+ .gsd-t/events/*.jsonl ─┼─▶ bin/parallelism-report.cjs
1005
+ .gsd-t/domains/*/tasks.md ┤ │
1006
+ .gsd-t/token-log.md ─┘ (pure I/O — never writes, never spawns)
1007
+
1008
+
1009
+ scripts/gsd-t-dashboard-server.js
1010
+ (5s in-memory cache per wave param)
1011
+ ├── GET /api/parallelism → JSON metrics
1012
+ ├── GET /api/parallelism/report → markdown
1013
+ └── POST /api/unattended-stop → writes sentinel
1014
+
1015
+
1016
+ scripts/gsd-t-transcript.html
1017
+ `<aside class="spawn-panel"> .parallelism-panel`
1018
+ polls /api/parallelism every 5s
1019
+ ```
1020
+
1021
+ **Color thresholds** (worst-of across five per-signal colors, from contract §color_state):
1022
+
1023
+ | Signal | Green | Yellow | Red |
1024
+ |--------|-------|--------|-----|
1025
+ | activeWorkers / readyTasks | ≥80% | 50–80% | <50% AND >10 min since last spawn |
1026
+ | Gate veto rate (D4) | <10% | 10–30% | >30% |
1027
+ | parallelism_factor vs. D6 estimate | ≥80% | 50–80% | <50% |
1028
+ | Spawn age (any active worker) | <30 min | 30–45 min | >45 min |
1029
+ | Time since last `spawn_started` (when ready>0) | <5 min | 5–10 min | >10 min |
1030
+
1031
+ Special: `color_state === "dimmed"` when no spawn-plan files exist (idle
1032
+ project) — never red.
1033
+
1034
+ **Silent-fail invariant**: malformed spawn-plan JSON, corrupt JSONL event
1035
+ lines, missing `.gsd-t/domains/`, missing `.gsd-t/token-log.md` — every
1036
+ case logs a note to `metrics.notes` / Full Report `## Notes` and continues
1037
+ with partial data. Observer must never throw when watching a live system.
1038
+
1039
+ **Contract**: `.gsd-t/contracts/parallelism-report-contract.md` v1.0.0.
1040
+
1041
+ **Tests**: `test/m44-d9-parallelism.test.js` — 16 tests covering metric
1042
+ shape, parallelism_factor math (live 1-worker, 4-worker, mixed-duration,
1043
+ post-wave), color-state thresholds, silent-fail on malformed inputs, Full
1044
+ Report markdown sections, and `/api/parallelism*` endpoint shape + 5s
1045
+ cache behaviour.
1046
+
981
1047
  ## Planned Architecture Changes (M23-M24)
982
1048
 
983
1049
  **M23: Headless Mode**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "3.18.13",
3
+ "version": "3.18.17",
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",
@@ -131,7 +131,20 @@ function writeRow(payload) {
131
131
  /**
132
132
  * Find the most-recently-modified .ndjson in <cwd>/.gsd-t/transcripts/.
133
133
  * Returns the absolute path, or null if none exists or the directory is absent.
134
+ *
135
+ * Target-selection rules (M45 D2):
136
+ * 1. Default: prefer the most-recently-modified SPAWN NDJSON (any file whose
137
+ * basename does NOT start with `in-session-`).
138
+ * 2. Fallback: if no spawn NDJSON has been modified within the last
139
+ * IN_SESSION_FALLBACK_MS (30s) AND a fresh `in-session-*.ndjson` exists,
140
+ * target that file instead. Log the decision to stderr so regressions
141
+ * are obvious during debugging.
142
+ * 3. Last resort: if only in-session NDJSONs exist, pick the newest one
143
+ * (covers the edge case where a user triggers /compact during a pure
144
+ * conversation session with no spawns in flight).
134
145
  */
146
+ const IN_SESSION_FALLBACK_MS = 30 * 1000; // 30 seconds
147
+
135
148
  function findActiveTranscript(cwd) {
136
149
  const transcriptsDir = path.join(cwd, ".gsd-t", "transcripts");
137
150
  let entries;
@@ -143,21 +156,51 @@ function findActiveTranscript(cwd) {
143
156
  const ndjsons = entries.filter((e) => e.endsWith(".ndjson"));
144
157
  if (!ndjsons.length) return null;
145
158
 
146
- let newest = null;
147
- let newestMtime = -1;
159
+ let newestSpawn = null;
160
+ let newestSpawnMtime = -1;
161
+ let newestInSession = null;
162
+ let newestInSessionMtime = -1;
148
163
  for (const name of ndjsons) {
149
164
  const full = path.join(transcriptsDir, name);
165
+ let mtimeMs;
150
166
  try {
151
- const stat = fs.statSync(full);
152
- if (stat.mtimeMs > newestMtime) {
153
- newestMtime = stat.mtimeMs;
154
- newest = full;
167
+ mtimeMs = fs.statSync(full).mtimeMs;
168
+ } catch {
169
+ continue;
170
+ }
171
+ const isInSession = name.indexOf("in-session-") === 0;
172
+ if (isInSession) {
173
+ if (mtimeMs > newestInSessionMtime) {
174
+ newestInSessionMtime = mtimeMs;
175
+ newestInSession = full;
176
+ }
177
+ } else {
178
+ if (mtimeMs > newestSpawnMtime) {
179
+ newestSpawnMtime = mtimeMs;
180
+ newestSpawn = full;
155
181
  }
182
+ }
183
+ }
184
+
185
+ const now = Date.now();
186
+ const spawnFresh = newestSpawn != null && (now - newestSpawnMtime) < IN_SESSION_FALLBACK_MS;
187
+
188
+ if (spawnFresh) return newestSpawn;
189
+
190
+ if (newestInSession != null) {
191
+ try {
192
+ process.stderr.write(
193
+ "compact-detector: targeting " + path.basename(newestInSession) + " (fallback)\n"
194
+ );
156
195
  } catch {
157
- // skip unreadable entries
196
+ // silent stderr write must never break the hook
158
197
  }
198
+ return newestInSession;
159
199
  }
160
- return newest;
200
+
201
+ // No fresh spawn, no in-session — fall back to the newest spawn (even if stale)
202
+ // so v1.0.0 behavior is preserved for legacy projects with no in-session file.
203
+ return newestSpawn;
161
204
  }
162
205
 
163
206
  /**
@@ -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) {