hillclimb 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +331 -87
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import fs15 from "fs";
5
- import path18 from "path";
4
+ import fs16 from "fs";
5
+ import path19 from "path";
6
6
  import * as p6 from "@clack/prompts";
7
7
 
8
8
  // src/commands/init.ts
@@ -424,6 +424,27 @@ var TOOLS = [
424
424
  // Events are wired inside the plugin itself; see OPENCODE_PLUGIN_CONTENT.
425
425
  detect: () => isDir(path4.join(os2.homedir(), ".local", "share", "opencode")) || isDir(path4.join(os2.homedir(), ".config", "opencode"))
426
426
  },
427
+ {
428
+ // VS Code Copilot Chat. Same "no SessionEnd" constraint as Codex (the
429
+ // event is documented but never fires — VS Code issue microsoft/vscode#300650).
430
+ // Stale-state cleanup happens on next SessionStart, like Codex.
431
+ // Hook config lives at .github/hooks/hooks.json — distinct from
432
+ // .claude/settings.local.json so Claude Code and Copilot Chat don't
433
+ // fire each other's hooks even though VS Code's docs claim they share.
434
+ tool: "copilot-chat",
435
+ label: "GitHub Copilot Chat",
436
+ settingsFile: ".github/hooks/hooks.json",
437
+ format: "copilot",
438
+ events: [
439
+ { eventName: "SessionStart", command: GIT_TRACES_CMD("copilot-chat") },
440
+ { eventName: "Stop", command: GIT_TRACES_CMD("copilot-chat") },
441
+ {
442
+ eventName: "Stop",
443
+ command: `${HOOK_CMD("upload")} --tool=copilot-chat`
444
+ }
445
+ ],
446
+ detect: copilotChatDetect
447
+ },
427
448
  {
428
449
  // Codex has no SessionEnd hook (only SessionStart, PreToolUse, PostToolUse,
429
450
  // UserPromptSubmit, Stop). We can't clean up refs/state at session end;
@@ -451,6 +472,20 @@ function isDir(p7) {
451
472
  return false;
452
473
  }
453
474
  }
475
+ function copilotChatDetect() {
476
+ const home = os2.homedir();
477
+ const suffix = path4.join(
478
+ "User",
479
+ "globalStorage",
480
+ "github.copilot-chat"
481
+ );
482
+ 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
486
+ ].filter((p7) => p7 !== null);
487
+ return candidates.some(isDir);
488
+ }
454
489
  function settingsPath(repoRoot, def) {
455
490
  return path4.join(repoRoot, def.settingsFile);
456
491
  }
@@ -545,6 +580,36 @@ function cursorUninstall(settings, eventName, command) {
545
580
  }
546
581
  return true;
547
582
  }
583
+ function copilotHookPresent(entries, command) {
584
+ return entries.some((e) => e.type === "command" && e.command === command);
585
+ }
586
+ function copilotInstall(settings, eventName, command) {
587
+ if (!settings.hooks) settings.hooks = {};
588
+ if (!settings.hooks[eventName]) settings.hooks[eventName] = [];
589
+ const entries = settings.hooks[eventName];
590
+ if (copilotHookPresent(entries, command)) return false;
591
+ entries.push({ type: "command", command });
592
+ return true;
593
+ }
594
+ function copilotCheck(settings, eventName, command) {
595
+ const entries = settings.hooks?.[eventName] ?? [];
596
+ return copilotHookPresent(entries, command);
597
+ }
598
+ function copilotUninstall(settings, eventName, command) {
599
+ const entries = settings.hooks?.[eventName];
600
+ if (!entries || entries.length === 0) return false;
601
+ const next = entries.filter(
602
+ (e) => !(e.type === "command" && e.command === command)
603
+ );
604
+ if (next.length === entries.length) return false;
605
+ if (next.length > 0) {
606
+ settings.hooks[eventName] = next;
607
+ } else {
608
+ delete settings.hooks[eventName];
609
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
610
+ }
611
+ return true;
612
+ }
548
613
  var OPENCODE_PLUGIN_VERSION = 2;
549
614
  var OPENCODE_PLUGIN_MARKER = `// HILLCLIMB_OPENCODE_PLUGIN_VERSION=${OPENCODE_PLUGIN_VERSION}`;
550
615
  var OPENCODE_PLUGIN_CONTENT = `${OPENCODE_PLUGIN_MARKER}
@@ -714,13 +779,34 @@ async function opencodeCheck(file) {
714
779
  }
715
780
  }
716
781
  function install(settings, format, eventName, command) {
717
- return format === "cursor" ? cursorInstall(settings, eventName, command) : claudeInstall(settings, eventName, command);
782
+ switch (format) {
783
+ case "cursor":
784
+ return cursorInstall(settings, eventName, command);
785
+ case "copilot":
786
+ return copilotInstall(settings, eventName, command);
787
+ default:
788
+ return claudeInstall(settings, eventName, command);
789
+ }
718
790
  }
719
791
  function check(settings, format, eventName, command) {
720
- return format === "cursor" ? cursorCheck(settings, eventName, command) : claudeCheck(settings, eventName, command);
792
+ switch (format) {
793
+ case "cursor":
794
+ return cursorCheck(settings, eventName, command);
795
+ case "copilot":
796
+ return copilotCheck(settings, eventName, command);
797
+ default:
798
+ return claudeCheck(settings, eventName, command);
799
+ }
721
800
  }
722
801
  function uninstall(settings, format, eventName, command) {
723
- return format === "cursor" ? cursorUninstall(settings, eventName, command) : claudeUninstall(settings, eventName, command);
802
+ switch (format) {
803
+ case "cursor":
804
+ return cursorUninstall(settings, eventName, command);
805
+ case "copilot":
806
+ return copilotUninstall(settings, eventName, command);
807
+ default:
808
+ return claudeUninstall(settings, eventName, command);
809
+ }
724
810
  }
725
811
  function legacyCommandsFor(command) {
726
812
  const prefix = "npx hillclimb ";
@@ -1277,7 +1363,7 @@ async function runInit(args = []) {
1277
1363
  const hookResults = await installDetectedHooks(repoRoot);
1278
1364
  if (hookResults.length === 0) {
1279
1365
  p3.log.warn(
1280
- "No supported tools detected (.claude/, .cursor/, .codex/). Hook not installed."
1366
+ "No supported tools detected (Claude Code, Cursor, Codex, opencode, GitHub Copilot Chat). Hook not installed."
1281
1367
  );
1282
1368
  } else {
1283
1369
  for (const r of hookResults) {
@@ -11464,6 +11550,9 @@ function formatTitleTimestamp(date) {
11464
11550
  const pad = (n) => String(n).padStart(2, "0");
11465
11551
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
11466
11552
  }
11553
+ function lineHasAssistant(line) {
11554
+ return line.includes('"type":"assistant"') || line.includes('"role":"assistant"') || line.includes('"type":"assistant.');
11555
+ }
11467
11556
  async function hasAssistantMessage(transcriptPath) {
11468
11557
  const stream = fs8.createReadStream(transcriptPath, { encoding: "utf-8" });
11469
11558
  let buffer = "";
@@ -11474,15 +11563,14 @@ async function hasAssistantMessage(transcriptPath) {
11474
11563
  while (newlineIdx !== -1) {
11475
11564
  const line = buffer.slice(0, newlineIdx);
11476
11565
  buffer = buffer.slice(newlineIdx + 1);
11477
- if (line.includes('"type":"assistant"') || line.includes('"role":"assistant"')) {
11566
+ if (lineHasAssistant(line)) {
11478
11567
  stream.destroy();
11479
11568
  return true;
11480
11569
  }
11481
11570
  newlineIdx = buffer.indexOf("\n");
11482
11571
  }
11483
11572
  }
11484
- if (buffer.includes('"type":"assistant"') || buffer.includes('"role":"assistant"'))
11485
- return true;
11573
+ if (lineHasAssistant(buffer)) return true;
11486
11574
  } catch {
11487
11575
  return true;
11488
11576
  }
@@ -11600,7 +11688,13 @@ async function uploadSession(args) {
11600
11688
  }
11601
11689
  const client = new PlatformClient(config.apiBaseUrl, identity.sessionCookie);
11602
11690
  const shortId = sessionId.slice(0, 12);
11603
- const toolLabels = { cursor: "Cursor", codex: "Codex", claude: "Claude", opencode: "opencode" };
11691
+ const toolLabels = {
11692
+ cursor: "Cursor",
11693
+ codex: "Codex",
11694
+ claude: "Claude",
11695
+ "copilot-chat": "GitHub Copilot Chat",
11696
+ opencode: "opencode"
11697
+ };
11604
11698
  const toolLabel = toolLabels[sourceTool] ?? "Claude";
11605
11699
  const title = `${toolLabel} session ${shortId} \u2014 ${formatTitleTimestamp(now)}`;
11606
11700
  const body = `Session ID: ${sessionId}
@@ -11641,6 +11735,15 @@ Uploaded: ${now.toISOString()}`;
11641
11735
  }
11642
11736
  }
11643
11737
  var WORKER_ENV_FLAG = "HILLCLIMB_UPLOAD_WORKER";
11738
+ var TOOL_ENV_FLAG = "HILLCLIMB_UPLOAD_TOOL";
11739
+ function parseToolArg(argv) {
11740
+ for (let i = 0; i < argv.length; i++) {
11741
+ const a = argv[i];
11742
+ if (a.startsWith("--tool=")) return a.slice("--tool=".length);
11743
+ if (a === "--tool" && i + 1 < argv.length) return argv[i + 1];
11744
+ }
11745
+ return null;
11746
+ }
11644
11747
  async function runUpload() {
11645
11748
  if (process.env[WORKER_ENV_FLAG] === "1") {
11646
11749
  await runUploadWorker();
@@ -11669,11 +11772,17 @@ async function runUpload() {
11669
11772
  appendLog("error", "Cannot detach worker: process.argv[1] is empty");
11670
11773
  return;
11671
11774
  }
11775
+ const toolArg = parseToolArg(process.argv.slice(2));
11776
+ const workerEnv = {
11777
+ ...process.env,
11778
+ [WORKER_ENV_FLAG]: "1"
11779
+ };
11780
+ if (toolArg) workerEnv[TOOL_ENV_FLAG] = toolArg;
11672
11781
  try {
11673
11782
  const child = spawn2(process.execPath, [entrypoint, "upload"], {
11674
11783
  detached: true,
11675
11784
  stdio: ["pipe", "ignore", "ignore"],
11676
- env: { ...process.env, [WORKER_ENV_FLAG]: "1" }
11785
+ env: workerEnv
11677
11786
  });
11678
11787
  child.on("error", (err) => {
11679
11788
  appendLog("error", `Failed to spawn worker: ${err.message}`);
@@ -11719,6 +11828,8 @@ async function runUploadWorker() {
11719
11828
  );
11720
11829
  return;
11721
11830
  }
11831
+ const toolOverride = process.env[TOOL_ENV_FLAG];
11832
+ if (toolOverride) payload.tool = toolOverride;
11722
11833
  try {
11723
11834
  await runUploadInner(payload);
11724
11835
  } catch (err) {
@@ -11941,7 +12052,7 @@ function detectTransitionKind(repoRoot, prevHeadSha, nextHeadSha) {
11941
12052
  return "branch-switch";
11942
12053
  }
11943
12054
  }
11944
- function buildBaselineMetadata(repoRoot, sessionId, tool, baselineSha, cliVersion, epoch, prevHeadSha, transitionKind) {
12055
+ function buildBaselineMetadata(repoRoot, sessionId, tool, baselineSha, cliVersion, epoch, prevHeadSha, transitionKind, startedAt) {
11945
12056
  const headSha = safeGit(repoRoot, ["rev-parse", "HEAD"]) ?? "unknown";
11946
12057
  const branch = safeGit(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]) ?? null;
11947
12058
  const remoteUrl = safeGit(repoRoot, ["config", "--get", "remote.origin.url"]) ?? null;
@@ -11953,7 +12064,7 @@ function buildBaselineMetadata(repoRoot, sessionId, tool, baselineSha, cliVersio
11953
12064
  return {
11954
12065
  sessionId,
11955
12066
  tool,
11956
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
12067
+ startedAt,
11957
12068
  cwd: repoRoot,
11958
12069
  patchFormat: "incremental",
11959
12070
  epoch,
@@ -12058,9 +12169,9 @@ function parseCommitFiles(repoRoot, sha) {
12058
12169
  oldPath
12059
12170
  });
12060
12171
  } else {
12061
- const path19 = parts[parts.length - 1];
12062
- indexByPath.set(path19, files.length);
12063
- files.push({ path: path19, status, additions: 0, deletions: 0 });
12172
+ const path20 = parts[parts.length - 1];
12173
+ indexByPath.set(path20, files.length);
12174
+ files.push({ path: path20, status, additions: 0, deletions: 0 });
12064
12175
  }
12065
12176
  }
12066
12177
  for (const line of numstat.split("\n")) {
@@ -12111,7 +12222,7 @@ import crypto from "crypto";
12111
12222
  import fs10 from "fs";
12112
12223
  import os7 from "os";
12113
12224
  import path13 from "path";
12114
- var CURRENT_SCHEMA_VERSION = 2;
12225
+ var CURRENT_SCHEMA_VERSION = 3;
12115
12226
  var STATE_DIR = path13.join(os7.homedir(), ".hillclimb", "git-traces");
12116
12227
  function stateFileForRepo(repoRoot, tool) {
12117
12228
  const hash = crypto.createHash("sha256").update(`${path13.resolve(repoRoot)}\0${tool}`).digest("hex").slice(0, 16);
@@ -12188,6 +12299,7 @@ var TOOL_LABELS = {
12188
12299
  cursor: "Cursor",
12189
12300
  codex: "Codex",
12190
12301
  claude: "Claude",
12302
+ "copilot-chat": "GitHub Copilot Chat",
12191
12303
  opencode: "opencode"
12192
12304
  };
12193
12305
  function resolveCwd(payload) {
@@ -12237,7 +12349,16 @@ async function uploadFile(client, contributionId, filename, mimeType, buffer) {
12237
12349
  );
12238
12350
  appendLog("info", `git-traces: uploaded ${filename} (${buffer.byteLength} bytes)`);
12239
12351
  }
12240
- async function openEpoch(params) {
12352
+ function pinEpochBaseline(repoRoot, sessionId, epoch) {
12353
+ const baselineSha = captureBaselineSha(repoRoot);
12354
+ pinRef(
12355
+ repoRoot,
12356
+ `refs/hillclimb/baseline/${sessionId}/${epochPrefix(epoch)}`,
12357
+ baselineSha
12358
+ );
12359
+ return { baselineSha, headSha: captureHeadSha(repoRoot) };
12360
+ }
12361
+ async function uploadEpochBaseline(params) {
12241
12362
  const {
12242
12363
  repoRoot,
12243
12364
  client,
@@ -12245,16 +12366,12 @@ async function openEpoch(params) {
12245
12366
  sessionId,
12246
12367
  tool,
12247
12368
  epoch,
12369
+ baselineSha,
12248
12370
  prevHeadSha,
12249
- transitionKind
12371
+ transitionKind,
12372
+ startedAt
12250
12373
  } = params;
12251
- const baselineSha = captureBaselineSha(repoRoot);
12252
12374
  const prefix = epochPrefix(epoch);
12253
- pinRef(
12254
- repoRoot,
12255
- `refs/hillclimb/baseline/${sessionId}/${prefix}`,
12256
- baselineSha
12257
- );
12258
12375
  const metadata = buildBaselineMetadata(
12259
12376
  repoRoot,
12260
12377
  sessionId,
@@ -12263,7 +12380,8 @@ async function openEpoch(params) {
12263
12380
  CLI_VERSION,
12264
12381
  epoch,
12265
12382
  prevHeadSha,
12266
- transitionKind
12383
+ transitionKind,
12384
+ startedAt
12267
12385
  );
12268
12386
  let baselineTreeSha;
12269
12387
  let bundleBuffer;
@@ -12297,11 +12415,17 @@ async function openEpoch(params) {
12297
12415
  "application/x-git-bundle",
12298
12416
  bundleBuffer
12299
12417
  );
12300
- return {
12301
- baselineSha,
12302
- baselineTreeSha,
12303
- headSha: captureHeadSha(repoRoot)
12304
- };
12418
+ return { baselineTreeSha };
12419
+ }
12420
+ async function openEpoch(params) {
12421
+ const { baselineSha, headSha } = pinEpochBaseline(
12422
+ params.repoRoot,
12423
+ params.sessionId,
12424
+ params.epoch
12425
+ );
12426
+ const uploaded = await uploadEpochBaseline({ ...params, baselineSha });
12427
+ if (!uploaded) return null;
12428
+ return { baselineSha, baselineTreeSha: uploaded.baselineTreeSha, headSha };
12305
12429
  }
12306
12430
  async function initializeSession(cwd, tool, sessionId) {
12307
12431
  const project = await findProjectForCwd(cwd);
@@ -12309,57 +12433,25 @@ async function initializeSession(cwd, tool, sessionId) {
12309
12433
  appendLog("warn", "git-traces: no hillclimb project config found, skipping");
12310
12434
  return null;
12311
12435
  }
12312
- const { config } = project;
12313
- const identity = await loadIdentity(config.apiBaseUrl);
12314
- if (!identity) {
12315
- appendLog(
12316
- "warn",
12317
- `git-traces: no saved login for ${config.apiBaseUrl}, skipping`
12318
- );
12319
- return null;
12320
- }
12321
- const client = new PlatformClient(config.apiBaseUrl, identity.sessionCookie);
12322
- const toolLabel = TOOL_LABELS[tool] ?? "Claude";
12323
- const now = /* @__PURE__ */ new Date();
12324
- const shortId = sessionId.slice(0, 12);
12325
- const contribution = await client.createContribution(config.workspaceId, {
12326
- contributionTypeSlug: GIT_TRACES_SLUG,
12327
- title: `${toolLabel} session ${shortId} \u2014 ${formatTitleTimestamp2(now)}`,
12328
- body: `Session ID: ${sessionId}
12329
- Tool: ${toolLabel}
12330
- Repo: ${cwd}
12331
- Uploaded: ${now.toISOString()}`
12332
- });
12333
- await client.submitContribution(contribution.id);
12334
- const artifacts = await openEpoch({
12335
- repoRoot: cwd,
12336
- client,
12337
- contributionId: contribution.id,
12338
- sessionId,
12339
- tool,
12340
- epoch: 1,
12341
- prevHeadSha: null,
12342
- transitionKind: "initial"
12343
- });
12344
- if (!artifacts) return null;
12436
+ const { baselineSha, headSha } = pinEpochBaseline(cwd, sessionId, 1);
12345
12437
  const state = {
12346
12438
  schemaVersion: CURRENT_SCHEMA_VERSION,
12347
12439
  sessionId,
12348
- contributionId: contribution.id,
12349
- baselineSha: artifacts.baselineSha,
12350
- baselineTreeSha: artifacts.baselineTreeSha,
12351
- lastSnapshotSha: artifacts.baselineSha,
12352
- lastSnapshotTreeSha: artifacts.baselineTreeSha,
12440
+ contributionId: null,
12441
+ baselineSha,
12442
+ baselineTreeSha: null,
12443
+ lastSnapshotSha: baselineSha,
12444
+ lastSnapshotTreeSha: null,
12353
12445
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
12354
12446
  epoch: 1,
12355
12447
  turnCount: 0,
12356
- headSha: artifacts.headSha,
12448
+ headSha,
12357
12449
  repoRoot: cwd
12358
12450
  };
12359
12451
  await writeSessionState(state, tool);
12360
12452
  appendLog(
12361
12453
  "info",
12362
- `git-traces: session started (epoch=1, baseline=${artifacts.baselineSha.slice(0, 8)}, contribution=${contribution.id})`
12454
+ `git-traces: session pending (baseline=${baselineSha.slice(0, 8)}, awaiting first turn)`
12363
12455
  );
12364
12456
  return state;
12365
12457
  }
@@ -12417,6 +12509,53 @@ async function handleStop(payload, tool) {
12417
12509
  project.config.apiBaseUrl,
12418
12510
  identity.sessionCookie
12419
12511
  );
12512
+ if (state.contributionId === null) {
12513
+ const toolLabel = TOOL_LABELS[tool] ?? "Claude";
12514
+ const now = /* @__PURE__ */ new Date();
12515
+ const shortId = state.sessionId.slice(0, 12);
12516
+ const contribution = await client.createContribution(
12517
+ project.config.workspaceId,
12518
+ {
12519
+ contributionTypeSlug: GIT_TRACES_SLUG,
12520
+ title: `${toolLabel} session ${shortId} \u2014 ${formatTitleTimestamp2(now)}`,
12521
+ body: `Session ID: ${state.sessionId}
12522
+ Tool: ${toolLabel}
12523
+ Repo: ${cwd}
12524
+ Uploaded: ${now.toISOString()}`
12525
+ }
12526
+ );
12527
+ await client.submitContribution(contribution.id);
12528
+ const uploaded = await uploadEpochBaseline({
12529
+ repoRoot: cwd,
12530
+ client,
12531
+ contributionId: contribution.id,
12532
+ sessionId: state.sessionId,
12533
+ tool,
12534
+ epoch: 1,
12535
+ baselineSha: state.baselineSha,
12536
+ prevHeadSha: null,
12537
+ transitionKind: "initial",
12538
+ startedAt: state.startedAt
12539
+ });
12540
+ if (!uploaded) return;
12541
+ state.contributionId = contribution.id;
12542
+ state.baselineTreeSha = uploaded.baselineTreeSha;
12543
+ state.lastSnapshotTreeSha = uploaded.baselineTreeSha;
12544
+ await writeSessionState(state, tool);
12545
+ appendLog(
12546
+ "info",
12547
+ `git-traces: session registered on first turn (epoch=1, baseline=${state.baselineSha.slice(0, 8)}, contribution=${contribution.id})`
12548
+ );
12549
+ }
12550
+ const contributionId = state.contributionId;
12551
+ const lastSnapshotTreeSha = state.lastSnapshotTreeSha;
12552
+ if (contributionId === null || lastSnapshotTreeSha === null) {
12553
+ appendLog(
12554
+ "error",
12555
+ "git-traces: invariant violation \u2014 session state missing contributionId or tree SHA after lazy init"
12556
+ );
12557
+ return;
12558
+ }
12420
12559
  const currentHeadSha = captureHeadSha(cwd);
12421
12560
  if (currentHeadSha !== state.headSha) {
12422
12561
  const transitionKind = detectTransitionKind(
@@ -12432,12 +12571,13 @@ async function handleStop(payload, tool) {
12432
12571
  const artifacts = await openEpoch({
12433
12572
  repoRoot: cwd,
12434
12573
  client,
12435
- contributionId: state.contributionId,
12574
+ contributionId,
12436
12575
  sessionId: state.sessionId,
12437
12576
  tool,
12438
12577
  epoch: nextEpoch,
12439
12578
  prevHeadSha: state.headSha || null,
12440
- transitionKind
12579
+ transitionKind,
12580
+ startedAt: state.startedAt
12441
12581
  });
12442
12582
  if (!artifacts) return;
12443
12583
  const next = {
@@ -12457,7 +12597,7 @@ async function handleStop(payload, tool) {
12457
12597
  const currentTreeSha = buildSnapshotTree(cwd, currentSha);
12458
12598
  const patchBuffer = createTreeDiffPatchGz(
12459
12599
  cwd,
12460
- state.lastSnapshotTreeSha,
12600
+ lastSnapshotTreeSha,
12461
12601
  currentTreeSha
12462
12602
  );
12463
12603
  if (!patchBuffer) {
@@ -12478,7 +12618,7 @@ async function handleStop(payload, tool) {
12478
12618
  const filename = `${prefix}-${turnLabel}-${recordedAt}.patch.gz`;
12479
12619
  await uploadFile(
12480
12620
  client,
12481
- state.contributionId,
12621
+ contributionId,
12482
12622
  filename,
12483
12623
  "application/gzip",
12484
12624
  patchBuffer
@@ -12514,9 +12654,15 @@ async function handleSessionEnd(payload, tool) {
12514
12654
 
12515
12655
  // src/git-traces/index.ts
12516
12656
  var WORKER_ENV_FLAG2 = "HILLCLIMB_GIT_TRACES_WORKER";
12517
- var TOOL_ENV_FLAG = "HILLCLIMB_GIT_TRACES_TOOL";
12518
- var KNOWN_TOOLS = /* @__PURE__ */ new Set(["claude", "codex", "cursor", "opencode"]);
12519
- function parseToolArg(argv) {
12657
+ var TOOL_ENV_FLAG2 = "HILLCLIMB_GIT_TRACES_TOOL";
12658
+ var KNOWN_TOOLS = /* @__PURE__ */ new Set([
12659
+ "claude",
12660
+ "codex",
12661
+ "copilot-chat",
12662
+ "cursor",
12663
+ "opencode"
12664
+ ]);
12665
+ function parseToolArg2(argv) {
12520
12666
  for (let i = 0; i < argv.length; i++) {
12521
12667
  const a = argv[i];
12522
12668
  if (a.startsWith("--tool=")) return a.slice("--tool=".length);
@@ -12537,7 +12683,7 @@ async function runGitTraces() {
12537
12683
  await runGitTracesWorker();
12538
12684
  return;
12539
12685
  }
12540
- const toolArg = parseToolArg(process.argv.slice(2));
12686
+ const toolArg = parseToolArg2(process.argv.slice(2));
12541
12687
  appendLog(
12542
12688
  "info",
12543
12689
  `git-traces hook invoked (pid ${process.pid}, tool=${toolArg ?? "<none>"})`
@@ -12578,7 +12724,7 @@ async function runGitTraces() {
12578
12724
  env: {
12579
12725
  ...process.env,
12580
12726
  [WORKER_ENV_FLAG2]: "1",
12581
- [TOOL_ENV_FLAG]: toolArg
12727
+ [TOOL_ENV_FLAG2]: toolArg
12582
12728
  }
12583
12729
  }
12584
12730
  );
@@ -12600,7 +12746,7 @@ async function runGitTraces() {
12600
12746
  }
12601
12747
  }
12602
12748
  async function runGitTracesWorker() {
12603
- const tool = process.env[TOOL_ENV_FLAG] ?? parseToolArg(process.argv.slice(2)) ?? null;
12749
+ const tool = process.env[TOOL_ENV_FLAG2] ?? parseToolArg2(process.argv.slice(2)) ?? null;
12604
12750
  appendLog(
12605
12751
  "info",
12606
12752
  `git-traces worker started (pid ${process.pid}, tool=${tool ?? "<none>"})`
@@ -13174,8 +13320,106 @@ var CodexSource = class {
13174
13320
  }
13175
13321
  };
13176
13322
 
13323
+ // src/sources/copilotChat.ts
13324
+ import fs15 from "fs";
13325
+ import os11 from "os";
13326
+ import path18 from "path";
13327
+ import { fileURLToPath } from "url";
13328
+ function vsCodeUserDirs() {
13329
+ const home = os11.homedir();
13330
+ const dirs = [
13331
+ path18.join(home, "Library", "Application Support", "Code", "User"),
13332
+ path18.join(home, ".config", "Code", "User")
13333
+ ];
13334
+ if (process.env.APPDATA) {
13335
+ dirs.push(path18.join(process.env.APPDATA, "Code", "User"));
13336
+ }
13337
+ return dirs;
13338
+ }
13339
+ function uriToFsPath(uri) {
13340
+ if (!uri.startsWith("file://")) return null;
13341
+ try {
13342
+ return fileURLToPath(uri);
13343
+ } catch {
13344
+ return null;
13345
+ }
13346
+ }
13347
+ async function readWorkspaceFolder(workspaceJsonPath) {
13348
+ let raw;
13349
+ try {
13350
+ raw = await fs15.promises.readFile(workspaceJsonPath, "utf-8");
13351
+ } catch {
13352
+ return null;
13353
+ }
13354
+ let data;
13355
+ try {
13356
+ data = JSON.parse(raw);
13357
+ } catch {
13358
+ return null;
13359
+ }
13360
+ if (!data || typeof data !== "object") return null;
13361
+ const obj = data;
13362
+ if (typeof obj.folder === "string") {
13363
+ return uriToFsPath(obj.folder) ?? obj.folder;
13364
+ }
13365
+ return null;
13366
+ }
13367
+ var CopilotChatSource = class {
13368
+ name = "copilot-chat";
13369
+ async scan() {
13370
+ const results = [];
13371
+ for (const userDir of vsCodeUserDirs()) {
13372
+ const workspaceStorage = path18.join(userDir, "workspaceStorage");
13373
+ let hashDirs;
13374
+ try {
13375
+ hashDirs = await fs15.promises.readdir(workspaceStorage, {
13376
+ withFileTypes: true
13377
+ });
13378
+ } catch {
13379
+ continue;
13380
+ }
13381
+ for (const hash of hashDirs) {
13382
+ if (!hash.isDirectory()) continue;
13383
+ const wsRoot = path18.join(workspaceStorage, hash.name);
13384
+ const transcriptsDir = path18.join(
13385
+ wsRoot,
13386
+ "GitHub.copilot-chat",
13387
+ "transcripts"
13388
+ );
13389
+ let transcriptEntries;
13390
+ try {
13391
+ transcriptEntries = await fs15.promises.readdir(transcriptsDir, {
13392
+ withFileTypes: true
13393
+ });
13394
+ } catch {
13395
+ continue;
13396
+ }
13397
+ const repoPath = await readWorkspaceFolder(
13398
+ path18.join(wsRoot, "workspace.json")
13399
+ );
13400
+ if (!repoPath) continue;
13401
+ for (const entry of transcriptEntries) {
13402
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
13403
+ const sessionId = entry.name.slice(0, -".jsonl".length);
13404
+ results.push({
13405
+ sourceName: this.name,
13406
+ absolutePath: path18.join(transcriptsDir, entry.name),
13407
+ repoPath,
13408
+ metadata: { sessionId }
13409
+ });
13410
+ }
13411
+ }
13412
+ }
13413
+ return results;
13414
+ }
13415
+ };
13416
+
13177
13417
  // src/sources/index.ts
13178
- var sources = [new ClaudeSource(), new CodexSource()];
13418
+ var sources = [
13419
+ new ClaudeSource(),
13420
+ new CodexSource(),
13421
+ new CopilotChatSource()
13422
+ ];
13179
13423
 
13180
13424
  // src/cli.ts
13181
13425
  function reportRedactionStats(noun, stats) {
@@ -13199,7 +13443,7 @@ function reportRedactionStats(noun, stats) {
13199
13443
  async function filterByTimeRange(group, range) {
13200
13444
  const results = await Promise.all(
13201
13445
  group.files.map(
13202
- (f) => fs15.promises.stat(f.absolutePath).then((s) => ({ file: f, stat: s })).catch(() => null)
13446
+ (f) => fs16.promises.stat(f.absolutePath).then((s) => ({ file: f, stat: s })).catch(() => null)
13203
13447
  )
13204
13448
  );
13205
13449
  const filtered = [];
@@ -13226,10 +13470,10 @@ async function runInteractive() {
13226
13470
  s.start(`Scanning ${source.name} logs...`);
13227
13471
  const allFiles = await source.scan();
13228
13472
  const allGroups = await mergeByRepo(allFiles);
13229
- const repoRoot = path18.resolve(repo.root);
13473
+ const repoRoot = path19.resolve(repo.root);
13230
13474
  const matching = allGroups.filter((g) => {
13231
- const resolved = path18.resolve(g.repoPath);
13232
- return resolved === repoRoot || resolved.startsWith(repoRoot + path18.sep);
13475
+ const resolved = path19.resolve(g.repoPath);
13476
+ return resolved === repoRoot || resolved.startsWith(repoRoot + path19.sep);
13233
13477
  });
13234
13478
  if (matching.length === 0) {
13235
13479
  s.stop(`No ${source.name} logs found for ${repo.name}.`);
@@ -13260,7 +13504,7 @@ async function runInteractive() {
13260
13504
  }
13261
13505
  }
13262
13506
  const envFileNames = await discoverEnvFiles(repoRoot);
13263
- const envFilePaths = envFileNames.map((n) => path18.join(repoRoot, n));
13507
+ const envFilePaths = envFileNames.map((n) => path19.join(repoRoot, n));
13264
13508
  const additionalFiles = await promptSecretFiles(envFileNames);
13265
13509
  const secretResult = await collectSecrets(
13266
13510
  repoRoot,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hillclimb",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Extract and export AI coding tool logs grouped by repo",
5
5
  "license": "MIT",
6
6
  "type": "module",