hillclimb 0.1.3 → 0.1.4

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 +20 -2
  2. package/dist/cli.js +430 -166
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -5,7 +5,7 @@ Extract AI coding tool sessions (Claude Code, Cursor, Codex, opencode) and uploa
5
5
  ## Quickstart
6
6
 
7
7
  ```sh
8
- npx hillclimb init
8
+ npx hillclimb
9
9
  ```
10
10
 
11
11
  Run in any git repo. Sessions auto-upload when they end.
@@ -18,7 +18,25 @@ Shows login and hook status for the current repo.
18
18
 
19
19
  ## Hooks
20
20
 
21
- `hillclimb init` detects which tools you use (`~/.claude`, `~/.cursor`, `~/.codex`, `~/.local/share/opencode`) and installs upload + git-trace hooks for each. You might want to gitignore the respective directories for the tools you use in your repo.
21
+ `hillclimb` detects which tools you use (`~/.claude`, `~/.cursor`, `~/.codex`, `~/.local/share/opencode`) and installs upload + git-trace hooks for each. You might want to gitignore the respective directories for the tools you use in your repo.
22
+
23
+ To manually export historical logs instead of configuring auto-upload, run:
24
+
25
+ ```sh
26
+ npx hillclimb export
27
+ ```
28
+
29
+ ## Logs
30
+
31
+ Hillclimb writes one log file per day to `~/.hillclimb/logs/YYYY-MM-DD.log`. Timestamps and the daily filename are in Pacific Time, so logs line up with Hillclimb's internal clock regardless of your timezone.
32
+
33
+ Each hook firing tags every line it writes (parent + worker) with a short correlation ID, e.g. `[a1b2c3]`. To follow one flow across interleaved hook runs:
34
+
35
+ ```sh
36
+ grep '\[a1b2c3\]' ~/.hillclimb/logs/$(TZ=America/Los_Angeles date +%F).log
37
+ ```
38
+
39
+ HTTP calls log method, path, status, and latency. Auth codes and presigned-URL signatures are deliberately omitted.
22
40
 
23
41
  ## License
24
42
 
package/dist/cli.js CHANGED
@@ -146,6 +146,97 @@ async function clearIdentity(apiBaseUrl) {
146
146
  return true;
147
147
  }
148
148
 
149
+ // src/platform/error-format.ts
150
+ function formatError(err) {
151
+ if (!(err instanceof Error)) return String(err);
152
+ const parts = [err.message || err.name];
153
+ const status = err.status;
154
+ const code = err.code;
155
+ if (typeof status === "number") parts.push(`status=${status}`);
156
+ if (typeof code === "string" && code) parts.push(`code=${code}`);
157
+ if (err.cause !== void 0 && err.cause !== null) {
158
+ const causeMsg = err.cause instanceof Error ? err.cause.message : String(err.cause);
159
+ if (causeMsg) parts.push(`cause=${causeMsg}`);
160
+ }
161
+ return parts.join(" ");
162
+ }
163
+
164
+ // src/platform/log.ts
165
+ import fs3 from "fs";
166
+ import path4 from "path";
167
+ var PT_TIME_ZONE = "America/Los_Angeles";
168
+ function pacificParts(date) {
169
+ const parts = new Intl.DateTimeFormat("en-US", {
170
+ timeZone: PT_TIME_ZONE,
171
+ year: "numeric",
172
+ month: "2-digit",
173
+ day: "2-digit",
174
+ hour: "2-digit",
175
+ minute: "2-digit",
176
+ second: "2-digit",
177
+ hour12: false
178
+ }).formatToParts(date);
179
+ const lookup = {};
180
+ for (const p7 of parts) if (p7.type !== "literal") lookup[p7.type] = p7.value;
181
+ if (lookup.hour === "24") lookup.hour = "00";
182
+ return {
183
+ year: lookup.year ?? "0000",
184
+ month: lookup.month ?? "00",
185
+ day: lookup.day ?? "00",
186
+ hour: lookup.hour ?? "00",
187
+ minute: lookup.minute ?? "00",
188
+ second: lookup.second ?? "00"
189
+ };
190
+ }
191
+ function pacificOffsetMinutes(date) {
192
+ const p7 = pacificParts(date);
193
+ const asIfUtc = Date.UTC(
194
+ Number(p7.year),
195
+ Number(p7.month) - 1,
196
+ Number(p7.day),
197
+ Number(p7.hour),
198
+ Number(p7.minute),
199
+ Number(p7.second)
200
+ );
201
+ return Math.round((asIfUtc - date.getTime()) / 6e4);
202
+ }
203
+ function pacificTimestamp(date) {
204
+ const p7 = pacificParts(date);
205
+ const ms = String(date.getMilliseconds()).padStart(3, "0");
206
+ const off = pacificOffsetMinutes(date);
207
+ const sign = off >= 0 ? "+" : "-";
208
+ const abs = Math.abs(off);
209
+ const oh = String(Math.floor(abs / 60)).padStart(2, "0");
210
+ const om = String(abs % 60).padStart(2, "0");
211
+ return `${p7.year}-${p7.month}-${p7.day}T${p7.hour}:${p7.minute}:${p7.second}.${ms}${sign}${oh}:${om}`;
212
+ }
213
+ function pacificDateString(date) {
214
+ const p7 = pacificParts(date);
215
+ return `${p7.year}-${p7.month}-${p7.day}`;
216
+ }
217
+ function logsDir() {
218
+ return path4.join(configDir(), "logs");
219
+ }
220
+ function todayLogPath() {
221
+ return path4.join(logsDir(), `${pacificDateString(/* @__PURE__ */ new Date())}.log`);
222
+ }
223
+ var logPrefix = "";
224
+ function setLogPrefix(prefix) {
225
+ logPrefix = prefix;
226
+ }
227
+ function appendLog(level, message) {
228
+ const tag = logPrefix ? ` ${logPrefix}` : "";
229
+ const line = `[${pacificTimestamp(/* @__PURE__ */ new Date())}] [${level}]${tag} ${message}
230
+ `;
231
+ try {
232
+ fs3.mkdirSync(logsDir(), { recursive: true, mode: 448 });
233
+ fs3.appendFileSync(todayLogPath(), line);
234
+ } catch {
235
+ process.stderr.write(`hillclimb:${tag} ${level}: ${message}
236
+ `);
237
+ }
238
+ }
239
+
149
240
  // src/platform/client.ts
150
241
  var PlatformError = class extends Error {
151
242
  constructor(message, status, code) {
@@ -229,9 +320,21 @@ var PlatformClient = class {
229
320
  if (body !== void 0) {
230
321
  init.body = JSON.stringify(body);
231
322
  }
232
- const res = await fetch(this.url(relative), init);
323
+ const start = Date.now();
324
+ let res;
325
+ try {
326
+ res = await fetch(this.url(relative), init);
327
+ } catch (err) {
328
+ appendLog(
329
+ "error",
330
+ `[${method} ${relative}] FAILED ${Date.now() - start}ms ${formatError(err)}`
331
+ );
332
+ throw err;
333
+ }
334
+ const elapsedMs = Date.now() - start;
233
335
  this.mergeSetCookies(res);
234
336
  if (!res.ok) {
337
+ appendLog("warn", `[${method} ${relative}] ${res.status} ${elapsedMs}ms`);
235
338
  let code;
236
339
  let message = `${method} ${relative} failed: HTTP ${res.status}`;
237
340
  try {
@@ -253,6 +356,7 @@ var PlatformClient = class {
253
356
  }
254
357
  throw new PlatformError(message, res.status, code);
255
358
  }
359
+ appendLog("info", `[${method} ${relative}] ${res.status} ${elapsedMs}ms`);
256
360
  if (res.status === 204) {
257
361
  return void 0;
258
362
  }
@@ -337,12 +441,31 @@ var PlatformClient = class {
337
441
  async uploadToPresignedUrl(presignedUrl, headers, body) {
338
442
  const ab = new ArrayBuffer(body.byteLength);
339
443
  new Uint8Array(ab).set(body);
340
- const res = await fetch(presignedUrl, {
341
- method: "PUT",
342
- headers,
343
- body: new Blob([ab])
344
- });
444
+ const host = (() => {
445
+ try {
446
+ return new URL(presignedUrl).host;
447
+ } catch {
448
+ return "presigned-url";
449
+ }
450
+ })();
451
+ const start = Date.now();
452
+ let res;
453
+ try {
454
+ res = await fetch(presignedUrl, {
455
+ method: "PUT",
456
+ headers,
457
+ body: new Blob([ab])
458
+ });
459
+ } catch (err) {
460
+ appendLog(
461
+ "error",
462
+ `[PUT ${host}] FAILED ${Date.now() - start}ms ${formatError(err)}`
463
+ );
464
+ throw err;
465
+ }
466
+ const elapsedMs = Date.now() - start;
345
467
  if (!res.ok) {
468
+ appendLog("warn", `[PUT ${host}] ${res.status} ${elapsedMs}ms`);
346
469
  let detail = "";
347
470
  try {
348
471
  detail = await res.text();
@@ -353,6 +476,10 @@ var PlatformClient = class {
353
476
  res.status
354
477
  );
355
478
  }
479
+ appendLog(
480
+ "info",
481
+ `[PUT ${host}] ${res.status} ${elapsedMs}ms (${body.byteLength} bytes)`
482
+ );
356
483
  }
357
484
  async submitContribution(contributionId) {
358
485
  const data = await this.request(
@@ -377,9 +504,9 @@ var PlatformClient = class {
377
504
  };
378
505
 
379
506
  // src/platform/hooks.ts
380
- import fs3 from "fs";
507
+ import fs4 from "fs";
381
508
  import os2 from "os";
382
- import path4 from "path";
509
+ import path5 from "path";
383
510
  var HOOK_CMD = (sub) => `npx hillclimb ${sub}`;
384
511
  var GIT_TRACES_CMD = (tool) => `npx hillclimb git-traces --tool=${tool}`;
385
512
  var TOOLS = [
@@ -394,7 +521,7 @@ var TOOLS = [
394
521
  { eventName: "Stop", command: GIT_TRACES_CMD("claude") },
395
522
  { eventName: "SessionEnd", command: GIT_TRACES_CMD("claude") }
396
523
  ],
397
- detect: () => isDir(path4.join(os2.homedir(), ".claude"))
524
+ detect: () => isDir(path5.join(os2.homedir(), ".claude"))
398
525
  },
399
526
  {
400
527
  tool: "cursor",
@@ -407,7 +534,7 @@ var TOOLS = [
407
534
  { eventName: "stop", command: GIT_TRACES_CMD("cursor") },
408
535
  { eventName: "sessionEnd", command: GIT_TRACES_CMD("cursor") }
409
536
  ],
410
- detect: () => isDir(path4.join(os2.homedir(), ".cursor"))
537
+ detect: () => isDir(path5.join(os2.homedir(), ".cursor"))
411
538
  },
412
539
  {
413
540
  // opencode has no settings-file hook system (the `experimental.hook` block
@@ -422,7 +549,7 @@ var TOOLS = [
422
549
  format: "opencode",
423
550
  events: [],
424
551
  // Events are wired inside the plugin itself; see OPENCODE_PLUGIN_CONTENT.
425
- detect: () => isDir(path4.join(os2.homedir(), ".local", "share", "opencode")) || isDir(path4.join(os2.homedir(), ".config", "opencode"))
552
+ detect: () => isDir(path5.join(os2.homedir(), ".local", "share", "opencode")) || isDir(path5.join(os2.homedir(), ".config", "opencode"))
426
553
  },
427
554
  {
428
555
  // VS Code Copilot Chat. Same "no SessionEnd" constraint as Codex (the
@@ -462,36 +589,36 @@ var TOOLS = [
462
589
  { eventName: "SessionStart", command: GIT_TRACES_CMD("codex") },
463
590
  { eventName: "Stop", command: GIT_TRACES_CMD("codex") }
464
591
  ],
465
- detect: () => isDir(path4.join(os2.homedir(), ".codex"))
592
+ detect: () => isDir(path5.join(os2.homedir(), ".codex"))
466
593
  }
467
594
  ];
468
595
  function isDir(p7) {
469
596
  try {
470
- return fs3.statSync(p7).isDirectory();
597
+ return fs4.statSync(p7).isDirectory();
471
598
  } catch {
472
599
  return false;
473
600
  }
474
601
  }
475
602
  function copilotChatDetect() {
476
603
  const home = os2.homedir();
477
- const suffix = path4.join(
604
+ const suffix = path5.join(
478
605
  "User",
479
606
  "globalStorage",
480
607
  "github.copilot-chat"
481
608
  );
482
609
  const candidates = [
483
- path4.join(home, "Library", "Application Support", "Code", suffix),
484
- path4.join(home, ".config", "Code", suffix),
485
- process.env.APPDATA ? path4.join(process.env.APPDATA, "Code", suffix) : null
610
+ path5.join(home, "Library", "Application Support", "Code", suffix),
611
+ path5.join(home, ".config", "Code", suffix),
612
+ process.env.APPDATA ? path5.join(process.env.APPDATA, "Code", suffix) : null
486
613
  ].filter((p7) => p7 !== null);
487
614
  return candidates.some(isDir);
488
615
  }
489
616
  function settingsPath(repoRoot, def) {
490
- return path4.join(repoRoot, def.settingsFile);
617
+ return path5.join(repoRoot, def.settingsFile);
491
618
  }
492
619
  async function readJson(file) {
493
620
  try {
494
- const raw = await fs3.promises.readFile(file, "utf-8");
621
+ const raw = await fs4.promises.readFile(file, "utf-8");
495
622
  const parsed = JSON.parse(raw);
496
623
  if (parsed && typeof parsed === "object") return parsed;
497
624
  return {};
@@ -501,8 +628,8 @@ async function readJson(file) {
501
628
  }
502
629
  }
503
630
  async function writeJson(file, obj) {
504
- await fs3.promises.mkdir(path4.dirname(file), { recursive: true });
505
- await fs3.promises.writeFile(file, `${JSON.stringify(obj, null, 2)}
631
+ await fs4.promises.mkdir(path5.dirname(file), { recursive: true });
632
+ await fs4.promises.writeFile(file, `${JSON.stringify(obj, null, 2)}
506
633
  `);
507
634
  }
508
635
  function claudeHookPresent(matchers, command) {
@@ -613,8 +740,8 @@ function copilotUninstall(settings, eventName, command) {
613
740
  var OPENCODE_PLUGIN_VERSION = 2;
614
741
  var OPENCODE_PLUGIN_MARKER = `// HILLCLIMB_OPENCODE_PLUGIN_VERSION=${OPENCODE_PLUGIN_VERSION}`;
615
742
  var OPENCODE_PLUGIN_CONTENT = `${OPENCODE_PLUGIN_MARKER}
616
- // Auto-installed by \`npx hillclimb init\`. Do not edit manually \u2014 re-running
617
- // init will overwrite this file. See https://github.com/hillclimbai for context.
743
+ // Auto-installed by \`npx hillclimb\`. Do not edit manually \u2014 re-running
744
+ // hillclimb will overwrite this file. See https://github.com/hillclimbai for context.
618
745
 
619
746
  import { spawn } from "node:child_process";
620
747
  import fs from "node:fs";
@@ -760,19 +887,19 @@ export default HillclimbPlugin;
760
887
  `;
761
888
  async function opencodeInstall(file) {
762
889
  try {
763
- const existing = await fs3.promises.readFile(file, "utf-8");
890
+ const existing = await fs4.promises.readFile(file, "utf-8");
764
891
  if (existing === OPENCODE_PLUGIN_CONTENT) {
765
892
  return { installed: 0, alreadyPresent: 1 };
766
893
  }
767
894
  } catch {
768
895
  }
769
- await fs3.promises.mkdir(path4.dirname(file), { recursive: true });
770
- await fs3.promises.writeFile(file, OPENCODE_PLUGIN_CONTENT);
896
+ await fs4.promises.mkdir(path5.dirname(file), { recursive: true });
897
+ await fs4.promises.writeFile(file, OPENCODE_PLUGIN_CONTENT);
771
898
  return { installed: 1, alreadyPresent: 0 };
772
899
  }
773
900
  async function opencodeCheck(file) {
774
901
  try {
775
- const content = await fs3.promises.readFile(file, "utf-8");
902
+ const content = await fs4.promises.readFile(file, "utf-8");
776
903
  return content.includes(OPENCODE_PLUGIN_MARKER);
777
904
  } catch {
778
905
  return false;
@@ -900,17 +1027,17 @@ async function checkAllHooks(repoRoot) {
900
1027
  }
901
1028
  return results;
902
1029
  }
903
- var CODEX_CONFIG_PATH = path4.join(os2.homedir(), ".codex", "config.toml");
1030
+ var CODEX_CONFIG_PATH = path5.join(os2.homedir(), ".codex", "config.toml");
904
1031
  async function ensureCodexHooksEnabled() {
905
1032
  let content;
906
1033
  try {
907
- content = await fs3.promises.readFile(CODEX_CONFIG_PATH, "utf-8");
1034
+ content = await fs4.promises.readFile(CODEX_CONFIG_PATH, "utf-8");
908
1035
  } catch (err) {
909
1036
  if (err.code === "ENOENT") {
910
- await fs3.promises.mkdir(path4.dirname(CODEX_CONFIG_PATH), {
1037
+ await fs4.promises.mkdir(path5.dirname(CODEX_CONFIG_PATH), {
911
1038
  recursive: true
912
1039
  });
913
- await fs3.promises.writeFile(
1040
+ await fs4.promises.writeFile(
914
1041
  CODEX_CONFIG_PATH,
915
1042
  "[features]\ncodex_hooks = true\n"
916
1043
  );
@@ -926,7 +1053,7 @@ async function ensureCodexHooksEnabled() {
926
1053
  } else {
927
1054
  content = content.trimEnd() + "\n\n[features]\ncodex_hooks = true\n";
928
1055
  }
929
- await fs3.promises.writeFile(CODEX_CONFIG_PATH, content);
1056
+ await fs4.promises.writeFile(CODEX_CONFIG_PATH, content);
930
1057
  return true;
931
1058
  }
932
1059
  var CLAUDE_DEF = TOOLS[0];
@@ -982,8 +1109,10 @@ function openBrowser(url) {
982
1109
  }
983
1110
  async function webAuth(apiBaseUrl) {
984
1111
  if (!IS_TTY) {
1112
+ appendLog("info", "web-auth: skipped (not a TTY)");
985
1113
  return null;
986
1114
  }
1115
+ appendLog("info", `web-auth: started (apiBaseUrl=${apiBaseUrl})`);
987
1116
  const client = new PlatformClient(apiBaseUrl);
988
1117
  const codeResponse = await withSpinner(
989
1118
  "Preparing web authentication...",
@@ -991,6 +1120,10 @@ async function webAuth(apiBaseUrl) {
991
1120
  "Could not start web authentication.",
992
1121
  () => client.createCliAuthCode()
993
1122
  );
1123
+ appendLog(
1124
+ "info",
1125
+ `web-auth: code prepared (expiresIn=${codeResponse.expiresIn}s, pollInterval=${codeResponse.interval}s)`
1126
+ );
994
1127
  const url = codeResponse.verificationUrl;
995
1128
  openBrowser(url);
996
1129
  p2.log.info(`Opening browser to: ${cyan(url)}`);
@@ -1004,6 +1137,7 @@ async function webAuth(apiBaseUrl) {
1004
1137
  try {
1005
1138
  const poll = await client.pollCliAuthCode(codeResponse.code);
1006
1139
  if (poll.status === "approved" && poll.sessionToken) {
1140
+ appendLog("info", "web-auth: authorized");
1007
1141
  spinner5.stop("Authorized.");
1008
1142
  const secure = apiBaseUrl.startsWith("https://");
1009
1143
  const cookieName = secure ? "__Secure-better-auth.session_token" : "better-auth.session_token";
@@ -1019,6 +1153,10 @@ async function webAuth(apiBaseUrl) {
1019
1153
  return data;
1020
1154
  }
1021
1155
  );
1156
+ appendLog(
1157
+ "info",
1158
+ `web-auth: completed (email=${bootstrap.session.email})`
1159
+ );
1022
1160
  return {
1023
1161
  client,
1024
1162
  sessionCookie,
@@ -1027,13 +1165,16 @@ async function webAuth(apiBaseUrl) {
1027
1165
  };
1028
1166
  }
1029
1167
  if (poll.status === "expired") {
1168
+ appendLog("warn", "web-auth: code expired before authorization");
1030
1169
  spinner5.stop("Authorization expired.");
1031
1170
  p2.log.warn("The authorization code expired. Please try again.");
1032
1171
  return null;
1033
1172
  }
1034
- } catch {
1173
+ } catch (err) {
1174
+ appendLog("warn", `web-auth: poll attempt failed: ${formatError(err)}`);
1035
1175
  }
1036
1176
  }
1177
+ appendLog("warn", "web-auth: timed out waiting for authorization");
1037
1178
  spinner5.stop("Authorization timed out.");
1038
1179
  p2.log.warn("Timed out waiting for browser authorization.");
1039
1180
  return null;
@@ -1087,16 +1228,20 @@ async function tryReuseIdentity(apiBaseUrl, client, identity) {
1087
1228
  async function runInit(args = []) {
1088
1229
  const forceLogin = args.includes("--login");
1089
1230
  const workspaceSlugFlag = parseStringFlag(args, "workspace");
1090
- p3.intro("hillclimb init");
1231
+ appendLog(
1232
+ "info",
1233
+ `init: started (cwd=${process.cwd()}, forceLogin=${forceLogin}, workspaceSlug=${workspaceSlugFlag ?? "<none>"})`
1234
+ );
1235
+ p3.intro("hillclimb");
1091
1236
  const repo = detectRepoRoot();
1092
1237
  if (!repo) {
1093
- p3.log.error(
1094
- "Not a git repository. Run `npx hillclimb init` inside a repo."
1095
- );
1238
+ appendLog("error", "init: not a git repository, aborting");
1239
+ p3.log.error("Not a git repository. Run `npx hillclimb` inside a repo.");
1096
1240
  p3.outro("Aborted.");
1097
1241
  process.exit(1);
1098
1242
  }
1099
1243
  const { root: repoRoot, name: repoName } = repo;
1244
+ appendLog("info", `init: repo detected (root=${repoRoot}, name=${repoName})`);
1100
1245
  p3.log.info(`Repo: ${repoName} (${repoRoot})`);
1101
1246
  const apiBaseUrl = resolveDefaultApiUrl();
1102
1247
  const saved = forceLogin ? null : await loadIdentity(apiBaseUrl);
@@ -1126,9 +1271,11 @@ async function runInit(args = []) {
1126
1271
  }
1127
1272
  method = selected;
1128
1273
  }
1274
+ appendLog("info", `init: auth method selected (${method})`);
1129
1275
  if (method === "web") {
1130
1276
  const result = await webAuth(apiBaseUrl);
1131
1277
  if (!result) {
1278
+ appendLog("error", "init: web authentication failed");
1132
1279
  p3.log.error("Web authentication failed.");
1133
1280
  p3.outro("Aborted.");
1134
1281
  process.exit(1);
@@ -1193,6 +1340,10 @@ async function runInit(args = []) {
1193
1340
  }
1194
1341
  pickedId = resolved.organizationId;
1195
1342
  workspace = resolved;
1343
+ appendLog(
1344
+ "info",
1345
+ `init: workspace pre-scoped (slug=${workspaceSlugFlag}, id=${resolved.id}, orgId=${pickedId})`
1346
+ );
1196
1347
  const belongs = orgs.some((o) => o.id === pickedId);
1197
1348
  if (!belongs && !isAdmin) {
1198
1349
  p3.log.error(
@@ -1209,6 +1360,7 @@ async function runInit(args = []) {
1209
1360
  await client.setActiveOrganization(pickedId);
1210
1361
  }
1211
1362
  } catch (err) {
1363
+ appendLog("error", `init: setActiveOrganization failed: ${formatError(err)}`);
1212
1364
  p3.log.error(err instanceof Error ? err.message : String(err));
1213
1365
  p3.outro("Aborted.");
1214
1366
  process.exit(1);
@@ -1249,6 +1401,7 @@ async function runInit(args = []) {
1249
1401
  await client.setActiveOrganization(pickedId);
1250
1402
  }
1251
1403
  } catch (err) {
1404
+ appendLog("error", `init: setActiveOrganization failed: ${formatError(err)}`);
1252
1405
  p3.log.error(err instanceof Error ? err.message : String(err));
1253
1406
  p3.outro("Aborted.");
1254
1407
  process.exit(1);
@@ -1289,6 +1442,10 @@ async function runInit(args = []) {
1289
1442
  process.exit(1);
1290
1443
  }
1291
1444
  workspace = picked;
1445
+ appendLog(
1446
+ "info",
1447
+ `init: workspace selected (slug=${workspace.slug}, id=${workspace.id}, orgId=${pickedId})`
1448
+ );
1292
1449
  }
1293
1450
  const types = await withSpinner(
1294
1451
  "Loading contribution types...",
@@ -1339,6 +1496,10 @@ async function runInit(args = []) {
1339
1496
  p3.outro("Aborted.");
1340
1497
  process.exit(1);
1341
1498
  }
1499
+ appendLog(
1500
+ "info",
1501
+ `init: contribution type selected (slug=${type.slug}, name=${type.name})`
1502
+ );
1342
1503
  const pickedOrg = orgs.find((o) => o.id === activeOrgId);
1343
1504
  await upsertProject(repoRoot, {
1344
1505
  apiBaseUrl,
@@ -1351,6 +1512,7 @@ async function runInit(args = []) {
1351
1512
  contributionTypeName: type.name,
1352
1513
  autoSubmit: true
1353
1514
  });
1515
+ appendLog("info", `init: project config saved (repoRoot=${repoRoot})`);
1354
1516
  p3.log.success(`Saved config for ${repoRoot}`);
1355
1517
  await saveIdentity({
1356
1518
  apiBaseUrl,
@@ -1362,10 +1524,19 @@ async function runInit(args = []) {
1362
1524
  try {
1363
1525
  const hookResults = await installDetectedHooks(repoRoot);
1364
1526
  if (hookResults.length === 0) {
1527
+ appendLog(
1528
+ "warn",
1529
+ "init: no supported tools detected, hooks not installed"
1530
+ );
1365
1531
  p3.log.warn(
1366
1532
  "No supported tools detected (Claude Code, Cursor, Codex, opencode, GitHub Copilot Chat). Hook not installed."
1367
1533
  );
1368
1534
  } else {
1535
+ const tools = hookResults.map((r) => `${r.tool}${r.alreadyPresent ? "(existing)" : ""}`).join(",");
1536
+ appendLog(
1537
+ "info",
1538
+ `init: hooks installed (count=${hookResults.length}, tools=${tools})`
1539
+ );
1369
1540
  for (const r of hookResults) {
1370
1541
  if (r.alreadyPresent) {
1371
1542
  p3.log.info(`${r.label} hook already present`);
@@ -1377,9 +1548,17 @@ async function runInit(args = []) {
1377
1548
  try {
1378
1549
  const changed = await ensureCodexHooksEnabled();
1379
1550
  if (changed) {
1551
+ appendLog(
1552
+ "info",
1553
+ "init: enabled codex_hooks in ~/.codex/config.toml"
1554
+ );
1380
1555
  p3.log.success("Enabled codex_hooks in ~/.codex/config.toml");
1381
1556
  }
1382
1557
  } catch (err) {
1558
+ appendLog(
1559
+ "warn",
1560
+ `init: could not enable codex_hooks: ${formatError(err)}`
1561
+ );
1383
1562
  p3.log.warn(
1384
1563
  `Could not enable codex_hooks in config.toml: ${err instanceof Error ? err.message : String(err)}`
1385
1564
  );
@@ -1387,12 +1566,17 @@ async function runInit(args = []) {
1387
1566
  }
1388
1567
  }
1389
1568
  } catch (err) {
1569
+ appendLog("error", `init: failed to install hooks: ${formatError(err)}`);
1390
1570
  p3.log.error(
1391
1571
  `Failed to install hooks: ${err instanceof Error ? err.message : String(err)}`
1392
1572
  );
1393
1573
  p3.outro("Partially configured. Re-run after fixing the error.");
1394
1574
  process.exit(1);
1395
1575
  }
1576
+ appendLog(
1577
+ "info",
1578
+ `init: completed (workspace=${workspace.slug}, contributionType=${type.slug})`
1579
+ );
1396
1580
  p3.outro(
1397
1581
  `Done. Your next coding session in this repo will upload automatically to ${workspace.name}.`
1398
1582
  );
@@ -1433,6 +1617,7 @@ function resolveDefaultApiUrl2() {
1433
1617
  async function runLogin(_args = []) {
1434
1618
  const apiBaseUrl = resolveDefaultApiUrl2();
1435
1619
  header("login");
1620
+ appendLog("info", `login: started (apiBaseUrl=${apiBaseUrl})`);
1436
1621
  const saved = await loadIdentity(apiBaseUrl);
1437
1622
  if (saved) {
1438
1623
  if (IS_TTY) {
@@ -1463,6 +1648,10 @@ async function runLogin(_args = []) {
1463
1648
  process.stdout.write("\r\x1B[K");
1464
1649
  }
1465
1650
  if (verified) {
1651
+ appendLog(
1652
+ "info",
1653
+ `login: reused saved login (email=${verified.email})`
1654
+ );
1466
1655
  row(CHECK, "Signed in", cyan(verified.email));
1467
1656
  const fresh = probe.getSessionCookie();
1468
1657
  if (fresh) {
@@ -1481,10 +1670,15 @@ async function runLogin(_args = []) {
1481
1670
  return;
1482
1671
  }
1483
1672
  if (expired) {
1673
+ appendLog("info", "login: saved login expired, falling through");
1484
1674
  row(CROSS, "Signed in", dim("expired \u2014 please sign in again"));
1485
1675
  println();
1486
1676
  await clearIdentity(apiBaseUrl);
1487
1677
  } else if (verifyError) {
1678
+ appendLog(
1679
+ "warn",
1680
+ `login: could not verify saved login: ${formatError(verifyError)}`
1681
+ );
1488
1682
  row(CROSS, "Signed in", dim("could not verify saved login"));
1489
1683
  println(` ${dim(verifyError.message)}`);
1490
1684
  println();
@@ -1510,9 +1704,11 @@ async function runLogin(_args = []) {
1510
1704
  }
1511
1705
  method = selected;
1512
1706
  }
1707
+ appendLog("info", `login: auth method selected (${method})`);
1513
1708
  if (method === "web") {
1514
1709
  const result = await webAuth(apiBaseUrl);
1515
1710
  if (!result) {
1711
+ appendLog("error", "login: web authentication failed");
1516
1712
  p4.log.error("Web authentication failed.");
1517
1713
  p4.outro("Aborted.");
1518
1714
  process.exit(1);
@@ -1524,9 +1720,10 @@ async function runLogin(_args = []) {
1524
1720
  userId: result.userId,
1525
1721
  savedAt: (/* @__PURE__ */ new Date()).toISOString()
1526
1722
  });
1723
+ appendLog("info", `login: completed via web auth (email=${result.email})`);
1527
1724
  println();
1528
1725
  row(CHECK, "Signed in", cyan(result.email));
1529
- footer("Run `npx hillclimb init` in a repo to wire up auto-upload.");
1726
+ footer("Run `npx hillclimb` in a repo to wire up auto-upload.");
1530
1727
  return;
1531
1728
  }
1532
1729
  const email = await requireText("Email");
@@ -1550,6 +1747,7 @@ async function runLogin(_args = []) {
1550
1747
  );
1551
1748
  const cookie = client.getSessionCookie();
1552
1749
  if (!cookie) {
1750
+ appendLog("error", "login: session cookie missing after sign-in");
1553
1751
  p4.log.error("Session cookie was lost during sign-in.");
1554
1752
  p4.outro("Aborted.");
1555
1753
  process.exit(1);
@@ -1561,9 +1759,13 @@ async function runLogin(_args = []) {
1561
1759
  userId: bootstrap.session.userId,
1562
1760
  savedAt: (/* @__PURE__ */ new Date()).toISOString()
1563
1761
  });
1762
+ appendLog(
1763
+ "info",
1764
+ `login: completed via email/password (email=${bootstrap.session.email})`
1765
+ );
1564
1766
  println();
1565
1767
  row(CHECK, "Signed in", cyan(bootstrap.session.email));
1566
- footer("Run `npx hillclimb init` in a repo to wire up auto-upload.");
1768
+ footer("Run `npx hillclimb` in a repo to wire up auto-upload.");
1567
1769
  }
1568
1770
 
1569
1771
  // src/commands/logout.ts
@@ -1595,39 +1797,12 @@ async function runLogout(args = []) {
1595
1797
  );
1596
1798
  footer(
1597
1799
  "Per-repo upload hooks still active.",
1598
- "Re-run `npx hillclimb init` to re-authenticate a repo."
1800
+ "Re-run `npx hillclimb` to re-authenticate a repo."
1599
1801
  );
1600
1802
  }
1601
1803
 
1602
1804
  // src/commands/status.ts
1603
1805
  import path6 from "path";
1604
-
1605
- // src/platform/log.ts
1606
- import fs4 from "fs";
1607
- import path5 from "path";
1608
- function today() {
1609
- const d = /* @__PURE__ */ new Date();
1610
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
1611
- }
1612
- function logsDir() {
1613
- return path5.join(configDir(), "logs");
1614
- }
1615
- function todayLogPath() {
1616
- return path5.join(logsDir(), `${today()}.log`);
1617
- }
1618
- function appendLog(level, message) {
1619
- const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] [${level}] ${message}
1620
- `;
1621
- try {
1622
- fs4.mkdirSync(logsDir(), { recursive: true, mode: 448 });
1623
- fs4.appendFileSync(todayLogPath(), line);
1624
- } catch {
1625
- process.stderr.write(`hillclimb: ${level}: ${message}
1626
- `);
1627
- }
1628
- }
1629
-
1630
- // src/commands/status.ts
1631
1806
  async function runStatus(args = []) {
1632
1807
  const debug = args.includes("--debug");
1633
1808
  header("status");
@@ -1641,7 +1816,7 @@ async function runStatus(args = []) {
1641
1816
  return;
1642
1817
  }
1643
1818
  row(CROSS, "Repo", `${bold(repo.name)} ${dim("(not configured)")}`);
1644
- footer(`Run \`npx hillclimb init\` in ${repo.name} to connect it.`);
1819
+ footer(`Run \`npx hillclimb\` in ${repo.name} to connect it.`);
1645
1820
  return;
1646
1821
  }
1647
1822
  const { repoRoot, config } = match;
@@ -1703,11 +1878,11 @@ async function runStatus(args = []) {
1703
1878
  row(CROSS, "Auto-upload", dim("error checking hooks"));
1704
1879
  }
1705
1880
  if (loginError === "expired") {
1706
- footer("Run `npx hillclimb init` to sign in again.");
1881
+ footer("Run `npx hillclimb` to sign in again.");
1707
1882
  } else if (loginError === "unreachable") {
1708
1883
  footer(`Couldn't reach ${config.apiBaseUrl}. Check your connection.`);
1709
1884
  } else if (!anyHookInstalled) {
1710
- footer("Run `npx hillclimb init` to install the upload hook.");
1885
+ footer("Run `npx hillclimb` to install the upload hook.");
1711
1886
  } else {
1712
1887
  footer("All set.");
1713
1888
  }
@@ -11634,7 +11809,7 @@ async function runUploadInner(payload) {
11634
11809
  if (!match) {
11635
11810
  appendLog(
11636
11811
  "warn",
11637
- `Skipping session ${sessionId}: no hillclimb config for cwd ${cwd}. Run \`npx hillclimb init\` in the repo.`
11812
+ `Skipping session ${sessionId}: no hillclimb config for cwd ${cwd}. Run \`npx hillclimb\` in the repo.`
11638
11813
  );
11639
11814
  return;
11640
11815
  }
@@ -11724,7 +11899,7 @@ Uploaded: ${now.toISOString()}`;
11724
11899
  if (err instanceof PlatformError && err.status === 401) {
11725
11900
  appendLog(
11726
11901
  "error",
11727
- `Session ${sessionId} upload failed: authentication expired. Re-run \`npx hillclimb init\` in ${repoRoot}.`
11902
+ `Session ${sessionId} upload failed: authentication expired. Re-run \`npx hillclimb\` in ${repoRoot}.`
11728
11903
  );
11729
11904
  return;
11730
11905
  }
@@ -11842,6 +12017,7 @@ async function runUploadWorker() {
11842
12017
 
11843
12018
  // src/git-traces/index.ts
11844
12019
  import { spawn as spawn3 } from "child_process";
12020
+ import crypto2 from "crypto";
11845
12021
 
11846
12022
  // src/git-traces/handlers.ts
11847
12023
  import { execFileSync as execFileSync2 } from "child_process";
@@ -11852,23 +12028,74 @@ import fs9 from "fs";
11852
12028
  import os6 from "os";
11853
12029
  import path12 from "path";
11854
12030
  import { gzipSync } from "zlib";
11855
- var EXEC_OPTS = { timeout: 3e4, maxBuffer: 50 * 1024 * 1024 };
12031
+ var GIT_COMMAND_TIMEOUT_MS = 12e4;
12032
+ var EXEC_OPTS = {
12033
+ timeout: GIT_COMMAND_TIMEOUT_MS,
12034
+ maxBuffer: 50 * 1024 * 1024
12035
+ };
12036
+ var MAX_ERROR_OUTPUT_CHARS = 2e3;
11856
12037
  var MAX_UNTRACKED_FILE_BYTES = 10 * 1024 * 1024;
11857
12038
  var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
12039
+ function quoteGitArg(arg) {
12040
+ if (/^[A-Za-z0-9_./:=@%+,-]+$/.test(arg)) return arg;
12041
+ return JSON.stringify(arg);
12042
+ }
12043
+ function formatGitCommand(args) {
12044
+ return ["git", ...args.map(quoteGitArg)].join(" ");
12045
+ }
12046
+ function outputToString(output) {
12047
+ if (Buffer.isBuffer(output)) return output.toString("utf-8").trim();
12048
+ if (typeof output === "string") return output.trim();
12049
+ return null;
12050
+ }
12051
+ function truncateOutput(output) {
12052
+ if (output.length <= MAX_ERROR_OUTPUT_CHARS) return output;
12053
+ return `${output.slice(0, MAX_ERROR_OUTPUT_CHARS)}...`;
12054
+ }
12055
+ function formatGitFailure(command, failure, err) {
12056
+ const details = [];
12057
+ if (typeof failure.status === "number")
12058
+ details.push(`exit=${failure.status}`);
12059
+ if (failure.signal) details.push(`signal=${failure.signal}`);
12060
+ const stderr = outputToString(failure.stderr);
12061
+ if (stderr) details.push(`stderr=${truncateOutput(stderr)}`);
12062
+ const stdout = outputToString(failure.stdout);
12063
+ if (!stderr && stdout) details.push(`stdout=${truncateOutput(stdout)}`);
12064
+ if (details.length === 0 && err instanceof Error && err.message) {
12065
+ details.push(err.message);
12066
+ }
12067
+ return `${command} failed${details.length ? ` (${details.join("; ")})` : ""}`;
12068
+ }
12069
+ function createGitError(args, err) {
12070
+ const failure = err;
12071
+ const command = formatGitCommand(args);
12072
+ const isTimeout = failure.code === "ETIMEDOUT";
12073
+ const message = isTimeout ? `${command} timed out after ${GIT_COMMAND_TIMEOUT_MS}ms` : formatGitFailure(command, failure, err);
12074
+ const wrapped = new Error(message);
12075
+ wrapped.isGitTimeout = isTimeout;
12076
+ return wrapped;
12077
+ }
12078
+ function isGitTimeoutError(err) {
12079
+ return err instanceof Error && err.isGitTimeout === true;
12080
+ }
12081
+ function gitBuffer(repoRoot, args, options = {}) {
12082
+ try {
12083
+ return execFileSync("git", args, {
12084
+ cwd: repoRoot,
12085
+ ...options.env ? { env: options.env } : {},
12086
+ ...options.input !== void 0 ? { input: options.input } : {},
12087
+ stdio: ["pipe", "pipe", "pipe"],
12088
+ ...EXEC_OPTS
12089
+ });
12090
+ } catch (err) {
12091
+ throw createGitError(args, err);
12092
+ }
12093
+ }
11858
12094
  function git(repoRoot, args) {
11859
- return execFileSync("git", args, {
11860
- cwd: repoRoot,
11861
- stdio: ["pipe", "pipe", "pipe"],
11862
- ...EXEC_OPTS
11863
- }).toString("utf-8").trim();
12095
+ return gitBuffer(repoRoot, args).toString("utf-8").trim();
11864
12096
  }
11865
12097
  function gitWithEnv(repoRoot, args, env) {
11866
- return execFileSync("git", args, {
11867
- cwd: repoRoot,
11868
- env,
11869
- stdio: ["pipe", "pipe", "pipe"],
11870
- ...EXEC_OPTS
11871
- }).toString("utf-8").trim();
12098
+ return gitBuffer(repoRoot, args, { env }).toString("utf-8").trim();
11872
12099
  }
11873
12100
  function isGitRepo(dir) {
11874
12101
  try {
@@ -11882,11 +12109,13 @@ function captureBaselineSha(repoRoot) {
11882
12109
  try {
11883
12110
  const sha = git(repoRoot, ["stash", "create"]);
11884
12111
  if (sha) return sha;
11885
- } catch {
12112
+ } catch (err) {
12113
+ if (isGitTimeoutError(err)) throw err;
11886
12114
  }
11887
12115
  try {
11888
12116
  return git(repoRoot, ["rev-parse", "HEAD"]);
11889
- } catch {
12117
+ } catch (err) {
12118
+ if (isGitTimeoutError(err)) throw err;
11890
12119
  const tree = git(repoRoot, ["write-tree"]);
11891
12120
  return git(repoRoot, ["commit-tree", tree, "-m", "empty baseline"]);
11892
12121
  }
@@ -11904,7 +12133,8 @@ function captureSnapshotSha(repoRoot) {
11904
12133
  try {
11905
12134
  const sha = git(repoRoot, ["stash", "create"]);
11906
12135
  if (sha) return sha;
11907
- } catch {
12136
+ } catch (err) {
12137
+ if (isGitTimeoutError(err)) throw err;
11908
12138
  }
11909
12139
  return git(repoRoot, ["rev-parse", "HEAD"]);
11910
12140
  }
@@ -11939,12 +12169,9 @@ function buildUntrackedTree(repoRoot) {
11939
12169
  );
11940
12170
  const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
11941
12171
  try {
11942
- execFileSync("git", ["update-index", "--add", "-z", "--stdin"], {
11943
- cwd: repoRoot,
11944
- env,
11945
- input: kept.join("\0") + "\0",
11946
- stdio: ["pipe", "pipe", "pipe"],
11947
- ...EXEC_OPTS
12172
+ gitBuffer(repoRoot, ["update-index", "--add", "-z", "--stdin"], {
12173
+ input: `${kept.join("\0")}\0`,
12174
+ env
11948
12175
  });
11949
12176
  return gitWithEnv(repoRoot, ["write-tree"], env);
11950
12177
  } finally {
@@ -11967,10 +12194,12 @@ function buildSnapshotTree(repoRoot, stashSha) {
11967
12194
  const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
11968
12195
  try {
11969
12196
  gitWithEnv(repoRoot, ["read-tree", trackedTree], env);
11970
- const untrackedList = execFileSync(
11971
- "git",
12197
+ const untrackedList = gitBuffer(
12198
+ repoRoot,
11972
12199
  ["ls-tree", "-r", untrackedTree],
11973
- { cwd: repoRoot, env, stdio: ["pipe", "pipe", "pipe"], ...EXEC_OPTS }
12200
+ {
12201
+ env
12202
+ }
11974
12203
  ).toString("utf-8");
11975
12204
  const indexInfo = untrackedList.split("\n").filter(Boolean).map((line) => {
11976
12205
  const [meta, filePath] = line.split(" ");
@@ -11978,12 +12207,9 @@ function buildSnapshotTree(repoRoot, stashSha) {
11978
12207
  return `${mode} ${sha} ${filePath}`;
11979
12208
  }).join("\n");
11980
12209
  if (indexInfo) {
11981
- execFileSync("git", ["update-index", "--index-info"], {
11982
- cwd: repoRoot,
11983
- env,
12210
+ gitBuffer(repoRoot, ["update-index", "--index-info"], {
11984
12211
  input: indexInfo,
11985
- stdio: ["pipe", "pipe", "pipe"],
11986
- ...EXEC_OPTS
12212
+ env
11987
12213
  });
11988
12214
  }
11989
12215
  return gitWithEnv(repoRoot, ["write-tree"], env);
@@ -12020,15 +12246,12 @@ function createBundleFromTree(repoRoot, treeSha, sessionId, label) {
12020
12246
  }
12021
12247
  function createTreeDiffPatchGz(repoRoot, fromTreeSha, toTreeSha) {
12022
12248
  if (fromTreeSha === toTreeSha) return null;
12023
- const diff = execFileSync(
12024
- "git",
12025
- ["diff", "--binary", fromTreeSha, toTreeSha],
12026
- {
12027
- cwd: repoRoot,
12028
- stdio: ["pipe", "pipe", "pipe"],
12029
- ...EXEC_OPTS
12030
- }
12031
- );
12249
+ const diff = gitBuffer(repoRoot, [
12250
+ "diff",
12251
+ "--binary",
12252
+ fromTreeSha,
12253
+ toTreeSha
12254
+ ]);
12032
12255
  if (diff.length === 0) return null;
12033
12256
  return Buffer.from(gzipSync(diff));
12034
12257
  }
@@ -12087,7 +12310,11 @@ function buildBaselineMetadata(repoRoot, sessionId, tool, baselineSha, cliVersio
12087
12310
  function enumerateCommits(repoRoot, fromSha, toSha) {
12088
12311
  let shas;
12089
12312
  try {
12090
- const out = git(repoRoot, ["rev-list", "--reverse", `${fromSha}..${toSha}`]);
12313
+ const out = git(repoRoot, [
12314
+ "rev-list",
12315
+ "--reverse",
12316
+ `${fromSha}..${toSha}`
12317
+ ]);
12091
12318
  if (!out) return [];
12092
12319
  shas = out.split("\n").filter(Boolean);
12093
12320
  } catch (err) {
@@ -12139,12 +12366,7 @@ function parseCommitFiles(repoRoot, sha) {
12139
12366
  let nameStatus = "";
12140
12367
  let numstat = "";
12141
12368
  try {
12142
- nameStatus = git(repoRoot, [
12143
- "show",
12144
- "--name-status",
12145
- "--format=",
12146
- sha
12147
- ]);
12369
+ nameStatus = git(repoRoot, ["show", "--name-status", "--format=", sha]);
12148
12370
  numstat = git(repoRoot, ["show", "--numstat", "--format=", sha]);
12149
12371
  } catch {
12150
12372
  return [];
@@ -12192,7 +12414,13 @@ function parseCommitFiles(repoRoot, sha) {
12192
12414
  function safeGit(repoRoot, args) {
12193
12415
  try {
12194
12416
  return git(repoRoot, args);
12195
- } catch {
12417
+ } catch (err) {
12418
+ if (isGitTimeoutError(err)) {
12419
+ appendLog(
12420
+ "warn",
12421
+ `git-traces: optional metadata command skipped: ${err instanceof Error ? err.message : String(err)}`
12422
+ );
12423
+ }
12196
12424
  return null;
12197
12425
  }
12198
12426
  }
@@ -12347,7 +12575,10 @@ async function uploadFile(client, contributionId, filename, mimeType, buffer) {
12347
12575
  presigned.headers,
12348
12576
  buffer
12349
12577
  );
12350
- appendLog("info", `git-traces: uploaded ${filename} (${buffer.byteLength} bytes)`);
12578
+ appendLog(
12579
+ "info",
12580
+ `git-traces: uploaded ${filename} (${buffer.byteLength} bytes)`
12581
+ );
12351
12582
  }
12352
12583
  function pinEpochBaseline(repoRoot, sessionId, epoch) {
12353
12584
  const baselineSha = captureBaselineSha(repoRoot);
@@ -12358,11 +12589,9 @@ function pinEpochBaseline(repoRoot, sessionId, epoch) {
12358
12589
  );
12359
12590
  return { baselineSha, headSha: captureHeadSha(repoRoot) };
12360
12591
  }
12361
- async function uploadEpochBaseline(params) {
12592
+ function buildEpochBaselineArtifacts(params) {
12362
12593
  const {
12363
12594
  repoRoot,
12364
- client,
12365
- contributionId,
12366
12595
  sessionId,
12367
12596
  tool,
12368
12597
  epoch,
@@ -12372,50 +12601,63 @@ async function uploadEpochBaseline(params) {
12372
12601
  startedAt
12373
12602
  } = params;
12374
12603
  const prefix = epochPrefix(epoch);
12375
- const metadata = buildBaselineMetadata(
12376
- repoRoot,
12377
- sessionId,
12378
- tool,
12379
- baselineSha,
12380
- CLI_VERSION,
12381
- epoch,
12382
- prevHeadSha,
12383
- transitionKind,
12384
- startedAt
12385
- );
12386
- let baselineTreeSha;
12387
- let bundleBuffer;
12388
12604
  try {
12389
- baselineTreeSha = buildSnapshotTree(repoRoot, baselineSha);
12390
- bundleBuffer = createBundleFromTree(
12605
+ const metadata = buildBaselineMetadata(
12606
+ repoRoot,
12607
+ sessionId,
12608
+ tool,
12609
+ baselineSha,
12610
+ CLI_VERSION,
12611
+ epoch,
12612
+ prevHeadSha,
12613
+ transitionKind,
12614
+ startedAt
12615
+ );
12616
+ const baselineTreeSha = buildSnapshotTree(repoRoot, baselineSha);
12617
+ const bundleBuffer = createBundleFromTree(
12391
12618
  repoRoot,
12392
12619
  baselineTreeSha,
12393
12620
  `${sessionId}-${prefix}`,
12394
12621
  `baseline ${prefix}`
12395
12622
  );
12623
+ const metadataBuffer = Buffer.from(JSON.stringify(metadata, null, 2));
12624
+ return { baselineTreeSha, metadataBuffer, bundleBuffer };
12396
12625
  } catch (err) {
12397
12626
  appendLog(
12398
12627
  "error",
12399
- `git-traces: failed to create baseline bundle for ${prefix}: ${err instanceof Error ? err.message : String(err)}`
12628
+ `git-traces: failed to build baseline artifacts for ${prefix}: ${formatError(err)}`
12400
12629
  );
12401
12630
  return null;
12402
12631
  }
12403
- const metadataBuffer = Buffer.from(JSON.stringify(metadata, null, 2));
12632
+ }
12633
+ async function uploadEpochBaselineArtifacts(params) {
12634
+ const { client, contributionId, epoch, artifacts } = params;
12635
+ const prefix = epochPrefix(epoch);
12404
12636
  await uploadFile(
12405
12637
  client,
12406
12638
  contributionId,
12407
12639
  `${prefix}-baseline.json`,
12408
12640
  "application/json",
12409
- metadataBuffer
12641
+ artifacts.metadataBuffer
12410
12642
  );
12411
12643
  await uploadFile(
12412
12644
  client,
12413
12645
  contributionId,
12414
12646
  `${prefix}-baseline.bundle`,
12415
12647
  "application/x-git-bundle",
12416
- bundleBuffer
12648
+ artifacts.bundleBuffer
12417
12649
  );
12418
- return { baselineTreeSha };
12650
+ }
12651
+ async function uploadEpochBaseline(params) {
12652
+ const artifacts = buildEpochBaselineArtifacts(params);
12653
+ if (!artifacts) return null;
12654
+ await uploadEpochBaselineArtifacts({
12655
+ client: params.client,
12656
+ contributionId: params.contributionId,
12657
+ epoch: params.epoch,
12658
+ artifacts
12659
+ });
12660
+ return { baselineTreeSha: artifacts.baselineTreeSha };
12419
12661
  }
12420
12662
  async function openEpoch(params) {
12421
12663
  const { baselineSha, headSha } = pinEpochBaseline(
@@ -12430,7 +12672,10 @@ async function openEpoch(params) {
12430
12672
  async function initializeSession(cwd, tool, sessionId) {
12431
12673
  const project = await findProjectForCwd(cwd);
12432
12674
  if (!project) {
12433
- appendLog("warn", "git-traces: no hillclimb project config found, skipping");
12675
+ appendLog(
12676
+ "warn",
12677
+ "git-traces: no hillclimb project config found, skipping"
12678
+ );
12434
12679
  return null;
12435
12680
  }
12436
12681
  const { baselineSha, headSha } = pinEpochBaseline(cwd, sessionId, 1);
@@ -12510,6 +12755,17 @@ async function handleStop(payload, tool) {
12510
12755
  identity.sessionCookie
12511
12756
  );
12512
12757
  if (state.contributionId === null) {
12758
+ const artifacts = buildEpochBaselineArtifacts({
12759
+ repoRoot: cwd,
12760
+ sessionId: state.sessionId,
12761
+ tool,
12762
+ epoch: 1,
12763
+ baselineSha: state.baselineSha,
12764
+ prevHeadSha: null,
12765
+ transitionKind: "initial",
12766
+ startedAt: state.startedAt
12767
+ });
12768
+ if (!artifacts) return;
12513
12769
  const toolLabel = TOOL_LABELS[tool] ?? "Claude";
12514
12770
  const now = /* @__PURE__ */ new Date();
12515
12771
  const shortId = state.sessionId.slice(0, 12);
@@ -12524,23 +12780,16 @@ Repo: ${cwd}
12524
12780
  Uploaded: ${now.toISOString()}`
12525
12781
  }
12526
12782
  );
12527
- await client.submitContribution(contribution.id);
12528
- const uploaded = await uploadEpochBaseline({
12529
- repoRoot: cwd,
12783
+ await uploadEpochBaselineArtifacts({
12530
12784
  client,
12531
12785
  contributionId: contribution.id,
12532
- sessionId: state.sessionId,
12533
- tool,
12534
12786
  epoch: 1,
12535
- baselineSha: state.baselineSha,
12536
- prevHeadSha: null,
12537
- transitionKind: "initial",
12538
- startedAt: state.startedAt
12787
+ artifacts
12539
12788
  });
12540
- if (!uploaded) return;
12789
+ await client.submitContribution(contribution.id);
12541
12790
  state.contributionId = contribution.id;
12542
- state.baselineTreeSha = uploaded.baselineTreeSha;
12543
- state.lastSnapshotTreeSha = uploaded.baselineTreeSha;
12791
+ state.baselineTreeSha = artifacts.baselineTreeSha;
12792
+ state.lastSnapshotTreeSha = artifacts.baselineTreeSha;
12544
12793
  await writeSessionState(state, tool);
12545
12794
  appendLog(
12546
12795
  "info",
@@ -12655,6 +12904,10 @@ async function handleSessionEnd(payload, tool) {
12655
12904
  // src/git-traces/index.ts
12656
12905
  var WORKER_ENV_FLAG2 = "HILLCLIMB_GIT_TRACES_WORKER";
12657
12906
  var TOOL_ENV_FLAG2 = "HILLCLIMB_GIT_TRACES_TOOL";
12907
+ var FLOW_ID_ENV = "HILLCLIMB_GIT_TRACES_FLOW";
12908
+ function newFlowId() {
12909
+ return crypto2.randomBytes(3).toString("hex");
12910
+ }
12658
12911
  var KNOWN_TOOLS = /* @__PURE__ */ new Set([
12659
12912
  "claude",
12660
12913
  "codex",
@@ -12683,6 +12936,8 @@ async function runGitTraces() {
12683
12936
  await runGitTracesWorker();
12684
12937
  return;
12685
12938
  }
12939
+ const flowId = newFlowId();
12940
+ setLogPrefix(`[${flowId}]`);
12686
12941
  const toolArg = parseToolArg2(process.argv.slice(2));
12687
12942
  appendLog(
12688
12943
  "info",
@@ -12699,10 +12954,7 @@ async function runGitTraces() {
12699
12954
  try {
12700
12955
  raw = await readStdin2();
12701
12956
  } catch (err) {
12702
- appendLog(
12703
- "error",
12704
- `git-traces: failed to read stdin: ${err instanceof Error ? err.message : String(err)}`
12705
- );
12957
+ appendLog("error", `git-traces: failed to read stdin: ${formatError(err)}`);
12706
12958
  return;
12707
12959
  }
12708
12960
  if (!raw.trim()) {
@@ -12724,7 +12976,8 @@ async function runGitTraces() {
12724
12976
  env: {
12725
12977
  ...process.env,
12726
12978
  [WORKER_ENV_FLAG2]: "1",
12727
- [TOOL_ENV_FLAG2]: toolArg
12979
+ [TOOL_ENV_FLAG2]: toolArg,
12980
+ [FLOW_ID_ENV]: flowId
12728
12981
  }
12729
12982
  }
12730
12983
  );
@@ -12741,11 +12994,12 @@ async function runGitTraces() {
12741
12994
  } catch (err) {
12742
12995
  appendLog(
12743
12996
  "error",
12744
- `git-traces: failed to spawn worker: ${err instanceof Error ? err.message : String(err)}`
12997
+ `git-traces: failed to spawn worker: ${formatError(err)}`
12745
12998
  );
12746
12999
  }
12747
13000
  }
12748
13001
  async function runGitTracesWorker() {
13002
+ setLogPrefix(`[${process.env[FLOW_ID_ENV] ?? newFlowId()}]`);
12749
13003
  const tool = process.env[TOOL_ENV_FLAG2] ?? parseToolArg2(process.argv.slice(2)) ?? null;
12750
13004
  appendLog(
12751
13005
  "info",
@@ -12764,7 +13018,7 @@ async function runGitTracesWorker() {
12764
13018
  } catch (err) {
12765
13019
  appendLog(
12766
13020
  "error",
12767
- `git-traces worker: failed to read stdin: ${err instanceof Error ? err.message : String(err)}`
13021
+ `git-traces worker: failed to read stdin: ${formatError(err)}`
12768
13022
  );
12769
13023
  return;
12770
13024
  }
@@ -12778,7 +13032,7 @@ async function runGitTracesWorker() {
12778
13032
  } catch (err) {
12779
13033
  appendLog(
12780
13034
  "error",
12781
- `git-traces worker: failed to parse payload: ${err instanceof Error ? err.message : String(err)}`
13035
+ `git-traces worker: failed to parse payload: ${formatError(err)}`
12782
13036
  );
12783
13037
  return;
12784
13038
  }
@@ -12817,9 +13071,12 @@ async function runGitTracesWorker() {
12817
13071
  appendLog("warn", `git-traces worker: unknown event ${event}`);
12818
13072
  }
12819
13073
  } catch (err) {
13074
+ const detail = formatError(err);
13075
+ const stack = err instanceof Error ? err.stack : void 0;
12820
13076
  appendLog(
12821
13077
  "error",
12822
- `git-traces worker: unexpected error: ${err instanceof Error ? err.stack ?? err.message : String(err)}`
13078
+ `git-traces worker: unexpected error: ${detail}${stack ? `
13079
+ ${stack}` : ""}`
12823
13080
  );
12824
13081
  }
12825
13082
  }
@@ -13548,11 +13805,14 @@ async function main() {
13548
13805
  const [, , subcommand, ...rest] = process.argv;
13549
13806
  switch (subcommand) {
13550
13807
  case void 0:
13551
- await runInteractive();
13808
+ await runInit([]);
13552
13809
  return;
13553
13810
  case "init":
13554
13811
  await runInit(rest);
13555
13812
  return;
13813
+ case "export":
13814
+ await runInteractive();
13815
+ return;
13556
13816
  case "login":
13557
13817
  await runLogin(rest);
13558
13818
  return;
@@ -13572,11 +13832,15 @@ async function main() {
13572
13832
  case "-h":
13573
13833
  case "help": {
13574
13834
  process.stdout.write(
13575
- "Usage: npx hillclimb [subcommand]\n\nSubcommands:\n (none) Interactive export flow (manual use)\n login Sign in and save the credential for reuse across repos\n logout Clear the saved sign-in (pass --url <api> to scope to one instance)\n init Configure this repo for automatic upload (--login forces fresh sign-in)\n status Show whether you're signed in and this repo is connected (pass --debug for details)\n upload Hook entry point \u2014 reads JSON payload from stdin and uploads one session\n help Show this message\n"
13835
+ "Usage: npx hillclimb [subcommand]\n\nSubcommands:\n (none) Configure this repo for automatic upload\n init Configure this repo for automatic upload (--login forces fresh sign-in)\n export Interactive export flow (manual use)\n login Sign in and save the credential for reuse across repos\n logout Clear the saved sign-in (pass --url <api> to scope to one instance)\n status Show whether you're signed in and this repo is connected (pass --debug for details)\n upload Hook entry point \u2014 reads JSON payload from stdin and uploads one session\n help Show this message\n"
13576
13836
  );
13577
13837
  return;
13578
13838
  }
13579
13839
  default:
13840
+ if (subcommand === "--login" || subcommand === "--workspace" || subcommand.startsWith("--workspace=")) {
13841
+ await runInit([subcommand, ...rest]);
13842
+ return;
13843
+ }
13580
13844
  process.stderr.write(
13581
13845
  `Unknown subcommand: ${subcommand}${rest.length > 0 ? ` ${rest.join(" ")}` : ""}
13582
13846
  `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hillclimb",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Extract and export AI coding tool logs grouped by repo",
5
5
  "license": "MIT",
6
6
  "type": "module",