@groundtrack/hooks 0.1.25 → 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,128 @@
1
+ import { join } from "node:path";
2
+ import { stateDir } from "../setup/agents.js";
3
+ import { readFile, writeAtomic } from "../setup/files.js";
4
+ export function watchStatePath(context, source) {
5
+ return join(stateDir(context), `watch-state-${source}.json`);
6
+ }
7
+ export function watchLockPath(context, source) {
8
+ return join(stateDir(context), `watch-${source}.lock`);
9
+ }
10
+ export function emptyWatchState() {
11
+ return { version: 1, files: {}, sessions: {} };
12
+ }
13
+ function isRecord(value) {
14
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
15
+ }
16
+ function isNullableString(value) {
17
+ return value === null || typeof value === "string";
18
+ }
19
+ function isSerializedMetadata(value) {
20
+ if (!isRecord(value))
21
+ return false;
22
+ return (typeof value.externalSessionId === "string" &&
23
+ isNullableString(value.timestamp) &&
24
+ isNullableString(value.cwd) &&
25
+ isNullableString(value.repositoryUrl) &&
26
+ isNullableString(value.repositoryBranch) &&
27
+ isNullableString(value.repositoryCommitSha) &&
28
+ isNullableString(value.originator) &&
29
+ typeof value.isSubagent === "boolean" &&
30
+ (value.historyBaseThreadId === undefined || isNullableString(value.historyBaseThreadId)) &&
31
+ (value.historyMode === undefined || isNullableString(value.historyMode)) &&
32
+ isNullableString(value.startedAt) &&
33
+ isNullableString(value.endedAt));
34
+ }
35
+ function isWatchFileState(value) {
36
+ if (!isRecord(value))
37
+ return false;
38
+ return (typeof value.size === "number" &&
39
+ Number.isFinite(value.size) &&
40
+ typeof value.mtimeMs === "number" &&
41
+ Number.isFinite(value.mtimeMs) &&
42
+ typeof value.sessionId === "string" &&
43
+ (value.status === "active" || value.status === "imported") &&
44
+ isSerializedMetadata(value.metadata) &&
45
+ (value.contentHash === undefined || typeof value.contentHash === "string") &&
46
+ (value.receiptKey === undefined || typeof value.receiptKey === "string") &&
47
+ (value.importedAt === undefined || typeof value.importedAt === "string"));
48
+ }
49
+ function isWatchSessionState(value) {
50
+ if (!isRecord(value))
51
+ return false;
52
+ return (Array.isArray(value.pages) &&
53
+ value.pages.every((page) => typeof page === "string") &&
54
+ typeof value.fingerprint === "string" &&
55
+ (value.status === "active" || value.status === "imported") &&
56
+ (value.contentHash === undefined || typeof value.contentHash === "string") &&
57
+ (value.receiptKey === undefined || typeof value.receiptKey === "string") &&
58
+ (value.importedAt === undefined || typeof value.importedAt === "string"));
59
+ }
60
+ export function serializeMetadata(metadata) {
61
+ return {
62
+ externalSessionId: metadata.externalSessionId,
63
+ timestamp: metadata.timestamp?.toISOString() ?? null,
64
+ cwd: metadata.cwd,
65
+ repositoryUrl: metadata.repositoryUrl,
66
+ repositoryBranch: metadata.repositoryBranch,
67
+ repositoryCommitSha: metadata.repositoryCommitSha,
68
+ originator: metadata.originator,
69
+ isSubagent: metadata.isSubagent,
70
+ historyBaseThreadId: metadata.historyBaseThreadId ?? null,
71
+ historyMode: metadata.historyMode ?? null,
72
+ startedAt: metadata.startedAt?.toISOString() ?? null,
73
+ endedAt: metadata.endedAt?.toISOString() ?? null,
74
+ };
75
+ }
76
+ function parseDate(value) {
77
+ if (value === null)
78
+ return null;
79
+ const date = new Date(value);
80
+ return Number.isNaN(date.getTime()) ? null : date;
81
+ }
82
+ export function deserializeMetadata(metadata) {
83
+ return {
84
+ externalSessionId: metadata.externalSessionId,
85
+ timestamp: parseDate(metadata.timestamp),
86
+ cwd: metadata.cwd,
87
+ repositoryUrl: metadata.repositoryUrl,
88
+ repositoryBranch: metadata.repositoryBranch,
89
+ repositoryCommitSha: metadata.repositoryCommitSha,
90
+ originator: metadata.originator,
91
+ isSubagent: metadata.isSubagent,
92
+ historyBaseThreadId: metadata.historyBaseThreadId ?? null,
93
+ historyMode: metadata.historyMode ?? null,
94
+ startedAt: parseDate(metadata.startedAt),
95
+ endedAt: parseDate(metadata.endedAt),
96
+ };
97
+ }
98
+ export function readWatchState(context, source) {
99
+ const path = watchStatePath(context, source);
100
+ const text = readFile(path);
101
+ if (text === null)
102
+ return emptyWatchState();
103
+ let value;
104
+ try {
105
+ value = JSON.parse(text);
106
+ }
107
+ catch {
108
+ throw new Error(`Groundtrack's watch state is not valid JSON: ${path}`);
109
+ }
110
+ if (!isRecord(value) || value.version !== 1 || !isRecord(value.files))
111
+ throw new Error(`Groundtrack's watch state is invalid: ${path}`);
112
+ const files = {};
113
+ for (const [filePath, file] of Object.entries(value.files)) {
114
+ if (isWatchFileState(file))
115
+ files[filePath] = file;
116
+ }
117
+ const sessions = {};
118
+ if (isRecord(value.sessions)) {
119
+ for (const [sessionId, session] of Object.entries(value.sessions)) {
120
+ if (isWatchSessionState(session))
121
+ sessions[sessionId] = session;
122
+ }
123
+ }
124
+ return { version: 1, files, sessions };
125
+ }
126
+ export function writeWatchState(context, source, state) {
127
+ writeAtomic(watchStatePath(context, source), `${JSON.stringify(state, null, 2)}\n`, 0o600);
128
+ }
@@ -0,0 +1,38 @@
1
+ import { type SetupEnvironment } from "../setup/agents.js";
2
+ import { type ImportArguments } from "./cli.js";
3
+ import type { ImportAgent } from "./types.js";
4
+ export declare const WATCH_USAGE = "Usage:\n groundtrack watch --once [--agent codex|claude] [--repo PATH | --all-repos] [--since DATE]\n [--include-subagents] [--include-unscoped]\n [--visibility private|organization] [--dry-run] [--yes] [--json]\n\nWatch options:\n --once Run one scan and exit (required for now)\n --agent VALUE codex or claude\n --repo PATH Watch sessions for this Git repository (default: current repository)\n --all-repos Watch sessions associated with any Git repository\n --include-unscoped With --all-repos, also watch sessions outside a Git repository\n --since DATE Only sessions at or after an ISO timestamp or local YYYY-MM-DD\n --include-subagents Include subagent, forked-agent, and guardian sessions\n --visibility VALUE private (default) or organization\n --api-key KEY API key; prefer GROUNDTRACK_IMPORT_KEY or GROUNDTRACK_API_KEY\n --receipt-scope VALUE Stable receipt scope from create_session_import_key\n --url URL Groundtrack app or API URL (default: https://app.groundtrack.dev)\n --dry-run Scan and plan without uploading or writing cursor state\n --yes, -y Upload completed sessions without prompting\n --json Emit exactly one machine-readable result object\n";
5
+ export type WatchArguments = ImportArguments & Readonly<{
6
+ once: boolean;
7
+ }>;
8
+ export declare function parseWatchArguments(argv: readonly string[]): WatchArguments;
9
+ export type WatchResult = Readonly<{
10
+ dryRun: boolean;
11
+ preview: boolean;
12
+ agent: ImportAgent;
13
+ scope: string;
14
+ scanned: number;
15
+ added: number;
16
+ changed: number;
17
+ removed: number;
18
+ cacheHits: number;
19
+ cacheMisses: number;
20
+ active: number;
21
+ complete: number;
22
+ alreadyImported: number;
23
+ uploaded: number;
24
+ duplicate: number;
25
+ failed: Array<{
26
+ path: string;
27
+ error: string;
28
+ }>;
29
+ skipped: Record<string, number>;
30
+ cursorPath: string;
31
+ sessionsUrl: string;
32
+ }>;
33
+ export type WatchDependencies = Readonly<{
34
+ fetch?: typeof globalThis.fetch;
35
+ now?: () => number;
36
+ }>;
37
+ export declare function runWatchOnce(args: ImportArguments, context: SetupEnvironment, dependencies?: WatchDependencies): Promise<WatchResult>;
38
+ export declare function runWatchCli(argv: readonly string[]): Promise<void>;
@@ -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.25",
3
+ "version": "0.1.27",
4
4
  "description": "Lifecycle hook utilities for Groundtrack MCP clients.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -116,6 +116,6 @@
116
116
  "typescript": "~7.0.2"
117
117
  },
118
118
  "engines": {
119
- "node": ">=18"
119
+ "node": ">=22.19"
120
120
  }
121
121
  }