hillclimb 0.2.0 → 0.3.0

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 +985 -305
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import fs17 from "fs";
5
- import path20 from "path";
5
+ import path21 from "path";
6
6
  import * as p6 from "@clack/prompts";
7
7
 
8
8
  // src/commands/init.ts
@@ -28,8 +28,7 @@ var cyan = wrap(36, 39);
28
28
  import fs from "fs";
29
29
  import os from "os";
30
30
  import path from "path";
31
- var CONFIG_DIR = path.join(os.homedir(), ".hillclimb");
32
- var CONFIG_PATH = path.join(CONFIG_DIR, "projects.json");
31
+ var DEFAULT_CONFIG_DIR = path.join(os.homedir(), ".hillclimb");
33
32
  function normalizeProjectConfig(raw) {
34
33
  if (!raw || typeof raw !== "object") return null;
35
34
  const config = raw;
@@ -54,14 +53,14 @@ function normalizeProjectConfig(raw) {
54
53
  };
55
54
  }
56
55
  function configDir() {
57
- return CONFIG_DIR;
56
+ return process.env.HILLCLIMB_CONFIG_DIR ?? DEFAULT_CONFIG_DIR;
58
57
  }
59
58
  function configPath() {
60
- return CONFIG_PATH;
59
+ return path.join(configDir(), "projects.json");
61
60
  }
62
61
  async function loadProjects() {
63
62
  try {
64
- const raw = await fs.promises.readFile(CONFIG_PATH, "utf-8");
63
+ const raw = await fs.promises.readFile(configPath(), "utf-8");
65
64
  const parsed = JSON.parse(raw);
66
65
  if (!parsed.projects || typeof parsed.projects !== "object") {
67
66
  return { projects: {} };
@@ -77,12 +76,12 @@ async function loadProjects() {
77
76
  }
78
77
  }
79
78
  async function saveProjects(file) {
80
- await fs.promises.mkdir(CONFIG_DIR, { recursive: true, mode: 448 });
81
- const tmp = `${CONFIG_PATH}.tmp`;
79
+ await fs.promises.mkdir(configDir(), { recursive: true, mode: 448 });
80
+ const tmp = `${configPath()}.tmp`;
82
81
  await fs.promises.writeFile(tmp, JSON.stringify(file, null, 2), {
83
82
  mode: 384
84
83
  });
85
- await fs.promises.rename(tmp, CONFIG_PATH);
84
+ await fs.promises.rename(tmp, configPath());
86
85
  }
87
86
  async function upsertProject(repoRoot, config) {
88
87
  const file = await loadProjects();
@@ -177,13 +176,15 @@ async function ensureGitignored(repoRoot, files) {
177
176
  // src/identity.ts
178
177
  import fs3 from "fs";
179
178
  import path4 from "path";
180
- var IDENTITY_PATH = path4.join(configDir(), "identity.json");
179
+ function identityPath() {
180
+ return path4.join(configDir(), "identity.json");
181
+ }
181
182
  function normalizeUrl(apiBaseUrl) {
182
183
  return apiBaseUrl.replace(/\/$/, "");
183
184
  }
184
185
  async function loadAllIdentities() {
185
186
  try {
186
- const raw = await fs3.promises.readFile(IDENTITY_PATH, "utf-8");
187
+ const raw = await fs3.promises.readFile(identityPath(), "utf-8");
187
188
  const parsed = JSON.parse(raw);
188
189
  if (!parsed.identities || typeof parsed.identities !== "object") {
189
190
  return { identities: {} };
@@ -199,11 +200,11 @@ async function loadIdentity(apiBaseUrl) {
199
200
  }
200
201
  async function writeIdentityFile(file) {
201
202
  await fs3.promises.mkdir(configDir(), { recursive: true, mode: 448 });
202
- const tmp = `${IDENTITY_PATH}.tmp`;
203
+ const tmp = `${identityPath()}.tmp`;
203
204
  await fs3.promises.writeFile(tmp, JSON.stringify(file, null, 2), {
204
205
  mode: 384
205
206
  });
206
- await fs3.promises.rename(tmp, IDENTITY_PATH);
207
+ await fs3.promises.rename(tmp, identityPath());
207
208
  }
208
209
  async function saveIdentity(identity) {
209
210
  const file = await loadAllIdentities();
@@ -602,6 +603,8 @@ import path6 from "path";
602
603
  var HOOK_CMD = (sub) => `npx hillclimb@latest ${sub}`;
603
604
  var GIT_TRACES_CMD = (tool) => `npx hillclimb@latest git-traces --tool=${tool}`;
604
605
  var CLAUDE_SESSIONEND_UPLOAD_TIMEOUT_SECONDS = 30;
606
+ var DEV_HOOK_ENV = "HILLCLIMB_DEV_HOOK=1";
607
+ var SKIP_SELF_HEAL_ENV = "HILLCLIMB_SKIP_HOOK_SELF_HEAL=1";
605
608
  var TOOLS = [
606
609
  {
607
610
  tool: "claude",
@@ -764,17 +767,18 @@ function claudeCheck(settings, eventName, command, options = {}) {
764
767
  const matchers = settings.hooks?.[eventName] ?? [];
765
768
  return claudeHookPresent(matchers, command, options);
766
769
  }
767
- function claudeUninstall(settings, eventName, command) {
770
+ function claudeUninstallMatching(settings, eventName, predicate) {
768
771
  const hooks = settings.hooks;
769
772
  const matchers = hooks?.[eventName];
770
773
  if (!hooks || !matchers || matchers.length === 0) return false;
771
774
  let removedAny = false;
772
775
  const next = [];
773
776
  for (const m of matchers) {
774
- const remaining = (m.hooks ?? []).filter(
775
- (h) => !(h.type === "command" && h.command === command)
776
- );
777
- if (remaining.length !== (m.hooks ?? []).length) removedAny = true;
777
+ const remaining = (m.hooks ?? []).filter((h) => {
778
+ const remove = h.type === "command" && typeof h.command === "string" && predicate(h.command);
779
+ if (remove) removedAny = true;
780
+ return !remove;
781
+ });
778
782
  if (remaining.length > 0) next.push({ ...m, hooks: remaining });
779
783
  }
780
784
  if (!removedAny) return false;
@@ -802,11 +806,11 @@ function cursorCheck(settings, eventName, command) {
802
806
  const entries = settings.hooks?.[eventName] ?? [];
803
807
  return cursorHookPresent(entries, command);
804
808
  }
805
- function cursorUninstall(settings, eventName, command) {
809
+ function cursorUninstallMatching(settings, eventName, predicate) {
806
810
  const hooks = settings.hooks;
807
811
  const entries = hooks?.[eventName];
808
812
  if (!hooks || !entries || entries.length === 0) return false;
809
- const next = entries.filter((e) => e.command !== command);
813
+ const next = entries.filter((e) => !predicate(e.command));
810
814
  if (next.length === entries.length) return false;
811
815
  if (next.length > 0) {
812
816
  hooks[eventName] = next;
@@ -834,12 +838,12 @@ function copilotCheck(settings, eventName, command) {
834
838
  const entries = settings.hooks?.[eventName] ?? [];
835
839
  return copilotHookPresent(entries, command);
836
840
  }
837
- function copilotUninstall(settings, eventName, command) {
841
+ function copilotUninstallMatching(settings, eventName, predicate) {
838
842
  const hooks = settings.hooks;
839
843
  const entries = hooks?.[eventName];
840
844
  if (!hooks || !entries || entries.length === 0) return false;
841
845
  const next = entries.filter(
842
- (e) => !(e.type === "command" && e.command === command)
846
+ (e) => !(e.type === "command" && predicate(e.command))
843
847
  );
844
848
  if (next.length === entries.length) return false;
845
849
  if (next.length > 0) {
@@ -850,7 +854,7 @@ function copilotUninstall(settings, eventName, command) {
850
854
  }
851
855
  return true;
852
856
  }
853
- var OPENCODE_PLUGIN_VERSION = 4;
857
+ var OPENCODE_PLUGIN_VERSION = 6;
854
858
  var OPENCODE_PLUGIN_MARKER = `// HILLCLIMB_OPENCODE_PLUGIN_VERSION=${OPENCODE_PLUGIN_VERSION}`;
855
859
  var OPENCODE_PLUGIN_CONTENT = `${OPENCODE_PLUGIN_MARKER}
856
860
  // Auto-installed by \`npx hillclimb\`. Do not edit manually \u2014 re-running
@@ -952,7 +956,10 @@ export const HillclimbPlugin = async ({ directory }) => ({
952
956
  const type = event && event.type;
953
957
  const props = (event && event.properties) || {};
954
958
  const info = props.info || {};
955
- const sessionID = props.sessionID || info.sessionID || info.id;
959
+ const sessionID =
960
+ props.sessionID ||
961
+ info.sessionID ||
962
+ (type === "message.updated" || type === "message.part.updated" ? undefined : info.id);
956
963
  const cwd = directory || props.directory || info.directory;
957
964
 
958
965
  if (type === "message.updated") {
@@ -990,11 +997,8 @@ export const HillclimbPlugin = async ({ directory }) => ({
990
997
 
991
998
  if (type === "session.deleted" && sessionID) {
992
999
  // User explicitly ended this session. Upload its transcript and drop
993
- // the buffer. We do NOT clean up git-traces here: the user may still
994
- // have other live sessions in this repo, and git-traces state is
995
- // keyed per (repoRoot, tool) \u2014 clearing it here would wipe their
996
- // concurrent session. Cleanup happens at server.instance.disposed
997
- // (or on the next session.created via stale-state detection).
1000
+ // the buffer. git-traces state is session-scoped, so this cleanup will
1001
+ // not wipe another concurrent session in the same repo.
998
1002
  const transcriptPath = writeTranscript(sessionID);
999
1003
  if (transcriptPath) {
1000
1004
  spawnHillclimb("upload", {
@@ -1005,15 +1009,19 @@ export const HillclimbPlugin = async ({ directory }) => ({
1005
1009
  tool: TOOL,
1006
1010
  });
1007
1011
  }
1012
+ spawnHillclimb("git-traces --tool=opencode", {
1013
+ session_id: sessionID,
1014
+ cwd,
1015
+ hook_event_name: "session.deleted",
1016
+ tool: TOOL,
1017
+ });
1008
1018
  sessionMessages.delete(sessionID);
1009
1019
  return;
1010
1020
  }
1011
1021
 
1012
1022
  if (type === "server.instance.disposed") {
1013
1023
  // opencode is shutting down \u2014 flush every buffered session's transcript
1014
- // so nothing gets lost. Then clean up git-traces refs/state. No
1015
- // sessionID on this event, but git-traces state is keyed by
1016
- // (repoRoot, tool) so cwd alone is enough for handleSessionEnd.
1024
+ // so nothing gets lost. Then clean up git-traces refs/state per session.
1017
1025
  for (const sid of sessionMessages.keys()) {
1018
1026
  const transcriptPath = writeTranscript(sid);
1019
1027
  if (transcriptPath) {
@@ -1025,8 +1033,15 @@ export const HillclimbPlugin = async ({ directory }) => ({
1025
1033
  tool: TOOL,
1026
1034
  });
1027
1035
  }
1036
+ spawnHillclimb("git-traces --tool=opencode", {
1037
+ session_id: sid,
1038
+ cwd,
1039
+ hook_event_name: "server.instance.disposed",
1040
+ tool: TOOL,
1041
+ });
1028
1042
  }
1029
1043
  sessionMessages.clear();
1044
+ // Keep a no-session cleanup for pre-session-scoped legacy state.
1030
1045
  spawnHillclimb("git-traces --tool=opencode", {
1031
1046
  cwd,
1032
1047
  hook_event_name: "server.instance.disposed",
@@ -1039,16 +1054,16 @@ export const HillclimbPlugin = async ({ directory }) => ({
1039
1054
 
1040
1055
  export default HillclimbPlugin;
1041
1056
  `;
1042
- async function opencodeInstall(file) {
1057
+ async function opencodeInstall(file, content = OPENCODE_PLUGIN_CONTENT) {
1043
1058
  try {
1044
1059
  const existing = await fs5.promises.readFile(file, "utf-8");
1045
- if (existing === OPENCODE_PLUGIN_CONTENT) {
1060
+ if (existing === content) {
1046
1061
  return { installed: 0, alreadyPresent: 1 };
1047
1062
  }
1048
1063
  } catch {
1049
1064
  }
1050
1065
  await fs5.promises.mkdir(path6.dirname(file), { recursive: true });
1051
- await fs5.promises.writeFile(file, OPENCODE_PLUGIN_CONTENT);
1066
+ await fs5.promises.writeFile(file, content);
1052
1067
  return { installed: 1, alreadyPresent: 0 };
1053
1068
  }
1054
1069
  async function opencodeCheck(file) {
@@ -1079,14 +1094,24 @@ function check(settings, format, eventName, command, options = {}) {
1079
1094
  return claudeCheck(settings, eventName, command, options);
1080
1095
  }
1081
1096
  }
1082
- function uninstall(settings, format, eventName, command) {
1097
+ function commandsForEvent(settings, format, eventName) {
1083
1098
  switch (format) {
1084
1099
  case "cursor":
1085
- return cursorUninstall(settings, eventName, command);
1100
+ return (settings.hooks?.[eventName] ?? []).map((e) => e.command).filter((command) => typeof command === "string");
1086
1101
  case "copilot":
1087
- return copilotUninstall(settings, eventName, command);
1102
+ return (settings.hooks?.[eventName] ?? []).filter((e) => e.type === "command").map((e) => e.command).filter((command) => typeof command === "string");
1088
1103
  default:
1089
- return claudeUninstall(settings, eventName, command);
1104
+ return (settings.hooks?.[eventName] ?? []).flatMap((matcher) => matcher.hooks ?? []).filter((hook) => hook.type === "command").map((hook) => hook.command).filter((command) => typeof command === "string");
1105
+ }
1106
+ }
1107
+ function uninstallMatching(settings, format, eventName, predicate) {
1108
+ switch (format) {
1109
+ case "cursor":
1110
+ return cursorUninstallMatching(settings, eventName, predicate);
1111
+ case "copilot":
1112
+ return copilotUninstallMatching(settings, eventName, predicate);
1113
+ default:
1114
+ return claudeUninstallMatching(settings, eventName, predicate);
1090
1115
  }
1091
1116
  }
1092
1117
  function legacyCommandsFor(command) {
@@ -1107,6 +1132,20 @@ function legacyCommandsFor(command) {
1107
1132
  }
1108
1133
  return legacy;
1109
1134
  }
1135
+ function legacyBareGitTracesCommandsFor(command) {
1136
+ return legacyCommandsFor(command).filter(
1137
+ (legacy) => legacy.endsWith(" git-traces")
1138
+ );
1139
+ }
1140
+ function isHillclimbOwnedCommandFor(command, currentCommand) {
1141
+ if (command === currentCommand || legacyCommandsFor(currentCommand).includes(command)) {
1142
+ return true;
1143
+ }
1144
+ const prefix = "npx hillclimb@latest ";
1145
+ if (!currentCommand.startsWith(prefix)) return false;
1146
+ const subcommand = currentCommand.slice(prefix.length);
1147
+ return command.endsWith(` ${subcommand}`) && (command.includes(DEV_HOOK_ENV) || command.includes(SKIP_SELF_HEAL_ENV) && command.includes("dist/cli.js"));
1148
+ }
1110
1149
  async function installHooksForTool(repoRoot, def) {
1111
1150
  const file = settingsPath(repoRoot, def);
1112
1151
  if (def.format === "opencode") {
@@ -1118,10 +1157,13 @@ async function installHooksForTool(repoRoot, def) {
1118
1157
  let alreadyPresent = 0;
1119
1158
  let mutated = false;
1120
1159
  for (const evt of def.events) {
1121
- for (const legacy of legacyCommandsFor(evt.command)) {
1122
- if (uninstall(settings, def.format, evt.eventName, legacy)) {
1123
- mutated = true;
1124
- }
1160
+ if (uninstallMatching(
1161
+ settings,
1162
+ def.format,
1163
+ evt.eventName,
1164
+ (command) => command !== evt.command && isHillclimbOwnedCommandFor(command, evt.command)
1165
+ )) {
1166
+ mutated = true;
1125
1167
  }
1126
1168
  const added = install(settings, def.format, evt.eventName, evt.command, {
1127
1169
  timeout: evt.timeout
@@ -1157,6 +1199,27 @@ async function checkHooksForTool(repoRoot, def) {
1157
1199
  }
1158
1200
  return { allInstalled: missing.length === 0, settingsFile: file, missing };
1159
1201
  }
1202
+ async function findLegacyGitTracesHookOwners(repoRoot, eventName) {
1203
+ if (!eventName) return [];
1204
+ const owners = [];
1205
+ for (const def of TOOLS) {
1206
+ if (def.format === "opencode") continue;
1207
+ const events = def.events.filter(
1208
+ (evt) => evt.eventName === eventName && evt.command.startsWith("npx hillclimb@latest git-traces --tool=")
1209
+ );
1210
+ if (events.length === 0) continue;
1211
+ const settings = await readJson(settingsPath(repoRoot, def));
1212
+ const commands = new Set(commandsForEvent(settings, def.format, eventName));
1213
+ if (events.some(
1214
+ (evt) => legacyBareGitTracesCommandsFor(evt.command).some(
1215
+ (legacy) => commands.has(legacy)
1216
+ )
1217
+ )) {
1218
+ owners.push(def.tool);
1219
+ }
1220
+ }
1221
+ return owners;
1222
+ }
1160
1223
  function detectTools(repoRoot) {
1161
1224
  return TOOLS.filter((def) => def.detect(repoRoot));
1162
1225
  }
@@ -11304,6 +11367,59 @@ function parseOutputBlob(raw) {
11304
11367
  }
11305
11368
  return [String(parsed), void 0];
11306
11369
  }
11370
+ function asObject(value) {
11371
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
11372
+ }
11373
+ function compactExtra(extra) {
11374
+ const compacted = {};
11375
+ for (const [key, value] of Object.entries(extra)) {
11376
+ if (value !== void 0 && value !== null) compacted[key] = value;
11377
+ }
11378
+ return Object.keys(compacted).length > 0 ? compacted : void 0;
11379
+ }
11380
+ function parseJsonObject(raw) {
11381
+ const obj = asObject(raw);
11382
+ if (obj) return obj;
11383
+ if (typeof raw !== "string") return void 0;
11384
+ try {
11385
+ return asObject(JSON.parse(raw));
11386
+ } catch {
11387
+ return void 0;
11388
+ }
11389
+ }
11390
+ function subagentRefFromSpawnOutput(args, rawOutput) {
11391
+ const output = parseJsonObject(rawOutput);
11392
+ const sessionId = typeof output?.agent_id === "string" && output.agent_id || typeof output?.agent_path === "string" && output.agent_path || void 0;
11393
+ if (!sessionId) return void 0;
11394
+ const extra = compactExtra({
11395
+ agent_role: typeof args?.agent_type === "string" && args.agent_type || typeof output?.agent_role === "string" && output.agent_role || void 0,
11396
+ nickname: typeof output?.nickname === "string" && output.nickname || typeof output?.agent_nickname === "string" && output.agent_nickname || void 0
11397
+ });
11398
+ return {
11399
+ session_id: sessionId,
11400
+ extra
11401
+ };
11402
+ }
11403
+ function codexTrajectoryExtra(metaPayload) {
11404
+ const source = asObject(metaPayload.source);
11405
+ const subagent = asObject(source?.subagent);
11406
+ const threadSpawn = asObject(subagent?.thread_spawn);
11407
+ const parentThreadId = typeof threadSpawn?.parent_thread_id === "string" ? threadSpawn.parent_thread_id : void 0;
11408
+ const subagentExtra = threadSpawn ? compactExtra({
11409
+ depth: threadSpawn.depth,
11410
+ agent_path: threadSpawn.agent_path,
11411
+ agent_nickname: threadSpawn.agent_nickname,
11412
+ agent_role: threadSpawn.agent_role
11413
+ }) : void 0;
11414
+ return compactExtra({
11415
+ thread_source: metaPayload.thread_source,
11416
+ agent_nickname: metaPayload.agent_nickname,
11417
+ agent_role: metaPayload.agent_role,
11418
+ parent_session_id: parentThreadId,
11419
+ parent_thread_id: parentThreadId,
11420
+ subagent: subagentExtra
11421
+ });
11422
+ }
11307
11423
  function convertCodexToTrajectory(jsonlContent, sessionId) {
11308
11424
  const rawEvents = [];
11309
11425
  for (const line of jsonlContent.split("\n")) {
@@ -11317,7 +11433,7 @@ function convertCodexToTrajectory(jsonlContent, sessionId) {
11317
11433
  if (rawEvents.length === 0) return null;
11318
11434
  const sessionMeta = rawEvents.find((e) => e.type === "session_meta");
11319
11435
  const metaPayload = sessionMeta?.payload ?? {};
11320
- const sid = sessionId ?? metaPayload.id ?? "";
11436
+ const sid = sessionId ?? (typeof metaPayload.id === "string" ? metaPayload.id : "");
11321
11437
  const agentVersion = metaPayload.cli_version ?? "unknown";
11322
11438
  const agentExtra = {};
11323
11439
  for (const key of ["originator", "cwd", "git", "instructions"]) {
@@ -11434,6 +11550,13 @@ function convertCodexToTrajectory(jsonlContent, sessionId) {
11434
11550
  callInfo.output = outputText;
11435
11551
  callInfo.metadata = metadata;
11436
11552
  callInfo.timestamp = callInfo.timestamp ?? timestamp;
11553
+ if (callInfo.tool_name === "spawn_agent") {
11554
+ const subagentRef = subagentRefFromSpawnOutput(
11555
+ callInfo.arguments,
11556
+ payload.output
11557
+ );
11558
+ if (subagentRef) callInfo.subagentRefs = [subagentRef];
11559
+ }
11437
11560
  normalizedEvents.push(callInfo);
11438
11561
  pendingReasoning = void 0;
11439
11562
  }
@@ -11489,7 +11612,8 @@ function convertCodexToTrajectory(jsonlContent, sessionId) {
11489
11612
  extra: Object.keys(agentExtra).length > 0 ? agentExtra : void 0
11490
11613
  },
11491
11614
  steps,
11492
- final_metrics: finalMetrics
11615
+ final_metrics: finalMetrics,
11616
+ extra: codexTrajectoryExtra(metaPayload)
11493
11617
  };
11494
11618
  }
11495
11619
  function convertEventToStep2(event, stepId, defaultModelName) {
@@ -11524,7 +11648,8 @@ function convertEventToStep2(event, stepId, defaultModelName) {
11524
11648
  if (event.output !== void 0) {
11525
11649
  const result = {
11526
11650
  source_call_id: callId || void 0,
11527
- content: event.output
11651
+ content: event.output,
11652
+ subagent_trajectory_ref: event.subagentRefs
11528
11653
  };
11529
11654
  observation = { results: [result] };
11530
11655
  }
@@ -11572,13 +11697,13 @@ function excludeNone(obj) {
11572
11697
  }
11573
11698
 
11574
11699
  // src/normalizer/copilotChat.ts
11575
- function asObject(value) {
11700
+ function asObject2(value) {
11576
11701
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
11577
11702
  }
11578
11703
  function asNumber(value) {
11579
11704
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
11580
11705
  }
11581
- function compactExtra(extra) {
11706
+ function compactExtra2(extra) {
11582
11707
  const result = {};
11583
11708
  for (const [key, value] of Object.entries(extra)) {
11584
11709
  if (value !== void 0 && value !== null) result[key] = value;
@@ -11591,11 +11716,11 @@ function parseJsonLines(content) {
11591
11716
  const trimmed = line.trim();
11592
11717
  if (!trimmed) continue;
11593
11718
  try {
11594
- const parsed = asObject(JSON.parse(trimmed));
11719
+ const parsed = asObject2(JSON.parse(trimmed));
11595
11720
  if (!parsed) continue;
11596
11721
  entries.push({
11597
11722
  type: typeof parsed.type === "string" ? parsed.type : void 0,
11598
- data: asObject(parsed.data),
11723
+ data: asObject2(parsed.data),
11599
11724
  id: typeof parsed.id === "string" ? parsed.id : void 0,
11600
11725
  timestamp: typeof parsed.timestamp === "string" ? parsed.timestamp : void 0
11601
11726
  });
@@ -11605,11 +11730,11 @@ function parseJsonLines(content) {
11605
11730
  return entries;
11606
11731
  }
11607
11732
  function parseArguments(value) {
11608
- const obj = asObject(value);
11733
+ const obj = asObject2(value);
11609
11734
  if (obj) return obj;
11610
11735
  if (typeof value === "string") {
11611
11736
  try {
11612
- const parsed = asObject(JSON.parse(value));
11737
+ const parsed = asObject2(JSON.parse(value));
11613
11738
  if (parsed) return parsed;
11614
11739
  } catch {
11615
11740
  return value ? { input: value } : {};
@@ -11631,7 +11756,7 @@ function buildMetrics2(data) {
11631
11756
  completion_tokens: outputTokens || void 0,
11632
11757
  cached_tokens: cacheReadTokens || void 0,
11633
11758
  cost_usd: cost || void 0,
11634
- extra: compactExtra({
11759
+ extra: compactExtra2({
11635
11760
  cache_write_tokens: cacheWriteTokens || void 0,
11636
11761
  duration_ms: data.duration,
11637
11762
  initiator: data.initiator,
@@ -11663,7 +11788,7 @@ function finalMetricsFromSteps(steps) {
11663
11788
  };
11664
11789
  }
11665
11790
  function makeToolCall(request) {
11666
- const req = asObject(request);
11791
+ const req = asObject2(request);
11667
11792
  if (!req) return null;
11668
11793
  const callId = typeof req.toolCallId === "string" && req.toolCallId || typeof req.id === "string" && req.id || "";
11669
11794
  const name = typeof req.name === "string" && req.name || typeof req.toolName === "string" && req.toolName || "tool";
@@ -11683,7 +11808,7 @@ function toolCallFromExecutionStart(data) {
11683
11808
  };
11684
11809
  }
11685
11810
  function contentFromToolResult(data) {
11686
- const result = asObject(data.result);
11811
+ const result = asObject2(data.result);
11687
11812
  if (typeof result?.content === "string") return result.content;
11688
11813
  if (typeof data.content === "string") return data.content;
11689
11814
  return void 0;
@@ -11709,7 +11834,7 @@ function convertCopilotChatToTrajectory(jsonlContent, sessionId) {
11709
11834
  copilotVersion = data.copilotVersion;
11710
11835
  if (typeof data.vscodeVersion === "string")
11711
11836
  vscodeVersion = data.vscodeVersion;
11712
- const context = asObject(data.context);
11837
+ const context = asObject2(data.context);
11713
11838
  if (typeof context?.cwd === "string") cwd = context.cwd;
11714
11839
  continue;
11715
11840
  }
@@ -11722,7 +11847,7 @@ function convertCopilotChatToTrajectory(jsonlContent, sessionId) {
11722
11847
  timestamp: entry.timestamp,
11723
11848
  source,
11724
11849
  message: content,
11725
- extra: compactExtra({
11850
+ extra: compactExtra2({
11726
11851
  attachments: data.attachments,
11727
11852
  source: data.source,
11728
11853
  agent_mode: data.agentMode,
@@ -11752,7 +11877,7 @@ function convertCopilotChatToTrajectory(jsonlContent, sessionId) {
11752
11877
  source: "agent",
11753
11878
  message: content || (toolCalls.length > 0 ? "(tool use)" : ""),
11754
11879
  model_name: defaultModelName,
11755
- extra: compactExtra({
11880
+ extra: compactExtra2({
11756
11881
  message_id: data.messageId,
11757
11882
  phase: data.phase,
11758
11883
  output_tokens: data.outputTokens
@@ -11798,7 +11923,7 @@ function convertCopilotChatToTrajectory(jsonlContent, sessionId) {
11798
11923
  target.observation = observation;
11799
11924
  const extra = { ...target.extra ?? {} };
11800
11925
  extra.tool_success = data.success;
11801
- target.extra = compactExtra(extra);
11926
+ target.extra = compactExtra2(extra);
11802
11927
  }
11803
11928
  if (callId) pendingToolSteps.delete(callId);
11804
11929
  continue;
@@ -11824,7 +11949,7 @@ function convertCopilotChatToTrajectory(jsonlContent, sessionId) {
11824
11949
  name: "github-copilot-chat",
11825
11950
  version: copilotVersion,
11826
11951
  model_name: defaultModelName,
11827
- extra: compactExtra({
11952
+ extra: compactExtra2({
11828
11953
  vscode_version: vscodeVersion,
11829
11954
  cwd
11830
11955
  })
@@ -11892,7 +12017,7 @@ function convertCursorToTrajectory(jsonlContent, sessionId) {
11892
12017
  }
11893
12018
 
11894
12019
  // src/normalizer/opencode.ts
11895
- function asObject2(value) {
12020
+ function asObject3(value) {
11896
12021
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
11897
12022
  }
11898
12023
  function asArray(value) {
@@ -11901,7 +12026,7 @@ function asArray(value) {
11901
12026
  function asNumber2(value) {
11902
12027
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
11903
12028
  }
11904
- function compactExtra2(extra) {
12029
+ function compactExtra3(extra) {
11905
12030
  const result = {};
11906
12031
  for (const [key, value] of Object.entries(extra)) {
11907
12032
  if (value !== void 0 && value !== null) result[key] = value;
@@ -11915,7 +12040,7 @@ function parseJsonLines2(content) {
11915
12040
  if (!trimmed) continue;
11916
12041
  try {
11917
12042
  const parsed = JSON.parse(trimmed);
11918
- const obj = asObject2(parsed);
12043
+ const obj = asObject3(parsed);
11919
12044
  if (obj) events.push(obj);
11920
12045
  } catch {
11921
12046
  }
@@ -11930,7 +12055,7 @@ function timestampToIso(value) {
11930
12055
  return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
11931
12056
  }
11932
12057
  function timeFromObject(value) {
11933
- const obj = asObject2(value);
12058
+ const obj = asObject3(value);
11934
12059
  if (!obj) return void 0;
11935
12060
  return timestampToIso(obj.start ?? obj.created ?? obj.completed ?? obj.end);
11936
12061
  }
@@ -11943,12 +12068,12 @@ function stringify2(value) {
11943
12068
  }
11944
12069
  }
11945
12070
  function argsFromUnknown(value) {
11946
- const obj = asObject2(value);
12071
+ const obj = asObject3(value);
11947
12072
  if (obj) return obj;
11948
12073
  if (typeof value === "string") {
11949
12074
  try {
11950
12075
  const parsed = JSON.parse(value);
11951
- const parsedObj = asObject2(parsed);
12076
+ const parsedObj = asObject3(parsed);
11952
12077
  if (parsedObj) return parsedObj;
11953
12078
  } catch {
11954
12079
  return value ? { input: value } : {};
@@ -11957,16 +12082,16 @@ function argsFromUnknown(value) {
11957
12082
  return value === void 0 || value === null ? {} : { value };
11958
12083
  }
11959
12084
  function modelNameFromInfo(info) {
11960
- const model = asObject2(info.model);
12085
+ const model = asObject3(info.model);
11961
12086
  const modelID = typeof info.modelID === "string" && info.modelID || typeof model?.modelID === "string" && model.modelID || void 0;
11962
12087
  const providerID = typeof info.providerID === "string" && info.providerID || typeof model?.providerID === "string" && model.providerID || void 0;
11963
12088
  if (providerID && modelID) return `${providerID}/${modelID}`;
11964
12089
  return modelID;
11965
12090
  }
11966
12091
  function metricsFromTokens(tokens, cost) {
11967
- const t = asObject2(tokens);
12092
+ const t = asObject3(tokens);
11968
12093
  if (!t) return void 0;
11969
- const cache = asObject2(t.cache);
12094
+ const cache = asObject3(t.cache);
11970
12095
  const input = asNumber2(t.input) ?? 0;
11971
12096
  const output = asNumber2(t.output) ?? 0;
11972
12097
  const reasoning = asNumber2(t.reasoning) ?? 0;
@@ -11976,7 +12101,7 @@ function metricsFromTokens(tokens, cost) {
11976
12101
  if (!input && !output && !cacheRead && !cacheWrite && !costUsd) {
11977
12102
  return void 0;
11978
12103
  }
11979
- const extra = compactExtra2({
12104
+ const extra = compactExtra3({
11980
12105
  reasoning_tokens: reasoning || void 0,
11981
12106
  cache_write_tokens: cacheWrite || void 0
11982
12107
  });
@@ -12017,12 +12142,12 @@ function finalMetricsFromSteps2(steps) {
12017
12142
  };
12018
12143
  }
12019
12144
  function entryFromLine(line) {
12020
- const info = asObject2(line.info);
12145
+ const info = asObject3(line.info);
12021
12146
  if (info) {
12022
12147
  return {
12023
12148
  info,
12024
12149
  parts: asArray(line.parts).flatMap((part) => {
12025
- const obj = asObject2(part);
12150
+ const obj = asObject3(part);
12026
12151
  return obj ? [obj] : [];
12027
12152
  })
12028
12153
  };
@@ -12031,7 +12156,7 @@ function entryFromLine(line) {
12031
12156
  return {
12032
12157
  info: line,
12033
12158
  parts: asArray(line.parts).flatMap((part) => {
12034
- const obj = asObject2(part);
12159
+ const obj = asObject3(part);
12035
12160
  return obj ? [obj] : [];
12036
12161
  })
12037
12162
  };
@@ -12050,9 +12175,9 @@ function entriesFromEventWrappers(lines) {
12050
12175
  }
12051
12176
  for (const line of lines) {
12052
12177
  const type = line.type;
12053
- const props = asObject2(line.properties) ?? line;
12178
+ const props = asObject3(line.properties) ?? line;
12054
12179
  if (type === "message.updated") {
12055
- const info = asObject2(props.info);
12180
+ const info = asObject3(props.info);
12056
12181
  const id = typeof info?.id === "string" ? info.id : void 0;
12057
12182
  if (!info || !id) continue;
12058
12183
  const entry = getEntry(id, info.sessionID);
@@ -12060,7 +12185,7 @@ function entriesFromEventWrappers(lines) {
12060
12185
  continue;
12061
12186
  }
12062
12187
  if (type === "message.part.updated") {
12063
- const part = asObject2(props.part);
12188
+ const part = asObject3(props.part);
12064
12189
  const messageID = typeof part?.messageID === "string" && part.messageID || void 0;
12065
12190
  if (!part || !messageID) continue;
12066
12191
  const entry = getEntry(messageID, part.sessionID);
@@ -12085,8 +12210,8 @@ function getAgentVersion(exportInfo) {
12085
12210
  }
12086
12211
  function sortEntries(entries) {
12087
12212
  return [...entries].sort((a, b) => {
12088
- const at = asNumber2(asObject2(a.info.time)?.created) ?? 0;
12089
- const bt = asNumber2(asObject2(b.info.time)?.created) ?? 0;
12213
+ const at = asNumber2(asObject3(a.info.time)?.created) ?? 0;
12214
+ const bt = asNumber2(asObject3(b.info.time)?.created) ?? 0;
12090
12215
  return at - bt;
12091
12216
  });
12092
12217
  }
@@ -12143,7 +12268,7 @@ function buildUserStep(entry, stepId, defaultModelName) {
12143
12268
  source: "user",
12144
12269
  message,
12145
12270
  model_name: defaultModelName,
12146
- extra: compactExtra2(extra)
12271
+ extra: compactExtra3(extra)
12147
12272
  };
12148
12273
  }
12149
12274
  function buildAgentStep(parts, info, stepId, defaultModelName, fallbackMetrics) {
@@ -12173,7 +12298,7 @@ function buildAgentStep(parts, info, stepId, defaultModelName, fallbackMetrics)
12173
12298
  break;
12174
12299
  }
12175
12300
  case "tool": {
12176
- const state = asObject2(part.state) ?? {};
12301
+ const state = asObject3(part.state) ?? {};
12177
12302
  const callID = typeof part.callID === "string" && part.callID || typeof part.id === "string" && part.id || "";
12178
12303
  const toolName = typeof part.tool === "string" && part.tool || "tool";
12179
12304
  const input = argsFromUnknown(state.input);
@@ -12227,7 +12352,7 @@ function buildAgentStep(parts, info, stepId, defaultModelName, fallbackMetrics)
12227
12352
  if (toolCalls.length > 0) step.tool_calls = toolCalls;
12228
12353
  if (observation) step.observation = observation;
12229
12354
  if (metrics) step.metrics = metrics;
12230
- const compactedExtra = compactExtra2(extra);
12355
+ const compactedExtra = compactExtra3(extra);
12231
12356
  if (compactedExtra) step.extra = compactedExtra;
12232
12357
  return step;
12233
12358
  }
@@ -12243,7 +12368,7 @@ function buildUnavailableAgentStep(info, stepId, defaultModelName) {
12243
12368
  message: "(message unavailable)",
12244
12369
  model_name: modelNameFromInfo(info) ?? defaultModelName,
12245
12370
  metrics,
12246
- extra: compactExtra2({
12371
+ extra: compactExtra3({
12247
12372
  content_unavailable: true,
12248
12373
  finish_reason: info.finish,
12249
12374
  error: info.error
@@ -12313,14 +12438,14 @@ function convertRunEventsToTrajectory(events, sessionId) {
12313
12438
  }
12314
12439
  if (type === "step_finish") {
12315
12440
  if (current) {
12316
- current.finish = asObject2(event.part) ?? {};
12441
+ current.finish = asObject3(event.part) ?? {};
12317
12442
  turns.push(current);
12318
12443
  current = null;
12319
12444
  }
12320
12445
  continue;
12321
12446
  }
12322
12447
  if (current && (type === "text" || type === "reasoning" || type === "tool_use")) {
12323
- const part = asObject2(event.part);
12448
+ const part = asObject3(event.part);
12324
12449
  if (part) current.parts.push(part);
12325
12450
  }
12326
12451
  }
@@ -12362,17 +12487,17 @@ function convertOpenCodeToTrajectory(content, sessionId) {
12362
12487
  if (!trimmed) return null;
12363
12488
  try {
12364
12489
  const parsed = JSON.parse(trimmed);
12365
- const parsedObj = asObject2(parsed);
12490
+ const parsedObj = asObject3(parsed);
12366
12491
  const messages = asArray(parsedObj?.messages);
12367
12492
  if (parsedObj && messages.length > 0) {
12368
12493
  const entries2 = messages.flatMap((message) => {
12369
- const entry = entryFromLine(asObject2(message) ?? {});
12494
+ const entry = entryFromLine(asObject3(message) ?? {});
12370
12495
  return entry ? [entry] : [];
12371
12496
  });
12372
12497
  return convertMessageEntriesToTrajectory(
12373
12498
  entries2,
12374
12499
  sessionId,
12375
- asObject2(parsedObj.info)
12500
+ asObject3(parsedObj.info)
12376
12501
  );
12377
12502
  }
12378
12503
  } catch {
@@ -12418,7 +12543,7 @@ var NormalizeMiddleware = class {
12418
12543
  continue;
12419
12544
  const content = file.content ? file.content.toString("utf-8") : null;
12420
12545
  if (!content) continue;
12421
- const sessionId = file.metadata?.sessionId ?? path9.basename(file.absolutePath, ".jsonl");
12546
+ const sessionId = file.metadata?.sessionId ?? (file.sourceName === "codex" ? void 0 : path9.basename(file.absolutePath, ".jsonl"));
12422
12547
  try {
12423
12548
  const trajectory = normalizeContent(
12424
12549
  file.sourceName,
@@ -12676,6 +12801,7 @@ function resolveSourceTool(payload) {
12676
12801
  return "claude";
12677
12802
  }
12678
12803
  async function selfHealHook(repoRoot, tool) {
12804
+ if (process.env.HILLCLIMB_SKIP_HOOK_SELF_HEAL === "1") return;
12679
12805
  try {
12680
12806
  const result = await healHookForTool(repoRoot, tool);
12681
12807
  if (result.skipped) {
@@ -12958,6 +13084,7 @@ import crypto2 from "crypto";
12958
13084
 
12959
13085
  // src/git-traces/handlers.ts
12960
13086
  import { execFileSync as execFileSync2 } from "child_process";
13087
+ import path15 from "path";
12961
13088
 
12962
13089
  // src/git-traces/git-ops.ts
12963
13090
  import { execFileSync } from "child_process";
@@ -13212,12 +13339,34 @@ function detectTransitionKind(repoRoot, prevHeadSha, nextHeadSha) {
13212
13339
  return "branch-switch";
13213
13340
  }
13214
13341
  }
13342
+ function parseDirtyFilesFromStatus(status) {
13343
+ const raw = Buffer.isBuffer(status) ? status.toString("utf-8") : status;
13344
+ if (!raw) return [];
13345
+ if (raw.includes("\0")) {
13346
+ const fields = raw.split("\0").filter(Boolean);
13347
+ const dirtyFiles = [];
13348
+ for (let i = 0; i < fields.length; i++) {
13349
+ const record = fields[i];
13350
+ if (record.length < 4) continue;
13351
+ const statusCode = record.slice(0, 2);
13352
+ dirtyFiles.push(record.slice(3));
13353
+ if (statusCode.includes("R") || statusCode.includes("C")) i++;
13354
+ }
13355
+ return dirtyFiles;
13356
+ }
13357
+ return raw.split("\n").filter(Boolean).map((line) => {
13358
+ const match = /^(..) (.*)$/.exec(line);
13359
+ const pathPart = match ? match[2] : line.trim();
13360
+ if (pathPart.includes(" -> ")) return pathPart.split(" -> ").at(-1) ?? "";
13361
+ return pathPart;
13362
+ }).filter(Boolean);
13363
+ }
13215
13364
  function buildBaselineMetadata(repoRoot, sessionId, tool, baselineSha, cliVersion, epoch, prevHeadSha, transitionKind, startedAt) {
13216
13365
  const headSha = safeGit(repoRoot, ["rev-parse", "HEAD"]) ?? "unknown";
13217
13366
  const branch = safeGit(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]) ?? null;
13218
13367
  const remoteUrl = safeGit(repoRoot, ["config", "--get", "remote.origin.url"]) ?? null;
13219
- const statusRaw = safeGit(repoRoot, ["status", "--porcelain"]) ?? "";
13220
- const dirtyFiles = statusRaw.split("\n").filter(Boolean).map((line) => line.slice(3));
13368
+ const statusRaw = safeGitBuffer(repoRoot, ["status", "--porcelain=v1", "-z"]) ?? Buffer.alloc(0);
13369
+ const dirtyFiles = parseDirtyFilesFromStatus(statusRaw);
13221
13370
  const authorName = safeGit(repoRoot, ["config", "user.name"]) ?? "";
13222
13371
  const authorEmail = safeGit(repoRoot, ["config", "user.email"]) ?? "";
13223
13372
  const commits = transitionKind === "commit" && prevHeadSha && headSha !== "unknown" ? enumerateCommits(repoRoot, prevHeadSha, headSha) : void 0;
@@ -13328,9 +13477,9 @@ function parseCommitFiles(repoRoot, sha) {
13328
13477
  oldPath
13329
13478
  });
13330
13479
  } else {
13331
- const path21 = parts[parts.length - 1];
13332
- indexByPath.set(path21, files.length);
13333
- files.push({ path: path21, status, additions: 0, deletions: 0 });
13480
+ const path22 = parts[parts.length - 1];
13481
+ indexByPath.set(path22, files.length);
13482
+ files.push({ path: path22, status, additions: 0, deletions: 0 });
13334
13483
  }
13335
13484
  }
13336
13485
  for (const line of numstat.split("\n")) {
@@ -13361,6 +13510,19 @@ function safeGit(repoRoot, args) {
13361
13510
  return null;
13362
13511
  }
13363
13512
  }
13513
+ function safeGitBuffer(repoRoot, args) {
13514
+ try {
13515
+ return gitBuffer(repoRoot, args);
13516
+ } catch (err) {
13517
+ if (isGitTimeoutError(err)) {
13518
+ appendLog(
13519
+ "warn",
13520
+ `git-traces: optional metadata command skipped: ${err instanceof Error ? err.message : String(err)}`
13521
+ );
13522
+ }
13523
+ return null;
13524
+ }
13525
+ }
13364
13526
  function cleanupSessionRefs(repoRoot, sessionId) {
13365
13527
  for (const prefix of [
13366
13528
  `refs/hillclimb/baseline/${sessionId}`,
@@ -13388,20 +13550,24 @@ import fs11 from "fs";
13388
13550
  import os7 from "os";
13389
13551
  import path14 from "path";
13390
13552
  var CURRENT_SCHEMA_VERSION = 3;
13391
- var STATE_DIR = path14.join(os7.homedir(), ".hillclimb", "git-traces");
13392
- function stateFileForRepo(repoRoot, tool) {
13393
- const hash = crypto.createHash("sha256").update(`${path14.resolve(repoRoot)}\0${tool}`).digest("hex").slice(0, 16);
13394
- return path14.join(STATE_DIR, `${hash}.json`);
13553
+ var DEFAULT_STATE_DIR = path14.join(os7.homedir(), ".hillclimb", "git-traces");
13554
+ var LOCK_RETRIES = 120;
13555
+ var LOCK_RETRY_DELAY_MS = 500;
13556
+ function stateDir() {
13557
+ return process.env.HILLCLIMB_GIT_TRACES_STATE_DIR ?? DEFAULT_STATE_DIR;
13558
+ }
13559
+ function stateFileForRepo(repoRoot, tool, sessionId) {
13560
+ const hash = crypto.createHash("sha256").update(
13561
+ sessionId ? `${path14.resolve(repoRoot)}\0${tool}\0${sessionId}` : `${path14.resolve(repoRoot)}\0${tool}`
13562
+ ).digest("hex").slice(0, 16);
13563
+ return path14.join(stateDir(), `${hash}.json`);
13395
13564
  }
13396
13565
  function lockFileForRepo(repoRoot, tool) {
13397
13566
  return `${stateFileForRepo(repoRoot, tool)}.lock`;
13398
13567
  }
13399
- async function readSessionState(repoRoot, tool) {
13568
+ async function readStateFile(file) {
13400
13569
  try {
13401
- const raw = await fs11.promises.readFile(
13402
- stateFileForRepo(repoRoot, tool),
13403
- "utf-8"
13404
- );
13570
+ const raw = await fs11.promises.readFile(file, "utf-8");
13405
13571
  const parsed = JSON.parse(raw);
13406
13572
  if (parsed.schemaVersion !== CURRENT_SCHEMA_VERSION) {
13407
13573
  return null;
@@ -13411,24 +13577,112 @@ async function readSessionState(repoRoot, tool) {
13411
13577
  return null;
13412
13578
  }
13413
13579
  }
13580
+ async function listScopedSessionStates(repoRoot, tool) {
13581
+ let entries;
13582
+ try {
13583
+ entries = await fs11.promises.readdir(stateDir(), { withFileTypes: true });
13584
+ } catch {
13585
+ return [];
13586
+ }
13587
+ const states = [];
13588
+ for (const entry of entries) {
13589
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
13590
+ const file = path14.join(stateDir(), entry.name);
13591
+ const state = await readStateFile(file);
13592
+ if (!state) continue;
13593
+ if (typeof state.repoRoot !== "string" || typeof state.sessionId !== "string") {
13594
+ continue;
13595
+ }
13596
+ if (path14.resolve(state.repoRoot) !== path14.resolve(repoRoot)) continue;
13597
+ if (path14.resolve(file) !== path14.resolve(stateFileForRepo(repoRoot, tool, state.sessionId))) {
13598
+ continue;
13599
+ }
13600
+ let mtimeMs = 0;
13601
+ try {
13602
+ mtimeMs = (await fs11.promises.stat(file)).mtimeMs;
13603
+ } catch {
13604
+ continue;
13605
+ }
13606
+ states.push({ state, mtimeMs });
13607
+ }
13608
+ return states;
13609
+ }
13610
+ async function listSessionStatesForSession(tool, sessionId) {
13611
+ let entries;
13612
+ try {
13613
+ entries = await fs11.promises.readdir(stateDir(), { withFileTypes: true });
13614
+ } catch {
13615
+ return [];
13616
+ }
13617
+ const states = [];
13618
+ for (const entry of entries) {
13619
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
13620
+ const file = path14.join(stateDir(), entry.name);
13621
+ const state = await readStateFile(file);
13622
+ if (!state) continue;
13623
+ if (typeof state.repoRoot !== "string" || typeof state.sessionId !== "string") {
13624
+ continue;
13625
+ }
13626
+ if (state.sessionId !== sessionId) continue;
13627
+ if (path14.resolve(file) !== path14.resolve(stateFileForRepo(state.repoRoot, tool, sessionId))) {
13628
+ continue;
13629
+ }
13630
+ let mtimeMs = 0;
13631
+ try {
13632
+ mtimeMs = (await fs11.promises.stat(file)).mtimeMs;
13633
+ } catch {
13634
+ continue;
13635
+ }
13636
+ states.push({ state, mtimeMs });
13637
+ }
13638
+ return states;
13639
+ }
13640
+ async function readSessionState(repoRoot, tool, sessionId) {
13641
+ if (sessionId) {
13642
+ const scoped = await readStateFile(
13643
+ stateFileForRepo(repoRoot, tool, sessionId)
13644
+ );
13645
+ if (scoped) return scoped;
13646
+ const legacy = await readStateFile(stateFileForRepo(repoRoot, tool));
13647
+ return legacy?.sessionId === sessionId ? legacy : null;
13648
+ }
13649
+ return readStateFile(stateFileForRepo(repoRoot, tool));
13650
+ }
13414
13651
  async function writeSessionState(state, tool) {
13415
- const file = stateFileForRepo(state.repoRoot, tool);
13416
- await fs11.promises.mkdir(STATE_DIR, { recursive: true, mode: 448 });
13652
+ const file = stateFileForRepo(state.repoRoot, tool, state.sessionId);
13653
+ await fs11.promises.mkdir(stateDir(), { recursive: true, mode: 448 });
13417
13654
  const tmp = `${file}.tmp`;
13418
13655
  await fs11.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
13419
13656
  mode: 384
13420
13657
  });
13421
13658
  await fs11.promises.rename(tmp, file);
13659
+ const legacyFile = stateFileForRepo(state.repoRoot, tool);
13660
+ const legacy = await readStateFile(legacyFile);
13661
+ if (legacy?.sessionId === state.sessionId) {
13662
+ await deleteStateFile(legacyFile);
13663
+ }
13422
13664
  }
13423
- async function deleteSessionState(repoRoot, tool) {
13665
+ async function deleteStateFile(file) {
13424
13666
  try {
13425
- await fs11.promises.unlink(stateFileForRepo(repoRoot, tool));
13667
+ await fs11.promises.unlink(file);
13426
13668
  } catch {
13427
13669
  }
13428
13670
  }
13429
- async function acquireLock(repoRoot, tool, retries = 3, delayMs = 200) {
13671
+ async function deleteSessionState(repoRoot, tool, sessionId) {
13672
+ if (sessionId) {
13673
+ await deleteStateFile(stateFileForRepo(repoRoot, tool, sessionId));
13674
+ const legacyFile = stateFileForRepo(repoRoot, tool);
13675
+ const legacy = await readStateFile(legacyFile);
13676
+ if (legacy?.sessionId === sessionId) {
13677
+ await deleteStateFile(legacyFile);
13678
+ }
13679
+ return;
13680
+ }
13681
+ await deleteStateFile(stateFileForRepo(repoRoot, tool));
13682
+ }
13683
+ async function acquireLock(repoRoot, tool, retries = LOCK_RETRIES, delayMs = LOCK_RETRY_DELAY_MS) {
13430
13684
  const lockPath = lockFileForRepo(repoRoot, tool);
13431
- await fs11.promises.mkdir(STATE_DIR, { recursive: true, mode: 448 });
13685
+ await fs11.promises.mkdir(stateDir(), { recursive: true, mode: 448 });
13432
13686
  for (let i = 0; i < retries; i++) {
13433
13687
  try {
13434
13688
  const fd = await fs11.promises.open(
@@ -13459,6 +13713,7 @@ async function releaseLock(repoRoot, tool) {
13459
13713
  var CLI_VERSION = "0.2.0";
13460
13714
  var GIT_TRACES_SLUG = "git-traces";
13461
13715
  var MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
13716
+ var STALE_SCOPED_STATE_TTL_MS = 24 * 60 * 60 * 1e3;
13462
13717
  function formatEpochSeconds2(date) {
13463
13718
  return String(Math.floor(date.getTime() / 1e3));
13464
13719
  }
@@ -13469,6 +13724,16 @@ var TOOL_LABELS = {
13469
13724
  "copilot-chat": "GitHub Copilot Chat",
13470
13725
  opencode: "opencode"
13471
13726
  };
13727
+ async function loadConfiguredRepos() {
13728
+ const file = await loadProjects();
13729
+ return Object.entries(file.projects).map(([repoRoot, config]) => ({
13730
+ repoRoot: path15.resolve(repoRoot),
13731
+ config
13732
+ })).sort((a, b) => a.repoRoot.localeCompare(b.repoRoot));
13733
+ }
13734
+ function repoLabel(repoRoot) {
13735
+ return path15.basename(repoRoot) || repoRoot;
13736
+ }
13472
13737
  function resolveCwd(payload) {
13473
13738
  return payload.cwd ?? payload.workspace_roots?.[0] ?? null;
13474
13739
  }
@@ -13528,27 +13793,43 @@ function canUploadEpochBaselineArtifacts(epoch, artifacts) {
13528
13793
  }
13529
13794
  function pinEpochBaseline(repoRoot, sessionId, epoch) {
13530
13795
  const baselineSha = captureBaselineSha(repoRoot);
13796
+ const baselineRefPrefix = `refs/hillclimb/baseline/${sessionId}/${epochPrefix(epoch)}`;
13797
+ deleteRef(repoRoot, baselineRefPrefix);
13798
+ pinRef(repoRoot, `${baselineRefPrefix}/tracked`, baselineSha);
13799
+ return { baselineSha, headSha: captureHeadSha(repoRoot) };
13800
+ }
13801
+ function pinFrozenBaselineTree(repoRoot, sessionId, epoch, baselineTreeSha) {
13802
+ const prefix = epochPrefix(epoch);
13803
+ const commit = execGit(repoRoot, [
13804
+ "commit-tree",
13805
+ baselineTreeSha,
13806
+ "-m",
13807
+ `frozen baseline ${prefix} for session ${sessionId}`
13808
+ ]);
13531
13809
  pinRef(
13532
13810
  repoRoot,
13533
- `refs/hillclimb/baseline/${sessionId}/${epochPrefix(epoch)}`,
13534
- baselineSha
13811
+ `refs/hillclimb/baseline/${sessionId}/${prefix}/tree`,
13812
+ commit
13535
13813
  );
13536
- return { baselineSha, headSha: captureHeadSha(repoRoot) };
13537
13814
  }
13538
- function buildEpochBaselineArtifacts(params) {
13815
+ function freezeEpochBaseline(params) {
13539
13816
  const {
13540
13817
  repoRoot,
13541
13818
  sessionId,
13542
13819
  tool,
13543
13820
  epoch,
13544
- baselineSha,
13545
13821
  prevHeadSha,
13546
13822
  transitionKind,
13547
13823
  startedAt
13548
13824
  } = params;
13549
13825
  const prefix = epochPrefix(epoch);
13550
13826
  try {
13551
- const metadata = buildBaselineMetadata(
13827
+ const { baselineSha, headSha } = pinEpochBaseline(
13828
+ repoRoot,
13829
+ sessionId,
13830
+ epoch
13831
+ );
13832
+ const baselineMetadata = buildBaselineMetadata(
13552
13833
  repoRoot,
13553
13834
  sessionId,
13554
13835
  tool,
@@ -13560,13 +13841,29 @@ function buildEpochBaselineArtifacts(params) {
13560
13841
  startedAt
13561
13842
  );
13562
13843
  const baselineTreeSha = buildSnapshotTree(repoRoot, baselineSha);
13844
+ pinFrozenBaselineTree(repoRoot, sessionId, epoch, baselineTreeSha);
13845
+ return { baselineSha, baselineTreeSha, baselineMetadata, headSha };
13846
+ } catch (err) {
13847
+ appendLog(
13848
+ "error",
13849
+ `git-traces: failed to freeze baseline for ${prefix}: ${formatError(err)}`
13850
+ );
13851
+ return null;
13852
+ }
13853
+ }
13854
+ function buildFrozenEpochBaselineArtifacts(params) {
13855
+ const { repoRoot, sessionId, epoch, baselineTreeSha, baselineMetadata } = params;
13856
+ const prefix = epochPrefix(epoch);
13857
+ try {
13563
13858
  const bundleBuffer = createBundleFromTree(
13564
13859
  repoRoot,
13565
13860
  baselineTreeSha,
13566
13861
  `${sessionId}-${prefix}`,
13567
13862
  `baseline ${prefix}`
13568
13863
  );
13569
- const metadataBuffer = Buffer.from(JSON.stringify(metadata, null, 2));
13864
+ const metadataBuffer = Buffer.from(
13865
+ JSON.stringify(baselineMetadata, null, 2)
13866
+ );
13570
13867
  return { baselineTreeSha, metadataBuffer, bundleBuffer };
13571
13868
  } catch (err) {
13572
13869
  appendLog(
@@ -13576,6 +13873,46 @@ function buildEpochBaselineArtifacts(params) {
13576
13873
  return null;
13577
13874
  }
13578
13875
  }
13876
+ function buildEpochBaselineArtifacts(params) {
13877
+ const {
13878
+ repoRoot,
13879
+ sessionId,
13880
+ tool,
13881
+ epoch,
13882
+ baselineSha,
13883
+ prevHeadSha,
13884
+ transitionKind,
13885
+ startedAt
13886
+ } = params;
13887
+ const prefix = epochPrefix(epoch);
13888
+ try {
13889
+ const metadata = buildBaselineMetadata(
13890
+ repoRoot,
13891
+ sessionId,
13892
+ tool,
13893
+ baselineSha,
13894
+ CLI_VERSION,
13895
+ epoch,
13896
+ prevHeadSha,
13897
+ transitionKind,
13898
+ startedAt
13899
+ );
13900
+ const baselineTreeSha = buildSnapshotTree(repoRoot, baselineSha);
13901
+ return buildFrozenEpochBaselineArtifacts({
13902
+ repoRoot,
13903
+ sessionId,
13904
+ epoch,
13905
+ baselineTreeSha,
13906
+ baselineMetadata: metadata
13907
+ });
13908
+ } catch (err) {
13909
+ appendLog(
13910
+ "error",
13911
+ `git-traces: failed to build baseline artifacts for ${prefix}: ${formatError(err)}`
13912
+ );
13913
+ return null;
13914
+ }
13915
+ }
13579
13916
  async function uploadEpochBaselineArtifacts(params) {
13580
13917
  const { client, contributionId, epoch, artifacts } = params;
13581
13918
  const prefix = epochPrefix(epoch);
@@ -13596,6 +13933,34 @@ async function uploadEpochBaselineArtifacts(params) {
13596
13933
  artifacts.metadataBuffer
13597
13934
  );
13598
13935
  }
13936
+ async function createGitTracesContribution(params) {
13937
+ const { client, config, repoRoot, state, tool, now, artifacts } = params;
13938
+ const toolLabel = TOOL_LABELS[tool] ?? "Claude";
13939
+ const epochSeconds = formatEpochSeconds2(now);
13940
+ const shortId = state.sessionId.slice(0, 12);
13941
+ const repoName = repoLabel(repoRoot);
13942
+ const contribution = await client.createContribution(config.projectId, {
13943
+ contributionTypeSlug: GIT_TRACES_SLUG,
13944
+ title: `${toolLabel} session ${shortId} \u2014 ${repoName} \u2014 ${epochSeconds}`,
13945
+ body: `Session ID: ${state.sessionId}
13946
+ Tool: ${toolLabel}
13947
+ Repo: ${repoRoot}
13948
+ Uploaded: ${now.toISOString()}`
13949
+ });
13950
+ const uploaded = await uploadEpochBaselineArtifacts({
13951
+ client,
13952
+ contributionId: contribution.id,
13953
+ epoch: 1,
13954
+ artifacts
13955
+ });
13956
+ if (!uploaded) return null;
13957
+ await client.submitContribution(contribution.id);
13958
+ appendLog(
13959
+ "info",
13960
+ `git-traces: baseline uploaded (repo=${repoRoot}, project=${config.projectId}, contribution=${contribution.id}, epoch=1, bundleBytes=${artifacts.bundleBuffer.byteLength}, metadataBytes=${artifacts.metadataBuffer.byteLength})`
13961
+ );
13962
+ return contribution.id;
13963
+ }
13599
13964
  async function uploadEpochBaseline(params) {
13600
13965
  const artifacts = buildEpochBaselineArtifacts(params);
13601
13966
  if (!artifacts) return null;
@@ -13619,45 +13984,115 @@ async function openEpoch(params) {
13619
13984
  if (!uploaded) return null;
13620
13985
  return { baselineSha, baselineTreeSha: uploaded.baselineTreeSha, headSha };
13621
13986
  }
13622
- async function initializeSession(cwd, tool, sessionId) {
13623
- const project = await findProjectForCwd(cwd);
13624
- if (!project) {
13625
- appendLog(
13626
- "warn",
13627
- "git-traces: no hillclimb project config found, skipping"
13628
- );
13629
- return null;
13630
- }
13631
- const { baselineSha, headSha } = pinEpochBaseline(cwd, sessionId, 1);
13987
+ async function initializeSession(repoRoot, tool, sessionId) {
13988
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
13989
+ const frozen = freezeEpochBaseline({
13990
+ repoRoot,
13991
+ sessionId,
13992
+ tool,
13993
+ epoch: 1,
13994
+ prevHeadSha: null,
13995
+ transitionKind: "initial",
13996
+ startedAt
13997
+ });
13998
+ if (!frozen) return null;
13632
13999
  const state = {
13633
14000
  schemaVersion: CURRENT_SCHEMA_VERSION,
13634
14001
  sessionId,
13635
14002
  contributionId: null,
13636
- baselineSha,
13637
- baselineTreeSha: null,
13638
- lastSnapshotSha: baselineSha,
13639
- lastSnapshotTreeSha: null,
13640
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
14003
+ baselineSha: frozen.baselineSha,
14004
+ baselineTreeSha: frozen.baselineTreeSha,
14005
+ baselineMetadata: frozen.baselineMetadata,
14006
+ lastSnapshotSha: frozen.baselineSha,
14007
+ lastSnapshotTreeSha: frozen.baselineTreeSha,
14008
+ startedAt,
13641
14009
  epoch: 1,
13642
14010
  turnCount: 0,
13643
- headSha,
13644
- repoRoot: cwd
14011
+ headSha: frozen.headSha,
14012
+ repoRoot
13645
14013
  };
13646
14014
  await writeSessionState(state, tool);
13647
14015
  appendLog(
13648
14016
  "info",
13649
- `git-traces: session pending (baseline=${baselineSha.slice(0, 8)}, awaiting first turn)`
14017
+ `git-traces: session pending (repo=${repoRoot}, baseline=${frozen.baselineSha.slice(0, 8)}, awaiting first turn)`
13650
14018
  );
13651
14019
  return state;
13652
14020
  }
14021
+ async function cleanupStaleScopedSessionStates(repoRoot, tool, currentSessionId, options = {}) {
14022
+ const nowMs = options.nowMs ?? Date.now();
14023
+ const ttlMs = options.ttlMs ?? STALE_SCOPED_STATE_TTL_MS;
14024
+ const states = await listScopedSessionStates(repoRoot, tool);
14025
+ let removed = 0;
14026
+ for (const { state, mtimeMs } of states) {
14027
+ if (state.sessionId === currentSessionId) continue;
14028
+ const startedAtMs = Date.parse(state.startedAt);
14029
+ const ageBaseMs = Number.isNaN(startedAtMs) ? mtimeMs : startedAtMs;
14030
+ if (nowMs - ageBaseMs <= ttlMs) continue;
14031
+ appendLog(
14032
+ "info",
14033
+ `git-traces: cleaning up stale scoped session ${state.sessionId}`
14034
+ );
14035
+ cleanupSessionRefs(repoRoot, state.sessionId);
14036
+ await deleteSessionState(repoRoot, tool, state.sessionId);
14037
+ removed++;
14038
+ }
14039
+ return removed;
14040
+ }
14041
+ async function processSessionStartRepo(repo, tool, sessionId) {
14042
+ const { repoRoot } = repo;
14043
+ if (!isGitRepo(repoRoot)) {
14044
+ appendLog(
14045
+ "info",
14046
+ `git-traces: skipping repo on SessionStart (repo=${repoRoot}, reason=not-git-repo)`
14047
+ );
14048
+ return "skipped";
14049
+ }
14050
+ await acquireLock(repoRoot, tool);
14051
+ try {
14052
+ const staleLegacy = await readSessionState(repoRoot, tool);
14053
+ if (staleLegacy && staleLegacy.sessionId !== sessionId) {
14054
+ appendLog(
14055
+ "info",
14056
+ `git-traces: cleaning up stale session ${staleLegacy.sessionId} (repo=${repoRoot})`
14057
+ );
14058
+ cleanupSessionRefs(repoRoot, staleLegacy.sessionId);
14059
+ await deleteSessionState(repoRoot, tool);
14060
+ }
14061
+ const staleCount = await cleanupStaleScopedSessionStates(
14062
+ repoRoot,
14063
+ tool,
14064
+ sessionId
14065
+ );
14066
+ if (staleCount > 0) {
14067
+ appendLog(
14068
+ "info",
14069
+ `git-traces: cleaned stale scoped sessions (repo=${repoRoot}, count=${staleCount})`
14070
+ );
14071
+ }
14072
+ const state = await initializeSession(repoRoot, tool, sessionId);
14073
+ return state ? "initialized" : "failed";
14074
+ } catch (err) {
14075
+ appendLog(
14076
+ "error",
14077
+ `git-traces: SessionStart failed for repo ${repoRoot}: ${formatError(err)}`
14078
+ );
14079
+ return "failed";
14080
+ } finally {
14081
+ await releaseLock(repoRoot, tool);
14082
+ }
14083
+ }
13653
14084
  async function handleSessionStart(payload, tool) {
13654
14085
  const cwd = resolveCwd(payload);
13655
14086
  if (!cwd) {
13656
14087
  appendLog("warn", "git-traces: no cwd in payload, skipping");
13657
14088
  return;
13658
14089
  }
13659
- if (!isGitRepo(cwd)) {
13660
- appendLog("info", "git-traces: not a git repo, skipping");
14090
+ const startingProject = await findProjectForCwd(cwd);
14091
+ if (!startingProject) {
14092
+ appendLog(
14093
+ "warn",
14094
+ "git-traces: no hillclimb project config found, skipping"
14095
+ );
13661
14096
  return;
13662
14097
  }
13663
14098
  const sessionId = resolveSessionId(payload);
@@ -13665,115 +14100,146 @@ async function handleSessionStart(payload, tool) {
13665
14100
  appendLog("warn", "git-traces: no session_id in payload, skipping");
13666
14101
  return;
13667
14102
  }
13668
- await acquireLock(cwd, tool);
13669
- try {
13670
- const stale = await readSessionState(cwd, tool);
13671
- if (stale && stale.sessionId !== sessionId) {
13672
- appendLog(
13673
- "info",
13674
- `git-traces: cleaning up stale session ${stale.sessionId}`
13675
- );
13676
- cleanupSessionRefs(cwd, stale.sessionId);
13677
- await deleteSessionState(cwd, tool);
13678
- }
13679
- await initializeSession(cwd, tool, sessionId);
13680
- } finally {
13681
- await releaseLock(cwd, tool);
14103
+ const repos = await loadConfiguredRepos();
14104
+ let initialized = 0;
14105
+ let skipped = 0;
14106
+ let failed = 0;
14107
+ for (const repo of repos) {
14108
+ const outcome = await processSessionStartRepo(repo, tool, sessionId);
14109
+ if (outcome === "initialized") initialized++;
14110
+ else if (outcome === "skipped") skipped++;
14111
+ else failed++;
13682
14112
  }
14113
+ appendLog(
14114
+ "info",
14115
+ `git-traces: SessionStart summary (session=${sessionId}, tool=${tool}, triggerRepo=${startingProject.repoRoot}, configured=${repos.length}, initialized=${initialized}, skipped=${skipped}, failed=${failed})`
14116
+ );
13683
14117
  }
13684
- async function handleStop(payload, tool) {
13685
- const cwd = resolveCwd(payload);
13686
- if (!cwd) return;
13687
- if (!isGitRepo(cwd)) return;
13688
- const recordedAt = Date.now();
13689
- await acquireLock(cwd, tool);
13690
- try {
13691
- const state = await readSessionState(cwd, tool);
13692
- if (!state) return;
13693
- const project = await findProjectForCwd(cwd);
13694
- if (!project) return;
13695
- const identity = await loadIdentity(project.config.apiBaseUrl);
13696
- if (!identity) {
13697
- appendLog(
13698
- "warn",
13699
- `git-traces: no saved login for ${project.config.apiBaseUrl}, skipping epoch close`
13700
- );
13701
- return;
13702
- }
13703
- const client = new PlatformClient(
13704
- project.config.apiBaseUrl,
13705
- identity.sessionCookie
14118
+ function buildInitialBaselineArtifactsForState(repoRoot, tool, state) {
14119
+ if (state.baselineTreeSha && state.baselineMetadata) {
14120
+ return buildFrozenEpochBaselineArtifacts({
14121
+ repoRoot,
14122
+ sessionId: state.sessionId,
14123
+ epoch: 1,
14124
+ baselineTreeSha: state.baselineTreeSha,
14125
+ baselineMetadata: state.baselineMetadata
14126
+ });
14127
+ }
14128
+ return buildEpochBaselineArtifacts({
14129
+ repoRoot,
14130
+ sessionId: state.sessionId,
14131
+ tool,
14132
+ epoch: 1,
14133
+ baselineSha: state.baselineSha,
14134
+ prevHeadSha: null,
14135
+ transitionKind: "initial",
14136
+ startedAt: state.startedAt
14137
+ });
14138
+ }
14139
+ async function loadRepoClient(repo) {
14140
+ const identity = await loadIdentity(repo.config.apiBaseUrl);
14141
+ if (!identity) {
14142
+ appendLog(
14143
+ "warn",
14144
+ `git-traces: skipping repo on Stop (repo=${repo.repoRoot}, project=${repo.config.projectId}, reason=no-saved-login, apiBaseUrl=${repo.config.apiBaseUrl})`
13706
14145
  );
13707
- if (state.contributionId === null) {
13708
- const artifacts = buildEpochBaselineArtifacts({
13709
- repoRoot: cwd,
13710
- sessionId: state.sessionId,
13711
- tool,
13712
- epoch: 1,
13713
- baselineSha: state.baselineSha,
13714
- prevHeadSha: null,
13715
- transitionKind: "initial",
13716
- startedAt: state.startedAt
13717
- });
13718
- if (!artifacts) return;
13719
- if (!canUploadEpochBaselineArtifacts(1, artifacts)) return;
13720
- const toolLabel = TOOL_LABELS[tool] ?? "Claude";
13721
- const now = /* @__PURE__ */ new Date();
13722
- const epochSeconds = formatEpochSeconds2(now);
13723
- const shortId = state.sessionId.slice(0, 12);
13724
- const contribution = await client.createContribution(
13725
- project.config.projectId,
13726
- {
13727
- contributionTypeSlug: GIT_TRACES_SLUG,
13728
- title: `${toolLabel} session ${shortId} \u2014 ${epochSeconds}`,
13729
- body: `Session ID: ${state.sessionId}
13730
- Tool: ${toolLabel}
13731
- Repo: ${cwd}
13732
- Uploaded: ${now.toISOString()}`
13733
- }
13734
- );
13735
- const uploaded = await uploadEpochBaselineArtifacts({
13736
- client,
13737
- contributionId: contribution.id,
13738
- epoch: 1,
13739
- artifacts
13740
- });
13741
- if (!uploaded) return;
13742
- await client.submitContribution(contribution.id);
13743
- state.contributionId = contribution.id;
13744
- state.baselineTreeSha = artifacts.baselineTreeSha;
13745
- state.lastSnapshotTreeSha = artifacts.baselineTreeSha;
13746
- await writeSessionState(state, tool);
14146
+ return null;
14147
+ }
14148
+ return new PlatformClient(repo.config.apiBaseUrl, identity.sessionCookie);
14149
+ }
14150
+ async function registerInitialContribution(params) {
14151
+ const { repo, state, tool, client, artifacts } = params;
14152
+ const contributionId = await createGitTracesContribution({
14153
+ client,
14154
+ config: repo.config,
14155
+ repoRoot: repo.repoRoot,
14156
+ state,
14157
+ tool,
14158
+ now: /* @__PURE__ */ new Date(),
14159
+ artifacts
14160
+ });
14161
+ if (!contributionId) return false;
14162
+ state.contributionId = contributionId;
14163
+ state.baselineTreeSha = artifacts.baselineTreeSha;
14164
+ state.lastSnapshotTreeSha = artifacts.baselineTreeSha;
14165
+ await writeSessionState(state, tool);
14166
+ appendLog(
14167
+ "info",
14168
+ `git-traces: session registered on first changed turn (repo=${repo.repoRoot}, project=${repo.config.projectId}, contribution=${contributionId}, epoch=1, baseline=${state.baselineSha.slice(0, 8)})`
14169
+ );
14170
+ return true;
14171
+ }
14172
+ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14173
+ const { repoRoot, config } = repo;
14174
+ if (!isGitRepo(repoRoot)) {
14175
+ appendLog(
14176
+ "info",
14177
+ `git-traces: skipping repo on Stop (repo=${repoRoot}, project=${config.projectId}, reason=not-git-repo)`
14178
+ );
14179
+ return "skipped";
14180
+ }
14181
+ await acquireLock(repoRoot, tool);
14182
+ try {
14183
+ const state = await readSessionState(repoRoot, tool, sessionId);
14184
+ if (!state) {
13747
14185
  appendLog(
13748
14186
  "info",
13749
- `git-traces: session registered on first turn (epoch=1, baseline=${state.baselineSha.slice(0, 8)}, contribution=${contribution.id})`
14187
+ `git-traces: skipping repo on Stop (repo=${repoRoot}, project=${config.projectId}, reason=no-active-state)`
13750
14188
  );
14189
+ return "no-state";
13751
14190
  }
13752
- const contributionId = state.contributionId;
13753
- const lastSnapshotTreeSha = state.lastSnapshotTreeSha;
13754
- if (contributionId === null || lastSnapshotTreeSha === null) {
14191
+ const lastSnapshotTreeSha = state.lastSnapshotTreeSha ?? state.baselineTreeSha;
14192
+ if (!lastSnapshotTreeSha) {
13755
14193
  appendLog(
13756
14194
  "error",
13757
- "git-traces: invariant violation \u2014 session state missing contributionId or tree SHA after lazy init"
14195
+ `git-traces: invariant violation \u2014 session state missing snapshot tree SHA (repo=${repoRoot}, session=${state.sessionId})`
13758
14196
  );
13759
- return;
14197
+ return "failed";
13760
14198
  }
13761
- const currentHeadSha = captureHeadSha(cwd);
14199
+ const currentHeadSha = captureHeadSha(repoRoot);
13762
14200
  if (currentHeadSha !== state.headSha) {
14201
+ const client2 = await loadRepoClient(repo);
14202
+ if (!client2) return "skipped";
14203
+ if (state.contributionId === null) {
14204
+ const artifacts2 = buildInitialBaselineArtifactsForState(
14205
+ repoRoot,
14206
+ tool,
14207
+ state
14208
+ );
14209
+ if (!artifacts2 || !canUploadEpochBaselineArtifacts(1, artifacts2)) {
14210
+ return "skipped";
14211
+ }
14212
+ const registered = await registerInitialContribution({
14213
+ repo,
14214
+ state,
14215
+ tool,
14216
+ client: client2,
14217
+ artifacts: artifacts2
14218
+ });
14219
+ if (!registered) return "failed";
14220
+ }
14221
+ const contributionId2 = state.contributionId;
14222
+ if (contributionId2 === null) {
14223
+ appendLog(
14224
+ "error",
14225
+ `git-traces: invariant violation \u2014 missing contribution after registration (repo=${repoRoot}, session=${state.sessionId})`
14226
+ );
14227
+ return "failed";
14228
+ }
13763
14229
  const transitionKind = detectTransitionKind(
13764
- cwd,
14230
+ repoRoot,
13765
14231
  state.headSha || null,
13766
14232
  currentHeadSha
13767
14233
  );
13768
14234
  const nextEpoch = state.epoch + 1;
13769
14235
  appendLog(
13770
14236
  "info",
13771
- `git-traces: HEAD moved (${transitionKind}, ${state.headSha.slice(0, 8)} \u2192 ${currentHeadSha.slice(0, 8)}), opening epoch ${nextEpoch}`
14237
+ `git-traces: HEAD moved (repo=${repoRoot}, project=${config.projectId}, contribution=${contributionId2}, transition=${transitionKind}, from=${state.headSha.slice(0, 8)}, to=${currentHeadSha.slice(0, 8)}, openingEpoch=${nextEpoch})`
13772
14238
  );
13773
14239
  const artifacts = await openEpoch({
13774
- repoRoot: cwd,
13775
- client,
13776
- contributionId,
14240
+ repoRoot,
14241
+ client: client2,
14242
+ contributionId: contributionId2,
13777
14243
  sessionId: state.sessionId,
13778
14244
  tool,
13779
14245
  epoch: nextEpoch,
@@ -13781,7 +14247,7 @@ Uploaded: ${now.toISOString()}`
13781
14247
  transitionKind,
13782
14248
  startedAt: state.startedAt
13783
14249
  });
13784
- if (!artifacts) return;
14250
+ if (!artifacts) return "failed";
13785
14251
  const next = {
13786
14252
  ...state,
13787
14253
  baselineSha: artifacts.baselineSha,
@@ -13793,66 +14259,204 @@ Uploaded: ${now.toISOString()}`
13793
14259
  headSha: artifacts.headSha
13794
14260
  };
13795
14261
  await writeSessionState(next, tool);
13796
- return;
14262
+ appendLog(
14263
+ "info",
14264
+ `git-traces: epoch baseline uploaded (repo=${repoRoot}, project=${config.projectId}, contribution=${contributionId2}, epoch=${nextEpoch}, baseline=${artifacts.baselineSha.slice(0, 8)})`
14265
+ );
14266
+ return "uploaded";
13797
14267
  }
13798
- const currentSha = captureSnapshotSha(cwd);
13799
- const currentTreeSha = buildSnapshotTree(cwd, currentSha);
14268
+ const currentSha = captureSnapshotSha(repoRoot);
14269
+ const currentTreeSha = buildSnapshotTree(repoRoot, currentSha);
13800
14270
  const patchBuffer = createTreeDiffPatchGz(
13801
- cwd,
14271
+ repoRoot,
13802
14272
  lastSnapshotTreeSha,
13803
14273
  currentTreeSha
13804
14274
  );
13805
14275
  if (!patchBuffer) {
13806
14276
  appendLog(
13807
14277
  "info",
13808
- "git-traces: turn produced identical snapshot tree, skipping upload"
14278
+ `git-traces: turn produced identical snapshot tree, skipping upload (repo=${repoRoot}, project=${config.projectId})`
13809
14279
  );
13810
- return;
14280
+ return "unchanged";
13811
14281
  }
13812
- state.turnCount++;
13813
14282
  const prefix = epochPrefix(state.epoch);
13814
- const turnLabel = turnSuffix(state.turnCount);
14283
+ const nextTurnCount = state.turnCount + 1;
14284
+ const turnLabel = turnSuffix(nextTurnCount);
14285
+ const filename = `${prefix}-${turnLabel}-${recordedAt}.patch.gz`;
14286
+ if (state.contributionId === null && !canUploadFile(filename, patchBuffer)) {
14287
+ appendLog(
14288
+ "warn",
14289
+ `git-traces: first changed turn skipped before contribution creation (repo=${repoRoot}, project=${config.projectId}, reason=patch-too-large)`
14290
+ );
14291
+ return "skipped";
14292
+ }
14293
+ const client = await loadRepoClient(repo);
14294
+ if (!client) return "skipped";
14295
+ if (state.contributionId === null) {
14296
+ const artifacts = buildInitialBaselineArtifactsForState(
14297
+ repoRoot,
14298
+ tool,
14299
+ state
14300
+ );
14301
+ if (!artifacts || !canUploadEpochBaselineArtifacts(1, artifacts)) {
14302
+ return "skipped";
14303
+ }
14304
+ const registered = await registerInitialContribution({
14305
+ repo,
14306
+ state,
14307
+ tool,
14308
+ client,
14309
+ artifacts
14310
+ });
14311
+ if (!registered) return "failed";
14312
+ }
14313
+ const contributionId = state.contributionId;
14314
+ if (contributionId === null) {
14315
+ appendLog(
14316
+ "error",
14317
+ `git-traces: invariant violation \u2014 missing contribution before patch upload (repo=${repoRoot}, session=${state.sessionId})`
14318
+ );
14319
+ return "failed";
14320
+ }
13815
14321
  pinRef(
13816
- cwd,
14322
+ repoRoot,
13817
14323
  `refs/hillclimb/turns/${state.sessionId}/${prefix}/${turnLabel}`,
13818
14324
  currentSha
13819
14325
  );
13820
- const filename = `${prefix}-${turnLabel}-${recordedAt}.patch.gz`;
13821
- await uploadFile(
14326
+ const uploaded = await uploadFile(
13822
14327
  client,
13823
14328
  contributionId,
13824
14329
  filename,
13825
14330
  "application/gzip",
13826
14331
  patchBuffer
13827
14332
  );
14333
+ if (!uploaded) {
14334
+ appendLog(
14335
+ "warn",
14336
+ `git-traces: ${prefix}-${turnLabel} not uploaded; keeping last uploaded snapshot unchanged (repo=${repoRoot}, project=${config.projectId}, contribution=${contributionId})`
14337
+ );
14338
+ return "skipped";
14339
+ }
14340
+ state.turnCount = nextTurnCount;
13828
14341
  state.lastSnapshotSha = currentSha;
13829
14342
  state.lastSnapshotTreeSha = currentTreeSha;
13830
14343
  await writeSessionState(state, tool);
13831
14344
  appendLog(
13832
14345
  "info",
13833
- `git-traces: ${prefix}-${turnLabel} uploaded (${patchBuffer.byteLength} bytes)`
14346
+ `git-traces: patch uploaded (repo=${repoRoot}, project=${config.projectId}, contribution=${contributionId}, epoch=${state.epoch}, turn=${turnLabel}, bytes=${patchBuffer.byteLength})`
13834
14347
  );
14348
+ return "uploaded";
14349
+ } catch (err) {
14350
+ appendLog(
14351
+ "error",
14352
+ `git-traces: Stop failed for repo ${repoRoot}: ${formatError(err)}`
14353
+ );
14354
+ return "failed";
13835
14355
  } finally {
13836
- await releaseLock(cwd, tool);
14356
+ await releaseLock(repoRoot, tool);
13837
14357
  }
13838
14358
  }
13839
- async function handleSessionEnd(payload, tool) {
14359
+ async function handleStop(payload, tool) {
13840
14360
  const cwd = resolveCwd(payload);
13841
14361
  if (!cwd) return;
13842
- await acquireLock(cwd, tool);
14362
+ const project = await findProjectForCwd(cwd);
14363
+ if (!project) return;
14364
+ const sessionId = resolveSessionId(payload);
14365
+ const recordedAt = Date.now();
14366
+ const repos = await loadConfiguredRepos();
14367
+ const repoByRoot = new Map(repos.map((repo) => [repo.repoRoot, repo]));
14368
+ const targets = [];
14369
+ let missingConfig = 0;
14370
+ if (sessionId) {
14371
+ const storedStates = await listSessionStatesForSession(tool, sessionId);
14372
+ for (const { state } of storedStates) {
14373
+ const repo = repoByRoot.get(path15.resolve(state.repoRoot));
14374
+ if (!repo) {
14375
+ missingConfig++;
14376
+ appendLog(
14377
+ "warn",
14378
+ `git-traces: skipping repo on Stop (repo=${state.repoRoot}, session=${sessionId}, reason=missing-config)`
14379
+ );
14380
+ continue;
14381
+ }
14382
+ targets.push(repo);
14383
+ }
14384
+ if (targets.length === 0 && missingConfig === 0) {
14385
+ targets.push({ repoRoot: project.repoRoot, config: project.config });
14386
+ }
14387
+ } else {
14388
+ targets.push({ repoRoot: project.repoRoot, config: project.config });
14389
+ }
14390
+ let uploaded = 0;
14391
+ let unchanged = 0;
14392
+ let skipped = missingConfig;
14393
+ let noState = 0;
14394
+ let failed = 0;
14395
+ for (const repo of targets) {
14396
+ const outcome = await processStopRepo(repo, tool, sessionId, recordedAt);
14397
+ if (outcome === "uploaded") uploaded++;
14398
+ else if (outcome === "unchanged") unchanged++;
14399
+ else if (outcome === "skipped") skipped++;
14400
+ else if (outcome === "no-state") noState++;
14401
+ else failed++;
14402
+ }
14403
+ appendLog(
14404
+ "info",
14405
+ `git-traces: Stop summary (session=${sessionId ?? "<none>"}, tool=${tool}, triggerRepo=${project.repoRoot}, configured=${repos.length}, frozenStates=${targets.length + missingConfig}, uploaded=${uploaded}, unchanged=${unchanged}, skipped=${skipped}, noState=${noState}, failed=${failed})`
14406
+ );
14407
+ }
14408
+ async function cleanupSessionStateForRepo(repoRoot, tool, sessionId) {
14409
+ await acquireLock(repoRoot, tool);
13843
14410
  try {
13844
- const state = await readSessionState(cwd, tool);
13845
- if (!state) return;
13846
- cleanupSessionRefs(cwd, state.sessionId);
13847
- await deleteSessionState(cwd, tool);
14411
+ const state = await readSessionState(repoRoot, tool, sessionId);
14412
+ if (!state) return "no-state";
14413
+ cleanupSessionRefs(repoRoot, state.sessionId);
14414
+ await deleteSessionState(repoRoot, tool, state.sessionId);
13848
14415
  appendLog(
13849
14416
  "info",
13850
- `git-traces: session ${state.sessionId} cleaned up (${state.epoch} epoch(s), last had ${state.turnCount} turns)`
14417
+ `git-traces: session ${state.sessionId} cleaned up (repo=${repoRoot}, epochCount=${state.epoch}, lastTurnCount=${state.turnCount})`
14418
+ );
14419
+ return "cleaned";
14420
+ } catch (err) {
14421
+ appendLog(
14422
+ "error",
14423
+ `git-traces: SessionEnd cleanup failed for repo ${repoRoot}: ${formatError(err)}`
13851
14424
  );
14425
+ return "failed";
13852
14426
  } finally {
13853
- await releaseLock(cwd, tool);
14427
+ await releaseLock(repoRoot, tool);
13854
14428
  }
13855
14429
  }
14430
+ async function handleSessionEnd(payload, tool) {
14431
+ const cwd = resolveCwd(payload);
14432
+ const sessionId = resolveSessionId(payload);
14433
+ const project = cwd ? await findProjectForCwd(cwd) : null;
14434
+ const triggerRepo = project?.repoRoot ?? cwd ?? "<none>";
14435
+ const repoRoots = [];
14436
+ if (sessionId) {
14437
+ const states = await listSessionStatesForSession(tool, sessionId);
14438
+ for (const { state } of states) {
14439
+ repoRoots.push(path15.resolve(state.repoRoot));
14440
+ }
14441
+ }
14442
+ if (repoRoots.length === 0 && cwd) {
14443
+ repoRoots.push(project?.repoRoot ?? cwd);
14444
+ }
14445
+ if (repoRoots.length === 0) return;
14446
+ let cleaned = 0;
14447
+ let noState = 0;
14448
+ let failed = 0;
14449
+ for (const repoRoot of repoRoots) {
14450
+ const outcome = await cleanupSessionStateForRepo(repoRoot, tool, sessionId);
14451
+ if (outcome === "cleaned") cleaned++;
14452
+ else if (outcome === "no-state") noState++;
14453
+ else failed++;
14454
+ }
14455
+ appendLog(
14456
+ "info",
14457
+ `git-traces: SessionEnd summary (session=${sessionId ?? "<none>"}, tool=${tool}, triggerRepo=${triggerRepo}, states=${repoRoots.length}, cleaned=${cleaned}, noState=${noState}, failed=${failed})`
14458
+ );
14459
+ }
13856
14460
 
13857
14461
  // src/git-traces/index.ts
13858
14462
  var WORKER_ENV_FLAG2 = "HILLCLIMB_GIT_TRACES_WORKER";
@@ -13868,6 +14472,25 @@ var KNOWN_TOOLS = /* @__PURE__ */ new Set([
13868
14472
  "cursor",
13869
14473
  "opencode"
13870
14474
  ]);
14475
+ function classifyHookEvent(event) {
14476
+ switch (event) {
14477
+ case "SessionStart":
14478
+ case "sessionStart":
14479
+ case "session.created":
14480
+ return "sessionStart";
14481
+ case "Stop":
14482
+ case "stop":
14483
+ case "session.idle":
14484
+ return "stop";
14485
+ case "SessionEnd":
14486
+ case "sessionEnd":
14487
+ case "session.deleted":
14488
+ case "server.instance.disposed":
14489
+ return "sessionEnd";
14490
+ default:
14491
+ return "unknown";
14492
+ }
14493
+ }
13871
14494
  function parseToolArg2(argv) {
13872
14495
  for (let i = 0; i < argv.length; i++) {
13873
14496
  const a = argv[i];
@@ -13887,25 +14510,91 @@ async function readStdin2() {
13887
14510
  function resolveCwd2(payload) {
13888
14511
  return payload.cwd ?? payload.workspace_roots?.[0] ?? null;
13889
14512
  }
14513
+ async function repairHookForTool(repoRoot, tool) {
14514
+ const result = await healHookForTool(repoRoot, tool);
14515
+ if (result.skipped) {
14516
+ appendLog("warn", `self-heal: skipped ${tool} hook (${result.skipped})`);
14517
+ return;
14518
+ }
14519
+ if (result.changed) {
14520
+ appendLog("info", `self-heal: updated ${tool} hook`);
14521
+ }
14522
+ }
13890
14523
  async function selfHealHook2(payload, tool) {
14524
+ if (process.env.HILLCLIMB_SKIP_HOOK_SELF_HEAL === "1") return;
13891
14525
  const cwd = resolveCwd2(payload);
13892
14526
  if (!cwd) return;
13893
14527
  try {
13894
14528
  const project = await findProjectForCwd(cwd);
13895
14529
  if (!project) return;
13896
- const result = await healHookForTool(project.repoRoot, tool);
13897
- if (result.skipped) {
13898
- appendLog("warn", `self-heal: skipped ${tool} hook (${result.skipped})`);
13899
- return;
14530
+ await repairHookForTool(project.repoRoot, tool);
14531
+ } catch (err) {
14532
+ appendLog(
14533
+ "warn",
14534
+ `self-heal: failed to repair ${tool} hook: ${formatError(err)}`
14535
+ );
14536
+ }
14537
+ }
14538
+ async function resolveLegacyBareTool(raw) {
14539
+ let payload;
14540
+ try {
14541
+ payload = JSON.parse(raw);
14542
+ } catch (err) {
14543
+ appendLog(
14544
+ "error",
14545
+ `git-traces: missing --tool and failed to parse payload for legacy hook repair: ${formatError(err)}`
14546
+ );
14547
+ return null;
14548
+ }
14549
+ const cwd = resolveCwd2(payload);
14550
+ if (!cwd) {
14551
+ appendLog(
14552
+ "error",
14553
+ "git-traces: missing --tool and no cwd in payload for legacy hook repair"
14554
+ );
14555
+ return null;
14556
+ }
14557
+ try {
14558
+ const project = await findProjectForCwd(cwd);
14559
+ if (!project) {
14560
+ appendLog(
14561
+ "error",
14562
+ `git-traces: missing --tool and no hillclimb config for cwd ${cwd}`
14563
+ );
14564
+ return null;
13900
14565
  }
13901
- if (result.changed) {
13902
- appendLog("info", `self-heal: updated ${tool} hook`);
14566
+ const owners = await findLegacyGitTracesHookOwners(
14567
+ project.repoRoot,
14568
+ payload.hook_event_name
14569
+ );
14570
+ if (owners.length === 0) {
14571
+ appendLog(
14572
+ "error",
14573
+ `git-traces: missing --tool and no legacy bare git-traces hook matched event ${payload.hook_event_name ?? "<none>"}`
14574
+ );
14575
+ return null;
14576
+ }
14577
+ for (const owner of owners) {
14578
+ await repairHookForTool(project.repoRoot, owner);
13903
14579
  }
14580
+ if (owners.length > 1) {
14581
+ appendLog(
14582
+ "warn",
14583
+ `git-traces: legacy bare hook matched multiple tools (${owners.join(", ")}); repaired hooks but skipped this event`
14584
+ );
14585
+ return null;
14586
+ }
14587
+ appendLog(
14588
+ "info",
14589
+ `git-traces: inferred legacy bare hook owner ${owners[0]} and repaired hook`
14590
+ );
14591
+ return owners[0];
13904
14592
  } catch (err) {
13905
14593
  appendLog(
13906
14594
  "warn",
13907
- `self-heal: failed to repair ${tool} hook: ${formatError(err)}`
14595
+ `git-traces: failed legacy hook repair: ${formatError(err)}`
13908
14596
  );
14597
+ return null;
13909
14598
  }
13910
14599
  }
13911
14600
  async function runGitTraces() {
@@ -13920,13 +14609,6 @@ async function runGitTraces() {
13920
14609
  "info",
13921
14610
  `git-traces hook invoked (pid ${process.pid}, tool=${toolArg ?? "<none>"})`
13922
14611
  );
13923
- if (!toolArg || !KNOWN_TOOLS.has(toolArg)) {
13924
- appendLog(
13925
- "error",
13926
- `git-traces: missing or unknown --tool arg (got ${toolArg ?? "<none>"})`
13927
- );
13928
- return;
13929
- }
13930
14612
  let raw;
13931
14613
  try {
13932
14614
  raw = await readStdin2();
@@ -13938,6 +14620,15 @@ async function runGitTraces() {
13938
14620
  appendLog("warn", "git-traces: empty stdin, expected hook payload");
13939
14621
  return;
13940
14622
  }
14623
+ let tool = toolArg;
14624
+ if (tool && !KNOWN_TOOLS.has(tool)) {
14625
+ appendLog("error", `git-traces: unknown --tool arg (got ${tool})`);
14626
+ return;
14627
+ }
14628
+ if (!tool) {
14629
+ tool = await resolveLegacyBareTool(raw);
14630
+ if (!tool) return;
14631
+ }
13941
14632
  const entrypoint = process.argv[1];
13942
14633
  if (!entrypoint) {
13943
14634
  appendLog("error", "git-traces: process.argv[1] is empty");
@@ -13946,14 +14637,14 @@ async function runGitTraces() {
13946
14637
  try {
13947
14638
  const child = spawn3(
13948
14639
  process.execPath,
13949
- [entrypoint, "git-traces", `--tool=${toolArg}`],
14640
+ [entrypoint, "git-traces", `--tool=${tool}`],
13950
14641
  {
13951
14642
  detached: true,
13952
14643
  stdio: ["pipe", "ignore", "ignore"],
13953
14644
  env: {
13954
14645
  ...process.env,
13955
14646
  [WORKER_ENV_FLAG2]: "1",
13956
- [TOOL_ENV_FLAG2]: toolArg,
14647
+ [TOOL_ENV_FLAG2]: tool,
13957
14648
  [FLOW_ID_ENV]: flowId
13958
14649
  }
13959
14650
  }
@@ -14024,25 +14715,14 @@ async function runGitTracesWorker() {
14024
14715
  }
14025
14716
  await selfHealHook2(payload, tool);
14026
14717
  try {
14027
- switch (event) {
14028
- case "SessionStart":
14718
+ switch (classifyHookEvent(event)) {
14029
14719
  case "sessionStart":
14030
- // opencode: session.created fires when a new session is first opened.
14031
- case "session.created":
14032
14720
  await handleSessionStart(payload, tool);
14033
14721
  break;
14034
- case "Stop":
14035
14722
  case "stop":
14036
- // opencode: session.idle fires at end-of-turn (agent finished responding).
14037
- case "session.idle":
14038
14723
  await handleStop(payload, tool);
14039
14724
  break;
14040
- case "SessionEnd":
14041
14725
  case "sessionEnd":
14042
- // opencode: server.instance.disposed fires when opencode shuts down.
14043
- // Payload has `cwd` (from the plugin) but no session_id; handleSessionEnd
14044
- // reads state by (repoRoot, tool), so cwd alone is sufficient for cleanup.
14045
- case "server.instance.disposed":
14046
14726
  await handleSessionEnd(payload, tool);
14047
14727
  break;
14048
14728
  default:
@@ -14061,14 +14741,14 @@ ${stack}` : ""}`
14061
14741
 
14062
14742
  // src/outputs/zip.ts
14063
14743
  import fs13 from "fs";
14064
- import path16 from "path";
14744
+ import path17 from "path";
14065
14745
  import archiver2 from "archiver";
14066
14746
 
14067
14747
  // src/outputs/downloads.ts
14068
14748
  import { execSync as execSync2 } from "child_process";
14069
14749
  import fs12 from "fs";
14070
14750
  import os8 from "os";
14071
- import path15 from "path";
14751
+ import path16 from "path";
14072
14752
  function getDownloadsFolder() {
14073
14753
  const home = os8.homedir();
14074
14754
  if (process.platform === "linux") {
@@ -14081,7 +14761,7 @@ function getDownloadsFolder() {
14081
14761
  } catch {
14082
14762
  }
14083
14763
  }
14084
- const downloads = path15.join(home, "Downloads");
14764
+ const downloads = path16.join(home, "Downloads");
14085
14765
  if (fs12.existsSync(downloads)) return downloads;
14086
14766
  return home;
14087
14767
  }
@@ -14091,11 +14771,11 @@ function sanitizeFilename(name) {
14091
14771
  return name.replace(/[^a-zA-Z0-9._-]/g, "_");
14092
14772
  }
14093
14773
  function getUniqueFilename(dir, base, ext) {
14094
- let candidate = path16.join(dir, `${base}${ext}`);
14774
+ let candidate = path17.join(dir, `${base}${ext}`);
14095
14775
  if (!fs13.existsSync(candidate)) return candidate;
14096
14776
  let i = 1;
14097
14777
  while (fs13.existsSync(candidate)) {
14098
- candidate = path16.join(dir, `${base}-${i}${ext}`);
14778
+ candidate = path17.join(dir, `${base}-${i}${ext}`);
14099
14779
  i++;
14100
14780
  }
14101
14781
  return candidate;
@@ -14105,7 +14785,7 @@ var ZipOutput = class {
14105
14785
  label = "Save as .zip to Downloads";
14106
14786
  async emit(group, options) {
14107
14787
  const downloadsDir = getDownloadsFolder();
14108
- const repoName = sanitizeFilename(path16.basename(group.repoPath));
14788
+ const repoName = sanitizeFilename(path17.basename(group.repoPath));
14109
14789
  const timeRange = options.timeRange;
14110
14790
  const rangePart = timeRange?.label ?? "all";
14111
14791
  const epochSeconds = Math.floor(Date.now() / 1e3);
@@ -14307,11 +14987,11 @@ async function confirmExport(group, output) {
14307
14987
  // src/sources/claude.ts
14308
14988
  import fs14 from "fs";
14309
14989
  import os9 from "os";
14310
- import path17 from "path";
14990
+ import path18 from "path";
14311
14991
  import readline from "readline";
14312
14992
  var SKIP_DIRS = /* @__PURE__ */ new Set(["tool-results"]);
14313
14993
  async function resolveRepoPath(projectDir) {
14314
- const indexPath = path17.join(projectDir, "sessions-index.json");
14994
+ const indexPath = path18.join(projectDir, "sessions-index.json");
14315
14995
  try {
14316
14996
  const raw = await fs14.promises.readFile(indexPath, "utf-8");
14317
14997
  const data = JSON.parse(raw);
@@ -14326,7 +15006,7 @@ async function resolveRepoPath(projectDir) {
14326
15006
  });
14327
15007
  for (const entry of entries) {
14328
15008
  if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
14329
- const cwd = await extractCwdFromJsonl(path17.join(projectDir, entry.name));
15009
+ const cwd = await extractCwdFromJsonl(path18.join(projectDir, entry.name));
14330
15010
  if (cwd) {
14331
15011
  cwdCounts.set(cwd, (cwdCounts.get(cwd) ?? 0) + 1);
14332
15012
  }
@@ -14372,7 +15052,7 @@ async function collectFiles(dir, baseDir, sourceName, repoPath, results) {
14372
15052
  return;
14373
15053
  }
14374
15054
  for (const entry of entries) {
14375
- const fullPath = path17.join(dir, entry.name);
15055
+ const fullPath = path18.join(dir, entry.name);
14376
15056
  if (entry.isDirectory()) {
14377
15057
  if (SKIP_DIRS.has(entry.name)) continue;
14378
15058
  await collectFiles(fullPath, baseDir, sourceName, repoPath, results);
@@ -14394,7 +15074,7 @@ function fallbackDecode(encodedName) {
14394
15074
  var ClaudeSource = class {
14395
15075
  name = "claude";
14396
15076
  async scan() {
14397
- const baseDir = path17.join(os9.homedir(), ".claude", "projects");
15077
+ const baseDir = path18.join(os9.homedir(), ".claude", "projects");
14398
15078
  try {
14399
15079
  await fs14.promises.access(baseDir);
14400
15080
  } catch {
@@ -14406,7 +15086,7 @@ var ClaudeSource = class {
14406
15086
  const dirEntries = projectDirs.filter((d) => d.isDirectory());
14407
15087
  const resultArrays = await Promise.all(
14408
15088
  dirEntries.map(async (dir) => {
14409
- const projectPath = path17.join(baseDir, dir.name);
15089
+ const projectPath = path18.join(baseDir, dir.name);
14410
15090
  const repoPath = await resolveRepoPath(projectPath) ?? fallbackDecode(dir.name);
14411
15091
  const files = [];
14412
15092
  await collectFiles(
@@ -14426,7 +15106,7 @@ var ClaudeSource = class {
14426
15106
  // src/sources/codex.ts
14427
15107
  import fs15 from "fs";
14428
15108
  import os10 from "os";
14429
- import path18 from "path";
15109
+ import path19 from "path";
14430
15110
  import readline2 from "readline";
14431
15111
  async function parseSessionMeta(filePath) {
14432
15112
  const stream = fs15.createReadStream(filePath, { encoding: "utf-8" });
@@ -14459,7 +15139,7 @@ async function findJsonlFiles(dir) {
14459
15139
  return;
14460
15140
  }
14461
15141
  for (const entry of entries) {
14462
- const full = path18.join(d, entry.name);
15142
+ const full = path19.join(d, entry.name);
14463
15143
  if (entry.isDirectory()) {
14464
15144
  await walk(full);
14465
15145
  } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
@@ -14504,14 +15184,14 @@ async function loadHistory(historyPath) {
14504
15184
  var CodexSource = class {
14505
15185
  name = "codex";
14506
15186
  async scan() {
14507
- const codexDir = path18.join(os10.homedir(), ".codex");
14508
- const sessionsDir = path18.join(codexDir, "sessions");
15187
+ const codexDir = path19.join(os10.homedir(), ".codex");
15188
+ const sessionsDir = path19.join(codexDir, "sessions");
14509
15189
  try {
14510
15190
  await fs15.promises.access(sessionsDir);
14511
15191
  } catch {
14512
15192
  return [];
14513
15193
  }
14514
- const historyPath = path18.join(codexDir, "history.jsonl");
15194
+ const historyPath = path19.join(codexDir, "history.jsonl");
14515
15195
  const [jsonlFiles, historyMap] = await Promise.all([
14516
15196
  findJsonlFiles(sessionsDir),
14517
15197
  loadHistory(historyPath)
@@ -14534,8 +15214,8 @@ var CodexSource = class {
14534
15214
  });
14535
15215
  const historyLines = historyMap.get(meta.sessionId);
14536
15216
  if (historyLines) {
14537
- const sessionDir = path18.relative(sessionsDir, path18.dirname(filePath));
14538
- const historyAbsPath = path18.join(
15217
+ const sessionDir = path19.relative(sessionsDir, path19.dirname(filePath));
15218
+ const historyAbsPath = path19.join(
14539
15219
  sessionsDir,
14540
15220
  sessionDir,
14541
15221
  `history-${meta.sessionId}.jsonl`
@@ -14557,16 +15237,16 @@ var CodexSource = class {
14557
15237
  // src/sources/copilotChat.ts
14558
15238
  import fs16 from "fs";
14559
15239
  import os11 from "os";
14560
- import path19 from "path";
15240
+ import path20 from "path";
14561
15241
  import { fileURLToPath } from "url";
14562
15242
  function vsCodeUserDirs() {
14563
15243
  const home = os11.homedir();
14564
15244
  const dirs = [
14565
- path19.join(home, "Library", "Application Support", "Code", "User"),
14566
- path19.join(home, ".config", "Code", "User")
15245
+ path20.join(home, "Library", "Application Support", "Code", "User"),
15246
+ path20.join(home, ".config", "Code", "User")
14567
15247
  ];
14568
15248
  if (process.env.APPDATA) {
14569
- dirs.push(path19.join(process.env.APPDATA, "Code", "User"));
15249
+ dirs.push(path20.join(process.env.APPDATA, "Code", "User"));
14570
15250
  }
14571
15251
  return dirs;
14572
15252
  }
@@ -14603,7 +15283,7 @@ var CopilotChatSource = class {
14603
15283
  async scan() {
14604
15284
  const results = [];
14605
15285
  for (const userDir of vsCodeUserDirs()) {
14606
- const workspaceStorage = path19.join(userDir, "workspaceStorage");
15286
+ const workspaceStorage = path20.join(userDir, "workspaceStorage");
14607
15287
  let hashDirs;
14608
15288
  try {
14609
15289
  hashDirs = await fs16.promises.readdir(workspaceStorage, {
@@ -14614,8 +15294,8 @@ var CopilotChatSource = class {
14614
15294
  }
14615
15295
  for (const hash of hashDirs) {
14616
15296
  if (!hash.isDirectory()) continue;
14617
- const wsRoot = path19.join(workspaceStorage, hash.name);
14618
- const transcriptsDir = path19.join(
15297
+ const wsRoot = path20.join(workspaceStorage, hash.name);
15298
+ const transcriptsDir = path20.join(
14619
15299
  wsRoot,
14620
15300
  "GitHub.copilot-chat",
14621
15301
  "transcripts"
@@ -14629,7 +15309,7 @@ var CopilotChatSource = class {
14629
15309
  continue;
14630
15310
  }
14631
15311
  const repoPath = await readWorkspaceFolder(
14632
- path19.join(wsRoot, "workspace.json")
15312
+ path20.join(wsRoot, "workspace.json")
14633
15313
  );
14634
15314
  if (!repoPath) continue;
14635
15315
  for (const entry of transcriptEntries) {
@@ -14637,7 +15317,7 @@ var CopilotChatSource = class {
14637
15317
  const sessionId = entry.name.slice(0, -".jsonl".length);
14638
15318
  results.push({
14639
15319
  sourceName: this.name,
14640
- absolutePath: path19.join(transcriptsDir, entry.name),
15320
+ absolutePath: path20.join(transcriptsDir, entry.name),
14641
15321
  repoPath,
14642
15322
  metadata: { sessionId }
14643
15323
  });
@@ -14704,10 +15384,10 @@ async function runInteractive() {
14704
15384
  s.start(`Scanning ${source.name} logs...`);
14705
15385
  const allFiles = await source.scan();
14706
15386
  const allGroups = await mergeByRepo(allFiles);
14707
- const repoRoot = path20.resolve(repo.root);
15387
+ const repoRoot = path21.resolve(repo.root);
14708
15388
  const matching = allGroups.filter((g) => {
14709
- const resolved = path20.resolve(g.repoPath);
14710
- return resolved === repoRoot || resolved.startsWith(repoRoot + path20.sep);
15389
+ const resolved = path21.resolve(g.repoPath);
15390
+ return resolved === repoRoot || resolved.startsWith(repoRoot + path21.sep);
14711
15391
  });
14712
15392
  if (matching.length === 0) {
14713
15393
  s.stop(`No ${source.name} logs found for ${repo.name}.`);
@@ -14738,7 +15418,7 @@ async function runInteractive() {
14738
15418
  }
14739
15419
  }
14740
15420
  const envFileNames = await discoverEnvFiles(repoRoot);
14741
- const envFilePaths = envFileNames.map((n) => path20.join(repoRoot, n));
15421
+ const envFilePaths = envFileNames.map((n) => path21.join(repoRoot, n));
14742
15422
  const additionalFiles = await promptSecretFiles(envFileNames);
14743
15423
  const secretResult = await collectSecrets(
14744
15424
  repoRoot,