@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.
- package/CHANGELOG.md +45 -0
- package/bin/gsd-t-parallel-probe.cjs +132 -0
- package/bin/gsd-t-parallel.cjs +242 -9
- package/bin/gsd-t-task-graph.cjs +80 -19
- package/bin/gsd-t-unattended.cjs +210 -9
- package/bin/headless-auto-spawn.cjs +17 -1
- package/bin/headless-exit-codes.cjs +36 -18
- package/bin/m44-proof-measure.cjs +285 -0
- package/bin/parallelism-report.cjs +535 -0
- package/commands/gsd-t-debug.md +10 -14
- package/commands/gsd-t-execute.md +10 -16
- package/commands/gsd-t-help.md +1 -0
- package/commands/gsd-t-integrate.md +8 -14
- package/commands/gsd-t-quick.md +10 -14
- package/commands/gsd-t-unattended-watch.md +58 -1
- package/commands/gsd-t-visualize.md +15 -12
- package/commands/gsd-t-wave.md +2 -11
- package/docs/architecture.md +66 -0
- package/package.json +1 -1
- package/scripts/gsd-t-compact-detector.js +51 -8
- package/scripts/gsd-t-dashboard-server.js +138 -85
- package/scripts/gsd-t-transcript.html +152 -1
- package/scripts/hooks/gsd-t-conversation-capture.js +258 -0
- package/templates/CLAUDE-global.md +54 -0
|
@@ -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
|
-
|
|
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.
|