@tekyzinc/gsd-t 3.23.10 → 3.23.11

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 CHANGED
@@ -2,7 +2,47 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
- ## [Unreleased]
5
+ ## [3.23.11] - 2026-05-07
6
+
7
+ ### Fixed — `/api/parallelism` 500 — install `parallelism-report.cjs` to `~/.claude/bin/`
8
+
9
+ - **Root cause**: `scripts/gsd-t-dashboard-server.js::_loadParallelismReporter` resolves `require(path.join(__dirname, "..", "bin", "parallelism-report.cjs"))`. With `__dirname = ~/.claude/scripts/`, it looks for `~/.claude/bin/parallelism-report.cjs` — but no installer code path ever populated `~/.claude/bin/`. Every dashboard 500'd on `/api/parallelism` with `Cannot find module …/parallelism-report.cjs`; the right-rail PARALLELISM panel silently dimmed for every project. The break suggests `~/.claude/bin/` propagation has been silently broken since the M44 D9 panel shipped.
10
+ - **Fix** (`bin/gsd-t.js`, ~30 LOC additive): new `GLOBAL_BIN_DIR = ~/.claude/bin` constant; new `GLOBAL_BIN_TOOLS = ["parallelism-report.cjs"]` array; new `installGlobalBinTools()` mirroring `installUtilityScripts` shape (symlink-safe `copyFile`, eol-normalised idempotent compare, `+x` chmod). Wired into `doInstall()` between Utility Scripts and Context Meter so it runs on both `install` and `update`. `gsd-t doctor` gains `checkDoctorGlobalBin()` flagging missing tools with a clear "re-run install" hint.
11
+ - **Hot-patch**: `mkdir -p ~/.claude/bin && cp bin/parallelism-report.cjs ~/.claude/bin/` applied immediately so the live dashboard recovers without waiting for npm publish. Verified `curl http://localhost:7488/api/parallelism` returns 200 with the schema-versioned envelope (was 500 module-unavailable).
12
+ - **Doctrinal shift**: per the user's "test the real setup" directive, the regression spec lives under a new `e2e/live-journeys/` tree that probes the **running** dashboard instead of an in-process `startServer(0, ...)` fixture. Two specs added:
13
+ - `e2e/live-journeys/parallelism-endpoint.spec.ts` (4 tests) — schema envelope, no-500 sentinel, right-rail DOM populates from `/transcripts`, file-system regression sentinel for `~/.claude/bin/parallelism-report.cjs` existence.
14
+ - `e2e/live-journeys/dashboard-endpoint-coverage.spec.ts` (12 tests) — covers every dashboard route (`/`, `/transcripts`, `/ping`, `/metrics`, `/api/main-session`, `/api/spawn-plans`, `/api/parallelism`, `/api/parallelism/report`, `/events`, `/api/spawn-plans/stream`, 404 catch-all) plus a regression sentinel for the "parallelism-report module unavailable" string.
15
+ - Both specs `test.skip()` cleanly when no dashboard is reachable (`GSD_T_LIVE_DASHBOARD_URL` env override; default `http://localhost:7488`), keeping non-local CI green.
16
+ - **Adversarial Red Team** (focused, in-session): reviewed `update-all` self-heal gap (mitigated by `gsd-t doctor`), symlink safety (covered via `copyFile`), cross-platform path resolution (covered via `os.homedir()`), project-bin sweep collision (`parallelism-report.cjs` not in `DEPRECATED_BIN_STRAYS` so existing project copies are untouched). VERDICT: GRUDGING PASS — 0 blocking issues.
17
+ - **Verification**: unit suite **2233/2233 pass** (zero regressions). Playwright `e2e/journeys/` + `e2e/viewer/` **43/43 pass + 1 placeholder skip**. New live spec **4/4 pass** against the running :7488 dashboard.
18
+ - **Architecture doc**: `docs/architecture.md` Parallelism Observability section now records the install location and the distinction between `GLOBAL_BIN_TOOLS` (`~/.claude/bin/`) and `PROJECT_BIN_TOOLS` (per-project `bin/`).
19
+
20
+ ### Fixed — M53b conversation-capture project-routing: parallel sessions cross-talk
21
+
22
+ - **Root cause**: `scripts/hooks/gsd-t-conversation-capture.js::_resolveProjectDir(payload)` fell through to `_walkUpForProject(process.cwd())` when `GSD_T_PROJECT_DIR` was unset and `payload.cwd` was absent. `process.cwd()` for a Claude Code Stop/UserPromptSubmit hook is the directory the user launched `claude` from. When two parallel Claude Code sessions ran in different projects (`/Users/david/projects/GSD-T` and `/Users/david/projects/Move-Zoom-Recordings-to-GDrive`), the SAME node-runtime hook process resolved to whichever project the hook inherited via cwd — frames from one session landed in the other project's `.gsd-t/transcripts/` dir. Confirmed concrete misroute: GSD-T orchestrator's NDJSON written into `Move-Zoom-Recordings-to-GDrive/.gsd-t/transcripts/in-session-800d4b3b-….ndjson`.
23
+ - **Fix**: `_resolveProjectDir` now decodes `payload.transcript_path`'s `~/.claude/projects/{slug}/{sid}.jsonl` slug back to the real project root. New helpers:
24
+ - `_slugFromTranscriptPath(p)` — extracts the slug (first path segment after `~/.claude/projects/`); rejects paths outside that root.
25
+ - `_slugToProjectDir(slug)` — DFS-walks the filesystem, greedily consuming `-`-separated tokens as directory names; first leaf where `.gsd-t/` exists wins. Disambiguates literal-hyphen project names like `Move-Zoom-Recordings-to-GDrive` by consulting the disk. Rejects slugs containing `..`, `/`, `\\`, `\0`, or not starting with `-`.
26
+ - `_resolveProjectDir` priority is now: env → transcript_path slug → `payload.cwd` → cwd walk-up. Walk-up emits a one-line stderr warning ("project-dir resolved via cwd walk-up — unreliable for parallel sessions") so misroutes stay diagnosable.
27
+ - **Tests**: `test/m53b-conversation-routing.test.js` (new, 16 tests) covers happy-path slug-decode, parallel-session no-cross-talk, literal-hyphen disambiguation, non-GSD-T target fallthrough, path-traversal slug rejection, env-priority preservation, plus 8 unit-level helper tests. `test/m53b-conversation-routing-redteam.test.js` (new, 7 tests) — three adversarial `_resolveProjectDir` variants (walk-up only / naive slug-decode without `.gsd-t/` check / literal-slug-as-path) each violate at least one of four named invariants (I1 own-project / I2 .gsd-t/-existence / I3 slug-decoded / I4 not-neutral-cwd), with two positive controls proving the real fix passes all four on both clean-name and literal-hyphen projects.
28
+ - **Journey spec**: `e2e/journeys/conversation-routing.spec.ts` (new, 3 tests) — fires the real hook process twice with two different `transcript_path` values pointing at slugs encoding two different projects under a fake `$HOME`; asserts each NDJSON lands in the matching project, neither cross-routes, neutral-cwd has no `.gsd-t/transcripts/` tree (proves walk-up did not fire), and slug-as-literal-path attack is rejected. Manifest entry added to `.gsd-t/journey-manifest.json`.
29
+ - **Verification**: full unit suite **2226/2226 pass** (was 2210; +16 routing + 7 redteam = +23, zero regressions; the 2 pre-existing flakes from gsd-t-debug-env-induced `event-stream.test.js` / `watch-progress-writer.test.js` remain unchanged — pass cleanly when those env vars are unset). Playwright `e2e/journeys/` **16/16 pass** (was 13; +3 conversation-routing).
30
+ - **Note**: existing misrouted NDJSONs in `Move-Zoom-Recordings-to-GDrive/.gsd-t/transcripts/` remain (acceptable historical records). Going-forward NDJSONs will route correctly. Hot-patch applied to `~/.claude/scripts/hooks/gsd-t-conversation-capture.js`; full propagation on next `npm publish` + `/gsd-t-version-update-all`.
31
+ - **Contract**: `conversation-capture-contract.md` v1.1.0 → v1.2.0 (project-dir resolution algorithm documented with priority order + slug-decode protocol + defenses-against-pitfalls table; schema unchanged).
32
+
33
+ ### Fixed — M45 D2 conversation-capture regression: bodyless `assistant_turn` frames
34
+
35
+ - **Root cause**: `scripts/hooks/gsd-t-conversation-capture.js::_extractAssistantContent` tried payload shapes (`assistant_message`, `message.content`, `content`) that Claude Code's Stop hook never sends. Stop hook payload is `{session_id, transcript_path, hook_event_name, stop_hook_active}` — message body lives in the transcript JSONL at `transcript_path`. Function fell through to `null`; every `assistant_turn` frame written since v3.18.14 (M45 D2 ship 2026-04-23) was bodyless. Two weeks of broken capture; viewer correctly rendered empty bubbles.
36
+ - **Fix**: hook now reads the assistant body from `transcript_path`. New helpers:
37
+ - `_safeTranscriptPath(p)` — locks the path to `${HOME}/.claude/projects/`. Path-traversal attempts (`/etc/passwd`, relative paths) fail open (`return null`).
38
+ - `_readFileTail(filePath, 64*1024)` — opens fd, reads last 64 KB, drops leading mid-line partial. Multi-MB transcripts never get fully loaded.
39
+ - `_readAssistantFromTranscript(transcriptPath)` — scans tail bottom-up, parses each line as JSON (skips corrupt), picks the latest `type === 'assistant' && isSidechain !== true` row, concatenates all `text`-type content blocks (ignores `tool_use` / `tool_result` / `thinking`), skips tool_use-only rows.
40
+ - `_extractAssistantContent(payload)` — transcript-first; original 3 fallback shapes preserved for legacy/test payloads.
41
+ - **Tests**: `test/m45-d2-conversation-capture.test.js` +11 cases (transcript happy-path, multi-block concatenation, latest-row selection, sidechain skipping, tool_use-only skipping, /etc/passwd rejection, relative-path rejection, missing transcript_path → fallback, unreadable file → stub, >1 MB tail-only read, corrupt-JSON line skipping). `test/m53-conversation-content-redteam.test.js` (new, 4 tests) — three broken extractor variants (regress-to-old-code, picks-user-message, first-text-block-only) each violate one of three named invariants (I1 non-empty / I2 marker-match / I3 tail-marker-present), with a positive control proving the harness isn't trivially broken.
42
+ - **Journey spec**: `e2e/journeys/conversation-content.spec.ts` — writes a 7-frame in-session NDJSON fixture with 3 assistant_turn frames (one multi-paragraph with HEAD + TAIL markers); navigates to `/transcripts`; asserts `#main-stream .frame.assistant-turn` count = 3, every `.body` non-empty, each carries its expected marker, multi-paragraph TAIL marker present, USER-PROMPT marker absent from any assistant bubble, no `.frame.raw` JSON-dump fallback.
43
+ - **Verification**: full unit suite **2210/2210 pass** (was 2195; +11 M45 D2 + 4 M53 redteam = +15, zero regressions). Playwright `e2e/journeys/` + `e2e/viewer/` **36/36 pass** (was 35; +1 conversation-content).
44
+ - **Note**: existing 6 bodyless NDJSONs (`Move-Zoom-Recordings-to-GDrive/.gsd-t/transcripts/`) remain — historical records, acceptable. Going-forward NDJSONs will be populated. The installed hook at `~/.claude/scripts/hooks/gsd-t-conversation-capture.js` syncs on next `npm publish` + `/gsd-t-version-update-all`.
45
+ - **Contract**: `conversation-capture-contract.md` v1.0.0 → v1.1.0 (assistant-body extraction protocol documented; schema unchanged — same `assistant_turn` frame, just populated where v1.0.0 was bodyless).
6
46
 
7
47
  ## [3.23.10] - 2026-05-06
8
48
 
package/bin/gsd-t.js CHANGED
@@ -45,6 +45,7 @@ const { mapHeadlessExitCode } = require(path.join(__dirname, "headless-exit-code
45
45
  const CLAUDE_DIR = path.join(os.homedir(), ".claude");
46
46
  const COMMANDS_DIR = path.join(CLAUDE_DIR, "commands");
47
47
  const SCRIPTS_DIR = path.join(CLAUDE_DIR, "scripts");
48
+ const GLOBAL_BIN_DIR = path.join(CLAUDE_DIR, "bin");
48
49
  const CLAUDE_TEMPLATES_DIR = path.join(CLAUDE_DIR, "templates");
49
50
  const GLOBAL_CLAUDE_MD = path.join(CLAUDE_DIR, "CLAUDE.md");
50
51
  const SETTINGS_JSON = path.join(CLAUDE_DIR, "settings.json");
@@ -1169,6 +1170,33 @@ function installUtilityScripts() {
1169
1170
  }
1170
1171
  }
1171
1172
 
1173
+ // ─── Global Bin Tools (~/.claude/bin/) ───────────────────────────────────────
1174
+ // Modules resolved by globally-installed scripts via
1175
+ // `path.join(__dirname, "..", "bin", <tool>)` (e.g. gsd-t-dashboard-server.js
1176
+ // → parallelism-report.cjs). Distinct from PROJECT_BIN_TOOLS, which copy into
1177
+ // each registered project's bin/.
1178
+ const GLOBAL_BIN_TOOLS = ["parallelism-report.cjs"];
1179
+
1180
+ function installGlobalBinTools() {
1181
+ ensureDir(GLOBAL_BIN_DIR);
1182
+ for (const tool of GLOBAL_BIN_TOOLS) {
1183
+ const src = path.join(PKG_ROOT, "bin", tool);
1184
+ const dest = path.join(GLOBAL_BIN_DIR, tool);
1185
+ if (!fs.existsSync(src)) {
1186
+ warn(`Global bin tool source missing: ${tool} — skipping`);
1187
+ continue;
1188
+ }
1189
+ const srcContent = fs.readFileSync(src, "utf8");
1190
+ const destContent = fs.existsSync(dest) ? fs.readFileSync(dest, "utf8") : "";
1191
+ if (normalizeEol(srcContent) !== normalizeEol(destContent)) {
1192
+ copyFile(src, dest, `bin/${tool}`);
1193
+ try { fs.chmodSync(dest, 0o755); } catch {}
1194
+ } else {
1195
+ info(`bin/${tool} unchanged`);
1196
+ }
1197
+ }
1198
+ }
1199
+
1172
1200
  // ─── CGC (CodeGraphContext) ──────────────────────────────────────────────────
1173
1201
 
1174
1202
  function installCgc() {
@@ -1508,6 +1536,9 @@ async function doInstall(opts = {}) {
1508
1536
  heading("Utility Scripts");
1509
1537
  installUtilityScripts();
1510
1538
 
1539
+ heading("Global Bin Tools (~/.claude/bin/)");
1540
+ installGlobalBinTools();
1541
+
1511
1542
  heading("Context Meter (PostToolUse)");
1512
1543
  const cmHook = configureContextMeterHooks(SETTINGS_JSON);
1513
1544
  if (cmHook.installed) {
@@ -2725,6 +2756,20 @@ function checkDoctorInstallation() {
2725
2756
  issues += checkDoctorClaudeMd();
2726
2757
  issues += checkDoctorSettings();
2727
2758
  issues += checkDoctorEncoding(installed);
2759
+ issues += checkDoctorGlobalBin();
2760
+ return issues;
2761
+ }
2762
+
2763
+ function checkDoctorGlobalBin() {
2764
+ let issues = 0;
2765
+ const missing = GLOBAL_BIN_TOOLS.filter((tool) => !fs.existsSync(path.join(GLOBAL_BIN_DIR, tool)));
2766
+ if (missing.length === 0) {
2767
+ success(`All ${GLOBAL_BIN_TOOLS.length} global bin tool${GLOBAL_BIN_TOOLS.length === 1 ? "" : "s"} installed (~/.claude/bin/)`);
2768
+ } else {
2769
+ warn(`Missing global bin tool${missing.length === 1 ? "" : "s"} in ~/.claude/bin/: ${missing.join(", ")}`);
2770
+ info("Fix: re-run `npx @tekyzinc/gsd-t install` (or `update`)");
2771
+ issues++;
2772
+ }
2728
2773
  return issues;
2729
2774
  }
2730
2775
 
@@ -1008,6 +1008,15 @@ defined in `.gsd-t/contracts/parallelism-report-contract.md` v1.0.0.
1008
1008
  Per-spawn timeline, Per-gate decisions, Per-worker Gantt, Token cost, and
1009
1009
  Notes sections.
1010
1010
 
1011
+ **Install location**: the dashboard server (installed at
1012
+ `~/.claude/scripts/gsd-t-dashboard-server.js`) resolves
1013
+ `require(path.join(__dirname, "..", "bin", "parallelism-report.cjs"))` at
1014
+ request time, so the module must live at **`~/.claude/bin/parallelism-report.cjs`**.
1015
+ The installer handles this via `installGlobalBinTools()` (driven by
1016
+ `GLOBAL_BIN_TOOLS` in `bin/gsd-t.js`), and `gsd-t doctor` flags any missing
1017
+ entry. This is distinct from `PROJECT_BIN_TOOLS`, which copies into each
1018
+ registered project's local `bin/`.
1019
+
1011
1020
  **Data flow**:
1012
1021
 
1013
1022
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "3.23.10",
3
+ "version": "3.23.11",
4
4
  "description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
5
5
  "author": "Tekyz, Inc.",
6
6
  "license": "MIT",
@@ -16,16 +16,20 @@
16
16
  * - Never throws to the caller — catches all errors, logs to stderr, exits 0.
17
17
  * - `content` is capped at 16 KB per frame; over-cap writes `truncated: true`.
18
18
  * - Append-only; never overwrites an existing in-session NDJSON file.
19
- * - Project-dir discovery: prefers `GSD_T_PROJECT_DIR`, then `payload.cwd`,
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`,
20
23
  * then walks up from `process.cwd()` looking for `.gsd-t/progress.md`.
21
24
  * Silent no-op if no project dir found.
22
25
  *
23
- * Contract: .gsd-t/contracts/conversation-capture-contract.md v1.0.0
26
+ * Contract: .gsd-t/contracts/conversation-capture-contract.md v1.2.0
24
27
  */
25
28
 
26
29
  const fs = require('fs');
27
30
  const path = require('path');
28
31
  const crypto = require('crypto');
32
+ const os = require('os');
29
33
 
30
34
  const DEFAULT_SCRIPT_GUARD_MS = 5000;
31
35
  const CONTENT_CAP_BYTES = 16 * 1024; // 16 KB
@@ -69,15 +73,102 @@ function _walkUpForProject(startDir) {
69
73
  return null;
70
74
  }
71
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
+
72
139
  function _resolveProjectDir(payload) {
140
+ // 1. Explicit env override (preserved for tests + operator overrides).
73
141
  const env = process.env.GSD_T_PROJECT_DIR;
74
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).
75
155
  if (payload && typeof payload.cwd === 'string' && path.isAbsolute(payload.cwd)
76
156
  && fs.existsSync(path.join(payload.cwd, '.gsd-t'))) {
77
157
  return payload.cwd;
78
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.
79
162
  const walked = _walkUpForProject(process.cwd());
80
- if (walked) return walked;
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
+ }
81
172
  return null;
82
173
  }
83
174
 
@@ -134,9 +225,93 @@ function _extractUserContent(payload) {
134
225
  return null;
135
226
  }
136
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
+
137
306
  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).
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).
140
315
  if (payload && typeof payload.assistant_message === 'string') return payload.assistant_message;
141
316
  if (payload && payload.message && typeof payload.message.content === 'string') {
142
317
  return payload.message.content;
@@ -253,6 +428,12 @@ module.exports = {
253
428
  _buildToolUseFrame,
254
429
  _appendFrame,
255
430
  _handle,
431
+ _extractAssistantContent,
432
+ _readAssistantFromTranscript,
433
+ _safeTranscriptPath,
434
+ _readFileTail,
435
+ _slugFromTranscriptPath,
436
+ _slugToProjectDir,
256
437
  CONTENT_CAP_BYTES,
257
438
  },
258
439
  };