@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 +11 -2
- package/dist/enrich-store-event.js +97 -1
- package/package.json +2 -1
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
|
|
14
|
-
|
|
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
|
|
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.
|
|
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
|
},
|