@tekyzinc/gsd-t 3.29.11 → 4.0.12
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 +146 -0
- package/bin/gsd-t-parallel.cjs +64 -13
- package/bin/gsd-t.js +48 -376
- package/bin/journey-coverage.cjs +6 -3
- package/bin/parallel-cli.cjs +13 -1
- package/commands/gsd-t-debug.md +47 -533
- package/commands/gsd-t-design-decompose.md +24 -497
- package/commands/gsd-t-doc-ripple.md +23 -139
- package/commands/gsd-t-execute.md +41 -958
- package/commands/gsd-t-help.md +0 -2
- package/commands/gsd-t-impact.md +26 -295
- package/commands/gsd-t-integrate.md +44 -369
- package/commands/gsd-t-milestone.md +25 -124
- package/commands/gsd-t-partition.md +28 -539
- package/commands/gsd-t-plan.md +26 -472
- package/commands/gsd-t-prd.md +25 -311
- package/commands/gsd-t-quick.md +1 -1
- package/commands/gsd-t-resume.md +0 -33
- package/commands/gsd-t-verify.md +52 -569
- package/commands/gsd-t-wave.md +41 -430
- package/package.json +1 -1
- package/scripts/gsd-t-calibration-hook.js +3 -1
- package/scripts/hooks/pre-commit-journey-coverage +0 -2
- package/scripts/hooks/pre-commit-playwright-gate +0 -2
- package/templates/CLAUDE-global.md +43 -173
- package/templates/CLAUDE-project.md +118 -104
- package/templates/prompts/design-verify-subagent.md +3 -0
- package/templates/prompts/qa-subagent.md +3 -0
- package/templates/prompts/red-team-subagent.md +5 -2
- package/templates/workflows/_lib.js +176 -0
- package/templates/workflows/gsd-t-debug.workflow.js +86 -0
- package/templates/workflows/gsd-t-execute.workflow.js +206 -0
- package/templates/workflows/gsd-t-integrate.workflow.js +71 -0
- package/templates/workflows/gsd-t-phase.workflow.js +94 -0
- package/templates/workflows/gsd-t-quick.workflow.js +72 -0
- package/templates/workflows/gsd-t-verify.workflow.js +348 -0
- package/templates/workflows/gsd-t-wave.workflow.js +43 -0
- package/bin/check-headless-sessions.js +0 -140
- package/bin/context-budget-audit.cjs +0 -447
- package/bin/context-meter-config.cjs +0 -101
- package/bin/context-meter-config.test.cjs +0 -101
- package/bin/event-stream.cjs +0 -205
- package/bin/gsd-t-benchmark-orchestrator.js +0 -437
- package/bin/gsd-t-capture-lint.cjs +0 -440
- package/bin/gsd-t-economics.cjs +0 -315
- package/bin/gsd-t-in-session-usage.cjs +0 -213
- package/bin/gsd-t-orchestrator-config.cjs +0 -161
- package/bin/gsd-t-orchestrator-queue.cjs +0 -180
- package/bin/gsd-t-orchestrator-recover.cjs +0 -231
- package/bin/gsd-t-orchestrator-worker.cjs +0 -219
- package/bin/gsd-t-orchestrator.js +0 -535
- package/bin/gsd-t-parallel-probe.cjs +0 -132
- package/bin/gsd-t-ratelimit-probe-worker.cjs +0 -237
- package/bin/gsd-t-ratelimit-probe.cjs +0 -648
- package/bin/gsd-t-report-tokens.cjs +0 -549
- package/bin/gsd-t-stream-feed-client.cjs +0 -151
- package/bin/gsd-t-token-backfill.cjs +0 -366
- package/bin/gsd-t-token-capture.cjs +0 -321
- package/bin/gsd-t-token-dashboard.cjs +0 -353
- package/bin/gsd-t-token-regenerate-log.cjs +0 -129
- package/bin/gsd-t-tool-attribution.cjs +0 -377
- package/bin/gsd-t-tool-cost.cjs +0 -195
- package/bin/gsd-t-transcript-tee.cjs +0 -246
- package/bin/gsd-t-unattended-heartbeat.cjs +0 -188
- package/bin/gsd-t-unattended-platform.cjs +0 -551
- package/bin/gsd-t-unattended-platform.js +0 -381
- package/bin/gsd-t-unattended-safety.cjs +0 -773
- package/bin/gsd-t-unattended-safety.js +0 -788
- package/bin/gsd-t-unattended.cjs +0 -2109
- package/bin/gsd-t-unattended.js +0 -1367
- package/bin/gsd-t-worker-dispatch.cjs +0 -211
- package/bin/handoff-lock.cjs +0 -249
- package/bin/handoff-lock.js +0 -249
- package/bin/headless-auto-spawn.cjs +0 -711
- package/bin/headless-auto-spawn.js +0 -373
- package/bin/headless-exit-codes.cjs +0 -67
- package/bin/live-activity-report.cjs +0 -615
- package/bin/log-tail.cjs +0 -81
- package/bin/m44-proof-measure.cjs +0 -285
- package/bin/m46-iter-proof.cjs +0 -149
- package/bin/m46-worker-proof.cjs +0 -201
- package/bin/m55-substrate-proof.cjs +0 -134
- package/bin/metrics-rollup.js +0 -200
- package/bin/model-windows.cjs +0 -99
- package/bin/model-windows.test.cjs +0 -75
- package/bin/parallelism-report.cjs +0 -535
- package/bin/runway-estimator.cjs +0 -242
- package/bin/spawn-plan-derive.cjs +0 -163
- package/bin/spawn-plan-status-updater.cjs +0 -292
- package/bin/spawn-plan-writer.cjs +0 -204
- package/bin/supervisor-pid-fingerprint.cjs +0 -126
- package/bin/token-budget.cjs +0 -265
- package/bin/unattended-watch-format.cjs +0 -178
- package/bin/watch-progress.js +0 -155
- package/commands/gsd-t-unattended-stop.md +0 -85
- package/commands/gsd-t-unattended-watch.md +0 -465
- package/commands/gsd-t-unattended.md +0 -476
- package/commands/gsd-t-visualize.md +0 -116
- package/scripts/gsd-t-context-meter.e2e.test.js +0 -364
- package/scripts/gsd-t-context-meter.js +0 -341
- package/scripts/gsd-t-context-meter.test.js +0 -471
- package/scripts/gsd-t-dashboard-server.js +0 -1203
- package/scripts/gsd-t-post-commit-spawn-plan.sh +0 -86
- package/scripts/gsd-t-transcript.html +0 -1821
- package/scripts/hooks/gsd-t-conversation-capture.js +0 -439
- package/scripts/hooks/gsd-t-in-session-usage-hook.js +0 -84
- package/scripts/spawn-plan-fmt-tokens.cjs +0 -80
- package/templates/hooks/post-commit-spawn-plan.sh +0 -85
|
@@ -1,439 +0,0 @@
|
|
|
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 decodes
|
|
20
|
-
* `payload.transcript_path`'s `~/.claude/projects/{slug}` to a real project
|
|
21
|
-
* root (session-specific signal — required when multiple parallel Claude
|
|
22
|
-
* Code sessions share one node-runtime hook process), then `payload.cwd`,
|
|
23
|
-
* then walks up from `process.cwd()` looking for `.gsd-t/progress.md`.
|
|
24
|
-
* Silent no-op if no project dir found.
|
|
25
|
-
*
|
|
26
|
-
* Contract: .gsd-t/contracts/conversation-capture-contract.md v1.2.0
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
const fs = require('fs');
|
|
30
|
-
const path = require('path');
|
|
31
|
-
const crypto = require('crypto');
|
|
32
|
-
const os = require('os');
|
|
33
|
-
|
|
34
|
-
const DEFAULT_SCRIPT_GUARD_MS = 5000;
|
|
35
|
-
const CONTENT_CAP_BYTES = 16 * 1024; // 16 KB
|
|
36
|
-
const MAX_STDIN = 1024 * 1024; // 1 MiB defense-in-depth
|
|
37
|
-
const started = Date.now();
|
|
38
|
-
|
|
39
|
-
function _readStdin() {
|
|
40
|
-
return new Promise((resolve) => {
|
|
41
|
-
let buf = '';
|
|
42
|
-
let aborted = false;
|
|
43
|
-
process.stdin.setEncoding('utf8');
|
|
44
|
-
process.stdin.on('data', (chunk) => {
|
|
45
|
-
if (aborted) return;
|
|
46
|
-
buf += chunk;
|
|
47
|
-
if (buf.length > MAX_STDIN) {
|
|
48
|
-
aborted = true;
|
|
49
|
-
try { process.stdin.destroy(); } catch (_) { /* noop */ }
|
|
50
|
-
resolve('');
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
process.stdin.on('end', () => { if (!aborted) resolve(buf); });
|
|
54
|
-
process.stdin.on('error', () => resolve(buf));
|
|
55
|
-
setTimeout(() => resolve(buf), DEFAULT_SCRIPT_GUARD_MS).unref();
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function _parsePayload(raw) {
|
|
60
|
-
try { return JSON.parse(raw || '{}'); } catch (_) { return null; }
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function _walkUpForProject(startDir) {
|
|
64
|
-
try {
|
|
65
|
-
let dir = path.resolve(startDir || '.');
|
|
66
|
-
for (let i = 0; i < 10; i++) {
|
|
67
|
-
if (fs.existsSync(path.join(dir, '.gsd-t', 'progress.md'))) return dir;
|
|
68
|
-
const parent = path.dirname(dir);
|
|
69
|
-
if (parent === dir) break;
|
|
70
|
-
dir = parent;
|
|
71
|
-
}
|
|
72
|
-
} catch (_) { /* swallow */ }
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Decode a Claude Code project slug (the directory name under
|
|
77
|
-
// `~/.claude/projects/`) back to an absolute project root that contains a
|
|
78
|
-
// `.gsd-t/` directory. The slug encoding is lossy — `/` and literal `-` both
|
|
79
|
-
// map to `-` — so we DFS-walk the filesystem, greedily consuming token runs as
|
|
80
|
-
// directory names. First match whose `.gsd-t/` exists wins. Returns null if
|
|
81
|
-
// nothing matches or the slug is malformed.
|
|
82
|
-
//
|
|
83
|
-
// Why this exists: Claude Code Stop hook payloads carry `transcript_path` =
|
|
84
|
-
// `~/.claude/projects/{slug}/{sessionId}.jsonl`. The hook runs as one shared
|
|
85
|
-
// node-runtime process across parallel Claude Code sessions, so `process.cwd()`
|
|
86
|
-
// can resolve to the wrong project. The slug is the only *session-specific*
|
|
87
|
-
// signal we have for project routing.
|
|
88
|
-
function _slugToProjectDir(slug) {
|
|
89
|
-
if (typeof slug !== 'string' || slug.length === 0) return null;
|
|
90
|
-
if (slug[0] !== '-') return null; // must encode leading '/'
|
|
91
|
-
// Reject anything that could traversal-escape after decode.
|
|
92
|
-
if (slug.includes('/') || slug.includes('\\') || slug.includes('\0')) return null;
|
|
93
|
-
if (slug.includes('..')) return null;
|
|
94
|
-
const tokens = slug.slice(1).split('-'); // strip leading '-' (the leading '/')
|
|
95
|
-
if (tokens.length === 0 || tokens.some((t) => t.length === 0)) return null;
|
|
96
|
-
// DFS over how many '-'-separated tokens form each directory segment.
|
|
97
|
-
// Greedy preference: try fewest tokens first (most '/' separators) so deeper
|
|
98
|
-
// paths win when both interpretations exist.
|
|
99
|
-
function walk(prefix, idx) {
|
|
100
|
-
if (idx >= tokens.length) {
|
|
101
|
-
try {
|
|
102
|
-
if (fs.existsSync(path.join(prefix, '.gsd-t'))) return prefix;
|
|
103
|
-
} catch (_) { /* swallow */ }
|
|
104
|
-
return null;
|
|
105
|
-
}
|
|
106
|
-
for (let k = 1; k <= tokens.length - idx; k++) {
|
|
107
|
-
const seg = tokens.slice(idx, idx + k).join('-');
|
|
108
|
-
const next = path.join(prefix, seg);
|
|
109
|
-
// Re-validate after path.join — defense against weird inputs.
|
|
110
|
-
if (next.includes('..')) continue;
|
|
111
|
-
let exists = false;
|
|
112
|
-
try { exists = fs.existsSync(next); } catch (_) { exists = false; }
|
|
113
|
-
if (!exists) continue;
|
|
114
|
-
const found = walk(next, idx + k);
|
|
115
|
-
if (found) return found;
|
|
116
|
-
}
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
return walk('/', 0);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Extract the slug ({dir-name} under `~/.claude/projects/`) from a
|
|
123
|
-
// transcript_path. Returns null on malformed input.
|
|
124
|
-
function _slugFromTranscriptPath(p) {
|
|
125
|
-
if (typeof p !== 'string' || !path.isAbsolute(p)) return null;
|
|
126
|
-
const home = process.env.HOME || os.homedir();
|
|
127
|
-
if (!home) return null;
|
|
128
|
-
const root = path.resolve(home, '.claude', 'projects') + path.sep;
|
|
129
|
-
const resolved = path.resolve(p);
|
|
130
|
-
if (!resolved.startsWith(root)) return null;
|
|
131
|
-
const rest = resolved.slice(root.length);
|
|
132
|
-
// First path segment after the projects/ root is the slug.
|
|
133
|
-
const sep = rest.indexOf(path.sep);
|
|
134
|
-
const slug = sep === -1 ? rest : rest.slice(0, sep);
|
|
135
|
-
if (!slug) return null;
|
|
136
|
-
return slug;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function _resolveProjectDir(payload) {
|
|
140
|
-
// 1. Explicit env override (preserved for tests + operator overrides).
|
|
141
|
-
const env = process.env.GSD_T_PROJECT_DIR;
|
|
142
|
-
if (env && fs.existsSync(path.join(env, '.gsd-t'))) return env;
|
|
143
|
-
// 2. Session-specific signal: decode the transcript_path slug. This is the
|
|
144
|
-
// ONLY source that's per-session under parallel Claude Code instances —
|
|
145
|
-
// cwd / walk-up resolve to whichever project the hook process happens to
|
|
146
|
-
// inherit, which misroutes frames across projects.
|
|
147
|
-
if (payload && typeof payload.transcript_path === 'string') {
|
|
148
|
-
const slug = _slugFromTranscriptPath(payload.transcript_path);
|
|
149
|
-
if (slug) {
|
|
150
|
-
const fromSlug = _slugToProjectDir(slug);
|
|
151
|
-
if (fromSlug) return fromSlug;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
// 3. payload.cwd (Claude Code may carry it on some events).
|
|
155
|
-
if (payload && typeof payload.cwd === 'string' && path.isAbsolute(payload.cwd)
|
|
156
|
-
&& fs.existsSync(path.join(payload.cwd, '.gsd-t'))) {
|
|
157
|
-
return payload.cwd;
|
|
158
|
-
}
|
|
159
|
-
// 4. Last resort: walk up from process.cwd(). Known-unreliable for parallel
|
|
160
|
-
// sessions sharing a node-runtime hook process — emit a one-line warning
|
|
161
|
-
// so misroutes are diagnosable from stderr.
|
|
162
|
-
const walked = _walkUpForProject(process.cwd());
|
|
163
|
-
if (walked) {
|
|
164
|
-
try {
|
|
165
|
-
process.stderr.write(
|
|
166
|
-
'gsd-t-conversation-capture: project-dir resolved via cwd walk-up (' +
|
|
167
|
-
walked + ') — unreliable for parallel sessions\n'
|
|
168
|
-
);
|
|
169
|
-
} catch (_) { /* noop */ }
|
|
170
|
-
return walked;
|
|
171
|
-
}
|
|
172
|
-
return null;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function _resolveSessionId(payload) {
|
|
176
|
-
if (payload && typeof payload.session_id === 'string' && payload.session_id.length > 0
|
|
177
|
-
&& !/[\/\\\0]|\.\./.test(payload.session_id)) {
|
|
178
|
-
return payload.session_id;
|
|
179
|
-
}
|
|
180
|
-
// Fallback: deterministic-ish per-process hash. Stable within one process,
|
|
181
|
-
// different across processes. Keeps the filename non-empty when Claude Code
|
|
182
|
-
// omits session_id (shouldn't happen in practice but we must not explode).
|
|
183
|
-
// Also used as defense-in-depth for malformed session_ids containing path
|
|
184
|
-
// separators / `..` that would let path.join collapse the `in-session-`
|
|
185
|
-
// prefix (protects the filename-prefix discriminator contract with the
|
|
186
|
-
// viewer + compact-detector).
|
|
187
|
-
const seed = String(process.pid) + ':' + String(started);
|
|
188
|
-
return 'pid-' + crypto.createHash('sha1').update(seed).digest('hex').slice(0, 12);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function _capContent(raw) {
|
|
192
|
-
if (raw == null) return { content: null, truncated: false };
|
|
193
|
-
let str;
|
|
194
|
-
if (typeof raw === 'string') str = raw;
|
|
195
|
-
else {
|
|
196
|
-
try { str = JSON.stringify(raw); } catch (_) { str = String(raw); }
|
|
197
|
-
}
|
|
198
|
-
const byteLen = Buffer.byteLength(str, 'utf8');
|
|
199
|
-
if (byteLen <= CONTENT_CAP_BYTES) return { content: str, truncated: false };
|
|
200
|
-
// Truncate by bytes; slice then re-decode to avoid breaking a multi-byte char.
|
|
201
|
-
const buf = Buffer.from(str, 'utf8').subarray(0, CONTENT_CAP_BYTES);
|
|
202
|
-
return { content: buf.toString('utf8'), truncated: true };
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function _appendFrame(projectDir, sessionId, frame) {
|
|
206
|
-
const transcriptsDir = path.join(projectDir, '.gsd-t', 'transcripts');
|
|
207
|
-
try { fs.mkdirSync(transcriptsDir, { recursive: true }); } catch (_) { /* noop */ }
|
|
208
|
-
const outPath = path.join(transcriptsDir, 'in-session-' + sessionId + '.ndjson');
|
|
209
|
-
// Path-traversal guard: resolved path must stay under transcriptsDir.
|
|
210
|
-
const resolvedOut = path.resolve(outPath);
|
|
211
|
-
const resolvedDir = path.resolve(transcriptsDir) + path.sep;
|
|
212
|
-
if (!resolvedOut.startsWith(resolvedDir)) return;
|
|
213
|
-
fs.appendFileSync(outPath, JSON.stringify(frame) + '\n', 'utf8');
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function _extractUserContent(payload) {
|
|
217
|
-
// Claude Code UserPromptSubmit payload carries the prompt text.
|
|
218
|
-
if (payload && typeof payload.prompt === 'string') return payload.prompt;
|
|
219
|
-
if (payload && payload.message && typeof payload.message.content === 'string') {
|
|
220
|
-
return payload.message.content;
|
|
221
|
-
}
|
|
222
|
-
if (payload && payload.user_message && typeof payload.user_message === 'string') {
|
|
223
|
-
return payload.user_message;
|
|
224
|
-
}
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Tail-read the last `bytes` of a file as UTF-8. Returns '' on any error.
|
|
229
|
-
// Used so multi-MB transcripts don't get fully loaded into RAM.
|
|
230
|
-
function _readFileTail(filePath, bytes) {
|
|
231
|
-
let fd = -1;
|
|
232
|
-
try {
|
|
233
|
-
const st = fs.statSync(filePath);
|
|
234
|
-
if (!st.isFile()) return '';
|
|
235
|
-
const size = st.size;
|
|
236
|
-
if (size === 0) return '';
|
|
237
|
-
const want = Math.min(bytes, size);
|
|
238
|
-
const start = size - want;
|
|
239
|
-
fd = fs.openSync(filePath, 'r');
|
|
240
|
-
const buf = Buffer.alloc(want);
|
|
241
|
-
fs.readSync(fd, buf, 0, want, start);
|
|
242
|
-
let str = buf.toString('utf8');
|
|
243
|
-
// If we sliced mid-line, drop the (possibly malformed) leading partial.
|
|
244
|
-
if (start > 0) {
|
|
245
|
-
const nl = str.indexOf('\n');
|
|
246
|
-
if (nl >= 0) str = str.slice(nl + 1);
|
|
247
|
-
}
|
|
248
|
-
return str;
|
|
249
|
-
} catch (_) {
|
|
250
|
-
return '';
|
|
251
|
-
} finally {
|
|
252
|
-
if (fd >= 0) { try { fs.closeSync(fd); } catch (_) { /* noop */ } }
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// Validate `transcript_path` from a hook payload before reading. Stop hooks
|
|
257
|
-
// from Claude Code put the file under `~/.claude/projects/`; we lock to that
|
|
258
|
-
// to defeat path-traversal attempts (BUG-1 sanitizer pattern). Fail open
|
|
259
|
-
// (return null) on anything suspicious.
|
|
260
|
-
function _safeTranscriptPath(p) {
|
|
261
|
-
if (typeof p !== 'string' || p.length === 0) return null;
|
|
262
|
-
if (!path.isAbsolute(p)) return null;
|
|
263
|
-
const home = process.env.HOME || os.homedir();
|
|
264
|
-
if (!home) return null;
|
|
265
|
-
const allowedRoot = path.resolve(home, '.claude', 'projects') + path.sep;
|
|
266
|
-
const resolved = path.resolve(p);
|
|
267
|
-
if (!resolved.startsWith(allowedRoot)) return null;
|
|
268
|
-
return resolved;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Pull the assistant body out of a Claude Code transcript JSONL by scanning
|
|
272
|
-
// from the tail. Each line is one event; the latest `type === 'assistant'`
|
|
273
|
-
// row carries the message. Concatenate all `text`-type content blocks; ignore
|
|
274
|
-
// tool_use / tool_result / thinking blocks. Returns null if no assistant row
|
|
275
|
-
// is found or every candidate is body-less (tool_use only).
|
|
276
|
-
function _readAssistantFromTranscript(transcriptPath) {
|
|
277
|
-
const safe = _safeTranscriptPath(transcriptPath);
|
|
278
|
-
if (!safe) return null;
|
|
279
|
-
const tail = _readFileTail(safe, 64 * 1024);
|
|
280
|
-
if (!tail) return null;
|
|
281
|
-
const lines = tail.split('\n');
|
|
282
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
283
|
-
const line = lines[i];
|
|
284
|
-
if (!line) continue;
|
|
285
|
-
let row;
|
|
286
|
-
try { row = JSON.parse(line); } catch (_) { continue; }
|
|
287
|
-
if (!row || row.type !== 'assistant') continue;
|
|
288
|
-
// Skip subagent turns — orchestrator transcripts record both, but only
|
|
289
|
-
// the orchestrator's own assistant turn belongs in this in-session file.
|
|
290
|
-
if (row.isSidechain === true) continue;
|
|
291
|
-
const msg = row.message;
|
|
292
|
-
if (!msg) continue;
|
|
293
|
-
const blocks = msg.content;
|
|
294
|
-
if (typeof blocks === 'string') return blocks;
|
|
295
|
-
if (!Array.isArray(blocks)) continue;
|
|
296
|
-
const texts = [];
|
|
297
|
-
for (const b of blocks) {
|
|
298
|
-
if (b && b.type === 'text' && typeof b.text === 'string') texts.push(b.text);
|
|
299
|
-
}
|
|
300
|
-
if (texts.length === 0) continue; // tool_use-only turn — keep scanning
|
|
301
|
-
return texts.join('');
|
|
302
|
-
}
|
|
303
|
-
return null;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function _extractAssistantContent(payload) {
|
|
307
|
-
// PRIMARY: Claude Code Stop hook payload carries `transcript_path` to the
|
|
308
|
-
// orchestrator's JSONL. Read the most recent assistant row from the tail.
|
|
309
|
-
if (payload && typeof payload.transcript_path === 'string') {
|
|
310
|
-
const fromTranscript = _readAssistantFromTranscript(payload.transcript_path);
|
|
311
|
-
if (fromTranscript != null) return fromTranscript;
|
|
312
|
-
}
|
|
313
|
-
// Fallback shapes — kept so older / non-Claude-Code payload shapes still
|
|
314
|
-
// work (and so unit tests can exercise the hook without a real transcript).
|
|
315
|
-
if (payload && typeof payload.assistant_message === 'string') return payload.assistant_message;
|
|
316
|
-
if (payload && payload.message && typeof payload.message.content === 'string') {
|
|
317
|
-
return payload.message.content;
|
|
318
|
-
}
|
|
319
|
-
if (payload && typeof payload.content === 'string') return payload.content;
|
|
320
|
-
return null;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function _buildUserFrame(payload, sessionId, ts) {
|
|
324
|
-
const { content, truncated } = _capContent(_extractUserContent(payload));
|
|
325
|
-
const frame = {
|
|
326
|
-
type: 'user_turn',
|
|
327
|
-
ts,
|
|
328
|
-
session_id: sessionId,
|
|
329
|
-
};
|
|
330
|
-
if (content != null) frame.content = content;
|
|
331
|
-
if (truncated) frame.truncated = true;
|
|
332
|
-
if (payload && typeof payload.message_id === 'string') frame.message_id = payload.message_id;
|
|
333
|
-
return frame;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function _buildAssistantFrame(payload, sessionId, ts) {
|
|
337
|
-
const { content, truncated } = _capContent(_extractAssistantContent(payload));
|
|
338
|
-
const frame = {
|
|
339
|
-
type: 'assistant_turn',
|
|
340
|
-
ts,
|
|
341
|
-
session_id: sessionId,
|
|
342
|
-
};
|
|
343
|
-
if (content != null) frame.content = content;
|
|
344
|
-
if (truncated) frame.truncated = true;
|
|
345
|
-
if (payload && typeof payload.message_id === 'string') frame.message_id = payload.message_id;
|
|
346
|
-
return frame;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function _buildSessionStartFrame(sessionId, ts) {
|
|
350
|
-
return { type: 'session_start', ts, session_id: sessionId };
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function _buildToolUseFrame(payload, sessionId, ts) {
|
|
354
|
-
const frame = {
|
|
355
|
-
type: 'tool_use',
|
|
356
|
-
ts,
|
|
357
|
-
session_id: sessionId,
|
|
358
|
-
};
|
|
359
|
-
if (payload && typeof payload.tool_name === 'string') frame.name = payload.tool_name;
|
|
360
|
-
else if (payload && payload.tool && typeof payload.tool.name === 'string') frame.name = payload.tool.name;
|
|
361
|
-
if (payload && typeof payload.tool_use_id === 'string') frame.tool_use_id = payload.tool_use_id;
|
|
362
|
-
if (payload && typeof payload.duration_ms === 'number') frame.duration_ms = payload.duration_ms;
|
|
363
|
-
return frame;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
function _handle(payload) {
|
|
367
|
-
if (!payload || typeof payload !== 'object') return;
|
|
368
|
-
const event = payload.hook_event_name;
|
|
369
|
-
if (!event) return;
|
|
370
|
-
|
|
371
|
-
const projectDir = _resolveProjectDir(payload);
|
|
372
|
-
if (!projectDir) return; // not a GSD-T project — silent no-op
|
|
373
|
-
|
|
374
|
-
const sessionId = _resolveSessionId(payload);
|
|
375
|
-
const ts = new Date().toISOString();
|
|
376
|
-
|
|
377
|
-
switch (event) {
|
|
378
|
-
case 'SessionStart': {
|
|
379
|
-
_appendFrame(projectDir, sessionId, _buildSessionStartFrame(sessionId, ts));
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
case 'UserPromptSubmit': {
|
|
383
|
-
_appendFrame(projectDir, sessionId, _buildUserFrame(payload, sessionId, ts));
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
case 'Stop': {
|
|
387
|
-
_appendFrame(projectDir, sessionId, _buildAssistantFrame(payload, sessionId, ts));
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
case 'PostToolUse': {
|
|
391
|
-
// Opt-in: guarded to keep default writes small.
|
|
392
|
-
if (process.env.GSD_T_CAPTURE_TOOL_USES !== '1') return;
|
|
393
|
-
_appendFrame(projectDir, sessionId, _buildToolUseFrame(payload, sessionId, ts));
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
default:
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
async function main() {
|
|
402
|
-
try {
|
|
403
|
-
const raw = await _readStdin();
|
|
404
|
-
const payload = _parsePayload(raw);
|
|
405
|
-
if (!payload) return;
|
|
406
|
-
_handle(payload);
|
|
407
|
-
} catch (err) {
|
|
408
|
-
try { process.stderr.write('gsd-t-conversation-capture: ' + (err && err.message || err) + '\n'); } catch (_) { /* noop */ }
|
|
409
|
-
} finally {
|
|
410
|
-
const elapsed = Date.now() - started;
|
|
411
|
-
if (elapsed > DEFAULT_SCRIPT_GUARD_MS) process.exitCode = 0;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
if (require.main === module) {
|
|
416
|
-
main();
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
module.exports = {
|
|
420
|
-
_internal: {
|
|
421
|
-
_parsePayload,
|
|
422
|
-
_resolveProjectDir,
|
|
423
|
-
_resolveSessionId,
|
|
424
|
-
_capContent,
|
|
425
|
-
_buildUserFrame,
|
|
426
|
-
_buildAssistantFrame,
|
|
427
|
-
_buildSessionStartFrame,
|
|
428
|
-
_buildToolUseFrame,
|
|
429
|
-
_appendFrame,
|
|
430
|
-
_handle,
|
|
431
|
-
_extractAssistantContent,
|
|
432
|
-
_readAssistantFromTranscript,
|
|
433
|
-
_safeTranscriptPath,
|
|
434
|
-
_readFileTail,
|
|
435
|
-
_slugFromTranscriptPath,
|
|
436
|
-
_slugToProjectDir,
|
|
437
|
-
CONTENT_CAP_BYTES,
|
|
438
|
-
},
|
|
439
|
-
};
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
/**
|
|
4
|
-
* GSD-T In-Session Usage Hook (M43 D1-T2)
|
|
5
|
-
*
|
|
6
|
-
* Installed into `~/.claude/settings.json` (Stop + SessionEnd) to capture
|
|
7
|
-
* per-turn token usage for the dialog channel. Reads the hook payload from
|
|
8
|
-
* stdin, resolves the project directory from `payload.cwd`, then delegates to
|
|
9
|
-
* `bin/gsd-t-in-session-usage.cjs::processHookPayload` which reads the
|
|
10
|
-
* transcript at `payload.transcript_path` and appends schema-v2 rows to
|
|
11
|
-
* `.gsd-t/metrics/token-usage.jsonl`.
|
|
12
|
-
*
|
|
13
|
-
* Silent on success (hooks run async in Claude Code — stdout is ignored).
|
|
14
|
-
* Swallows every error: a flaky capture must never break the user's session.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
const fs = require('fs');
|
|
18
|
-
const path = require('path');
|
|
19
|
-
|
|
20
|
-
const DEFAULT_SCRIPT_GUARD_MS = 5000;
|
|
21
|
-
const started = Date.now();
|
|
22
|
-
|
|
23
|
-
function _readStdin() {
|
|
24
|
-
return new Promise((resolve) => {
|
|
25
|
-
let buf = '';
|
|
26
|
-
process.stdin.setEncoding('utf8');
|
|
27
|
-
process.stdin.on('data', chunk => { buf += chunk; });
|
|
28
|
-
process.stdin.on('end', () => resolve(buf));
|
|
29
|
-
process.stdin.on('error', () => resolve(buf));
|
|
30
|
-
setTimeout(() => resolve(buf), DEFAULT_SCRIPT_GUARD_MS).unref();
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function _parsePayload(raw) {
|
|
35
|
-
try {
|
|
36
|
-
return JSON.parse(raw || '{}');
|
|
37
|
-
} catch (_) {
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function _resolveProjectDir(payload) {
|
|
43
|
-
if (payload && payload.cwd && fs.existsSync(payload.cwd)) return payload.cwd;
|
|
44
|
-
if (process.cwd) return process.cwd();
|
|
45
|
-
return '.';
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function _resolveInSessionModule(projectDir) {
|
|
49
|
-
const candidates = [
|
|
50
|
-
path.join(projectDir, 'bin', 'gsd-t-in-session-usage.cjs'),
|
|
51
|
-
path.join(process.env.HOME || '', '.claude', 'gsd-t', 'bin', 'gsd-t-in-session-usage.cjs'),
|
|
52
|
-
];
|
|
53
|
-
for (const p of candidates) {
|
|
54
|
-
if (fs.existsSync(p)) return p;
|
|
55
|
-
}
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async function main() {
|
|
60
|
-
try {
|
|
61
|
-
const raw = await _readStdin();
|
|
62
|
-
const payload = _parsePayload(raw);
|
|
63
|
-
if (!payload) return;
|
|
64
|
-
if (payload.hook_event_name !== 'Stop' && payload.hook_event_name !== 'SessionEnd') return;
|
|
65
|
-
|
|
66
|
-
const projectDir = _resolveProjectDir(payload);
|
|
67
|
-
const modulePath = _resolveInSessionModule(projectDir);
|
|
68
|
-
if (!modulePath) return;
|
|
69
|
-
|
|
70
|
-
const mod = require(modulePath);
|
|
71
|
-
mod.processHookPayload({ projectDir, payload });
|
|
72
|
-
} catch (_) {
|
|
73
|
-
// Intentionally swallow — hook must never interrupt the user session.
|
|
74
|
-
} finally {
|
|
75
|
-
const elapsed = Date.now() - started;
|
|
76
|
-
if (elapsed > DEFAULT_SCRIPT_GUARD_MS) process.exitCode = 0;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (require.main === module) {
|
|
81
|
-
main();
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
module.exports = { _internal: { _parsePayload, _resolveProjectDir } };
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* GSD-T Spawn Plan — token formatting + cumulative totals (M44 D8 T7)
|
|
5
|
-
*
|
|
6
|
-
* Pure helper module used by the transcript right-side spawn-plan panel.
|
|
7
|
-
* Extracted from the inline script in `scripts/gsd-t-transcript.html` so the
|
|
8
|
-
* same functions can be unit-tested under Node without loading a DOM.
|
|
9
|
-
*
|
|
10
|
-
* Contract: .gsd-t/contracts/spawn-plan-contract.md v1.0.0
|
|
11
|
-
* - Token cell format: `in=12.5k out=1.7k $0.42` (k-suffix above 1000,
|
|
12
|
-
* 2-decimal USD). Returns `—` when tokens are null/missing.
|
|
13
|
-
* - Cumulative totals: sum `{in,out,cr,cc,cost_usd}` across done tasks.
|
|
14
|
-
* Returns `null` when no done-task has tokens (panel renders `—`).
|
|
15
|
-
*
|
|
16
|
-
* Zero external deps. `.cjs` so it loads in both ESM-default and CJS projects.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* k-suffix number formatter. `1500` → `1.5k`, `999` → `999`, `null` → `0`.
|
|
21
|
-
* Used by `fmtTokens` to compact large counts.
|
|
22
|
-
*
|
|
23
|
-
* @param {number|string|null|undefined} n
|
|
24
|
-
* @returns {string}
|
|
25
|
-
*/
|
|
26
|
-
function fmtK(n) {
|
|
27
|
-
if (n == null || Number.isNaN(Number(n))) return '0';
|
|
28
|
-
const num = Number(n);
|
|
29
|
-
if (Math.abs(num) >= 1000) return (num / 1000).toFixed(1) + 'k';
|
|
30
|
-
return String(num);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Render a token-cell string from a `{in, out, cr, cc, cost_usd}` object.
|
|
35
|
-
* When `tokens` is null/undefined/non-object, returns the em-dash marker
|
|
36
|
-
* per the "zero is a measurement, dash is acknowledged gap" rule.
|
|
37
|
-
*
|
|
38
|
-
* @param {object|null|undefined} tokens
|
|
39
|
-
* @returns {string}
|
|
40
|
-
*/
|
|
41
|
-
function fmtTokens(tokens) {
|
|
42
|
-
if (!tokens || typeof tokens !== 'object') return '—';
|
|
43
|
-
const ins = fmtK(tokens.in);
|
|
44
|
-
const out = fmtK(tokens.out);
|
|
45
|
-
const cost = (typeof tokens.cost_usd === 'number')
|
|
46
|
-
? '$' + tokens.cost_usd.toFixed(2)
|
|
47
|
-
: '';
|
|
48
|
-
return ['in=' + ins, 'out=' + out, cost].filter(Boolean).join(' ');
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Sum the `tokens` field across every task whose `status === 'done'` and
|
|
53
|
-
* whose `tokens` is a populated object. Returns null when no done-task has
|
|
54
|
-
* a tokens object attached — the panel renders that state as `—`.
|
|
55
|
-
*
|
|
56
|
-
* @param {Array<{status?:string, tokens?:object}>|null|undefined} tasks
|
|
57
|
-
* @returns {{in:number,out:number,cr:number,cc:number,cost_usd:number}|null}
|
|
58
|
-
*/
|
|
59
|
-
function sumTokens(tasks) {
|
|
60
|
-
const acc = { in: 0, out: 0, cr: 0, cc: 0, cost_usd: 0 };
|
|
61
|
-
let hit = false;
|
|
62
|
-
for (const t of (tasks || [])) {
|
|
63
|
-
if (!t || t.status !== 'done' || !t.tokens) continue;
|
|
64
|
-
acc.in += Number(t.tokens.in || 0);
|
|
65
|
-
acc.out += Number(t.tokens.out || 0);
|
|
66
|
-
acc.cr += Number(t.tokens.cr || 0);
|
|
67
|
-
acc.cc += Number(t.tokens.cc || 0);
|
|
68
|
-
acc.cost_usd += Number(t.tokens.cost_usd || 0);
|
|
69
|
-
hit = true;
|
|
70
|
-
}
|
|
71
|
-
if (!hit) return null;
|
|
72
|
-
acc.cost_usd = Math.round(acc.cost_usd * 100) / 100;
|
|
73
|
-
return acc;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
module.exports = {
|
|
77
|
-
fmtK,
|
|
78
|
-
fmtTokens,
|
|
79
|
-
sumTokens,
|
|
80
|
-
};
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# GSD-T Post-Commit Spawn-Plan Hook — installed by `gsd-t init`
|
|
3
|
-
#
|
|
4
|
-
# This is a thin shim that delegates to the canonical hook script
|
|
5
|
-
# shipped with GSD-T. If your project doesn't yet have the canonical
|
|
6
|
-
# script, this template copy handles commits by itself; once the
|
|
7
|
-
# framework script is in place, the framework wins.
|
|
8
|
-
#
|
|
9
|
-
# Contract: .gsd-t/contracts/spawn-plan-contract.md v1.0.0
|
|
10
|
-
|
|
11
|
-
set +e # silent-fail — never break the commit
|
|
12
|
-
|
|
13
|
-
PROJECT_DIR="$(git rev-parse --show-toplevel 2>/dev/null)"
|
|
14
|
-
if [ -z "$PROJECT_DIR" ] || [ ! -d "$PROJECT_DIR/.gsd-t/spawns" ]; then
|
|
15
|
-
exit 0
|
|
16
|
-
fi
|
|
17
|
-
|
|
18
|
-
CANONICAL="$PROJECT_DIR/scripts/gsd-t-post-commit-spawn-plan.sh"
|
|
19
|
-
if [ -x "$CANONICAL" ]; then
|
|
20
|
-
# Delegate to the framework-shipped hook.
|
|
21
|
-
"$CANONICAL"
|
|
22
|
-
exit 0
|
|
23
|
-
fi
|
|
24
|
-
|
|
25
|
-
UPDATER="$PROJECT_DIR/bin/spawn-plan-status-updater.cjs"
|
|
26
|
-
if [ ! -f "$UPDATER" ]; then
|
|
27
|
-
exit 0
|
|
28
|
-
fi
|
|
29
|
-
|
|
30
|
-
if ! command -v node >/dev/null 2>&1; then
|
|
31
|
-
echo "[spawn-plan-hook] node not found — skipping" 1>&2
|
|
32
|
-
exit 0
|
|
33
|
-
fi
|
|
34
|
-
|
|
35
|
-
COMMIT_SHA="$(git rev-parse --short HEAD 2>/dev/null)"
|
|
36
|
-
COMMIT_MSG="$(git log -1 --format=%B 2>/dev/null)"
|
|
37
|
-
if [ -z "$COMMIT_SHA" ] || [ -z "$COMMIT_MSG" ]; then
|
|
38
|
-
exit 0
|
|
39
|
-
fi
|
|
40
|
-
|
|
41
|
-
TASK_IDS="$(printf '%s' "$COMMIT_MSG" | grep -oE '\[M[0-9]+-D[0-9]+-T[0-9]+\]' | sed 's/[][]//g' | awk '!seen[$0]++')"
|
|
42
|
-
if [ -z "$TASK_IDS" ]; then
|
|
43
|
-
exit 0
|
|
44
|
-
fi
|
|
45
|
-
|
|
46
|
-
printf '%s\n' "$TASK_IDS" | node -e '
|
|
47
|
-
"use strict";
|
|
48
|
-
try {
|
|
49
|
-
const path = require("path");
|
|
50
|
-
const fs = require("fs");
|
|
51
|
-
const projectDir = process.argv[1];
|
|
52
|
-
const commit = process.argv[2];
|
|
53
|
-
let raw = "";
|
|
54
|
-
process.stdin.setEncoding("utf8");
|
|
55
|
-
process.stdin.on("data", (c) => { raw += c; });
|
|
56
|
-
process.stdin.on("end", () => {
|
|
57
|
-
try {
|
|
58
|
-
const taskIds = raw.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
59
|
-
if (!taskIds.length) process.exit(0);
|
|
60
|
-
const updater = require(path.join(projectDir, "bin", "spawn-plan-status-updater.cjs"));
|
|
61
|
-
const activePaths = updater.listActivePlans(projectDir);
|
|
62
|
-
if (!activePaths.length) process.exit(0);
|
|
63
|
-
for (const fp of activePaths) {
|
|
64
|
-
let plan;
|
|
65
|
-
try { plan = JSON.parse(fs.readFileSync(fp, "utf8")); } catch (_) { continue; }
|
|
66
|
-
const spawnId = plan && plan.spawnId;
|
|
67
|
-
const spawnStartedAt = plan && plan.startedAt;
|
|
68
|
-
if (!spawnId) continue;
|
|
69
|
-
const planTaskIds = new Set((plan.tasks || []).map((t) => t && t.id).filter(Boolean));
|
|
70
|
-
for (const taskId of taskIds) {
|
|
71
|
-
if (!planTaskIds.has(taskId)) continue;
|
|
72
|
-
const tokens = updater.sumTokensForTask({ projectDir, taskId, spawnStartedAt });
|
|
73
|
-
updater.markTaskDone({ spawnId, taskId, commit, tokens, projectDir });
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
} catch (err) {
|
|
77
|
-
try { process.stderr.write("[spawn-plan-hook] " + String(err && err.message || err) + "\n"); } catch (_) { /* silent */ }
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
|
-
} catch (err) {
|
|
81
|
-
try { process.stderr.write("[spawn-plan-hook] " + String(err && err.message || err) + "\n"); } catch (_) { /* silent */ }
|
|
82
|
-
}
|
|
83
|
-
' "$PROJECT_DIR" "$COMMIT_SHA" 2>/dev/null
|
|
84
|
-
|
|
85
|
-
exit 0
|