@groundtrack/hooks 0.1.12 → 0.1.14

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
@@ -67,6 +67,17 @@ hooks in `.codex/config.toml` are recognized and repaired without duplicating
67
67
  them. Claude's `CLAUDE.md` imports the shared `AGENTS.md` policy. The plugin is
68
68
  added to the existing OpenCode JSON/JSONC configuration.
69
69
 
70
+ Claude setup also installs a synchronous `SessionStart` check in project settings.
71
+ At startup, resume, clear, or fork it checks local MCP configuration and Claude's
72
+ own `mcp get` status. If Groundtrack is missing, disabled, awaiting approval,
73
+ unauthenticated, or not connected, Claude shows a warning with a repair command
74
+ and gives the agent the same context. An incomplete OAuth flow can leave Claude
75
+ in `Needs authentication` even when `mcp get` exits successfully; this state is
76
+ not treated as connected. The check never starts OAuth or blocks the session on
77
+ failure; its native probe has a five-second limit. It needs Claude project hooks
78
+ to be enabled and trusted, and is not a replacement for a live tool call. Other
79
+ clients still rely on their own setup and diagnostics.
80
+
70
81
  In each selected agent, ask:
71
82
 
72
83
  > Verify Groundtrack's connection and hook context with search_experiences.
@@ -0,0 +1,13 @@
1
+ import { type SetupEnvironment } from "./setup/agents.js";
2
+ import { type ClientRunner } from "./setup/connections.js";
3
+ type SessionStartOutput = {
4
+ systemMessage: string;
5
+ hookSpecificOutput: {
6
+ hookEventName: "SessionStart";
7
+ additionalContext: string;
8
+ };
9
+ };
10
+ /** Read-only, bounded probe; no OAuth side effects, network calls of our own, or persistent state. */
11
+ export declare function claudeSessionHealth(context: SetupEnvironment, runner?: ClientRunner): Promise<SessionStartOutput | Record<string, never>>;
12
+ export declare function claudeSessionHook(): Promise<SessionStartOutput | Record<string, never>>;
13
+ export {};
@@ -0,0 +1,89 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { MCP_URL, configPaths, environment, executable, isGroundtrack, readConfig, serverMap, } from "./setup/agents.js";
4
+ import { at, readJson } from "./setup/config.js";
5
+ import { claudeStatus, runClient } from "./setup/connections.js";
6
+ const setupCommand = "npx --yes --package @groundtrack/hooks@latest groundtrack setup --agent claude";
7
+ function readConfigText(path) {
8
+ try {
9
+ // A diagnostic may read the same symlinked profile path Claude accepts.
10
+ return readFileSync(path, "utf8");
11
+ }
12
+ catch (error) {
13
+ if (error.code === "ENOENT")
14
+ return "";
15
+ throw error;
16
+ }
17
+ }
18
+ function warning(problem, repair) {
19
+ const message = `Groundtrack is unavailable in this Claude Code session (${problem}). Searches, events, and outcomes may not be recorded. From this project, run: ${repair}`;
20
+ return {
21
+ systemMessage: message,
22
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: message },
23
+ };
24
+ }
25
+ function settings(context) {
26
+ const userDir = context.env.CLAUDE_CONFIG_DIR || join(context.homeDir, ".claude");
27
+ return [
28
+ readJson(readConfigText(join(userDir, "settings.json"))),
29
+ readJson(readConfigText(join(context.repoDir, ".claude/settings.json"))),
30
+ readJson(readConfigText(join(context.repoDir, ".claude/settings.local.json"))),
31
+ ];
32
+ }
33
+ /** Read-only, bounded probe; no OAuth side effects, network calls of our own, or persistent state. */
34
+ export async function claudeSessionHealth(context, runner = runClient) {
35
+ try {
36
+ const paths = configPaths("claude", context);
37
+ const project = readConfig("claude", readConfigText(paths.project));
38
+ const user = readConfig("claude", readConfigText(paths.user));
39
+ const local = at(user, ["projects", context.repoDir, "mcpServers"]);
40
+ const candidates = [local ?? {}, serverMap("claude", project), serverMap("claude", user)]
41
+ .flatMap((map) => Object.entries(map))
42
+ .filter(([alias, server]) => isGroundtrack(alias, server, MCP_URL));
43
+ if (!candidates.length)
44
+ return warning("MCP server missing", setupCommand);
45
+ const [alias, server] = candidates[0];
46
+ if (!/^[a-zA-Z0-9_.-]+$/.test(alias))
47
+ return warning("invalid server name", setupCommand);
48
+ const configs = [...settings(context), user, at(user, ["projects", context.repoDir])];
49
+ if (configs.some((config) => typeof config === "object" &&
50
+ config !== null &&
51
+ Array.isArray(config.disabledMcpServers) &&
52
+ config.disabledMcpServers.includes(alias)) ||
53
+ (typeof server === "object" &&
54
+ server !== null &&
55
+ (("disabled" in server && server.disabled === true) ||
56
+ ("enabled" in server && server.enabled === false))))
57
+ return warning("MCP server disabled", `claude (then re-enable ${alias} in /mcp; rerun ${setupCommand})`);
58
+ const binary = executable("claude", context);
59
+ if (!binary)
60
+ return warning("connection could not be checked", setupCommand);
61
+ const status = claudeStatus(await runner({ binary, args: ["mcp", "get", alias] }, context, false));
62
+ switch (status) {
63
+ case "connected":
64
+ return {};
65
+ case "needs_auth":
66
+ return warning("MCP sign-in required", `claude mcp login ${alias}`);
67
+ case "pending":
68
+ return warning("MCP server awaiting project approval", "claude (then approve Groundtrack in /mcp)");
69
+ case "disabled":
70
+ return warning("MCP server disabled in Claude Code", "claude (then re-enable Groundtrack in /mcp)");
71
+ case "failed":
72
+ return warning("MCP connection failed", setupCommand);
73
+ case "unknown":
74
+ return warning("MCP connection could not be verified", setupCommand);
75
+ }
76
+ }
77
+ catch {
78
+ // A malformed config or failed probe must never prevent Claude from starting.
79
+ return warning("MCP connection could not be verified", setupCommand);
80
+ }
81
+ }
82
+ export async function claudeSessionHook() {
83
+ try {
84
+ return await claudeSessionHealth(environment());
85
+ }
86
+ catch {
87
+ return {};
88
+ }
89
+ }
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { hookOutput } from "./enrich-store-event.js";
3
+ import { claudeSessionHook } from "./claude-session-health.js";
3
4
  import { packageVersion } from "./version.js";
4
5
  async function main() {
5
6
  if (process.argv[2] === "version" || process.argv[2] === "--version") {
@@ -7,6 +8,10 @@ async function main() {
7
8
  process.stdout.write(process.argv.includes("--json") ? JSON.stringify({ version }) : version);
8
9
  return;
9
10
  }
11
+ if (process.argv[2] === "claude-session") {
12
+ process.stdout.write(JSON.stringify(await claudeSessionHook()));
13
+ return;
14
+ }
10
15
  let raw = "";
11
16
  for await (const chunk of process.stdin)
12
17
  raw += chunk;
File without changes
@@ -6,6 +6,9 @@ export type CommandResult = {
6
6
  stdout: string;
7
7
  stderr: string;
8
8
  };
9
+ export type ClaudeStatus = "connected" | "pending" | "disabled" | "needs_auth" | "failed" | "unknown";
10
+ /** Claude's `mcp get` can exit successfully even when its cached status is unhealthy. */
11
+ export declare function claudeStatus(probe: CommandResult): ClaudeStatus;
9
12
  export type ClientCommand = {
10
13
  binary: string;
11
14
  args: string[];
@@ -3,6 +3,21 @@ import { stripVTControlCharacters } from "node:util";
3
3
  import { configPaths, executable, readConfig, serverMap } from "./agents.js";
4
4
  import { readFile } from "./files.js";
5
5
  import { activationSteps } from "./plan.js";
6
+ /** Claude's `mcp get` can exit successfully even when its cached status is unhealthy. */
7
+ export function claudeStatus(probe) {
8
+ const output = stripVTControlCharacters(`${probe.stdout}\n${probe.stderr}`);
9
+ if (/pending approval|awaiting approval/i.test(output))
10
+ return "pending";
11
+ if (/status:[^\n]*(?:disabled|rejected)/i.test(output))
12
+ return "disabled";
13
+ if (/status:[^\n]*(?:needs authentication|not authenticated|needs.auth)/i.test(output))
14
+ return "needs_auth";
15
+ if (/status:\s*[^a-z0-9\n]*connected\b/i.test(output) && probe.exitCode === 0)
16
+ return "connected";
17
+ if (/status:[^\n]*(?:failed to connect|connection failed)/i.test(output))
18
+ return "failed";
19
+ return "unknown";
20
+ }
6
21
  /** Probe output can include headers. Keep it bounded and private; never persist or print it. */
7
22
  export const runClient = (command, context, interactive) => new Promise((resolve) => {
8
23
  const child = spawn(command.binary, command.args, {
@@ -75,12 +90,12 @@ async function prepareConnection(agent, context, url, runner) {
75
90
  }
76
91
  if (agent.agent === "claude") {
77
92
  const probe = await runner({ binary, args: ["mcp", "get", agent.alias] }, context, false);
78
- const output = stripVTControlCharacters(`${probe.stdout}\n${probe.stderr}`);
79
- if (/pending approval|awaiting approval/i.test(output))
93
+ const status = claudeStatus(probe);
94
+ if (status === "pending")
80
95
  return { ...base, status: "needs_project_approval" };
81
- if (/status:\s*[^a-z0-9\n]*connected\b/i.test(output) && probe.exitCode === 0)
96
+ if (status === "connected")
82
97
  return { ...base, status: "authenticated" };
83
- if (/status:[^\n]*disabled/i.test(output))
98
+ if (status === "disabled")
84
99
  return {
85
100
  ...base,
86
101
  status: "failed",
@@ -96,7 +111,7 @@ async function prepareConnection(agent, context, url, runner) {
96
111
  ...base,
97
112
  status: "ready",
98
113
  command,
99
- offer: !agent.existingConnection || /needs authentication|not authenticated/i.test(output),
114
+ offer: !agent.existingConnection || status === "needs_auth",
100
115
  };
101
116
  }
102
117
  if (agent.agent === "codex") {
@@ -43,6 +43,43 @@ function commandHook(command) {
43
43
  statusMessage: "Adding Groundtrack session metadata",
44
44
  };
45
45
  }
46
+ const CLAUDE_SESSION_COMMAND = "groundtrack-hook claude-session";
47
+ const CLAUDE_SESSION_MATCHER = "startup|resume|clear|fork";
48
+ function hasClaudeSessionHook(config) {
49
+ return array(at(config, ["hooks", "SessionStart"]), "SessionStart hooks").some((entry) => {
50
+ const group = record(entry);
51
+ return (group?.matcher === CLAUDE_SESSION_MATCHER &&
52
+ array(group.hooks, "Hook commands").some((hook) => record(hook)?.type === "command" &&
53
+ record(hook)?.command === CLAUDE_SESSION_COMMAND &&
54
+ record(hook)?.async !== true));
55
+ });
56
+ }
57
+ function upsertClaudeSessionHook(text) {
58
+ const groups = array(at(readJson(text), ["hooks", "SessionStart"]), "SessionStart hooks");
59
+ let updated = text;
60
+ // Only replace our own command, even when it shares a group with other hooks.
61
+ for (let i = groups.length - 1; i >= 0; i--) {
62
+ const hooks = array(record(groups[i])?.hooks, "Hook commands");
63
+ for (let j = hooks.length - 1; j >= 0; j--)
64
+ if (record(hooks[j])?.command === CLAUDE_SESSION_COMMAND)
65
+ updated = setJson(updated, ["hooks", "SessionStart", i, "hooks", j], undefined);
66
+ if (hooks.length && hooks.every((hook) => record(hook)?.command === CLAUDE_SESSION_COMMAND))
67
+ updated = setJson(updated, ["hooks", "SessionStart", i], undefined);
68
+ }
69
+ if (at(readJson(updated), ["hooks", "SessionStart"]) === undefined)
70
+ updated = setJson(updated, ["hooks", "SessionStart"], []);
71
+ return setJson(updated, ["hooks", "SessionStart", -1], {
72
+ matcher: CLAUDE_SESSION_MATCHER,
73
+ hooks: [
74
+ {
75
+ type: "command",
76
+ command: CLAUDE_SESSION_COMMAND,
77
+ timeout: 8,
78
+ statusMessage: "Checking Groundtrack connection",
79
+ },
80
+ ],
81
+ });
82
+ }
46
83
  function hasHook(config, aliases, command) {
47
84
  return array(at(config, ["hooks", "PreToolUse"]), "PreToolUse hooks").some((entry) => {
48
85
  const group = record(entry);
@@ -106,6 +143,8 @@ function planClaude(files, context, aliases) {
106
143
  !hasHook(local, aliases, "groundtrack-hook claude")) {
107
144
  files.set(projectPath, upsertCommandHook(files.read(projectPath) ?? "", aliases, "groundtrack-hook claude"));
108
145
  }
146
+ if (![user, local, jsonFile(files, projectPath)].some(hasClaudeSessionHook))
147
+ files.set(projectPath, upsertClaudeSessionHook(files.read(projectPath) ?? ""));
109
148
  const bridgePath = join(context.repoDir, "CLAUDE.md");
110
149
  const bridge = files.read(bridgePath);
111
150
  if (!claudeInstructionsAreCurrent(bridge)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groundtrack/hooks",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Lifecycle hook utilities for Groundtrack MCP clients.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -1,8 +0,0 @@
1
- import type { SessionNormalizer } from "./format.js";
2
- import type { AgentSessionMetadata } from "./types.js";
3
- export type DecodedOpenCodeSession = Readonly<{
4
- metadata: AgentSessionMetadata;
5
- records: readonly unknown[];
6
- }>;
7
- export declare function decodeOpenCodeSessionExport(value: unknown): DecodedOpenCodeSession | null;
8
- export declare function createOpenCodeSessionNormalizer(metadata?: AgentSessionMetadata): SessionNormalizer;
@@ -1,230 +0,0 @@
1
- import { isSessionRecord, sessionString, } from "./canonical.js";
2
- import { containsInternalInstructionText, redactCredentialText, sanitizeSessionValue, sanitizeWorkingDirectory, } from "./redaction.js";
3
- function unixMillisecondsDate(value) {
4
- if (!Number.isSafeInteger(value) || value < 0)
5
- return null;
6
- const date = new Date(value);
7
- return Number.isNaN(date.getTime()) ? null : date;
8
- }
9
- function messageTimestamp(message) {
10
- const info = isSessionRecord(message.info) ? message.info : {};
11
- const time = isSessionRecord(info.time) ? info.time : {};
12
- return (unixMillisecondsDate(time.completed)?.toISOString() ??
13
- unixMillisecondsDate(time.created)?.toISOString() ??
14
- null);
15
- }
16
- export function decodeOpenCodeSessionExport(value) {
17
- if (!isSessionRecord(value) || !isSessionRecord(value.info) || !Array.isArray(value.messages))
18
- return null;
19
- const externalSessionId = sessionString(value.info.id);
20
- if (!externalSessionId)
21
- return null;
22
- const time = isSessionRecord(value.info.time) ? value.info.time : {};
23
- const startedAt = unixMillisecondsDate(time.created);
24
- const endedAt = unixMillisecondsDate(time.updated);
25
- return {
26
- metadata: {
27
- externalSessionId,
28
- timestamp: endedAt ?? startedAt,
29
- cwd: sessionString(value.info.directory),
30
- repositoryUrl: null,
31
- repositoryBranch: null,
32
- repositoryCommitSha: null,
33
- originator: "opencode",
34
- isSubagent: sessionString(value.info.parentID) !== null,
35
- startedAt,
36
- endedAt,
37
- },
38
- records: [
39
- {
40
- type: "opencode_session",
41
- timestamp: startedAt?.toISOString() ?? null,
42
- info: {
43
- id: value.info.id,
44
- directory: value.info.directory,
45
- model: value.info.model,
46
- version: value.info.version,
47
- },
48
- },
49
- ...value.messages.map((message) => isSessionRecord(message)
50
- ? {
51
- type: "opencode_message",
52
- timestamp: messageTimestamp(message),
53
- info: message.info,
54
- parts: message.parts,
55
- }
56
- : message),
57
- ],
58
- };
59
- }
60
- function timestampIso(value) {
61
- if (!Number.isSafeInteger(value) || value < 0)
62
- return null;
63
- const date = new Date(value);
64
- return Number.isNaN(date.getTime()) ? null : date.toISOString();
65
- }
66
- function modelName(value) {
67
- if (typeof value === "string")
68
- return value || null;
69
- if (!isSessionRecord(value))
70
- return null;
71
- const provider = sessionString(value.providerID);
72
- const model = sessionString(value.modelID) ?? sessionString(value.id);
73
- return provider && model ? `${provider}/${model}` : model;
74
- }
75
- function containsInternalInstructions(value) {
76
- if (typeof value === "string")
77
- return containsInternalInstructionText(value);
78
- if (Array.isArray(value))
79
- return value.some(containsInternalInstructions);
80
- if (!isSessionRecord(value))
81
- return false;
82
- return Object.values(value).some(containsInternalInstructions);
83
- }
84
- function redactHomePaths(value) {
85
- if (typeof value === "string")
86
- return value
87
- .replace(/\/Users\/[^/\s"'`]+/g, "~")
88
- .replace(/\/home\/[^/\s"'`]+/g, "~")
89
- .replace(/[A-Za-z]:\\Users\\[^\\\s"'`]+/gi, "~");
90
- if (Array.isArray(value))
91
- return value.map(redactHomePaths);
92
- if (!isSessionRecord(value))
93
- return value;
94
- return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, redactHomePaths(nested)]));
95
- }
96
- function sanitizeOpenCodeValue(value) {
97
- return redactHomePaths(sanitizeSessionValue(value));
98
- }
99
- function safeValue(value) {
100
- return containsInternalInstructions(value)
101
- ? "[REDACTED INTERNAL INSTRUCTIONS]"
102
- : sanitizeOpenCodeValue(value);
103
- }
104
- function partTimestamp(part, key, fallback) {
105
- const state = isSessionRecord(part.state) ? part.state : {};
106
- const time = isSessionRecord(state.time) ? state.time : {};
107
- return timestampIso(time[key]) ?? fallback;
108
- }
109
- function normalizeOpenCodeMessage(record, entryBudget) {
110
- const info = isSessionRecord(record.info) ? record.info : null;
111
- const role = info ? sessionString(info.role) : null;
112
- if (!info || (role !== "user" && role !== "assistant") || !Array.isArray(record.parts))
113
- return { entries: [], sensitive: false, unsupported: true };
114
- const occurredAt = sessionString(record.timestamp);
115
- const entries = [];
116
- let sensitive = false;
117
- let unsupported = false;
118
- for (const part of record.parts) {
119
- if (entries.length > entryBudget)
120
- break;
121
- if (!isSessionRecord(part)) {
122
- unsupported = true;
123
- continue;
124
- }
125
- const type = sessionString(part.type);
126
- if (type === "reasoning" || type === "file" || type === "patch" || type === "snapshot") {
127
- sensitive = true;
128
- continue;
129
- }
130
- if (type === "text") {
131
- const text = sessionString(part.text);
132
- if (!text || part.ignored === true)
133
- continue;
134
- if (part.synthetic === true || containsInternalInstructionText(text))
135
- sensitive = true;
136
- else
137
- entries.push({
138
- kind: role === "user" ? "user_message" : "assistant_message",
139
- occurredAt,
140
- payload: { text: sanitizeOpenCodeValue(text) },
141
- });
142
- continue;
143
- }
144
- if (type === "tool" && role === "assistant") {
145
- const toolName = sessionString(part.tool);
146
- const state = isSessionRecord(part.state) ? part.state : null;
147
- if (!toolName || !state) {
148
- unsupported = true;
149
- continue;
150
- }
151
- const inputContainsInternalInstructions = containsInternalInstructions(state.input);
152
- sensitive ||= inputContainsInternalInstructions;
153
- entries.push({
154
- kind: "tool_call",
155
- occurredAt: partTimestamp(part, "start", occurredAt),
156
- payload: {
157
- callId: sessionString(part.callID),
158
- toolName: redactCredentialText(toolName),
159
- input: inputContainsInternalInstructions
160
- ? "[REDACTED INTERNAL INSTRUCTIONS]"
161
- : sanitizeOpenCodeValue(state.input),
162
- },
163
- });
164
- const status = sessionString(state.status);
165
- if (status === "completed" || status === "error") {
166
- const output = status === "completed" ? state.output : state.error;
167
- const outputContainsInternalInstructions = containsInternalInstructions(output);
168
- sensitive ||= outputContainsInternalInstructions;
169
- entries.push({
170
- kind: "tool_result",
171
- occurredAt: partTimestamp(part, "end", occurredAt),
172
- payload: {
173
- callId: sessionString(part.callID),
174
- output: safeValue(output),
175
- ...(status === "error" ? { isError: true } : {}),
176
- },
177
- });
178
- }
179
- continue;
180
- }
181
- if (type === "step-start" ||
182
- type === "step-finish" ||
183
- type === "retry" ||
184
- type === "compaction")
185
- continue;
186
- if (type === "subtask" || type === "agent")
187
- sensitive = true;
188
- else
189
- unsupported = true;
190
- }
191
- const overflowed = entries.length > entryBudget;
192
- return {
193
- entries: overflowed ? entries.slice(0, entryBudget) : entries,
194
- sensitive,
195
- unsupported,
196
- overflowed,
197
- };
198
- }
199
- export function createOpenCodeSessionNormalizer(metadata) {
200
- let model = null;
201
- return {
202
- normalizeRecord: (record, entryBudget = Number.POSITIVE_INFINITY) => {
203
- if (record.type === "opencode_session") {
204
- const info = isSessionRecord(record.info) ? record.info : {};
205
- model ??= modelName(info.model);
206
- return { entries: [], sensitive: false, unsupported: false };
207
- }
208
- if (record.type !== "opencode_message")
209
- return { entries: [], sensitive: false, unsupported: true };
210
- const info = isSessionRecord(record.info) ? record.info : {};
211
- const provider = sessionString(info.providerID);
212
- const modelId = sessionString(info.modelID);
213
- model ??= provider && modelId ? `${provider}/${modelId}` : modelId;
214
- return normalizeOpenCodeMessage(record, entryBudget);
215
- },
216
- canonicalMetadata: () => metadata
217
- ? {
218
- type: "session",
219
- version: 1,
220
- externalSessionId: metadata.externalSessionId,
221
- repositoryUrl: metadata.repositoryUrl,
222
- repositoryBranch: metadata.repositoryBranch,
223
- repositoryCommitSha: metadata.repositoryCommitSha,
224
- workingDirectory: sanitizeWorkingDirectory(metadata.cwd),
225
- originator: "opencode",
226
- model: model ? redactCredentialText(model) : null,
227
- }
228
- : null,
229
- };
230
- }