@groundtrack/hooks 0.1.0 → 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 +43 -3
- package/dist/agent-instructions.js +10 -0
- package/dist/doctor.js +527 -0
- package/dist/enrich-store-event.js +114 -8
- package/dist/groundtrack-hook.js +6 -0
- package/dist/groundtrack.js +175 -0
- package/dist/version.js +12 -0
- package/package.json +8 -3
- package/templates/AGENTS.md +16 -0
package/README.md
CHANGED
|
@@ -9,9 +9,49 @@ Codex, and Cursor hook configurations:
|
|
|
9
9
|
npm install -g @groundtrack/hooks
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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:
|
|
45
|
+
|
|
46
|
+
- the client session and model identifiers (using Claude's host-provided
|
|
47
|
+
transcript path when its hook payload omits the model);
|
|
48
|
+
- for `store_event`, a stable client event id derived from the host's tool invocation id; and
|
|
49
|
+
- repository, branch, commit, and dirty-worktree context collected with Git.
|
|
50
|
+
|
|
51
|
+
The Git context is collected from the hook's working directory (or a single
|
|
52
|
+
Cursor workspace root) and overrides model-provided values. Collection fails
|
|
53
|
+
open when the hook is outside a Git worktree. The command does not send network
|
|
54
|
+
requests.
|
|
15
55
|
|
|
16
56
|
Update it with:
|
|
17
57
|
|
|
@@ -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
|
+
}
|
|
@@ -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,13 +20,107 @@ function normalizeModelId(value) {
|
|
|
18
20
|
const provider = PROVIDERS.find(([pattern]) => pattern.test(model))?.[1];
|
|
19
21
|
return provider ? `${provider}/${model}` : null;
|
|
20
22
|
}
|
|
21
|
-
function
|
|
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
|
+
}
|
|
75
|
+
function groundtrackContextTool(toolName) {
|
|
22
76
|
const normalized = nonEmptyString(toolName)?.toLowerCase();
|
|
23
|
-
if (!normalized
|
|
24
|
-
return
|
|
25
|
-
|
|
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;
|
|
26
89
|
}
|
|
27
|
-
|
|
90
|
+
function workspacePath(hookInput) {
|
|
91
|
+
const cwd = nonEmptyString(hookInput.cwd);
|
|
92
|
+
if (cwd)
|
|
93
|
+
return cwd;
|
|
94
|
+
if (!Array.isArray(hookInput.workspace_roots) || hookInput.workspace_roots.length !== 1) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return nonEmptyString(hookInput.workspace_roots[0]);
|
|
98
|
+
}
|
|
99
|
+
function git(cwd, args) {
|
|
100
|
+
try {
|
|
101
|
+
return execFileSync("git", ["-C", cwd, ...args], {
|
|
102
|
+
encoding: "utf8",
|
|
103
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
104
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
105
|
+
timeout: 2_000,
|
|
106
|
+
}).trim();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function gitContext(cwd) {
|
|
113
|
+
if (!cwd || git(cwd, ["rev-parse", "--is-inside-work-tree"]) !== "true")
|
|
114
|
+
return null;
|
|
115
|
+
const status = git(cwd, ["status", "--porcelain"]);
|
|
116
|
+
return {
|
|
117
|
+
repoKey: sanitizeRepoKey(git(cwd, ["remote", "get-url", "origin"])),
|
|
118
|
+
branch: nonEmptyString(git(cwd, ["branch", "--show-current"])),
|
|
119
|
+
commitSha: nonEmptyString(git(cwd, ["rev-parse", "HEAD"])),
|
|
120
|
+
isDirty: status == null ? null : status.length > 0,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export function enrichInput(hookInput, clientOverride, tool = "store_event") {
|
|
28
124
|
const toolInput = hookInput.tool_input &&
|
|
29
125
|
typeof hookInput.tool_input === "object" &&
|
|
30
126
|
!Array.isArray(hookInput.tool_input)
|
|
@@ -33,17 +129,27 @@ export function enrichInput(hookInput, clientOverride) {
|
|
|
33
129
|
const isCursor = nonEmptyString(hookInput.conversation_id) != null;
|
|
34
130
|
const host = nonEmptyString(clientOverride)?.toLowerCase() ?? (isCursor ? "cursor" : "codex");
|
|
35
131
|
const hostSessionId = nonEmptyString(isCursor ? hookInput.conversation_id : hookInput.session_id);
|
|
36
|
-
const hostModel = normalizeModelId(isCursor
|
|
132
|
+
const hostModel = normalizeModelId(isCursor
|
|
133
|
+
? (hookInput.model_id ?? hookInput.model)
|
|
134
|
+
: (hookInput.model ?? hookInput.model_id ?? transcriptModel(hookInput.transcript_path)));
|
|
135
|
+
const invocationId = nonEmptyString(isCursor ? hookInput.generation_id : hookInput.tool_use_id);
|
|
136
|
+
const context = gitContext(workspacePath(hookInput));
|
|
37
137
|
if (hostSessionId)
|
|
38
138
|
toolInput.sessionId = `${host}:${hostSessionId}`;
|
|
39
139
|
if (hostModel)
|
|
40
140
|
toolInput.modelId = hostModel;
|
|
141
|
+
if (tool === "store_event" && invocationId) {
|
|
142
|
+
toolInput.clientEventId = [host, hostSessionId, invocationId].filter(Boolean).join(":");
|
|
143
|
+
}
|
|
144
|
+
if (context)
|
|
145
|
+
Object.assign(toolInput, context);
|
|
41
146
|
return toolInput;
|
|
42
147
|
}
|
|
43
148
|
export function hookOutput(hookInput, clientOverride) {
|
|
44
|
-
|
|
149
|
+
const tool = groundtrackContextTool(hookInput.tool_name);
|
|
150
|
+
if (!tool)
|
|
45
151
|
return {};
|
|
46
|
-
const updatedInput = enrichInput(hookInput, clientOverride);
|
|
152
|
+
const updatedInput = enrichInput(hookInput, clientOverride, tool);
|
|
47
153
|
if (nonEmptyString(hookInput.conversation_id)) {
|
|
48
154
|
return { permission: "allow", updated_input: updatedInput };
|
|
49
155
|
}
|
package/dist/groundtrack-hook.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/version.js
ADDED
|
@@ -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.
|
|
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
|
],
|
|
@@ -27,12 +29,15 @@
|
|
|
27
29
|
"build": "tsc -p tsconfig.json",
|
|
28
30
|
"lint": "oxlint .",
|
|
29
31
|
"prepack": "pnpm run build",
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
+
"release:patch": "node scripts/release-patch.mjs",
|
|
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"
|
|
32
36
|
},
|
|
33
37
|
"devDependencies": {
|
|
34
38
|
"@groundtrack/typescript": "workspace:*",
|
|
35
39
|
"@types/node": "^25.9.1",
|
|
40
|
+
"tsx": "^4.20.5",
|
|
36
41
|
"typescript": "~7.0.2"
|
|
37
42
|
},
|
|
38
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 -->
|