@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.
- package/README.md +25 -0
- package/dist/groundtrack.js +6 -1
- package/dist/session-import/canonical.js +1 -1
- package/dist/session-import/cli.d.ts +1 -2
- package/dist/session-import/cli.js +37 -45
- package/dist/session-import/codex-pages.d.ts +22 -0
- package/dist/session-import/codex-pages.js +184 -0
- package/dist/session-import/codex.js +3 -0
- package/dist/session-import/compression.d.ts +8 -0
- package/dist/session-import/compression.js +16 -0
- package/dist/session-import/discovery.d.ts +20 -3
- package/dist/session-import/discovery.js +114 -39
- package/dist/session-import/prune.js +30 -12
- package/dist/session-import/receipts.d.ts +1 -1
- package/dist/session-import/sync.d.ts +32 -0
- package/dist/session-import/sync.js +55 -0
- package/dist/session-import/types.d.ts +3 -0
- package/dist/session-import/watch-lock.d.ts +17 -0
- package/dist/session-import/watch-lock.js +105 -0
- package/dist/session-import/watch-state.d.ts +47 -0
- package/dist/session-import/watch-state.js +128 -0
- package/dist/session-import/watch.d.ts +38 -0
- package/dist/session-import/watch.js +402 -0
- package/dist/setup/cli.js +1 -0
- package/package.json +2 -2
|
@@ -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.
|
|
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": ">=
|
|
119
|
+
"node": ">=22.19"
|
|
120
120
|
}
|
|
121
121
|
}
|