hillclimb 0.4.4 → 0.5.1

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.
Files changed (2) hide show
  1. package/dist/cli.js +907 -394
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import fs18 from "fs";
5
- import path22 from "path";
4
+ import fs19 from "fs";
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/gitignore.ts
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 path4.join(configDir(), "identity.json");
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 fs3.promises.readFile(identityPath(), "utf-8");
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 fs3.promises.mkdir(configDir(), { recursive: true, mode: 448 });
154
+ await fs2.promises.mkdir(configDir(), { recursive: true, mode: 448 });
203
155
  const tmp = `${identityPath()}.tmp`;
204
- await fs3.promises.writeFile(tmp, JSON.stringify(file, null, 2), {
156
+ await fs2.promises.writeFile(tmp, JSON.stringify(file, null, 2), {
205
157
  mode: 384
206
158
  });
207
- await fs3.promises.rename(tmp, identityPath());
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 fs4 from "fs";
246
- import path5 from "path";
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 path5.join(configDir(), "logs");
249
+ return path4.join(configDir(), "logs");
298
250
  }
299
251
  function todayLogPath() {
300
- return path5.join(logsDir(), `${pacificDateString(/* @__PURE__ */ new Date())}.log`);
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
- fs4.mkdirSync(logsDir(), { recursive: true, mode: 448 });
312
- fs4.appendFileSync(todayLogPath(), line);
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;
@@ -617,6 +621,15 @@ var TOOLS = [
617
621
  command: HOOK_CMD("upload"),
618
622
  timeout: CLAUDE_SESSIONEND_UPLOAD_TIMEOUT_SECONDS
619
623
  },
624
+ // Per-turn transcript capture. SessionEnd is the clean one-shot
625
+ // upload, but it never fires for sessions that are killed or never
626
+ // cleanly closed (e.g. long-lived remote sessions), so those
627
+ // transcripts are lost entirely. Uploading on Stop captures them
628
+ // turn-by-turn. `--tool=claude` is required: resolveSourceTool()
629
+ // defaults a bare `upload` on a Stop event to Codex, and the explicit
630
+ // tool also keys the debug-log agent/git completion correlation to
631
+ // "claude" (see expectedKinds() in debug-logs.ts).
632
+ { eventName: "Stop", command: `${HOOK_CMD("upload")} --tool=claude` },
620
633
  { eventName: "SessionStart", command: GIT_TRACES_CMD("claude") },
621
634
  { eventName: "Stop", command: GIT_TRACES_CMD("claude") },
622
635
  { eventName: "SessionEnd", command: GIT_TRACES_CMD("claude") }
@@ -630,6 +643,10 @@ var TOOLS = [
630
643
  format: "cursor",
631
644
  events: [
632
645
  { eventName: "sessionEnd", command: HOOK_CMD("upload") },
646
+ // Per-turn capture, mirroring Claude. Cursor's stop payload carries a
647
+ // populated transcript_path (verified on Cursor 3.0.16); --tool=cursor
648
+ // is required so a bare Stop upload isn't misattributed to Codex.
649
+ { eventName: "stop", command: `${HOOK_CMD("upload")} --tool=cursor` },
633
650
  { eventName: "sessionStart", command: GIT_TRACES_CMD("cursor") },
634
651
  { eventName: "stop", command: GIT_TRACES_CMD("cursor") },
635
652
  { eventName: "sessionEnd", command: GIT_TRACES_CMD("cursor") }
@@ -710,20 +727,41 @@ function copilotChatDetect() {
710
727
  return candidates.some(isDir);
711
728
  }
712
729
  function ensureHooks(settings) {
713
- if (!settings.hooks) settings.hooks = {};
730
+ if (!settings.hooks || typeof settings.hooks !== "object" || Array.isArray(settings.hooks)) {
731
+ settings.hooks = {};
732
+ }
714
733
  return settings.hooks;
715
734
  }
716
735
  function settingsPath(repoRoot, def) {
717
736
  return path6.join(repoRoot, def.settingsFile);
718
737
  }
719
- async function readJson(file) {
738
+ async function readJson(file, options = {}) {
720
739
  try {
721
740
  const raw = await fs5.promises.readFile(file, "utf-8");
741
+ if (!raw.trim()) return {};
722
742
  const parsed = JSON.parse(raw);
723
743
  if (parsed && typeof parsed === "object") return parsed;
724
744
  return {};
725
745
  } catch (err) {
726
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 {};
727
765
  throw err;
728
766
  }
729
767
  }
@@ -854,7 +892,7 @@ function copilotUninstallMatching(settings, eventName, predicate) {
854
892
  }
855
893
  return true;
856
894
  }
857
- var OPENCODE_PLUGIN_VERSION = 6;
895
+ var OPENCODE_PLUGIN_VERSION = 7;
858
896
  var OPENCODE_PLUGIN_MARKER = `// HILLCLIMB_OPENCODE_PLUGIN_VERSION=${OPENCODE_PLUGIN_VERSION}`;
859
897
  var OPENCODE_PLUGIN_CONTENT = `${OPENCODE_PLUGIN_MARKER}
860
898
  // Auto-installed by \`npx hillclimb\`. Do not edit manually \u2014 re-running
@@ -983,12 +1021,29 @@ export const HillclimbPlugin = async ({ directory }) => ({
983
1021
  }
984
1022
 
985
1023
  if (type === "session.idle" && sessionID) {
986
- // Per-turn git-traces snapshot only. Transcript upload is deferred to
987
- // session.deleted (user-ended) or server.instance.disposed (process exit)
988
- // so a single session produces one contribution, not one per turn.
1024
+ // Per-turn snapshot: upload the transcript-so-far AND take a git-traces
1025
+ // snapshot. The CLI keeps this to one contribution per session (reuse),
1026
+ // attaching the latest transcript on each turn, so this is one
1027
+ // contribution per session, not per turn. Keep the buffer \u2014 it's dropped
1028
+ // only on session.deleted / server.instance.disposed.
1029
+ //
1030
+ // Pass transcript_path to BOTH spawns: the debug-log eventId for a stop
1031
+ // event folds in transcript_path, so diverging payloads would orphan the
1032
+ // two completions and double-upload the debug log.
1033
+ const transcriptPath = writeTranscript(sessionID);
1034
+ if (transcriptPath) {
1035
+ spawnHillclimb("upload", {
1036
+ session_id: sessionID,
1037
+ cwd,
1038
+ transcript_path: transcriptPath,
1039
+ hook_event_name: "session.idle",
1040
+ tool: TOOL,
1041
+ });
1042
+ }
989
1043
  spawnHillclimb("git-traces --tool=opencode", {
990
1044
  session_id: sessionID,
991
1045
  cwd,
1046
+ transcript_path: transcriptPath || undefined,
992
1047
  hook_event_name: "session.idle",
993
1048
  tool: TOOL,
994
1049
  });
@@ -1137,6 +1192,19 @@ function legacyBareGitTracesCommandsFor(command) {
1137
1192
  (legacy) => legacy.endsWith(" git-traces")
1138
1193
  );
1139
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
+ }
1140
1208
  function isHillclimbOwnedCommandFor(command, currentCommand) {
1141
1209
  if (command === currentCommand || legacyCommandsFor(currentCommand).includes(command)) {
1142
1210
  return true;
@@ -1152,7 +1220,7 @@ async function installHooksForTool(repoRoot, def) {
1152
1220
  const r = await opencodeInstall(file);
1153
1221
  return { settingsFile: file, ...r, changed: r.installed > 0 };
1154
1222
  }
1155
- const settings = await readJson(file);
1223
+ const settings = await readJson(file, { repairMalformed: true });
1156
1224
  let installed = 0;
1157
1225
  let alreadyPresent = 0;
1158
1226
  let mutated = false;
@@ -1197,6 +1265,10 @@ async function checkHooksForTool(repoRoot, def) {
1197
1265
  missing.push(`${evt.eventName}:${evt.command}`);
1198
1266
  }
1199
1267
  }
1268
+ if (def.tool === "codex" && missing.length === 0) {
1269
+ const enabled = await areCodexHooksEnabled();
1270
+ if (!enabled) missing.push("features.hooks");
1271
+ }
1200
1272
  return { allInstalled: missing.length === 0, settingsFile: file, missing };
1201
1273
  }
1202
1274
  async function findLegacyGitTracesHookOwners(repoRoot, eventName) {
@@ -1220,9 +1292,57 @@ async function findLegacyGitTracesHookOwners(repoRoot, eventName) {
1220
1292
  }
1221
1293
  return owners;
1222
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
+ }
1223
1315
  function detectTools(repoRoot) {
1224
1316
  return TOOLS.filter((def) => def.detect(repoRoot));
1225
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
+ }
1226
1346
  async function installDetectedHooks(repoRoot) {
1227
1347
  const detected = detectTools(repoRoot);
1228
1348
  const results = [];
@@ -1250,6 +1370,20 @@ async function healHookForTool(repoRoot, tool) {
1250
1370
  };
1251
1371
  }
1252
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
+ }
1253
1387
  return {
1254
1388
  tool: def.tool,
1255
1389
  label: def.label,
@@ -1273,20 +1407,38 @@ async function checkAllHooks(repoRoot) {
1273
1407
  }
1274
1408
  return results;
1275
1409
  }
1276
- var CODEX_CONFIG_PATH = path6.join(os2.homedir(), ".codex", "config.toml");
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
+ }
1277
1431
  async function ensureCodexHooksEnabled() {
1278
1432
  let content;
1433
+ const configPath2 = codexConfigPath();
1279
1434
  try {
1280
- content = await fs5.promises.readFile(CODEX_CONFIG_PATH, "utf-8");
1435
+ content = await fs5.promises.readFile(configPath2, "utf-8");
1281
1436
  } catch (err) {
1282
1437
  if (err.code === "ENOENT") {
1283
- await fs5.promises.mkdir(path6.dirname(CODEX_CONFIG_PATH), {
1438
+ await fs5.promises.mkdir(path6.dirname(configPath2), {
1284
1439
  recursive: true
1285
1440
  });
1286
- await fs5.promises.writeFile(
1287
- CODEX_CONFIG_PATH,
1288
- "[features]\nhooks = true\n"
1289
- );
1441
+ await fs5.promises.writeFile(configPath2, "[features]\nhooks = true\n");
1290
1442
  return true;
1291
1443
  }
1292
1444
  throw err;
@@ -1324,7 +1476,7 @@ hooks = true
1324
1476
  `;
1325
1477
  }
1326
1478
  if (content === original) return false;
1327
- await fs5.promises.writeFile(CODEX_CONFIG_PATH, content);
1479
+ await fs5.promises.writeFile(configPath2, content);
1328
1480
  return true;
1329
1481
  }
1330
1482
  var CLAUDE_DEF = TOOLS[0];
@@ -1724,6 +1876,7 @@ async function runInit(args = []) {
1724
1876
  userId: bootstrap.session.userId,
1725
1877
  savedAt: (/* @__PURE__ */ new Date()).toISOString()
1726
1878
  });
1879
+ const autoUploadWarnings = [];
1727
1880
  try {
1728
1881
  const hookResults = await installDetectedHooks(repoRoot);
1729
1882
  if (hookResults.length === 0) {
@@ -1748,7 +1901,7 @@ async function runInit(args = []) {
1748
1901
  }
1749
1902
  }
1750
1903
  try {
1751
- const ignored = await ensureGitignored(
1904
+ const ignored = await ensureHookFilesIgnored(
1752
1905
  repoRoot,
1753
1906
  hookResults.map((r) => r.settingsFile)
1754
1907
  );
@@ -1761,11 +1914,21 @@ async function runInit(args = []) {
1761
1914
  `${ignored.created ? "Created" : "Updated"} .gitignore for hook files`
1762
1915
  );
1763
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
+ }
1764
1926
  } catch (err) {
1765
1927
  appendLog(
1766
1928
  "warn",
1767
1929
  `init: could not update .gitignore for hook files: ${formatError(err)}`
1768
1930
  );
1931
+ autoUploadWarnings.push("Could not update .gitignore for hook files.");
1769
1932
  p3.log.warn(
1770
1933
  `Could not update .gitignore for hook files: ${err instanceof Error ? err.message : String(err)}`
1771
1934
  );
@@ -1782,6 +1945,9 @@ async function runInit(args = []) {
1782
1945
  "warn",
1783
1946
  `init: could not enable Codex hooks: ${formatError(err)}`
1784
1947
  );
1948
+ autoUploadWarnings.push(
1949
+ "Could not enable Codex hooks in config.toml."
1950
+ );
1785
1951
  p3.log.warn(
1786
1952
  `Could not enable hooks in config.toml: ${err instanceof Error ? err.message : String(err)}`
1787
1953
  );
@@ -1801,7 +1967,7 @@ async function runInit(args = []) {
1801
1967
  `init: completed (project=${project.slug}, contributionType=${type.slug})`
1802
1968
  );
1803
1969
  p3.outro(
1804
- `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}.`
1805
1971
  );
1806
1972
  }
1807
1973
 
@@ -2020,7 +2186,7 @@ async function runLogout(args = []) {
2020
2186
  }
2021
2187
 
2022
2188
  // src/commands/status.ts
2023
- import path7 from "path";
2189
+ import path8 from "path";
2024
2190
  async function runStatus(args = []) {
2025
2191
  const debug = args.includes("--debug");
2026
2192
  header("status");
@@ -2038,7 +2204,7 @@ async function runStatus(args = []) {
2038
2204
  return;
2039
2205
  }
2040
2206
  const { repoRoot, config } = match;
2041
- const repoName = repo?.name ?? path7.basename(repoRoot);
2207
+ const repoName = repo?.name ?? path8.basename(repoRoot);
2042
2208
  if (IS_TTY) {
2043
2209
  process.stdout.write(` ${dim("Checking login...")}`);
2044
2210
  }
@@ -2132,7 +2298,7 @@ async function runStatus(args = []) {
2132
2298
  try {
2133
2299
  const hookStatuses = await checkAllHooks(repoRoot);
2134
2300
  for (const h of hookStatuses) {
2135
- const rel = path7.relative(repoRoot, h.settingsFile) || h.settingsFile;
2301
+ const rel = path8.relative(repoRoot, h.settingsFile) || h.settingsFile;
2136
2302
  debugRow(
2137
2303
  `${h.label} hook:`,
2138
2304
  `${rel} ${dim(`(${h.installed ? "installed" : "missing"})`)}`
@@ -2149,14 +2315,67 @@ async function runStatus(args = []) {
2149
2315
 
2150
2316
  // src/commands/upload.ts
2151
2317
  import { spawn as spawn2 } from "child_process";
2152
- import fs10 from "fs";
2153
- import os5 from "os";
2154
- import path13 from "path";
2318
+ import crypto3 from "crypto";
2319
+ import fs11 from "fs";
2320
+ import os6 from "os";
2321
+ import path15 from "path";
2155
2322
 
2156
2323
  // src/debug-logs.ts
2157
2324
  import crypto from "crypto";
2158
2325
  import fs9 from "fs";
2159
- import path11 from "path";
2326
+ import path12 from "path";
2327
+
2328
+ // src/hook-events.ts
2329
+ function classifyHookEvent(event) {
2330
+ switch (event) {
2331
+ case "Stop":
2332
+ case "stop":
2333
+ case "session.idle":
2334
+ return "stop";
2335
+ case "SessionEnd":
2336
+ case "sessionEnd":
2337
+ case "session.deleted":
2338
+ case "server.instance.disposed":
2339
+ return "sessionEnd";
2340
+ default:
2341
+ return null;
2342
+ }
2343
+ }
2344
+
2345
+ // src/hook-payload.ts
2346
+ function resolveHookCwd(payload) {
2347
+ return payload.cwd ?? payload.workspace_roots?.[0] ?? null;
2348
+ }
2349
+ function resolveHookSessionId(payload) {
2350
+ return payload.session_id ?? payload.conversation_id ?? null;
2351
+ }
2352
+ function inferSourceToolFromPayload(payload) {
2353
+ if (payload.tool) return payload.tool;
2354
+ if (payload.cursor_version) return "cursor";
2355
+ const transcriptPath = payload.transcript_path ?? "";
2356
+ if (transcriptPath.includes(".claude/projects/")) return "claude";
2357
+ if (transcriptPath.includes(".cursor/projects/")) return "cursor";
2358
+ if (transcriptPath.includes("GitHub.copilot-chat")) return "copilot-chat";
2359
+ if (transcriptPath.includes(".hillclimb/opencode-transcripts/")) {
2360
+ return "opencode";
2361
+ }
2362
+ if (transcriptPath.includes(".codex/")) return "codex";
2363
+ switch (payload.hook_event_name) {
2364
+ case "session.idle":
2365
+ case "session.deleted":
2366
+ case "server.instance.disposed":
2367
+ return "opencode";
2368
+ default:
2369
+ return null;
2370
+ }
2371
+ }
2372
+ function fallbackSourceTool(payload) {
2373
+ const inferred = inferSourceToolFromPayload(payload);
2374
+ if (inferred) return inferred;
2375
+ const eventKind = classifyHookEvent(payload.hook_event_name);
2376
+ if (eventKind === "stop") return "codex";
2377
+ return "claude";
2378
+ }
2160
2379
 
2161
2380
  // src/middleware/pattern-redact.ts
2162
2381
  import os3 from "os";
@@ -10645,7 +10864,7 @@ var middleware = [];
10645
10864
 
10646
10865
  // src/middleware/secrets.ts
10647
10866
  import fs7 from "fs";
10648
- import path8 from "path";
10867
+ import path9 from "path";
10649
10868
  var KNOWN_NON_SECRETS = /* @__PURE__ */ new Set([
10650
10869
  "true",
10651
10870
  "false",
@@ -10800,7 +11019,7 @@ async function discoverEnvFiles(repoRoot) {
10800
11019
  const envFiles = [];
10801
11020
  for (const name of entries) {
10802
11021
  if (!name.startsWith(".env")) continue;
10803
- const filePath = path8.join(repoRoot, name);
11022
+ const filePath = path9.join(repoRoot, name);
10804
11023
  try {
10805
11024
  const stat = await fs7.promises.stat(filePath);
10806
11025
  if (stat.isFile()) envFiles.push(name);
@@ -10825,7 +11044,7 @@ async function collectSecrets(repoRoot, envFiles, additionalFiles) {
10825
11044
  }
10826
11045
  }
10827
11046
  for (const filePath of additionalFiles) {
10828
- const resolved = path8.resolve(repoRoot, filePath);
11047
+ const resolved = path9.resolve(repoRoot, filePath);
10829
11048
  sourceFiles.push(resolved);
10830
11049
  for (const value of await parseEnvFile(resolved)) {
10831
11050
  if (isUsableValue(value)) {
@@ -10855,16 +11074,16 @@ import archiver from "archiver";
10855
11074
 
10856
11075
  // src/outputs/archive.ts
10857
11076
  import os4 from "os";
10858
- import path9 from "path";
11077
+ import path10 from "path";
10859
11078
  function getSourceBaseDir(sourceName) {
10860
11079
  const home = os4.homedir();
10861
11080
  switch (sourceName) {
10862
11081
  case "claude":
10863
- return path9.join(home, ".claude", "projects");
11082
+ return path10.join(home, ".claude", "projects");
10864
11083
  case "codex":
10865
- return path9.join(home, ".codex", "sessions");
11084
+ return path10.join(home, ".codex", "sessions");
10866
11085
  case "debug-logs":
10867
- return path9.join(configDir(), "logs");
11086
+ return path10.join(configDir(), "logs");
10868
11087
  default:
10869
11088
  return home;
10870
11089
  }
@@ -10873,8 +11092,8 @@ function addGroupToArchive(archive, group, selectedSources) {
10873
11092
  for (const file of group.files) {
10874
11093
  if (!selectedSources.has(file.sourceName)) continue;
10875
11094
  const baseDir = getSourceBaseDir(file.sourceName);
10876
- const relativePath = file.absolutePath.startsWith(baseDir) ? path9.relative(baseDir, file.absolutePath) : path9.basename(file.absolutePath);
10877
- const archivePath = path9.join(file.sourceName, relativePath);
11095
+ const relativePath = file.absolutePath.startsWith(baseDir) ? path10.relative(baseDir, file.absolutePath) : path10.basename(file.absolutePath);
11096
+ const archivePath = path10.join(file.sourceName, relativePath);
10878
11097
  if (file.content) {
10879
11098
  archive.append(file.content, { name: archivePath });
10880
11099
  } else {
@@ -10916,14 +11135,23 @@ var PlatformUploadOutput = class {
10916
11135
  contributionTitle,
10917
11136
  contributionBody,
10918
11137
  zipFilename,
10919
- autoSubmit
11138
+ autoSubmit,
11139
+ existingContributionId,
11140
+ onContributionCreated
10920
11141
  } = this.opts;
10921
- const contribution = await client.createContribution(projectId, {
10922
- contributionTypeSlug,
10923
- title: contributionTitle,
10924
- body: contributionBody
10925
- });
10926
- const presigned = await client.createUpload(contribution.id, {
11142
+ let contributionId;
11143
+ if (existingContributionId) {
11144
+ contributionId = existingContributionId;
11145
+ } else {
11146
+ const contribution = await client.createContribution(projectId, {
11147
+ contributionTypeSlug,
11148
+ title: contributionTitle,
11149
+ body: contributionBody
11150
+ });
11151
+ contributionId = contribution.id;
11152
+ if (onContributionCreated) await onContributionCreated(contributionId);
11153
+ }
11154
+ const presigned = await client.createUpload(contributionId, {
10927
11155
  originalFilename: zipFilename,
10928
11156
  mimeType: "application/zip",
10929
11157
  sizeBytes: buffer.byteLength
@@ -10939,30 +11167,30 @@ var PlatformUploadOutput = class {
10939
11167
  );
10940
11168
  appendLog("info", `PUT to presigned URL succeeded for ${zipFilename}`);
10941
11169
  if (autoSubmit) {
10942
- appendLog("info", `submitting contribution ${contribution.id}`);
10943
- await client.submitContribution(contribution.id);
10944
- appendLog("info", `contribution ${contribution.id} submitted`);
11170
+ appendLog("info", `submitting contribution ${contributionId}`);
11171
+ await client.submitContribution(contributionId);
11172
+ appendLog("info", `contribution ${contributionId} submitted`);
10945
11173
  }
10946
- return contribution.id;
11174
+ return contributionId;
10947
11175
  }
10948
11176
  };
10949
11177
 
10950
11178
  // src/pipeline.ts
10951
11179
  import fs8 from "fs";
10952
- import path10 from "path";
11180
+ import path11 from "path";
10953
11181
  function canonicalizePath(p7) {
10954
- let resolved = path10.resolve(p7);
10955
- if (resolved.endsWith(path10.sep) && resolved !== path10.sep) {
11182
+ let resolved = path11.resolve(p7);
11183
+ if (resolved.endsWith(path11.sep) && resolved !== path11.sep) {
10956
11184
  resolved = resolved.slice(0, -1);
10957
11185
  }
10958
11186
  return resolved;
10959
11187
  }
10960
11188
  function computeLabel(repoPath, allPaths) {
10961
- const segments = repoPath.split(path10.sep).filter(Boolean);
11189
+ const segments = repoPath.split(path11.sep).filter(Boolean);
10962
11190
  for (let depth = 1; depth <= segments.length; depth++) {
10963
11191
  const label = segments.slice(-depth).join("/");
10964
11192
  const matches = allPaths.filter((p7) => {
10965
- const s = p7.split(path10.sep).filter(Boolean);
11193
+ const s = p7.split(path11.sep).filter(Boolean);
10966
11194
  return s.slice(-depth).join("/") === label;
10967
11195
  });
10968
11196
  if (matches.length === 1) return label;
@@ -11040,7 +11268,7 @@ var DEFAULT_WAIT_MS = 6e4;
11040
11268
  var LOCK_RETRIES = 100;
11041
11269
  var LOCK_RETRY_DELAY_MS = 100;
11042
11270
  function stateDir() {
11043
- return process.env.HILLCLIMB_DEBUG_LOG_STATE_DIR ?? path11.join(configDir(), "debug-log-uploads");
11271
+ return process.env.HILLCLIMB_DEBUG_LOG_STATE_DIR ?? path12.join(configDir(), "debug-log-uploads");
11044
11272
  }
11045
11273
  function waitMs() {
11046
11274
  const raw = process.env.HILLCLIMB_DEBUG_LOG_WAIT_MS;
@@ -11049,7 +11277,7 @@ function waitMs() {
11049
11277
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_WAIT_MS;
11050
11278
  }
11051
11279
  function stateFile(eventId) {
11052
- return path11.join(stateDir(), `${eventId}.json`);
11280
+ return path12.join(stateDir(), `${eventId}.json`);
11053
11281
  }
11054
11282
  function lockFile(eventId) {
11055
11283
  return `${stateFile(eventId)}.lock`;
@@ -11073,26 +11301,11 @@ function toolLabel(tool) {
11073
11301
  };
11074
11302
  return labels[tool] ?? tool;
11075
11303
  }
11076
- function classifyHookEvent(event) {
11077
- switch (event) {
11078
- case "Stop":
11079
- case "stop":
11080
- case "session.idle":
11081
- return "stop";
11082
- case "SessionEnd":
11083
- case "sessionEnd":
11084
- case "session.deleted":
11085
- case "server.instance.disposed":
11086
- return "sessionEnd";
11087
- default:
11088
- return null;
11089
- }
11090
- }
11091
11304
  function resolveCwd(payload) {
11092
- return payload.cwd ?? payload.workspace_roots?.[0] ?? null;
11305
+ return resolveHookCwd(payload);
11093
11306
  }
11094
11307
  function resolveSessionId(payload) {
11095
- return payload.session_id ?? payload.conversation_id ?? null;
11308
+ return resolveHookSessionId(payload);
11096
11309
  }
11097
11310
  function stringOrNull(value) {
11098
11311
  return typeof value === "string" && value.length > 0 ? value : null;
@@ -11100,7 +11313,7 @@ function stringOrNull(value) {
11100
11313
  function expectedKinds(tool, eventKind, payload) {
11101
11314
  if (eventKind === "stop") {
11102
11315
  return new Set(
11103
- tool === "codex" || tool === "copilot-chat" ? ["agent", "git"] : ["git"]
11316
+ tool === "codex" || tool === "copilot-chat" || tool === "claude" || tool === "cursor" || tool === "opencode" ? ["agent", "git"] : ["git"]
11104
11317
  );
11105
11318
  }
11106
11319
  if (tool === "opencode" && !resolveSessionId(payload)) {
@@ -11111,7 +11324,7 @@ function expectedKinds(tool, eventKind, payload) {
11111
11324
  async function transcriptFingerprint(payload) {
11112
11325
  const transcriptPath = stringOrNull(payload.transcript_path);
11113
11326
  if (!transcriptPath) return {};
11114
- const resolved = path11.resolve(transcriptPath);
11327
+ const resolved = path12.resolve(transcriptPath);
11115
11328
  try {
11116
11329
  const stat = await fs9.promises.stat(resolved);
11117
11330
  return {
@@ -11150,13 +11363,10 @@ async function eventContext(tool, payload) {
11150
11363
  conversationId: stringOrNull(payload.conversation_id),
11151
11364
  turnId,
11152
11365
  eventNonce,
11153
- // Only per-turn stop events need the transcript to disambiguate (they
11154
- // often lack a turn id); sessionEnd is keyed by sessionId. Folding it in
11155
- // for sessionEnd would diverge the agent/git eventIds whenever the two
11156
- // hooks send different payloads e.g. the opencode plugin passes
11157
- // transcript_path to the upload spawn but not the git-traces spawn,
11158
- // causing a 60s orphan wait and a duplicate debug-log upload.
11159
- ...eventKind === "stop" ? await transcriptFingerprint(payload) : {}
11366
+ // Only stop events without stable turn IDs need the transcript to
11367
+ // disambiguate. When a turn_id exists, including mutable transcript
11368
+ // mtime/size can split agent+git completions for the same logical event.
11369
+ ...eventKind === "stop" && !turnId ? await transcriptFingerprint(payload) : {}
11160
11370
  };
11161
11371
  const eventId = crypto.createHash("sha256").update(JSON.stringify(fingerprint)).digest("hex").slice(0, 32);
11162
11372
  return {
@@ -11227,7 +11437,7 @@ function initialState(ctx, now) {
11227
11437
  eventKind: ctx.eventKind,
11228
11438
  hookEventName: ctx.hookEventName,
11229
11439
  sessionId: ctx.sessionId,
11230
- logDate: path11.basename(todayLogPath(), ".log"),
11440
+ logDate: path12.basename(todayLogPath(), ".log"),
11231
11441
  firstSeenAt: now.toISOString()
11232
11442
  };
11233
11443
  }
@@ -11289,7 +11499,7 @@ async function waitForExpectedKinds(ctx, state) {
11289
11499
  }
11290
11500
  async function buildMiddleware(repoRoot) {
11291
11501
  const envFileNames = await discoverEnvFiles(repoRoot);
11292
- const envFilePaths = envFileNames.map((n) => path11.join(repoRoot, n));
11502
+ const envFilePaths = envFileNames.map((n) => path12.join(repoRoot, n));
11293
11503
  const secretResult = await collectSecrets(repoRoot, envFilePaths, []);
11294
11504
  const middleware2 = [];
11295
11505
  if (secretResult.values.size > 0) {
@@ -11335,7 +11545,7 @@ async function uploadDebugLog(ctx, state) {
11335
11545
  };
11336
11546
  const group = {
11337
11547
  repoPath: ctx.repoRoot,
11338
- label: path11.basename(ctx.repoRoot),
11548
+ label: path12.basename(ctx.repoRoot),
11339
11549
  files: [sourceFile],
11340
11550
  sourceNames: [DEBUG_LOGS_SLUG],
11341
11551
  lastModified: now
@@ -11357,7 +11567,7 @@ async function uploadDebugLog(ctx, state) {
11357
11567
  `Tool: ${label}`,
11358
11568
  `Event: ${ctx.hookEventName ?? ctx.eventKind}`,
11359
11569
  `Repo: ${ctx.repoRoot}`,
11360
- `Log: ${path11.basename(logPath)}`,
11570
+ `Log: ${path12.basename(logPath)}`,
11361
11571
  `Agent done: ${state.agentDoneAt ?? "<not observed>"}`,
11362
11572
  `Git done: ${state.gitDoneAt ?? "<not observed>"}`,
11363
11573
  `Uploaded: ${now.toISOString()}`
@@ -11374,7 +11584,7 @@ async function uploadDebugLog(ctx, state) {
11374
11584
  );
11375
11585
  appendLog(
11376
11586
  "info",
11377
- `debug-logs: uploaded ${path11.basename(logPath)} to project ${ctx.config.projectSlug} (${ctx.config.projectId}) as contribution ${contributionId}`
11587
+ `debug-logs: uploaded ${path12.basename(logPath)} to project ${ctx.config.projectSlug} (${ctx.config.projectId}) as contribution ${contributionId}`
11378
11588
  );
11379
11589
  return contributionId;
11380
11590
  } catch (err) {
@@ -11427,7 +11637,7 @@ async function recordDebugLogCompletion(args) {
11427
11637
  }
11428
11638
 
11429
11639
  // src/normalizer/index.ts
11430
- import path12 from "path";
11640
+ import path13 from "path";
11431
11641
 
11432
11642
  // src/normalizer/claude.ts
11433
11643
  function stringify(value) {
@@ -13125,14 +13335,20 @@ var NormalizeMiddleware = class {
13125
13335
  continue;
13126
13336
  const content = file.content ? file.content.toString("utf-8") : null;
13127
13337
  if (!content) continue;
13128
- const sessionId = file.metadata?.sessionId ?? (file.sourceName === "codex" ? void 0 : path12.basename(file.absolutePath, ".jsonl"));
13338
+ const sessionId = file.metadata?.sessionId ?? (file.sourceName === "codex" ? void 0 : path13.basename(file.absolutePath, ".jsonl"));
13129
13339
  try {
13130
13340
  const trajectory = normalizeContent(
13131
13341
  file.sourceName,
13132
13342
  content,
13133
13343
  sessionId
13134
13344
  );
13135
- if (!trajectory) continue;
13345
+ if (!trajectory) {
13346
+ appendLog(
13347
+ "warn",
13348
+ `normalize: produced no ATIF for source=${file.sourceName} session=${sessionId ?? "<unknown>"} file=${file.absolutePath}`
13349
+ );
13350
+ continue;
13351
+ }
13136
13352
  const json = JSON.stringify(
13137
13353
  excludeNone(trajectory),
13138
13354
  null,
@@ -13146,13 +13362,140 @@ var NormalizeMiddleware = class {
13146
13362
  metadata: { ...file.metadata, isAtif: true },
13147
13363
  content: Buffer.from(json, "utf-8")
13148
13364
  });
13149
- } catch {
13150
- }
13365
+ } catch (err) {
13366
+ appendLog(
13367
+ "warn",
13368
+ `normalize: failed for source=${file.sourceName} session=${sessionId ?? "<unknown>"} file=${file.absolutePath}: ${err instanceof Error ? err.message : String(err)}`
13369
+ );
13370
+ }
13151
13371
  }
13152
13372
  return { ...group, files: newFiles };
13153
13373
  }
13154
13374
  };
13155
13375
 
13376
+ // src/upload-state.ts
13377
+ import crypto2 from "crypto";
13378
+ import fs10 from "fs";
13379
+ import os5 from "os";
13380
+ import path14 from "path";
13381
+ var CURRENT_SCHEMA_VERSION2 = 1;
13382
+ var DEFAULT_STATE_DIR = path14.join(
13383
+ os5.homedir(),
13384
+ ".hillclimb",
13385
+ "agent-uploads"
13386
+ );
13387
+ var LOCK_RETRIES2 = 120;
13388
+ var LOCK_RETRY_DELAY_MS2 = 500;
13389
+ var DEFAULT_STATE_TTL_MS = 24 * 60 * 60 * 1e3;
13390
+ function stateDir2() {
13391
+ return process.env.HILLCLIMB_UPLOAD_STATE_DIR ?? DEFAULT_STATE_DIR;
13392
+ }
13393
+ function readPositiveEnvMs(name, fallback) {
13394
+ const raw = process.env[name];
13395
+ if (raw === void 0) return fallback;
13396
+ const n = Number(raw);
13397
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
13398
+ }
13399
+ function stateTtlMs() {
13400
+ return readPositiveEnvMs(
13401
+ "HILLCLIMB_UPLOAD_STATE_TTL_MS",
13402
+ DEFAULT_STATE_TTL_MS
13403
+ );
13404
+ }
13405
+ function stateFileFor(repoRoot, tool, sessionId) {
13406
+ const hash = crypto2.createHash("sha256").update(`${path14.resolve(repoRoot)}\0${tool}\0${sessionId}`).digest("hex").slice(0, 16);
13407
+ return path14.join(stateDir2(), `${hash}.json`);
13408
+ }
13409
+ function lockFileFor(repoRoot, tool, sessionId) {
13410
+ return `${stateFileFor(repoRoot, tool, sessionId)}.lock`;
13411
+ }
13412
+ async function readUploadState(repoRoot, tool, sessionId) {
13413
+ try {
13414
+ const raw = await fs10.promises.readFile(
13415
+ stateFileFor(repoRoot, tool, sessionId),
13416
+ "utf-8"
13417
+ );
13418
+ const parsed = JSON.parse(raw);
13419
+ if (parsed.schemaVersion !== CURRENT_SCHEMA_VERSION2) return null;
13420
+ return parsed;
13421
+ } catch {
13422
+ return null;
13423
+ }
13424
+ }
13425
+ async function writeUploadState(state) {
13426
+ const file = stateFileFor(state.repoRoot, state.tool, state.sessionId);
13427
+ await fs10.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
13428
+ const tmp = `${file}.tmp`;
13429
+ await fs10.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
13430
+ mode: 384
13431
+ });
13432
+ await fs10.promises.rename(tmp, file);
13433
+ }
13434
+ async function deleteUploadState(repoRoot, tool, sessionId) {
13435
+ try {
13436
+ await fs10.promises.unlink(stateFileFor(repoRoot, tool, sessionId));
13437
+ } catch {
13438
+ }
13439
+ }
13440
+ async function acquireLock2(repoRoot, tool, sessionId, retries = LOCK_RETRIES2, delayMs = LOCK_RETRY_DELAY_MS2) {
13441
+ const lockPath = lockFileFor(repoRoot, tool, sessionId);
13442
+ await fs10.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
13443
+ for (let i = 0; i < retries; i++) {
13444
+ try {
13445
+ const fd = await fs10.promises.open(
13446
+ lockPath,
13447
+ fs10.constants.O_CREAT | fs10.constants.O_EXCL | fs10.constants.O_WRONLY
13448
+ );
13449
+ try {
13450
+ await fd.write(String(process.pid));
13451
+ } finally {
13452
+ await fd.close();
13453
+ }
13454
+ return;
13455
+ } catch (err) {
13456
+ if (err.code === "EEXIST" && i < retries - 1) {
13457
+ await new Promise((r) => setTimeout(r, delayMs));
13458
+ continue;
13459
+ }
13460
+ throw err;
13461
+ }
13462
+ }
13463
+ throw new Error(`Failed to acquire upload lock after ${retries} retries`);
13464
+ }
13465
+ async function releaseLock2(repoRoot, tool, sessionId) {
13466
+ try {
13467
+ await fs10.promises.unlink(lockFileFor(repoRoot, tool, sessionId));
13468
+ } catch {
13469
+ }
13470
+ }
13471
+ async function withUploadLock(repoRoot, tool, sessionId, fn) {
13472
+ await acquireLock2(repoRoot, tool, sessionId);
13473
+ try {
13474
+ return await fn();
13475
+ } finally {
13476
+ await releaseLock2(repoRoot, tool, sessionId);
13477
+ }
13478
+ }
13479
+ async function sweepStaleUploadStates(ttlMs = stateTtlMs(), now = Date.now()) {
13480
+ let entries;
13481
+ try {
13482
+ entries = await fs10.promises.readdir(stateDir2(), { withFileTypes: true });
13483
+ } catch {
13484
+ return;
13485
+ }
13486
+ for (const entry of entries) {
13487
+ if (!entry.isFile()) continue;
13488
+ const file = path14.join(stateDir2(), entry.name);
13489
+ try {
13490
+ const st = await fs10.promises.stat(file);
13491
+ if (now - st.mtimeMs > ttlMs) {
13492
+ await fs10.promises.unlink(file);
13493
+ }
13494
+ } catch {
13495
+ }
13496
+ }
13497
+ }
13498
+
13156
13499
  // src/commands/upload.ts
13157
13500
  async function readStdin() {
13158
13501
  if (process.stdin.isTTY) return "";
@@ -13168,11 +13511,14 @@ function sanitize2(value) {
13168
13511
  function formatEpochSeconds2(date) {
13169
13512
  return String(Math.floor(date.getTime() / 1e3));
13170
13513
  }
13514
+ function newFlowId() {
13515
+ return crypto3.randomBytes(3).toString("hex");
13516
+ }
13171
13517
  function lineHasAssistant(line) {
13172
13518
  return line.includes('"type":"assistant"') || line.includes('"role":"assistant"') || line.includes('"type":"assistant.');
13173
13519
  }
13174
13520
  async function hasAssistantMessage(transcriptPath) {
13175
- const stream = fs10.createReadStream(transcriptPath, { encoding: "utf-8" });
13521
+ const stream = fs11.createReadStream(transcriptPath, { encoding: "utf-8" });
13176
13522
  let buffer = "";
13177
13523
  try {
13178
13524
  for await (const chunk of stream) {
@@ -13194,16 +13540,41 @@ async function hasAssistantMessage(transcriptPath) {
13194
13540
  }
13195
13541
  return false;
13196
13542
  }
13197
- function resolveSourceTool(payload) {
13198
- if (payload.tool) return payload.tool;
13199
- if (payload.cursor_version) return "cursor";
13200
- if (payload.hook_event_name === "Stop") return "codex";
13201
- return "claude";
13543
+ function hashFileSha256(file) {
13544
+ return new Promise((resolve, reject) => {
13545
+ const hash = crypto3.createHash("sha256");
13546
+ const stream = fs11.createReadStream(file);
13547
+ stream.on("data", (chunk) => hash.update(chunk));
13548
+ stream.on("error", reject);
13549
+ stream.on("end", () => resolve(hash.digest("hex")));
13550
+ });
13551
+ }
13552
+ async function resolveSourceTool(payload, repoRoot) {
13553
+ const inferred = inferSourceToolFromPayload(payload);
13554
+ if (inferred) return inferred;
13555
+ if (repoRoot) {
13556
+ const owners = await findLegacyUploadHookOwners(
13557
+ repoRoot,
13558
+ payload.hook_event_name
13559
+ );
13560
+ if (owners.length === 1) {
13561
+ appendLog("info", `upload: inferred legacy bare hook owner ${owners[0]}`);
13562
+ return owners[0];
13563
+ }
13564
+ if (owners.length > 1) {
13565
+ appendLog(
13566
+ "warn",
13567
+ `upload: legacy bare hook matched multiple tools (${owners.join(", ")}); skipping to avoid misattribution`
13568
+ );
13569
+ return null;
13570
+ }
13571
+ }
13572
+ return fallbackSourceTool(payload);
13202
13573
  }
13203
13574
  function summarizePayload(payload) {
13204
- const sessionId = payload.session_id ?? payload.conversation_id ?? null;
13575
+ const sessionId = resolveHookSessionId(payload);
13205
13576
  const turnId = payload.turn_id ?? null;
13206
- const cwd = payload.cwd ?? payload.workspace_roots?.[0] ?? null;
13577
+ const cwd = resolveHookCwd(payload);
13207
13578
  return JSON.stringify({
13208
13579
  session_id: sessionId,
13209
13580
  turn_id: turnId,
@@ -13236,12 +13607,12 @@ async function selfHealHook(repoRoot, tool) {
13236
13607
  }
13237
13608
  }
13238
13609
  function resolveCursorTranscriptPath(payload) {
13239
- const id = payload.conversation_id ?? payload.session_id;
13610
+ const id = resolveHookSessionId(payload);
13240
13611
  const workspace = payload.workspace_roots?.[0];
13241
13612
  if (!id || !workspace) return void 0;
13242
13613
  const encoded = workspace.replace(/^\//, "").replace(/\//g, "-");
13243
- return path13.join(
13244
- os5.homedir(),
13614
+ return path15.join(
13615
+ os6.homedir(),
13245
13616
  ".cursor",
13246
13617
  "projects",
13247
13618
  encoded,
@@ -13251,14 +13622,10 @@ function resolveCursorTranscriptPath(payload) {
13251
13622
  );
13252
13623
  }
13253
13624
  async function runUploadInner(payload) {
13254
- const sessionId = payload.session_id;
13625
+ const sessionId = resolveHookSessionId(payload);
13255
13626
  const transcriptPath = payload.transcript_path ?? resolveCursorTranscriptPath(payload);
13256
- const cwd = payload.cwd ?? payload.workspace_roots?.[0];
13257
- const sourceTool = resolveSourceTool(payload);
13258
- appendLog(
13259
- "info",
13260
- `[${sessionId ?? "no-id"}] payload parsed (tool=${sourceTool}, cwd=${cwd ?? "?"})`
13261
- );
13627
+ const cwd = resolveHookCwd(payload);
13628
+ const eventKind = classifyHookEvent(payload.hook_event_name);
13262
13629
  if (!sessionId || !transcriptPath || !cwd) {
13263
13630
  appendLog(
13264
13631
  "warn",
@@ -13275,10 +13642,20 @@ async function runUploadInner(payload) {
13275
13642
  return;
13276
13643
  }
13277
13644
  const { repoRoot, config } = match;
13645
+ const sourceTool = await resolveSourceTool(payload, repoRoot);
13646
+ if (!sourceTool) {
13647
+ payload.tool = "unknown";
13648
+ return;
13649
+ }
13650
+ payload.tool = sourceTool;
13651
+ appendLog(
13652
+ "info",
13653
+ `[${sessionId}] payload parsed (tool=${sourceTool}, cwd=${cwd}, event=${eventKind ?? "?"})`
13654
+ );
13278
13655
  await selfHealHook(repoRoot, sourceTool);
13279
- const transcriptResolved = path13.resolve(transcriptPath);
13656
+ const transcriptResolved = path15.resolve(transcriptPath);
13280
13657
  try {
13281
- const stat = await fs10.promises.stat(transcriptResolved);
13658
+ const stat = await fs11.promises.stat(transcriptResolved);
13282
13659
  if (!stat.isFile()) {
13283
13660
  appendLog(
13284
13661
  "warn",
@@ -13305,92 +13682,166 @@ async function runUploadInner(payload) {
13305
13682
  transcriptPath: transcriptResolved,
13306
13683
  repoRoot,
13307
13684
  config,
13308
- sourceTool
13685
+ sourceTool,
13686
+ eventKind
13309
13687
  });
13310
13688
  }
13311
13689
  async function uploadSession(args) {
13312
- const { sessionId, transcriptPath, repoRoot, config, sourceTool } = args;
13313
- const now = /* @__PURE__ */ new Date();
13314
- const sourceFile = {
13315
- sourceName: sourceTool,
13316
- absolutePath: transcriptPath,
13317
- repoPath: repoRoot
13318
- };
13319
- const group = {
13320
- repoPath: repoRoot,
13321
- label: path13.basename(repoRoot),
13322
- files: [sourceFile],
13323
- sourceNames: [sourceTool],
13324
- lastModified: now
13325
- };
13326
- const envFileNames = await discoverEnvFiles(repoRoot);
13327
- const envFilePaths = envFileNames.map((n) => path13.join(repoRoot, n));
13328
- const secretResult = await collectSecrets(repoRoot, envFilePaths, []);
13329
- const mwChain = [];
13330
- if (secretResult.values.size > 0) {
13331
- mwChain.push(new RedactMiddleware(secretResult.values));
13332
- }
13333
- mwChain.push(new PatternRedactMiddleware());
13334
- mwChain.push(new NormalizeMiddleware());
13335
- const identity = await loadIdentity(config.apiBaseUrl);
13336
- if (!identity) {
13337
- appendLog(
13338
- "error",
13339
- `Session ${sessionId} upload skipped: no saved login for ${config.apiBaseUrl}. Run \`npx hillclimb login\`.`
13690
+ const { sessionId, transcriptPath, repoRoot, config, sourceTool, eventKind } = args;
13691
+ const isSessionEnd = eventKind === "sessionEnd";
13692
+ await withUploadLock(repoRoot, sourceTool, sessionId, async () => {
13693
+ const prior = await readUploadState(repoRoot, sourceTool, sessionId);
13694
+ let transcriptSize;
13695
+ let transcriptSha256;
13696
+ try {
13697
+ transcriptSize = (await fs11.promises.stat(transcriptPath)).size;
13698
+ transcriptSha256 = await hashFileSha256(transcriptPath);
13699
+ } catch {
13700
+ }
13701
+ if (prior?.contributionId && transcriptSize !== void 0 && transcriptSha256 !== void 0 && transcriptSize === prior.lastTranscriptSize && transcriptSha256 === prior.lastTranscriptSha256) {
13702
+ appendLog(
13703
+ "info",
13704
+ `[${sessionId}] skipping ${sourceTool} ${isSessionEnd ? "SessionEnd" : "Stop"} upload (transcript unchanged at ${transcriptSize} bytes${isSessionEnd ? "; local state cleared" : ""})`
13705
+ );
13706
+ if (isSessionEnd) {
13707
+ await deleteUploadState(repoRoot, sourceTool, sessionId);
13708
+ }
13709
+ return;
13710
+ }
13711
+ const now = /* @__PURE__ */ new Date();
13712
+ const sourceFile = {
13713
+ sourceName: sourceTool,
13714
+ absolutePath: transcriptPath,
13715
+ repoPath: repoRoot
13716
+ };
13717
+ const group = {
13718
+ repoPath: repoRoot,
13719
+ label: path15.basename(repoRoot),
13720
+ files: [sourceFile],
13721
+ sourceNames: [sourceTool],
13722
+ lastModified: now
13723
+ };
13724
+ const envFileNames = await discoverEnvFiles(repoRoot);
13725
+ const envFilePaths = envFileNames.map((n) => path15.join(repoRoot, n));
13726
+ const secretResult = await collectSecrets(repoRoot, envFilePaths, []);
13727
+ const mwChain = [];
13728
+ if (secretResult.values.size > 0) {
13729
+ mwChain.push(new RedactMiddleware(secretResult.values));
13730
+ }
13731
+ mwChain.push(new PatternRedactMiddleware());
13732
+ mwChain.push(new NormalizeMiddleware());
13733
+ const identity = await loadIdentity(config.apiBaseUrl);
13734
+ if (!identity) {
13735
+ appendLog(
13736
+ "error",
13737
+ `Session ${sessionId} upload skipped: no saved login for ${config.apiBaseUrl}. Run \`npx hillclimb login\`.`
13738
+ );
13739
+ return;
13740
+ }
13741
+ const client = new PlatformClient(
13742
+ config.apiBaseUrl,
13743
+ identity.sessionCookie
13340
13744
  );
13341
- return;
13342
- }
13343
- const client = new PlatformClient(config.apiBaseUrl, identity.sessionCookie);
13344
- const shortId = sessionId.slice(0, 12);
13345
- const toolLabels = {
13346
- cursor: "Cursor",
13347
- codex: "Codex",
13348
- claude: "Claude",
13349
- "copilot-chat": "GitHub Copilot Chat",
13350
- opencode: "opencode"
13351
- };
13352
- const toolLabel2 = toolLabels[sourceTool] ?? "Claude";
13353
- const epochSeconds = formatEpochSeconds2(now);
13354
- const title = `${toolLabel2} session ${shortId} \u2014 ${epochSeconds}`;
13355
- const body = `Session ID: ${sessionId}
13745
+ const shortId = sessionId.slice(0, 12);
13746
+ const toolLabels = {
13747
+ cursor: "Cursor",
13748
+ codex: "Codex",
13749
+ claude: "Claude",
13750
+ "copilot-chat": "GitHub Copilot Chat",
13751
+ opencode: "opencode"
13752
+ };
13753
+ const toolLabel2 = toolLabels[sourceTool] ?? "Claude";
13754
+ const epochSeconds = formatEpochSeconds2(now);
13755
+ const seq = (prior?.uploadCount ?? 0) + 1;
13756
+ const title = `${toolLabel2} session ${shortId} \u2014 ${epochSeconds}`;
13757
+ const body = `Session ID: ${sessionId}
13356
13758
  Tool: ${toolLabel2}
13357
13759
  Repo: ${repoRoot}
13358
13760
  Uploaded: ${now.toISOString()}`;
13359
- const zipFilename = `${sourceTool}-${sanitize2(shortId)}-${epochSeconds}.zip`;
13360
- const output = new PlatformUploadOutput({
13361
- client,
13362
- projectId: config.projectId,
13363
- contributionTypeSlug: config.contributionTypeSlug,
13364
- contributionTitle: title,
13365
- contributionBody: body,
13366
- zipFilename,
13367
- autoSubmit: config.autoSubmit
13368
- });
13369
- appendLog("info", `[${sessionId}] starting pipeline`);
13370
- try {
13371
- const contributionId = await runPipeline(group, mwChain, output, {
13372
- selectedSources: [sourceTool]
13761
+ const zipFilename = `${sourceTool}-${sanitize2(shortId)}-${epochSeconds}-${String(seq).padStart(3, "0")}.zip`;
13762
+ const alreadySubmitted = prior?.submitted ?? false;
13763
+ const submitThisUpload = config.autoSubmit && !alreadySubmitted;
13764
+ const output = new PlatformUploadOutput({
13765
+ client,
13766
+ projectId: config.projectId,
13767
+ contributionTypeSlug: config.contributionTypeSlug,
13768
+ contributionTitle: title,
13769
+ contributionBody: body,
13770
+ zipFilename,
13771
+ autoSubmit: submitThisUpload,
13772
+ existingContributionId: prior?.contributionId ?? void 0,
13773
+ // Persist the new contribution id before the file PUT so a failed
13774
+ // upload can't make the next Stop create a second contribution.
13775
+ onContributionCreated: (id) => writeUploadState({
13776
+ schemaVersion: CURRENT_SCHEMA_VERSION2,
13777
+ sessionId,
13778
+ tool: sourceTool,
13779
+ repoRoot,
13780
+ projectId: config.projectId,
13781
+ contributionId: id,
13782
+ submitted: alreadySubmitted,
13783
+ uploadCount: prior?.uploadCount ?? 0,
13784
+ firstUploadedAt: prior?.firstUploadedAt ?? now.toISOString(),
13785
+ lastUploadedAt: prior?.lastUploadedAt ?? now.toISOString(),
13786
+ lastTranscriptSize: prior?.lastTranscriptSize,
13787
+ lastTranscriptSha256: prior?.lastTranscriptSha256
13788
+ })
13373
13789
  });
13790
+ 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)";
13374
13791
  appendLog(
13375
13792
  "info",
13376
- `Uploaded session ${sessionId} to project ${config.projectSlug} (${config.projectId}) as contribution ${contributionId}`
13793
+ `[${sessionId}] ${sourceTool} ${isSessionEnd ? "SessionEnd" : "Stop"} upload \u2192 ${reuseDesc}`
13377
13794
  );
13378
- } catch (err) {
13379
- if (err instanceof PlatformError && err.status === 401) {
13795
+ let contributionId;
13796
+ try {
13797
+ contributionId = await runPipeline(group, mwChain, output, {
13798
+ selectedSources: [sourceTool]
13799
+ });
13800
+ } catch (err) {
13801
+ if (err instanceof PlatformError && err.status === 401) {
13802
+ appendLog(
13803
+ "error",
13804
+ `Session ${sessionId} upload failed: authentication expired. Re-run \`npx hillclimb\` in ${repoRoot}.`
13805
+ );
13806
+ return;
13807
+ }
13380
13808
  appendLog(
13381
13809
  "error",
13382
- `Session ${sessionId} upload failed: authentication expired. Re-run \`npx hillclimb\` in ${repoRoot}.`
13810
+ `Session ${sessionId} upload failed: ${err instanceof Error ? err.message : String(err)}`
13383
13811
  );
13384
13812
  return;
13385
13813
  }
13814
+ const next = {
13815
+ schemaVersion: CURRENT_SCHEMA_VERSION2,
13816
+ sessionId,
13817
+ tool: sourceTool,
13818
+ repoRoot,
13819
+ projectId: config.projectId,
13820
+ contributionId,
13821
+ submitted: alreadySubmitted || submitThisUpload,
13822
+ uploadCount: seq,
13823
+ firstUploadedAt: prior?.firstUploadedAt ?? now.toISOString(),
13824
+ lastUploadedAt: now.toISOString(),
13825
+ lastTranscriptSize: transcriptSize,
13826
+ lastTranscriptSha256: transcriptSha256
13827
+ };
13828
+ await writeUploadState(next);
13386
13829
  appendLog(
13387
- "error",
13388
- `Session ${sessionId} upload failed: ${err instanceof Error ? err.message : String(err)}`
13830
+ "info",
13831
+ `Uploaded session ${sessionId} to project ${config.projectSlug} (${config.projectId}) as contribution ${contributionId} (seq=${seq})`
13389
13832
  );
13390
- }
13833
+ if (isSessionEnd) {
13834
+ await deleteUploadState(repoRoot, sourceTool, sessionId);
13835
+ appendLog(
13836
+ "info",
13837
+ `[${sessionId}] session complete \u2014 contribution ${contributionId}, ${seq} file(s); local state cleared`
13838
+ );
13839
+ }
13840
+ });
13391
13841
  }
13392
13842
  var WORKER_ENV_FLAG = "HILLCLIMB_UPLOAD_WORKER";
13393
13843
  var TOOL_ENV_FLAG = "HILLCLIMB_UPLOAD_TOOL";
13844
+ var FLOW_ID_ENV = "HILLCLIMB_UPLOAD_FLOW";
13394
13845
  function parseToolArg(argv) {
13395
13846
  for (let i = 0; i < argv.length; i++) {
13396
13847
  const a = argv[i];
@@ -13404,6 +13855,8 @@ async function runUpload() {
13404
13855
  await runUploadWorker();
13405
13856
  return;
13406
13857
  }
13858
+ const flowId = newFlowId();
13859
+ setLogPrefix(`[${flowId}]`);
13407
13860
  appendLog("info", `upload hook invoked (pid ${process.pid})`);
13408
13861
  let raw;
13409
13862
  try {
@@ -13430,7 +13883,8 @@ async function runUpload() {
13430
13883
  const toolArg = parseToolArg(process.argv.slice(2));
13431
13884
  const workerEnv = {
13432
13885
  ...process.env,
13433
- [WORKER_ENV_FLAG]: "1"
13886
+ [WORKER_ENV_FLAG]: "1",
13887
+ [FLOW_ID_ENV]: flowId
13434
13888
  };
13435
13889
  if (toolArg) workerEnv[TOOL_ENV_FLAG] = toolArg;
13436
13890
  try {
@@ -13457,6 +13911,7 @@ async function runUpload() {
13457
13911
  }
13458
13912
  }
13459
13913
  async function runUploadWorker() {
13914
+ setLogPrefix(`[${process.env[FLOW_ID_ENV] ?? newFlowId()}]`);
13460
13915
  appendLog("info", `upload worker started (pid ${process.pid})`);
13461
13916
  let raw;
13462
13917
  try {
@@ -13495,25 +13950,29 @@ async function runUploadWorker() {
13495
13950
  } finally {
13496
13951
  await recordDebugLogCompletion({
13497
13952
  kind: "agent",
13498
- tool: resolveSourceTool(payload),
13953
+ tool: fallbackSourceTool(payload),
13499
13954
  payload
13500
13955
  });
13956
+ try {
13957
+ await sweepStaleUploadStates();
13958
+ } catch {
13959
+ }
13501
13960
  }
13502
13961
  }
13503
13962
 
13504
13963
  // src/git-traces/index.ts
13505
13964
  import { spawn as spawn3 } from "child_process";
13506
- import crypto3 from "crypto";
13965
+ import crypto5 from "crypto";
13507
13966
 
13508
13967
  // src/git-traces/handlers.ts
13509
- import { execFileSync as execFileSync2 } from "child_process";
13510
- import path16 from "path";
13968
+ import { execFileSync as execFileSync3 } from "child_process";
13969
+ import path18 from "path";
13511
13970
 
13512
13971
  // src/git-traces/git-ops.ts
13513
- import { execFileSync } from "child_process";
13514
- import fs11 from "fs";
13515
- import os6 from "os";
13516
- import path14 from "path";
13972
+ import { execFileSync as execFileSync2 } from "child_process";
13973
+ import fs12 from "fs";
13974
+ import os7 from "os";
13975
+ import path16 from "path";
13517
13976
  import { gzipSync } from "zlib";
13518
13977
  var GIT_COMMAND_TIMEOUT_MS = 12e4;
13519
13978
  var EXEC_OPTS = {
@@ -13567,7 +14026,7 @@ function isGitTimeoutError(err) {
13567
14026
  }
13568
14027
  function gitBuffer(repoRoot, args, options = {}) {
13569
14028
  try {
13570
- return execFileSync("git", args, {
14029
+ return execFileSync2("git", args, {
13571
14030
  cwd: repoRoot,
13572
14031
  ...options.env ? { env: options.env } : {},
13573
14032
  ...options.input !== void 0 ? { input: options.input } : {},
@@ -13676,8 +14135,8 @@ function removePathsFromIndex(repoRoot, env, paths) {
13676
14135
  function filterOversizedFilesFromTree(repoRoot, treeSha, options = {}) {
13677
14136
  const oversizedFiles = listOversizedTreeFiles(repoRoot, treeSha, options);
13678
14137
  if (oversizedFiles.length === 0) return treeSha;
13679
- const tmpIndex = path14.join(
13680
- os6.tmpdir(),
14138
+ const tmpIndex = path16.join(
14139
+ os7.tmpdir(),
13681
14140
  `hillclimb-filter-${Date.now()}-${process.pid}`
13682
14141
  );
13683
14142
  const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
@@ -13691,7 +14150,7 @@ function filterOversizedFilesFromTree(repoRoot, treeSha, options = {}) {
13691
14150
  return gitWithEnv(repoRoot, ["write-tree"], env);
13692
14151
  } finally {
13693
14152
  try {
13694
- fs11.unlinkSync(tmpIndex);
14153
+ fs12.unlinkSync(tmpIndex);
13695
14154
  } catch {
13696
14155
  }
13697
14156
  }
@@ -13708,7 +14167,7 @@ function buildUntrackedTree(repoRoot, omittedFiles) {
13708
14167
  for (const relPath of list.split("\0")) {
13709
14168
  if (!relPath) continue;
13710
14169
  try {
13711
- const stat = fs11.lstatSync(path14.join(repoRoot, relPath));
14170
+ const stat = fs12.lstatSync(path16.join(repoRoot, relPath));
13712
14171
  if (stat.size > MAX_SNAPSHOT_FILE_BYTES) {
13713
14172
  recordOmittedSnapshotFile(omittedFiles, {
13714
14173
  path: relPath,
@@ -13723,8 +14182,8 @@ function buildUntrackedTree(repoRoot, omittedFiles) {
13723
14182
  }
13724
14183
  }
13725
14184
  if (kept.length === 0) return null;
13726
- const tmpIndex = path14.join(
13727
- os6.tmpdir(),
14185
+ const tmpIndex = path16.join(
14186
+ os7.tmpdir(),
13728
14187
  `hillclimb-untracked-${Date.now()}-${process.pid}`
13729
14188
  );
13730
14189
  const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
@@ -13736,7 +14195,7 @@ function buildUntrackedTree(repoRoot, omittedFiles) {
13736
14195
  return gitWithEnv(repoRoot, ["write-tree"], env);
13737
14196
  } finally {
13738
14197
  try {
13739
- fs11.unlinkSync(tmpIndex);
14198
+ fs12.unlinkSync(tmpIndex);
13740
14199
  } catch {
13741
14200
  }
13742
14201
  }
@@ -13753,8 +14212,8 @@ function buildSnapshotTree(repoRoot, stashSha, options = {}) {
13753
14212
  const untrackedTree = buildUntrackedTree(repoRoot, options.omittedFiles);
13754
14213
  if (!untrackedTree || untrackedTree === EMPTY_TREE_SHA)
13755
14214
  return filteredTrackedTree;
13756
- const tmpIndex = path14.join(
13757
- os6.tmpdir(),
14215
+ const tmpIndex = path16.join(
14216
+ os7.tmpdir(),
13758
14217
  `hillclimb-index-${Date.now()}-${process.pid}`
13759
14218
  );
13760
14219
  const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
@@ -13781,7 +14240,7 @@ function buildSnapshotTree(repoRoot, stashSha, options = {}) {
13781
14240
  return gitWithEnv(repoRoot, ["write-tree"], env);
13782
14241
  } finally {
13783
14242
  try {
13784
- fs11.unlinkSync(tmpIndex);
14243
+ fs12.unlinkSync(tmpIndex);
13785
14244
  } catch {
13786
14245
  }
13787
14246
  }
@@ -13795,16 +14254,19 @@ function createBundleFromTree(repoRoot, treeSha, sessionId, label) {
13795
14254
  ]);
13796
14255
  const orphanRef = `refs/hillclimb/bundle/${sessionId}`;
13797
14256
  pinRef(repoRoot, orphanRef, orphanCommit);
13798
- const tmpFile = path14.join(
13799
- os6.tmpdir(),
13800
- `hillclimb-bundle-${Date.now()}.bundle`
14257
+ const tmpFile = path16.join(
14258
+ os7.tmpdir(),
14259
+ // Include the pid (like the other temp files in this module) so concurrent
14260
+ // git-traces workers — e.g. two sessions, or a parent + subagent — don't
14261
+ // collide on the same `git bundle create` path and its `.lock`.
14262
+ `hillclimb-bundle-${Date.now()}-${process.pid}.bundle`
13801
14263
  );
13802
14264
  try {
13803
14265
  git(repoRoot, ["bundle", "create", tmpFile, orphanRef]);
13804
- return fs11.readFileSync(tmpFile);
14266
+ return fs12.readFileSync(tmpFile);
13805
14267
  } finally {
13806
14268
  try {
13807
- fs11.unlinkSync(tmpFile);
14269
+ fs12.unlinkSync(tmpFile);
13808
14270
  } catch {
13809
14271
  }
13810
14272
  deleteRef(repoRoot, orphanRef);
@@ -13902,7 +14364,7 @@ function buildBaselineMetadata(repoRoot, sessionId, tool, baselineSha, cliVersio
13902
14364
  ...omittedFiles.length > 0 ? { omittedFiles } : {}
13903
14365
  },
13904
14366
  author: { name: authorName, email: authorEmail },
13905
- hostname: os6.hostname(),
14367
+ hostname: os7.hostname(),
13906
14368
  cliVersion,
13907
14369
  commits
13908
14370
  };
@@ -13991,9 +14453,9 @@ function parseCommitFiles(repoRoot, sha) {
13991
14453
  oldPath
13992
14454
  });
13993
14455
  } else {
13994
- const path23 = parts[parts.length - 1];
13995
- indexByPath.set(path23, files.length);
13996
- files.push({ path: path23, status, additions: 0, deletions: 0 });
14456
+ const path25 = parts[parts.length - 1];
14457
+ indexByPath.set(path25, files.length);
14458
+ files.push({ path: path25, status, additions: 0, deletions: 0 });
13997
14459
  }
13998
14460
  }
13999
14461
  for (const line of numstat.split("\n")) {
@@ -14059,31 +14521,31 @@ function cleanupSessionRefs(repoRoot, sessionId) {
14059
14521
  }
14060
14522
 
14061
14523
  // src/git-traces/session-state.ts
14062
- import crypto2 from "crypto";
14063
- import fs12 from "fs";
14064
- import os7 from "os";
14065
- import path15 from "path";
14066
- var CURRENT_SCHEMA_VERSION2 = 3;
14067
- var DEFAULT_STATE_DIR = path15.join(os7.homedir(), ".hillclimb", "git-traces");
14068
- var LOCK_RETRIES2 = 120;
14069
- var LOCK_RETRY_DELAY_MS2 = 500;
14070
- function stateDir2() {
14071
- return process.env.HILLCLIMB_GIT_TRACES_STATE_DIR ?? DEFAULT_STATE_DIR;
14524
+ import crypto4 from "crypto";
14525
+ import fs13 from "fs";
14526
+ import os8 from "os";
14527
+ import path17 from "path";
14528
+ var CURRENT_SCHEMA_VERSION3 = 3;
14529
+ var DEFAULT_STATE_DIR2 = path17.join(os8.homedir(), ".hillclimb", "git-traces");
14530
+ var LOCK_RETRIES3 = 120;
14531
+ var LOCK_RETRY_DELAY_MS3 = 500;
14532
+ function stateDir3() {
14533
+ return process.env.HILLCLIMB_GIT_TRACES_STATE_DIR ?? DEFAULT_STATE_DIR2;
14072
14534
  }
14073
14535
  function stateFileForRepo(repoRoot, tool, sessionId) {
14074
- const hash = crypto2.createHash("sha256").update(
14075
- sessionId ? `${path15.resolve(repoRoot)}\0${tool}\0${sessionId}` : `${path15.resolve(repoRoot)}\0${tool}`
14536
+ const hash = crypto4.createHash("sha256").update(
14537
+ sessionId ? `${path17.resolve(repoRoot)}\0${tool}\0${sessionId}` : `${path17.resolve(repoRoot)}\0${tool}`
14076
14538
  ).digest("hex").slice(0, 16);
14077
- return path15.join(stateDir2(), `${hash}.json`);
14539
+ return path17.join(stateDir3(), `${hash}.json`);
14078
14540
  }
14079
14541
  function lockFileForRepo(repoRoot, tool) {
14080
14542
  return `${stateFileForRepo(repoRoot, tool)}.lock`;
14081
14543
  }
14082
14544
  async function readStateFile(file) {
14083
14545
  try {
14084
- const raw = await fs12.promises.readFile(file, "utf-8");
14546
+ const raw = await fs13.promises.readFile(file, "utf-8");
14085
14547
  const parsed = JSON.parse(raw);
14086
- if (parsed.schemaVersion !== CURRENT_SCHEMA_VERSION2) {
14548
+ if (parsed.schemaVersion !== CURRENT_SCHEMA_VERSION3) {
14087
14549
  return null;
14088
14550
  }
14089
14551
  return parsed;
@@ -14094,26 +14556,26 @@ async function readStateFile(file) {
14094
14556
  async function listScopedSessionStates(repoRoot, tool) {
14095
14557
  let entries;
14096
14558
  try {
14097
- entries = await fs12.promises.readdir(stateDir2(), { withFileTypes: true });
14559
+ entries = await fs13.promises.readdir(stateDir3(), { withFileTypes: true });
14098
14560
  } catch {
14099
14561
  return [];
14100
14562
  }
14101
14563
  const states = [];
14102
14564
  for (const entry of entries) {
14103
14565
  if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
14104
- const file = path15.join(stateDir2(), entry.name);
14566
+ const file = path17.join(stateDir3(), entry.name);
14105
14567
  const state = await readStateFile(file);
14106
14568
  if (!state) continue;
14107
14569
  if (typeof state.repoRoot !== "string" || typeof state.sessionId !== "string") {
14108
14570
  continue;
14109
14571
  }
14110
- if (path15.resolve(state.repoRoot) !== path15.resolve(repoRoot)) continue;
14111
- if (path15.resolve(file) !== path15.resolve(stateFileForRepo(repoRoot, tool, state.sessionId))) {
14572
+ if (path17.resolve(state.repoRoot) !== path17.resolve(repoRoot)) continue;
14573
+ if (path17.resolve(file) !== path17.resolve(stateFileForRepo(repoRoot, tool, state.sessionId))) {
14112
14574
  continue;
14113
14575
  }
14114
14576
  let mtimeMs = 0;
14115
14577
  try {
14116
- mtimeMs = (await fs12.promises.stat(file)).mtimeMs;
14578
+ mtimeMs = (await fs13.promises.stat(file)).mtimeMs;
14117
14579
  } catch {
14118
14580
  continue;
14119
14581
  }
@@ -14124,26 +14586,26 @@ async function listScopedSessionStates(repoRoot, tool) {
14124
14586
  async function listSessionStatesForSession(tool, sessionId) {
14125
14587
  let entries;
14126
14588
  try {
14127
- entries = await fs12.promises.readdir(stateDir2(), { withFileTypes: true });
14589
+ entries = await fs13.promises.readdir(stateDir3(), { withFileTypes: true });
14128
14590
  } catch {
14129
14591
  return [];
14130
14592
  }
14131
14593
  const states = [];
14132
14594
  for (const entry of entries) {
14133
14595
  if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
14134
- const file = path15.join(stateDir2(), entry.name);
14596
+ const file = path17.join(stateDir3(), entry.name);
14135
14597
  const state = await readStateFile(file);
14136
14598
  if (!state) continue;
14137
14599
  if (typeof state.repoRoot !== "string" || typeof state.sessionId !== "string") {
14138
14600
  continue;
14139
14601
  }
14140
14602
  if (state.sessionId !== sessionId) continue;
14141
- if (path15.resolve(file) !== path15.resolve(stateFileForRepo(state.repoRoot, tool, sessionId))) {
14603
+ if (path17.resolve(file) !== path17.resolve(stateFileForRepo(state.repoRoot, tool, sessionId))) {
14142
14604
  continue;
14143
14605
  }
14144
14606
  let mtimeMs = 0;
14145
14607
  try {
14146
- mtimeMs = (await fs12.promises.stat(file)).mtimeMs;
14608
+ mtimeMs = (await fs13.promises.stat(file)).mtimeMs;
14147
14609
  } catch {
14148
14610
  continue;
14149
14611
  }
@@ -14164,12 +14626,12 @@ async function readSessionState(repoRoot, tool, sessionId) {
14164
14626
  }
14165
14627
  async function writeSessionState(state, tool) {
14166
14628
  const file = stateFileForRepo(state.repoRoot, tool, state.sessionId);
14167
- await fs12.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
14629
+ await fs13.promises.mkdir(stateDir3(), { recursive: true, mode: 448 });
14168
14630
  const tmp = `${file}.tmp`;
14169
- await fs12.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
14631
+ await fs13.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
14170
14632
  mode: 384
14171
14633
  });
14172
- await fs12.promises.rename(tmp, file);
14634
+ await fs13.promises.rename(tmp, file);
14173
14635
  const legacyFile = stateFileForRepo(state.repoRoot, tool);
14174
14636
  const legacy = await readStateFile(legacyFile);
14175
14637
  if (legacy?.sessionId === state.sessionId) {
@@ -14178,7 +14640,7 @@ async function writeSessionState(state, tool) {
14178
14640
  }
14179
14641
  async function deleteStateFile(file) {
14180
14642
  try {
14181
- await fs12.promises.unlink(file);
14643
+ await fs13.promises.unlink(file);
14182
14644
  } catch {
14183
14645
  }
14184
14646
  }
@@ -14194,14 +14656,14 @@ async function deleteSessionState(repoRoot, tool, sessionId) {
14194
14656
  }
14195
14657
  await deleteStateFile(stateFileForRepo(repoRoot, tool));
14196
14658
  }
14197
- async function acquireLock2(repoRoot, tool, retries = LOCK_RETRIES2, delayMs = LOCK_RETRY_DELAY_MS2) {
14659
+ async function acquireLock3(repoRoot, tool, retries = LOCK_RETRIES3, delayMs = LOCK_RETRY_DELAY_MS3) {
14198
14660
  const lockPath = lockFileForRepo(repoRoot, tool);
14199
- await fs12.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
14661
+ await fs13.promises.mkdir(stateDir3(), { recursive: true, mode: 448 });
14200
14662
  for (let i = 0; i < retries; i++) {
14201
14663
  try {
14202
- const fd = await fs12.promises.open(
14664
+ const fd = await fs13.promises.open(
14203
14665
  lockPath,
14204
- fs12.constants.O_CREAT | fs12.constants.O_EXCL | fs12.constants.O_WRONLY
14666
+ fs13.constants.O_CREAT | fs13.constants.O_EXCL | fs13.constants.O_WRONLY
14205
14667
  );
14206
14668
  await fd.write(String(process.pid));
14207
14669
  await fd.close();
@@ -14216,9 +14678,9 @@ async function acquireLock2(repoRoot, tool, retries = LOCK_RETRIES2, delayMs = L
14216
14678
  }
14217
14679
  throw new Error(`Failed to acquire lock after ${retries} retries`);
14218
14680
  }
14219
- async function releaseLock2(repoRoot, tool) {
14681
+ async function releaseLock3(repoRoot, tool) {
14220
14682
  try {
14221
- await fs12.promises.unlink(lockFileForRepo(repoRoot, tool));
14683
+ await fs13.promises.unlink(lockFileForRepo(repoRoot, tool));
14222
14684
  } catch {
14223
14685
  }
14224
14686
  }
@@ -14241,18 +14703,18 @@ var TOOL_LABELS = {
14241
14703
  async function loadConfiguredRepos() {
14242
14704
  const file = await loadProjects();
14243
14705
  return Object.entries(file.projects).map(([repoRoot, config]) => ({
14244
- repoRoot: path16.resolve(repoRoot),
14706
+ repoRoot: path18.resolve(repoRoot),
14245
14707
  config
14246
14708
  })).sort((a, b) => a.repoRoot.localeCompare(b.repoRoot));
14247
14709
  }
14248
14710
  function repoLabel(repoRoot) {
14249
- return path16.basename(repoRoot) || repoRoot;
14711
+ return path18.basename(repoRoot) || repoRoot;
14250
14712
  }
14251
14713
  function resolveCwd2(payload) {
14252
- return payload.cwd ?? payload.workspace_roots?.[0] ?? null;
14714
+ return resolveHookCwd(payload);
14253
14715
  }
14254
14716
  function resolveSessionId2(payload) {
14255
- return payload.session_id ?? payload.conversation_id ?? null;
14717
+ return resolveHookSessionId(payload);
14256
14718
  }
14257
14719
  function epochPrefix(epoch) {
14258
14720
  return `epoch-${String(epoch).padStart(3, "0")}`;
@@ -14268,7 +14730,7 @@ function captureHeadSha(cwd) {
14268
14730
  }
14269
14731
  }
14270
14732
  function execGit(cwd, args) {
14271
- return execFileSync2("git", args, {
14733
+ return execFileSync3("git", args, {
14272
14734
  cwd,
14273
14735
  stdio: ["pipe", "pipe", "pipe"],
14274
14736
  timeout: 3e4,
@@ -14461,27 +14923,34 @@ async function createGitTracesContribution(params) {
14461
14923
  const epochSeconds = formatEpochSeconds3(now);
14462
14924
  const shortId = state.sessionId.slice(0, 12);
14463
14925
  const repoName = repoLabel(repoRoot);
14464
- const contribution = await client.createContribution(config.projectId, {
14465
- contributionTypeSlug: GIT_TRACES_SLUG,
14466
- title: `${toolLabel2} session ${shortId} \u2014 ${repoName} \u2014 ${epochSeconds}`,
14467
- body: `Session ID: ${state.sessionId}
14926
+ let contributionId = state.contributionId;
14927
+ if (!contributionId) {
14928
+ const contribution = await client.createContribution(config.projectId, {
14929
+ contributionTypeSlug: GIT_TRACES_SLUG,
14930
+ title: `${toolLabel2} session ${shortId} \u2014 ${repoName} \u2014 ${epochSeconds}`,
14931
+ body: `Session ID: ${state.sessionId}
14468
14932
  Tool: ${toolLabel2}
14469
14933
  Repo: ${repoRoot}
14470
14934
  Uploaded: ${now.toISOString()}`
14471
- });
14935
+ });
14936
+ contributionId = contribution.id;
14937
+ state.contributionId = contributionId;
14938
+ state.baselineUploaded = false;
14939
+ await writeSessionState(state, tool);
14940
+ }
14472
14941
  const uploaded = await uploadEpochBaselineArtifacts({
14473
14942
  client,
14474
- contributionId: contribution.id,
14943
+ contributionId,
14475
14944
  epoch: 1,
14476
14945
  artifacts
14477
14946
  });
14478
14947
  if (!uploaded) return null;
14479
- await client.submitContribution(contribution.id);
14948
+ await client.submitContribution(contributionId);
14480
14949
  appendLog(
14481
14950
  "info",
14482
- `git-traces: baseline uploaded (repo=${repoRoot}, project=${config.projectId}, contribution=${contribution.id}, epoch=1, bundleBytes=${artifacts.bundleBuffer.byteLength}, metadataBytes=${artifacts.metadataBuffer.byteLength})`
14951
+ `git-traces: baseline uploaded (repo=${repoRoot}, project=${config.projectId}, contribution=${contributionId}, epoch=1, bundleBytes=${artifacts.bundleBuffer.byteLength}, metadataBytes=${artifacts.metadataBuffer.byteLength})`
14483
14952
  );
14484
- return contribution.id;
14953
+ return contributionId;
14485
14954
  }
14486
14955
  async function uploadEpochBaseline(params) {
14487
14956
  const artifacts = buildEpochBaselineArtifacts(params);
@@ -14519,9 +14988,10 @@ async function initializeSession(repoRoot, tool, sessionId) {
14519
14988
  });
14520
14989
  if (!frozen) return null;
14521
14990
  const state = {
14522
- schemaVersion: CURRENT_SCHEMA_VERSION2,
14991
+ schemaVersion: CURRENT_SCHEMA_VERSION3,
14523
14992
  sessionId,
14524
14993
  contributionId: null,
14994
+ baselineUploaded: false,
14525
14995
  baselineSha: frozen.baselineSha,
14526
14996
  baselineTreeSha: frozen.baselineTreeSha,
14527
14997
  baselineMetadata: frozen.baselineMetadata,
@@ -14569,7 +15039,7 @@ async function processSessionStartRepo(repo, tool, sessionId) {
14569
15039
  );
14570
15040
  return "skipped";
14571
15041
  }
14572
- await acquireLock2(repoRoot, tool);
15042
+ await acquireLock3(repoRoot, tool);
14573
15043
  try {
14574
15044
  const staleLegacy = await readSessionState(repoRoot, tool);
14575
15045
  if (staleLegacy && staleLegacy.sessionId !== sessionId) {
@@ -14600,7 +15070,7 @@ async function processSessionStartRepo(repo, tool, sessionId) {
14600
15070
  );
14601
15071
  return "failed";
14602
15072
  } finally {
14603
- await releaseLock2(repoRoot, tool);
15073
+ await releaseLock3(repoRoot, tool);
14604
15074
  }
14605
15075
  }
14606
15076
  async function handleSessionStart(payload, tool) {
@@ -14682,6 +15152,7 @@ async function registerInitialContribution(params) {
14682
15152
  });
14683
15153
  if (!contributionId) return false;
14684
15154
  state.contributionId = contributionId;
15155
+ state.baselineUploaded = true;
14685
15156
  state.baselineTreeSha = artifacts.baselineTreeSha;
14686
15157
  state.lastSnapshotTreeSha = artifacts.baselineTreeSha;
14687
15158
  await writeSessionState(state, tool);
@@ -14700,7 +15171,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14700
15171
  );
14701
15172
  return "skipped";
14702
15173
  }
14703
- await acquireLock2(repoRoot, tool);
15174
+ await acquireLock3(repoRoot, tool);
14704
15175
  let state = null;
14705
15176
  try {
14706
15177
  state = await readSessionState(repoRoot, tool, sessionId);
@@ -14723,7 +15194,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14723
15194
  if (currentHeadSha !== state.headSha) {
14724
15195
  const client2 = await loadRepoClient(repo);
14725
15196
  if (!client2) return "skipped";
14726
- if (state.contributionId === null) {
15197
+ if (state.contributionId === null || !state.baselineUploaded) {
14727
15198
  const artifacts2 = buildInitialBaselineArtifactsForState(
14728
15199
  repoRoot,
14729
15200
  tool,
@@ -14806,7 +15277,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14806
15277
  const nextTurnCount = state.turnCount + 1;
14807
15278
  const turnLabel = turnSuffix(nextTurnCount);
14808
15279
  const filename = `${prefix}-${turnLabel}-${recordedAt}.patch.gz`;
14809
- if (state.contributionId === null && !canUploadFile(filename, patchBuffer)) {
15280
+ if ((state.contributionId === null || !state.baselineUploaded) && !canUploadFile(filename, patchBuffer)) {
14810
15281
  appendLog(
14811
15282
  "warn",
14812
15283
  `git-traces: first changed turn skipped before contribution creation (repo=${repoRoot}, project=${config.projectId}, reason=patch-too-large)`
@@ -14815,7 +15286,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14815
15286
  }
14816
15287
  const client = await loadRepoClient(repo);
14817
15288
  if (!client) return "skipped";
14818
- if (state.contributionId === null) {
15289
+ if (state.contributionId === null || !state.baselineUploaded) {
14819
15290
  const artifacts = buildInitialBaselineArtifactsForState(
14820
15291
  repoRoot,
14821
15292
  tool,
@@ -14880,6 +15351,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14880
15351
  {
14881
15352
  ...state,
14882
15353
  contributionId: null,
15354
+ baselineUploaded: false,
14883
15355
  lastSnapshotSha: state.baselineSha,
14884
15356
  lastSnapshotTreeSha: state.baselineTreeSha,
14885
15357
  turnCount: 0
@@ -14901,7 +15373,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14901
15373
  }
14902
15374
  return "failed";
14903
15375
  } finally {
14904
- await releaseLock2(repoRoot, tool);
15376
+ await releaseLock3(repoRoot, tool);
14905
15377
  }
14906
15378
  }
14907
15379
  async function handleStop(payload, tool) {
@@ -14918,7 +15390,7 @@ async function handleStop(payload, tool) {
14918
15390
  if (sessionId) {
14919
15391
  const storedStates = await listSessionStatesForSession(tool, sessionId);
14920
15392
  for (const { state } of storedStates) {
14921
- const repo = repoByRoot.get(path16.resolve(state.repoRoot));
15393
+ const repo = repoByRoot.get(path18.resolve(state.repoRoot));
14922
15394
  if (!repo) {
14923
15395
  missingConfig++;
14924
15396
  appendLog(
@@ -14954,7 +15426,7 @@ async function handleStop(payload, tool) {
14954
15426
  );
14955
15427
  }
14956
15428
  async function cleanupSessionStateForRepo(repoRoot, tool, sessionId) {
14957
- await acquireLock2(repoRoot, tool);
15429
+ await acquireLock3(repoRoot, tool);
14958
15430
  try {
14959
15431
  const state = await readSessionState(repoRoot, tool, sessionId);
14960
15432
  if (!state) return "no-state";
@@ -14972,7 +15444,7 @@ async function cleanupSessionStateForRepo(repoRoot, tool, sessionId) {
14972
15444
  );
14973
15445
  return "failed";
14974
15446
  } finally {
14975
- await releaseLock2(repoRoot, tool);
15447
+ await releaseLock3(repoRoot, tool);
14976
15448
  }
14977
15449
  }
14978
15450
  async function handleSessionEnd(payload, tool) {
@@ -14980,21 +15452,62 @@ async function handleSessionEnd(payload, tool) {
14980
15452
  const sessionId = resolveSessionId2(payload);
14981
15453
  const project = cwd ? await findProjectForCwd(cwd) : null;
14982
15454
  const triggerRepo = project?.repoRoot ?? cwd ?? "<none>";
15455
+ const recordedAt = Date.now();
14983
15456
  const repoRoots = [];
14984
15457
  if (sessionId) {
14985
15458
  const states = await listSessionStatesForSession(tool, sessionId);
14986
15459
  for (const { state } of states) {
14987
- repoRoots.push(path16.resolve(state.repoRoot));
15460
+ repoRoots.push(path18.resolve(state.repoRoot));
14988
15461
  }
14989
15462
  }
14990
15463
  if (repoRoots.length === 0 && cwd) {
14991
15464
  repoRoots.push(project?.repoRoot ?? cwd);
14992
15465
  }
14993
15466
  if (repoRoots.length === 0) return;
15467
+ const repos = await loadConfiguredRepos();
15468
+ const repoByRoot = new Map(repos.map((repo) => [repo.repoRoot, repo]));
14994
15469
  let cleaned = 0;
14995
15470
  let noState = 0;
15471
+ let uploaded = 0;
15472
+ let unchanged = 0;
15473
+ let skipped = 0;
14996
15474
  let failed = 0;
14997
15475
  for (const repoRoot of repoRoots) {
15476
+ const repo = repoByRoot.get(path18.resolve(repoRoot)) ?? (project && path18.resolve(project.repoRoot) === path18.resolve(repoRoot) ? { repoRoot: project.repoRoot, config: project.config } : null);
15477
+ if (!repo) {
15478
+ skipped++;
15479
+ appendLog(
15480
+ "warn",
15481
+ `git-traces: preserving SessionEnd state for repo ${repoRoot} (reason=missing-config)`
15482
+ );
15483
+ continue;
15484
+ }
15485
+ const finalOutcome = await processStopRepo(
15486
+ repo,
15487
+ tool,
15488
+ sessionId,
15489
+ recordedAt
15490
+ );
15491
+ if (finalOutcome === "uploaded") uploaded++;
15492
+ else if (finalOutcome === "unchanged") unchanged++;
15493
+ else if (finalOutcome === "skipped") {
15494
+ skipped++;
15495
+ appendLog(
15496
+ "warn",
15497
+ `git-traces: preserving SessionEnd state for repo ${repoRoot} after skipped final upload`
15498
+ );
15499
+ continue;
15500
+ } else if (finalOutcome === "failed") {
15501
+ failed++;
15502
+ appendLog(
15503
+ "warn",
15504
+ `git-traces: preserving SessionEnd state for repo ${repoRoot} after failed final upload`
15505
+ );
15506
+ continue;
15507
+ } else {
15508
+ noState++;
15509
+ continue;
15510
+ }
14998
15511
  const outcome = await cleanupSessionStateForRepo(repoRoot, tool, sessionId);
14999
15512
  if (outcome === "cleaned") cleaned++;
15000
15513
  else if (outcome === "no-state") noState++;
@@ -15002,16 +15515,16 @@ async function handleSessionEnd(payload, tool) {
15002
15515
  }
15003
15516
  appendLog(
15004
15517
  "info",
15005
- `git-traces: SessionEnd summary (session=${sessionId ?? "<none>"}, tool=${tool}, triggerRepo=${triggerRepo}, states=${repoRoots.length}, cleaned=${cleaned}, noState=${noState}, failed=${failed})`
15518
+ `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})`
15006
15519
  );
15007
15520
  }
15008
15521
 
15009
15522
  // src/git-traces/index.ts
15010
15523
  var WORKER_ENV_FLAG2 = "HILLCLIMB_GIT_TRACES_WORKER";
15011
15524
  var TOOL_ENV_FLAG2 = "HILLCLIMB_GIT_TRACES_TOOL";
15012
- var FLOW_ID_ENV = "HILLCLIMB_GIT_TRACES_FLOW";
15013
- function newFlowId() {
15014
- return crypto3.randomBytes(3).toString("hex");
15525
+ var FLOW_ID_ENV2 = "HILLCLIMB_GIT_TRACES_FLOW";
15526
+ function newFlowId2() {
15527
+ return crypto5.randomBytes(3).toString("hex");
15015
15528
  }
15016
15529
  var KNOWN_TOOLS = /* @__PURE__ */ new Set([
15017
15530
  "claude",
@@ -15150,7 +15663,7 @@ async function runGitTraces() {
15150
15663
  await runGitTracesWorker();
15151
15664
  return;
15152
15665
  }
15153
- const flowId = newFlowId();
15666
+ const flowId = newFlowId2();
15154
15667
  setLogPrefix(`[${flowId}]`);
15155
15668
  const toolArg = parseToolArg2(process.argv.slice(2));
15156
15669
  appendLog(
@@ -15193,7 +15706,7 @@ async function runGitTraces() {
15193
15706
  ...process.env,
15194
15707
  [WORKER_ENV_FLAG2]: "1",
15195
15708
  [TOOL_ENV_FLAG2]: tool,
15196
- [FLOW_ID_ENV]: flowId
15709
+ [FLOW_ID_ENV2]: flowId
15197
15710
  }
15198
15711
  }
15199
15712
  );
@@ -15215,7 +15728,7 @@ async function runGitTraces() {
15215
15728
  }
15216
15729
  }
15217
15730
  async function runGitTracesWorker() {
15218
- setLogPrefix(`[${process.env[FLOW_ID_ENV] ?? newFlowId()}]`);
15731
+ setLogPrefix(`[${process.env[FLOW_ID_ENV2] ?? newFlowId2()}]`);
15219
15732
  const tool = process.env[TOOL_ENV_FLAG2] ?? parseToolArg2(process.argv.slice(2)) ?? null;
15220
15733
  appendLog(
15221
15734
  "info",
@@ -15297,29 +15810,29 @@ ${stack}` : ""}`
15297
15810
  }
15298
15811
 
15299
15812
  // src/outputs/zip.ts
15300
- import fs14 from "fs";
15301
- import path18 from "path";
15813
+ import fs15 from "fs";
15814
+ import path20 from "path";
15302
15815
  import archiver2 from "archiver";
15303
15816
 
15304
15817
  // src/outputs/downloads.ts
15305
15818
  import { execSync as execSync2 } from "child_process";
15306
- import fs13 from "fs";
15307
- import os8 from "os";
15308
- import path17 from "path";
15819
+ import fs14 from "fs";
15820
+ import os9 from "os";
15821
+ import path19 from "path";
15309
15822
  function getDownloadsFolder() {
15310
- const home = os8.homedir();
15823
+ const home = os9.homedir();
15311
15824
  if (process.platform === "linux") {
15312
15825
  try {
15313
15826
  const xdgDir = execSync2("xdg-user-dir DOWNLOAD", {
15314
15827
  encoding: "utf-8",
15315
15828
  timeout: 3e3
15316
15829
  }).trim();
15317
- if (xdgDir && fs13.existsSync(xdgDir)) return xdgDir;
15830
+ if (xdgDir && fs14.existsSync(xdgDir)) return xdgDir;
15318
15831
  } catch {
15319
15832
  }
15320
15833
  }
15321
- const downloads = path17.join(home, "Downloads");
15322
- if (fs13.existsSync(downloads)) return downloads;
15834
+ const downloads = path19.join(home, "Downloads");
15835
+ if (fs14.existsSync(downloads)) return downloads;
15323
15836
  return home;
15324
15837
  }
15325
15838
 
@@ -15328,11 +15841,11 @@ function sanitizeFilename(name) {
15328
15841
  return name.replace(/[^a-zA-Z0-9._-]/g, "_");
15329
15842
  }
15330
15843
  function getUniqueFilename(dir, base, ext) {
15331
- let candidate = path18.join(dir, `${base}${ext}`);
15332
- if (!fs14.existsSync(candidate)) return candidate;
15844
+ let candidate = path20.join(dir, `${base}${ext}`);
15845
+ if (!fs15.existsSync(candidate)) return candidate;
15333
15846
  let i = 1;
15334
- while (fs14.existsSync(candidate)) {
15335
- candidate = path18.join(dir, `${base}-${i}${ext}`);
15847
+ while (fs15.existsSync(candidate)) {
15848
+ candidate = path20.join(dir, `${base}-${i}${ext}`);
15336
15849
  i++;
15337
15850
  }
15338
15851
  return candidate;
@@ -15342,13 +15855,13 @@ var ZipOutput = class {
15342
15855
  label = "Save as .zip to Downloads";
15343
15856
  async emit(group, options) {
15344
15857
  const downloadsDir = getDownloadsFolder();
15345
- const repoName = sanitizeFilename(path18.basename(group.repoPath));
15858
+ const repoName = sanitizeFilename(path20.basename(group.repoPath));
15346
15859
  const timeRange = options.timeRange;
15347
15860
  const rangePart = timeRange?.label ?? "all";
15348
15861
  const epochSeconds = Math.floor(Date.now() / 1e3);
15349
15862
  const baseName = `logs-${repoName}-${rangePart}-${epochSeconds}`;
15350
15863
  const outputPath = getUniqueFilename(downloadsDir, baseName, ".zip");
15351
- const output = fs14.createWriteStream(outputPath);
15864
+ const output = fs15.createWriteStream(outputPath);
15352
15865
  const archive = archiver2("zip", { zlib: { level: 6 } });
15353
15866
  const done = new Promise((resolve, reject) => {
15354
15867
  output.on("close", resolve);
@@ -15542,15 +16055,15 @@ async function confirmExport(group, output) {
15542
16055
  }
15543
16056
 
15544
16057
  // src/sources/claude.ts
15545
- import fs15 from "fs";
15546
- import os9 from "os";
15547
- import path19 from "path";
16058
+ import fs16 from "fs";
16059
+ import os10 from "os";
16060
+ import path21 from "path";
15548
16061
  import readline from "readline";
15549
16062
  var SKIP_DIRS = /* @__PURE__ */ new Set(["tool-results"]);
15550
16063
  async function resolveRepoPath(projectDir) {
15551
- const indexPath = path19.join(projectDir, "sessions-index.json");
16064
+ const indexPath = path21.join(projectDir, "sessions-index.json");
15552
16065
  try {
15553
- const raw = await fs15.promises.readFile(indexPath, "utf-8");
16066
+ const raw = await fs16.promises.readFile(indexPath, "utf-8");
15554
16067
  const data = JSON.parse(raw);
15555
16068
  if (data.originalPath && typeof data.originalPath === "string") {
15556
16069
  return data.originalPath;
@@ -15558,12 +16071,12 @@ async function resolveRepoPath(projectDir) {
15558
16071
  } catch {
15559
16072
  }
15560
16073
  const cwdCounts = /* @__PURE__ */ new Map();
15561
- const entries = await fs15.promises.readdir(projectDir, {
16074
+ const entries = await fs16.promises.readdir(projectDir, {
15562
16075
  withFileTypes: true
15563
16076
  });
15564
16077
  for (const entry of entries) {
15565
16078
  if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
15566
- const cwd = await extractCwdFromJsonl(path19.join(projectDir, entry.name));
16079
+ const cwd = await extractCwdFromJsonl(path21.join(projectDir, entry.name));
15567
16080
  if (cwd) {
15568
16081
  cwdCounts.set(cwd, (cwdCounts.get(cwd) ?? 0) + 1);
15569
16082
  }
@@ -15582,7 +16095,7 @@ async function resolveRepoPath(projectDir) {
15582
16095
  return null;
15583
16096
  }
15584
16097
  async function extractCwdFromJsonl(filePath) {
15585
- const stream = fs15.createReadStream(filePath, { encoding: "utf-8" });
16098
+ const stream = fs16.createReadStream(filePath, { encoding: "utf-8" });
15586
16099
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
15587
16100
  try {
15588
16101
  for await (const line of rl) {
@@ -15604,12 +16117,12 @@ async function extractCwdFromJsonl(filePath) {
15604
16117
  async function collectFiles(dir, baseDir, sourceName, repoPath, results) {
15605
16118
  let entries;
15606
16119
  try {
15607
- entries = await fs15.promises.readdir(dir, { withFileTypes: true });
16120
+ entries = await fs16.promises.readdir(dir, { withFileTypes: true });
15608
16121
  } catch {
15609
16122
  return;
15610
16123
  }
15611
16124
  for (const entry of entries) {
15612
- const fullPath = path19.join(dir, entry.name);
16125
+ const fullPath = path21.join(dir, entry.name);
15613
16126
  if (entry.isDirectory()) {
15614
16127
  if (SKIP_DIRS.has(entry.name)) continue;
15615
16128
  await collectFiles(fullPath, baseDir, sourceName, repoPath, results);
@@ -15631,19 +16144,19 @@ function fallbackDecode(encodedName) {
15631
16144
  var ClaudeSource = class {
15632
16145
  name = "claude";
15633
16146
  async scan() {
15634
- const baseDir = path19.join(os9.homedir(), ".claude", "projects");
16147
+ const baseDir = path21.join(os10.homedir(), ".claude", "projects");
15635
16148
  try {
15636
- await fs15.promises.access(baseDir);
16149
+ await fs16.promises.access(baseDir);
15637
16150
  } catch {
15638
16151
  return [];
15639
16152
  }
15640
- const projectDirs = await fs15.promises.readdir(baseDir, {
16153
+ const projectDirs = await fs16.promises.readdir(baseDir, {
15641
16154
  withFileTypes: true
15642
16155
  });
15643
16156
  const dirEntries = projectDirs.filter((d) => d.isDirectory());
15644
16157
  const resultArrays = await Promise.all(
15645
16158
  dirEntries.map(async (dir) => {
15646
- const projectPath = path19.join(baseDir, dir.name);
16159
+ const projectPath = path21.join(baseDir, dir.name);
15647
16160
  const repoPath = await resolveRepoPath(projectPath) ?? fallbackDecode(dir.name);
15648
16161
  const files = [];
15649
16162
  await collectFiles(
@@ -15661,12 +16174,12 @@ var ClaudeSource = class {
15661
16174
  };
15662
16175
 
15663
16176
  // src/sources/codex.ts
15664
- import fs16 from "fs";
15665
- import os10 from "os";
15666
- import path20 from "path";
16177
+ import fs17 from "fs";
16178
+ import os11 from "os";
16179
+ import path22 from "path";
15667
16180
  import readline2 from "readline";
15668
16181
  async function parseSessionMeta(filePath) {
15669
- const stream = fs16.createReadStream(filePath, { encoding: "utf-8" });
16182
+ const stream = fs17.createReadStream(filePath, { encoding: "utf-8" });
15670
16183
  const rl = readline2.createInterface({ input: stream, crlfDelay: Infinity });
15671
16184
  try {
15672
16185
  for await (const line of rl) {
@@ -15691,12 +16204,12 @@ async function findJsonlFiles(dir) {
15691
16204
  async function walk(d) {
15692
16205
  let entries;
15693
16206
  try {
15694
- entries = await fs16.promises.readdir(d, { withFileTypes: true });
16207
+ entries = await fs17.promises.readdir(d, { withFileTypes: true });
15695
16208
  } catch {
15696
16209
  return;
15697
16210
  }
15698
16211
  for (const entry of entries) {
15699
- const full = path20.join(d, entry.name);
16212
+ const full = path22.join(d, entry.name);
15700
16213
  if (entry.isDirectory()) {
15701
16214
  await walk(full);
15702
16215
  } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
@@ -15710,11 +16223,11 @@ async function findJsonlFiles(dir) {
15710
16223
  async function loadHistory(historyPath) {
15711
16224
  const map = /* @__PURE__ */ new Map();
15712
16225
  try {
15713
- await fs16.promises.access(historyPath);
16226
+ await fs17.promises.access(historyPath);
15714
16227
  } catch {
15715
16228
  return map;
15716
16229
  }
15717
- const stream = fs16.createReadStream(historyPath, { encoding: "utf-8" });
16230
+ const stream = fs17.createReadStream(historyPath, { encoding: "utf-8" });
15718
16231
  const rl = readline2.createInterface({ input: stream, crlfDelay: Infinity });
15719
16232
  try {
15720
16233
  for await (const line of rl) {
@@ -15741,14 +16254,14 @@ async function loadHistory(historyPath) {
15741
16254
  var CodexSource = class {
15742
16255
  name = "codex";
15743
16256
  async scan() {
15744
- const codexDir = path20.join(os10.homedir(), ".codex");
15745
- const sessionsDir = path20.join(codexDir, "sessions");
16257
+ const codexDir = path22.join(os11.homedir(), ".codex");
16258
+ const sessionsDir = path22.join(codexDir, "sessions");
15746
16259
  try {
15747
- await fs16.promises.access(sessionsDir);
16260
+ await fs17.promises.access(sessionsDir);
15748
16261
  } catch {
15749
16262
  return [];
15750
16263
  }
15751
- const historyPath = path20.join(codexDir, "history.jsonl");
16264
+ const historyPath = path22.join(codexDir, "history.jsonl");
15752
16265
  const [jsonlFiles, historyMap] = await Promise.all([
15753
16266
  findJsonlFiles(sessionsDir),
15754
16267
  loadHistory(historyPath)
@@ -15771,8 +16284,8 @@ var CodexSource = class {
15771
16284
  });
15772
16285
  const historyLines = historyMap.get(meta.sessionId);
15773
16286
  if (historyLines) {
15774
- const sessionDir = path20.relative(sessionsDir, path20.dirname(filePath));
15775
- const historyAbsPath = path20.join(
16287
+ const sessionDir = path22.relative(sessionsDir, path22.dirname(filePath));
16288
+ const historyAbsPath = path22.join(
15776
16289
  sessionsDir,
15777
16290
  sessionDir,
15778
16291
  `history-${meta.sessionId}.jsonl`
@@ -15792,18 +16305,18 @@ var CodexSource = class {
15792
16305
  };
15793
16306
 
15794
16307
  // src/sources/copilotChat.ts
15795
- import fs17 from "fs";
15796
- import os11 from "os";
15797
- import path21 from "path";
16308
+ import fs18 from "fs";
16309
+ import os12 from "os";
16310
+ import path23 from "path";
15798
16311
  import { fileURLToPath } from "url";
15799
16312
  function vsCodeUserDirs() {
15800
- const home = os11.homedir();
16313
+ const home = os12.homedir();
15801
16314
  const dirs = [
15802
- path21.join(home, "Library", "Application Support", "Code", "User"),
15803
- path21.join(home, ".config", "Code", "User")
16315
+ path23.join(home, "Library", "Application Support", "Code", "User"),
16316
+ path23.join(home, ".config", "Code", "User")
15804
16317
  ];
15805
16318
  if (process.env.APPDATA) {
15806
- dirs.push(path21.join(process.env.APPDATA, "Code", "User"));
16319
+ dirs.push(path23.join(process.env.APPDATA, "Code", "User"));
15807
16320
  }
15808
16321
  return dirs;
15809
16322
  }
@@ -15818,7 +16331,7 @@ function uriToFsPath(uri) {
15818
16331
  async function readWorkspaceFolder(workspaceJsonPath) {
15819
16332
  let raw;
15820
16333
  try {
15821
- raw = await fs17.promises.readFile(workspaceJsonPath, "utf-8");
16334
+ raw = await fs18.promises.readFile(workspaceJsonPath, "utf-8");
15822
16335
  } catch {
15823
16336
  return null;
15824
16337
  }
@@ -15840,10 +16353,10 @@ var CopilotChatSource = class {
15840
16353
  async scan() {
15841
16354
  const results = [];
15842
16355
  for (const userDir of vsCodeUserDirs()) {
15843
- const workspaceStorage = path21.join(userDir, "workspaceStorage");
16356
+ const workspaceStorage = path23.join(userDir, "workspaceStorage");
15844
16357
  let hashDirs;
15845
16358
  try {
15846
- hashDirs = await fs17.promises.readdir(workspaceStorage, {
16359
+ hashDirs = await fs18.promises.readdir(workspaceStorage, {
15847
16360
  withFileTypes: true
15848
16361
  });
15849
16362
  } catch {
@@ -15851,22 +16364,22 @@ var CopilotChatSource = class {
15851
16364
  }
15852
16365
  for (const hash of hashDirs) {
15853
16366
  if (!hash.isDirectory()) continue;
15854
- const wsRoot = path21.join(workspaceStorage, hash.name);
15855
- const transcriptsDir = path21.join(
16367
+ const wsRoot = path23.join(workspaceStorage, hash.name);
16368
+ const transcriptsDir = path23.join(
15856
16369
  wsRoot,
15857
16370
  "GitHub.copilot-chat",
15858
16371
  "transcripts"
15859
16372
  );
15860
16373
  let transcriptEntries;
15861
16374
  try {
15862
- transcriptEntries = await fs17.promises.readdir(transcriptsDir, {
16375
+ transcriptEntries = await fs18.promises.readdir(transcriptsDir, {
15863
16376
  withFileTypes: true
15864
16377
  });
15865
16378
  } catch {
15866
16379
  continue;
15867
16380
  }
15868
16381
  const repoPath = await readWorkspaceFolder(
15869
- path21.join(wsRoot, "workspace.json")
16382
+ path23.join(wsRoot, "workspace.json")
15870
16383
  );
15871
16384
  if (!repoPath) continue;
15872
16385
  for (const entry of transcriptEntries) {
@@ -15874,7 +16387,7 @@ var CopilotChatSource = class {
15874
16387
  const sessionId = entry.name.slice(0, -".jsonl".length);
15875
16388
  results.push({
15876
16389
  sourceName: this.name,
15877
- absolutePath: path21.join(transcriptsDir, entry.name),
16390
+ absolutePath: path23.join(transcriptsDir, entry.name),
15878
16391
  repoPath,
15879
16392
  metadata: { sessionId }
15880
16393
  });
@@ -15914,7 +16427,7 @@ function reportRedactionStats(noun, stats) {
15914
16427
  async function filterByTimeRange(group, range) {
15915
16428
  const results = await Promise.all(
15916
16429
  group.files.map(
15917
- (f) => fs18.promises.stat(f.absolutePath).then((s) => ({ file: f, stat: s })).catch(() => null)
16430
+ (f) => fs19.promises.stat(f.absolutePath).then((s) => ({ file: f, stat: s })).catch(() => null)
15918
16431
  )
15919
16432
  );
15920
16433
  const filtered = [];
@@ -15941,10 +16454,10 @@ async function runInteractive() {
15941
16454
  s.start(`Scanning ${source.name} logs...`);
15942
16455
  const allFiles = await source.scan();
15943
16456
  const allGroups = await mergeByRepo(allFiles);
15944
- const repoRoot = path22.resolve(repo.root);
16457
+ const repoRoot = path24.resolve(repo.root);
15945
16458
  const matching = allGroups.filter((g) => {
15946
- const resolved = path22.resolve(g.repoPath);
15947
- return resolved === repoRoot || resolved.startsWith(repoRoot + path22.sep);
16459
+ const resolved = path24.resolve(g.repoPath);
16460
+ return resolved === repoRoot || resolved.startsWith(repoRoot + path24.sep);
15948
16461
  });
15949
16462
  if (matching.length === 0) {
15950
16463
  s.stop(`No ${source.name} logs found for ${repo.name}.`);
@@ -15975,7 +16488,7 @@ async function runInteractive() {
15975
16488
  }
15976
16489
  }
15977
16490
  const envFileNames = await discoverEnvFiles(repoRoot);
15978
- const envFilePaths = envFileNames.map((n) => path22.join(repoRoot, n));
16491
+ const envFilePaths = envFileNames.map((n) => path24.join(repoRoot, n));
15979
16492
  const additionalFiles = await promptSecretFiles(envFileNames);
15980
16493
  const secretResult = await collectSecrets(
15981
16494
  repoRoot,