@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 +41 -1
- package/bin/gsd-t.js +45 -0
- package/docs/architecture.md +9 -0
- package/package.json +1 -1
- package/scripts/hooks/gsd-t-conversation-capture.js +186 -5
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
|
-
## [
|
|
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
|
|
package/docs/architecture.md
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|
|
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)
|
|
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
|
-
//
|
|
139
|
-
//
|
|
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
|
};
|