hillclimb 0.1.9 → 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 (3) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +1930 -375
  3. 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 = 3;
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
@@ -864,20 +868,54 @@ import path from "node:path";
864
868
  const TOOL = "opencode";
865
869
  const TRANSCRIPT_DIR = path.join(os.homedir(), ".hillclimb", "opencode-transcripts");
866
870
 
867
- // sessionID -> Map<messageID, messageInfo>. Using a Map-of-Maps so late
868
- // message.updated events (streaming deltas) overwrite earlier versions of
869
- // the same message instead of appending duplicates.
871
+ // sessionID -> Map<messageID, { info, parts }>. Using a Map-of-Maps so late
872
+ // message.updated / message.part.updated events overwrite earlier versions
873
+ // instead of appending duplicates.
870
874
  const sessionMessages = new Map();
871
875
 
872
- function ingestMessage(sessionID, info) {
873
- if (!sessionID || !info) return;
876
+ function ensureRecord(sessionID, messageID, info) {
877
+ if (!sessionID || !messageID) return null;
874
878
  let bucket = sessionMessages.get(sessionID);
875
879
  if (!bucket) {
876
880
  bucket = new Map();
877
881
  sessionMessages.set(sessionID, bucket);
878
882
  }
879
- const id = info.id || ((info.role || "unknown") + "-" + bucket.size);
880
- bucket.set(id, info);
883
+ let record = bucket.get(messageID);
884
+ if (!record) {
885
+ record = {
886
+ info: info || { id: messageID, sessionID },
887
+ parts: new Map(),
888
+ };
889
+ bucket.set(messageID, record);
890
+ } else if (info) {
891
+ record.info = Object.assign({}, record.info || {}, info);
892
+ }
893
+ return record;
894
+ }
895
+
896
+ function ingestMessage(sessionID, info) {
897
+ if (!sessionID || !info) return;
898
+ const id = info.id || ((info.role || "unknown") + "-" + Date.now());
899
+ ensureRecord(sessionID, id, info);
900
+ }
901
+
902
+ function ingestPart(sessionID, part) {
903
+ if (!part) return;
904
+ const sid = sessionID || part.sessionID;
905
+ const messageID = part.messageID;
906
+ if (!sid || !messageID) return;
907
+ const record = ensureRecord(sid, messageID, { id: messageID, sessionID: sid });
908
+ if (!record) return;
909
+ const partID = part.id || ((part.type || "part") + "-" + record.parts.size);
910
+ record.parts.set(partID, part);
911
+ }
912
+
913
+ function serializeRecord(record) {
914
+ const info = record.info || {};
915
+ const parts = Array.from(record.parts.values());
916
+ if (parts.length === 0) return info;
917
+ // Preserve the old top-level message-info shape while adding parts.
918
+ return Object.assign({}, info, { parts });
881
919
  }
882
920
 
883
921
  function writeTranscript(sessionID) {
@@ -887,7 +925,7 @@ function writeTranscript(sessionID) {
887
925
  fs.mkdirSync(TRANSCRIPT_DIR, { recursive: true });
888
926
  const file = path.join(TRANSCRIPT_DIR, sessionID + ".jsonl");
889
927
  const lines = [];
890
- for (const msg of bucket.values()) lines.push(JSON.stringify(msg));
928
+ for (const record of bucket.values()) lines.push(JSON.stringify(serializeRecord(record)));
891
929
  fs.writeFileSync(file, lines.join("\\n") + "\\n");
892
930
  return file;
893
931
  } catch {
@@ -917,17 +955,27 @@ export const HillclimbPlugin = async ({ directory }) => ({
917
955
  event: async ({ event }) => {
918
956
  const type = event && event.type;
919
957
  const props = (event && event.properties) || {};
920
- const sessionID = props.sessionID;
958
+ const info = props.info || {};
959
+ const sessionID =
960
+ props.sessionID ||
961
+ info.sessionID ||
962
+ (type === "message.updated" || type === "message.part.updated" ? undefined : info.id);
963
+ const cwd = directory || props.directory || info.directory;
921
964
 
922
965
  if (type === "message.updated") {
923
- ingestMessage(sessionID, props.info);
966
+ ingestMessage(sessionID, info);
967
+ return;
968
+ }
969
+
970
+ if (type === "message.part.updated") {
971
+ ingestPart(sessionID, props.part);
924
972
  return;
925
973
  }
926
974
 
927
975
  if (type === "session.created" && sessionID) {
928
976
  spawnHillclimb("git-traces --tool=opencode", {
929
977
  session_id: sessionID,
930
- cwd: directory,
978
+ cwd,
931
979
  hook_event_name: "session.created",
932
980
  tool: TOOL,
933
981
  });
@@ -940,7 +988,7 @@ export const HillclimbPlugin = async ({ directory }) => ({
940
988
  // so a single session produces one contribution, not one per turn.
941
989
  spawnHillclimb("git-traces --tool=opencode", {
942
990
  session_id: sessionID,
943
- cwd: directory,
991
+ cwd,
944
992
  hook_event_name: "session.idle",
945
993
  tool: TOOL,
946
994
  });
@@ -949,45 +997,53 @@ export const HillclimbPlugin = async ({ directory }) => ({
949
997
 
950
998
  if (type === "session.deleted" && sessionID) {
951
999
  // User explicitly ended this session. Upload its transcript and drop
952
- // the buffer. We do NOT clean up git-traces here: the user may still
953
- // have other live sessions in this repo, and git-traces state is
954
- // keyed per (repoRoot, tool) \u2014 clearing it here would wipe their
955
- // concurrent session. Cleanup happens at server.instance.disposed
956
- // (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.
957
1002
  const transcriptPath = writeTranscript(sessionID);
958
1003
  if (transcriptPath) {
959
1004
  spawnHillclimb("upload", {
960
1005
  session_id: sessionID,
961
- cwd: directory,
1006
+ cwd,
962
1007
  transcript_path: transcriptPath,
963
1008
  hook_event_name: "session.deleted",
964
1009
  tool: TOOL,
965
1010
  });
966
1011
  }
1012
+ spawnHillclimb("git-traces --tool=opencode", {
1013
+ session_id: sessionID,
1014
+ cwd,
1015
+ hook_event_name: "session.deleted",
1016
+ tool: TOOL,
1017
+ });
967
1018
  sessionMessages.delete(sessionID);
968
1019
  return;
969
1020
  }
970
1021
 
971
1022
  if (type === "server.instance.disposed") {
972
1023
  // opencode is shutting down \u2014 flush every buffered session's transcript
973
- // so nothing gets lost. Then clean up git-traces refs/state. No
974
- // sessionID on this event, but git-traces state is keyed by
975
- // (repoRoot, tool) so cwd alone is enough for handleSessionEnd.
1024
+ // so nothing gets lost. Then clean up git-traces refs/state per session.
976
1025
  for (const sid of sessionMessages.keys()) {
977
1026
  const transcriptPath = writeTranscript(sid);
978
1027
  if (transcriptPath) {
979
1028
  spawnHillclimb("upload", {
980
1029
  session_id: sid,
981
- cwd: directory,
1030
+ cwd,
982
1031
  transcript_path: transcriptPath,
983
1032
  hook_event_name: "server.instance.disposed",
984
1033
  tool: TOOL,
985
1034
  });
986
1035
  }
1036
+ spawnHillclimb("git-traces --tool=opencode", {
1037
+ session_id: sid,
1038
+ cwd,
1039
+ hook_event_name: "server.instance.disposed",
1040
+ tool: TOOL,
1041
+ });
987
1042
  }
988
1043
  sessionMessages.clear();
1044
+ // Keep a no-session cleanup for pre-session-scoped legacy state.
989
1045
  spawnHillclimb("git-traces --tool=opencode", {
990
- cwd: directory,
1046
+ cwd,
991
1047
  hook_event_name: "server.instance.disposed",
992
1048
  tool: TOOL,
993
1049
  });
@@ -998,16 +1054,16 @@ export const HillclimbPlugin = async ({ directory }) => ({
998
1054
 
999
1055
  export default HillclimbPlugin;
1000
1056
  `;
1001
- async function opencodeInstall(file) {
1057
+ async function opencodeInstall(file, content = OPENCODE_PLUGIN_CONTENT) {
1002
1058
  try {
1003
1059
  const existing = await fs5.promises.readFile(file, "utf-8");
1004
- if (existing === OPENCODE_PLUGIN_CONTENT) {
1060
+ if (existing === content) {
1005
1061
  return { installed: 0, alreadyPresent: 1 };
1006
1062
  }
1007
1063
  } catch {
1008
1064
  }
1009
1065
  await fs5.promises.mkdir(path6.dirname(file), { recursive: true });
1010
- await fs5.promises.writeFile(file, OPENCODE_PLUGIN_CONTENT);
1066
+ await fs5.promises.writeFile(file, content);
1011
1067
  return { installed: 1, alreadyPresent: 0 };
1012
1068
  }
1013
1069
  async function opencodeCheck(file) {
@@ -1038,14 +1094,24 @@ function check(settings, format, eventName, command, options = {}) {
1038
1094
  return claudeCheck(settings, eventName, command, options);
1039
1095
  }
1040
1096
  }
1041
- function uninstall(settings, format, eventName, command) {
1097
+ function commandsForEvent(settings, format, eventName) {
1098
+ switch (format) {
1099
+ case "cursor":
1100
+ return (settings.hooks?.[eventName] ?? []).map((e) => e.command).filter((command) => typeof command === "string");
1101
+ case "copilot":
1102
+ return (settings.hooks?.[eventName] ?? []).filter((e) => e.type === "command").map((e) => e.command).filter((command) => typeof command === "string");
1103
+ default:
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) {
1042
1108
  switch (format) {
1043
1109
  case "cursor":
1044
- return cursorUninstall(settings, eventName, command);
1110
+ return cursorUninstallMatching(settings, eventName, predicate);
1045
1111
  case "copilot":
1046
- return copilotUninstall(settings, eventName, command);
1112
+ return copilotUninstallMatching(settings, eventName, predicate);
1047
1113
  default:
1048
- return claudeUninstall(settings, eventName, command);
1114
+ return claudeUninstallMatching(settings, eventName, predicate);
1049
1115
  }
1050
1116
  }
1051
1117
  function legacyCommandsFor(command) {
@@ -1066,21 +1132,38 @@ function legacyCommandsFor(command) {
1066
1132
  }
1067
1133
  return legacy;
1068
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
+ }
1069
1149
  async function installHooksForTool(repoRoot, def) {
1070
1150
  const file = settingsPath(repoRoot, def);
1071
1151
  if (def.format === "opencode") {
1072
1152
  const r = await opencodeInstall(file);
1073
- return { settingsFile: file, ...r };
1153
+ return { settingsFile: file, ...r, changed: r.installed > 0 };
1074
1154
  }
1075
1155
  const settings = await readJson(file);
1076
1156
  let installed = 0;
1077
1157
  let alreadyPresent = 0;
1078
1158
  let mutated = false;
1079
1159
  for (const evt of def.events) {
1080
- for (const legacy of legacyCommandsFor(evt.command)) {
1081
- if (uninstall(settings, def.format, evt.eventName, legacy)) {
1082
- mutated = true;
1083
- }
1160
+ if (uninstallMatching(
1161
+ settings,
1162
+ def.format,
1163
+ evt.eventName,
1164
+ (command) => command !== evt.command && isHillclimbOwnedCommandFor(command, evt.command)
1165
+ )) {
1166
+ mutated = true;
1084
1167
  }
1085
1168
  const added = install(settings, def.format, evt.eventName, evt.command, {
1086
1169
  timeout: evt.timeout
@@ -1093,7 +1176,7 @@ async function installHooksForTool(repoRoot, def) {
1093
1176
  }
1094
1177
  }
1095
1178
  if (mutated) await writeJson(file, settings);
1096
- return { settingsFile: file, installed, alreadyPresent };
1179
+ return { settingsFile: file, installed, alreadyPresent, changed: mutated };
1097
1180
  }
1098
1181
  async function checkHooksForTool(repoRoot, def) {
1099
1182
  const file = settingsPath(repoRoot, def);
@@ -1116,6 +1199,27 @@ async function checkHooksForTool(repoRoot, def) {
1116
1199
  }
1117
1200
  return { allInstalled: missing.length === 0, settingsFile: file, missing };
1118
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
+ }
1119
1223
  function detectTools(repoRoot) {
1120
1224
  return TOOLS.filter((def) => def.detect(repoRoot));
1121
1225
  }
@@ -1133,6 +1237,29 @@ async function installDetectedHooks(repoRoot) {
1133
1237
  }
1134
1238
  return results;
1135
1239
  }
1240
+ async function healHookForTool(repoRoot, tool) {
1241
+ const def = TOOLS.find((candidate) => candidate.tool === tool);
1242
+ if (!def) {
1243
+ return {
1244
+ tool,
1245
+ changed: false,
1246
+ alreadyCurrent: false,
1247
+ installed: 0,
1248
+ alreadyPresent: 0,
1249
+ skipped: "unsupported-tool"
1250
+ };
1251
+ }
1252
+ const r = await installHooksForTool(repoRoot, def);
1253
+ return {
1254
+ tool: def.tool,
1255
+ label: def.label,
1256
+ settingsFile: r.settingsFile,
1257
+ changed: r.changed,
1258
+ alreadyCurrent: !r.changed,
1259
+ installed: r.installed,
1260
+ alreadyPresent: r.alreadyPresent
1261
+ };
1262
+ }
1136
1263
  async function checkAllHooks(repoRoot) {
1137
1264
  const results = [];
1138
1265
  for (const def of TOOLS) {
@@ -11240,6 +11367,59 @@ function parseOutputBlob(raw) {
11240
11367
  }
11241
11368
  return [String(parsed), void 0];
11242
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
+ }
11243
11423
  function convertCodexToTrajectory(jsonlContent, sessionId) {
11244
11424
  const rawEvents = [];
11245
11425
  for (const line of jsonlContent.split("\n")) {
@@ -11253,7 +11433,7 @@ function convertCodexToTrajectory(jsonlContent, sessionId) {
11253
11433
  if (rawEvents.length === 0) return null;
11254
11434
  const sessionMeta = rawEvents.find((e) => e.type === "session_meta");
11255
11435
  const metaPayload = sessionMeta?.payload ?? {};
11256
- const sid = sessionId ?? metaPayload.id ?? "";
11436
+ const sid = sessionId ?? (typeof metaPayload.id === "string" ? metaPayload.id : "");
11257
11437
  const agentVersion = metaPayload.cli_version ?? "unknown";
11258
11438
  const agentExtra = {};
11259
11439
  for (const key of ["originator", "cwd", "git", "instructions"]) {
@@ -11370,6 +11550,13 @@ function convertCodexToTrajectory(jsonlContent, sessionId) {
11370
11550
  callInfo.output = outputText;
11371
11551
  callInfo.metadata = metadata;
11372
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
+ }
11373
11560
  normalizedEvents.push(callInfo);
11374
11561
  pendingReasoning = void 0;
11375
11562
  }
@@ -11425,7 +11612,8 @@ function convertCodexToTrajectory(jsonlContent, sessionId) {
11425
11612
  extra: Object.keys(agentExtra).length > 0 ? agentExtra : void 0
11426
11613
  },
11427
11614
  steps,
11428
- final_metrics: finalMetrics
11615
+ final_metrics: finalMetrics,
11616
+ extra: codexTrajectoryExtra(metaPayload)
11429
11617
  };
11430
11618
  }
11431
11619
  function convertEventToStep2(event, stepId, defaultModelName) {
@@ -11460,7 +11648,8 @@ function convertEventToStep2(event, stepId, defaultModelName) {
11460
11648
  if (event.output !== void 0) {
11461
11649
  const result = {
11462
11650
  source_call_id: callId || void 0,
11463
- content: event.output
11651
+ content: event.output,
11652
+ subagent_trajectory_ref: event.subagentRefs
11464
11653
  };
11465
11654
  observation = { results: [result] };
11466
11655
  }
@@ -11488,6 +11677,288 @@ function convertEventToStep2(event, stepId, defaultModelName) {
11488
11677
  return null;
11489
11678
  }
11490
11679
 
11680
+ // src/normalizer/types.ts
11681
+ var ATIF_VERSION = "ATIF-v1.6";
11682
+ function excludeNone(obj) {
11683
+ const result = {};
11684
+ for (const [key, value] of Object.entries(obj)) {
11685
+ if (value === void 0 || value === null) continue;
11686
+ if (Array.isArray(value)) {
11687
+ result[key] = value.map(
11688
+ (item) => typeof item === "object" && item !== null && !Array.isArray(item) ? excludeNone(item) : item
11689
+ );
11690
+ } else if (typeof value === "object" && !Array.isArray(value)) {
11691
+ result[key] = excludeNone(value);
11692
+ } else {
11693
+ result[key] = value;
11694
+ }
11695
+ }
11696
+ return result;
11697
+ }
11698
+
11699
+ // src/normalizer/copilotChat.ts
11700
+ function asObject2(value) {
11701
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
11702
+ }
11703
+ function asNumber(value) {
11704
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
11705
+ }
11706
+ function compactExtra2(extra) {
11707
+ const result = {};
11708
+ for (const [key, value] of Object.entries(extra)) {
11709
+ if (value !== void 0 && value !== null) result[key] = value;
11710
+ }
11711
+ return Object.keys(result).length > 0 ? result : void 0;
11712
+ }
11713
+ function parseJsonLines(content) {
11714
+ const entries = [];
11715
+ for (const line of content.split("\n")) {
11716
+ const trimmed = line.trim();
11717
+ if (!trimmed) continue;
11718
+ try {
11719
+ const parsed = asObject2(JSON.parse(trimmed));
11720
+ if (!parsed) continue;
11721
+ entries.push({
11722
+ type: typeof parsed.type === "string" ? parsed.type : void 0,
11723
+ data: asObject2(parsed.data),
11724
+ id: typeof parsed.id === "string" ? parsed.id : void 0,
11725
+ timestamp: typeof parsed.timestamp === "string" ? parsed.timestamp : void 0
11726
+ });
11727
+ } catch {
11728
+ }
11729
+ }
11730
+ return entries;
11731
+ }
11732
+ function parseArguments(value) {
11733
+ const obj = asObject2(value);
11734
+ if (obj) return obj;
11735
+ if (typeof value === "string") {
11736
+ try {
11737
+ const parsed = asObject2(JSON.parse(value));
11738
+ if (parsed) return parsed;
11739
+ } catch {
11740
+ return value ? { input: value } : {};
11741
+ }
11742
+ }
11743
+ return value === void 0 || value === null ? {} : { value };
11744
+ }
11745
+ function buildMetrics2(data) {
11746
+ const inputTokens = asNumber(data.inputTokens) ?? 0;
11747
+ const outputTokens = asNumber(data.outputTokens) ?? 0;
11748
+ const cacheReadTokens = asNumber(data.cacheReadTokens) ?? 0;
11749
+ const cacheWriteTokens = asNumber(data.cacheWriteTokens) ?? 0;
11750
+ const cost = asNumber(data.cost);
11751
+ if (!inputTokens && !outputTokens && !cacheReadTokens && !cacheWriteTokens && !cost) {
11752
+ return void 0;
11753
+ }
11754
+ return {
11755
+ prompt_tokens: inputTokens + cacheReadTokens || void 0,
11756
+ completion_tokens: outputTokens || void 0,
11757
+ cached_tokens: cacheReadTokens || void 0,
11758
+ cost_usd: cost || void 0,
11759
+ extra: compactExtra2({
11760
+ cache_write_tokens: cacheWriteTokens || void 0,
11761
+ duration_ms: data.duration,
11762
+ initiator: data.initiator,
11763
+ api_call_id: data.apiCallId,
11764
+ provider_call_id: data.providerCallId,
11765
+ parent_tool_call_id: data.parentToolCallId,
11766
+ quota_snapshots: data.quotaSnapshots,
11767
+ copilot_usage: data.copilotUsage
11768
+ })
11769
+ };
11770
+ }
11771
+ function finalMetricsFromSteps(steps) {
11772
+ let prompt = 0;
11773
+ let completion = 0;
11774
+ let cached = 0;
11775
+ let cost = 0;
11776
+ for (const step of steps) {
11777
+ prompt += step.metrics?.prompt_tokens ?? 0;
11778
+ completion += step.metrics?.completion_tokens ?? 0;
11779
+ cached += step.metrics?.cached_tokens ?? 0;
11780
+ cost += step.metrics?.cost_usd ?? 0;
11781
+ }
11782
+ return {
11783
+ total_prompt_tokens: prompt || void 0,
11784
+ total_completion_tokens: completion || void 0,
11785
+ total_cached_tokens: cached || void 0,
11786
+ total_cost_usd: cost || void 0,
11787
+ total_steps: steps.length
11788
+ };
11789
+ }
11790
+ function makeToolCall(request) {
11791
+ const req = asObject2(request);
11792
+ if (!req) return null;
11793
+ const callId = typeof req.toolCallId === "string" && req.toolCallId || typeof req.id === "string" && req.id || "";
11794
+ const name = typeof req.name === "string" && req.name || typeof req.toolName === "string" && req.toolName || "tool";
11795
+ return {
11796
+ tool_call_id: callId,
11797
+ function_name: name,
11798
+ arguments: parseArguments(req.arguments)
11799
+ };
11800
+ }
11801
+ function toolCallFromExecutionStart(data) {
11802
+ const callId = typeof data.toolCallId === "string" && data.toolCallId || typeof data.id === "string" && data.id || "";
11803
+ const name = typeof data.toolName === "string" && data.toolName || typeof data.name === "string" && data.name || "tool";
11804
+ return {
11805
+ tool_call_id: callId,
11806
+ function_name: name,
11807
+ arguments: parseArguments(data.arguments)
11808
+ };
11809
+ }
11810
+ function contentFromToolResult(data) {
11811
+ const result = asObject2(data.result);
11812
+ if (typeof result?.content === "string") return result.content;
11813
+ if (typeof data.content === "string") return data.content;
11814
+ return void 0;
11815
+ }
11816
+ function convertCopilotChatToTrajectory(jsonlContent, sessionId) {
11817
+ const entries = parseJsonLines(jsonlContent);
11818
+ if (entries.length === 0) return null;
11819
+ let sid = sessionId ?? "unknown";
11820
+ let copilotVersion = "unknown";
11821
+ let vscodeVersion;
11822
+ let cwd;
11823
+ let defaultModelName;
11824
+ const steps = [];
11825
+ const pendingReasoning = [];
11826
+ const pendingToolSteps = /* @__PURE__ */ new Map();
11827
+ let lastAgentStep;
11828
+ for (const entry of entries) {
11829
+ const data = entry.data ?? {};
11830
+ if (entry.type === "session.start") {
11831
+ if (!sessionId && typeof data.sessionId === "string")
11832
+ sid = data.sessionId;
11833
+ if (typeof data.copilotVersion === "string")
11834
+ copilotVersion = data.copilotVersion;
11835
+ if (typeof data.vscodeVersion === "string")
11836
+ vscodeVersion = data.vscodeVersion;
11837
+ const context = asObject2(data.context);
11838
+ if (typeof context?.cwd === "string") cwd = context.cwd;
11839
+ continue;
11840
+ }
11841
+ if (entry.type === "user.message" || entry.type === "system.message") {
11842
+ const content = typeof data.content === "string" && data.content || typeof data.transformedContent === "string" && data.transformedContent || "";
11843
+ if (!content.trim()) continue;
11844
+ const source = entry.type === "system.message" ? "system" : "user";
11845
+ const step = {
11846
+ step_id: steps.length + 1,
11847
+ timestamp: entry.timestamp,
11848
+ source,
11849
+ message: content,
11850
+ extra: compactExtra2({
11851
+ attachments: data.attachments,
11852
+ source: data.source,
11853
+ agent_mode: data.agentMode,
11854
+ interaction_id: data.interactionId
11855
+ })
11856
+ };
11857
+ steps.push(step);
11858
+ continue;
11859
+ }
11860
+ if (entry.type === "assistant.reasoning") {
11861
+ if (typeof data.content === "string" && data.content.trim()) {
11862
+ pendingReasoning.push(data.content.trim());
11863
+ }
11864
+ continue;
11865
+ }
11866
+ if (entry.type === "assistant.message") {
11867
+ const toolCalls = (Array.isArray(data.toolRequests) ? data.toolRequests : []).flatMap((request) => {
11868
+ const call = makeToolCall(request);
11869
+ return call ? [call] : [];
11870
+ });
11871
+ const content = typeof data.content === "string" && data.content.trim() ? data.content.trim() : "";
11872
+ const reasoning = typeof data.reasoningText === "string" && data.reasoningText.trim() || pendingReasoning.join("\n\n") || void 0;
11873
+ pendingReasoning.length = 0;
11874
+ const step = {
11875
+ step_id: steps.length + 1,
11876
+ timestamp: entry.timestamp,
11877
+ source: "agent",
11878
+ message: content || (toolCalls.length > 0 ? "(tool use)" : ""),
11879
+ model_name: defaultModelName,
11880
+ extra: compactExtra2({
11881
+ message_id: data.messageId,
11882
+ phase: data.phase,
11883
+ output_tokens: data.outputTokens
11884
+ })
11885
+ };
11886
+ if (reasoning) step.reasoning_content = reasoning;
11887
+ if (toolCalls.length > 0) step.tool_calls = toolCalls;
11888
+ if (step.message || step.tool_calls?.length || step.reasoning_content) {
11889
+ steps.push(step);
11890
+ lastAgentStep = step;
11891
+ for (const call of toolCalls) {
11892
+ if (call.tool_call_id) pendingToolSteps.set(call.tool_call_id, step);
11893
+ }
11894
+ }
11895
+ continue;
11896
+ }
11897
+ if (entry.type === "tool.execution_start") {
11898
+ const toolCall = toolCallFromExecutionStart(data);
11899
+ const step = {
11900
+ step_id: steps.length + 1,
11901
+ timestamp: entry.timestamp,
11902
+ source: "agent",
11903
+ message: `Executed ${toolCall.function_name} ${toolCall.tool_call_id}`.trim(),
11904
+ model_name: defaultModelName,
11905
+ tool_calls: [toolCall]
11906
+ };
11907
+ steps.push(step);
11908
+ lastAgentStep = step;
11909
+ if (toolCall.tool_call_id)
11910
+ pendingToolSteps.set(toolCall.tool_call_id, step);
11911
+ continue;
11912
+ }
11913
+ if (entry.type === "tool.execution_complete") {
11914
+ const callId = typeof data.toolCallId === "string" && data.toolCallId || typeof data.id === "string" && data.id || "";
11915
+ const target = pendingToolSteps.get(callId);
11916
+ const content = contentFromToolResult(data);
11917
+ if (target && content !== void 0) {
11918
+ const observation = target.observation ?? { results: [] };
11919
+ observation.results.push({
11920
+ source_call_id: callId || void 0,
11921
+ content
11922
+ });
11923
+ target.observation = observation;
11924
+ const extra = { ...target.extra ?? {} };
11925
+ extra.tool_success = data.success;
11926
+ target.extra = compactExtra2(extra);
11927
+ }
11928
+ if (callId) pendingToolSteps.delete(callId);
11929
+ continue;
11930
+ }
11931
+ if (entry.type === "assistant.usage") {
11932
+ if (typeof data.model === "string" && !defaultModelName) {
11933
+ defaultModelName = data.model;
11934
+ if (lastAgentStep && !lastAgentStep.model_name) {
11935
+ lastAgentStep.model_name = defaultModelName;
11936
+ }
11937
+ }
11938
+ const metrics = buildMetrics2(data);
11939
+ if (metrics && lastAgentStep && !lastAgentStep.metrics) {
11940
+ lastAgentStep.metrics = metrics;
11941
+ }
11942
+ }
11943
+ }
11944
+ if (steps.length === 0) return null;
11945
+ return {
11946
+ schema_version: ATIF_VERSION,
11947
+ session_id: sid,
11948
+ agent: {
11949
+ name: "github-copilot-chat",
11950
+ version: copilotVersion,
11951
+ model_name: defaultModelName,
11952
+ extra: compactExtra2({
11953
+ vscode_version: vscodeVersion,
11954
+ cwd
11955
+ })
11956
+ },
11957
+ steps,
11958
+ final_metrics: finalMetricsFromSteps(steps)
11959
+ };
11960
+ }
11961
+
11491
11962
  // src/normalizer/cursor.ts
11492
11963
  function convertCursorToTrajectory(jsonlContent, sessionId) {
11493
11964
  const lines = [];
@@ -11545,80 +12016,566 @@ function convertCursorToTrajectory(jsonlContent, sessionId) {
11545
12016
  };
11546
12017
  }
11547
12018
 
11548
- // src/normalizer/types.ts
11549
- function excludeNone(obj) {
12019
+ // src/normalizer/opencode.ts
12020
+ function asObject3(value) {
12021
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
12022
+ }
12023
+ function asArray(value) {
12024
+ return Array.isArray(value) ? value : [];
12025
+ }
12026
+ function asNumber2(value) {
12027
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
12028
+ }
12029
+ function compactExtra3(extra) {
11550
12030
  const result = {};
11551
- for (const [key, value] of Object.entries(obj)) {
11552
- if (value === void 0 || value === null) continue;
11553
- if (Array.isArray(value)) {
11554
- result[key] = value.map(
11555
- (item) => typeof item === "object" && item !== null && !Array.isArray(item) ? excludeNone(item) : item
11556
- );
11557
- } else if (typeof value === "object" && !Array.isArray(value)) {
11558
- result[key] = excludeNone(value);
11559
- } else {
11560
- result[key] = value;
12031
+ for (const [key, value] of Object.entries(extra)) {
12032
+ if (value !== void 0 && value !== null) result[key] = value;
12033
+ }
12034
+ return Object.keys(result).length > 0 ? result : void 0;
12035
+ }
12036
+ function parseJsonLines2(content) {
12037
+ const events = [];
12038
+ for (const line of content.split("\n")) {
12039
+ const trimmed = line.trim();
12040
+ if (!trimmed) continue;
12041
+ try {
12042
+ const parsed = JSON.parse(trimmed);
12043
+ const obj = asObject3(parsed);
12044
+ if (obj) events.push(obj);
12045
+ } catch {
11561
12046
  }
11562
12047
  }
11563
- return result;
12048
+ return events;
11564
12049
  }
11565
-
11566
- // src/normalizer/index.ts
11567
- function normalizeContent(sourceName, content, sessionId) {
11568
- switch (sourceName) {
11569
- case "claude":
11570
- return convertClaudeToTrajectory(content, sessionId);
11571
- case "codex":
11572
- return convertCodexToTrajectory(content, sessionId);
11573
- case "cursor":
11574
- return convertCursorToTrajectory(content, sessionId);
11575
- default:
11576
- return null;
12050
+ function timestampToIso(value) {
12051
+ const numeric = asNumber2(value);
12052
+ if (numeric === void 0) return void 0;
12053
+ const millis = numeric < 1e12 ? numeric * 1e3 : numeric;
12054
+ const date = new Date(millis);
12055
+ return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
12056
+ }
12057
+ function timeFromObject(value) {
12058
+ const obj = asObject3(value);
12059
+ if (!obj) return void 0;
12060
+ return timestampToIso(obj.start ?? obj.created ?? obj.completed ?? obj.end);
12061
+ }
12062
+ function stringify2(value) {
12063
+ if (typeof value === "string") return value;
12064
+ try {
12065
+ return JSON.stringify(value);
12066
+ } catch {
12067
+ return String(value);
11577
12068
  }
11578
12069
  }
11579
- var NormalizeMiddleware = class {
11580
- name = "normalize";
11581
- async process(group) {
11582
- const newFiles = [];
11583
- for (const file of group.files) {
11584
- newFiles.push(file);
11585
- if (!file.absolutePath.endsWith(".jsonl")) continue;
11586
- if (!["claude", "codex", "cursor"].includes(file.sourceName)) continue;
11587
- const content = file.content ? file.content.toString("utf-8") : null;
11588
- if (!content) continue;
11589
- const sessionId = file.metadata?.sessionId ?? path9.basename(file.absolutePath, ".jsonl");
11590
- try {
11591
- const trajectory = normalizeContent(
11592
- file.sourceName,
11593
- content,
11594
- sessionId
11595
- );
11596
- if (!trajectory) continue;
11597
- const json = JSON.stringify(
11598
- excludeNone(trajectory),
11599
- null,
11600
- 2
11601
- );
11602
- const atifPath = file.absolutePath.replace(/\.jsonl$/, ".atif.json");
11603
- newFiles.push({
11604
- sourceName: file.sourceName,
11605
- absolutePath: atifPath,
11606
- repoPath: file.repoPath,
11607
- metadata: { ...file.metadata, isAtif: true },
11608
- content: Buffer.from(json, "utf-8")
11609
- });
11610
- } catch {
11611
- }
11612
- }
11613
- return { ...group, files: newFiles };
12070
+ function argsFromUnknown(value) {
12071
+ const obj = asObject3(value);
12072
+ if (obj) return obj;
12073
+ if (typeof value === "string") {
12074
+ try {
12075
+ const parsed = JSON.parse(value);
12076
+ const parsedObj = asObject3(parsed);
12077
+ if (parsedObj) return parsedObj;
12078
+ } catch {
12079
+ return value ? { input: value } : {};
12080
+ }
12081
+ }
12082
+ return value === void 0 || value === null ? {} : { value };
12083
+ }
12084
+ function modelNameFromInfo(info) {
12085
+ const model = asObject3(info.model);
12086
+ const modelID = typeof info.modelID === "string" && info.modelID || typeof model?.modelID === "string" && model.modelID || void 0;
12087
+ const providerID = typeof info.providerID === "string" && info.providerID || typeof model?.providerID === "string" && model.providerID || void 0;
12088
+ if (providerID && modelID) return `${providerID}/${modelID}`;
12089
+ return modelID;
12090
+ }
12091
+ function metricsFromTokens(tokens, cost) {
12092
+ const t = asObject3(tokens);
12093
+ if (!t) return void 0;
12094
+ const cache = asObject3(t.cache);
12095
+ const input = asNumber2(t.input) ?? 0;
12096
+ const output = asNumber2(t.output) ?? 0;
12097
+ const reasoning = asNumber2(t.reasoning) ?? 0;
12098
+ const cacheRead = asNumber2(cache?.read) ?? 0;
12099
+ const cacheWrite = asNumber2(cache?.write) ?? 0;
12100
+ const costUsd = asNumber2(cost);
12101
+ if (!input && !output && !cacheRead && !cacheWrite && !costUsd) {
12102
+ return void 0;
11614
12103
  }
11615
- };
11616
-
11617
- // src/outputs/platform.ts
11618
- import { PassThrough } from "stream";
11619
- import archiver from "archiver";
11620
-
11621
- // src/outputs/archive.ts
12104
+ const extra = compactExtra3({
12105
+ reasoning_tokens: reasoning || void 0,
12106
+ cache_write_tokens: cacheWrite || void 0
12107
+ });
12108
+ return {
12109
+ prompt_tokens: input + cacheRead || void 0,
12110
+ completion_tokens: output || void 0,
12111
+ cached_tokens: cacheRead || void 0,
12112
+ cost_usd: costUsd || void 0,
12113
+ extra
12114
+ };
12115
+ }
12116
+ function metricsFromInfo(info) {
12117
+ return metricsFromTokens(info.tokens, info.cost);
12118
+ }
12119
+ function addFinalMetricTotals(totals, metrics) {
12120
+ if (!metrics) return;
12121
+ totals.total_prompt_tokens += metrics.prompt_tokens ?? 0;
12122
+ totals.total_completion_tokens += metrics.completion_tokens ?? 0;
12123
+ totals.total_cached_tokens += metrics.cached_tokens ?? 0;
12124
+ totals.total_cost_usd += metrics.cost_usd ?? 0;
12125
+ }
12126
+ function finalMetricsFromSteps2(steps) {
12127
+ const totals = {
12128
+ total_prompt_tokens: 0,
12129
+ total_completion_tokens: 0,
12130
+ total_cached_tokens: 0,
12131
+ total_cost_usd: 0,
12132
+ total_steps: steps.length,
12133
+ extra: {}
12134
+ };
12135
+ for (const step of steps) addFinalMetricTotals(totals, step.metrics);
12136
+ return {
12137
+ total_prompt_tokens: totals.total_prompt_tokens || void 0,
12138
+ total_completion_tokens: totals.total_completion_tokens || void 0,
12139
+ total_cached_tokens: totals.total_cached_tokens || void 0,
12140
+ total_cost_usd: totals.total_cost_usd || void 0,
12141
+ total_steps: steps.length
12142
+ };
12143
+ }
12144
+ function entryFromLine(line) {
12145
+ const info = asObject3(line.info);
12146
+ if (info) {
12147
+ return {
12148
+ info,
12149
+ parts: asArray(line.parts).flatMap((part) => {
12150
+ const obj = asObject3(part);
12151
+ return obj ? [obj] : [];
12152
+ })
12153
+ };
12154
+ }
12155
+ if (typeof line.role === "string" || typeof line.sessionID === "string") {
12156
+ return {
12157
+ info: line,
12158
+ parts: asArray(line.parts).flatMap((part) => {
12159
+ const obj = asObject3(part);
12160
+ return obj ? [obj] : [];
12161
+ })
12162
+ };
12163
+ }
12164
+ return null;
12165
+ }
12166
+ function entriesFromEventWrappers(lines) {
12167
+ const byMessage = /* @__PURE__ */ new Map();
12168
+ function getEntry(messageID, sessionID) {
12169
+ let entry = byMessage.get(messageID);
12170
+ if (!entry) {
12171
+ entry = { info: { id: messageID, sessionID }, parts: [] };
12172
+ byMessage.set(messageID, entry);
12173
+ }
12174
+ return entry;
12175
+ }
12176
+ for (const line of lines) {
12177
+ const type = line.type;
12178
+ const props = asObject3(line.properties) ?? line;
12179
+ if (type === "message.updated") {
12180
+ const info = asObject3(props.info);
12181
+ const id = typeof info?.id === "string" ? info.id : void 0;
12182
+ if (!info || !id) continue;
12183
+ const entry = getEntry(id, info.sessionID);
12184
+ entry.info = info;
12185
+ continue;
12186
+ }
12187
+ if (type === "message.part.updated") {
12188
+ const part = asObject3(props.part);
12189
+ const messageID = typeof part?.messageID === "string" && part.messageID || void 0;
12190
+ if (!part || !messageID) continue;
12191
+ const entry = getEntry(messageID, part.sessionID);
12192
+ const partID = typeof part.id === "string" ? part.id : void 0;
12193
+ const existingIndex = partID ? entry.parts.findIndex((p7) => p7.id === partID) : -1;
12194
+ if (existingIndex >= 0) entry.parts[existingIndex] = part;
12195
+ else entry.parts.push(part);
12196
+ }
12197
+ }
12198
+ return [...byMessage.values()];
12199
+ }
12200
+ function getSessionId(sessionId, exportInfo, entries) {
12201
+ if (sessionId) return sessionId;
12202
+ if (typeof exportInfo?.id === "string") return exportInfo.id;
12203
+ for (const entry of entries) {
12204
+ if (typeof entry.info.sessionID === "string") return entry.info.sessionID;
12205
+ }
12206
+ return "unknown";
12207
+ }
12208
+ function getAgentVersion(exportInfo) {
12209
+ return typeof exportInfo?.version === "string" && exportInfo.version || "unknown";
12210
+ }
12211
+ function sortEntries(entries) {
12212
+ return [...entries].sort((a, b) => {
12213
+ const at = asNumber2(asObject3(a.info.time)?.created) ?? 0;
12214
+ const bt = asNumber2(asObject3(b.info.time)?.created) ?? 0;
12215
+ return at - bt;
12216
+ });
12217
+ }
12218
+ function splitAssistantParts(parts) {
12219
+ let sawBoundary = false;
12220
+ const groups = [];
12221
+ let current = [];
12222
+ for (const part of parts) {
12223
+ const type = part.type;
12224
+ if (type === "step-start") {
12225
+ sawBoundary = true;
12226
+ if (current.length > 0) groups.push(current);
12227
+ current = [part];
12228
+ continue;
12229
+ }
12230
+ current.push(part);
12231
+ if (type === "step-finish") {
12232
+ sawBoundary = true;
12233
+ groups.push(current);
12234
+ current = [];
12235
+ }
12236
+ }
12237
+ if (current.length > 0) groups.push(current);
12238
+ return sawBoundary ? groups : [parts];
12239
+ }
12240
+ function textFromFilePart(part) {
12241
+ const filename = typeof part.filename === "string" ? part.filename : void 0;
12242
+ const url = typeof part.url === "string" ? part.url : void 0;
12243
+ const mime = typeof part.mime === "string" ? part.mime : void 0;
12244
+ const label = filename ?? url;
12245
+ if (!label) return void 0;
12246
+ return mime ? `[file:${mime}] ${label}` : `[file] ${label}`;
12247
+ }
12248
+ function buildUserStep(entry, stepId, defaultModelName) {
12249
+ const textParts = [];
12250
+ const extra = {};
12251
+ for (const part of entry.parts) {
12252
+ if (part.type === "text" && typeof part.text === "string") {
12253
+ if (part.text.trim()) textParts.push(part.text.trim());
12254
+ continue;
12255
+ }
12256
+ if (part.type === "file") {
12257
+ const fileText = textFromFilePart(part);
12258
+ if (fileText) textParts.push(fileText);
12259
+ }
12260
+ }
12261
+ const message = textParts.join("\n\n").trim();
12262
+ if (!message) return null;
12263
+ if (entry.info.agent) extra.agent = entry.info.agent;
12264
+ if (entry.info.tools) extra.tools = entry.info.tools;
12265
+ return {
12266
+ step_id: stepId,
12267
+ timestamp: timeFromObject(entry.info.time),
12268
+ source: "user",
12269
+ message,
12270
+ model_name: defaultModelName,
12271
+ extra: compactExtra3(extra)
12272
+ };
12273
+ }
12274
+ function buildAgentStep(parts, info, stepId, defaultModelName, fallbackMetrics) {
12275
+ const textParts = [];
12276
+ const reasoningParts = [];
12277
+ const toolCalls = [];
12278
+ const observationResults = [];
12279
+ const extra = {};
12280
+ let metrics;
12281
+ let timestamp = timeFromObject(info.time);
12282
+ for (const part of parts) {
12283
+ if (!timestamp) timestamp = timeFromObject(part.time);
12284
+ switch (part.type) {
12285
+ case "text":
12286
+ if (typeof part.text === "string" && part.text.trim()) {
12287
+ textParts.push(part.text.trim());
12288
+ }
12289
+ break;
12290
+ case "reasoning":
12291
+ if (typeof part.text === "string" && part.text.trim()) {
12292
+ reasoningParts.push(part.text.trim());
12293
+ }
12294
+ break;
12295
+ case "file": {
12296
+ const fileText = textFromFilePart(part);
12297
+ if (fileText) textParts.push(fileText);
12298
+ break;
12299
+ }
12300
+ case "tool": {
12301
+ const state = asObject3(part.state) ?? {};
12302
+ const callID = typeof part.callID === "string" && part.callID || typeof part.id === "string" && part.id || "";
12303
+ const toolName = typeof part.tool === "string" && part.tool || "tool";
12304
+ const input = argsFromUnknown(state.input);
12305
+ toolCalls.push({
12306
+ tool_call_id: callID,
12307
+ function_name: toolName,
12308
+ arguments: input
12309
+ });
12310
+ const output = state.output ?? state.error;
12311
+ if (output !== void 0 && output !== null) {
12312
+ observationResults.push({
12313
+ source_call_id: callID || void 0,
12314
+ content: stringify2(output)
12315
+ });
12316
+ }
12317
+ if (state.status) extra.status = state.status;
12318
+ if (state.metadata) extra.tool_metadata = state.metadata;
12319
+ if (part.metadata) extra.part_metadata = part.metadata;
12320
+ break;
12321
+ }
12322
+ case "step-finish":
12323
+ metrics = metricsFromTokens(part.tokens, part.cost) ?? metrics;
12324
+ if (part.reason) extra.finish_reason = part.reason;
12325
+ if (part.snapshot) extra.snapshot = part.snapshot;
12326
+ break;
12327
+ case "patch":
12328
+ extra.patches = [...asArray(extra.patches), part];
12329
+ break;
12330
+ case "agent":
12331
+ if (part.name) extra.agent = part.name;
12332
+ break;
12333
+ case "retry":
12334
+ extra.retry = part;
12335
+ break;
12336
+ }
12337
+ }
12338
+ metrics = metrics ?? fallbackMetrics;
12339
+ if (textParts.length === 0 && reasoningParts.length === 0 && toolCalls.length === 0 && !metrics) {
12340
+ return null;
12341
+ }
12342
+ const observation = observationResults.length > 0 ? { results: observationResults } : void 0;
12343
+ const step = {
12344
+ step_id: stepId,
12345
+ timestamp,
12346
+ source: "agent",
12347
+ message: textParts.length > 0 ? textParts.join("\n\n") : "(tool use)",
12348
+ model_name: modelNameFromInfo(info) ?? defaultModelName
12349
+ };
12350
+ if (reasoningParts.length > 0)
12351
+ step.reasoning_content = reasoningParts.join("\n\n");
12352
+ if (toolCalls.length > 0) step.tool_calls = toolCalls;
12353
+ if (observation) step.observation = observation;
12354
+ if (metrics) step.metrics = metrics;
12355
+ const compactedExtra = compactExtra3(extra);
12356
+ if (compactedExtra) step.extra = compactedExtra;
12357
+ return step;
12358
+ }
12359
+ function buildUnavailableAgentStep(info, stepId, defaultModelName) {
12360
+ const metrics = metricsFromInfo(info);
12361
+ if (!metrics && !modelNameFromInfo(info) && !info.error && !info.finish) {
12362
+ return null;
12363
+ }
12364
+ return {
12365
+ step_id: stepId,
12366
+ timestamp: timeFromObject(info.time),
12367
+ source: "agent",
12368
+ message: "(message unavailable)",
12369
+ model_name: modelNameFromInfo(info) ?? defaultModelName,
12370
+ metrics,
12371
+ extra: compactExtra3({
12372
+ content_unavailable: true,
12373
+ finish_reason: info.finish,
12374
+ error: info.error
12375
+ })
12376
+ };
12377
+ }
12378
+ function convertMessageEntriesToTrajectory(entries, sessionId, exportInfo) {
12379
+ if (entries.length === 0) return null;
12380
+ const orderedEntries = sortEntries(entries);
12381
+ const defaultModelName = orderedEntries.map((entry) => modelNameFromInfo(entry.info)).find((model) => typeof model === "string");
12382
+ const steps = [];
12383
+ for (const entry of orderedEntries) {
12384
+ const role = entry.info.role;
12385
+ if (role === "user") {
12386
+ const step = buildUserStep(entry, steps.length + 1, defaultModelName);
12387
+ if (step) steps.push(step);
12388
+ continue;
12389
+ }
12390
+ if (role !== "assistant") continue;
12391
+ if (entry.parts.length === 0) {
12392
+ const step = buildUnavailableAgentStep(
12393
+ entry.info,
12394
+ steps.length + 1,
12395
+ defaultModelName
12396
+ );
12397
+ if (step) steps.push(step);
12398
+ continue;
12399
+ }
12400
+ const groups = splitAssistantParts(entry.parts);
12401
+ const hasPartMetrics = groups.some(
12402
+ (group) => group.some((part) => part.type === "step-finish")
12403
+ );
12404
+ const fallbackMetrics = hasPartMetrics ? void 0 : metricsFromInfo(entry.info);
12405
+ for (let i = 0; i < groups.length; i++) {
12406
+ const step = buildAgentStep(
12407
+ groups[i],
12408
+ entry.info,
12409
+ steps.length + 1,
12410
+ defaultModelName,
12411
+ i === 0 ? fallbackMetrics : void 0
12412
+ );
12413
+ if (step) steps.push(step);
12414
+ }
12415
+ }
12416
+ if (steps.length === 0) return null;
12417
+ return {
12418
+ schema_version: ATIF_VERSION,
12419
+ session_id: getSessionId(sessionId, exportInfo, orderedEntries),
12420
+ agent: {
12421
+ name: "opencode",
12422
+ version: getAgentVersion(exportInfo),
12423
+ model_name: defaultModelName
12424
+ },
12425
+ steps,
12426
+ final_metrics: finalMetricsFromSteps2(steps)
12427
+ };
12428
+ }
12429
+ function convertRunEventsToTrajectory(events, sessionId) {
12430
+ const session = sessionId ?? (events.map((event) => event.sessionID).find((sid) => typeof sid === "string") || "unknown");
12431
+ const turns = [];
12432
+ let current = null;
12433
+ for (const event of events) {
12434
+ const type = event.type;
12435
+ if (type === "step_start") {
12436
+ current = { parts: [], timestamp: event.timestamp };
12437
+ continue;
12438
+ }
12439
+ if (type === "step_finish") {
12440
+ if (current) {
12441
+ current.finish = asObject3(event.part) ?? {};
12442
+ turns.push(current);
12443
+ current = null;
12444
+ }
12445
+ continue;
12446
+ }
12447
+ if (current && (type === "text" || type === "reasoning" || type === "tool_use")) {
12448
+ const part = asObject3(event.part);
12449
+ if (part) current.parts.push(part);
12450
+ }
12451
+ }
12452
+ const steps = [];
12453
+ for (const turn of turns) {
12454
+ const parts = [...turn.parts];
12455
+ if (turn.finish) {
12456
+ parts.push({ ...turn.finish, type: "step-finish" });
12457
+ }
12458
+ const step = buildAgentStep(
12459
+ parts,
12460
+ { time: { created: turn.timestamp } },
12461
+ steps.length + 1,
12462
+ void 0
12463
+ );
12464
+ if (step) steps.push(step);
12465
+ }
12466
+ if (steps.length === 0) return null;
12467
+ return {
12468
+ schema_version: ATIF_VERSION,
12469
+ session_id: session,
12470
+ agent: {
12471
+ name: "opencode",
12472
+ version: "unknown"
12473
+ },
12474
+ steps,
12475
+ final_metrics: finalMetricsFromSteps2(steps)
12476
+ };
12477
+ }
12478
+ function isRunEvent(lines) {
12479
+ return lines.some(
12480
+ (line) => ["step_start", "step_finish", "text", "reasoning", "tool_use"].includes(
12481
+ String(line.type ?? "")
12482
+ )
12483
+ );
12484
+ }
12485
+ function convertOpenCodeToTrajectory(content, sessionId) {
12486
+ const trimmed = content.trim();
12487
+ if (!trimmed) return null;
12488
+ try {
12489
+ const parsed = JSON.parse(trimmed);
12490
+ const parsedObj = asObject3(parsed);
12491
+ const messages = asArray(parsedObj?.messages);
12492
+ if (parsedObj && messages.length > 0) {
12493
+ const entries2 = messages.flatMap((message) => {
12494
+ const entry = entryFromLine(asObject3(message) ?? {});
12495
+ return entry ? [entry] : [];
12496
+ });
12497
+ return convertMessageEntriesToTrajectory(
12498
+ entries2,
12499
+ sessionId,
12500
+ asObject3(parsedObj.info)
12501
+ );
12502
+ }
12503
+ } catch {
12504
+ }
12505
+ const lines = parseJsonLines2(content);
12506
+ if (lines.length === 0) return null;
12507
+ if (isRunEvent(lines)) return convertRunEventsToTrajectory(lines, sessionId);
12508
+ const wrappedEntries = entriesFromEventWrappers(lines);
12509
+ const entries = wrappedEntries.length > 0 ? wrappedEntries : lines.flatMap((line) => {
12510
+ const entry = entryFromLine(line);
12511
+ return entry ? [entry] : [];
12512
+ });
12513
+ return convertMessageEntriesToTrajectory(entries, sessionId);
12514
+ }
12515
+
12516
+ // src/normalizer/index.ts
12517
+ function normalizeContent(sourceName, content, sessionId) {
12518
+ switch (sourceName) {
12519
+ case "claude":
12520
+ return convertClaudeToTrajectory(content, sessionId);
12521
+ case "codex":
12522
+ return convertCodexToTrajectory(content, sessionId);
12523
+ case "cursor":
12524
+ return convertCursorToTrajectory(content, sessionId);
12525
+ case "opencode":
12526
+ return convertOpenCodeToTrajectory(content, sessionId);
12527
+ case "copilot-chat":
12528
+ return convertCopilotChatToTrajectory(content, sessionId);
12529
+ default:
12530
+ return null;
12531
+ }
12532
+ }
12533
+ var NormalizeMiddleware = class {
12534
+ name = "normalize";
12535
+ async process(group) {
12536
+ const newFiles = [];
12537
+ for (const file of group.files) {
12538
+ newFiles.push(file);
12539
+ if (!file.absolutePath.endsWith(".jsonl")) continue;
12540
+ if (!["claude", "codex", "cursor", "opencode", "copilot-chat"].includes(
12541
+ file.sourceName
12542
+ ))
12543
+ continue;
12544
+ const content = file.content ? file.content.toString("utf-8") : null;
12545
+ if (!content) continue;
12546
+ const sessionId = file.metadata?.sessionId ?? (file.sourceName === "codex" ? void 0 : path9.basename(file.absolutePath, ".jsonl"));
12547
+ try {
12548
+ const trajectory = normalizeContent(
12549
+ file.sourceName,
12550
+ content,
12551
+ sessionId
12552
+ );
12553
+ if (!trajectory) continue;
12554
+ const json = JSON.stringify(
12555
+ excludeNone(trajectory),
12556
+ null,
12557
+ 2
12558
+ );
12559
+ const atifPath = file.absolutePath.replace(/\.jsonl$/, ".atif.json");
12560
+ newFiles.push({
12561
+ sourceName: file.sourceName,
12562
+ absolutePath: atifPath,
12563
+ repoPath: file.repoPath,
12564
+ metadata: { ...file.metadata, isAtif: true },
12565
+ content: Buffer.from(json, "utf-8")
12566
+ });
12567
+ } catch {
12568
+ }
12569
+ }
12570
+ return { ...group, files: newFiles };
12571
+ }
12572
+ };
12573
+
12574
+ // src/outputs/platform.ts
12575
+ import { PassThrough } from "stream";
12576
+ import archiver from "archiver";
12577
+
12578
+ // src/outputs/archive.ts
11622
12579
  import os4 from "os";
11623
12580
  import path10 from "path";
11624
12581
  function getSourceBaseDir(sourceName) {
@@ -11843,6 +12800,24 @@ function resolveSourceTool(payload) {
11843
12800
  if (payload.hook_event_name === "Stop") return "codex";
11844
12801
  return "claude";
11845
12802
  }
12803
+ async function selfHealHook(repoRoot, tool) {
12804
+ if (process.env.HILLCLIMB_SKIP_HOOK_SELF_HEAL === "1") return;
12805
+ try {
12806
+ const result = await healHookForTool(repoRoot, tool);
12807
+ if (result.skipped) {
12808
+ appendLog("warn", `self-heal: skipped ${tool} hook (${result.skipped})`);
12809
+ return;
12810
+ }
12811
+ if (result.changed) {
12812
+ appendLog("info", `self-heal: updated ${tool} hook`);
12813
+ }
12814
+ } catch (err) {
12815
+ appendLog(
12816
+ "warn",
12817
+ `self-heal: failed to repair ${tool} hook: ${err instanceof Error ? err.message : String(err)}`
12818
+ );
12819
+ }
12820
+ }
11846
12821
  function resolveCursorTranscriptPath(payload) {
11847
12822
  const id = payload.conversation_id ?? payload.session_id;
11848
12823
  const workspace = payload.workspace_roots?.[0];
@@ -11874,6 +12849,16 @@ async function runUploadInner(payload) {
11874
12849
  );
11875
12850
  return;
11876
12851
  }
12852
+ const match = await findProjectForCwd(cwd);
12853
+ if (!match) {
12854
+ appendLog(
12855
+ "warn",
12856
+ `Skipping session ${sessionId}: no hillclimb config for cwd ${cwd}. Run \`npx hillclimb\` in the repo.`
12857
+ );
12858
+ return;
12859
+ }
12860
+ const { repoRoot, config } = match;
12861
+ await selfHealHook(repoRoot, sourceTool);
11877
12862
  const transcriptResolved = path12.resolve(transcriptPath);
11878
12863
  try {
11879
12864
  const stat = await fs9.promises.stat(transcriptResolved);
@@ -11891,14 +12876,6 @@ async function runUploadInner(payload) {
11891
12876
  );
11892
12877
  return;
11893
12878
  }
11894
- const match = await findProjectForCwd(cwd);
11895
- if (!match) {
11896
- appendLog(
11897
- "warn",
11898
- `Skipping session ${sessionId}: no hillclimb config for cwd ${cwd}. Run \`npx hillclimb\` in the repo.`
11899
- );
11900
- return;
11901
- }
11902
12879
  if (!await hasAssistantMessage(transcriptResolved)) {
11903
12880
  appendLog(
11904
12881
  "info",
@@ -11906,7 +12883,6 @@ async function runUploadInner(payload) {
11906
12883
  );
11907
12884
  return;
11908
12885
  }
11909
- const { repoRoot, config } = match;
11910
12886
  await uploadSession({
11911
12887
  sessionId,
11912
12888
  transcriptPath: transcriptResolved,
@@ -12108,6 +13084,7 @@ import crypto2 from "crypto";
12108
13084
 
12109
13085
  // src/git-traces/handlers.ts
12110
13086
  import { execFileSync as execFileSync2 } from "child_process";
13087
+ import path15 from "path";
12111
13088
 
12112
13089
  // src/git-traces/git-ops.ts
12113
13090
  import { execFileSync } from "child_process";
@@ -12362,12 +13339,34 @@ function detectTransitionKind(repoRoot, prevHeadSha, nextHeadSha) {
12362
13339
  return "branch-switch";
12363
13340
  }
12364
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
+ }
12365
13364
  function buildBaselineMetadata(repoRoot, sessionId, tool, baselineSha, cliVersion, epoch, prevHeadSha, transitionKind, startedAt) {
12366
13365
  const headSha = safeGit(repoRoot, ["rev-parse", "HEAD"]) ?? "unknown";
12367
13366
  const branch = safeGit(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]) ?? null;
12368
13367
  const remoteUrl = safeGit(repoRoot, ["config", "--get", "remote.origin.url"]) ?? null;
12369
- const statusRaw = safeGit(repoRoot, ["status", "--porcelain"]) ?? "";
12370
- 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);
12371
13370
  const authorName = safeGit(repoRoot, ["config", "user.name"]) ?? "";
12372
13371
  const authorEmail = safeGit(repoRoot, ["config", "user.email"]) ?? "";
12373
13372
  const commits = transitionKind === "commit" && prevHeadSha && headSha !== "unknown" ? enumerateCommits(repoRoot, prevHeadSha, headSha) : void 0;
@@ -12478,9 +13477,9 @@ function parseCommitFiles(repoRoot, sha) {
12478
13477
  oldPath
12479
13478
  });
12480
13479
  } else {
12481
- const path21 = parts[parts.length - 1];
12482
- indexByPath.set(path21, files.length);
12483
- 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 });
12484
13483
  }
12485
13484
  }
12486
13485
  for (const line of numstat.split("\n")) {
@@ -12511,6 +13510,19 @@ function safeGit(repoRoot, args) {
12511
13510
  return null;
12512
13511
  }
12513
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
+ }
12514
13526
  function cleanupSessionRefs(repoRoot, sessionId) {
12515
13527
  for (const prefix of [
12516
13528
  `refs/hillclimb/baseline/${sessionId}`,
@@ -12538,20 +13550,24 @@ import fs11 from "fs";
12538
13550
  import os7 from "os";
12539
13551
  import path14 from "path";
12540
13552
  var CURRENT_SCHEMA_VERSION = 3;
12541
- var STATE_DIR = path14.join(os7.homedir(), ".hillclimb", "git-traces");
12542
- function stateFileForRepo(repoRoot, tool) {
12543
- const hash = crypto.createHash("sha256").update(`${path14.resolve(repoRoot)}\0${tool}`).digest("hex").slice(0, 16);
12544
- 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`);
12545
13564
  }
12546
13565
  function lockFileForRepo(repoRoot, tool) {
12547
13566
  return `${stateFileForRepo(repoRoot, tool)}.lock`;
12548
13567
  }
12549
- async function readSessionState(repoRoot, tool) {
13568
+ async function readStateFile(file) {
12550
13569
  try {
12551
- const raw = await fs11.promises.readFile(
12552
- stateFileForRepo(repoRoot, tool),
12553
- "utf-8"
12554
- );
13570
+ const raw = await fs11.promises.readFile(file, "utf-8");
12555
13571
  const parsed = JSON.parse(raw);
12556
13572
  if (parsed.schemaVersion !== CURRENT_SCHEMA_VERSION) {
12557
13573
  return null;
@@ -12561,42 +13577,130 @@ async function readSessionState(repoRoot, tool) {
12561
13577
  return null;
12562
13578
  }
12563
13579
  }
12564
- async function writeSessionState(state, tool) {
12565
- const file = stateFileForRepo(state.repoRoot, tool);
12566
- await fs11.promises.mkdir(STATE_DIR, { recursive: true, mode: 448 });
12567
- const tmp = `${file}.tmp`;
12568
- await fs11.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
12569
- mode: 384
12570
- });
12571
- await fs11.promises.rename(tmp, file);
12572
- }
12573
- async function deleteSessionState(repoRoot, tool) {
13580
+ async function listScopedSessionStates(repoRoot, tool) {
13581
+ let entries;
12574
13582
  try {
12575
- await fs11.promises.unlink(stateFileForRepo(repoRoot, tool));
13583
+ entries = await fs11.promises.readdir(stateDir(), { withFileTypes: true });
12576
13584
  } catch {
13585
+ return [];
12577
13586
  }
12578
- }
12579
- async function acquireLock(repoRoot, tool, retries = 3, delayMs = 200) {
12580
- const lockPath = lockFileForRepo(repoRoot, tool);
12581
- await fs11.promises.mkdir(STATE_DIR, { recursive: true, mode: 448 });
12582
- for (let i = 0; i < retries; i++) {
12583
- try {
12584
- const fd = await fs11.promises.open(
12585
- lockPath,
12586
- fs11.constants.O_CREAT | fs11.constants.O_EXCL | fs11.constants.O_WRONLY
12587
- );
12588
- await fd.write(String(process.pid));
12589
- await fd.close();
12590
- return fd.fd;
12591
- } catch (err) {
12592
- if (err.code === "EEXIST" && i < retries - 1) {
12593
- await new Promise((r) => setTimeout(r, delayMs));
12594
- continue;
12595
- }
12596
- throw err;
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;
12597
13595
  }
12598
- }
12599
- throw new Error(`Failed to acquire lock after ${retries} retries`);
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
+ }
13651
+ async function writeSessionState(state, tool) {
13652
+ const file = stateFileForRepo(state.repoRoot, tool, state.sessionId);
13653
+ await fs11.promises.mkdir(stateDir(), { recursive: true, mode: 448 });
13654
+ const tmp = `${file}.tmp`;
13655
+ await fs11.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
13656
+ mode: 384
13657
+ });
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
+ }
13664
+ }
13665
+ async function deleteStateFile(file) {
13666
+ try {
13667
+ await fs11.promises.unlink(file);
13668
+ } catch {
13669
+ }
13670
+ }
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) {
13684
+ const lockPath = lockFileForRepo(repoRoot, tool);
13685
+ await fs11.promises.mkdir(stateDir(), { recursive: true, mode: 448 });
13686
+ for (let i = 0; i < retries; i++) {
13687
+ try {
13688
+ const fd = await fs11.promises.open(
13689
+ lockPath,
13690
+ fs11.constants.O_CREAT | fs11.constants.O_EXCL | fs11.constants.O_WRONLY
13691
+ );
13692
+ await fd.write(String(process.pid));
13693
+ await fd.close();
13694
+ return fd.fd;
13695
+ } catch (err) {
13696
+ if (err.code === "EEXIST" && i < retries - 1) {
13697
+ await new Promise((r) => setTimeout(r, delayMs));
13698
+ continue;
13699
+ }
13700
+ throw err;
13701
+ }
13702
+ }
13703
+ throw new Error(`Failed to acquire lock after ${retries} retries`);
12600
13704
  }
12601
13705
  async function releaseLock(repoRoot, tool) {
12602
13706
  try {
@@ -12606,9 +13710,10 @@ async function releaseLock(repoRoot, tool) {
12606
13710
  }
12607
13711
 
12608
13712
  // src/git-traces/handlers.ts
12609
- var CLI_VERSION = "0.1.0";
13713
+ var CLI_VERSION = "0.2.0";
12610
13714
  var GIT_TRACES_SLUG = "git-traces";
12611
13715
  var MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
13716
+ var STALE_SCOPED_STATE_TTL_MS = 24 * 60 * 60 * 1e3;
12612
13717
  function formatEpochSeconds2(date) {
12613
13718
  return String(Math.floor(date.getTime() / 1e3));
12614
13719
  }
@@ -12619,6 +13724,16 @@ var TOOL_LABELS = {
12619
13724
  "copilot-chat": "GitHub Copilot Chat",
12620
13725
  opencode: "opencode"
12621
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
+ }
12622
13737
  function resolveCwd(payload) {
12623
13738
  return payload.cwd ?? payload.workspace_roots?.[0] ?? null;
12624
13739
  }
@@ -12678,27 +13793,43 @@ function canUploadEpochBaselineArtifacts(epoch, artifacts) {
12678
13793
  }
12679
13794
  function pinEpochBaseline(repoRoot, sessionId, epoch) {
12680
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
+ ]);
12681
13809
  pinRef(
12682
13810
  repoRoot,
12683
- `refs/hillclimb/baseline/${sessionId}/${epochPrefix(epoch)}`,
12684
- baselineSha
13811
+ `refs/hillclimb/baseline/${sessionId}/${prefix}/tree`,
13812
+ commit
12685
13813
  );
12686
- return { baselineSha, headSha: captureHeadSha(repoRoot) };
12687
13814
  }
12688
- function buildEpochBaselineArtifacts(params) {
13815
+ function freezeEpochBaseline(params) {
12689
13816
  const {
12690
13817
  repoRoot,
12691
13818
  sessionId,
12692
13819
  tool,
12693
13820
  epoch,
12694
- baselineSha,
12695
13821
  prevHeadSha,
12696
13822
  transitionKind,
12697
13823
  startedAt
12698
13824
  } = params;
12699
13825
  const prefix = epochPrefix(epoch);
12700
13826
  try {
12701
- const metadata = buildBaselineMetadata(
13827
+ const { baselineSha, headSha } = pinEpochBaseline(
13828
+ repoRoot,
13829
+ sessionId,
13830
+ epoch
13831
+ );
13832
+ const baselineMetadata = buildBaselineMetadata(
12702
13833
  repoRoot,
12703
13834
  sessionId,
12704
13835
  tool,
@@ -12710,13 +13841,29 @@ function buildEpochBaselineArtifacts(params) {
12710
13841
  startedAt
12711
13842
  );
12712
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 {
12713
13858
  const bundleBuffer = createBundleFromTree(
12714
13859
  repoRoot,
12715
13860
  baselineTreeSha,
12716
13861
  `${sessionId}-${prefix}`,
12717
13862
  `baseline ${prefix}`
12718
13863
  );
12719
- const metadataBuffer = Buffer.from(JSON.stringify(metadata, null, 2));
13864
+ const metadataBuffer = Buffer.from(
13865
+ JSON.stringify(baselineMetadata, null, 2)
13866
+ );
12720
13867
  return { baselineTreeSha, metadataBuffer, bundleBuffer };
12721
13868
  } catch (err) {
12722
13869
  appendLog(
@@ -12726,6 +13873,46 @@ function buildEpochBaselineArtifacts(params) {
12726
13873
  return null;
12727
13874
  }
12728
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
+ }
12729
13916
  async function uploadEpochBaselineArtifacts(params) {
12730
13917
  const { client, contributionId, epoch, artifacts } = params;
12731
13918
  const prefix = epochPrefix(epoch);
@@ -12746,6 +13933,34 @@ async function uploadEpochBaselineArtifacts(params) {
12746
13933
  artifacts.metadataBuffer
12747
13934
  );
12748
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
+ }
12749
13964
  async function uploadEpochBaseline(params) {
12750
13965
  const artifacts = buildEpochBaselineArtifacts(params);
12751
13966
  if (!artifacts) return null;
@@ -12769,45 +13984,115 @@ async function openEpoch(params) {
12769
13984
  if (!uploaded) return null;
12770
13985
  return { baselineSha, baselineTreeSha: uploaded.baselineTreeSha, headSha };
12771
13986
  }
12772
- async function initializeSession(cwd, tool, sessionId) {
12773
- const project = await findProjectForCwd(cwd);
12774
- if (!project) {
12775
- appendLog(
12776
- "warn",
12777
- "git-traces: no hillclimb project config found, skipping"
12778
- );
12779
- return null;
12780
- }
12781
- 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;
12782
13999
  const state = {
12783
14000
  schemaVersion: CURRENT_SCHEMA_VERSION,
12784
14001
  sessionId,
12785
14002
  contributionId: null,
12786
- baselineSha,
12787
- baselineTreeSha: null,
12788
- lastSnapshotSha: baselineSha,
12789
- lastSnapshotTreeSha: null,
12790
- 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,
12791
14009
  epoch: 1,
12792
14010
  turnCount: 0,
12793
- headSha,
12794
- repoRoot: cwd
14011
+ headSha: frozen.headSha,
14012
+ repoRoot
12795
14013
  };
12796
14014
  await writeSessionState(state, tool);
12797
14015
  appendLog(
12798
14016
  "info",
12799
- `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)`
12800
14018
  );
12801
14019
  return state;
12802
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
+ }
12803
14084
  async function handleSessionStart(payload, tool) {
12804
14085
  const cwd = resolveCwd(payload);
12805
14086
  if (!cwd) {
12806
14087
  appendLog("warn", "git-traces: no cwd in payload, skipping");
12807
14088
  return;
12808
14089
  }
12809
- if (!isGitRepo(cwd)) {
12810
- 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
+ );
12811
14096
  return;
12812
14097
  }
12813
14098
  const sessionId = resolveSessionId(payload);
@@ -12815,115 +14100,146 @@ async function handleSessionStart(payload, tool) {
12815
14100
  appendLog("warn", "git-traces: no session_id in payload, skipping");
12816
14101
  return;
12817
14102
  }
12818
- await acquireLock(cwd, tool);
12819
- try {
12820
- const stale = await readSessionState(cwd, tool);
12821
- if (stale && stale.sessionId !== sessionId) {
12822
- appendLog(
12823
- "info",
12824
- `git-traces: cleaning up stale session ${stale.sessionId}`
12825
- );
12826
- cleanupSessionRefs(cwd, stale.sessionId);
12827
- await deleteSessionState(cwd, tool);
12828
- }
12829
- await initializeSession(cwd, tool, sessionId);
12830
- } finally {
12831
- 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++;
12832
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
+ );
12833
14117
  }
12834
- async function handleStop(payload, tool) {
12835
- const cwd = resolveCwd(payload);
12836
- if (!cwd) return;
12837
- if (!isGitRepo(cwd)) return;
12838
- const recordedAt = Date.now();
12839
- await acquireLock(cwd, tool);
12840
- try {
12841
- const state = await readSessionState(cwd, tool);
12842
- if (!state) return;
12843
- const project = await findProjectForCwd(cwd);
12844
- if (!project) return;
12845
- const identity = await loadIdentity(project.config.apiBaseUrl);
12846
- if (!identity) {
12847
- appendLog(
12848
- "warn",
12849
- `git-traces: no saved login for ${project.config.apiBaseUrl}, skipping epoch close`
12850
- );
12851
- return;
12852
- }
12853
- const client = new PlatformClient(
12854
- project.config.apiBaseUrl,
12855
- 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})`
12856
14145
  );
12857
- if (state.contributionId === null) {
12858
- const artifacts = buildEpochBaselineArtifacts({
12859
- repoRoot: cwd,
12860
- sessionId: state.sessionId,
12861
- tool,
12862
- epoch: 1,
12863
- baselineSha: state.baselineSha,
12864
- prevHeadSha: null,
12865
- transitionKind: "initial",
12866
- startedAt: state.startedAt
12867
- });
12868
- if (!artifacts) return;
12869
- if (!canUploadEpochBaselineArtifacts(1, artifacts)) return;
12870
- const toolLabel = TOOL_LABELS[tool] ?? "Claude";
12871
- const now = /* @__PURE__ */ new Date();
12872
- const epochSeconds = formatEpochSeconds2(now);
12873
- const shortId = state.sessionId.slice(0, 12);
12874
- const contribution = await client.createContribution(
12875
- project.config.projectId,
12876
- {
12877
- contributionTypeSlug: GIT_TRACES_SLUG,
12878
- title: `${toolLabel} session ${shortId} \u2014 ${epochSeconds}`,
12879
- body: `Session ID: ${state.sessionId}
12880
- Tool: ${toolLabel}
12881
- Repo: ${cwd}
12882
- Uploaded: ${now.toISOString()}`
12883
- }
12884
- );
12885
- const uploaded = await uploadEpochBaselineArtifacts({
12886
- client,
12887
- contributionId: contribution.id,
12888
- epoch: 1,
12889
- artifacts
12890
- });
12891
- if (!uploaded) return;
12892
- await client.submitContribution(contribution.id);
12893
- state.contributionId = contribution.id;
12894
- state.baselineTreeSha = artifacts.baselineTreeSha;
12895
- state.lastSnapshotTreeSha = artifacts.baselineTreeSha;
12896
- 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) {
12897
14185
  appendLog(
12898
14186
  "info",
12899
- `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)`
12900
14188
  );
14189
+ return "no-state";
12901
14190
  }
12902
- const contributionId = state.contributionId;
12903
- const lastSnapshotTreeSha = state.lastSnapshotTreeSha;
12904
- if (contributionId === null || lastSnapshotTreeSha === null) {
14191
+ const lastSnapshotTreeSha = state.lastSnapshotTreeSha ?? state.baselineTreeSha;
14192
+ if (!lastSnapshotTreeSha) {
12905
14193
  appendLog(
12906
14194
  "error",
12907
- "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})`
12908
14196
  );
12909
- return;
14197
+ return "failed";
12910
14198
  }
12911
- const currentHeadSha = captureHeadSha(cwd);
14199
+ const currentHeadSha = captureHeadSha(repoRoot);
12912
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
+ }
12913
14229
  const transitionKind = detectTransitionKind(
12914
- cwd,
14230
+ repoRoot,
12915
14231
  state.headSha || null,
12916
14232
  currentHeadSha
12917
14233
  );
12918
14234
  const nextEpoch = state.epoch + 1;
12919
14235
  appendLog(
12920
14236
  "info",
12921
- `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})`
12922
14238
  );
12923
14239
  const artifacts = await openEpoch({
12924
- repoRoot: cwd,
12925
- client,
12926
- contributionId,
14240
+ repoRoot,
14241
+ client: client2,
14242
+ contributionId: contributionId2,
12927
14243
  sessionId: state.sessionId,
12928
14244
  tool,
12929
14245
  epoch: nextEpoch,
@@ -12931,7 +14247,7 @@ Uploaded: ${now.toISOString()}`
12931
14247
  transitionKind,
12932
14248
  startedAt: state.startedAt
12933
14249
  });
12934
- if (!artifacts) return;
14250
+ if (!artifacts) return "failed";
12935
14251
  const next = {
12936
14252
  ...state,
12937
14253
  baselineSha: artifacts.baselineSha,
@@ -12943,66 +14259,204 @@ Uploaded: ${now.toISOString()}`
12943
14259
  headSha: artifacts.headSha
12944
14260
  };
12945
14261
  await writeSessionState(next, tool);
12946
- 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";
12947
14267
  }
12948
- const currentSha = captureSnapshotSha(cwd);
12949
- const currentTreeSha = buildSnapshotTree(cwd, currentSha);
14268
+ const currentSha = captureSnapshotSha(repoRoot);
14269
+ const currentTreeSha = buildSnapshotTree(repoRoot, currentSha);
12950
14270
  const patchBuffer = createTreeDiffPatchGz(
12951
- cwd,
14271
+ repoRoot,
12952
14272
  lastSnapshotTreeSha,
12953
14273
  currentTreeSha
12954
14274
  );
12955
14275
  if (!patchBuffer) {
12956
14276
  appendLog(
12957
14277
  "info",
12958
- "git-traces: turn produced identical snapshot tree, skipping upload"
14278
+ `git-traces: turn produced identical snapshot tree, skipping upload (repo=${repoRoot}, project=${config.projectId})`
12959
14279
  );
12960
- return;
14280
+ return "unchanged";
12961
14281
  }
12962
- state.turnCount++;
12963
14282
  const prefix = epochPrefix(state.epoch);
12964
- 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
+ }
12965
14321
  pinRef(
12966
- cwd,
14322
+ repoRoot,
12967
14323
  `refs/hillclimb/turns/${state.sessionId}/${prefix}/${turnLabel}`,
12968
14324
  currentSha
12969
14325
  );
12970
- const filename = `${prefix}-${turnLabel}-${recordedAt}.patch.gz`;
12971
- await uploadFile(
14326
+ const uploaded = await uploadFile(
12972
14327
  client,
12973
14328
  contributionId,
12974
14329
  filename,
12975
14330
  "application/gzip",
12976
14331
  patchBuffer
12977
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;
12978
14341
  state.lastSnapshotSha = currentSha;
12979
14342
  state.lastSnapshotTreeSha = currentTreeSha;
12980
14343
  await writeSessionState(state, tool);
12981
14344
  appendLog(
12982
14345
  "info",
12983
- `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})`
12984
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";
12985
14355
  } finally {
12986
- await releaseLock(cwd, tool);
14356
+ await releaseLock(repoRoot, tool);
12987
14357
  }
12988
14358
  }
12989
- async function handleSessionEnd(payload, tool) {
14359
+ async function handleStop(payload, tool) {
12990
14360
  const cwd = resolveCwd(payload);
12991
14361
  if (!cwd) return;
12992
- 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);
12993
14410
  try {
12994
- const state = await readSessionState(cwd, tool);
12995
- if (!state) return;
12996
- cleanupSessionRefs(cwd, state.sessionId);
12997
- 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);
12998
14415
  appendLog(
12999
14416
  "info",
13000
- `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)}`
13001
14424
  );
14425
+ return "failed";
13002
14426
  } finally {
13003
- await releaseLock(cwd, tool);
14427
+ await releaseLock(repoRoot, tool);
13004
14428
  }
13005
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
+ }
13006
14460
 
13007
14461
  // src/git-traces/index.ts
13008
14462
  var WORKER_ENV_FLAG2 = "HILLCLIMB_GIT_TRACES_WORKER";
@@ -13018,6 +14472,25 @@ var KNOWN_TOOLS = /* @__PURE__ */ new Set([
13018
14472
  "cursor",
13019
14473
  "opencode"
13020
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
+ }
13021
14494
  function parseToolArg2(argv) {
13022
14495
  for (let i = 0; i < argv.length; i++) {
13023
14496
  const a = argv[i];
@@ -13034,6 +14507,96 @@ async function readStdin2() {
13034
14507
  }
13035
14508
  return Buffer.concat(chunks).toString("utf-8");
13036
14509
  }
14510
+ function resolveCwd2(payload) {
14511
+ return payload.cwd ?? payload.workspace_roots?.[0] ?? null;
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
+ }
14523
+ async function selfHealHook2(payload, tool) {
14524
+ if (process.env.HILLCLIMB_SKIP_HOOK_SELF_HEAL === "1") return;
14525
+ const cwd = resolveCwd2(payload);
14526
+ if (!cwd) return;
14527
+ try {
14528
+ const project = await findProjectForCwd(cwd);
14529
+ if (!project) 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;
14565
+ }
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);
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];
14592
+ } catch (err) {
14593
+ appendLog(
14594
+ "warn",
14595
+ `git-traces: failed legacy hook repair: ${formatError(err)}`
14596
+ );
14597
+ return null;
14598
+ }
14599
+ }
13037
14600
  async function runGitTraces() {
13038
14601
  if (process.env[WORKER_ENV_FLAG2] === "1") {
13039
14602
  await runGitTracesWorker();
@@ -13046,13 +14609,6 @@ async function runGitTraces() {
13046
14609
  "info",
13047
14610
  `git-traces hook invoked (pid ${process.pid}, tool=${toolArg ?? "<none>"})`
13048
14611
  );
13049
- if (!toolArg || !KNOWN_TOOLS.has(toolArg)) {
13050
- appendLog(
13051
- "error",
13052
- `git-traces: missing or unknown --tool arg (got ${toolArg ?? "<none>"})`
13053
- );
13054
- return;
13055
- }
13056
14612
  let raw;
13057
14613
  try {
13058
14614
  raw = await readStdin2();
@@ -13064,6 +14620,15 @@ async function runGitTraces() {
13064
14620
  appendLog("warn", "git-traces: empty stdin, expected hook payload");
13065
14621
  return;
13066
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
+ }
13067
14632
  const entrypoint = process.argv[1];
13068
14633
  if (!entrypoint) {
13069
14634
  appendLog("error", "git-traces: process.argv[1] is empty");
@@ -13072,14 +14637,14 @@ async function runGitTraces() {
13072
14637
  try {
13073
14638
  const child = spawn3(
13074
14639
  process.execPath,
13075
- [entrypoint, "git-traces", `--tool=${toolArg}`],
14640
+ [entrypoint, "git-traces", `--tool=${tool}`],
13076
14641
  {
13077
14642
  detached: true,
13078
14643
  stdio: ["pipe", "ignore", "ignore"],
13079
14644
  env: {
13080
14645
  ...process.env,
13081
14646
  [WORKER_ENV_FLAG2]: "1",
13082
- [TOOL_ENV_FLAG2]: toolArg,
14647
+ [TOOL_ENV_FLAG2]: tool,
13083
14648
  [FLOW_ID_ENV]: flowId
13084
14649
  }
13085
14650
  }
@@ -13148,26 +14713,16 @@ async function runGitTracesWorker() {
13148
14713
  );
13149
14714
  return;
13150
14715
  }
14716
+ await selfHealHook2(payload, tool);
13151
14717
  try {
13152
- switch (event) {
13153
- case "SessionStart":
14718
+ switch (classifyHookEvent(event)) {
13154
14719
  case "sessionStart":
13155
- // opencode: session.created fires when a new session is first opened.
13156
- case "session.created":
13157
14720
  await handleSessionStart(payload, tool);
13158
14721
  break;
13159
- case "Stop":
13160
14722
  case "stop":
13161
- // opencode: session.idle fires at end-of-turn (agent finished responding).
13162
- case "session.idle":
13163
14723
  await handleStop(payload, tool);
13164
14724
  break;
13165
- case "SessionEnd":
13166
14725
  case "sessionEnd":
13167
- // opencode: server.instance.disposed fires when opencode shuts down.
13168
- // Payload has `cwd` (from the plugin) but no session_id; handleSessionEnd
13169
- // reads state by (repoRoot, tool), so cwd alone is sufficient for cleanup.
13170
- case "server.instance.disposed":
13171
14726
  await handleSessionEnd(payload, tool);
13172
14727
  break;
13173
14728
  default:
@@ -13186,14 +14741,14 @@ ${stack}` : ""}`
13186
14741
 
13187
14742
  // src/outputs/zip.ts
13188
14743
  import fs13 from "fs";
13189
- import path16 from "path";
14744
+ import path17 from "path";
13190
14745
  import archiver2 from "archiver";
13191
14746
 
13192
14747
  // src/outputs/downloads.ts
13193
14748
  import { execSync as execSync2 } from "child_process";
13194
14749
  import fs12 from "fs";
13195
14750
  import os8 from "os";
13196
- import path15 from "path";
14751
+ import path16 from "path";
13197
14752
  function getDownloadsFolder() {
13198
14753
  const home = os8.homedir();
13199
14754
  if (process.platform === "linux") {
@@ -13206,7 +14761,7 @@ function getDownloadsFolder() {
13206
14761
  } catch {
13207
14762
  }
13208
14763
  }
13209
- const downloads = path15.join(home, "Downloads");
14764
+ const downloads = path16.join(home, "Downloads");
13210
14765
  if (fs12.existsSync(downloads)) return downloads;
13211
14766
  return home;
13212
14767
  }
@@ -13216,11 +14771,11 @@ function sanitizeFilename(name) {
13216
14771
  return name.replace(/[^a-zA-Z0-9._-]/g, "_");
13217
14772
  }
13218
14773
  function getUniqueFilename(dir, base, ext) {
13219
- let candidate = path16.join(dir, `${base}${ext}`);
14774
+ let candidate = path17.join(dir, `${base}${ext}`);
13220
14775
  if (!fs13.existsSync(candidate)) return candidate;
13221
14776
  let i = 1;
13222
14777
  while (fs13.existsSync(candidate)) {
13223
- candidate = path16.join(dir, `${base}-${i}${ext}`);
14778
+ candidate = path17.join(dir, `${base}-${i}${ext}`);
13224
14779
  i++;
13225
14780
  }
13226
14781
  return candidate;
@@ -13230,7 +14785,7 @@ var ZipOutput = class {
13230
14785
  label = "Save as .zip to Downloads";
13231
14786
  async emit(group, options) {
13232
14787
  const downloadsDir = getDownloadsFolder();
13233
- const repoName = sanitizeFilename(path16.basename(group.repoPath));
14788
+ const repoName = sanitizeFilename(path17.basename(group.repoPath));
13234
14789
  const timeRange = options.timeRange;
13235
14790
  const rangePart = timeRange?.label ?? "all";
13236
14791
  const epochSeconds = Math.floor(Date.now() / 1e3);
@@ -13432,11 +14987,11 @@ async function confirmExport(group, output) {
13432
14987
  // src/sources/claude.ts
13433
14988
  import fs14 from "fs";
13434
14989
  import os9 from "os";
13435
- import path17 from "path";
14990
+ import path18 from "path";
13436
14991
  import readline from "readline";
13437
14992
  var SKIP_DIRS = /* @__PURE__ */ new Set(["tool-results"]);
13438
14993
  async function resolveRepoPath(projectDir) {
13439
- const indexPath = path17.join(projectDir, "sessions-index.json");
14994
+ const indexPath = path18.join(projectDir, "sessions-index.json");
13440
14995
  try {
13441
14996
  const raw = await fs14.promises.readFile(indexPath, "utf-8");
13442
14997
  const data = JSON.parse(raw);
@@ -13451,7 +15006,7 @@ async function resolveRepoPath(projectDir) {
13451
15006
  });
13452
15007
  for (const entry of entries) {
13453
15008
  if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
13454
- const cwd = await extractCwdFromJsonl(path17.join(projectDir, entry.name));
15009
+ const cwd = await extractCwdFromJsonl(path18.join(projectDir, entry.name));
13455
15010
  if (cwd) {
13456
15011
  cwdCounts.set(cwd, (cwdCounts.get(cwd) ?? 0) + 1);
13457
15012
  }
@@ -13497,7 +15052,7 @@ async function collectFiles(dir, baseDir, sourceName, repoPath, results) {
13497
15052
  return;
13498
15053
  }
13499
15054
  for (const entry of entries) {
13500
- const fullPath = path17.join(dir, entry.name);
15055
+ const fullPath = path18.join(dir, entry.name);
13501
15056
  if (entry.isDirectory()) {
13502
15057
  if (SKIP_DIRS.has(entry.name)) continue;
13503
15058
  await collectFiles(fullPath, baseDir, sourceName, repoPath, results);
@@ -13519,7 +15074,7 @@ function fallbackDecode(encodedName) {
13519
15074
  var ClaudeSource = class {
13520
15075
  name = "claude";
13521
15076
  async scan() {
13522
- const baseDir = path17.join(os9.homedir(), ".claude", "projects");
15077
+ const baseDir = path18.join(os9.homedir(), ".claude", "projects");
13523
15078
  try {
13524
15079
  await fs14.promises.access(baseDir);
13525
15080
  } catch {
@@ -13531,7 +15086,7 @@ var ClaudeSource = class {
13531
15086
  const dirEntries = projectDirs.filter((d) => d.isDirectory());
13532
15087
  const resultArrays = await Promise.all(
13533
15088
  dirEntries.map(async (dir) => {
13534
- const projectPath = path17.join(baseDir, dir.name);
15089
+ const projectPath = path18.join(baseDir, dir.name);
13535
15090
  const repoPath = await resolveRepoPath(projectPath) ?? fallbackDecode(dir.name);
13536
15091
  const files = [];
13537
15092
  await collectFiles(
@@ -13551,7 +15106,7 @@ var ClaudeSource = class {
13551
15106
  // src/sources/codex.ts
13552
15107
  import fs15 from "fs";
13553
15108
  import os10 from "os";
13554
- import path18 from "path";
15109
+ import path19 from "path";
13555
15110
  import readline2 from "readline";
13556
15111
  async function parseSessionMeta(filePath) {
13557
15112
  const stream = fs15.createReadStream(filePath, { encoding: "utf-8" });
@@ -13584,7 +15139,7 @@ async function findJsonlFiles(dir) {
13584
15139
  return;
13585
15140
  }
13586
15141
  for (const entry of entries) {
13587
- const full = path18.join(d, entry.name);
15142
+ const full = path19.join(d, entry.name);
13588
15143
  if (entry.isDirectory()) {
13589
15144
  await walk(full);
13590
15145
  } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
@@ -13629,14 +15184,14 @@ async function loadHistory(historyPath) {
13629
15184
  var CodexSource = class {
13630
15185
  name = "codex";
13631
15186
  async scan() {
13632
- const codexDir = path18.join(os10.homedir(), ".codex");
13633
- const sessionsDir = path18.join(codexDir, "sessions");
15187
+ const codexDir = path19.join(os10.homedir(), ".codex");
15188
+ const sessionsDir = path19.join(codexDir, "sessions");
13634
15189
  try {
13635
15190
  await fs15.promises.access(sessionsDir);
13636
15191
  } catch {
13637
15192
  return [];
13638
15193
  }
13639
- const historyPath = path18.join(codexDir, "history.jsonl");
15194
+ const historyPath = path19.join(codexDir, "history.jsonl");
13640
15195
  const [jsonlFiles, historyMap] = await Promise.all([
13641
15196
  findJsonlFiles(sessionsDir),
13642
15197
  loadHistory(historyPath)
@@ -13659,8 +15214,8 @@ var CodexSource = class {
13659
15214
  });
13660
15215
  const historyLines = historyMap.get(meta.sessionId);
13661
15216
  if (historyLines) {
13662
- const sessionDir = path18.relative(sessionsDir, path18.dirname(filePath));
13663
- const historyAbsPath = path18.join(
15217
+ const sessionDir = path19.relative(sessionsDir, path19.dirname(filePath));
15218
+ const historyAbsPath = path19.join(
13664
15219
  sessionsDir,
13665
15220
  sessionDir,
13666
15221
  `history-${meta.sessionId}.jsonl`
@@ -13682,16 +15237,16 @@ var CodexSource = class {
13682
15237
  // src/sources/copilotChat.ts
13683
15238
  import fs16 from "fs";
13684
15239
  import os11 from "os";
13685
- import path19 from "path";
15240
+ import path20 from "path";
13686
15241
  import { fileURLToPath } from "url";
13687
15242
  function vsCodeUserDirs() {
13688
15243
  const home = os11.homedir();
13689
15244
  const dirs = [
13690
- path19.join(home, "Library", "Application Support", "Code", "User"),
13691
- path19.join(home, ".config", "Code", "User")
15245
+ path20.join(home, "Library", "Application Support", "Code", "User"),
15246
+ path20.join(home, ".config", "Code", "User")
13692
15247
  ];
13693
15248
  if (process.env.APPDATA) {
13694
- dirs.push(path19.join(process.env.APPDATA, "Code", "User"));
15249
+ dirs.push(path20.join(process.env.APPDATA, "Code", "User"));
13695
15250
  }
13696
15251
  return dirs;
13697
15252
  }
@@ -13728,7 +15283,7 @@ var CopilotChatSource = class {
13728
15283
  async scan() {
13729
15284
  const results = [];
13730
15285
  for (const userDir of vsCodeUserDirs()) {
13731
- const workspaceStorage = path19.join(userDir, "workspaceStorage");
15286
+ const workspaceStorage = path20.join(userDir, "workspaceStorage");
13732
15287
  let hashDirs;
13733
15288
  try {
13734
15289
  hashDirs = await fs16.promises.readdir(workspaceStorage, {
@@ -13739,8 +15294,8 @@ var CopilotChatSource = class {
13739
15294
  }
13740
15295
  for (const hash of hashDirs) {
13741
15296
  if (!hash.isDirectory()) continue;
13742
- const wsRoot = path19.join(workspaceStorage, hash.name);
13743
- const transcriptsDir = path19.join(
15297
+ const wsRoot = path20.join(workspaceStorage, hash.name);
15298
+ const transcriptsDir = path20.join(
13744
15299
  wsRoot,
13745
15300
  "GitHub.copilot-chat",
13746
15301
  "transcripts"
@@ -13754,7 +15309,7 @@ var CopilotChatSource = class {
13754
15309
  continue;
13755
15310
  }
13756
15311
  const repoPath = await readWorkspaceFolder(
13757
- path19.join(wsRoot, "workspace.json")
15312
+ path20.join(wsRoot, "workspace.json")
13758
15313
  );
13759
15314
  if (!repoPath) continue;
13760
15315
  for (const entry of transcriptEntries) {
@@ -13762,7 +15317,7 @@ var CopilotChatSource = class {
13762
15317
  const sessionId = entry.name.slice(0, -".jsonl".length);
13763
15318
  results.push({
13764
15319
  sourceName: this.name,
13765
- absolutePath: path19.join(transcriptsDir, entry.name),
15320
+ absolutePath: path20.join(transcriptsDir, entry.name),
13766
15321
  repoPath,
13767
15322
  metadata: { sessionId }
13768
15323
  });
@@ -13829,10 +15384,10 @@ async function runInteractive() {
13829
15384
  s.start(`Scanning ${source.name} logs...`);
13830
15385
  const allFiles = await source.scan();
13831
15386
  const allGroups = await mergeByRepo(allFiles);
13832
- const repoRoot = path20.resolve(repo.root);
15387
+ const repoRoot = path21.resolve(repo.root);
13833
15388
  const matching = allGroups.filter((g) => {
13834
- const resolved = path20.resolve(g.repoPath);
13835
- return resolved === repoRoot || resolved.startsWith(repoRoot + path20.sep);
15389
+ const resolved = path21.resolve(g.repoPath);
15390
+ return resolved === repoRoot || resolved.startsWith(repoRoot + path21.sep);
13836
15391
  });
13837
15392
  if (matching.length === 0) {
13838
15393
  s.stop(`No ${source.name} logs found for ${repo.name}.`);
@@ -13863,7 +15418,7 @@ async function runInteractive() {
13863
15418
  }
13864
15419
  }
13865
15420
  const envFileNames = await discoverEnvFiles(repoRoot);
13866
- const envFilePaths = envFileNames.map((n) => path20.join(repoRoot, n));
15421
+ const envFilePaths = envFileNames.map((n) => path21.join(repoRoot, n));
13867
15422
  const additionalFiles = await promptSecretFiles(envFileNames);
13868
15423
  const secretResult = await collectSecrets(
13869
15424
  repoRoot,