@groundtrack/hooks 0.1.26 → 0.1.27

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.
@@ -0,0 +1,402 @@
1
+ import { createHash } from "node:crypto";
2
+ import { resolve } from "node:path";
3
+ import { environment } from "../setup/agents.js";
4
+ import { parseImportArguments } from "./cli.js";
5
+ import { groupCodexPages } from "./codex-pages.js";
6
+ import { discoverSessions, repositoryIdentity, statSessionFiles, } from "./discovery.js";
7
+ import { sessionImportFormat } from "./formats.js";
8
+ import { readReceipts } from "./receipts.js";
9
+ import { prepareSession, uploadPreparedSession } from "./sync.js";
10
+ import { acquireWatchLock } from "./watch-lock.js";
11
+ import { deserializeMetadata, readWatchState, serializeMetadata, watchStatePath, writeWatchState, } from "./watch-state.js";
12
+ export const WATCH_USAGE = `Usage:
13
+ groundtrack watch --once [--agent codex|claude] [--repo PATH | --all-repos] [--since DATE]
14
+ [--include-subagents] [--include-unscoped]
15
+ [--visibility private|organization] [--dry-run] [--yes] [--json]
16
+
17
+ Watch options:
18
+ --once Run one scan and exit (required for now)
19
+ --agent VALUE codex or claude
20
+ --repo PATH Watch sessions for this Git repository (default: current repository)
21
+ --all-repos Watch sessions associated with any Git repository
22
+ --include-unscoped With --all-repos, also watch sessions outside a Git repository
23
+ --since DATE Only sessions at or after an ISO timestamp or local YYYY-MM-DD
24
+ --include-subagents Include subagent, forked-agent, and guardian sessions
25
+ --visibility VALUE private (default) or organization
26
+ --api-key KEY API key; prefer GROUNDTRACK_IMPORT_KEY or GROUNDTRACK_API_KEY
27
+ --receipt-scope VALUE Stable receipt scope from create_session_import_key
28
+ --url URL Groundtrack app or API URL (default: https://app.groundtrack.dev)
29
+ --dry-run Scan and plan without uploading or writing cursor state
30
+ --yes, -y Upload completed sessions without prompting
31
+ --json Emit exactly one machine-readable result object
32
+ `;
33
+ export function parseWatchArguments(argv) {
34
+ const once = argv.includes("--once");
35
+ const args = parseImportArguments(argv.filter((arg) => arg !== "--once"));
36
+ if (args.help)
37
+ return { ...args, once: true };
38
+ if (!once)
39
+ throw new Error("The watch loop is not implemented yet. Use groundtrack watch --once.");
40
+ return { ...args, once: true };
41
+ }
42
+ function errorMessage(error) {
43
+ return error instanceof Error ? error.message : String(error);
44
+ }
45
+ function skippedCounts(skipped) {
46
+ const result = {};
47
+ for (const item of skipped)
48
+ result[item.reason] = (result[item.reason] ?? 0) + 1;
49
+ return result;
50
+ }
51
+ function fingerprintPages(pages, stats) {
52
+ const hash = createHash("sha256");
53
+ for (const path of [...pages].sort()) {
54
+ const stat = stats.get(path);
55
+ hash.update(`${path}\0${stat?.size ?? -1}\0${stat?.mtimeMs ?? -1}\n`);
56
+ }
57
+ return hash.digest("hex");
58
+ }
59
+ function diffWatchFiles(state, stats) {
60
+ let added = 0;
61
+ let changed = 0;
62
+ let removed = 0;
63
+ for (const [path, stat] of stats) {
64
+ const previous = state.files[path];
65
+ if (!previous)
66
+ added += 1;
67
+ else if (previous.size !== stat.size || previous.mtimeMs !== stat.mtimeMs)
68
+ changed += 1;
69
+ }
70
+ for (const path of Object.keys(state.files))
71
+ if (!stats.has(path))
72
+ removed += 1;
73
+ return { added, changed, removed };
74
+ }
75
+ function buildMetadataCache(state, stats) {
76
+ const cache = new Map();
77
+ for (const [path, stat] of stats) {
78
+ const previous = state.files[path];
79
+ if (!previous || previous.size !== stat.size || previous.mtimeMs !== stat.mtimeMs)
80
+ continue;
81
+ cache.set(path, deserializeMetadata(previous.metadata));
82
+ }
83
+ return cache;
84
+ }
85
+ function updateWatchStateFromScan(input) {
86
+ const next = {};
87
+ for (const [path, stat] of input.stats) {
88
+ const metadata = input.metadataByPath.get(path) ?? input.cache.get(path);
89
+ if (!metadata)
90
+ continue;
91
+ const previous = input.state.files[path];
92
+ const unchanged = previous && previous.size === stat.size && previous.mtimeMs === stat.mtimeMs;
93
+ next[path] = {
94
+ size: stat.size,
95
+ mtimeMs: stat.mtimeMs,
96
+ sessionId: `${input.format.source}:${metadata.externalSessionId}`,
97
+ metadata: serializeMetadata(metadata),
98
+ status: unchanged ? previous.status : "active",
99
+ ...(unchanged
100
+ ? {
101
+ ...(previous.contentHash ? { contentHash: previous.contentHash } : {}),
102
+ ...(previous.receiptKey ? { receiptKey: previous.receiptKey } : {}),
103
+ ...(previous.importedAt ? { importedAt: previous.importedAt } : {}),
104
+ }
105
+ : {}),
106
+ };
107
+ }
108
+ input.state.files = next;
109
+ }
110
+ function markImported(state, prepared, receipts) {
111
+ const file = state.files[prepared.candidate.path];
112
+ if (!file || !prepared.receiptKey)
113
+ return;
114
+ file.status = "imported";
115
+ file.contentHash = prepared.sourceContentHash;
116
+ file.receiptKey = prepared.receiptKey;
117
+ file.importedAt = receipts.receipts[prepared.receiptKey]?.importedAt ?? new Date().toISOString();
118
+ }
119
+ function markSessionImported(state, pending, receipts) {
120
+ const session = state.sessions[pending.sessionKey];
121
+ if (!session || !pending.prepared.receiptKey)
122
+ return;
123
+ session.status = "imported";
124
+ session.contentHash = pending.prepared.sourceContentHash;
125
+ session.receiptKey = pending.prepared.receiptKey;
126
+ session.importedAt =
127
+ receipts.receipts[pending.prepared.receiptKey]?.importedAt ?? new Date().toISOString();
128
+ }
129
+ export async function runWatchOnce(args, context, dependencies = {}) {
130
+ const agent = args.agent;
131
+ if (!agent)
132
+ throw new Error("Choose an agent with --agent. Watch is non-interactive.");
133
+ const format = sessionImportFormat(agent);
134
+ if (format.discovery !== "files")
135
+ throw new Error("watch --once currently supports file-backed sessions (Codex and Claude Code).");
136
+ const home = resolve(context.env[format.homeEnvironmentVariable] ||
137
+ resolve(context.homeDir, format.defaultHomeDirectory));
138
+ const repository = args.allRepositories ? undefined : repositoryIdentity(context.repoDir);
139
+ const apiKey = args.apiKey ?? context.env.GROUNDTRACK_IMPORT_KEY ?? context.env.GROUNDTRACK_API_KEY ?? null;
140
+ if (args.yes && !args.dryRun && !apiKey)
141
+ throw new Error("Set GROUNDTRACK_IMPORT_KEY or GROUNDTRACK_API_KEY before uploading.");
142
+ const providedReceiptScope = args.receiptScope ?? context.env.GROUNDTRACK_IMPORT_RECEIPT_SCOPE;
143
+ if (providedReceiptScope && !/^[a-f0-9]{64}$/.test(providedReceiptScope))
144
+ throw new Error("The import receipt scope must be a 64-character lowercase hex value.");
145
+ const stableReceiptScope = apiKey?.startsWith("gti_") ? providedReceiptScope : undefined;
146
+ const receipts = apiKey ? readReceipts(context) : { version: 1, receipts: {} };
147
+ const state = readWatchState(context, format.source);
148
+ const stats = statSessionFiles(format, home);
149
+ const diff = diffWatchFiles(state, stats);
150
+ const cache = buildMetadataCache(state, stats);
151
+ const nowMs = dependencies.now?.() ?? Date.now();
152
+ const discovery = await discoverSessions({
153
+ home,
154
+ format,
155
+ repository,
156
+ allRepositories: args.allRepositories,
157
+ includeUnscoped: args.includeUnscoped,
158
+ includeSubagents: args.includeSubagents,
159
+ since: args.since,
160
+ nowMs,
161
+ metadataCache: cache,
162
+ userHome: context.homeDir,
163
+ env: context.env,
164
+ platform: context.platform,
165
+ workingDirectory: context.repoDir,
166
+ });
167
+ const liveSessionIds = new Set();
168
+ if (format.agent === "codex") {
169
+ for (const item of discovery.skipped) {
170
+ if (item.reason !== "live")
171
+ continue;
172
+ const metadata = discovery.metadataByPath.get(item.path);
173
+ if (metadata && !metadata.isSubagent)
174
+ liveSessionIds.add(metadata.externalSessionId);
175
+ }
176
+ }
177
+ const grouped = format.agent === "codex"
178
+ ? await groupCodexPages({
179
+ candidates: discovery.included.filter((candidate) => !liveSessionIds.has(candidate.metadata.externalSessionId)),
180
+ metadataByPath: discovery.metadataByPath,
181
+ })
182
+ : {
183
+ candidates: discovery.included.map((candidate) => ({
184
+ candidate,
185
+ pages: [candidate.path],
186
+ merged: false,
187
+ })),
188
+ failed: [],
189
+ cleanup: () => { },
190
+ };
191
+ try {
192
+ const useSessionState = format.agent === "codex";
193
+ const failures = [
194
+ ...discovery.failed,
195
+ ...grouped.failed,
196
+ ];
197
+ const prepared = [];
198
+ const alreadyPrepared = [];
199
+ const skipped = skippedCounts(discovery.skipped);
200
+ let alreadyImported = 0;
201
+ for (const group of grouped.candidates) {
202
+ const candidate = group.candidate;
203
+ const sessionKey = `${format.source}:${candidate.metadata.externalSessionId}`;
204
+ const fingerprint = fingerprintPages(group.pages, stats);
205
+ if (useSessionState) {
206
+ const session = state.sessions[sessionKey];
207
+ if (session?.fingerprint === fingerprint &&
208
+ session.receiptKey &&
209
+ receipts.receipts[session.receiptKey]) {
210
+ session.status = "imported";
211
+ session.importedAt = receipts.receipts[session.receiptKey].importedAt;
212
+ alreadyImported += 1;
213
+ continue;
214
+ }
215
+ }
216
+ else {
217
+ const previous = state.files[candidate.path];
218
+ const current = stats.get(candidate.path);
219
+ const unchanged = Boolean(previous &&
220
+ current &&
221
+ previous.size === current.size &&
222
+ previous.mtimeMs === current.mtimeMs);
223
+ if (unchanged && previous.receiptKey && receipts.receipts[previous.receiptKey]) {
224
+ previous.status = "imported";
225
+ previous.importedAt = receipts.receipts[previous.receiptKey].importedAt;
226
+ alreadyImported += 1;
227
+ continue;
228
+ }
229
+ }
230
+ try {
231
+ const item = await prepareSession({
232
+ candidate,
233
+ format,
234
+ apiBaseUrl: args.apiBaseUrl,
235
+ apiKey,
236
+ receiptScope: stableReceiptScope,
237
+ visibility: args.visibility,
238
+ receipts,
239
+ });
240
+ const pending = {
241
+ prepared: item,
242
+ sessionKey,
243
+ fingerprint,
244
+ pages: group.pages,
245
+ };
246
+ if (item.alreadyImported) {
247
+ alreadyImported += 1;
248
+ alreadyPrepared.push(pending);
249
+ }
250
+ else
251
+ prepared.push(pending);
252
+ }
253
+ catch (error) {
254
+ failures.push({ path: candidate.path, error: errorMessage(error) });
255
+ }
256
+ }
257
+ updateWatchStateFromScan({
258
+ state,
259
+ format,
260
+ stats,
261
+ metadataByPath: discovery.metadataByPath,
262
+ cache,
263
+ });
264
+ if (useSessionState) {
265
+ for (const group of grouped.candidates) {
266
+ const sessionKey = `${format.source}:${group.candidate.metadata.externalSessionId}`;
267
+ const fingerprint = fingerprintPages(group.pages, stats);
268
+ const previous = state.sessions[sessionKey];
269
+ const unchanged = previous?.fingerprint === fingerprint;
270
+ state.sessions[sessionKey] = {
271
+ pages: [...group.pages],
272
+ fingerprint,
273
+ status: unchanged ? previous.status : "active",
274
+ ...(unchanged
275
+ ? {
276
+ ...(previous.contentHash ? { contentHash: previous.contentHash } : {}),
277
+ ...(previous.receiptKey ? { receiptKey: previous.receiptKey } : {}),
278
+ ...(previous.importedAt ? { importedAt: previous.importedAt } : {}),
279
+ }
280
+ : {}),
281
+ };
282
+ }
283
+ }
284
+ for (const pending of alreadyPrepared) {
285
+ if (useSessionState)
286
+ markSessionImported(state, pending, receipts);
287
+ else
288
+ markImported(state, pending.prepared, receipts);
289
+ }
290
+ const scope = args.allRepositories ? "all repositories" : repository.root;
291
+ const result = (uploaded, duplicate) => ({
292
+ dryRun: args.dryRun,
293
+ preview: !args.yes,
294
+ agent,
295
+ scope,
296
+ scanned: stats.size,
297
+ added: diff.added,
298
+ changed: diff.changed,
299
+ removed: diff.removed,
300
+ cacheHits: discovery.cacheHits,
301
+ cacheMisses: discovery.cacheMisses,
302
+ active: discovery.skipped.filter((item) => item.reason === "live").length,
303
+ complete: prepared.length,
304
+ alreadyImported,
305
+ uploaded,
306
+ duplicate,
307
+ failed: failures,
308
+ skipped,
309
+ cursorPath: watchStatePath(context, format.source),
310
+ sessionsUrl: args.apiBaseUrl.replace(/\/api$/, "/sessions"),
311
+ });
312
+ if (args.dryRun || !args.yes)
313
+ return result(0, 0);
314
+ if (!apiKey)
315
+ throw new Error("Set GROUNDTRACK_IMPORT_KEY or GROUNDTRACK_API_KEY before uploading.");
316
+ const uploadKey = apiKey;
317
+ let uploaded = 0;
318
+ let duplicate = 0;
319
+ for (let offset = 0; offset < prepared.length; offset += 2) {
320
+ await Promise.all(prepared.slice(offset, offset + 2).map(async (pending) => {
321
+ try {
322
+ const upload = await uploadPreparedSession({
323
+ prepared: pending.prepared,
324
+ format,
325
+ apiBaseUrl: args.apiBaseUrl,
326
+ apiKey: uploadKey,
327
+ visibility: args.visibility,
328
+ context,
329
+ receipts,
330
+ fetch: dependencies.fetch,
331
+ });
332
+ if (upload.duplicate)
333
+ duplicate += 1;
334
+ else
335
+ uploaded += 1;
336
+ if (useSessionState)
337
+ markSessionImported(state, pending, receipts);
338
+ else
339
+ markImported(state, pending.prepared, receipts);
340
+ writeWatchState(context, format.source, state);
341
+ }
342
+ catch (error) {
343
+ failures.push({
344
+ path: pending.prepared.candidate.path,
345
+ error: errorMessage(error),
346
+ });
347
+ }
348
+ }));
349
+ }
350
+ writeWatchState(context, format.source, state);
351
+ return result(uploaded, duplicate);
352
+ }
353
+ finally {
354
+ grouped.cleanup();
355
+ discovery.cleanup?.();
356
+ }
357
+ }
358
+ function printWatchResult(result, input) {
359
+ const skipped = Object.entries(result.skipped)
360
+ .map(([reason, count]) => `${reason} ${count}`)
361
+ .join(" · ");
362
+ const lines = [
363
+ `\nGroundtrack · watch ${result.agent} (once)`,
364
+ `Scope: ${result.scope}`,
365
+ `Scanned: ${result.scanned} files (${result.added} new, ${result.changed} changed, ${result.removed} removed)`,
366
+ `Active: ${result.active} · complete: ${result.complete} · already imported: ${result.alreadyImported}`,
367
+ `Uploaded: ${result.uploaded} (duplicates ${result.duplicate}) · failed: ${result.failed.length}`,
368
+ ...(skipped ? [`Skipped: ${skipped}`] : []),
369
+ `Cursor: ${result.cursorPath}`,
370
+ ...result.failed.slice(0, 5).map((item) => ` ! ${item.path}: ${item.error}`),
371
+ ];
372
+ if (result.dryRun)
373
+ lines.push("Dry run: no uploads, no cursor writes.");
374
+ else if (!input.yes)
375
+ lines.push("Preview only. Run with --yes to upload.");
376
+ lines.push("");
377
+ process.stdout.write(lines.join("\n"));
378
+ }
379
+ export async function runWatchCli(argv) {
380
+ const args = parseWatchArguments(argv);
381
+ if (args.help) {
382
+ process.stdout.write(WATCH_USAGE);
383
+ return;
384
+ }
385
+ if (!args.agent)
386
+ throw new Error("Choose an agent with --agent. Watch is non-interactive.");
387
+ const context = environment({ repoDir: args.repoDir, homeDir: args.homeDir });
388
+ const format = sessionImportFormat(args.agent);
389
+ const lock = args.yes && !args.dryRun ? acquireWatchLock(context, format.source) : null;
390
+ try {
391
+ const result = await runWatchOnce(args, context);
392
+ if (args.json)
393
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
394
+ else
395
+ printWatchResult(result, { yes: args.yes });
396
+ if (result.failed.length)
397
+ process.exitCode = 1;
398
+ }
399
+ finally {
400
+ lock?.release();
401
+ }
402
+ }
package/dist/setup/cli.js CHANGED
@@ -18,6 +18,7 @@ Usage:
18
18
  groundtrack status Check selected agents
19
19
  groundtrack doctor [--fix] Check or repair using the same flow
20
20
  groundtrack import [options] Import local coding-agent sessions
21
+ groundtrack watch --once [options] Sync completed sessions once
21
22
  groundtrack version
22
23
 
23
24
  Options:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groundtrack/hooks",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "Lifecycle hook utilities for Groundtrack MCP clients.",
5
5
  "keywords": [
6
6
  "claude-code",