@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.
@@ -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.