@tekyzinc/gsd-t 2.23.0 → 2.24.6

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.
@@ -1,201 +1,180 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * GSD-T Heartbeat — Claude Code Hook Event Writer
5
- *
6
- * Writes structured events to .gsd-t/heartbeat-{session_id}.jsonl
7
- * Installed as an async hook for multiple Claude Code events.
8
- *
9
- * Events captured:
10
- * SessionStart, PostToolUse, SubagentStart, SubagentStop,
11
- * TaskCompleted, TeammateIdle, Notification, Stop, SessionEnd
12
- */
13
-
14
- const fs = require("fs");
15
- const path = require("path");
16
-
17
- const MAX_STDIN = 1024 * 1024; // 1MB — prevent OOM from unbounded input
18
- const SAFE_SID = /^[a-zA-Z0-9_-]+$/; // Allowlist for session_id — blocks path traversal
19
- const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days — auto-cleanup threshold
20
-
21
- let input = "";
22
- let aborted = false;
23
- process.stdin.setEncoding("utf8");
24
- process.stdin.on("data", (d) => {
25
- input += d;
26
- if (input.length > MAX_STDIN) {
27
- aborted = true;
28
- process.stdin.destroy();
29
- }
30
- });
31
- process.stdin.on("end", () => {
32
- if (aborted) return; // Silently discard oversized input
33
- try {
34
- const hook = JSON.parse(input);
35
- const dir = hook.cwd || process.cwd();
36
-
37
- // Validate cwd is absolute path
38
- if (!path.isAbsolute(dir)) return;
39
-
40
- const gsdtDir = path.join(dir, ".gsd-t");
41
- if (!fs.existsSync(gsdtDir)) return;
42
-
43
- const sid = hook.session_id || "unknown";
44
-
45
- // Validate session_id — block path traversal (e.g., "../../etc/evil")
46
- if (!SAFE_SID.test(sid)) return;
47
-
48
- const file = path.join(gsdtDir, `heartbeat-${sid}.jsonl`);
49
-
50
- // Verify resolved path is still within .gsd-t/ directory
51
- const resolvedFile = path.resolve(file);
52
- const resolvedDir = path.resolve(gsdtDir);
53
- if (!resolvedFile.startsWith(resolvedDir + path.sep)) return;
54
-
55
- const event = buildEvent(hook);
56
- if (event) {
57
- cleanupOldHeartbeats(gsdtDir);
58
- // Symlink check — prevent redirection of event data to arbitrary files
59
- try { if (fs.lstatSync(file).isSymbolicLink()) return; } catch { /* file doesn't exist yet — safe */ }
60
- fs.appendFileSync(file, JSON.stringify(event) + "\n");
61
- }
62
- } catch (e) {
63
- // Silent failure never interfere with Claude Code
64
- }
65
- });
66
-
67
- function cleanupOldHeartbeats(gsdtDir) {
68
- try {
69
- const files = fs.readdirSync(gsdtDir);
70
- const now = Date.now();
71
- for (const f of files) {
72
- if (!f.startsWith("heartbeat-") || !f.endsWith(".jsonl")) continue;
73
- const fp = path.join(gsdtDir, f);
74
- const stat = fs.lstatSync(fp);
75
- if (stat.isSymbolicLink()) continue; // Don't follow symlinks
76
- if (now - stat.mtimeMs > MAX_AGE_MS) {
77
- fs.unlinkSync(fp);
78
- }
79
- }
80
- } catch {
81
- // Silent failure never interfere with Claude Code
82
- }
83
- }
84
-
85
- function buildEvent(hook) {
86
- const base = {
87
- ts: new Date().toISOString(),
88
- sid: hook.session_id,
89
- };
90
-
91
- switch (hook.hook_event_name) {
92
- case "SessionStart":
93
- return {
94
- ...base,
95
- evt: "session_start",
96
- data: { source: hook.source, model: hook.model },
97
- };
98
-
99
- case "PostToolUse":
100
- return {
101
- ...base,
102
- evt: "tool",
103
- tool: hook.tool_name,
104
- data: summarize(hook.tool_name, hook.tool_input),
105
- };
106
-
107
- case "SubagentStart":
108
- return {
109
- ...base,
110
- evt: "agent_spawn",
111
- data: { agent_id: hook.agent_id, agent_type: hook.agent_type },
112
- };
113
-
114
- case "SubagentStop":
115
- return {
116
- ...base,
117
- evt: "agent_stop",
118
- data: { agent_id: hook.agent_id, agent_type: hook.agent_type },
119
- };
120
-
121
- case "TaskCompleted":
122
- return {
123
- ...base,
124
- evt: "task_done",
125
- data: { task: hook.task_subject, agent: hook.teammate_name },
126
- };
127
-
128
- case "TeammateIdle":
129
- return {
130
- ...base,
131
- evt: "agent_idle",
132
- data: { agent: hook.teammate_name, team: hook.team_name },
133
- };
134
-
135
- case "Notification":
136
- return {
137
- ...base,
138
- evt: "notification",
139
- data: { message: hook.message, title: hook.title },
140
- };
141
-
142
- case "Stop":
143
- return { ...base, evt: "session_stop" };
144
-
145
- case "SessionEnd":
146
- return {
147
- ...base,
148
- evt: "session_end",
149
- data: { reason: hook.reason },
150
- };
151
-
152
- default:
153
- return null;
154
- }
155
- }
156
-
157
- function summarize(tool, input) {
158
- if (!tool || !input) return {};
159
- switch (tool) {
160
- case "Read":
161
- return { file: shortPath(input.file_path) };
162
- case "Edit":
163
- return { file: shortPath(input.file_path) };
164
- case "Write":
165
- return { file: shortPath(input.file_path) };
166
- case "Bash":
167
- return {
168
- cmd: (input.command || "").slice(0, 150),
169
- desc: input.description,
170
- };
171
- case "Grep":
172
- return { pattern: input.pattern, path: shortPath(input.path) };
173
- case "Glob":
174
- return { pattern: input.pattern };
175
- case "Task":
176
- return { desc: input.description, type: input.subagent_type };
177
- case "WebSearch":
178
- return { query: input.query };
179
- case "WebFetch":
180
- return { url: input.url };
181
- case "NotebookEdit":
182
- return { file: shortPath(input.notebook_path) };
183
- default:
184
- return {};
185
- }
186
- }
187
-
188
- function shortPath(p) {
189
- if (!p) return null;
190
- // Convert absolute paths to relative for readability
191
- const cwd = process.cwd();
192
- if (p.startsWith(cwd)) {
193
- return p.slice(cwd.length + 1).replace(/\\/g, "/");
194
- }
195
- // For home-dir paths, abbreviate
196
- const home = require("os").homedir();
197
- if (p.startsWith(home)) {
198
- return "~" + p.slice(home.length).replace(/\\/g, "/");
199
- }
200
- return p.replace(/\\/g, "/");
201
- }
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * GSD-T Heartbeat — Claude Code Hook Event Writer
5
+ *
6
+ * Writes structured events to .gsd-t/heartbeat-{session_id}.jsonl
7
+ * Installed as an async hook for multiple Claude Code events.
8
+ *
9
+ * Events captured:
10
+ * SessionStart, PostToolUse, SubagentStart, SubagentStop,
11
+ * TaskCompleted, TeammateIdle, Notification, Stop, SessionEnd
12
+ */
13
+
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+
17
+ const MAX_STDIN = 1024 * 1024; // 1MB — prevent OOM from unbounded input
18
+ const SAFE_SID = /^[a-zA-Z0-9_-]+$/; // Allowlist for session_id — blocks path traversal
19
+ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days — auto-cleanup threshold
20
+
21
+ // ─── Exports (for testing) ───────────────────────────────────────────────────
22
+ module.exports = { scrubSecrets, scrubUrl, buildEvent, summarize, shortPath };
23
+
24
+ // ─── Main (stdin processing) ─────────────────────────────────────────────────
25
+ if (require.main === module) {
26
+
27
+ let input = "";
28
+ let aborted = false;
29
+ process.stdin.setEncoding("utf8");
30
+ process.stdin.on("data", (d) => {
31
+ input += d;
32
+ if (input.length > MAX_STDIN) {
33
+ aborted = true;
34
+ process.stdin.destroy();
35
+ }
36
+ });
37
+ process.stdin.on("end", () => {
38
+ if (aborted) return; // Silently discard oversized input
39
+ try {
40
+ const hook = JSON.parse(input);
41
+ const dir = hook.cwd || process.cwd();
42
+
43
+ // Validate cwd is absolute path
44
+ if (!path.isAbsolute(dir)) return;
45
+
46
+ const gsdtDir = path.join(dir, ".gsd-t");
47
+ if (!fs.existsSync(gsdtDir)) return;
48
+
49
+ const sid = hook.session_id || "unknown";
50
+
51
+ // Validate session_id — block path traversal (e.g., "../../etc/evil")
52
+ if (!SAFE_SID.test(sid)) return;
53
+
54
+ const file = path.join(gsdtDir, `heartbeat-${sid}.jsonl`);
55
+
56
+ // Verify resolved path is still within .gsd-t/ directory
57
+ const resolvedFile = path.resolve(file);
58
+ const resolvedDir = path.resolve(gsdtDir);
59
+ if (!resolvedFile.startsWith(resolvedDir + path.sep)) return;
60
+
61
+ const event = buildEvent(hook);
62
+ if (event) {
63
+ if (hook.hook_event_name === "SessionStart") cleanupOldHeartbeats(gsdtDir);
64
+ // Symlink check — prevent redirection of event data to arbitrary files
65
+ try { if (fs.lstatSync(file).isSymbolicLink()) return; } catch { /* file doesn't exist yet — safe */ }
66
+ fs.appendFileSync(file, JSON.stringify(event) + "\n");
67
+ }
68
+ } catch (e) {
69
+ // Silent failure — never interfere with Claude Code
70
+ }
71
+ });
72
+
73
+ } // end require.main
74
+
75
+ function cleanupOldHeartbeats(gsdtDir) {
76
+ try {
77
+ const files = fs.readdirSync(gsdtDir);
78
+ const now = Date.now();
79
+ for (const f of files) {
80
+ if (!f.startsWith("heartbeat-") || !f.endsWith(".jsonl")) continue;
81
+ const fp = path.join(gsdtDir, f);
82
+ const stat = fs.lstatSync(fp);
83
+ if (stat.isSymbolicLink()) continue; // Don't follow symlinks
84
+ if (now - stat.mtimeMs > MAX_AGE_MS) {
85
+ fs.unlinkSync(fp);
86
+ }
87
+ }
88
+ } catch {
89
+ // Silent failure — never interfere with Claude Code
90
+ }
91
+ }
92
+
93
+ const EVENT_HANDLERS = {
94
+ SessionStart: (h) => ({ evt: "session_start", data: { source: h.source, model: h.model } }),
95
+ PostToolUse: (h) => ({ evt: "tool", tool: h.tool_name, data: summarize(h.tool_name, h.tool_input) }),
96
+ SubagentStart: (h) => ({ evt: "agent_spawn", data: { agent_id: h.agent_id, agent_type: h.agent_type } }),
97
+ SubagentStop: (h) => ({ evt: "agent_stop", data: { agent_id: h.agent_id, agent_type: h.agent_type } }),
98
+ TaskCompleted: (h) => ({ evt: "task_done", data: { task: h.task_subject, agent: h.teammate_name } }),
99
+ TeammateIdle: (h) => ({ evt: "agent_idle", data: { agent: h.teammate_name, team: h.team_name } }),
100
+ Notification: (h) => ({ evt: "notification", data: { message: scrubSecrets(h.message), title: scrubSecrets(h.title) } }),
101
+ Stop: () => ({ evt: "session_stop" }),
102
+ SessionEnd: (h) => ({ evt: "session_end", data: { reason: h.reason } }),
103
+ };
104
+
105
+ function buildEvent(hook) {
106
+ const handler = EVENT_HANDLERS[hook.hook_event_name];
107
+ if (!handler) return null;
108
+ return { ts: new Date().toISOString(), sid: hook.session_id, ...handler(hook) };
109
+ }
110
+
111
+ // Patterns that indicate sensitive values in CLI commands
112
+ const SECRET_FLAGS = /(--(password|token|secret|api[-_]?key|auth|credential|private[-_]?key)[\s=])\S+/gi;
113
+ const SECRET_SHORT = /(\s-p\s)\S+/gi;
114
+ const SECRET_ENV = /((API_KEY|SECRET|TOKEN|PASSWORD|BEARER|AUTH_TOKEN|PRIVATE_KEY|ACCESS_KEY|SECRET_KEY)=)\S+/gi;
115
+ const BEARER_HEADER = /(bearer\s+)\S+/gi;
116
+
117
+ function scrubSecrets(cmd) {
118
+ if (!cmd) return cmd;
119
+ return cmd
120
+ .replace(SECRET_FLAGS, "$1***")
121
+ .replace(SECRET_SHORT, "$1***")
122
+ .replace(SECRET_ENV, "$1***")
123
+ .replace(BEARER_HEADER, "$1***");
124
+ }
125
+
126
+ function scrubUrl(url) {
127
+ if (!url) return url;
128
+ try {
129
+ const u = new URL(url);
130
+ if (!u.search) return url;
131
+ for (const key of u.searchParams.keys()) {
132
+ u.searchParams.set(key, "***");
133
+ }
134
+ return u.toString();
135
+ } catch { return url; }
136
+ }
137
+
138
+ function summarize(tool, input) {
139
+ if (!tool || !input) return {};
140
+ switch (tool) {
141
+ case "Read":
142
+ case "Edit":
143
+ case "Write":
144
+ return { file: shortPath(input.file_path) };
145
+ case "Bash":
146
+ return {
147
+ cmd: scrubSecrets((input.command || "").slice(0, 150)),
148
+ desc: input.description,
149
+ };
150
+ case "Grep":
151
+ return { pattern: input.pattern, path: shortPath(input.path) };
152
+ case "Glob":
153
+ return { pattern: input.pattern };
154
+ case "Task":
155
+ return { desc: input.description, type: input.subagent_type };
156
+ case "WebSearch":
157
+ return { query: input.query };
158
+ case "WebFetch":
159
+ return { url: scrubUrl(input.url) };
160
+ case "NotebookEdit":
161
+ return { file: shortPath(input.notebook_path) };
162
+ default:
163
+ return {};
164
+ }
165
+ }
166
+
167
+ function shortPath(p) {
168
+ if (!p) return null;
169
+ // Convert absolute paths to relative for readability
170
+ const cwd = process.cwd();
171
+ if (p.startsWith(cwd)) {
172
+ return p.slice(cwd.length + 1).replace(/\\/g, "/");
173
+ }
174
+ // For home-dir paths, abbreviate
175
+ const home = require("os").homedir();
176
+ if (p.startsWith(home)) {
177
+ return "~" + p.slice(home.length).replace(/\\/g, "/");
178
+ }
179
+ return p.replace(/\\/g, "/");
180
+ }
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GSD-T SessionStart hook — shows version banner, auto-updates if needed.
4
+ * Always outputs a version line. Auto-installs new versions when available.
5
+ */
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+ const os = require("os");
9
+
10
+ const CLAUDE_DIR = path.join(os.homedir(), ".claude");
11
+ const VERSION_FILE = path.join(CLAUDE_DIR, ".gsd-t-version");
12
+ const CACHE_FILE = path.join(CLAUDE_DIR, ".gsd-t-update-check");
13
+ const CHANGELOG = "https://github.com/Tekyz-Inc/get-stuff-done-teams/blob/main/CHANGELOG.md";
14
+
15
+ function isNewer(a, b) {
16
+ const ap = a.split(".").map(Number);
17
+ const bp = b.split(".").map(Number);
18
+ for (let i = 0; i < 3; i++) {
19
+ if ((ap[i] || 0) > (bp[i] || 0)) return true;
20
+ if ((ap[i] || 0) < (bp[i] || 0)) return false;
21
+ }
22
+ return false;
23
+ }
24
+
25
+ try {
26
+ // Read installed version
27
+ if (!fs.existsSync(VERSION_FILE)) process.exit(0);
28
+ const installed = fs.readFileSync(VERSION_FILE, "utf8").trim();
29
+ if (!installed) process.exit(0);
30
+
31
+ // Read or create cache
32
+ let cached = null;
33
+ try {
34
+ if (fs.existsSync(CACHE_FILE)) {
35
+ cached = JSON.parse(fs.readFileSync(CACHE_FILE, "utf8"));
36
+ }
37
+ } catch { /* ignore */ }
38
+
39
+ // Refresh cache if stale (>1h) or missing
40
+ const isStale = !cached || (Date.now() - cached.timestamp) > 3600000;
41
+ if (isStale) {
42
+ const { execSync } = require("child_process");
43
+ try {
44
+ const result = execSync(
45
+ `"${process.execPath}" -e "const h=require('https');h.get('https://registry.npmjs.org/@tekyzinc/gsd-t/latest',{timeout:5000},(r)=>{let d='';r.on('data',(c)=>d+=c);r.on('end',()=>{try{process.stdout.write(JSON.parse(d).version)}catch{}})}).on('error',()=>{})"`,
46
+ { timeout: 8000, encoding: "utf8" }
47
+ ).trim();
48
+ if (result) {
49
+ cached = { latest: result, timestamp: Date.now() };
50
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(cached));
51
+ }
52
+ } catch { /* network error — skip */ }
53
+ }
54
+
55
+ // Auto-update if newer version available
56
+ if (cached && cached.latest && isNewer(cached.latest, installed)) {
57
+ const latest = cached.latest;
58
+ const { execSync } = require("child_process");
59
+ try {
60
+ // Install new version globally, then run update-all
61
+ execSync(`npm install -g @tekyzinc/gsd-t@${latest}`, {
62
+ timeout: 60000, encoding: "utf8", stdio: "pipe"
63
+ });
64
+ execSync("gsd-t update-all", {
65
+ timeout: 60000, encoding: "utf8", stdio: "pipe"
66
+ });
67
+ // Re-read version after update
68
+ const updated = fs.existsSync(VERSION_FILE)
69
+ ? fs.readFileSync(VERSION_FILE, "utf8").trim()
70
+ : latest;
71
+ console.log(`[GSD-T AUTO-UPDATE] v${installed} → v${updated}. Changelog: ${CHANGELOG}`);
72
+ } catch {
73
+ // Auto-update failed — fall back to manual notice
74
+ console.log(`[GSD-T UPDATE] v${installed} — update available (v${installed} → v${latest}). Auto-update failed — run manually: /user:gsd-t-version-update-all. Changelog: ${CHANGELOG}`);
75
+ }
76
+ } else {
77
+ console.log(`[GSD-T] v${installed} — up to date. Changelog: ${CHANGELOG}`);
78
+ }
79
+ } catch { /* graceful failure — don't block session start */ }
@@ -1,27 +1,42 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * Background update check — spawned detached by the CLI to refresh the version cache.
5
- * Usage: node npm-update-check.js <cache-file-path>
6
- */
7
-
8
- const https = require("https");
9
- const fs = require("fs");
10
-
11
- const cacheFile = process.argv[2];
12
- if (!cacheFile) process.exit(1);
13
-
14
- https.get("https://registry.npmjs.org/@tekyzinc/gsd-t/latest",
15
- { timeout: 5000 }, (res) => {
16
- let d = "";
17
- res.on("data", (c) => d += c);
18
- res.on("end", () => {
19
- try {
20
- const v = JSON.parse(d).version;
21
- if (v && /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(v)) {
22
- fs.writeFileSync(cacheFile,
23
- JSON.stringify({ latest: v, timestamp: Date.now() }));
24
- }
25
- } catch { /* malformed response skip */ }
26
- });
27
- }).on("error", () => {});
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Background update check — spawned detached by the CLI to refresh the version cache.
5
+ * Usage: node npm-update-check.js <cache-file-path>
6
+ */
7
+
8
+ const https = require("https");
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+ const os = require("os");
12
+
13
+ const cacheFile = process.argv[2];
14
+ if (!cacheFile) process.exit(1);
15
+
16
+ // Validate cache path is within ~/.claude/ to prevent arbitrary file writes
17
+ const resolved = path.resolve(cacheFile);
18
+ const claudeDir = path.join(os.homedir(), ".claude");
19
+ if (!resolved.startsWith(claudeDir + path.sep) && resolved !== claudeDir) {
20
+ process.exit(1);
21
+ }
22
+
23
+ https.get("https://registry.npmjs.org/@tekyzinc/gsd-t/latest",
24
+ { timeout: 5000 }, (res) => {
25
+ const MAX_RESPONSE = 1024 * 1024; // 1MB limit
26
+ let d = "";
27
+ res.on("data", (c) => {
28
+ d += c;
29
+ if (d.length > MAX_RESPONSE) { res.destroy(); return; }
30
+ });
31
+ res.on("end", () => {
32
+ try {
33
+ const v = JSON.parse(d).version;
34
+ if (v && /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(v)) {
35
+ // Symlink check — prevent redirection to arbitrary files
36
+ try { if (fs.lstatSync(cacheFile).isSymbolicLink()) return; } catch { /* doesn't exist yet — safe */ }
37
+ fs.writeFileSync(cacheFile,
38
+ JSON.stringify({ latest: v, timestamp: Date.now() }));
39
+ }
40
+ } catch { /* malformed response — skip */ }
41
+ });
42
+ }).on("error", () => {});
@@ -159,11 +159,17 @@ Even in development, the user may have:
159
159
 
160
160
  ## Update Notices
161
161
 
162
- On session start, a version check hook outputs one of two messages. Show the result to the user at the **beginning** of your first response:
162
+ On session start, a version check hook auto-updates GSD-T and outputs a status message. Show the result to the user at the **beginning** of your first response:
163
163
 
164
- - If `[GSD-T UPDATE]` appears → update is available. Show:
164
+ - If `[GSD-T AUTO-UPDATE]` appears → GSD-T was just auto-updated. Show:
165
165
  ```
166
- ⬆️ GSD-T update available: {installed} → {latest}
166
+ GSD-T auto-updated: v{old} → v{new}
167
+ Changelog: https://github.com/Tekyz-Inc/get-stuff-done-teams/blob/main/CHANGELOG.md
168
+ ```
169
+
170
+ - If `[GSD-T UPDATE]` appears → update available but auto-update failed. Show:
171
+ ```
172
+ ⬆️ GSD-T update available: v{installed} → v{latest} (auto-update failed)
167
173
  Run: /user:gsd-t-version-update-all
168
174
  Changelog: https://github.com/Tekyz-Inc/get-stuff-done-teams/blob/main/CHANGELOG.md
169
175
  ```
@@ -172,6 +178,7 @@ On session start, a version check hook outputs one of two messages. Show the res
172
178
  - If `[GSD-T]` appears → up to date. Show:
173
179
  ```
174
180
  GSD-T v{version} — up to date
181
+ Changelog: https://github.com/Tekyz-Inc/get-stuff-done-teams/blob/main/CHANGELOG.md
175
182
  ```
176
183
 
177
184
  ## Conversation vs. Work