hillclimb 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +184 -24
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -12040,10 +12040,8 @@ function expectedKinds(tool, eventKind2, payload) {
12040
12040
  }
12041
12041
  return /* @__PURE__ */ new Set(["agent", "git"]);
12042
12042
  }
12043
- async function transcriptFingerprint(payload) {
12044
- const transcriptPath = stringOrNull(payload.transcript_path);
12045
- if (!transcriptPath) return {};
12046
- const resolved = path13.resolve(transcriptPath);
12043
+ var TRANSCRIPT_STAT_ENV = "HILLCLIMB_DEBUG_LOG_TRANSCRIPT_STAT";
12044
+ async function statTranscriptFingerprint(resolved) {
12047
12045
  try {
12048
12046
  const stat = await fs12.promises.stat(resolved);
12049
12047
  return {
@@ -12055,6 +12053,44 @@ async function transcriptFingerprint(payload) {
12055
12053
  return { transcriptPath: resolved };
12056
12054
  }
12057
12055
  }
12056
+ function pinnedTranscriptFingerprint(resolved) {
12057
+ const raw = process.env[TRANSCRIPT_STAT_ENV];
12058
+ if (!raw) return null;
12059
+ try {
12060
+ const pinned = JSON.parse(raw);
12061
+ return pinned.transcriptPath === resolved ? pinned : null;
12062
+ } catch {
12063
+ return null;
12064
+ }
12065
+ }
12066
+ async function transcriptFingerprint(payload) {
12067
+ const transcriptPath = stringOrNull(payload.transcript_path);
12068
+ if (!transcriptPath) return {};
12069
+ const resolved = path13.resolve(transcriptPath);
12070
+ return pinnedTranscriptFingerprint(resolved) ?? await statTranscriptFingerprint(resolved);
12071
+ }
12072
+ async function captureDebugLogParentEnv(rawPayload) {
12073
+ let payload;
12074
+ try {
12075
+ payload = JSON.parse(rawPayload);
12076
+ } catch {
12077
+ return {};
12078
+ }
12079
+ if (typeof payload !== "object" || payload === null) return {};
12080
+ const transcriptPath = stringOrNull(payload.transcript_path);
12081
+ if (classifyHookEvent(payload.hook_event_name) !== "stop" || stringOrNull(payload.turn_id) !== null || !transcriptPath) {
12082
+ return {};
12083
+ }
12084
+ return {
12085
+ [TRANSCRIPT_STAT_ENV]: JSON.stringify(
12086
+ await statTranscriptFingerprint(path13.resolve(transcriptPath))
12087
+ )
12088
+ };
12089
+ }
12090
+ async function applyDebugLogParentEnv(env, rawPayload) {
12091
+ delete env[TRANSCRIPT_STAT_ENV];
12092
+ Object.assign(env, await captureDebugLogParentEnv(rawPayload));
12093
+ }
12058
12094
  async function eventContext(tool, payload) {
12059
12095
  const eventKind2 = classifyHookEvent(payload.hook_event_name);
12060
12096
  if (!eventKind2) return null;
@@ -12085,6 +12121,10 @@ async function eventContext(tool, payload) {
12085
12121
  // Only stop events without stable turn IDs need the transcript to
12086
12122
  // disambiguate. When a turn_id exists, including mutable transcript
12087
12123
  // mtime/size can split agent+git completions for the same logical event.
12124
+ // Workers prefer the stat their hook parent pinned via
12125
+ // TRANSCRIPT_STAT_ENV, so completion-time skew between the agent and git
12126
+ // workers no longer splits the eventId; the residual window is the
12127
+ // parents' dispatch skew (see captureDebugLogParentEnv).
12088
12128
  ...eventKind2 === "stop" && !turnId ? await transcriptFingerprint(payload) : {}
12089
12129
  };
12090
12130
  const eventId = crypto2.createHash("sha256").update(JSON.stringify(fingerprint)).digest("hex").slice(0, 32);
@@ -12373,7 +12413,7 @@ async function uploadDebugLog(ctx, state, sessionState, onContributionCreated) {
12373
12413
  `Session ID: ${ctx.sessionId}`,
12374
12414
  `Tool: ${label}`,
12375
12415
  `Repo: ${ctx.repoRoot}`,
12376
- "Log snapshots: complete Hillclimb daily log snapshots captured at hook events"
12416
+ "Log snapshots: machine-wide Hillclimb daily log snapshots captured at hook events; may include entries from other sessions on this machine"
12377
12417
  ].join("\n")
12378
12418
  } : {
12379
12419
  contributionTitle: `${label} debug log ${shortSession} - ${epochSeconds}`,
@@ -12788,6 +12828,21 @@ async function writeCursorState(repoRoot, tool, sessionId, cursor) {
12788
12828
  });
12789
12829
  await fs14.promises.rename(tmp, file);
12790
12830
  }
12831
+ async function setUploadSessionEndedAt(repoRoot, tool, sessionId, sessionEndedAt) {
12832
+ const state = await readUploadState(repoRoot, tool, sessionId);
12833
+ if (!state) return false;
12834
+ await writeUploadState({ ...state, sessionEndedAt });
12835
+ const now = /* @__PURE__ */ new Date();
12836
+ try {
12837
+ await fs14.promises.utimes(
12838
+ cursorFileFor(repoRoot, tool, sessionId),
12839
+ now,
12840
+ now
12841
+ );
12842
+ } catch {
12843
+ }
12844
+ return true;
12845
+ }
12791
12846
  async function acquireLock2(repoRoot, tool, sessionId, retries = lockRetries(), delayMs = lockRetryDelayMs()) {
12792
12847
  const lockPath = lockFileFor(repoRoot, tool, sessionId);
12793
12848
  await fs14.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
@@ -13240,10 +13295,22 @@ async function uploadSession(args) {
13240
13295
  } else {
13241
13296
  appendLog(
13242
13297
  "info",
13243
- `[${sessionId}] skipping ${sourceTool} ${eventLabel} upload (no new complete transcript lines past offset ${cursor.rawByteOffset}${isSessionEnd ? "; local state cleared" : ""})`
13298
+ `[${sessionId}] skipping ${sourceTool} ${eventLabel} upload (no new complete transcript lines past offset ${cursor.rawByteOffset}${isSessionEnd ? "; session state retained for late hooks" : ""})`
13244
13299
  );
13245
13300
  if (isSessionEnd) {
13246
- await deleteUploadState(repoRoot, sourceTool, sessionId);
13301
+ await setUploadSessionEndedAt(
13302
+ repoRoot,
13303
+ sourceTool,
13304
+ sessionId,
13305
+ new Date(recordedAt).toISOString()
13306
+ );
13307
+ } else if (prior?.sessionEndedAt) {
13308
+ await setUploadSessionEndedAt(
13309
+ repoRoot,
13310
+ sourceTool,
13311
+ sessionId,
13312
+ void 0
13313
+ );
13247
13314
  }
13248
13315
  return true;
13249
13316
  }
@@ -13313,14 +13380,20 @@ async function uploadSession(args) {
13313
13380
  uploadCount: prior.uploadCount + 1,
13314
13381
  codexLineage,
13315
13382
  codexLineageChecked,
13316
- codexLineageStamped: codexLineage
13383
+ codexLineageStamped: codexLineage,
13384
+ sessionEndedAt: void 0
13317
13385
  });
13318
13386
  appendLog(
13319
13387
  "info",
13320
13388
  `[${sessionId}] refreshed ${sourceTool} lineage meta for unchanged epoch=${epoch2}`
13321
13389
  );
13322
13390
  if (isSessionEnd) {
13323
- await deleteUploadState(repoRoot, sourceTool, sessionId);
13391
+ await setUploadSessionEndedAt(
13392
+ repoRoot,
13393
+ sourceTool,
13394
+ sessionId,
13395
+ new Date(recordedAt).toISOString()
13396
+ );
13324
13397
  }
13325
13398
  return true;
13326
13399
  }
@@ -13491,10 +13564,15 @@ async function uploadSession(args) {
13491
13564
  `Uploaded session ${sessionId} patch epoch=${epoch2} turn=${turn} to contribution ${contributionId2} (offset ${baseCursor.rawByteOffset} \u2192 ${nextCursor.rawByteOffset})`
13492
13565
  );
13493
13566
  if (isSessionEnd) {
13494
- await deleteUploadState(repoRoot, sourceTool, sessionId);
13567
+ await setUploadSessionEndedAt(
13568
+ repoRoot,
13569
+ sourceTool,
13570
+ sessionId,
13571
+ new Date(recordedAt).toISOString()
13572
+ );
13495
13573
  appendLog(
13496
13574
  "info",
13497
- `[${sessionId}] session complete \u2014 contribution ${contributionId2}, epoch ${epoch2}, ${turn} patch(es); local state cleared`
13575
+ `[${sessionId}] session complete \u2014 contribution ${contributionId2}, epoch ${epoch2}, ${turn} patch(es); session state retained for late hooks`
13498
13576
  );
13499
13577
  }
13500
13578
  return true;
@@ -13522,8 +13600,21 @@ async function uploadSession(args) {
13522
13600
  "info",
13523
13601
  `[${sessionId}] skipping ${sourceTool} ${eventLabel} upload (no complete transcript line yet)`
13524
13602
  );
13525
- if (isSessionEnd)
13526
- await deleteUploadState(repoRoot, sourceTool, sessionId);
13603
+ if (isSessionEnd) {
13604
+ await setUploadSessionEndedAt(
13605
+ repoRoot,
13606
+ sourceTool,
13607
+ sessionId,
13608
+ new Date(recordedAt).toISOString()
13609
+ );
13610
+ } else if (prior?.sessionEndedAt) {
13611
+ await setUploadSessionEndedAt(
13612
+ repoRoot,
13613
+ sourceTool,
13614
+ sessionId,
13615
+ void 0
13616
+ );
13617
+ }
13527
13618
  return true;
13528
13619
  }
13529
13620
  const epoch = (prior?.epoch ?? restored?.epoch ?? 0) + 1;
@@ -13670,10 +13761,15 @@ Uploaded: ${now.toISOString()}`;
13670
13761
  `Uploaded session ${sessionId} snapshot epoch=${epoch} to project ${config.projectSlug} (${config.projectId}) as contribution ${contributionId}`
13671
13762
  );
13672
13763
  if (isSessionEnd) {
13673
- await deleteUploadState(repoRoot, sourceTool, sessionId);
13764
+ await setUploadSessionEndedAt(
13765
+ repoRoot,
13766
+ sourceTool,
13767
+ sessionId,
13768
+ new Date(recordedAt).toISOString()
13769
+ );
13674
13770
  appendLog(
13675
13771
  "info",
13676
- `[${sessionId}] session complete \u2014 contribution ${contributionId}, epoch ${epoch} snapshot; local state cleared`
13772
+ `[${sessionId}] session complete \u2014 contribution ${contributionId}, epoch ${epoch} snapshot; session state retained for late hooks`
13677
13773
  );
13678
13774
  }
13679
13775
  return true;
@@ -13727,6 +13823,7 @@ async function runUpload() {
13727
13823
  [FLOW_ID_ENV]: flowId
13728
13824
  };
13729
13825
  if (toolArg) workerEnv[TOOL_ENV_FLAG] = toolArg;
13826
+ await applyDebugLogParentEnv(workerEnv, raw);
13730
13827
  try {
13731
13828
  const child = spawn2(process.execPath, [entrypoint, "upload"], {
13732
13829
  detached: true,
@@ -14580,6 +14677,18 @@ function cleanupSessionRefs(repoRoot, sessionId) {
14580
14677
  }
14581
14678
  }
14582
14679
  }
14680
+ function countScopedTurnTreeRefs(repoRoot, sessionId, epochPrefix3) {
14681
+ const base = `refs/hillclimb/scoped/${sessionId}/turns/`;
14682
+ try {
14683
+ return git(repoRoot, [
14684
+ "for-each-ref",
14685
+ "--format=%(refname)",
14686
+ epochPrefix3 ? `${base}${epochPrefix3}/` : base
14687
+ ]).split("\n").filter((ref) => ref.endsWith("/tree")).length;
14688
+ } catch {
14689
+ return 0;
14690
+ }
14691
+ }
14583
14692
 
14584
14693
  // src/git-traces/session-state.ts
14585
14694
  import crypto6 from "crypto";
@@ -14731,6 +14840,27 @@ async function writeStateFile(file, state) {
14731
14840
  });
14732
14841
  await fs17.promises.rename(tmp, file);
14733
14842
  }
14843
+ async function touchSessionState(repoRoot, tool, sessionId) {
14844
+ const now = /* @__PURE__ */ new Date();
14845
+ try {
14846
+ await fs17.promises.utimes(
14847
+ stateFileForRepo(repoRoot, tool, sessionId),
14848
+ now,
14849
+ now
14850
+ );
14851
+ } catch {
14852
+ }
14853
+ }
14854
+ async function statSessionStateMtime(repoRoot, tool, sessionId) {
14855
+ try {
14856
+ const stat = await fs17.promises.stat(
14857
+ stateFileForRepo(repoRoot, tool, sessionId)
14858
+ );
14859
+ return stat.mtimeMs;
14860
+ } catch {
14861
+ return null;
14862
+ }
14863
+ }
14734
14864
  async function deleteStateFile(file) {
14735
14865
  try {
14736
14866
  await fs17.promises.unlink(file);
@@ -15317,7 +15447,7 @@ async function cleanupStaleScopedSessionStates(repoRoot, tool, currentSessionId,
15317
15447
  for (const { state, mtimeMs } of states) {
15318
15448
  if (state.sessionId === currentSessionId) continue;
15319
15449
  const startedAtMs = Date.parse(state.startedAt);
15320
- const ageBaseMs = Number.isNaN(startedAtMs) ? mtimeMs : startedAtMs;
15450
+ const ageBaseMs = Number.isNaN(startedAtMs) ? mtimeMs : Math.max(startedAtMs, mtimeMs);
15321
15451
  if (nowMs - ageBaseMs <= ttlMs) continue;
15322
15452
  if (await readLegacySessionState(repoRoot, tool, state.sessionId)) {
15323
15453
  appendLog(
@@ -15336,9 +15466,32 @@ async function cleanupStaleScopedSessionStates(repoRoot, tool, currentSessionId,
15336
15466
  continue;
15337
15467
  }
15338
15468
  try {
15469
+ const lockedMtimeMs = await statSessionStateMtime(
15470
+ repoRoot,
15471
+ tool,
15472
+ state.sessionId
15473
+ );
15474
+ if (lockedMtimeMs === null) continue;
15475
+ const lockedBaseMs = Number.isNaN(startedAtMs) ? lockedMtimeMs : Math.max(startedAtMs, lockedMtimeMs);
15476
+ if (nowMs - lockedBaseMs <= ttlMs) {
15477
+ appendLog(
15478
+ "info",
15479
+ `git-traces: leaving session ${state.sessionId} (state refreshed before reap)`
15480
+ );
15481
+ continue;
15482
+ }
15483
+ const pinnedTurnTrees = countScopedTurnTreeRefs(
15484
+ repoRoot,
15485
+ state.sessionId,
15486
+ epochPrefix2(state.epoch)
15487
+ );
15488
+ const totalPinnedTurnTrees = countScopedTurnTreeRefs(
15489
+ repoRoot,
15490
+ state.sessionId
15491
+ );
15339
15492
  appendLog(
15340
15493
  "info",
15341
- `git-traces: cleaning up stale scoped session ${state.sessionId}`
15494
+ `git-traces: cleaning up stale scoped session ${state.sessionId} (epoch=${state.epoch}, uploadedTurns=${state.turnCount}, pinnedTurnTrees=${pinnedTurnTrees}, unuploadedPinnedTurns=${Math.max(0, pinnedTurnTrees - state.turnCount)}, totalPinnedTurnTrees=${totalPinnedTurnTrees}, contribution=${state.contributionId ?? "<none>"})`
15342
15495
  );
15343
15496
  cleanupSessionRefs(repoRoot, state.sessionId);
15344
15497
  await deleteSessionState2(repoRoot, tool, state.sessionId);
@@ -15384,6 +15537,7 @@ async function prepareSessionStartRepo(repo, tool, sessionId, lineage) {
15384
15537
  const scoped = (await readSessionStateCandidates(repoRoot, tool, sessionId)).scoped?.state;
15385
15538
  if (scoped) {
15386
15539
  pinScopedStateRefs(repoRoot, scoped);
15540
+ await touchSessionState(repoRoot, tool, sessionId);
15387
15541
  return "initialized";
15388
15542
  }
15389
15543
  const state = await initializeSession(
@@ -15935,7 +16089,11 @@ async function processStopRepo(repo, tool, sessionId, recordedAt, lineageCheck,
15935
16089
  { limits }
15936
16090
  );
15937
16091
  if (!patchBuffer) {
15938
- if (omissionsChanged) await persistState(state);
16092
+ if (omissionsChanged) {
16093
+ await persistState(state);
16094
+ } else {
16095
+ await touchSessionState(state.repoRoot, tool, sessionId);
16096
+ }
15939
16097
  appendLog(
15940
16098
  "info",
15941
16099
  `git-traces: turn produced identical snapshot tree, skipping upload (repo=${repoRoot}, project=${config.projectId})`
@@ -16439,6 +16597,13 @@ async function runGitTraces() {
16439
16597
  appendLog("error", "git-traces: process.argv[1] is empty");
16440
16598
  return;
16441
16599
  }
16600
+ const workerEnv = {
16601
+ ...process.env,
16602
+ [WORKER_ENV_FLAG2]: "1",
16603
+ [TOOL_ENV_FLAG2]: tool,
16604
+ [FLOW_ID_ENV2]: flowId
16605
+ };
16606
+ await applyDebugLogParentEnv(workerEnv, raw);
16442
16607
  try {
16443
16608
  const child = spawn3(
16444
16609
  process.execPath,
@@ -16446,12 +16611,7 @@ async function runGitTraces() {
16446
16611
  {
16447
16612
  detached: true,
16448
16613
  stdio: ["pipe", "ignore", "ignore"],
16449
- env: {
16450
- ...process.env,
16451
- [WORKER_ENV_FLAG2]: "1",
16452
- [TOOL_ENV_FLAG2]: tool,
16453
- [FLOW_ID_ENV2]: flowId
16454
- }
16614
+ env: workerEnv
16455
16615
  }
16456
16616
  );
16457
16617
  child.on("error", (err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hillclimb",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Extract and export AI coding tool logs grouped by repo",
5
5
  "license": "MIT",
6
6
  "type": "module",