@groundtrack/hooks 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -10,8 +10,17 @@ npm install -g @groundtrack/hooks
10
10
  ```
11
11
 
12
12
  The command reads a pre-tool-use hook payload from standard input and enriches
13
- Groundtrack `store_event` calls with the client session and model identifiers.
14
- It does not send network requests or read project files.
13
+ Groundtrack `store_event` calls with:
14
+
15
+ - the client session and model identifiers (using Claude's host-provided
16
+ transcript path when its hook payload omits the model);
17
+ - a stable client event id derived from the host's tool invocation id; and
18
+ - repository, branch, commit, and dirty-worktree context collected with Git.
19
+
20
+ The Git context is collected from the hook's working directory (or a single
21
+ Cursor workspace root) and overrides model-provided values. Collection fails
22
+ open when the hook is outside a Git worktree. The command does not send network
23
+ requests.
15
24
 
16
25
  Update it with:
17
26
 
@@ -1,3 +1,5 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { closeSync, fstatSync, openSync, readSync } from "node:fs";
1
3
  const PROVIDERS = [
2
4
  [/^claude(?:[-_.]|$)/, "anthropic"],
3
5
  [/^(?:gpt|o\d|codex)(?:[-_.]|$)/, "openai"],
@@ -18,12 +20,97 @@ function normalizeModelId(value) {
18
20
  const provider = PROVIDERS.find(([pattern]) => pattern.test(model))?.[1];
19
21
  return provider ? `${provider}/${model}` : null;
20
22
  }
23
+ function sanitizeRepoKey(value) {
24
+ const remote = nonEmptyString(value);
25
+ if (!remote)
26
+ return null;
27
+ try {
28
+ const path = remote.includes("://")
29
+ ? new URL(remote).pathname
30
+ : remote.replace(/^[^@\s]+@[^:\s]+:/, "");
31
+ const repoKey = path.replace(/^\/+/, "").replace(/\.git$/i, "");
32
+ return /^[a-z0-9._-]+(?:\/[a-z0-9._-]+)+$/i.test(repoKey) ? repoKey : null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ function transcriptModel(transcriptPath) {
39
+ const path = nonEmptyString(transcriptPath);
40
+ if (!path)
41
+ return null;
42
+ let fd = null;
43
+ try {
44
+ fd = openSync(path, "r");
45
+ const size = fstatSync(fd).size;
46
+ const length = Math.min(size, 256 * 1024);
47
+ const buffer = Buffer.alloc(length);
48
+ readSync(fd, buffer, 0, length, size - length);
49
+ const lines = buffer.toString("utf8").split("\n");
50
+ if (size > length)
51
+ lines.shift();
52
+ for (const line of lines.reverse()) {
53
+ if (!line.trim())
54
+ continue;
55
+ try {
56
+ const entry = JSON.parse(line);
57
+ const model = nonEmptyString(entry.message?.model ?? entry.model);
58
+ if (model)
59
+ return model;
60
+ }
61
+ catch {
62
+ // Ignore partial and non-JSON transcript lines.
63
+ }
64
+ }
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ finally {
70
+ if (fd != null)
71
+ closeSync(fd);
72
+ }
73
+ return null;
74
+ }
21
75
  function isGroundtrackStoreEvent(toolName) {
22
76
  const normalized = nonEmptyString(toolName)?.toLowerCase();
23
77
  if (!normalized?.includes("store_event"))
24
78
  return false;
25
79
  return normalized.includes("groundtrack") || normalized === "mcp:store_event";
26
80
  }
81
+ function workspacePath(hookInput) {
82
+ const cwd = nonEmptyString(hookInput.cwd);
83
+ if (cwd)
84
+ return cwd;
85
+ if (!Array.isArray(hookInput.workspace_roots) || hookInput.workspace_roots.length !== 1) {
86
+ return null;
87
+ }
88
+ return nonEmptyString(hookInput.workspace_roots[0]);
89
+ }
90
+ function git(cwd, args) {
91
+ try {
92
+ return execFileSync("git", ["-C", cwd, ...args], {
93
+ encoding: "utf8",
94
+ maxBuffer: 8 * 1024 * 1024,
95
+ stdio: ["ignore", "pipe", "ignore"],
96
+ timeout: 2_000,
97
+ }).trim();
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ function gitContext(cwd) {
104
+ if (!cwd || git(cwd, ["rev-parse", "--is-inside-work-tree"]) !== "true")
105
+ return null;
106
+ const status = git(cwd, ["status", "--porcelain"]);
107
+ return {
108
+ repoKey: sanitizeRepoKey(git(cwd, ["remote", "get-url", "origin"])),
109
+ branch: nonEmptyString(git(cwd, ["branch", "--show-current"])),
110
+ commitSha: nonEmptyString(git(cwd, ["rev-parse", "HEAD"])),
111
+ isDirty: status == null ? null : status.length > 0,
112
+ };
113
+ }
27
114
  export function enrichInput(hookInput, clientOverride) {
28
115
  const toolInput = hookInput.tool_input &&
29
116
  typeof hookInput.tool_input === "object" &&
@@ -33,11 +120,20 @@ export function enrichInput(hookInput, clientOverride) {
33
120
  const isCursor = nonEmptyString(hookInput.conversation_id) != null;
34
121
  const host = nonEmptyString(clientOverride)?.toLowerCase() ?? (isCursor ? "cursor" : "codex");
35
122
  const hostSessionId = nonEmptyString(isCursor ? hookInput.conversation_id : hookInput.session_id);
36
- const hostModel = normalizeModelId(isCursor ? (hookInput.model_id ?? hookInput.model) : hookInput.model);
123
+ const hostModel = normalizeModelId(isCursor
124
+ ? (hookInput.model_id ?? hookInput.model)
125
+ : (hookInput.model ?? hookInput.model_id ?? transcriptModel(hookInput.transcript_path)));
126
+ const invocationId = nonEmptyString(isCursor ? hookInput.generation_id : hookInput.tool_use_id);
127
+ const context = gitContext(workspacePath(hookInput));
37
128
  if (hostSessionId)
38
129
  toolInput.sessionId = `${host}:${hostSessionId}`;
39
130
  if (hostModel)
40
131
  toolInput.modelId = hostModel;
132
+ if (invocationId) {
133
+ toolInput.clientEventId = [host, hostSessionId, invocationId].filter(Boolean).join(":");
134
+ }
135
+ if (context)
136
+ Object.assign(toolInput, context);
41
137
  return toolInput;
42
138
  }
43
139
  export function hookOutput(hookInput, clientOverride) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groundtrack/hooks",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Lifecycle hook utilities for Groundtrack MCP clients.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -27,6 +27,7 @@
27
27
  "build": "tsc -p tsconfig.json",
28
28
  "lint": "oxlint .",
29
29
  "prepack": "pnpm run build",
30
+ "release:patch": "node scripts/release-patch.mjs",
30
31
  "test": "pnpm run build && node --test test/*.test.mjs",
31
32
  "typecheck": "tsc -p tsconfig.json --noEmit"
32
33
  },