hillclimb 0.5.0 → 0.5.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/dist/cli.js +634 -260
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import fs19 from "fs";
|
|
5
|
-
import
|
|
5
|
+
import path24 from "path";
|
|
6
6
|
import * as p6 from "@clack/prompts";
|
|
7
7
|
|
|
8
8
|
// src/commands/init.ts
|
|
9
|
+
import path7 from "path";
|
|
9
10
|
import * as p3 from "@clack/prompts";
|
|
10
11
|
|
|
11
12
|
// src/color.ts
|
|
@@ -124,67 +125,18 @@ function detectRepoRoot() {
|
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
// src/
|
|
128
|
+
// src/identity.ts
|
|
128
129
|
import fs2 from "fs";
|
|
129
130
|
import path3 from "path";
|
|
130
|
-
function toGitignorePattern(repoRoot, file) {
|
|
131
|
-
const rel = path3.relative(repoRoot, file);
|
|
132
|
-
if (!rel || rel.startsWith("..") || path3.isAbsolute(rel)) return null;
|
|
133
|
-
return `/${rel.split(path3.sep).join("/")}`;
|
|
134
|
-
}
|
|
135
|
-
function normalizePattern(pattern) {
|
|
136
|
-
return pattern.trim().replace(/\\/g, "/").replace(/^\/+/, "");
|
|
137
|
-
}
|
|
138
|
-
async function ensureGitignored(repoRoot, files) {
|
|
139
|
-
const gitignorePath = path3.join(repoRoot, ".gitignore");
|
|
140
|
-
const patterns = [
|
|
141
|
-
...new Set(
|
|
142
|
-
files.map((file) => toGitignorePattern(repoRoot, file)).filter((pattern) => pattern !== null)
|
|
143
|
-
)
|
|
144
|
-
];
|
|
145
|
-
let content = "";
|
|
146
|
-
let created = false;
|
|
147
|
-
try {
|
|
148
|
-
content = await fs2.promises.readFile(gitignorePath, "utf-8");
|
|
149
|
-
} catch (err) {
|
|
150
|
-
if (err.code !== "ENOENT") throw err;
|
|
151
|
-
created = true;
|
|
152
|
-
}
|
|
153
|
-
const existing = new Set(
|
|
154
|
-
content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith("#")).map(normalizePattern)
|
|
155
|
-
);
|
|
156
|
-
const added = patterns.filter(
|
|
157
|
-
(pattern) => !existing.has(normalizePattern(pattern))
|
|
158
|
-
);
|
|
159
|
-
if (added.length === 0) {
|
|
160
|
-
return { path: gitignorePath, added: [], created: false, changed: false };
|
|
161
|
-
}
|
|
162
|
-
let next = content;
|
|
163
|
-
if (next && !next.endsWith("\n")) next += "\n";
|
|
164
|
-
if (next) next += "\n";
|
|
165
|
-
next += ["# Hillclimb hook files", ...added].join("\n");
|
|
166
|
-
next += "\n";
|
|
167
|
-
await fs2.promises.writeFile(gitignorePath, next);
|
|
168
|
-
return {
|
|
169
|
-
path: gitignorePath,
|
|
170
|
-
added,
|
|
171
|
-
created,
|
|
172
|
-
changed: true
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// src/identity.ts
|
|
177
|
-
import fs3 from "fs";
|
|
178
|
-
import path4 from "path";
|
|
179
131
|
function identityPath() {
|
|
180
|
-
return
|
|
132
|
+
return path3.join(configDir(), "identity.json");
|
|
181
133
|
}
|
|
182
134
|
function normalizeUrl(apiBaseUrl) {
|
|
183
135
|
return apiBaseUrl.replace(/\/$/, "");
|
|
184
136
|
}
|
|
185
137
|
async function loadAllIdentities() {
|
|
186
138
|
try {
|
|
187
|
-
const raw = await
|
|
139
|
+
const raw = await fs2.promises.readFile(identityPath(), "utf-8");
|
|
188
140
|
const parsed = JSON.parse(raw);
|
|
189
141
|
if (!parsed.identities || typeof parsed.identities !== "object") {
|
|
190
142
|
return { identities: {} };
|
|
@@ -199,12 +151,12 @@ async function loadIdentity(apiBaseUrl) {
|
|
|
199
151
|
return file.identities[normalizeUrl(apiBaseUrl)] ?? null;
|
|
200
152
|
}
|
|
201
153
|
async function writeIdentityFile(file) {
|
|
202
|
-
await
|
|
154
|
+
await fs2.promises.mkdir(configDir(), { recursive: true, mode: 448 });
|
|
203
155
|
const tmp = `${identityPath()}.tmp`;
|
|
204
|
-
await
|
|
156
|
+
await fs2.promises.writeFile(tmp, JSON.stringify(file, null, 2), {
|
|
205
157
|
mode: 384
|
|
206
158
|
});
|
|
207
|
-
await
|
|
159
|
+
await fs2.promises.rename(tmp, identityPath());
|
|
208
160
|
}
|
|
209
161
|
async function saveIdentity(identity) {
|
|
210
162
|
const file = await loadAllIdentities();
|
|
@@ -242,8 +194,8 @@ function formatError(err) {
|
|
|
242
194
|
}
|
|
243
195
|
|
|
244
196
|
// src/platform/log.ts
|
|
245
|
-
import
|
|
246
|
-
import
|
|
197
|
+
import fs3 from "fs";
|
|
198
|
+
import path4 from "path";
|
|
247
199
|
var PT_TIME_ZONE = "America/Los_Angeles";
|
|
248
200
|
function pacificParts(date) {
|
|
249
201
|
const parts = new Intl.DateTimeFormat("en-US", {
|
|
@@ -294,10 +246,10 @@ function pacificDateString(date) {
|
|
|
294
246
|
return `${p7.year}-${p7.month}-${p7.day}`;
|
|
295
247
|
}
|
|
296
248
|
function logsDir() {
|
|
297
|
-
return
|
|
249
|
+
return path4.join(configDir(), "logs");
|
|
298
250
|
}
|
|
299
251
|
function todayLogPath() {
|
|
300
|
-
return
|
|
252
|
+
return path4.join(logsDir(), `${pacificDateString(/* @__PURE__ */ new Date())}.log`);
|
|
301
253
|
}
|
|
302
254
|
var logPrefix = "";
|
|
303
255
|
function setLogPrefix(prefix) {
|
|
@@ -308,8 +260,8 @@ function appendLog(level, message) {
|
|
|
308
260
|
const line = `[${pacificTimestamp(/* @__PURE__ */ new Date())}] [${level}]${tag} ${message}
|
|
309
261
|
`;
|
|
310
262
|
try {
|
|
311
|
-
|
|
312
|
-
|
|
263
|
+
fs3.mkdirSync(logsDir(), { recursive: true, mode: 448 });
|
|
264
|
+
fs3.appendFileSync(todayLogPath(), line);
|
|
313
265
|
} catch {
|
|
314
266
|
process.stderr.write(`hillclimb:${tag} ${level}: ${message}
|
|
315
267
|
`);
|
|
@@ -597,9 +549,61 @@ var PlatformClient = class {
|
|
|
597
549
|
};
|
|
598
550
|
|
|
599
551
|
// src/platform/hooks.ts
|
|
552
|
+
import { execFileSync } from "child_process";
|
|
600
553
|
import fs5 from "fs";
|
|
601
554
|
import os2 from "os";
|
|
602
555
|
import path6 from "path";
|
|
556
|
+
|
|
557
|
+
// src/gitignore.ts
|
|
558
|
+
import fs4 from "fs";
|
|
559
|
+
import path5 from "path";
|
|
560
|
+
function toGitignorePattern(repoRoot, file) {
|
|
561
|
+
const rel = path5.relative(repoRoot, file);
|
|
562
|
+
if (!rel || rel.startsWith("..") || path5.isAbsolute(rel)) return null;
|
|
563
|
+
return `/${rel.split(path5.sep).join("/")}`;
|
|
564
|
+
}
|
|
565
|
+
function normalizePattern(pattern) {
|
|
566
|
+
return pattern.trim().replace(/\\/g, "/").replace(/^\/+/, "");
|
|
567
|
+
}
|
|
568
|
+
async function ensureGitignored(repoRoot, files) {
|
|
569
|
+
const gitignorePath = path5.join(repoRoot, ".gitignore");
|
|
570
|
+
const patterns = [
|
|
571
|
+
...new Set(
|
|
572
|
+
files.map((file) => toGitignorePattern(repoRoot, file)).filter((pattern) => pattern !== null)
|
|
573
|
+
)
|
|
574
|
+
];
|
|
575
|
+
let content = "";
|
|
576
|
+
let created = false;
|
|
577
|
+
try {
|
|
578
|
+
content = await fs4.promises.readFile(gitignorePath, "utf-8");
|
|
579
|
+
} catch (err) {
|
|
580
|
+
if (err.code !== "ENOENT") throw err;
|
|
581
|
+
created = true;
|
|
582
|
+
}
|
|
583
|
+
const existing = new Set(
|
|
584
|
+
content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith("#")).map(normalizePattern)
|
|
585
|
+
);
|
|
586
|
+
const added = patterns.filter(
|
|
587
|
+
(pattern) => !existing.has(normalizePattern(pattern))
|
|
588
|
+
);
|
|
589
|
+
if (added.length === 0) {
|
|
590
|
+
return { path: gitignorePath, added: [], created: false, changed: false };
|
|
591
|
+
}
|
|
592
|
+
let next = content;
|
|
593
|
+
if (next && !next.endsWith("\n")) next += "\n";
|
|
594
|
+
if (next) next += "\n";
|
|
595
|
+
next += ["# Hillclimb hook files", ...added].join("\n");
|
|
596
|
+
next += "\n";
|
|
597
|
+
await fs4.promises.writeFile(gitignorePath, next);
|
|
598
|
+
return {
|
|
599
|
+
path: gitignorePath,
|
|
600
|
+
added,
|
|
601
|
+
created,
|
|
602
|
+
changed: true
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// src/platform/hooks.ts
|
|
603
607
|
var HOOK_CMD = (sub) => `npx hillclimb@latest ${sub}`;
|
|
604
608
|
var GIT_TRACES_CMD = (tool) => `npx hillclimb@latest git-traces --tool=${tool}`;
|
|
605
609
|
var CLAUDE_SESSIONEND_UPLOAD_TIMEOUT_SECONDS = 30;
|
|
@@ -723,20 +727,41 @@ function copilotChatDetect() {
|
|
|
723
727
|
return candidates.some(isDir);
|
|
724
728
|
}
|
|
725
729
|
function ensureHooks(settings) {
|
|
726
|
-
if (!settings.hooks
|
|
730
|
+
if (!settings.hooks || typeof settings.hooks !== "object" || Array.isArray(settings.hooks)) {
|
|
731
|
+
settings.hooks = {};
|
|
732
|
+
}
|
|
727
733
|
return settings.hooks;
|
|
728
734
|
}
|
|
729
735
|
function settingsPath(repoRoot, def) {
|
|
730
736
|
return path6.join(repoRoot, def.settingsFile);
|
|
731
737
|
}
|
|
732
|
-
async function readJson(file) {
|
|
738
|
+
async function readJson(file, options = {}) {
|
|
733
739
|
try {
|
|
734
740
|
const raw = await fs5.promises.readFile(file, "utf-8");
|
|
741
|
+
if (!raw.trim()) return {};
|
|
735
742
|
const parsed = JSON.parse(raw);
|
|
736
743
|
if (parsed && typeof parsed === "object") return parsed;
|
|
737
744
|
return {};
|
|
738
745
|
} catch (err) {
|
|
739
746
|
if (err.code === "ENOENT") return {};
|
|
747
|
+
if (options.repairMalformed && err instanceof SyntaxError) {
|
|
748
|
+
const backup = `${file}.malformed-${Date.now()}`;
|
|
749
|
+
try {
|
|
750
|
+
const raw = await fs5.promises.readFile(file, "utf-8");
|
|
751
|
+
await fs5.promises.writeFile(backup, raw);
|
|
752
|
+
appendLog(
|
|
753
|
+
"warn",
|
|
754
|
+
`hooks: backed up malformed JSON hook file ${file} to ${backup}`
|
|
755
|
+
);
|
|
756
|
+
} catch (backupErr) {
|
|
757
|
+
appendLog(
|
|
758
|
+
"warn",
|
|
759
|
+
`hooks: could not back up malformed JSON hook file ${file}: ${backupErr instanceof Error ? backupErr.message : String(backupErr)}`
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
return {};
|
|
763
|
+
}
|
|
764
|
+
if (err instanceof SyntaxError) return {};
|
|
740
765
|
throw err;
|
|
741
766
|
}
|
|
742
767
|
}
|
|
@@ -1167,6 +1192,19 @@ function legacyBareGitTracesCommandsFor(command) {
|
|
|
1167
1192
|
(legacy) => legacy.endsWith(" git-traces")
|
|
1168
1193
|
);
|
|
1169
1194
|
}
|
|
1195
|
+
function legacyBareUploadCommandsFor(command) {
|
|
1196
|
+
const prefix = "npx hillclimb@latest ";
|
|
1197
|
+
if (!command.startsWith(prefix)) return [];
|
|
1198
|
+
const sub = command.slice(prefix.length);
|
|
1199
|
+
if (!sub.startsWith("upload --tool=")) return [];
|
|
1200
|
+
return [
|
|
1201
|
+
"npx hillclimb@latest upload",
|
|
1202
|
+
"npx hillclimb upload",
|
|
1203
|
+
"hillclimb-extract upload",
|
|
1204
|
+
"npx hillclimb-extract upload",
|
|
1205
|
+
"npx @hillclimb/extract upload"
|
|
1206
|
+
];
|
|
1207
|
+
}
|
|
1170
1208
|
function isHillclimbOwnedCommandFor(command, currentCommand) {
|
|
1171
1209
|
if (command === currentCommand || legacyCommandsFor(currentCommand).includes(command)) {
|
|
1172
1210
|
return true;
|
|
@@ -1182,7 +1220,7 @@ async function installHooksForTool(repoRoot, def) {
|
|
|
1182
1220
|
const r = await opencodeInstall(file);
|
|
1183
1221
|
return { settingsFile: file, ...r, changed: r.installed > 0 };
|
|
1184
1222
|
}
|
|
1185
|
-
const settings = await readJson(file);
|
|
1223
|
+
const settings = await readJson(file, { repairMalformed: true });
|
|
1186
1224
|
let installed = 0;
|
|
1187
1225
|
let alreadyPresent = 0;
|
|
1188
1226
|
let mutated = false;
|
|
@@ -1227,6 +1265,10 @@ async function checkHooksForTool(repoRoot, def) {
|
|
|
1227
1265
|
missing.push(`${evt.eventName}:${evt.command}`);
|
|
1228
1266
|
}
|
|
1229
1267
|
}
|
|
1268
|
+
if (def.tool === "codex" && missing.length === 0) {
|
|
1269
|
+
const enabled = await areCodexHooksEnabled();
|
|
1270
|
+
if (!enabled) missing.push("features.hooks");
|
|
1271
|
+
}
|
|
1230
1272
|
return { allInstalled: missing.length === 0, settingsFile: file, missing };
|
|
1231
1273
|
}
|
|
1232
1274
|
async function findLegacyGitTracesHookOwners(repoRoot, eventName) {
|
|
@@ -1250,9 +1292,57 @@ async function findLegacyGitTracesHookOwners(repoRoot, eventName) {
|
|
|
1250
1292
|
}
|
|
1251
1293
|
return owners;
|
|
1252
1294
|
}
|
|
1295
|
+
async function findLegacyUploadHookOwners(repoRoot, eventName) {
|
|
1296
|
+
if (!eventName) return [];
|
|
1297
|
+
const owners = [];
|
|
1298
|
+
for (const def of TOOLS) {
|
|
1299
|
+
if (def.format === "opencode") continue;
|
|
1300
|
+
const events = def.events.filter(
|
|
1301
|
+
(evt) => evt.eventName === eventName && evt.command.startsWith("npx hillclimb@latest upload")
|
|
1302
|
+
);
|
|
1303
|
+
if (events.length === 0) continue;
|
|
1304
|
+
const settings = await readJson(settingsPath(repoRoot, def));
|
|
1305
|
+
const commands = new Set(commandsForEvent(settings, def.format, eventName));
|
|
1306
|
+
if (events.some((evt) => {
|
|
1307
|
+
const candidates = evt.command.includes("--tool=") ? legacyBareUploadCommandsFor(evt.command) : [evt.command, ...legacyCommandsFor(evt.command)];
|
|
1308
|
+
return candidates.some((candidate) => commands.has(candidate));
|
|
1309
|
+
})) {
|
|
1310
|
+
owners.push(def.tool);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
return owners;
|
|
1314
|
+
}
|
|
1253
1315
|
function detectTools(repoRoot) {
|
|
1254
1316
|
return TOOLS.filter((def) => def.detect(repoRoot));
|
|
1255
1317
|
}
|
|
1318
|
+
function trackedHookFiles(repoRoot, files) {
|
|
1319
|
+
const rels = files.map((file) => path6.relative(repoRoot, file)).filter((rel) => rel && !rel.startsWith("..") && !path6.isAbsolute(rel));
|
|
1320
|
+
if (rels.length === 0) return [];
|
|
1321
|
+
try {
|
|
1322
|
+
const output = execFileSync("git", ["ls-files", "--", ...rels], {
|
|
1323
|
+
cwd: repoRoot,
|
|
1324
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
1325
|
+
}).toString("utf-8").trim();
|
|
1326
|
+
if (!output) return [];
|
|
1327
|
+
return output.split(/\r?\n/).map((rel) => path6.join(repoRoot, rel));
|
|
1328
|
+
} catch {
|
|
1329
|
+
return [];
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
async function ensureHookFilesIgnored(repoRoot, files) {
|
|
1333
|
+
const ignored = await ensureGitignored(repoRoot, [
|
|
1334
|
+
...files,
|
|
1335
|
+
...files.map((file) => `${file}.malformed-*`)
|
|
1336
|
+
]);
|
|
1337
|
+
const tracked = trackedHookFiles(repoRoot, files);
|
|
1338
|
+
if (tracked.length > 0) {
|
|
1339
|
+
appendLog(
|
|
1340
|
+
"warn",
|
|
1341
|
+
`hooks: hook files are already tracked by git and may be committed (${tracked.join(", ")})`
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
return { ...ignored, tracked };
|
|
1345
|
+
}
|
|
1256
1346
|
async function installDetectedHooks(repoRoot) {
|
|
1257
1347
|
const detected = detectTools(repoRoot);
|
|
1258
1348
|
const results = [];
|
|
@@ -1280,6 +1370,20 @@ async function healHookForTool(repoRoot, tool) {
|
|
|
1280
1370
|
};
|
|
1281
1371
|
}
|
|
1282
1372
|
const r = await installHooksForTool(repoRoot, def);
|
|
1373
|
+
try {
|
|
1374
|
+
const ignored = await ensureHookFilesIgnored(repoRoot, [r.settingsFile]);
|
|
1375
|
+
if (ignored.changed) {
|
|
1376
|
+
appendLog(
|
|
1377
|
+
"info",
|
|
1378
|
+
`self-heal: gitignored ${def.tool} hook file (${ignored.added.join(",")})`
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
} catch (err) {
|
|
1382
|
+
appendLog(
|
|
1383
|
+
"warn",
|
|
1384
|
+
`self-heal: could not update .gitignore for ${def.tool} hook: ${err instanceof Error ? err.message : String(err)}`
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1283
1387
|
return {
|
|
1284
1388
|
tool: def.tool,
|
|
1285
1389
|
label: def.label,
|
|
@@ -1303,20 +1407,38 @@ async function checkAllHooks(repoRoot) {
|
|
|
1303
1407
|
}
|
|
1304
1408
|
return results;
|
|
1305
1409
|
}
|
|
1306
|
-
|
|
1410
|
+
function codexConfigPath() {
|
|
1411
|
+
return process.env.HILLCLIMB_CODEX_CONFIG_PATH ?? path6.join(os2.homedir(), ".codex", "config.toml");
|
|
1412
|
+
}
|
|
1413
|
+
function codexHooksEnabledInConfig(content) {
|
|
1414
|
+
const featuresMatch = content.match(/^\[features\]\s*$/m);
|
|
1415
|
+
if (!featuresMatch || featuresMatch.index === void 0) return false;
|
|
1416
|
+
const bodyStart = featuresMatch.index + featuresMatch[0].length;
|
|
1417
|
+
const nextSectionOffset = content.slice(bodyStart).search(/\n\[[^\n]+\]\s*(?:\r?\n|$)/);
|
|
1418
|
+
const bodyEnd = nextSectionOffset === -1 ? content.length : bodyStart + nextSectionOffset;
|
|
1419
|
+
const body = content.slice(bodyStart, bodyEnd);
|
|
1420
|
+
const hooksMatch = body.match(/^\s*hooks\s*=\s*(true|false)\s*(?:#.*)?$/im);
|
|
1421
|
+
return hooksMatch?.[1]?.toLowerCase() === "true";
|
|
1422
|
+
}
|
|
1423
|
+
async function areCodexHooksEnabled() {
|
|
1424
|
+
try {
|
|
1425
|
+
const content = await fs5.promises.readFile(codexConfigPath(), "utf-8");
|
|
1426
|
+
return codexHooksEnabledInConfig(content);
|
|
1427
|
+
} catch {
|
|
1428
|
+
return false;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1307
1431
|
async function ensureCodexHooksEnabled() {
|
|
1308
1432
|
let content;
|
|
1433
|
+
const configPath2 = codexConfigPath();
|
|
1309
1434
|
try {
|
|
1310
|
-
content = await fs5.promises.readFile(
|
|
1435
|
+
content = await fs5.promises.readFile(configPath2, "utf-8");
|
|
1311
1436
|
} catch (err) {
|
|
1312
1437
|
if (err.code === "ENOENT") {
|
|
1313
|
-
await fs5.promises.mkdir(path6.dirname(
|
|
1438
|
+
await fs5.promises.mkdir(path6.dirname(configPath2), {
|
|
1314
1439
|
recursive: true
|
|
1315
1440
|
});
|
|
1316
|
-
await fs5.promises.writeFile(
|
|
1317
|
-
CODEX_CONFIG_PATH,
|
|
1318
|
-
"[features]\nhooks = true\n"
|
|
1319
|
-
);
|
|
1441
|
+
await fs5.promises.writeFile(configPath2, "[features]\nhooks = true\n");
|
|
1320
1442
|
return true;
|
|
1321
1443
|
}
|
|
1322
1444
|
throw err;
|
|
@@ -1354,7 +1476,7 @@ hooks = true
|
|
|
1354
1476
|
`;
|
|
1355
1477
|
}
|
|
1356
1478
|
if (content === original) return false;
|
|
1357
|
-
await fs5.promises.writeFile(
|
|
1479
|
+
await fs5.promises.writeFile(configPath2, content);
|
|
1358
1480
|
return true;
|
|
1359
1481
|
}
|
|
1360
1482
|
var CLAUDE_DEF = TOOLS[0];
|
|
@@ -1754,6 +1876,7 @@ async function runInit(args = []) {
|
|
|
1754
1876
|
userId: bootstrap.session.userId,
|
|
1755
1877
|
savedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1756
1878
|
});
|
|
1879
|
+
const autoUploadWarnings = [];
|
|
1757
1880
|
try {
|
|
1758
1881
|
const hookResults = await installDetectedHooks(repoRoot);
|
|
1759
1882
|
if (hookResults.length === 0) {
|
|
@@ -1778,7 +1901,7 @@ async function runInit(args = []) {
|
|
|
1778
1901
|
}
|
|
1779
1902
|
}
|
|
1780
1903
|
try {
|
|
1781
|
-
const ignored = await
|
|
1904
|
+
const ignored = await ensureHookFilesIgnored(
|
|
1782
1905
|
repoRoot,
|
|
1783
1906
|
hookResults.map((r) => r.settingsFile)
|
|
1784
1907
|
);
|
|
@@ -1791,11 +1914,21 @@ async function runInit(args = []) {
|
|
|
1791
1914
|
`${ignored.created ? "Created" : "Updated"} .gitignore for hook files`
|
|
1792
1915
|
);
|
|
1793
1916
|
}
|
|
1917
|
+
if (ignored.tracked.length > 0) {
|
|
1918
|
+
const tracked = ignored.tracked.map((file) => path7.relative(repoRoot, file)).join(", ");
|
|
1919
|
+
autoUploadWarnings.push(
|
|
1920
|
+
`Hook files are already tracked by git: ${tracked}`
|
|
1921
|
+
);
|
|
1922
|
+
p3.log.warn(
|
|
1923
|
+
`Hook files are already tracked by git and may be committed: ${tracked}`
|
|
1924
|
+
);
|
|
1925
|
+
}
|
|
1794
1926
|
} catch (err) {
|
|
1795
1927
|
appendLog(
|
|
1796
1928
|
"warn",
|
|
1797
1929
|
`init: could not update .gitignore for hook files: ${formatError(err)}`
|
|
1798
1930
|
);
|
|
1931
|
+
autoUploadWarnings.push("Could not update .gitignore for hook files.");
|
|
1799
1932
|
p3.log.warn(
|
|
1800
1933
|
`Could not update .gitignore for hook files: ${err instanceof Error ? err.message : String(err)}`
|
|
1801
1934
|
);
|
|
@@ -1812,6 +1945,9 @@ async function runInit(args = []) {
|
|
|
1812
1945
|
"warn",
|
|
1813
1946
|
`init: could not enable Codex hooks: ${formatError(err)}`
|
|
1814
1947
|
);
|
|
1948
|
+
autoUploadWarnings.push(
|
|
1949
|
+
"Could not enable Codex hooks in config.toml."
|
|
1950
|
+
);
|
|
1815
1951
|
p3.log.warn(
|
|
1816
1952
|
`Could not enable hooks in config.toml: ${err instanceof Error ? err.message : String(err)}`
|
|
1817
1953
|
);
|
|
@@ -1831,7 +1967,7 @@ async function runInit(args = []) {
|
|
|
1831
1967
|
`init: completed (project=${project.slug}, contributionType=${type.slug})`
|
|
1832
1968
|
);
|
|
1833
1969
|
p3.outro(
|
|
1834
|
-
`Done. Your next coding session in this repo will upload automatically to ${project.name}.`
|
|
1970
|
+
autoUploadWarnings.length > 0 ? `Configured ${project.name}, but auto-upload needs attention: ${autoUploadWarnings.join(" ")}` : `Done. Your next coding session in this repo will upload automatically to ${project.name}.`
|
|
1835
1971
|
);
|
|
1836
1972
|
}
|
|
1837
1973
|
|
|
@@ -2050,7 +2186,7 @@ async function runLogout(args = []) {
|
|
|
2050
2186
|
}
|
|
2051
2187
|
|
|
2052
2188
|
// src/commands/status.ts
|
|
2053
|
-
import
|
|
2189
|
+
import path8 from "path";
|
|
2054
2190
|
async function runStatus(args = []) {
|
|
2055
2191
|
const debug = args.includes("--debug");
|
|
2056
2192
|
header("status");
|
|
@@ -2068,7 +2204,7 @@ async function runStatus(args = []) {
|
|
|
2068
2204
|
return;
|
|
2069
2205
|
}
|
|
2070
2206
|
const { repoRoot, config } = match;
|
|
2071
|
-
const repoName = repo?.name ??
|
|
2207
|
+
const repoName = repo?.name ?? path8.basename(repoRoot);
|
|
2072
2208
|
if (IS_TTY) {
|
|
2073
2209
|
process.stdout.write(` ${dim("Checking login...")}`);
|
|
2074
2210
|
}
|
|
@@ -2162,7 +2298,7 @@ async function runStatus(args = []) {
|
|
|
2162
2298
|
try {
|
|
2163
2299
|
const hookStatuses = await checkAllHooks(repoRoot);
|
|
2164
2300
|
for (const h of hookStatuses) {
|
|
2165
|
-
const rel =
|
|
2301
|
+
const rel = path8.relative(repoRoot, h.settingsFile) || h.settingsFile;
|
|
2166
2302
|
debugRow(
|
|
2167
2303
|
`${h.label} hook:`,
|
|
2168
2304
|
`${rel} ${dim(`(${h.installed ? "installed" : "missing"})`)}`
|
|
@@ -2179,14 +2315,16 @@ async function runStatus(args = []) {
|
|
|
2179
2315
|
|
|
2180
2316
|
// src/commands/upload.ts
|
|
2181
2317
|
import { spawn as spawn2 } from "child_process";
|
|
2318
|
+
import crypto3 from "crypto";
|
|
2182
2319
|
import fs11 from "fs";
|
|
2183
2320
|
import os6 from "os";
|
|
2184
|
-
import
|
|
2321
|
+
import path15 from "path";
|
|
2322
|
+
import readline from "readline";
|
|
2185
2323
|
|
|
2186
2324
|
// src/debug-logs.ts
|
|
2187
2325
|
import crypto from "crypto";
|
|
2188
2326
|
import fs9 from "fs";
|
|
2189
|
-
import
|
|
2327
|
+
import path12 from "path";
|
|
2190
2328
|
|
|
2191
2329
|
// src/hook-events.ts
|
|
2192
2330
|
function classifyHookEvent(event) {
|
|
@@ -2205,6 +2343,41 @@ function classifyHookEvent(event) {
|
|
|
2205
2343
|
}
|
|
2206
2344
|
}
|
|
2207
2345
|
|
|
2346
|
+
// src/hook-payload.ts
|
|
2347
|
+
function resolveHookCwd(payload) {
|
|
2348
|
+
return payload.cwd ?? payload.workspace_roots?.[0] ?? null;
|
|
2349
|
+
}
|
|
2350
|
+
function resolveHookSessionId(payload) {
|
|
2351
|
+
return payload.session_id ?? payload.conversation_id ?? null;
|
|
2352
|
+
}
|
|
2353
|
+
function inferSourceToolFromPayload(payload) {
|
|
2354
|
+
if (payload.tool) return payload.tool;
|
|
2355
|
+
if (payload.cursor_version) return "cursor";
|
|
2356
|
+
const transcriptPath = payload.transcript_path ?? "";
|
|
2357
|
+
if (transcriptPath.includes(".claude/projects/")) return "claude";
|
|
2358
|
+
if (transcriptPath.includes(".cursor/projects/")) return "cursor";
|
|
2359
|
+
if (transcriptPath.includes("GitHub.copilot-chat")) return "copilot-chat";
|
|
2360
|
+
if (transcriptPath.includes(".hillclimb/opencode-transcripts/")) {
|
|
2361
|
+
return "opencode";
|
|
2362
|
+
}
|
|
2363
|
+
if (transcriptPath.includes(".codex/")) return "codex";
|
|
2364
|
+
switch (payload.hook_event_name) {
|
|
2365
|
+
case "session.idle":
|
|
2366
|
+
case "session.deleted":
|
|
2367
|
+
case "server.instance.disposed":
|
|
2368
|
+
return "opencode";
|
|
2369
|
+
default:
|
|
2370
|
+
return null;
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
function fallbackSourceTool(payload) {
|
|
2374
|
+
const inferred = inferSourceToolFromPayload(payload);
|
|
2375
|
+
if (inferred) return inferred;
|
|
2376
|
+
const eventKind = classifyHookEvent(payload.hook_event_name);
|
|
2377
|
+
if (eventKind === "stop") return "codex";
|
|
2378
|
+
return "claude";
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2208
2381
|
// src/middleware/pattern-redact.ts
|
|
2209
2382
|
import os3 from "os";
|
|
2210
2383
|
import { Worker } from "worker_threads";
|
|
@@ -10692,7 +10865,7 @@ var middleware = [];
|
|
|
10692
10865
|
|
|
10693
10866
|
// src/middleware/secrets.ts
|
|
10694
10867
|
import fs7 from "fs";
|
|
10695
|
-
import
|
|
10868
|
+
import path9 from "path";
|
|
10696
10869
|
var KNOWN_NON_SECRETS = /* @__PURE__ */ new Set([
|
|
10697
10870
|
"true",
|
|
10698
10871
|
"false",
|
|
@@ -10847,7 +11020,7 @@ async function discoverEnvFiles(repoRoot) {
|
|
|
10847
11020
|
const envFiles = [];
|
|
10848
11021
|
for (const name of entries) {
|
|
10849
11022
|
if (!name.startsWith(".env")) continue;
|
|
10850
|
-
const filePath =
|
|
11023
|
+
const filePath = path9.join(repoRoot, name);
|
|
10851
11024
|
try {
|
|
10852
11025
|
const stat = await fs7.promises.stat(filePath);
|
|
10853
11026
|
if (stat.isFile()) envFiles.push(name);
|
|
@@ -10872,7 +11045,7 @@ async function collectSecrets(repoRoot, envFiles, additionalFiles) {
|
|
|
10872
11045
|
}
|
|
10873
11046
|
}
|
|
10874
11047
|
for (const filePath of additionalFiles) {
|
|
10875
|
-
const resolved =
|
|
11048
|
+
const resolved = path9.resolve(repoRoot, filePath);
|
|
10876
11049
|
sourceFiles.push(resolved);
|
|
10877
11050
|
for (const value of await parseEnvFile(resolved)) {
|
|
10878
11051
|
if (isUsableValue(value)) {
|
|
@@ -10902,16 +11075,16 @@ import archiver from "archiver";
|
|
|
10902
11075
|
|
|
10903
11076
|
// src/outputs/archive.ts
|
|
10904
11077
|
import os4 from "os";
|
|
10905
|
-
import
|
|
11078
|
+
import path10 from "path";
|
|
10906
11079
|
function getSourceBaseDir(sourceName) {
|
|
10907
11080
|
const home = os4.homedir();
|
|
10908
11081
|
switch (sourceName) {
|
|
10909
11082
|
case "claude":
|
|
10910
|
-
return
|
|
11083
|
+
return path10.join(home, ".claude", "projects");
|
|
10911
11084
|
case "codex":
|
|
10912
|
-
return
|
|
11085
|
+
return path10.join(home, ".codex", "sessions");
|
|
10913
11086
|
case "debug-logs":
|
|
10914
|
-
return
|
|
11087
|
+
return path10.join(configDir(), "logs");
|
|
10915
11088
|
default:
|
|
10916
11089
|
return home;
|
|
10917
11090
|
}
|
|
@@ -10920,8 +11093,8 @@ function addGroupToArchive(archive, group, selectedSources) {
|
|
|
10920
11093
|
for (const file of group.files) {
|
|
10921
11094
|
if (!selectedSources.has(file.sourceName)) continue;
|
|
10922
11095
|
const baseDir = getSourceBaseDir(file.sourceName);
|
|
10923
|
-
const relativePath = file.absolutePath.startsWith(baseDir) ?
|
|
10924
|
-
const archivePath =
|
|
11096
|
+
const relativePath = file.absolutePath.startsWith(baseDir) ? path10.relative(baseDir, file.absolutePath) : path10.basename(file.absolutePath);
|
|
11097
|
+
const archivePath = path10.join(file.sourceName, relativePath);
|
|
10925
11098
|
if (file.content) {
|
|
10926
11099
|
archive.append(file.content, { name: archivePath });
|
|
10927
11100
|
} else {
|
|
@@ -11005,20 +11178,20 @@ var PlatformUploadOutput = class {
|
|
|
11005
11178
|
|
|
11006
11179
|
// src/pipeline.ts
|
|
11007
11180
|
import fs8 from "fs";
|
|
11008
|
-
import
|
|
11181
|
+
import path11 from "path";
|
|
11009
11182
|
function canonicalizePath(p7) {
|
|
11010
|
-
let resolved =
|
|
11011
|
-
if (resolved.endsWith(
|
|
11183
|
+
let resolved = path11.resolve(p7);
|
|
11184
|
+
if (resolved.endsWith(path11.sep) && resolved !== path11.sep) {
|
|
11012
11185
|
resolved = resolved.slice(0, -1);
|
|
11013
11186
|
}
|
|
11014
11187
|
return resolved;
|
|
11015
11188
|
}
|
|
11016
11189
|
function computeLabel(repoPath, allPaths) {
|
|
11017
|
-
const segments = repoPath.split(
|
|
11190
|
+
const segments = repoPath.split(path11.sep).filter(Boolean);
|
|
11018
11191
|
for (let depth = 1; depth <= segments.length; depth++) {
|
|
11019
11192
|
const label = segments.slice(-depth).join("/");
|
|
11020
11193
|
const matches = allPaths.filter((p7) => {
|
|
11021
|
-
const s = p7.split(
|
|
11194
|
+
const s = p7.split(path11.sep).filter(Boolean);
|
|
11022
11195
|
return s.slice(-depth).join("/") === label;
|
|
11023
11196
|
});
|
|
11024
11197
|
if (matches.length === 1) return label;
|
|
@@ -11096,7 +11269,7 @@ var DEFAULT_WAIT_MS = 6e4;
|
|
|
11096
11269
|
var LOCK_RETRIES = 100;
|
|
11097
11270
|
var LOCK_RETRY_DELAY_MS = 100;
|
|
11098
11271
|
function stateDir() {
|
|
11099
|
-
return process.env.HILLCLIMB_DEBUG_LOG_STATE_DIR ??
|
|
11272
|
+
return process.env.HILLCLIMB_DEBUG_LOG_STATE_DIR ?? path12.join(configDir(), "debug-log-uploads");
|
|
11100
11273
|
}
|
|
11101
11274
|
function waitMs() {
|
|
11102
11275
|
const raw = process.env.HILLCLIMB_DEBUG_LOG_WAIT_MS;
|
|
@@ -11105,7 +11278,7 @@ function waitMs() {
|
|
|
11105
11278
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_WAIT_MS;
|
|
11106
11279
|
}
|
|
11107
11280
|
function stateFile(eventId) {
|
|
11108
|
-
return
|
|
11281
|
+
return path12.join(stateDir(), `${eventId}.json`);
|
|
11109
11282
|
}
|
|
11110
11283
|
function lockFile(eventId) {
|
|
11111
11284
|
return `${stateFile(eventId)}.lock`;
|
|
@@ -11130,10 +11303,10 @@ function toolLabel(tool) {
|
|
|
11130
11303
|
return labels[tool] ?? tool;
|
|
11131
11304
|
}
|
|
11132
11305
|
function resolveCwd(payload) {
|
|
11133
|
-
return payload
|
|
11306
|
+
return resolveHookCwd(payload);
|
|
11134
11307
|
}
|
|
11135
11308
|
function resolveSessionId(payload) {
|
|
11136
|
-
return payload
|
|
11309
|
+
return resolveHookSessionId(payload);
|
|
11137
11310
|
}
|
|
11138
11311
|
function stringOrNull(value) {
|
|
11139
11312
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
@@ -11152,7 +11325,7 @@ function expectedKinds(tool, eventKind, payload) {
|
|
|
11152
11325
|
async function transcriptFingerprint(payload) {
|
|
11153
11326
|
const transcriptPath = stringOrNull(payload.transcript_path);
|
|
11154
11327
|
if (!transcriptPath) return {};
|
|
11155
|
-
const resolved =
|
|
11328
|
+
const resolved = path12.resolve(transcriptPath);
|
|
11156
11329
|
try {
|
|
11157
11330
|
const stat = await fs9.promises.stat(resolved);
|
|
11158
11331
|
return {
|
|
@@ -11191,13 +11364,10 @@ async function eventContext(tool, payload) {
|
|
|
11191
11364
|
conversationId: stringOrNull(payload.conversation_id),
|
|
11192
11365
|
turnId,
|
|
11193
11366
|
eventNonce,
|
|
11194
|
-
// Only
|
|
11195
|
-
//
|
|
11196
|
-
//
|
|
11197
|
-
|
|
11198
|
-
// transcript_path to the upload spawn but not the git-traces spawn,
|
|
11199
|
-
// causing a 60s orphan wait and a duplicate debug-log upload.
|
|
11200
|
-
...eventKind === "stop" ? await transcriptFingerprint(payload) : {}
|
|
11367
|
+
// Only stop events without stable turn IDs need the transcript to
|
|
11368
|
+
// disambiguate. When a turn_id exists, including mutable transcript
|
|
11369
|
+
// mtime/size can split agent+git completions for the same logical event.
|
|
11370
|
+
...eventKind === "stop" && !turnId ? await transcriptFingerprint(payload) : {}
|
|
11201
11371
|
};
|
|
11202
11372
|
const eventId = crypto.createHash("sha256").update(JSON.stringify(fingerprint)).digest("hex").slice(0, 32);
|
|
11203
11373
|
return {
|
|
@@ -11268,7 +11438,7 @@ function initialState(ctx, now) {
|
|
|
11268
11438
|
eventKind: ctx.eventKind,
|
|
11269
11439
|
hookEventName: ctx.hookEventName,
|
|
11270
11440
|
sessionId: ctx.sessionId,
|
|
11271
|
-
logDate:
|
|
11441
|
+
logDate: path12.basename(todayLogPath(), ".log"),
|
|
11272
11442
|
firstSeenAt: now.toISOString()
|
|
11273
11443
|
};
|
|
11274
11444
|
}
|
|
@@ -11330,7 +11500,7 @@ async function waitForExpectedKinds(ctx, state) {
|
|
|
11330
11500
|
}
|
|
11331
11501
|
async function buildMiddleware(repoRoot) {
|
|
11332
11502
|
const envFileNames = await discoverEnvFiles(repoRoot);
|
|
11333
|
-
const envFilePaths = envFileNames.map((n) =>
|
|
11503
|
+
const envFilePaths = envFileNames.map((n) => path12.join(repoRoot, n));
|
|
11334
11504
|
const secretResult = await collectSecrets(repoRoot, envFilePaths, []);
|
|
11335
11505
|
const middleware2 = [];
|
|
11336
11506
|
if (secretResult.values.size > 0) {
|
|
@@ -11376,7 +11546,7 @@ async function uploadDebugLog(ctx, state) {
|
|
|
11376
11546
|
};
|
|
11377
11547
|
const group = {
|
|
11378
11548
|
repoPath: ctx.repoRoot,
|
|
11379
|
-
label:
|
|
11549
|
+
label: path12.basename(ctx.repoRoot),
|
|
11380
11550
|
files: [sourceFile],
|
|
11381
11551
|
sourceNames: [DEBUG_LOGS_SLUG],
|
|
11382
11552
|
lastModified: now
|
|
@@ -11398,7 +11568,7 @@ async function uploadDebugLog(ctx, state) {
|
|
|
11398
11568
|
`Tool: ${label}`,
|
|
11399
11569
|
`Event: ${ctx.hookEventName ?? ctx.eventKind}`,
|
|
11400
11570
|
`Repo: ${ctx.repoRoot}`,
|
|
11401
|
-
`Log: ${
|
|
11571
|
+
`Log: ${path12.basename(logPath)}`,
|
|
11402
11572
|
`Agent done: ${state.agentDoneAt ?? "<not observed>"}`,
|
|
11403
11573
|
`Git done: ${state.gitDoneAt ?? "<not observed>"}`,
|
|
11404
11574
|
`Uploaded: ${now.toISOString()}`
|
|
@@ -11415,7 +11585,7 @@ async function uploadDebugLog(ctx, state) {
|
|
|
11415
11585
|
);
|
|
11416
11586
|
appendLog(
|
|
11417
11587
|
"info",
|
|
11418
|
-
`debug-logs: uploaded ${
|
|
11588
|
+
`debug-logs: uploaded ${path12.basename(logPath)} to project ${ctx.config.projectSlug} (${ctx.config.projectId}) as contribution ${contributionId}`
|
|
11419
11589
|
);
|
|
11420
11590
|
return contributionId;
|
|
11421
11591
|
} catch (err) {
|
|
@@ -11468,7 +11638,7 @@ async function recordDebugLogCompletion(args) {
|
|
|
11468
11638
|
}
|
|
11469
11639
|
|
|
11470
11640
|
// src/normalizer/index.ts
|
|
11471
|
-
import
|
|
11641
|
+
import path13 from "path";
|
|
11472
11642
|
|
|
11473
11643
|
// src/normalizer/claude.ts
|
|
11474
11644
|
function stringify(value) {
|
|
@@ -13166,14 +13336,20 @@ var NormalizeMiddleware = class {
|
|
|
13166
13336
|
continue;
|
|
13167
13337
|
const content = file.content ? file.content.toString("utf-8") : null;
|
|
13168
13338
|
if (!content) continue;
|
|
13169
|
-
const sessionId = file.metadata?.sessionId ?? (file.sourceName === "codex" ? void 0 :
|
|
13339
|
+
const sessionId = file.metadata?.sessionId ?? (file.sourceName === "codex" ? void 0 : path13.basename(file.absolutePath, ".jsonl"));
|
|
13170
13340
|
try {
|
|
13171
13341
|
const trajectory = normalizeContent(
|
|
13172
13342
|
file.sourceName,
|
|
13173
13343
|
content,
|
|
13174
13344
|
sessionId
|
|
13175
13345
|
);
|
|
13176
|
-
if (!trajectory)
|
|
13346
|
+
if (!trajectory) {
|
|
13347
|
+
appendLog(
|
|
13348
|
+
"warn",
|
|
13349
|
+
`normalize: produced no ATIF for source=${file.sourceName} session=${sessionId ?? "<unknown>"} file=${file.absolutePath}`
|
|
13350
|
+
);
|
|
13351
|
+
continue;
|
|
13352
|
+
}
|
|
13177
13353
|
const json = JSON.stringify(
|
|
13178
13354
|
excludeNone(trajectory),
|
|
13179
13355
|
null,
|
|
@@ -13187,7 +13363,11 @@ var NormalizeMiddleware = class {
|
|
|
13187
13363
|
metadata: { ...file.metadata, isAtif: true },
|
|
13188
13364
|
content: Buffer.from(json, "utf-8")
|
|
13189
13365
|
});
|
|
13190
|
-
} catch {
|
|
13366
|
+
} catch (err) {
|
|
13367
|
+
appendLog(
|
|
13368
|
+
"warn",
|
|
13369
|
+
`normalize: failed for source=${file.sourceName} session=${sessionId ?? "<unknown>"} file=${file.absolutePath}: ${err instanceof Error ? err.message : String(err)}`
|
|
13370
|
+
);
|
|
13191
13371
|
}
|
|
13192
13372
|
}
|
|
13193
13373
|
return { ...group, files: newFiles };
|
|
@@ -13198,15 +13378,15 @@ var NormalizeMiddleware = class {
|
|
|
13198
13378
|
import crypto2 from "crypto";
|
|
13199
13379
|
import fs10 from "fs";
|
|
13200
13380
|
import os5 from "os";
|
|
13201
|
-
import
|
|
13381
|
+
import path14 from "path";
|
|
13202
13382
|
var CURRENT_SCHEMA_VERSION2 = 1;
|
|
13203
|
-
var DEFAULT_STATE_DIR =
|
|
13383
|
+
var DEFAULT_STATE_DIR = path14.join(
|
|
13204
13384
|
os5.homedir(),
|
|
13205
13385
|
".hillclimb",
|
|
13206
13386
|
"agent-uploads"
|
|
13207
13387
|
);
|
|
13208
|
-
var
|
|
13209
|
-
var
|
|
13388
|
+
var DEFAULT_LOCK_WAIT_MS = 5 * 60 * 1e3;
|
|
13389
|
+
var DEFAULT_LOCK_RETRY_DELAY_MS = 500;
|
|
13210
13390
|
var DEFAULT_STATE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
13211
13391
|
function stateDir2() {
|
|
13212
13392
|
return process.env.HILLCLIMB_UPLOAD_STATE_DIR ?? DEFAULT_STATE_DIR;
|
|
@@ -13223,9 +13403,26 @@ function stateTtlMs() {
|
|
|
13223
13403
|
DEFAULT_STATE_TTL_MS
|
|
13224
13404
|
);
|
|
13225
13405
|
}
|
|
13406
|
+
function lockRetryDelayMs() {
|
|
13407
|
+
return Math.max(
|
|
13408
|
+
1,
|
|
13409
|
+
readPositiveEnvMs(
|
|
13410
|
+
"HILLCLIMB_UPLOAD_LOCK_RETRY_DELAY_MS",
|
|
13411
|
+
DEFAULT_LOCK_RETRY_DELAY_MS
|
|
13412
|
+
)
|
|
13413
|
+
);
|
|
13414
|
+
}
|
|
13415
|
+
function lockRetries() {
|
|
13416
|
+
return Math.max(
|
|
13417
|
+
1,
|
|
13418
|
+
Math.ceil(
|
|
13419
|
+
readPositiveEnvMs("HILLCLIMB_UPLOAD_LOCK_WAIT_MS", DEFAULT_LOCK_WAIT_MS) / lockRetryDelayMs()
|
|
13420
|
+
)
|
|
13421
|
+
);
|
|
13422
|
+
}
|
|
13226
13423
|
function stateFileFor(repoRoot, tool, sessionId) {
|
|
13227
|
-
const hash = crypto2.createHash("sha256").update(`${
|
|
13228
|
-
return
|
|
13424
|
+
const hash = crypto2.createHash("sha256").update(`${path14.resolve(repoRoot)}\0${tool}\0${sessionId}`).digest("hex").slice(0, 16);
|
|
13425
|
+
return path14.join(stateDir2(), `${hash}.json`);
|
|
13229
13426
|
}
|
|
13230
13427
|
function lockFileFor(repoRoot, tool, sessionId) {
|
|
13231
13428
|
return `${stateFileFor(repoRoot, tool, sessionId)}.lock`;
|
|
@@ -13258,7 +13455,7 @@ async function deleteUploadState(repoRoot, tool, sessionId) {
|
|
|
13258
13455
|
} catch {
|
|
13259
13456
|
}
|
|
13260
13457
|
}
|
|
13261
|
-
async function acquireLock2(repoRoot, tool, sessionId, retries =
|
|
13458
|
+
async function acquireLock2(repoRoot, tool, sessionId, retries = lockRetries(), delayMs = lockRetryDelayMs()) {
|
|
13262
13459
|
const lockPath = lockFileFor(repoRoot, tool, sessionId);
|
|
13263
13460
|
await fs10.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
|
|
13264
13461
|
for (let i = 0; i < retries; i++) {
|
|
@@ -13267,18 +13464,24 @@ async function acquireLock2(repoRoot, tool, sessionId, retries = LOCK_RETRIES2,
|
|
|
13267
13464
|
lockPath,
|
|
13268
13465
|
fs10.constants.O_CREAT | fs10.constants.O_EXCL | fs10.constants.O_WRONLY
|
|
13269
13466
|
);
|
|
13270
|
-
|
|
13271
|
-
|
|
13467
|
+
try {
|
|
13468
|
+
await fd.write(String(process.pid));
|
|
13469
|
+
} finally {
|
|
13470
|
+
await fd.close();
|
|
13471
|
+
}
|
|
13272
13472
|
return;
|
|
13273
13473
|
} catch (err) {
|
|
13274
13474
|
if (err.code === "EEXIST" && i < retries - 1) {
|
|
13275
13475
|
await new Promise((r) => setTimeout(r, delayMs));
|
|
13276
13476
|
continue;
|
|
13277
13477
|
}
|
|
13478
|
+
if (err.code === "EEXIST") break;
|
|
13278
13479
|
throw err;
|
|
13279
13480
|
}
|
|
13280
13481
|
}
|
|
13281
|
-
throw new Error(
|
|
13482
|
+
throw new Error(
|
|
13483
|
+
`Failed to acquire upload lock for ${tool} session ${sessionId} after ${retries} retries (${delayMs}ms delay, lock=${lockPath})`
|
|
13484
|
+
);
|
|
13282
13485
|
}
|
|
13283
13486
|
async function releaseLock2(repoRoot, tool, sessionId) {
|
|
13284
13487
|
try {
|
|
@@ -13303,7 +13506,7 @@ async function sweepStaleUploadStates(ttlMs = stateTtlMs(), now = Date.now()) {
|
|
|
13303
13506
|
}
|
|
13304
13507
|
for (const entry of entries) {
|
|
13305
13508
|
if (!entry.isFile()) continue;
|
|
13306
|
-
const file =
|
|
13509
|
+
const file = path14.join(stateDir2(), entry.name);
|
|
13307
13510
|
try {
|
|
13308
13511
|
const st = await fs10.promises.stat(file);
|
|
13309
13512
|
if (now - st.mtimeMs > ttlMs) {
|
|
@@ -13329,6 +13532,9 @@ function sanitize2(value) {
|
|
|
13329
13532
|
function formatEpochSeconds2(date) {
|
|
13330
13533
|
return String(Math.floor(date.getTime() / 1e3));
|
|
13331
13534
|
}
|
|
13535
|
+
function newFlowId() {
|
|
13536
|
+
return crypto3.randomBytes(3).toString("hex");
|
|
13537
|
+
}
|
|
13332
13538
|
function lineHasAssistant(line) {
|
|
13333
13539
|
return line.includes('"type":"assistant"') || line.includes('"role":"assistant"') || line.includes('"type":"assistant.');
|
|
13334
13540
|
}
|
|
@@ -13355,16 +13561,41 @@ async function hasAssistantMessage(transcriptPath) {
|
|
|
13355
13561
|
}
|
|
13356
13562
|
return false;
|
|
13357
13563
|
}
|
|
13358
|
-
function
|
|
13359
|
-
|
|
13360
|
-
|
|
13361
|
-
|
|
13362
|
-
|
|
13564
|
+
function hashFileSha256(file) {
|
|
13565
|
+
return new Promise((resolve, reject) => {
|
|
13566
|
+
const hash = crypto3.createHash("sha256");
|
|
13567
|
+
const stream = fs11.createReadStream(file);
|
|
13568
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
13569
|
+
stream.on("error", reject);
|
|
13570
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
13571
|
+
});
|
|
13572
|
+
}
|
|
13573
|
+
async function resolveSourceTool(payload, repoRoot) {
|
|
13574
|
+
const inferred = inferSourceToolFromPayload(payload);
|
|
13575
|
+
if (inferred) return inferred;
|
|
13576
|
+
if (repoRoot) {
|
|
13577
|
+
const owners = await findLegacyUploadHookOwners(
|
|
13578
|
+
repoRoot,
|
|
13579
|
+
payload.hook_event_name
|
|
13580
|
+
);
|
|
13581
|
+
if (owners.length === 1) {
|
|
13582
|
+
appendLog("info", `upload: inferred legacy bare hook owner ${owners[0]}`);
|
|
13583
|
+
return owners[0];
|
|
13584
|
+
}
|
|
13585
|
+
if (owners.length > 1) {
|
|
13586
|
+
appendLog(
|
|
13587
|
+
"warn",
|
|
13588
|
+
`upload: legacy bare hook matched multiple tools (${owners.join(", ")}); skipping to avoid misattribution`
|
|
13589
|
+
);
|
|
13590
|
+
return null;
|
|
13591
|
+
}
|
|
13592
|
+
}
|
|
13593
|
+
return fallbackSourceTool(payload);
|
|
13363
13594
|
}
|
|
13364
13595
|
function summarizePayload(payload) {
|
|
13365
|
-
const sessionId = payload
|
|
13596
|
+
const sessionId = resolveHookSessionId(payload);
|
|
13366
13597
|
const turnId = payload.turn_id ?? null;
|
|
13367
|
-
const cwd = payload
|
|
13598
|
+
const cwd = resolveHookCwd(payload);
|
|
13368
13599
|
return JSON.stringify({
|
|
13369
13600
|
session_id: sessionId,
|
|
13370
13601
|
turn_id: turnId,
|
|
@@ -13378,6 +13609,54 @@ function summarizePayload(payload) {
|
|
|
13378
13609
|
cursor_version_present: !!payload.cursor_version
|
|
13379
13610
|
});
|
|
13380
13611
|
}
|
|
13612
|
+
async function codexSessionIdFromFile(filePath) {
|
|
13613
|
+
const stream = fs11.createReadStream(filePath, { encoding: "utf-8" });
|
|
13614
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
13615
|
+
try {
|
|
13616
|
+
for await (const line of rl) {
|
|
13617
|
+
if (!line.trim()) continue;
|
|
13618
|
+
try {
|
|
13619
|
+
const obj = JSON.parse(line);
|
|
13620
|
+
if (obj.type === "session_meta") return obj.payload?.id ?? null;
|
|
13621
|
+
} catch {
|
|
13622
|
+
return null;
|
|
13623
|
+
}
|
|
13624
|
+
return null;
|
|
13625
|
+
}
|
|
13626
|
+
} finally {
|
|
13627
|
+
rl.close();
|
|
13628
|
+
stream.destroy();
|
|
13629
|
+
}
|
|
13630
|
+
return null;
|
|
13631
|
+
}
|
|
13632
|
+
async function findCodexTranscriptPath(sessionId) {
|
|
13633
|
+
const sessionsDir = process.env.HILLCLIMB_CODEX_SESSIONS_DIR ?? path15.join(os6.homedir(), ".codex", "sessions");
|
|
13634
|
+
const candidates = [];
|
|
13635
|
+
async function walk(dir) {
|
|
13636
|
+
let entries;
|
|
13637
|
+
try {
|
|
13638
|
+
entries = await fs11.promises.readdir(dir, { withFileTypes: true });
|
|
13639
|
+
} catch {
|
|
13640
|
+
return;
|
|
13641
|
+
}
|
|
13642
|
+
for (const entry of entries) {
|
|
13643
|
+
const full = path15.join(dir, entry.name);
|
|
13644
|
+
if (entry.isDirectory()) {
|
|
13645
|
+
await walk(full);
|
|
13646
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl") && entry.name.includes(sessionId)) {
|
|
13647
|
+
candidates.push(full);
|
|
13648
|
+
}
|
|
13649
|
+
}
|
|
13650
|
+
}
|
|
13651
|
+
await walk(sessionsDir);
|
|
13652
|
+
candidates.sort();
|
|
13653
|
+
for (const filePath of candidates) {
|
|
13654
|
+
if (await codexSessionIdFromFile(filePath) === sessionId) {
|
|
13655
|
+
return filePath;
|
|
13656
|
+
}
|
|
13657
|
+
}
|
|
13658
|
+
return void 0;
|
|
13659
|
+
}
|
|
13381
13660
|
async function selfHealHook(repoRoot, tool) {
|
|
13382
13661
|
if (process.env.HILLCLIMB_SKIP_HOOK_SELF_HEAL === "1") return;
|
|
13383
13662
|
try {
|
|
@@ -13397,11 +13676,11 @@ async function selfHealHook(repoRoot, tool) {
|
|
|
13397
13676
|
}
|
|
13398
13677
|
}
|
|
13399
13678
|
function resolveCursorTranscriptPath(payload) {
|
|
13400
|
-
const id = payload
|
|
13679
|
+
const id = resolveHookSessionId(payload);
|
|
13401
13680
|
const workspace = payload.workspace_roots?.[0];
|
|
13402
13681
|
if (!id || !workspace) return void 0;
|
|
13403
13682
|
const encoded = workspace.replace(/^\//, "").replace(/\//g, "-");
|
|
13404
|
-
return
|
|
13683
|
+
return path15.join(
|
|
13405
13684
|
os6.homedir(),
|
|
13406
13685
|
".cursor",
|
|
13407
13686
|
"projects",
|
|
@@ -13411,22 +13690,31 @@ function resolveCursorTranscriptPath(payload) {
|
|
|
13411
13690
|
`${id}.jsonl`
|
|
13412
13691
|
);
|
|
13413
13692
|
}
|
|
13693
|
+
async function resolveTranscriptPath(payload, sourceTool, sessionId) {
|
|
13694
|
+
if (payload.transcript_path) return payload.transcript_path;
|
|
13695
|
+
if (sourceTool === "cursor") return resolveCursorTranscriptPath(payload);
|
|
13696
|
+
if (sourceTool === "codex") {
|
|
13697
|
+
const resolved = await findCodexTranscriptPath(sessionId);
|
|
13698
|
+
if (resolved) {
|
|
13699
|
+
appendLog(
|
|
13700
|
+
"info",
|
|
13701
|
+
`[${sessionId}] resolved missing Codex transcript_path via ~/.codex/sessions: ${resolved}`
|
|
13702
|
+
);
|
|
13703
|
+
}
|
|
13704
|
+
return resolved;
|
|
13705
|
+
}
|
|
13706
|
+
return void 0;
|
|
13707
|
+
}
|
|
13414
13708
|
async function runUploadInner(payload) {
|
|
13415
|
-
const sessionId = payload
|
|
13416
|
-
const
|
|
13417
|
-
const cwd = payload.cwd ?? payload.workspace_roots?.[0];
|
|
13418
|
-
const sourceTool = resolveSourceTool(payload);
|
|
13709
|
+
const sessionId = resolveHookSessionId(payload);
|
|
13710
|
+
const cwd = resolveHookCwd(payload);
|
|
13419
13711
|
const eventKind = classifyHookEvent(payload.hook_event_name);
|
|
13420
|
-
|
|
13421
|
-
"info",
|
|
13422
|
-
`[${sessionId ?? "no-id"}] payload parsed (tool=${sourceTool}, cwd=${cwd ?? "?"}, event=${eventKind ?? "?"})`
|
|
13423
|
-
);
|
|
13424
|
-
if (!sessionId || !transcriptPath || !cwd) {
|
|
13712
|
+
if (!sessionId || !cwd) {
|
|
13425
13713
|
appendLog(
|
|
13426
13714
|
"warn",
|
|
13427
|
-
`Skipping upload: missing required hook fields (session_id=${!!sessionId}, transcript_path=${!!
|
|
13715
|
+
`Skipping upload: missing required hook fields (session_id=${!!sessionId}, transcript_path=${!!payload.transcript_path}, cwd=${!!cwd})`
|
|
13428
13716
|
);
|
|
13429
|
-
return;
|
|
13717
|
+
return false;
|
|
13430
13718
|
}
|
|
13431
13719
|
const match = await findProjectForCwd(cwd);
|
|
13432
13720
|
if (!match) {
|
|
@@ -13434,11 +13722,33 @@ async function runUploadInner(payload) {
|
|
|
13434
13722
|
"warn",
|
|
13435
13723
|
`Skipping session ${sessionId}: no hillclimb config for cwd ${cwd}. Run \`npx hillclimb\` in the repo.`
|
|
13436
13724
|
);
|
|
13437
|
-
return;
|
|
13725
|
+
return false;
|
|
13438
13726
|
}
|
|
13439
13727
|
const { repoRoot, config } = match;
|
|
13728
|
+
const sourceTool = await resolveSourceTool(payload, repoRoot);
|
|
13729
|
+
if (!sourceTool) {
|
|
13730
|
+
payload.tool = "unknown";
|
|
13731
|
+
return false;
|
|
13732
|
+
}
|
|
13733
|
+
payload.tool = sourceTool;
|
|
13734
|
+
appendLog(
|
|
13735
|
+
"info",
|
|
13736
|
+
`[${sessionId}] payload parsed (tool=${sourceTool}, cwd=${cwd}, event=${eventKind ?? "?"})`
|
|
13737
|
+
);
|
|
13440
13738
|
await selfHealHook(repoRoot, sourceTool);
|
|
13441
|
-
const
|
|
13739
|
+
const transcriptPath = await resolveTranscriptPath(
|
|
13740
|
+
payload,
|
|
13741
|
+
sourceTool,
|
|
13742
|
+
sessionId
|
|
13743
|
+
);
|
|
13744
|
+
if (!transcriptPath) {
|
|
13745
|
+
appendLog(
|
|
13746
|
+
"warn",
|
|
13747
|
+
`Skipping upload: missing required hook fields (session_id=true, transcript_path=false, cwd=true)`
|
|
13748
|
+
);
|
|
13749
|
+
return false;
|
|
13750
|
+
}
|
|
13751
|
+
const transcriptResolved = path15.resolve(transcriptPath);
|
|
13442
13752
|
try {
|
|
13443
13753
|
const stat = await fs11.promises.stat(transcriptResolved);
|
|
13444
13754
|
if (!stat.isFile()) {
|
|
@@ -13446,23 +13756,23 @@ async function runUploadInner(payload) {
|
|
|
13446
13756
|
"warn",
|
|
13447
13757
|
`Skipping session ${sessionId}: transcript_path is not a file: ${transcriptResolved}`
|
|
13448
13758
|
);
|
|
13449
|
-
return;
|
|
13759
|
+
return false;
|
|
13450
13760
|
}
|
|
13451
13761
|
} catch (err) {
|
|
13452
13762
|
appendLog(
|
|
13453
13763
|
"warn",
|
|
13454
13764
|
`Skipping session ${sessionId}: transcript_path not readable (${transcriptResolved}): ${err instanceof Error ? err.message : String(err)}`
|
|
13455
13765
|
);
|
|
13456
|
-
return;
|
|
13766
|
+
return false;
|
|
13457
13767
|
}
|
|
13458
13768
|
if (!await hasAssistantMessage(transcriptResolved)) {
|
|
13459
13769
|
appendLog(
|
|
13460
13770
|
"info",
|
|
13461
13771
|
`Skipping session ${sessionId}: transcript contains no assistant messages (nothing to upload).`
|
|
13462
13772
|
);
|
|
13463
|
-
return;
|
|
13773
|
+
return false;
|
|
13464
13774
|
}
|
|
13465
|
-
await uploadSession({
|
|
13775
|
+
return await uploadSession({
|
|
13466
13776
|
sessionId,
|
|
13467
13777
|
transcriptPath: transcriptResolved,
|
|
13468
13778
|
repoRoot,
|
|
@@ -13474,14 +13784,16 @@ async function runUploadInner(payload) {
|
|
|
13474
13784
|
async function uploadSession(args) {
|
|
13475
13785
|
const { sessionId, transcriptPath, repoRoot, config, sourceTool, eventKind } = args;
|
|
13476
13786
|
const isSessionEnd = eventKind === "sessionEnd";
|
|
13477
|
-
await withUploadLock(repoRoot, sourceTool, sessionId, async () => {
|
|
13787
|
+
return await withUploadLock(repoRoot, sourceTool, sessionId, async () => {
|
|
13478
13788
|
const prior = await readUploadState(repoRoot, sourceTool, sessionId);
|
|
13479
13789
|
let transcriptSize;
|
|
13790
|
+
let transcriptSha256;
|
|
13480
13791
|
try {
|
|
13481
13792
|
transcriptSize = (await fs11.promises.stat(transcriptPath)).size;
|
|
13793
|
+
transcriptSha256 = await hashFileSha256(transcriptPath);
|
|
13482
13794
|
} catch {
|
|
13483
13795
|
}
|
|
13484
|
-
if (prior?.contributionId && transcriptSize !== void 0 && transcriptSize === prior.lastTranscriptSize) {
|
|
13796
|
+
if (prior?.contributionId && transcriptSize !== void 0 && transcriptSha256 !== void 0 && transcriptSize === prior.lastTranscriptSize && transcriptSha256 === prior.lastTranscriptSha256) {
|
|
13485
13797
|
appendLog(
|
|
13486
13798
|
"info",
|
|
13487
13799
|
`[${sessionId}] skipping ${sourceTool} ${isSessionEnd ? "SessionEnd" : "Stop"} upload (transcript unchanged at ${transcriptSize} bytes${isSessionEnd ? "; local state cleared" : ""})`
|
|
@@ -13489,7 +13801,7 @@ async function uploadSession(args) {
|
|
|
13489
13801
|
if (isSessionEnd) {
|
|
13490
13802
|
await deleteUploadState(repoRoot, sourceTool, sessionId);
|
|
13491
13803
|
}
|
|
13492
|
-
return;
|
|
13804
|
+
return true;
|
|
13493
13805
|
}
|
|
13494
13806
|
const now = /* @__PURE__ */ new Date();
|
|
13495
13807
|
const sourceFile = {
|
|
@@ -13499,13 +13811,13 @@ async function uploadSession(args) {
|
|
|
13499
13811
|
};
|
|
13500
13812
|
const group = {
|
|
13501
13813
|
repoPath: repoRoot,
|
|
13502
|
-
label:
|
|
13814
|
+
label: path15.basename(repoRoot),
|
|
13503
13815
|
files: [sourceFile],
|
|
13504
13816
|
sourceNames: [sourceTool],
|
|
13505
13817
|
lastModified: now
|
|
13506
13818
|
};
|
|
13507
13819
|
const envFileNames = await discoverEnvFiles(repoRoot);
|
|
13508
|
-
const envFilePaths = envFileNames.map((n) =>
|
|
13820
|
+
const envFilePaths = envFileNames.map((n) => path15.join(repoRoot, n));
|
|
13509
13821
|
const secretResult = await collectSecrets(repoRoot, envFilePaths, []);
|
|
13510
13822
|
const mwChain = [];
|
|
13511
13823
|
if (secretResult.values.size > 0) {
|
|
@@ -13519,7 +13831,7 @@ async function uploadSession(args) {
|
|
|
13519
13831
|
"error",
|
|
13520
13832
|
`Session ${sessionId} upload skipped: no saved login for ${config.apiBaseUrl}. Run \`npx hillclimb login\`.`
|
|
13521
13833
|
);
|
|
13522
|
-
return;
|
|
13834
|
+
return false;
|
|
13523
13835
|
}
|
|
13524
13836
|
const client = new PlatformClient(
|
|
13525
13837
|
config.apiBaseUrl,
|
|
@@ -13566,7 +13878,8 @@ Uploaded: ${now.toISOString()}`;
|
|
|
13566
13878
|
uploadCount: prior?.uploadCount ?? 0,
|
|
13567
13879
|
firstUploadedAt: prior?.firstUploadedAt ?? now.toISOString(),
|
|
13568
13880
|
lastUploadedAt: prior?.lastUploadedAt ?? now.toISOString(),
|
|
13569
|
-
lastTranscriptSize: prior?.lastTranscriptSize
|
|
13881
|
+
lastTranscriptSize: prior?.lastTranscriptSize,
|
|
13882
|
+
lastTranscriptSha256: prior?.lastTranscriptSha256
|
|
13570
13883
|
})
|
|
13571
13884
|
});
|
|
13572
13885
|
const reuseDesc = prior?.contributionId ? `reusing contribution ${prior.contributionId} (file #${seq})` : prior ? "new contribution (file #1; prior state had no contribution \u2014 earlier create may have failed)" : "new contribution (file #1; first upload for session)";
|
|
@@ -13585,13 +13898,13 @@ Uploaded: ${now.toISOString()}`;
|
|
|
13585
13898
|
"error",
|
|
13586
13899
|
`Session ${sessionId} upload failed: authentication expired. Re-run \`npx hillclimb\` in ${repoRoot}.`
|
|
13587
13900
|
);
|
|
13588
|
-
return;
|
|
13901
|
+
return false;
|
|
13589
13902
|
}
|
|
13590
13903
|
appendLog(
|
|
13591
13904
|
"error",
|
|
13592
13905
|
`Session ${sessionId} upload failed: ${err instanceof Error ? err.message : String(err)}`
|
|
13593
13906
|
);
|
|
13594
|
-
return;
|
|
13907
|
+
return false;
|
|
13595
13908
|
}
|
|
13596
13909
|
const next = {
|
|
13597
13910
|
schemaVersion: CURRENT_SCHEMA_VERSION2,
|
|
@@ -13604,7 +13917,8 @@ Uploaded: ${now.toISOString()}`;
|
|
|
13604
13917
|
uploadCount: seq,
|
|
13605
13918
|
firstUploadedAt: prior?.firstUploadedAt ?? now.toISOString(),
|
|
13606
13919
|
lastUploadedAt: now.toISOString(),
|
|
13607
|
-
lastTranscriptSize: transcriptSize
|
|
13920
|
+
lastTranscriptSize: transcriptSize,
|
|
13921
|
+
lastTranscriptSha256: transcriptSha256
|
|
13608
13922
|
};
|
|
13609
13923
|
await writeUploadState(next);
|
|
13610
13924
|
appendLog(
|
|
@@ -13618,10 +13932,12 @@ Uploaded: ${now.toISOString()}`;
|
|
|
13618
13932
|
`[${sessionId}] session complete \u2014 contribution ${contributionId}, ${seq} file(s); local state cleared`
|
|
13619
13933
|
);
|
|
13620
13934
|
}
|
|
13935
|
+
return true;
|
|
13621
13936
|
});
|
|
13622
13937
|
}
|
|
13623
13938
|
var WORKER_ENV_FLAG = "HILLCLIMB_UPLOAD_WORKER";
|
|
13624
13939
|
var TOOL_ENV_FLAG = "HILLCLIMB_UPLOAD_TOOL";
|
|
13940
|
+
var FLOW_ID_ENV = "HILLCLIMB_UPLOAD_FLOW";
|
|
13625
13941
|
function parseToolArg(argv) {
|
|
13626
13942
|
for (let i = 0; i < argv.length; i++) {
|
|
13627
13943
|
const a = argv[i];
|
|
@@ -13635,6 +13951,8 @@ async function runUpload() {
|
|
|
13635
13951
|
await runUploadWorker();
|
|
13636
13952
|
return;
|
|
13637
13953
|
}
|
|
13954
|
+
const flowId = newFlowId();
|
|
13955
|
+
setLogPrefix(`[${flowId}]`);
|
|
13638
13956
|
appendLog("info", `upload hook invoked (pid ${process.pid})`);
|
|
13639
13957
|
let raw;
|
|
13640
13958
|
try {
|
|
@@ -13661,7 +13979,8 @@ async function runUpload() {
|
|
|
13661
13979
|
const toolArg = parseToolArg(process.argv.slice(2));
|
|
13662
13980
|
const workerEnv = {
|
|
13663
13981
|
...process.env,
|
|
13664
|
-
[WORKER_ENV_FLAG]: "1"
|
|
13982
|
+
[WORKER_ENV_FLAG]: "1",
|
|
13983
|
+
[FLOW_ID_ENV]: flowId
|
|
13665
13984
|
};
|
|
13666
13985
|
if (toolArg) workerEnv[TOOL_ENV_FLAG] = toolArg;
|
|
13667
13986
|
try {
|
|
@@ -13688,6 +14007,7 @@ async function runUpload() {
|
|
|
13688
14007
|
}
|
|
13689
14008
|
}
|
|
13690
14009
|
async function runUploadWorker() {
|
|
14010
|
+
setLogPrefix(`[${process.env[FLOW_ID_ENV] ?? newFlowId()}]`);
|
|
13691
14011
|
appendLog("info", `upload worker started (pid ${process.pid})`);
|
|
13692
14012
|
let raw;
|
|
13693
14013
|
try {
|
|
@@ -13716,19 +14036,22 @@ async function runUploadWorker() {
|
|
|
13716
14036
|
const toolOverride = process.env[TOOL_ENV_FLAG];
|
|
13717
14037
|
if (toolOverride) payload.tool = toolOverride;
|
|
13718
14038
|
appendLog("info", `worker: payload summary: ${summarizePayload(payload)}`);
|
|
14039
|
+
let completed = false;
|
|
13719
14040
|
try {
|
|
13720
|
-
await runUploadInner(payload);
|
|
14041
|
+
completed = await runUploadInner(payload);
|
|
13721
14042
|
} catch (err) {
|
|
13722
14043
|
appendLog(
|
|
13723
14044
|
"error",
|
|
13724
14045
|
`worker: unexpected error: ${err instanceof Error ? err.stack ?? err.message : String(err)}`
|
|
13725
14046
|
);
|
|
13726
14047
|
} finally {
|
|
13727
|
-
|
|
13728
|
-
|
|
13729
|
-
|
|
13730
|
-
|
|
13731
|
-
|
|
14048
|
+
if (completed) {
|
|
14049
|
+
await recordDebugLogCompletion({
|
|
14050
|
+
kind: "agent",
|
|
14051
|
+
tool: fallbackSourceTool(payload),
|
|
14052
|
+
payload
|
|
14053
|
+
});
|
|
14054
|
+
}
|
|
13732
14055
|
try {
|
|
13733
14056
|
await sweepStaleUploadStates();
|
|
13734
14057
|
} catch {
|
|
@@ -13738,17 +14061,17 @@ async function runUploadWorker() {
|
|
|
13738
14061
|
|
|
13739
14062
|
// src/git-traces/index.ts
|
|
13740
14063
|
import { spawn as spawn3 } from "child_process";
|
|
13741
|
-
import
|
|
14064
|
+
import crypto5 from "crypto";
|
|
13742
14065
|
|
|
13743
14066
|
// src/git-traces/handlers.ts
|
|
13744
|
-
import { execFileSync as
|
|
13745
|
-
import
|
|
14067
|
+
import { execFileSync as execFileSync3 } from "child_process";
|
|
14068
|
+
import path18 from "path";
|
|
13746
14069
|
|
|
13747
14070
|
// src/git-traces/git-ops.ts
|
|
13748
|
-
import { execFileSync } from "child_process";
|
|
14071
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
13749
14072
|
import fs12 from "fs";
|
|
13750
14073
|
import os7 from "os";
|
|
13751
|
-
import
|
|
14074
|
+
import path16 from "path";
|
|
13752
14075
|
import { gzipSync } from "zlib";
|
|
13753
14076
|
var GIT_COMMAND_TIMEOUT_MS = 12e4;
|
|
13754
14077
|
var EXEC_OPTS = {
|
|
@@ -13802,7 +14125,7 @@ function isGitTimeoutError(err) {
|
|
|
13802
14125
|
}
|
|
13803
14126
|
function gitBuffer(repoRoot, args, options = {}) {
|
|
13804
14127
|
try {
|
|
13805
|
-
return
|
|
14128
|
+
return execFileSync2("git", args, {
|
|
13806
14129
|
cwd: repoRoot,
|
|
13807
14130
|
...options.env ? { env: options.env } : {},
|
|
13808
14131
|
...options.input !== void 0 ? { input: options.input } : {},
|
|
@@ -13911,7 +14234,7 @@ function removePathsFromIndex(repoRoot, env, paths) {
|
|
|
13911
14234
|
function filterOversizedFilesFromTree(repoRoot, treeSha, options = {}) {
|
|
13912
14235
|
const oversizedFiles = listOversizedTreeFiles(repoRoot, treeSha, options);
|
|
13913
14236
|
if (oversizedFiles.length === 0) return treeSha;
|
|
13914
|
-
const tmpIndex =
|
|
14237
|
+
const tmpIndex = path16.join(
|
|
13915
14238
|
os7.tmpdir(),
|
|
13916
14239
|
`hillclimb-filter-${Date.now()}-${process.pid}`
|
|
13917
14240
|
);
|
|
@@ -13943,7 +14266,7 @@ function buildUntrackedTree(repoRoot, omittedFiles) {
|
|
|
13943
14266
|
for (const relPath of list.split("\0")) {
|
|
13944
14267
|
if (!relPath) continue;
|
|
13945
14268
|
try {
|
|
13946
|
-
const stat = fs12.lstatSync(
|
|
14269
|
+
const stat = fs12.lstatSync(path16.join(repoRoot, relPath));
|
|
13947
14270
|
if (stat.size > MAX_SNAPSHOT_FILE_BYTES) {
|
|
13948
14271
|
recordOmittedSnapshotFile(omittedFiles, {
|
|
13949
14272
|
path: relPath,
|
|
@@ -13958,7 +14281,7 @@ function buildUntrackedTree(repoRoot, omittedFiles) {
|
|
|
13958
14281
|
}
|
|
13959
14282
|
}
|
|
13960
14283
|
if (kept.length === 0) return null;
|
|
13961
|
-
const tmpIndex =
|
|
14284
|
+
const tmpIndex = path16.join(
|
|
13962
14285
|
os7.tmpdir(),
|
|
13963
14286
|
`hillclimb-untracked-${Date.now()}-${process.pid}`
|
|
13964
14287
|
);
|
|
@@ -13988,7 +14311,7 @@ function buildSnapshotTree(repoRoot, stashSha, options = {}) {
|
|
|
13988
14311
|
const untrackedTree = buildUntrackedTree(repoRoot, options.omittedFiles);
|
|
13989
14312
|
if (!untrackedTree || untrackedTree === EMPTY_TREE_SHA)
|
|
13990
14313
|
return filteredTrackedTree;
|
|
13991
|
-
const tmpIndex =
|
|
14314
|
+
const tmpIndex = path16.join(
|
|
13992
14315
|
os7.tmpdir(),
|
|
13993
14316
|
`hillclimb-index-${Date.now()}-${process.pid}`
|
|
13994
14317
|
);
|
|
@@ -14030,7 +14353,7 @@ function createBundleFromTree(repoRoot, treeSha, sessionId, label) {
|
|
|
14030
14353
|
]);
|
|
14031
14354
|
const orphanRef = `refs/hillclimb/bundle/${sessionId}`;
|
|
14032
14355
|
pinRef(repoRoot, orphanRef, orphanCommit);
|
|
14033
|
-
const tmpFile =
|
|
14356
|
+
const tmpFile = path16.join(
|
|
14034
14357
|
os7.tmpdir(),
|
|
14035
14358
|
// Include the pid (like the other temp files in this module) so concurrent
|
|
14036
14359
|
// git-traces workers — e.g. two sessions, or a parent + subagent — don't
|
|
@@ -14229,9 +14552,9 @@ function parseCommitFiles(repoRoot, sha) {
|
|
|
14229
14552
|
oldPath
|
|
14230
14553
|
});
|
|
14231
14554
|
} else {
|
|
14232
|
-
const
|
|
14233
|
-
indexByPath.set(
|
|
14234
|
-
files.push({ path:
|
|
14555
|
+
const path25 = parts[parts.length - 1];
|
|
14556
|
+
indexByPath.set(path25, files.length);
|
|
14557
|
+
files.push({ path: path25, status, additions: 0, deletions: 0 });
|
|
14235
14558
|
}
|
|
14236
14559
|
}
|
|
14237
14560
|
for (const line of numstat.split("\n")) {
|
|
@@ -14297,22 +14620,22 @@ function cleanupSessionRefs(repoRoot, sessionId) {
|
|
|
14297
14620
|
}
|
|
14298
14621
|
|
|
14299
14622
|
// src/git-traces/session-state.ts
|
|
14300
|
-
import
|
|
14623
|
+
import crypto4 from "crypto";
|
|
14301
14624
|
import fs13 from "fs";
|
|
14302
14625
|
import os8 from "os";
|
|
14303
|
-
import
|
|
14626
|
+
import path17 from "path";
|
|
14304
14627
|
var CURRENT_SCHEMA_VERSION3 = 3;
|
|
14305
|
-
var DEFAULT_STATE_DIR2 =
|
|
14306
|
-
var
|
|
14307
|
-
var
|
|
14628
|
+
var DEFAULT_STATE_DIR2 = path17.join(os8.homedir(), ".hillclimb", "git-traces");
|
|
14629
|
+
var LOCK_RETRIES2 = 120;
|
|
14630
|
+
var LOCK_RETRY_DELAY_MS2 = 500;
|
|
14308
14631
|
function stateDir3() {
|
|
14309
14632
|
return process.env.HILLCLIMB_GIT_TRACES_STATE_DIR ?? DEFAULT_STATE_DIR2;
|
|
14310
14633
|
}
|
|
14311
14634
|
function stateFileForRepo(repoRoot, tool, sessionId) {
|
|
14312
|
-
const hash =
|
|
14313
|
-
sessionId ? `${
|
|
14635
|
+
const hash = crypto4.createHash("sha256").update(
|
|
14636
|
+
sessionId ? `${path17.resolve(repoRoot)}\0${tool}\0${sessionId}` : `${path17.resolve(repoRoot)}\0${tool}`
|
|
14314
14637
|
).digest("hex").slice(0, 16);
|
|
14315
|
-
return
|
|
14638
|
+
return path17.join(stateDir3(), `${hash}.json`);
|
|
14316
14639
|
}
|
|
14317
14640
|
function lockFileForRepo(repoRoot, tool) {
|
|
14318
14641
|
return `${stateFileForRepo(repoRoot, tool)}.lock`;
|
|
@@ -14339,14 +14662,14 @@ async function listScopedSessionStates(repoRoot, tool) {
|
|
|
14339
14662
|
const states = [];
|
|
14340
14663
|
for (const entry of entries) {
|
|
14341
14664
|
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
14342
|
-
const file =
|
|
14665
|
+
const file = path17.join(stateDir3(), entry.name);
|
|
14343
14666
|
const state = await readStateFile(file);
|
|
14344
14667
|
if (!state) continue;
|
|
14345
14668
|
if (typeof state.repoRoot !== "string" || typeof state.sessionId !== "string") {
|
|
14346
14669
|
continue;
|
|
14347
14670
|
}
|
|
14348
|
-
if (
|
|
14349
|
-
if (
|
|
14671
|
+
if (path17.resolve(state.repoRoot) !== path17.resolve(repoRoot)) continue;
|
|
14672
|
+
if (path17.resolve(file) !== path17.resolve(stateFileForRepo(repoRoot, tool, state.sessionId))) {
|
|
14350
14673
|
continue;
|
|
14351
14674
|
}
|
|
14352
14675
|
let mtimeMs = 0;
|
|
@@ -14369,14 +14692,14 @@ async function listSessionStatesForSession(tool, sessionId) {
|
|
|
14369
14692
|
const states = [];
|
|
14370
14693
|
for (const entry of entries) {
|
|
14371
14694
|
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
14372
|
-
const file =
|
|
14695
|
+
const file = path17.join(stateDir3(), entry.name);
|
|
14373
14696
|
const state = await readStateFile(file);
|
|
14374
14697
|
if (!state) continue;
|
|
14375
14698
|
if (typeof state.repoRoot !== "string" || typeof state.sessionId !== "string") {
|
|
14376
14699
|
continue;
|
|
14377
14700
|
}
|
|
14378
14701
|
if (state.sessionId !== sessionId) continue;
|
|
14379
|
-
if (
|
|
14702
|
+
if (path17.resolve(file) !== path17.resolve(stateFileForRepo(state.repoRoot, tool, sessionId))) {
|
|
14380
14703
|
continue;
|
|
14381
14704
|
}
|
|
14382
14705
|
let mtimeMs = 0;
|
|
@@ -14432,7 +14755,7 @@ async function deleteSessionState(repoRoot, tool, sessionId) {
|
|
|
14432
14755
|
}
|
|
14433
14756
|
await deleteStateFile(stateFileForRepo(repoRoot, tool));
|
|
14434
14757
|
}
|
|
14435
|
-
async function acquireLock3(repoRoot, tool, retries =
|
|
14758
|
+
async function acquireLock3(repoRoot, tool, retries = LOCK_RETRIES2, delayMs = LOCK_RETRY_DELAY_MS2) {
|
|
14436
14759
|
const lockPath = lockFileForRepo(repoRoot, tool);
|
|
14437
14760
|
await fs13.promises.mkdir(stateDir3(), { recursive: true, mode: 448 });
|
|
14438
14761
|
for (let i = 0; i < retries; i++) {
|
|
@@ -14479,18 +14802,18 @@ var TOOL_LABELS = {
|
|
|
14479
14802
|
async function loadConfiguredRepos() {
|
|
14480
14803
|
const file = await loadProjects();
|
|
14481
14804
|
return Object.entries(file.projects).map(([repoRoot, config]) => ({
|
|
14482
|
-
repoRoot:
|
|
14805
|
+
repoRoot: path18.resolve(repoRoot),
|
|
14483
14806
|
config
|
|
14484
14807
|
})).sort((a, b) => a.repoRoot.localeCompare(b.repoRoot));
|
|
14485
14808
|
}
|
|
14486
14809
|
function repoLabel(repoRoot) {
|
|
14487
|
-
return
|
|
14810
|
+
return path18.basename(repoRoot) || repoRoot;
|
|
14488
14811
|
}
|
|
14489
14812
|
function resolveCwd2(payload) {
|
|
14490
|
-
return payload
|
|
14813
|
+
return resolveHookCwd(payload);
|
|
14491
14814
|
}
|
|
14492
14815
|
function resolveSessionId2(payload) {
|
|
14493
|
-
return payload
|
|
14816
|
+
return resolveHookSessionId(payload);
|
|
14494
14817
|
}
|
|
14495
14818
|
function epochPrefix(epoch) {
|
|
14496
14819
|
return `epoch-${String(epoch).padStart(3, "0")}`;
|
|
@@ -14506,7 +14829,7 @@ function captureHeadSha(cwd) {
|
|
|
14506
14829
|
}
|
|
14507
14830
|
}
|
|
14508
14831
|
function execGit(cwd, args) {
|
|
14509
|
-
return
|
|
14832
|
+
return execFileSync3("git", args, {
|
|
14510
14833
|
cwd,
|
|
14511
14834
|
stdio: ["pipe", "pipe", "pipe"],
|
|
14512
14835
|
timeout: 3e4,
|
|
@@ -14699,27 +15022,34 @@ async function createGitTracesContribution(params) {
|
|
|
14699
15022
|
const epochSeconds = formatEpochSeconds3(now);
|
|
14700
15023
|
const shortId = state.sessionId.slice(0, 12);
|
|
14701
15024
|
const repoName = repoLabel(repoRoot);
|
|
14702
|
-
|
|
14703
|
-
|
|
14704
|
-
|
|
14705
|
-
|
|
15025
|
+
let contributionId = state.contributionId;
|
|
15026
|
+
if (!contributionId) {
|
|
15027
|
+
const contribution = await client.createContribution(config.projectId, {
|
|
15028
|
+
contributionTypeSlug: GIT_TRACES_SLUG,
|
|
15029
|
+
title: `${toolLabel2} session ${shortId} \u2014 ${repoName} \u2014 ${epochSeconds}`,
|
|
15030
|
+
body: `Session ID: ${state.sessionId}
|
|
14706
15031
|
Tool: ${toolLabel2}
|
|
14707
15032
|
Repo: ${repoRoot}
|
|
14708
15033
|
Uploaded: ${now.toISOString()}`
|
|
14709
|
-
|
|
15034
|
+
});
|
|
15035
|
+
contributionId = contribution.id;
|
|
15036
|
+
state.contributionId = contributionId;
|
|
15037
|
+
state.baselineUploaded = false;
|
|
15038
|
+
await writeSessionState(state, tool);
|
|
15039
|
+
}
|
|
14710
15040
|
const uploaded = await uploadEpochBaselineArtifacts({
|
|
14711
15041
|
client,
|
|
14712
|
-
contributionId
|
|
15042
|
+
contributionId,
|
|
14713
15043
|
epoch: 1,
|
|
14714
15044
|
artifacts
|
|
14715
15045
|
});
|
|
14716
15046
|
if (!uploaded) return null;
|
|
14717
|
-
await client.submitContribution(
|
|
15047
|
+
await client.submitContribution(contributionId);
|
|
14718
15048
|
appendLog(
|
|
14719
15049
|
"info",
|
|
14720
|
-
`git-traces: baseline uploaded (repo=${repoRoot}, project=${config.projectId}, contribution=${
|
|
15050
|
+
`git-traces: baseline uploaded (repo=${repoRoot}, project=${config.projectId}, contribution=${contributionId}, epoch=1, bundleBytes=${artifacts.bundleBuffer.byteLength}, metadataBytes=${artifacts.metadataBuffer.byteLength})`
|
|
14721
15051
|
);
|
|
14722
|
-
return
|
|
15052
|
+
return contributionId;
|
|
14723
15053
|
}
|
|
14724
15054
|
async function uploadEpochBaseline(params) {
|
|
14725
15055
|
const artifacts = buildEpochBaselineArtifacts(params);
|
|
@@ -14760,6 +15090,7 @@ async function initializeSession(repoRoot, tool, sessionId) {
|
|
|
14760
15090
|
schemaVersion: CURRENT_SCHEMA_VERSION3,
|
|
14761
15091
|
sessionId,
|
|
14762
15092
|
contributionId: null,
|
|
15093
|
+
baselineUploaded: false,
|
|
14763
15094
|
baselineSha: frozen.baselineSha,
|
|
14764
15095
|
baselineTreeSha: frozen.baselineTreeSha,
|
|
14765
15096
|
baselineMetadata: frozen.baselineMetadata,
|
|
@@ -14920,6 +15251,7 @@ async function registerInitialContribution(params) {
|
|
|
14920
15251
|
});
|
|
14921
15252
|
if (!contributionId) return false;
|
|
14922
15253
|
state.contributionId = contributionId;
|
|
15254
|
+
state.baselineUploaded = true;
|
|
14923
15255
|
state.baselineTreeSha = artifacts.baselineTreeSha;
|
|
14924
15256
|
state.lastSnapshotTreeSha = artifacts.baselineTreeSha;
|
|
14925
15257
|
await writeSessionState(state, tool);
|
|
@@ -14961,7 +15293,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
|
|
|
14961
15293
|
if (currentHeadSha !== state.headSha) {
|
|
14962
15294
|
const client2 = await loadRepoClient(repo);
|
|
14963
15295
|
if (!client2) return "skipped";
|
|
14964
|
-
if (state.contributionId === null) {
|
|
15296
|
+
if (state.contributionId === null || !state.baselineUploaded) {
|
|
14965
15297
|
const artifacts2 = buildInitialBaselineArtifactsForState(
|
|
14966
15298
|
repoRoot,
|
|
14967
15299
|
tool,
|
|
@@ -15044,7 +15376,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
|
|
|
15044
15376
|
const nextTurnCount = state.turnCount + 1;
|
|
15045
15377
|
const turnLabel = turnSuffix(nextTurnCount);
|
|
15046
15378
|
const filename = `${prefix}-${turnLabel}-${recordedAt}.patch.gz`;
|
|
15047
|
-
if (state.contributionId === null && !canUploadFile(filename, patchBuffer)) {
|
|
15379
|
+
if ((state.contributionId === null || !state.baselineUploaded) && !canUploadFile(filename, patchBuffer)) {
|
|
15048
15380
|
appendLog(
|
|
15049
15381
|
"warn",
|
|
15050
15382
|
`git-traces: first changed turn skipped before contribution creation (repo=${repoRoot}, project=${config.projectId}, reason=patch-too-large)`
|
|
@@ -15053,7 +15385,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
|
|
|
15053
15385
|
}
|
|
15054
15386
|
const client = await loadRepoClient(repo);
|
|
15055
15387
|
if (!client) return "skipped";
|
|
15056
|
-
if (state.contributionId === null) {
|
|
15388
|
+
if (state.contributionId === null || !state.baselineUploaded) {
|
|
15057
15389
|
const artifacts = buildInitialBaselineArtifactsForState(
|
|
15058
15390
|
repoRoot,
|
|
15059
15391
|
tool,
|
|
@@ -15118,6 +15450,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
|
|
|
15118
15450
|
{
|
|
15119
15451
|
...state,
|
|
15120
15452
|
contributionId: null,
|
|
15453
|
+
baselineUploaded: false,
|
|
15121
15454
|
lastSnapshotSha: state.baselineSha,
|
|
15122
15455
|
lastSnapshotTreeSha: state.baselineTreeSha,
|
|
15123
15456
|
turnCount: 0
|
|
@@ -15156,7 +15489,7 @@ async function handleStop(payload, tool) {
|
|
|
15156
15489
|
if (sessionId) {
|
|
15157
15490
|
const storedStates = await listSessionStatesForSession(tool, sessionId);
|
|
15158
15491
|
for (const { state } of storedStates) {
|
|
15159
|
-
const repo = repoByRoot.get(
|
|
15492
|
+
const repo = repoByRoot.get(path18.resolve(state.repoRoot));
|
|
15160
15493
|
if (!repo) {
|
|
15161
15494
|
missingConfig++;
|
|
15162
15495
|
appendLog(
|
|
@@ -15218,21 +15551,62 @@ async function handleSessionEnd(payload, tool) {
|
|
|
15218
15551
|
const sessionId = resolveSessionId2(payload);
|
|
15219
15552
|
const project = cwd ? await findProjectForCwd(cwd) : null;
|
|
15220
15553
|
const triggerRepo = project?.repoRoot ?? cwd ?? "<none>";
|
|
15554
|
+
const recordedAt = Date.now();
|
|
15221
15555
|
const repoRoots = [];
|
|
15222
15556
|
if (sessionId) {
|
|
15223
15557
|
const states = await listSessionStatesForSession(tool, sessionId);
|
|
15224
15558
|
for (const { state } of states) {
|
|
15225
|
-
repoRoots.push(
|
|
15559
|
+
repoRoots.push(path18.resolve(state.repoRoot));
|
|
15226
15560
|
}
|
|
15227
15561
|
}
|
|
15228
15562
|
if (repoRoots.length === 0 && cwd) {
|
|
15229
15563
|
repoRoots.push(project?.repoRoot ?? cwd);
|
|
15230
15564
|
}
|
|
15231
15565
|
if (repoRoots.length === 0) return;
|
|
15566
|
+
const repos = await loadConfiguredRepos();
|
|
15567
|
+
const repoByRoot = new Map(repos.map((repo) => [repo.repoRoot, repo]));
|
|
15232
15568
|
let cleaned = 0;
|
|
15233
15569
|
let noState = 0;
|
|
15570
|
+
let uploaded = 0;
|
|
15571
|
+
let unchanged = 0;
|
|
15572
|
+
let skipped = 0;
|
|
15234
15573
|
let failed = 0;
|
|
15235
15574
|
for (const repoRoot of repoRoots) {
|
|
15575
|
+
const repo = repoByRoot.get(path18.resolve(repoRoot)) ?? (project && path18.resolve(project.repoRoot) === path18.resolve(repoRoot) ? { repoRoot: project.repoRoot, config: project.config } : null);
|
|
15576
|
+
if (!repo) {
|
|
15577
|
+
skipped++;
|
|
15578
|
+
appendLog(
|
|
15579
|
+
"warn",
|
|
15580
|
+
`git-traces: preserving SessionEnd state for repo ${repoRoot} (reason=missing-config)`
|
|
15581
|
+
);
|
|
15582
|
+
continue;
|
|
15583
|
+
}
|
|
15584
|
+
const finalOutcome = await processStopRepo(
|
|
15585
|
+
repo,
|
|
15586
|
+
tool,
|
|
15587
|
+
sessionId,
|
|
15588
|
+
recordedAt
|
|
15589
|
+
);
|
|
15590
|
+
if (finalOutcome === "uploaded") uploaded++;
|
|
15591
|
+
else if (finalOutcome === "unchanged") unchanged++;
|
|
15592
|
+
else if (finalOutcome === "skipped") {
|
|
15593
|
+
skipped++;
|
|
15594
|
+
appendLog(
|
|
15595
|
+
"warn",
|
|
15596
|
+
`git-traces: preserving SessionEnd state for repo ${repoRoot} after skipped final upload`
|
|
15597
|
+
);
|
|
15598
|
+
continue;
|
|
15599
|
+
} else if (finalOutcome === "failed") {
|
|
15600
|
+
failed++;
|
|
15601
|
+
appendLog(
|
|
15602
|
+
"warn",
|
|
15603
|
+
`git-traces: preserving SessionEnd state for repo ${repoRoot} after failed final upload`
|
|
15604
|
+
);
|
|
15605
|
+
continue;
|
|
15606
|
+
} else {
|
|
15607
|
+
noState++;
|
|
15608
|
+
continue;
|
|
15609
|
+
}
|
|
15236
15610
|
const outcome = await cleanupSessionStateForRepo(repoRoot, tool, sessionId);
|
|
15237
15611
|
if (outcome === "cleaned") cleaned++;
|
|
15238
15612
|
else if (outcome === "no-state") noState++;
|
|
@@ -15240,16 +15614,16 @@ async function handleSessionEnd(payload, tool) {
|
|
|
15240
15614
|
}
|
|
15241
15615
|
appendLog(
|
|
15242
15616
|
"info",
|
|
15243
|
-
`git-traces: SessionEnd summary (session=${sessionId ?? "<none>"}, tool=${tool}, triggerRepo=${triggerRepo}, states=${repoRoots.length}, cleaned=${cleaned}, noState=${noState}, failed=${failed})`
|
|
15617
|
+
`git-traces: SessionEnd summary (session=${sessionId ?? "<none>"}, tool=${tool}, triggerRepo=${triggerRepo}, states=${repoRoots.length}, uploaded=${uploaded}, unchanged=${unchanged}, skipped=${skipped}, cleaned=${cleaned}, noState=${noState}, failed=${failed})`
|
|
15244
15618
|
);
|
|
15245
15619
|
}
|
|
15246
15620
|
|
|
15247
15621
|
// src/git-traces/index.ts
|
|
15248
15622
|
var WORKER_ENV_FLAG2 = "HILLCLIMB_GIT_TRACES_WORKER";
|
|
15249
15623
|
var TOOL_ENV_FLAG2 = "HILLCLIMB_GIT_TRACES_TOOL";
|
|
15250
|
-
var
|
|
15251
|
-
function
|
|
15252
|
-
return
|
|
15624
|
+
var FLOW_ID_ENV2 = "HILLCLIMB_GIT_TRACES_FLOW";
|
|
15625
|
+
function newFlowId2() {
|
|
15626
|
+
return crypto5.randomBytes(3).toString("hex");
|
|
15253
15627
|
}
|
|
15254
15628
|
var KNOWN_TOOLS = /* @__PURE__ */ new Set([
|
|
15255
15629
|
"claude",
|
|
@@ -15388,7 +15762,7 @@ async function runGitTraces() {
|
|
|
15388
15762
|
await runGitTracesWorker();
|
|
15389
15763
|
return;
|
|
15390
15764
|
}
|
|
15391
|
-
const flowId =
|
|
15765
|
+
const flowId = newFlowId2();
|
|
15392
15766
|
setLogPrefix(`[${flowId}]`);
|
|
15393
15767
|
const toolArg = parseToolArg2(process.argv.slice(2));
|
|
15394
15768
|
appendLog(
|
|
@@ -15431,7 +15805,7 @@ async function runGitTraces() {
|
|
|
15431
15805
|
...process.env,
|
|
15432
15806
|
[WORKER_ENV_FLAG2]: "1",
|
|
15433
15807
|
[TOOL_ENV_FLAG2]: tool,
|
|
15434
|
-
[
|
|
15808
|
+
[FLOW_ID_ENV2]: flowId
|
|
15435
15809
|
}
|
|
15436
15810
|
}
|
|
15437
15811
|
);
|
|
@@ -15453,7 +15827,7 @@ async function runGitTraces() {
|
|
|
15453
15827
|
}
|
|
15454
15828
|
}
|
|
15455
15829
|
async function runGitTracesWorker() {
|
|
15456
|
-
setLogPrefix(`[${process.env[
|
|
15830
|
+
setLogPrefix(`[${process.env[FLOW_ID_ENV2] ?? newFlowId2()}]`);
|
|
15457
15831
|
const tool = process.env[TOOL_ENV_FLAG2] ?? parseToolArg2(process.argv.slice(2)) ?? null;
|
|
15458
15832
|
appendLog(
|
|
15459
15833
|
"info",
|
|
@@ -15536,14 +15910,14 @@ ${stack}` : ""}`
|
|
|
15536
15910
|
|
|
15537
15911
|
// src/outputs/zip.ts
|
|
15538
15912
|
import fs15 from "fs";
|
|
15539
|
-
import
|
|
15913
|
+
import path20 from "path";
|
|
15540
15914
|
import archiver2 from "archiver";
|
|
15541
15915
|
|
|
15542
15916
|
// src/outputs/downloads.ts
|
|
15543
15917
|
import { execSync as execSync2 } from "child_process";
|
|
15544
15918
|
import fs14 from "fs";
|
|
15545
15919
|
import os9 from "os";
|
|
15546
|
-
import
|
|
15920
|
+
import path19 from "path";
|
|
15547
15921
|
function getDownloadsFolder() {
|
|
15548
15922
|
const home = os9.homedir();
|
|
15549
15923
|
if (process.platform === "linux") {
|
|
@@ -15556,7 +15930,7 @@ function getDownloadsFolder() {
|
|
|
15556
15930
|
} catch {
|
|
15557
15931
|
}
|
|
15558
15932
|
}
|
|
15559
|
-
const downloads =
|
|
15933
|
+
const downloads = path19.join(home, "Downloads");
|
|
15560
15934
|
if (fs14.existsSync(downloads)) return downloads;
|
|
15561
15935
|
return home;
|
|
15562
15936
|
}
|
|
@@ -15566,11 +15940,11 @@ function sanitizeFilename(name) {
|
|
|
15566
15940
|
return name.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
15567
15941
|
}
|
|
15568
15942
|
function getUniqueFilename(dir, base, ext) {
|
|
15569
|
-
let candidate =
|
|
15943
|
+
let candidate = path20.join(dir, `${base}${ext}`);
|
|
15570
15944
|
if (!fs15.existsSync(candidate)) return candidate;
|
|
15571
15945
|
let i = 1;
|
|
15572
15946
|
while (fs15.existsSync(candidate)) {
|
|
15573
|
-
candidate =
|
|
15947
|
+
candidate = path20.join(dir, `${base}-${i}${ext}`);
|
|
15574
15948
|
i++;
|
|
15575
15949
|
}
|
|
15576
15950
|
return candidate;
|
|
@@ -15580,7 +15954,7 @@ var ZipOutput = class {
|
|
|
15580
15954
|
label = "Save as .zip to Downloads";
|
|
15581
15955
|
async emit(group, options) {
|
|
15582
15956
|
const downloadsDir = getDownloadsFolder();
|
|
15583
|
-
const repoName = sanitizeFilename(
|
|
15957
|
+
const repoName = sanitizeFilename(path20.basename(group.repoPath));
|
|
15584
15958
|
const timeRange = options.timeRange;
|
|
15585
15959
|
const rangePart = timeRange?.label ?? "all";
|
|
15586
15960
|
const epochSeconds = Math.floor(Date.now() / 1e3);
|
|
@@ -15782,11 +16156,11 @@ async function confirmExport(group, output) {
|
|
|
15782
16156
|
// src/sources/claude.ts
|
|
15783
16157
|
import fs16 from "fs";
|
|
15784
16158
|
import os10 from "os";
|
|
15785
|
-
import
|
|
15786
|
-
import
|
|
16159
|
+
import path21 from "path";
|
|
16160
|
+
import readline2 from "readline";
|
|
15787
16161
|
var SKIP_DIRS = /* @__PURE__ */ new Set(["tool-results"]);
|
|
15788
16162
|
async function resolveRepoPath(projectDir) {
|
|
15789
|
-
const indexPath =
|
|
16163
|
+
const indexPath = path21.join(projectDir, "sessions-index.json");
|
|
15790
16164
|
try {
|
|
15791
16165
|
const raw = await fs16.promises.readFile(indexPath, "utf-8");
|
|
15792
16166
|
const data = JSON.parse(raw);
|
|
@@ -15801,7 +16175,7 @@ async function resolveRepoPath(projectDir) {
|
|
|
15801
16175
|
});
|
|
15802
16176
|
for (const entry of entries) {
|
|
15803
16177
|
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
|
|
15804
|
-
const cwd = await extractCwdFromJsonl(
|
|
16178
|
+
const cwd = await extractCwdFromJsonl(path21.join(projectDir, entry.name));
|
|
15805
16179
|
if (cwd) {
|
|
15806
16180
|
cwdCounts.set(cwd, (cwdCounts.get(cwd) ?? 0) + 1);
|
|
15807
16181
|
}
|
|
@@ -15821,7 +16195,7 @@ async function resolveRepoPath(projectDir) {
|
|
|
15821
16195
|
}
|
|
15822
16196
|
async function extractCwdFromJsonl(filePath) {
|
|
15823
16197
|
const stream = fs16.createReadStream(filePath, { encoding: "utf-8" });
|
|
15824
|
-
const rl =
|
|
16198
|
+
const rl = readline2.createInterface({ input: stream, crlfDelay: Infinity });
|
|
15825
16199
|
try {
|
|
15826
16200
|
for await (const line of rl) {
|
|
15827
16201
|
if (!line.trim()) continue;
|
|
@@ -15847,7 +16221,7 @@ async function collectFiles(dir, baseDir, sourceName, repoPath, results) {
|
|
|
15847
16221
|
return;
|
|
15848
16222
|
}
|
|
15849
16223
|
for (const entry of entries) {
|
|
15850
|
-
const fullPath =
|
|
16224
|
+
const fullPath = path21.join(dir, entry.name);
|
|
15851
16225
|
if (entry.isDirectory()) {
|
|
15852
16226
|
if (SKIP_DIRS.has(entry.name)) continue;
|
|
15853
16227
|
await collectFiles(fullPath, baseDir, sourceName, repoPath, results);
|
|
@@ -15869,7 +16243,7 @@ function fallbackDecode(encodedName) {
|
|
|
15869
16243
|
var ClaudeSource = class {
|
|
15870
16244
|
name = "claude";
|
|
15871
16245
|
async scan() {
|
|
15872
|
-
const baseDir =
|
|
16246
|
+
const baseDir = path21.join(os10.homedir(), ".claude", "projects");
|
|
15873
16247
|
try {
|
|
15874
16248
|
await fs16.promises.access(baseDir);
|
|
15875
16249
|
} catch {
|
|
@@ -15881,7 +16255,7 @@ var ClaudeSource = class {
|
|
|
15881
16255
|
const dirEntries = projectDirs.filter((d) => d.isDirectory());
|
|
15882
16256
|
const resultArrays = await Promise.all(
|
|
15883
16257
|
dirEntries.map(async (dir) => {
|
|
15884
|
-
const projectPath =
|
|
16258
|
+
const projectPath = path21.join(baseDir, dir.name);
|
|
15885
16259
|
const repoPath = await resolveRepoPath(projectPath) ?? fallbackDecode(dir.name);
|
|
15886
16260
|
const files = [];
|
|
15887
16261
|
await collectFiles(
|
|
@@ -15901,11 +16275,11 @@ var ClaudeSource = class {
|
|
|
15901
16275
|
// src/sources/codex.ts
|
|
15902
16276
|
import fs17 from "fs";
|
|
15903
16277
|
import os11 from "os";
|
|
15904
|
-
import
|
|
15905
|
-
import
|
|
16278
|
+
import path22 from "path";
|
|
16279
|
+
import readline3 from "readline";
|
|
15906
16280
|
async function parseSessionMeta(filePath) {
|
|
15907
16281
|
const stream = fs17.createReadStream(filePath, { encoding: "utf-8" });
|
|
15908
|
-
const rl =
|
|
16282
|
+
const rl = readline3.createInterface({ input: stream, crlfDelay: Infinity });
|
|
15909
16283
|
try {
|
|
15910
16284
|
for await (const line of rl) {
|
|
15911
16285
|
if (!line.trim()) continue;
|
|
@@ -15934,7 +16308,7 @@ async function findJsonlFiles(dir) {
|
|
|
15934
16308
|
return;
|
|
15935
16309
|
}
|
|
15936
16310
|
for (const entry of entries) {
|
|
15937
|
-
const full =
|
|
16311
|
+
const full = path22.join(d, entry.name);
|
|
15938
16312
|
if (entry.isDirectory()) {
|
|
15939
16313
|
await walk(full);
|
|
15940
16314
|
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
@@ -15953,7 +16327,7 @@ async function loadHistory(historyPath) {
|
|
|
15953
16327
|
return map;
|
|
15954
16328
|
}
|
|
15955
16329
|
const stream = fs17.createReadStream(historyPath, { encoding: "utf-8" });
|
|
15956
|
-
const rl =
|
|
16330
|
+
const rl = readline3.createInterface({ input: stream, crlfDelay: Infinity });
|
|
15957
16331
|
try {
|
|
15958
16332
|
for await (const line of rl) {
|
|
15959
16333
|
if (!line.trim()) continue;
|
|
@@ -15979,14 +16353,14 @@ async function loadHistory(historyPath) {
|
|
|
15979
16353
|
var CodexSource = class {
|
|
15980
16354
|
name = "codex";
|
|
15981
16355
|
async scan() {
|
|
15982
|
-
const codexDir =
|
|
15983
|
-
const sessionsDir =
|
|
16356
|
+
const codexDir = path22.join(os11.homedir(), ".codex");
|
|
16357
|
+
const sessionsDir = path22.join(codexDir, "sessions");
|
|
15984
16358
|
try {
|
|
15985
16359
|
await fs17.promises.access(sessionsDir);
|
|
15986
16360
|
} catch {
|
|
15987
16361
|
return [];
|
|
15988
16362
|
}
|
|
15989
|
-
const historyPath =
|
|
16363
|
+
const historyPath = path22.join(codexDir, "history.jsonl");
|
|
15990
16364
|
const [jsonlFiles, historyMap] = await Promise.all([
|
|
15991
16365
|
findJsonlFiles(sessionsDir),
|
|
15992
16366
|
loadHistory(historyPath)
|
|
@@ -16009,8 +16383,8 @@ var CodexSource = class {
|
|
|
16009
16383
|
});
|
|
16010
16384
|
const historyLines = historyMap.get(meta.sessionId);
|
|
16011
16385
|
if (historyLines) {
|
|
16012
|
-
const sessionDir =
|
|
16013
|
-
const historyAbsPath =
|
|
16386
|
+
const sessionDir = path22.relative(sessionsDir, path22.dirname(filePath));
|
|
16387
|
+
const historyAbsPath = path22.join(
|
|
16014
16388
|
sessionsDir,
|
|
16015
16389
|
sessionDir,
|
|
16016
16390
|
`history-${meta.sessionId}.jsonl`
|
|
@@ -16032,16 +16406,16 @@ var CodexSource = class {
|
|
|
16032
16406
|
// src/sources/copilotChat.ts
|
|
16033
16407
|
import fs18 from "fs";
|
|
16034
16408
|
import os12 from "os";
|
|
16035
|
-
import
|
|
16409
|
+
import path23 from "path";
|
|
16036
16410
|
import { fileURLToPath } from "url";
|
|
16037
16411
|
function vsCodeUserDirs() {
|
|
16038
16412
|
const home = os12.homedir();
|
|
16039
16413
|
const dirs = [
|
|
16040
|
-
|
|
16041
|
-
|
|
16414
|
+
path23.join(home, "Library", "Application Support", "Code", "User"),
|
|
16415
|
+
path23.join(home, ".config", "Code", "User")
|
|
16042
16416
|
];
|
|
16043
16417
|
if (process.env.APPDATA) {
|
|
16044
|
-
dirs.push(
|
|
16418
|
+
dirs.push(path23.join(process.env.APPDATA, "Code", "User"));
|
|
16045
16419
|
}
|
|
16046
16420
|
return dirs;
|
|
16047
16421
|
}
|
|
@@ -16078,7 +16452,7 @@ var CopilotChatSource = class {
|
|
|
16078
16452
|
async scan() {
|
|
16079
16453
|
const results = [];
|
|
16080
16454
|
for (const userDir of vsCodeUserDirs()) {
|
|
16081
|
-
const workspaceStorage =
|
|
16455
|
+
const workspaceStorage = path23.join(userDir, "workspaceStorage");
|
|
16082
16456
|
let hashDirs;
|
|
16083
16457
|
try {
|
|
16084
16458
|
hashDirs = await fs18.promises.readdir(workspaceStorage, {
|
|
@@ -16089,8 +16463,8 @@ var CopilotChatSource = class {
|
|
|
16089
16463
|
}
|
|
16090
16464
|
for (const hash of hashDirs) {
|
|
16091
16465
|
if (!hash.isDirectory()) continue;
|
|
16092
|
-
const wsRoot =
|
|
16093
|
-
const transcriptsDir =
|
|
16466
|
+
const wsRoot = path23.join(workspaceStorage, hash.name);
|
|
16467
|
+
const transcriptsDir = path23.join(
|
|
16094
16468
|
wsRoot,
|
|
16095
16469
|
"GitHub.copilot-chat",
|
|
16096
16470
|
"transcripts"
|
|
@@ -16104,7 +16478,7 @@ var CopilotChatSource = class {
|
|
|
16104
16478
|
continue;
|
|
16105
16479
|
}
|
|
16106
16480
|
const repoPath = await readWorkspaceFolder(
|
|
16107
|
-
|
|
16481
|
+
path23.join(wsRoot, "workspace.json")
|
|
16108
16482
|
);
|
|
16109
16483
|
if (!repoPath) continue;
|
|
16110
16484
|
for (const entry of transcriptEntries) {
|
|
@@ -16112,7 +16486,7 @@ var CopilotChatSource = class {
|
|
|
16112
16486
|
const sessionId = entry.name.slice(0, -".jsonl".length);
|
|
16113
16487
|
results.push({
|
|
16114
16488
|
sourceName: this.name,
|
|
16115
|
-
absolutePath:
|
|
16489
|
+
absolutePath: path23.join(transcriptsDir, entry.name),
|
|
16116
16490
|
repoPath,
|
|
16117
16491
|
metadata: { sessionId }
|
|
16118
16492
|
});
|
|
@@ -16179,10 +16553,10 @@ async function runInteractive() {
|
|
|
16179
16553
|
s.start(`Scanning ${source.name} logs...`);
|
|
16180
16554
|
const allFiles = await source.scan();
|
|
16181
16555
|
const allGroups = await mergeByRepo(allFiles);
|
|
16182
|
-
const repoRoot =
|
|
16556
|
+
const repoRoot = path24.resolve(repo.root);
|
|
16183
16557
|
const matching = allGroups.filter((g) => {
|
|
16184
|
-
const resolved =
|
|
16185
|
-
return resolved === repoRoot || resolved.startsWith(repoRoot +
|
|
16558
|
+
const resolved = path24.resolve(g.repoPath);
|
|
16559
|
+
return resolved === repoRoot || resolved.startsWith(repoRoot + path24.sep);
|
|
16186
16560
|
});
|
|
16187
16561
|
if (matching.length === 0) {
|
|
16188
16562
|
s.stop(`No ${source.name} logs found for ${repo.name}.`);
|
|
@@ -16213,7 +16587,7 @@ async function runInteractive() {
|
|
|
16213
16587
|
}
|
|
16214
16588
|
}
|
|
16215
16589
|
const envFileNames = await discoverEnvFiles(repoRoot);
|
|
16216
|
-
const envFilePaths = envFileNames.map((n) =>
|
|
16590
|
+
const envFilePaths = envFileNames.map((n) => path24.join(repoRoot, n));
|
|
16217
16591
|
const additionalFiles = await promptSecretFiles(envFileNames);
|
|
16218
16592
|
const secretResult = await collectSecrets(
|
|
16219
16593
|
repoRoot,
|