@groundtrack/hooks 0.1.1 → 0.1.2

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
@@ -9,12 +9,43 @@ Codex, and Cursor hook configurations:
9
9
  npm install -g @groundtrack/hooks
10
10
  ```
11
11
 
12
- The command reads a pre-tool-use hook payload from standard input and enriches
13
- Groundtrack `store_event` calls with:
12
+ ## Doctor CLI
13
+
14
+ The same package installs a deterministic `groundtrack` CLI. Run it from a
15
+ repository root:
16
+
17
+ ```bash
18
+ groundtrack doctor
19
+ groundtrack doctor --fix
20
+ ```
21
+
22
+ `doctor` checks the installed package/runtime version, discovers the actual
23
+ Groundtrack MCP server alias, validates Claude/Codex/Cursor hook configuration,
24
+ checks the managed `AGENTS.md` and `CLAUDE.md` instructions, and simulates a
25
+ hook invocation. It exits non-zero when repair is needed.
26
+
27
+ `doctor --fix` previews the diagnosis, asks before changing anything, updates
28
+ only Groundtrack-owned hook entries and versioned managed instruction blocks,
29
+ then repeats the verification. Use `--yes` for an explicitly authorized
30
+ unattended repair. Existing surrounding instructions and unrelated hooks are
31
+ preserved; malformed JSON and symbolic links are reported instead of
32
+ overwritten.
33
+
34
+ The CLI compares its package version with npm. Canonical hook configuration and
35
+ instruction templates ship inside that versioned package, so repair does not
36
+ depend on an LLM or download arbitrary configuration text.
37
+
38
+ `templates/AGENTS.md` is the single source of truth for the managed repository
39
+ policy. After changing it, run `pnpm sync:agent-instructions` to regenerate the
40
+ setup-guide example; tests reject drift between the generated copy and the
41
+ packaged template.
42
+
43
+ The `groundtrack-hook` command reads a pre-tool-use hook payload from standard input and enriches
44
+ Groundtrack `store_event` and `search_experiences` calls with:
14
45
 
15
46
  - the client session and model identifiers (using Claude's host-provided
16
47
  transcript path when its hook payload omits the model);
17
- - a stable client event id derived from the host's tool invocation id; and
48
+ - for `store_event`, a stable client event id derived from the host's tool invocation id; and
18
49
  - repository, branch, commit, and dirty-worktree context collected with Git.
19
50
 
20
51
  The Git context is collected from the hook's working directory (or a single
@@ -0,0 +1,10 @@
1
+ import { readFileSync } from "node:fs";
2
+ const VERSION_PATTERN = /^<!-- groundtrack:start version=(\d+) -->/;
3
+ /** Canonical managed Groundtrack policy, packaged with the CLI. */
4
+ export const AGENT_INSTRUCTION_BODY = readFileSync(new URL("../templates/AGENTS.md", import.meta.url), "utf8").trim();
5
+ const versionMatch = VERSION_PATTERN.exec(AGENT_INSTRUCTION_BODY);
6
+ if (!versionMatch) {
7
+ throw new Error("Canonical Groundtrack AGENTS.md template is missing its version marker.");
8
+ }
9
+ /** Version is derived from the canonical template marker; never define it separately. */
10
+ export const AGENT_INSTRUCTION_TEMPLATE_VERSION = Number(versionMatch[1]);
package/dist/doctor.js ADDED
@@ -0,0 +1,527 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { AGENT_INSTRUCTION_BODY, AGENT_INSTRUCTION_TEMPLATE_VERSION, } from "./agent-instructions.js";
5
+ import { hookOutput } from "./enrich-store-event.js";
6
+ import { packageVersion } from "./version.js";
7
+ const HOOK_TEMPLATE_VERSION = 1;
8
+ const MANAGED_START = `<!-- groundtrack:start version=${AGENT_INSTRUCTION_TEMPLATE_VERSION} -->`;
9
+ const MANAGED_END = "<!-- groundtrack:end -->";
10
+ const CLAUDE_INSTRUCTION_BODY = `${MANAGED_START}
11
+ @AGENTS.md
12
+ ${MANAGED_END}`;
13
+ const UNMARKED_AGENT_INSTRUCTION_BODY = AGENT_INSTRUCTION_BODY.replace(MANAGED_START, "")
14
+ .replace(MANAGED_END, "")
15
+ .trim();
16
+ const CLAUDE_USER_MATCHER = "^mcp__.+__(store_event|search_experiences)$";
17
+ const LEGACY_AGENT_SIGNATURE = "Use Groundtrack as the shared engineering memory for this repository.";
18
+ function readText(path) {
19
+ return existsSync(path) ? readFileSync(path, "utf8") : null;
20
+ }
21
+ function writeAtomic(path, content) {
22
+ mkdirSync(dirname(path), { recursive: true });
23
+ if (existsSync(path) && lstatSync(path).isSymbolicLink()) {
24
+ throw new Error("refusing to replace a symbolic link");
25
+ }
26
+ const temporary = `${path}.groundtrack-${process.pid}.tmp`;
27
+ try {
28
+ const mode = existsSync(path) ? statSync(path).mode & 0o777 : 0o644;
29
+ writeFileSync(temporary, content, { mode });
30
+ renameSync(temporary, path);
31
+ }
32
+ catch (error) {
33
+ if (existsSync(temporary))
34
+ unlinkSync(temporary);
35
+ throw error;
36
+ }
37
+ }
38
+ function readJson(path) {
39
+ const raw = readText(path);
40
+ if (raw == null)
41
+ return {};
42
+ const parsed = JSON.parse(raw);
43
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
44
+ throw new Error(`${path} must contain a JSON object.`);
45
+ }
46
+ return parsed;
47
+ }
48
+ function escapeRegExp(value) {
49
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
50
+ }
51
+ function matcherForAliases(aliases) {
52
+ const escaped = aliases.map(escapeRegExp);
53
+ const server = escaped.length === 1 ? escaped[0] : `(?:${escaped.join("|")})`;
54
+ return `^mcp__${server}__(store_event|search_experiences)$`;
55
+ }
56
+ function groundtrackAliases(repoDir) {
57
+ const aliases = new Set();
58
+ for (const relative of [".mcp.json", ".cursor/mcp.json"]) {
59
+ const path = join(repoDir, relative);
60
+ try {
61
+ const config = readJson(path);
62
+ const servers = config.mcpServers;
63
+ if (!servers || typeof servers !== "object" || Array.isArray(servers))
64
+ continue;
65
+ for (const [name, value] of Object.entries(servers)) {
66
+ const serialized = JSON.stringify(value).toLowerCase();
67
+ if (name.toLowerCase().includes("groundtrack") || serialized.includes("groundtrack")) {
68
+ aliases.add(name);
69
+ }
70
+ }
71
+ }
72
+ catch {
73
+ // The owning adapter reports malformed config; alias discovery stays fail-open.
74
+ }
75
+ }
76
+ const codex = readText(join(repoDir, ".codex/config.toml")) ?? "";
77
+ const table = /^\[mcp_servers\.([^\]]+)\]([\s\S]*?)(?=^\[|(?![\s\S]))/gm;
78
+ for (const match of codex.matchAll(table)) {
79
+ if (match[1]?.toLowerCase().includes("groundtrack") || match[2]?.includes("groundtrack")) {
80
+ aliases.add(match[1]);
81
+ }
82
+ }
83
+ if (aliases.size === 0)
84
+ aliases.add("groundtrack");
85
+ return [...aliases].sort();
86
+ }
87
+ function detectClients(repoDir) {
88
+ const clients = [];
89
+ if (existsSync(join(repoDir, ".claude")) || existsSync(join(repoDir, ".mcp.json"))) {
90
+ clients.push("claude");
91
+ }
92
+ if (existsSync(join(repoDir, ".codex")))
93
+ clients.push("codex");
94
+ if (existsSync(join(repoDir, ".cursor")))
95
+ clients.push("cursor");
96
+ return clients.length > 0 ? clients : ["claude"];
97
+ }
98
+ function asRecord(value) {
99
+ return value && typeof value === "object" && !Array.isArray(value)
100
+ ? value
101
+ : null;
102
+ }
103
+ function commandOf(value) {
104
+ return typeof asRecord(value)?.command === "string" ? asRecord(value).command : null;
105
+ }
106
+ function claudeHookIsCurrent(config, matcher) {
107
+ const hooks = asRecord(config.hooks);
108
+ const preToolUse = hooks?.PreToolUse;
109
+ if (!Array.isArray(preToolUse))
110
+ return false;
111
+ return preToolUse.some((entry) => {
112
+ const record = asRecord(entry);
113
+ return (record?.matcher === matcher &&
114
+ Array.isArray(record.hooks) &&
115
+ record.hooks.some((hook) => commandOf(hook) === "groundtrack-hook claude"));
116
+ });
117
+ }
118
+ function upsertClaudeHook(config, matcher) {
119
+ const hooks = asRecord(config.hooks) ?? {};
120
+ const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
121
+ const retained = existing.filter((entry) => {
122
+ const record = asRecord(entry);
123
+ return !(Array.isArray(record?.hooks) &&
124
+ record.hooks.some((hook) => commandOf(hook)?.includes("groundtrack-hook")));
125
+ });
126
+ return {
127
+ ...config,
128
+ hooks: {
129
+ ...hooks,
130
+ PreToolUse: [
131
+ ...retained,
132
+ {
133
+ matcher,
134
+ hooks: [
135
+ {
136
+ type: "command",
137
+ command: "groundtrack-hook claude",
138
+ timeout: 5,
139
+ statusMessage: "Adding Groundtrack session metadata",
140
+ },
141
+ ],
142
+ },
143
+ ],
144
+ },
145
+ };
146
+ }
147
+ function cursorHookIsCurrent(config) {
148
+ const hooks = asRecord(config.hooks);
149
+ const preToolUse = hooks?.preToolUse;
150
+ return (Array.isArray(preToolUse) &&
151
+ preToolUse.some((entry) => {
152
+ const record = asRecord(entry);
153
+ return (record?.command === "groundtrack-hook" &&
154
+ record.matcher ===
155
+ "MCP:.*(store_event|search_experiences)|MCP:(store_event|search_experiences)");
156
+ }));
157
+ }
158
+ function upsertCursorHook(config) {
159
+ const hooks = asRecord(config.hooks) ?? {};
160
+ const existing = Array.isArray(hooks.preToolUse) ? hooks.preToolUse : [];
161
+ const retained = existing.filter((entry) => !commandOf(entry)?.includes("groundtrack-hook"));
162
+ return {
163
+ version: typeof config.version === "number" ? config.version : 1,
164
+ ...config,
165
+ hooks: {
166
+ ...hooks,
167
+ preToolUse: [
168
+ ...retained,
169
+ {
170
+ command: "groundtrack-hook",
171
+ matcher: "MCP:.*(store_event|search_experiences)|MCP:(store_event|search_experiences)",
172
+ },
173
+ ],
174
+ },
175
+ };
176
+ }
177
+ function codexBlock(matcher) {
178
+ return `# groundtrack:start version=${HOOK_TEMPLATE_VERSION}
179
+ [[hooks.PreToolUse]]
180
+ matcher = ${JSON.stringify(matcher)}
181
+
182
+ [[hooks.PreToolUse.hooks]]
183
+ type = "command"
184
+ command = "groundtrack-hook"
185
+ timeout = 5
186
+ statusMessage = "Adding Groundtrack session metadata"
187
+ # groundtrack:end`;
188
+ }
189
+ function codexHookIsCurrent(content, matcher) {
190
+ return (content.includes(`matcher = ${JSON.stringify(matcher)}`) &&
191
+ content.includes('command = "groundtrack-hook"'));
192
+ }
193
+ function upsertCodexHook(content, matcher) {
194
+ const managed = /(?:^|\n)# groundtrack:start version=\d+\n[\s\S]*?\n# groundtrack:end(?:\n|$)/;
195
+ const desired = codexBlock(matcher);
196
+ if (managed.test(content)) {
197
+ return `${content.replace(managed, `\n${desired}\n`).trimEnd()}\n`;
198
+ }
199
+ const lines = content.split("\n");
200
+ const ranges = [];
201
+ for (let index = 0; index < lines.length; index += 1) {
202
+ if (lines[index]?.trim() !== "[[hooks.PreToolUse]]")
203
+ continue;
204
+ let end = index + 1;
205
+ while (end < lines.length) {
206
+ const trimmed = lines[end].trim();
207
+ if (trimmed === "[[hooks.PreToolUse]]")
208
+ break;
209
+ if (/^\[[^[]/.test(trimmed))
210
+ break;
211
+ end += 1;
212
+ }
213
+ if (lines.slice(index, end).join("\n").includes("groundtrack-hook"))
214
+ ranges.push([index, end]);
215
+ index = end - 1;
216
+ }
217
+ for (const [start, end] of ranges.reverse())
218
+ lines.splice(start, end - start);
219
+ const base = lines.join("\n").trimEnd();
220
+ return `${base}${base ? "\n\n" : ""}${desired}\n`;
221
+ }
222
+ function managedContentIsCurrent(content, desired) {
223
+ if (!content)
224
+ return false;
225
+ const start = content.indexOf(MANAGED_START);
226
+ const end = content.indexOf(MANAGED_END, start);
227
+ return start >= 0 && end >= 0 && content.slice(start, end + MANAGED_END.length) === desired;
228
+ }
229
+ function claudeInstructionsAreCurrent(content) {
230
+ if (!content)
231
+ return false;
232
+ return (/^@AGENTS\.md$/m.test(content) ||
233
+ managedContentIsCurrent(content, AGENT_INSTRUCTION_BODY) ||
234
+ content.includes(UNMARKED_AGENT_INSTRUCTION_BODY));
235
+ }
236
+ function claudeUsesInlineGroundtrackInstructions(content) {
237
+ return content?.includes(LEGACY_AGENT_SIGNATURE) ?? false;
238
+ }
239
+ function removeLegacyAgentInstructions(content) {
240
+ const lines = content.split("\n");
241
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
242
+ const heading = /^(#{1,6})\s+Groundtrack\s*$/.exec(lines[index].trim());
243
+ if (!heading)
244
+ continue;
245
+ const level = heading[1].length;
246
+ let end = index + 1;
247
+ while (end < lines.length) {
248
+ const nextHeading = /^(#{1,6})\s+/.exec(lines[end].trim());
249
+ if (nextHeading && nextHeading[1].length <= level)
250
+ break;
251
+ end += 1;
252
+ }
253
+ if (!lines.slice(index, end).join("\n").includes(LEGACY_AGENT_SIGNATURE))
254
+ continue;
255
+ let start = index;
256
+ if (start > 0 && lines[start - 1].trim() === "")
257
+ start -= 1;
258
+ lines.splice(start, end - start);
259
+ index = start;
260
+ }
261
+ return lines.join("\n");
262
+ }
263
+ function upsertManagedContent(content, desired, migrateLegacyAgentInstructions = false) {
264
+ const existing = migrateLegacyAgentInstructions
265
+ ? removeLegacyAgentInstructions(content ?? "")
266
+ : (content ?? "");
267
+ const managed = /<!-- groundtrack:start version=\d+ -->[\s\S]*?<!-- groundtrack:end -->/;
268
+ if (managed.test(existing)) {
269
+ return `${existing.replace(managed, desired).trimEnd()}\n`;
270
+ }
271
+ const base = existing.trimEnd();
272
+ return `${base}${base ? "\n\n" : ""}${desired}\n`;
273
+ }
274
+ function versionParts(version) {
275
+ return version
276
+ .replace(/^v/, "")
277
+ .split("-")[0]
278
+ .split(".")
279
+ .map((part) => Number.parseInt(part, 10) || 0);
280
+ }
281
+ export function compareVersions(left, right) {
282
+ const a = versionParts(left);
283
+ const b = versionParts(right);
284
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
285
+ const difference = (a[index] ?? 0) - (b[index] ?? 0);
286
+ if (difference !== 0)
287
+ return Math.sign(difference);
288
+ }
289
+ return 0;
290
+ }
291
+ function installedHookVersion() {
292
+ try {
293
+ const result = spawnSync("groundtrack-hook", ["version", "--json"], {
294
+ encoding: "utf8",
295
+ timeout: 5_000,
296
+ });
297
+ if (result.status !== 0)
298
+ return null;
299
+ const parsed = JSON.parse(result.stdout);
300
+ return typeof parsed.version === "string" ? parsed.version : null;
301
+ }
302
+ catch {
303
+ return null;
304
+ }
305
+ }
306
+ function installHookPackage(version) {
307
+ try {
308
+ execFileSync("npm", ["install", "-g", `@groundtrack/hooks@${version}`], {
309
+ stdio: "inherit",
310
+ timeout: 120_000,
311
+ });
312
+ return true;
313
+ }
314
+ catch {
315
+ return false;
316
+ }
317
+ }
318
+ function addFileCheck(checks, changedFiles, options, input) {
319
+ if (input.current) {
320
+ checks.push({
321
+ id: input.id,
322
+ label: input.label,
323
+ status: "ok",
324
+ detail: "current",
325
+ file: input.path,
326
+ });
327
+ return;
328
+ }
329
+ if (!options.fix) {
330
+ checks.push({
331
+ id: input.id,
332
+ label: input.label,
333
+ status: "issue",
334
+ detail: "missing or outdated",
335
+ file: input.path,
336
+ });
337
+ return;
338
+ }
339
+ try {
340
+ writeAtomic(input.path, input.updated());
341
+ changedFiles.push(input.path);
342
+ checks.push({
343
+ id: input.id,
344
+ label: input.label,
345
+ status: "fixed",
346
+ detail: "updated",
347
+ file: input.path,
348
+ });
349
+ }
350
+ catch (error) {
351
+ checks.push({
352
+ id: input.id,
353
+ label: input.label,
354
+ status: "blocked",
355
+ detail: error instanceof Error ? error.message : "update failed",
356
+ file: input.path,
357
+ });
358
+ }
359
+ }
360
+ export function runDoctor(options) {
361
+ const version = packageVersion();
362
+ const clients = options.clients ?? detectClients(options.repoDir);
363
+ const aliases = groundtrackAliases(options.repoDir);
364
+ const matcher = matcherForAliases(aliases);
365
+ const checks = [];
366
+ const changedFiles = [];
367
+ if (options.latestVersion && compareVersions(options.latestVersion, version) > 0) {
368
+ checks.push({
369
+ id: "cli-version",
370
+ label: "Groundtrack package",
371
+ status: "issue",
372
+ detail: `installed ${version}; latest ${options.latestVersion}`,
373
+ });
374
+ }
375
+ else {
376
+ checks.push({
377
+ id: "cli-version",
378
+ label: "Groundtrack package",
379
+ status: "ok",
380
+ detail: options.latestVersion ? `${version} (latest)` : `${version} (offline bundled state)`,
381
+ });
382
+ }
383
+ let runtimeVersion = options.installedHookVersion;
384
+ if (runtimeVersion === undefined)
385
+ runtimeVersion = installedHookVersion();
386
+ if (runtimeVersion === version) {
387
+ checks.push({ id: "hook-version", label: "Hook runtime", status: "ok", detail: version });
388
+ }
389
+ else if (options.fix && !options.skipPackageInstall && installHookPackage(version)) {
390
+ checks.push({
391
+ id: "hook-version",
392
+ label: "Hook runtime",
393
+ status: "fixed",
394
+ detail: `installed ${version}`,
395
+ });
396
+ }
397
+ else {
398
+ checks.push({
399
+ id: "hook-version",
400
+ label: "Hook runtime",
401
+ status: "issue",
402
+ detail: runtimeVersion
403
+ ? `installed ${runtimeVersion}; expected ${version}`
404
+ : `not found; expected ${version}`,
405
+ });
406
+ }
407
+ if (clients.includes("claude")) {
408
+ for (const [id, label, path, desiredMatcher] of [
409
+ [
410
+ "claude-user-hook",
411
+ "Claude user hook",
412
+ join(options.homeDir, ".claude/settings.json"),
413
+ CLAUDE_USER_MATCHER,
414
+ ],
415
+ [
416
+ "claude-repo-hook",
417
+ "Claude repository hook",
418
+ join(options.repoDir, ".claude/settings.json"),
419
+ matcher,
420
+ ],
421
+ ]) {
422
+ try {
423
+ const config = readJson(path);
424
+ addFileCheck(checks, changedFiles, options, {
425
+ id,
426
+ label,
427
+ path,
428
+ current: claudeHookIsCurrent(config, desiredMatcher),
429
+ updated: () => `${JSON.stringify(upsertClaudeHook(config, desiredMatcher), null, 2)}\n`,
430
+ });
431
+ }
432
+ catch (error) {
433
+ checks.push({
434
+ id,
435
+ label,
436
+ status: "blocked",
437
+ detail: error instanceof Error ? error.message : "invalid JSON",
438
+ file: path,
439
+ });
440
+ }
441
+ }
442
+ }
443
+ if (clients.includes("codex")) {
444
+ const path = join(options.repoDir, ".codex/config.toml");
445
+ const content = readText(path) ?? "";
446
+ addFileCheck(checks, changedFiles, options, {
447
+ id: "codex-repo-hook",
448
+ label: "Codex repository hook",
449
+ path,
450
+ current: codexHookIsCurrent(content, matcher),
451
+ updated: () => upsertCodexHook(content, matcher),
452
+ });
453
+ }
454
+ if (clients.includes("cursor")) {
455
+ const path = join(options.repoDir, ".cursor/hooks.json");
456
+ try {
457
+ const config = readJson(path);
458
+ addFileCheck(checks, changedFiles, options, {
459
+ id: "cursor-repo-hook",
460
+ label: "Cursor repository hook",
461
+ path,
462
+ current: cursorHookIsCurrent(config),
463
+ updated: () => `${JSON.stringify(upsertCursorHook(config), null, 2)}\n`,
464
+ });
465
+ }
466
+ catch (error) {
467
+ checks.push({
468
+ id: "cursor-repo-hook",
469
+ label: "Cursor repository hook",
470
+ status: "blocked",
471
+ detail: error instanceof Error ? error.message : "invalid JSON",
472
+ file: path,
473
+ });
474
+ }
475
+ }
476
+ const instructionFiles = new Map([["AGENTS.md", AGENT_INSTRUCTION_BODY]]);
477
+ if (clients.includes("claude") || existsSync(join(options.repoDir, "CLAUDE.md"))) {
478
+ instructionFiles.set("CLAUDE.md", CLAUDE_INSTRUCTION_BODY);
479
+ }
480
+ for (const [name, desired] of instructionFiles) {
481
+ const path = join(options.repoDir, name);
482
+ const content = readText(path);
483
+ const isClaudeInstructions = name === "CLAUDE.md";
484
+ addFileCheck(checks, changedFiles, options, {
485
+ id: `instructions-${name.toLowerCase()}`,
486
+ label: `${name} instructions`,
487
+ path,
488
+ current: isClaudeInstructions
489
+ ? claudeInstructionsAreCurrent(content)
490
+ : managedContentIsCurrent(content, desired),
491
+ updated: () => {
492
+ if (isClaudeInstructions && claudeUsesInlineGroundtrackInstructions(content)) {
493
+ return upsertManagedContent(content, AGENT_INSTRUCTION_BODY, true);
494
+ }
495
+ return upsertManagedContent(content, desired, name === "AGENTS.md");
496
+ },
497
+ });
498
+ }
499
+ const simulation = hookOutput({
500
+ tool_name: `mcp__${aliases[0]}__store_event`,
501
+ cwd: options.repoDir,
502
+ session_id: "doctor-session",
503
+ model: "claude-sonnet-4-5",
504
+ tool_use_id: "doctor-tool",
505
+ tool_input: { title: "Groundtrack doctor simulation" },
506
+ }, "claude");
507
+ const updated = asRecord(asRecord(simulation.hookSpecificOutput)?.updatedInput);
508
+ const simulationHealthy = updated?.sessionId === "claude:doctor-session" &&
509
+ updated.modelId === "anthropic/claude-sonnet-4-5" &&
510
+ updated.clientEventId === "claude:doctor-session:doctor-tool";
511
+ checks.push({
512
+ id: "hook-simulation",
513
+ label: "Hook simulation",
514
+ status: simulationHealthy ? "ok" : "issue",
515
+ detail: simulationHealthy
516
+ ? "session, model, and invocation context enriched"
517
+ : "enrichment failed",
518
+ });
519
+ return {
520
+ version,
521
+ clients,
522
+ aliases,
523
+ checks,
524
+ changedFiles,
525
+ healthy: checks.every((check) => check.status === "ok" || check.status === "fixed"),
526
+ };
527
+ }
@@ -72,11 +72,20 @@ function transcriptModel(transcriptPath) {
72
72
  }
73
73
  return null;
74
74
  }
75
- function isGroundtrackStoreEvent(toolName) {
75
+ function groundtrackContextTool(toolName) {
76
76
  const normalized = nonEmptyString(toolName)?.toLowerCase();
77
- if (!normalized?.includes("store_event"))
78
- return false;
79
- return normalized.includes("groundtrack") || normalized === "mcp:store_event";
77
+ if (!normalized)
78
+ return null;
79
+ const isGroundtrack = normalized.includes("groundtrack") ||
80
+ normalized === "mcp:store_event" ||
81
+ normalized === "mcp:search_experiences";
82
+ if (!isGroundtrack)
83
+ return null;
84
+ if (normalized.includes("store_event"))
85
+ return "store_event";
86
+ if (normalized.includes("search_experiences"))
87
+ return "search_experiences";
88
+ return null;
80
89
  }
81
90
  function workspacePath(hookInput) {
82
91
  const cwd = nonEmptyString(hookInput.cwd);
@@ -111,7 +120,7 @@ function gitContext(cwd) {
111
120
  isDirty: status == null ? null : status.length > 0,
112
121
  };
113
122
  }
114
- export function enrichInput(hookInput, clientOverride) {
123
+ export function enrichInput(hookInput, clientOverride, tool = "store_event") {
115
124
  const toolInput = hookInput.tool_input &&
116
125
  typeof hookInput.tool_input === "object" &&
117
126
  !Array.isArray(hookInput.tool_input)
@@ -129,7 +138,7 @@ export function enrichInput(hookInput, clientOverride) {
129
138
  toolInput.sessionId = `${host}:${hostSessionId}`;
130
139
  if (hostModel)
131
140
  toolInput.modelId = hostModel;
132
- if (invocationId) {
141
+ if (tool === "store_event" && invocationId) {
133
142
  toolInput.clientEventId = [host, hostSessionId, invocationId].filter(Boolean).join(":");
134
143
  }
135
144
  if (context)
@@ -137,9 +146,10 @@ export function enrichInput(hookInput, clientOverride) {
137
146
  return toolInput;
138
147
  }
139
148
  export function hookOutput(hookInput, clientOverride) {
140
- if (!isGroundtrackStoreEvent(hookInput.tool_name))
149
+ const tool = groundtrackContextTool(hookInput.tool_name);
150
+ if (!tool)
141
151
  return {};
142
- const updatedInput = enrichInput(hookInput, clientOverride);
152
+ const updatedInput = enrichInput(hookInput, clientOverride, tool);
143
153
  if (nonEmptyString(hookInput.conversation_id)) {
144
154
  return { permission: "allow", updated_input: updatedInput };
145
155
  }
@@ -1,6 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { hookOutput } from "./enrich-store-event.js";
3
+ import { packageVersion } from "./version.js";
3
4
  async function main() {
5
+ if (process.argv[2] === "version" || process.argv[2] === "--version") {
6
+ const version = packageVersion();
7
+ process.stdout.write(process.argv.includes("--json") ? JSON.stringify({ version }) : version);
8
+ return;
9
+ }
4
10
  let raw = "";
5
11
  for await (const chunk of process.stdin)
6
12
  raw += chunk;
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from "node:readline/promises";
3
+ import { spawnSync } from "node:child_process";
4
+ import { homedir } from "node:os";
5
+ import { resolve } from "node:path";
6
+ import { stdin, stdout } from "node:process";
7
+ import { compareVersions, runDoctor } from "./doctor.js";
8
+ import { packageVersion } from "./version.js";
9
+ function usage() {
10
+ return `Groundtrack CLI
11
+
12
+ Usage:
13
+ groundtrack doctor [--fix] [--yes] [--json] [--repo PATH]
14
+ groundtrack version [--json]
15
+
16
+ Commands:
17
+ doctor Diagnose Groundtrack hooks and managed agent instructions
18
+ doctor --fix Repair deterministic configuration drift
19
+ version Print the installed CLI/runtime version
20
+ `;
21
+ }
22
+ function parseArguments(argv) {
23
+ if (argv[0] &&
24
+ !["doctor", "version", "--version"].includes(argv[0]) &&
25
+ !argv[0].startsWith("-")) {
26
+ throw new Error(`Unknown command: ${argv[0]}\n\n${usage()}`);
27
+ }
28
+ const command = argv[0] === "version" || argv[0] === "--version" ? "version" : "doctor";
29
+ let repoDir = process.cwd();
30
+ let homeDir = homedir();
31
+ for (let index = 0; index < argv.length; index += 1) {
32
+ if (argv[index] === "--repo" && argv[index + 1])
33
+ repoDir = resolve(argv[++index]);
34
+ if (argv[index] === "--home" && argv[index + 1])
35
+ homeDir = resolve(argv[++index]);
36
+ }
37
+ return {
38
+ command,
39
+ fix: argv.includes("--fix"),
40
+ yes: argv.includes("--yes") || argv.includes("-y"),
41
+ json: argv.includes("--json"),
42
+ repoDir,
43
+ homeDir,
44
+ };
45
+ }
46
+ function printResult(result) {
47
+ stdout.write(`Groundtrack doctor ${result.version}\n`);
48
+ stdout.write(`Clients: ${result.clients.join(", ")}\n`);
49
+ stdout.write(`MCP aliases: ${result.aliases.join(", ")}\n\n`);
50
+ for (const check of result.checks) {
51
+ const icon = check.status === "ok"
52
+ ? "✓"
53
+ : check.status === "fixed"
54
+ ? "◆"
55
+ : check.status === "blocked"
56
+ ? "×"
57
+ : "!";
58
+ stdout.write(`${icon} ${check.label}: ${check.detail}${check.file ? ` (${check.file})` : ""}\n`);
59
+ }
60
+ if (result.changedFiles.length > 0) {
61
+ stdout.write(`\nUpdated ${result.changedFiles.length} file(s).\n`);
62
+ }
63
+ stdout.write(result.healthy
64
+ ? "\nGroundtrack integration is healthy.\n"
65
+ : "\nGroundtrack integration needs attention.\n");
66
+ }
67
+ async function confirmFix() {
68
+ const prompt = createInterface({ input: stdin, output: stdout });
69
+ try {
70
+ const answer = await prompt.question("Apply deterministic Groundtrack repairs? [y/N] ");
71
+ return /^y(?:es)?$/i.test(answer.trim());
72
+ }
73
+ finally {
74
+ prompt.close();
75
+ }
76
+ }
77
+ async function latestPackageVersion() {
78
+ const override = process.env.GROUNDTRACK_DOCTOR_LATEST_VERSION;
79
+ if (override)
80
+ return override;
81
+ if (process.env.GROUNDTRACK_DOCTOR_OFFLINE === "1")
82
+ return null;
83
+ const controller = new AbortController();
84
+ const timeout = setTimeout(() => controller.abort(), 2_000);
85
+ try {
86
+ const response = await fetch("https://registry.npmjs.org/@groundtrack%2Fhooks/latest", {
87
+ signal: controller.signal,
88
+ headers: { accept: "application/json" },
89
+ });
90
+ if (!response.ok)
91
+ return null;
92
+ const payload = (await response.json());
93
+ return typeof payload.version === "string" ? payload.version : null;
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ finally {
99
+ clearTimeout(timeout);
100
+ }
101
+ }
102
+ function updatePackageAndRerun(version) {
103
+ const install = spawnSync("npm", ["install", "-g", `@groundtrack/hooks@${version}`], {
104
+ stdio: "inherit",
105
+ timeout: 120_000,
106
+ });
107
+ if (install.status !== 0)
108
+ return install.status ?? 1;
109
+ const rerun = spawnSync("groundtrack", process.argv.slice(2), {
110
+ stdio: "inherit",
111
+ env: {
112
+ ...process.env,
113
+ GROUNDTRACK_DOCTOR_REEXEC: "1",
114
+ GROUNDTRACK_DOCTOR_LATEST_VERSION: version,
115
+ },
116
+ });
117
+ return rerun.status ?? 1;
118
+ }
119
+ async function main() {
120
+ const args = parseArguments(process.argv.slice(2));
121
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
122
+ stdout.write(usage());
123
+ return;
124
+ }
125
+ if (args.command === "version") {
126
+ const version = packageVersion();
127
+ stdout.write(args.json ? `${JSON.stringify({ version })}\n` : `${version}\n`);
128
+ return;
129
+ }
130
+ const base = {
131
+ repoDir: args.repoDir,
132
+ homeDir: args.homeDir,
133
+ skipPackageInstall: process.env.GROUNDTRACK_DOCTOR_SKIP_INSTALL === "1",
134
+ latestVersion: await latestPackageVersion(),
135
+ };
136
+ const diagnosis = runDoctor(base);
137
+ if (!args.fix) {
138
+ if (args.json)
139
+ stdout.write(`${JSON.stringify(diagnosis, null, 2)}\n`);
140
+ else
141
+ printResult(diagnosis);
142
+ process.exitCode = diagnosis.healthy ? 0 : 1;
143
+ return;
144
+ }
145
+ if (diagnosis.healthy) {
146
+ if (args.json)
147
+ stdout.write(`${JSON.stringify(diagnosis, null, 2)}\n`);
148
+ else
149
+ printResult(diagnosis);
150
+ return;
151
+ }
152
+ if (!args.json)
153
+ printResult(diagnosis);
154
+ if (!args.yes && !(await confirmFix())) {
155
+ stdout.write("No changes made.\n");
156
+ process.exitCode = 1;
157
+ return;
158
+ }
159
+ if (base.latestVersion &&
160
+ compareVersions(base.latestVersion, diagnosis.version) > 0 &&
161
+ process.env.GROUNDTRACK_DOCTOR_REEXEC !== "1") {
162
+ process.exitCode = updatePackageAndRerun(base.latestVersion);
163
+ return;
164
+ }
165
+ const repaired = runDoctor({ ...base, fix: true });
166
+ if (args.json)
167
+ stdout.write(`${JSON.stringify(repaired, null, 2)}\n`);
168
+ else
169
+ printResult(repaired);
170
+ process.exitCode = repaired.healthy ? 0 : 1;
171
+ }
172
+ main().catch((error) => {
173
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
174
+ process.exitCode = 1;
175
+ });
@@ -0,0 +1,12 @@
1
+ import { readFileSync } from "node:fs";
2
+ let cachedVersion = null;
3
+ export function packageVersion() {
4
+ if (cachedVersion)
5
+ return cachedVersion;
6
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
7
+ if (typeof packageJson.version !== "string" || !packageJson.version.trim()) {
8
+ throw new Error("@groundtrack/hooks package version is unavailable.");
9
+ }
10
+ cachedVersion = packageJson.version.trim();
11
+ return cachedVersion;
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groundtrack/hooks",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Lifecycle hook utilities for Groundtrack MCP clients.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -12,10 +12,12 @@
12
12
  ],
13
13
  "license": "MIT",
14
14
  "bin": {
15
+ "groundtrack": "./dist/groundtrack.js",
15
16
  "groundtrack-hook": "./dist/groundtrack-hook.js"
16
17
  },
17
18
  "files": [
18
19
  "dist/",
20
+ "templates/",
19
21
  "README.md",
20
22
  "LICENSE"
21
23
  ],
@@ -28,12 +30,14 @@
28
30
  "lint": "oxlint .",
29
31
  "prepack": "pnpm run build",
30
32
  "release:patch": "node scripts/release-patch.mjs",
31
- "test": "pnpm run build && node --test test/*.test.mjs",
32
- "typecheck": "tsc -p tsconfig.json --noEmit"
33
+ "sync:agent-instructions": "node scripts/sync-agent-instructions.mjs",
34
+ "test": "pnpm run build && node --import tsx --test test/*.test.ts",
35
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json"
33
36
  },
34
37
  "devDependencies": {
35
38
  "@groundtrack/typescript": "workspace:*",
36
39
  "@types/node": "^25.9.1",
40
+ "tsx": "^4.20.5",
37
41
  "typescript": "~7.0.2"
38
42
  },
39
43
  "engines": {
@@ -0,0 +1,16 @@
1
+ <!-- groundtrack:start version=1 -->
2
+
3
+ ## Groundtrack
4
+
5
+ Use Groundtrack as the shared engineering memory for this repository.
6
+
7
+ - Before non-trivial investigation or implementation where prior decisions, failures, or constraints could matter, call `search_experiences`. Start with a focused description of the work, then inspect the results and search one or two materially different adjacent concepts when related components, constraints, failure modes, or prior decisions could change the approach. Do not stop merely because the first search has no exact match; try a meaningfully broader or neighboring query. Keep this expansion bounded, and also search before repeating an approach that may already have been tried.
8
+ - When a relevant experience needs more context or verification, call `get_experience_events` with its id to inspect the underlying raw evidence and provenance.
9
+ - Apply relevant current experiences and heed any caveats that an experience was contradicted, superseded, or expired.
10
+ - Once the effect of a search is known, call `report_experience_use` with its retrieval id. Report when a result changed the approach or avoided a failure, and report `not_helpful` when none applied.
11
+ - After a non-obvious discovery, decision, workaround, correction, or failed approach that would help a future agent, call `store_event` promptly with a concise title and an explanation of what happened and why it matters.
12
+ - Do not store routine implementation details, obvious facts, unrestricted transcripts, or secrets.
13
+ - Keep the raw-event ids returned by `store_event`. When the related work lands in a commit or pull request, call `link_outcomes` with those ids and the explicit commit SHA and/or pull-request number. Never infer an outcome from worktree HEAD.
14
+ - When recurring engineering friction may support or oppose an existing initiative, call `search_initiatives` before storing the event and link it to the matching initiative with the appropriate direction. Propose a new initiative only when explicitly recommending organizational change and no existing initiative matches.
15
+
16
+ <!-- groundtrack:end -->