@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.
@@ -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>
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * GSD-T Conversation Capture Hook (M45 D2)
5
+ *
6
+ * Captures the orchestrator session's conversational turns into
7
+ * `.gsd-t/transcripts/in-session-{sessionId}.ndjson` so the visualizer
8
+ * left rail can list the in-session conversation alongside spawn entries.
9
+ *
10
+ * Installed into `~/.claude/settings.json` (SessionStart, UserPromptSubmit,
11
+ * Stop, optional PostToolUse). Reads the hook payload from stdin, dispatches
12
+ * on `hook_event_name`, appends a typed NDJSON frame. Writes content-level
13
+ * data (not just tokens — that's the job of `gsd-t-in-session-usage-hook.js`).
14
+ *
15
+ * Safety:
16
+ * - Never throws to the caller — catches all errors, logs to stderr, exits 0.
17
+ * - `content` is capped at 16 KB per frame; over-cap writes `truncated: true`.
18
+ * - Append-only; never overwrites an existing in-session NDJSON file.
19
+ * - Project-dir discovery: prefers `GSD_T_PROJECT_DIR`, then `payload.cwd`,
20
+ * then walks up from `process.cwd()` looking for `.gsd-t/progress.md`.
21
+ * Silent no-op if no project dir found.
22
+ *
23
+ * Contract: .gsd-t/contracts/conversation-capture-contract.md v1.0.0
24
+ */
25
+
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const crypto = require('crypto');
29
+
30
+ const DEFAULT_SCRIPT_GUARD_MS = 5000;
31
+ const CONTENT_CAP_BYTES = 16 * 1024; // 16 KB
32
+ const MAX_STDIN = 1024 * 1024; // 1 MiB defense-in-depth
33
+ const started = Date.now();
34
+
35
+ function _readStdin() {
36
+ return new Promise((resolve) => {
37
+ let buf = '';
38
+ let aborted = false;
39
+ process.stdin.setEncoding('utf8');
40
+ process.stdin.on('data', (chunk) => {
41
+ if (aborted) return;
42
+ buf += chunk;
43
+ if (buf.length > MAX_STDIN) {
44
+ aborted = true;
45
+ try { process.stdin.destroy(); } catch (_) { /* noop */ }
46
+ resolve('');
47
+ }
48
+ });
49
+ process.stdin.on('end', () => { if (!aborted) resolve(buf); });
50
+ process.stdin.on('error', () => resolve(buf));
51
+ setTimeout(() => resolve(buf), DEFAULT_SCRIPT_GUARD_MS).unref();
52
+ });
53
+ }
54
+
55
+ function _parsePayload(raw) {
56
+ try { return JSON.parse(raw || '{}'); } catch (_) { return null; }
57
+ }
58
+
59
+ function _walkUpForProject(startDir) {
60
+ try {
61
+ let dir = path.resolve(startDir || '.');
62
+ for (let i = 0; i < 10; i++) {
63
+ if (fs.existsSync(path.join(dir, '.gsd-t', 'progress.md'))) return dir;
64
+ const parent = path.dirname(dir);
65
+ if (parent === dir) break;
66
+ dir = parent;
67
+ }
68
+ } catch (_) { /* swallow */ }
69
+ return null;
70
+ }
71
+
72
+ function _resolveProjectDir(payload) {
73
+ const env = process.env.GSD_T_PROJECT_DIR;
74
+ if (env && fs.existsSync(path.join(env, '.gsd-t'))) return env;
75
+ if (payload && typeof payload.cwd === 'string' && path.isAbsolute(payload.cwd)
76
+ && fs.existsSync(path.join(payload.cwd, '.gsd-t'))) {
77
+ return payload.cwd;
78
+ }
79
+ const walked = _walkUpForProject(process.cwd());
80
+ if (walked) return walked;
81
+ return null;
82
+ }
83
+
84
+ function _resolveSessionId(payload) {
85
+ if (payload && typeof payload.session_id === 'string' && payload.session_id.length > 0
86
+ && !/[\/\\\0]|\.\./.test(payload.session_id)) {
87
+ return payload.session_id;
88
+ }
89
+ // Fallback: deterministic-ish per-process hash. Stable within one process,
90
+ // different across processes. Keeps the filename non-empty when Claude Code
91
+ // omits session_id (shouldn't happen in practice but we must not explode).
92
+ // Also used as defense-in-depth for malformed session_ids containing path
93
+ // separators / `..` that would let path.join collapse the `in-session-`
94
+ // prefix (protects the filename-prefix discriminator contract with the
95
+ // viewer + compact-detector).
96
+ const seed = String(process.pid) + ':' + String(started);
97
+ return 'pid-' + crypto.createHash('sha1').update(seed).digest('hex').slice(0, 12);
98
+ }
99
+
100
+ function _capContent(raw) {
101
+ if (raw == null) return { content: null, truncated: false };
102
+ let str;
103
+ if (typeof raw === 'string') str = raw;
104
+ else {
105
+ try { str = JSON.stringify(raw); } catch (_) { str = String(raw); }
106
+ }
107
+ const byteLen = Buffer.byteLength(str, 'utf8');
108
+ if (byteLen <= CONTENT_CAP_BYTES) return { content: str, truncated: false };
109
+ // Truncate by bytes; slice then re-decode to avoid breaking a multi-byte char.
110
+ const buf = Buffer.from(str, 'utf8').subarray(0, CONTENT_CAP_BYTES);
111
+ return { content: buf.toString('utf8'), truncated: true };
112
+ }
113
+
114
+ function _appendFrame(projectDir, sessionId, frame) {
115
+ const transcriptsDir = path.join(projectDir, '.gsd-t', 'transcripts');
116
+ try { fs.mkdirSync(transcriptsDir, { recursive: true }); } catch (_) { /* noop */ }
117
+ const outPath = path.join(transcriptsDir, 'in-session-' + sessionId + '.ndjson');
118
+ // Path-traversal guard: resolved path must stay under transcriptsDir.
119
+ const resolvedOut = path.resolve(outPath);
120
+ const resolvedDir = path.resolve(transcriptsDir) + path.sep;
121
+ if (!resolvedOut.startsWith(resolvedDir)) return;
122
+ fs.appendFileSync(outPath, JSON.stringify(frame) + '\n', 'utf8');
123
+ }
124
+
125
+ function _extractUserContent(payload) {
126
+ // Claude Code UserPromptSubmit payload carries the prompt text.
127
+ if (payload && typeof payload.prompt === 'string') return payload.prompt;
128
+ if (payload && payload.message && typeof payload.message.content === 'string') {
129
+ return payload.message.content;
130
+ }
131
+ if (payload && payload.user_message && typeof payload.user_message === 'string') {
132
+ return payload.user_message;
133
+ }
134
+ return null;
135
+ }
136
+
137
+ function _extractAssistantContent(payload) {
138
+ // Stop hook payloads vary. Try the common shapes; fall back to null so we
139
+ // still emit a stub frame (ts + session_id only).
140
+ if (payload && typeof payload.assistant_message === 'string') return payload.assistant_message;
141
+ if (payload && payload.message && typeof payload.message.content === 'string') {
142
+ return payload.message.content;
143
+ }
144
+ if (payload && typeof payload.content === 'string') return payload.content;
145
+ return null;
146
+ }
147
+
148
+ function _buildUserFrame(payload, sessionId, ts) {
149
+ const { content, truncated } = _capContent(_extractUserContent(payload));
150
+ const frame = {
151
+ type: 'user_turn',
152
+ ts,
153
+ session_id: sessionId,
154
+ };
155
+ if (content != null) frame.content = content;
156
+ if (truncated) frame.truncated = true;
157
+ if (payload && typeof payload.message_id === 'string') frame.message_id = payload.message_id;
158
+ return frame;
159
+ }
160
+
161
+ function _buildAssistantFrame(payload, sessionId, ts) {
162
+ const { content, truncated } = _capContent(_extractAssistantContent(payload));
163
+ const frame = {
164
+ type: 'assistant_turn',
165
+ ts,
166
+ session_id: sessionId,
167
+ };
168
+ if (content != null) frame.content = content;
169
+ if (truncated) frame.truncated = true;
170
+ if (payload && typeof payload.message_id === 'string') frame.message_id = payload.message_id;
171
+ return frame;
172
+ }
173
+
174
+ function _buildSessionStartFrame(sessionId, ts) {
175
+ return { type: 'session_start', ts, session_id: sessionId };
176
+ }
177
+
178
+ function _buildToolUseFrame(payload, sessionId, ts) {
179
+ const frame = {
180
+ type: 'tool_use',
181
+ ts,
182
+ session_id: sessionId,
183
+ };
184
+ if (payload && typeof payload.tool_name === 'string') frame.name = payload.tool_name;
185
+ else if (payload && payload.tool && typeof payload.tool.name === 'string') frame.name = payload.tool.name;
186
+ if (payload && typeof payload.tool_use_id === 'string') frame.tool_use_id = payload.tool_use_id;
187
+ if (payload && typeof payload.duration_ms === 'number') frame.duration_ms = payload.duration_ms;
188
+ return frame;
189
+ }
190
+
191
+ function _handle(payload) {
192
+ if (!payload || typeof payload !== 'object') return;
193
+ const event = payload.hook_event_name;
194
+ if (!event) return;
195
+
196
+ const projectDir = _resolveProjectDir(payload);
197
+ if (!projectDir) return; // not a GSD-T project — silent no-op
198
+
199
+ const sessionId = _resolveSessionId(payload);
200
+ const ts = new Date().toISOString();
201
+
202
+ switch (event) {
203
+ case 'SessionStart': {
204
+ _appendFrame(projectDir, sessionId, _buildSessionStartFrame(sessionId, ts));
205
+ return;
206
+ }
207
+ case 'UserPromptSubmit': {
208
+ _appendFrame(projectDir, sessionId, _buildUserFrame(payload, sessionId, ts));
209
+ return;
210
+ }
211
+ case 'Stop': {
212
+ _appendFrame(projectDir, sessionId, _buildAssistantFrame(payload, sessionId, ts));
213
+ return;
214
+ }
215
+ case 'PostToolUse': {
216
+ // Opt-in: guarded to keep default writes small.
217
+ if (process.env.GSD_T_CAPTURE_TOOL_USES !== '1') return;
218
+ _appendFrame(projectDir, sessionId, _buildToolUseFrame(payload, sessionId, ts));
219
+ return;
220
+ }
221
+ default:
222
+ return;
223
+ }
224
+ }
225
+
226
+ async function main() {
227
+ try {
228
+ const raw = await _readStdin();
229
+ const payload = _parsePayload(raw);
230
+ if (!payload) return;
231
+ _handle(payload);
232
+ } catch (err) {
233
+ try { process.stderr.write('gsd-t-conversation-capture: ' + (err && err.message || err) + '\n'); } catch (_) { /* noop */ }
234
+ } finally {
235
+ const elapsed = Date.now() - started;
236
+ if (elapsed > DEFAULT_SCRIPT_GUARD_MS) process.exitCode = 0;
237
+ }
238
+ }
239
+
240
+ if (require.main === module) {
241
+ main();
242
+ }
243
+
244
+ module.exports = {
245
+ _internal: {
246
+ _parsePayload,
247
+ _resolveProjectDir,
248
+ _resolveSessionId,
249
+ _capContent,
250
+ _buildUserFrame,
251
+ _buildAssistantFrame,
252
+ _buildSessionStartFrame,
253
+ _buildToolUseFrame,
254
+ _appendFrame,
255
+ _handle,
256
+ CONTENT_CAP_BYTES,
257
+ },
258
+ };
@@ -253,6 +253,60 @@ This gives the user real-time visibility into which model is handling each opera
253
253
 
254
254
  **Context Meter (M34/M38, v3.12.10+)** — The real context-window measurement feeding the headless-default spawn decision. A PostToolUse hook (`scripts/gsd-t-context-meter.js`) runs after every tool call, uses local token estimation to write the current input-token count into `.gsd-t/.context-meter-state.json`. `getSessionStatus()` reads that state file (fresh window = 5 minutes) with a historical heuristic fallback when the file is missing or stale. Command files consume the signal via a small bash shim (`CTX_PCT=$(node -e "…tb.getSessionStatus('.').pct")`). **Single-band model** (context-meter-contract v1.3.0): there's one threshold (default 85%) and one action — hand off to a detached headless spawn. No three-band routing, no silent downgrades, no MANDATORY STOP prose. The meter exists to inform spawn-time routing, not to pause work in-flight.
255
255
 
256
+ ## In-Session Conversation Capture (M45 D2)
257
+
258
+ The orchestrator session's user↔assistant dialog is captured into
259
+ `.gsd-t/transcripts/in-session-{sessionId}.ndjson` via a dedicated hook
260
+ script (`scripts/hooks/gsd-t-conversation-capture.js`). The viewer's left
261
+ rail labels these entries `💬 conversation` (front-end-only discriminator
262
+ — the `in-session-` filename prefix is the contract).
263
+
264
+ This hook captures **content** (user prompts + assistant replies). It is
265
+ complementary to `scripts/hooks/gsd-t-in-session-usage-hook.js` (M43 D1),
266
+ which captures per-turn **token usage** into
267
+ `.gsd-t/metrics/token-usage.jsonl`. Both hooks coexist on the same events.
268
+
269
+ **Install block** (append to `~/.claude/settings.json` alongside the existing
270
+ context-meter, version-check, compact-detector, and in-session-usage hooks):
271
+
272
+ ```json
273
+ {
274
+ "hooks": {
275
+ "SessionStart": [
276
+ { "matcher": "",
277
+ "hooks": [{ "type": "command",
278
+ "command": "node \"$HOME/.claude/scripts/hooks/gsd-t-conversation-capture.js\"",
279
+ "async": true }] }
280
+ ],
281
+ "UserPromptSubmit": [
282
+ { "matcher": "",
283
+ "hooks": [{ "type": "command",
284
+ "command": "node \"$HOME/.claude/scripts/hooks/gsd-t-conversation-capture.js\"",
285
+ "async": true }] }
286
+ ],
287
+ "Stop": [
288
+ { "matcher": "",
289
+ "hooks": [{ "type": "command",
290
+ "command": "node \"$HOME/.claude/scripts/hooks/gsd-t-conversation-capture.js\"",
291
+ "async": true }] }
292
+ ],
293
+ "PostToolUse": [
294
+ { "matcher": "",
295
+ "hooks": [{ "type": "command",
296
+ "command": "GSD_T_CAPTURE_TOOL_USES=1 node \"$HOME/.claude/scripts/hooks/gsd-t-conversation-capture.js\"",
297
+ "async": true }] }
298
+ ]
299
+ }
300
+ }
301
+ ```
302
+
303
+ The `PostToolUse` entry is **opt-in** via `GSD_T_CAPTURE_TOOL_USES=1`. Leave it
304
+ unset unless you want per-tool frames in the NDJSON (full tool payloads are
305
+ already recorded in `events/*.jsonl`).
306
+
307
+ Contract: `.gsd-t/contracts/conversation-capture-contract.md` v1.0.0. Frame
308
+ schema, file-naming, and session-id resolution rules are locked there.
309
+
256
310
  ## Observability Logging (MANDATORY)
257
311
 
258
312
  Every command that spawns a Task subagent, invokes `claude -p`, or calls `spawn('claude', ...)` MUST route the spawn through `bin/gsd-t-token-capture.cjs` so the real token-usage envelope is parsed and recorded. This is the M41 canonical pattern — the pre-M41 bash block that wrote `| N/A |` is retired.