hillclimb 0.7.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.
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import fs21 from "fs";
5
- import path23 from "path";
4
+ import fs23 from "fs";
5
+ import path24 from "path";
6
6
  import * as p6 from "@clack/prompts";
7
7
 
8
8
  // src/commands/init.ts
@@ -13,6 +13,7 @@ import * as p3 from "@clack/prompts";
13
13
  import fs from "fs";
14
14
  import os from "os";
15
15
  import path from "path";
16
+ var MAX_SNAPSHOT_LIMIT_OVERRIDE_BYTES = 100 * 1024 * 1024;
16
17
  var DEFAULT_CONFIG_DIR = path.join(os.homedir(), ".hillclimb");
17
18
  function normalizeProjectConfig(raw) {
18
19
  if (!raw || typeof raw !== "object") return null;
@@ -34,9 +35,24 @@ function normalizeProjectConfig(raw) {
34
35
  workspaceName: config.workspaceName ?? projectName,
35
36
  contributionTypeSlug: config.contributionTypeSlug,
36
37
  contributionTypeName: config.contributionTypeName,
37
- autoSubmit: config.autoSubmit
38
+ autoSubmit: config.autoSubmit,
39
+ snapshotLimits: normalizeSnapshotLimits(config.snapshotLimits)
38
40
  };
39
41
  }
42
+ function normalizeSnapshotLimits(raw) {
43
+ if (!raw || typeof raw !== "object") return void 0;
44
+ const limits = { ...raw };
45
+ if (!isValidSnapshotLimit(limits.maxTrackedFileBytes))
46
+ delete limits.maxTrackedFileBytes;
47
+ if (!isValidSnapshotLimit(limits.maxUntrackedFileBytes))
48
+ delete limits.maxUntrackedFileBytes;
49
+ if (!isValidSnapshotLimit(limits.maxBinaryFileBytes))
50
+ delete limits.maxBinaryFileBytes;
51
+ return Object.keys(limits).length > 0 ? limits : void 0;
52
+ }
53
+ function isValidSnapshotLimit(value) {
54
+ return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= MAX_SNAPSHOT_LIMIT_OVERRIDE_BYTES;
55
+ }
40
56
  function configDir() {
41
57
  return process.env.HILLCLIMB_CONFIG_DIR ?? DEFAULT_CONFIG_DIR;
42
58
  }
@@ -72,7 +88,12 @@ async function upsertProject(repoRoot, config) {
72
88
  const file = await loadProjects();
73
89
  const normalized = normalizeProjectConfig(config);
74
90
  if (!normalized) throw new Error("Invalid project config.");
75
- file.projects[path.resolve(repoRoot)] = normalized;
91
+ const resolvedRepoRoot = path.resolve(repoRoot);
92
+ const existing = file.projects[resolvedRepoRoot];
93
+ if (!normalized.snapshotLimits && existing?.snapshotLimits) {
94
+ normalized.snapshotLimits = existing.snapshotLimits;
95
+ }
96
+ file.projects[resolvedRepoRoot] = normalized;
76
97
  await saveProjects(file);
77
98
  }
78
99
  async function findProjectForCwd(cwd) {
@@ -350,6 +371,9 @@ var PlatformError = class extends Error {
350
371
  this.name = "PlatformError";
351
372
  }
352
373
  };
374
+ function isContributionUploadNotUploaded(err) {
375
+ return err instanceof PlatformError && err.status === 409 && err.code === "CONTRIBUTION_UPLOAD_NOT_UPLOADED";
376
+ }
353
377
  var PlatformClient = class {
354
378
  apiBaseUrl;
355
379
  sessionCookie;
@@ -2282,16 +2306,444 @@ async function runStatus(args = []) {
2282
2306
 
2283
2307
  // src/commands/upload.ts
2284
2308
  import { spawn as spawn2 } from "child_process";
2285
- import crypto4 from "crypto";
2286
- import fs13 from "fs";
2287
- import os6 from "os";
2288
- import path14 from "path";
2309
+ import crypto5 from "crypto";
2310
+ import fs15 from "fs";
2311
+ import os7 from "os";
2312
+ import path15 from "path";
2313
+
2314
+ // src/codex-lineage.ts
2315
+ import crypto from "crypto";
2316
+ import fs7 from "fs";
2317
+ import os3 from "os";
2318
+ import path9 from "path";
2289
2319
  import readline from "readline";
2320
+ var MAX_PREFIX_CANDIDATES = 32;
2321
+ var MAX_CANDIDATE_BYTES = 64 * 1024 * 1024;
2322
+ var VOLATILE_KEYS = /* @__PURE__ */ new Set([
2323
+ "timestamp",
2324
+ "started_at",
2325
+ "completed_at",
2326
+ "turn_id",
2327
+ "session_id",
2328
+ "forked_from_id",
2329
+ "parent_thread_id",
2330
+ "usage",
2331
+ "rate_limits",
2332
+ "input_tokens",
2333
+ "output_tokens",
2334
+ "cached_input_tokens",
2335
+ "total_tokens",
2336
+ "model_context_window",
2337
+ "context_window",
2338
+ "sequence_number",
2339
+ "duration_ms",
2340
+ "elapsed_ms",
2341
+ "latency_ms",
2342
+ "wall_time_ms"
2343
+ ]);
2344
+ function sessionsDir() {
2345
+ return process.env.HILLCLIMB_CODEX_SESSIONS_DIR ?? path9.join(os3.homedir(), ".codex", "sessions");
2346
+ }
2347
+ function asRecord(value) {
2348
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
2349
+ }
2350
+ function parseSessionMeta(payloadValue) {
2351
+ const payload = asRecord(payloadValue) ?? {};
2352
+ const source = asRecord(payload.source);
2353
+ return {
2354
+ id: typeof payload.id === "string" ? payload.id : null,
2355
+ forkedFromThreadId: typeof payload.forked_from_id === "string" ? payload.forked_from_id : null,
2356
+ isSubagent: payload.thread_source === "subagent" || source !== null && Object.hasOwn(source, "subagent")
2357
+ };
2358
+ }
2359
+ function normalizedValue(value, depth = 0) {
2360
+ if (typeof value === "string") return value.replace(/\r\n/g, "\n");
2361
+ if (Array.isArray(value)) {
2362
+ return value.map((item) => normalizedValue(item, depth + 1));
2363
+ }
2364
+ const record = asRecord(value);
2365
+ if (!record) return value;
2366
+ const normalized = {};
2367
+ for (const key of Object.keys(record).sort()) {
2368
+ if (depth === 0 && (key === "id" || key === "item_id")) continue;
2369
+ if (VOLATILE_KEYS.has(key)) continue;
2370
+ normalized[key] = normalizedValue(record[key], depth + 1);
2371
+ }
2372
+ return normalized;
2373
+ }
2374
+ function canonicalRecord(record) {
2375
+ if (record.type !== "response_item") return null;
2376
+ return JSON.stringify({
2377
+ type: "response_item",
2378
+ payload: normalizedValue(record.payload)
2379
+ });
2380
+ }
2381
+ function eventKind(record) {
2382
+ if (record.type !== "event_msg") return null;
2383
+ const payload = asRecord(record.payload);
2384
+ return typeof payload?.type === "string" ? payload.type : null;
2385
+ }
2386
+ function recordTurnId(record) {
2387
+ const payload = asRecord(record.payload);
2388
+ return typeof payload?.turn_id === "string" ? payload.turn_id : null;
2389
+ }
2390
+ function startTurn(turnId) {
2391
+ return {
2392
+ turnId,
2393
+ hash: crypto.createHash("sha256"),
2394
+ responseItemCount: 0
2395
+ };
2396
+ }
2397
+ async function parseRollout(filePath, maxTurns = Number.POSITIVE_INFINITY) {
2398
+ const stream = fs7.createReadStream(filePath, { encoding: "utf-8" });
2399
+ const lines = readline.createInterface({
2400
+ input: stream,
2401
+ crlfDelay: Infinity
2402
+ });
2403
+ let meta = null;
2404
+ let active = null;
2405
+ let malformed = false;
2406
+ const turns = [];
2407
+ try {
2408
+ for await (const line of lines) {
2409
+ if (!line.trim()) continue;
2410
+ let record;
2411
+ try {
2412
+ const parsed = JSON.parse(line);
2413
+ const parsedRecord = asRecord(parsed);
2414
+ if (!parsedRecord) {
2415
+ malformed = true;
2416
+ continue;
2417
+ }
2418
+ record = parsedRecord;
2419
+ } catch {
2420
+ malformed = true;
2421
+ continue;
2422
+ }
2423
+ if (record.type === "session_meta") {
2424
+ if (meta === null) meta = parseSessionMeta(record.payload);
2425
+ continue;
2426
+ }
2427
+ const kind = eventKind(record);
2428
+ const turnId = recordTurnId(record);
2429
+ if (kind === "task_started") {
2430
+ active = startTurn(turnId);
2431
+ } else if (record.type === "turn_context") {
2432
+ if (active === null || active.turnId !== null && turnId !== null && active.turnId !== turnId) {
2433
+ active = startTurn(turnId);
2434
+ } else if (active.turnId === null) {
2435
+ active.turnId = turnId;
2436
+ }
2437
+ }
2438
+ if (active !== null) {
2439
+ const canonical = canonicalRecord(record);
2440
+ if (canonical !== null) {
2441
+ active.hash.update(canonical).update("\n");
2442
+ active.responseItemCount++;
2443
+ }
2444
+ }
2445
+ if (kind === "task_complete" || kind === "turn_aborted") {
2446
+ if (active !== null && (active.turnId === null || turnId === null || active.turnId === turnId) && active.responseItemCount > 0) {
2447
+ turns.push({
2448
+ turnId: active.turnId ?? turnId,
2449
+ sha256: active.hash.digest("hex")
2450
+ });
2451
+ active = null;
2452
+ if (turns.length >= maxTurns) break;
2453
+ }
2454
+ }
2455
+ }
2456
+ } catch {
2457
+ malformed = true;
2458
+ } finally {
2459
+ lines.close();
2460
+ stream.destroy();
2461
+ }
2462
+ return { meta, turns, malformed, incomplete: active !== null };
2463
+ }
2464
+ async function codexSessionIdFromFile(filePath) {
2465
+ const stream = fs7.createReadStream(filePath, { encoding: "utf-8" });
2466
+ const lines = readline.createInterface({
2467
+ input: stream,
2468
+ crlfDelay: Infinity
2469
+ });
2470
+ try {
2471
+ for await (const line of lines) {
2472
+ if (!line.trim()) continue;
2473
+ try {
2474
+ const record = asRecord(JSON.parse(line));
2475
+ if (record?.type !== "session_meta") return null;
2476
+ return parseSessionMeta(record.payload).id;
2477
+ } catch {
2478
+ return null;
2479
+ }
2480
+ }
2481
+ } catch {
2482
+ return null;
2483
+ } finally {
2484
+ lines.close();
2485
+ stream.destroy();
2486
+ }
2487
+ return null;
2488
+ }
2489
+ async function findCodexRolloutPath(sessionId) {
2490
+ const candidates = [];
2491
+ async function walk(dir) {
2492
+ let entries;
2493
+ try {
2494
+ entries = await fs7.promises.readdir(dir, { withFileTypes: true });
2495
+ } catch {
2496
+ return;
2497
+ }
2498
+ for (const entry of entries) {
2499
+ const full = path9.join(dir, entry.name);
2500
+ if (entry.isDirectory()) {
2501
+ await walk(full);
2502
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl") && entry.name.includes(sessionId)) {
2503
+ candidates.push(full);
2504
+ }
2505
+ }
2506
+ }
2507
+ await walk(sessionsDir());
2508
+ candidates.sort();
2509
+ for (const candidate of candidates) {
2510
+ if (await codexSessionIdFromFile(candidate) === sessionId)
2511
+ return candidate;
2512
+ }
2513
+ return void 0;
2514
+ }
2515
+ function commonPrefixLength(a, b) {
2516
+ const limit = Math.min(a.length, b.length);
2517
+ let count = 0;
2518
+ while (count < limit && a[count]?.sha256 === b[count]?.sha256) count++;
2519
+ return count;
2520
+ }
2521
+ function boundary(turns, inheritedTurnCount, detectedBy) {
2522
+ if (inheritedTurnCount <= 0 || inheritedTurnCount > turns.length) {
2523
+ return void 0;
2524
+ }
2525
+ const replayPrefixSha256 = crypto.createHash("sha256").update("codex-turn-prefix-v1\0").update(String(inheritedTurnCount)).update("\0").update(
2526
+ turns.slice(0, inheritedTurnCount).map((turn) => turn.sha256).join("\0")
2527
+ ).digest("hex");
2528
+ return {
2529
+ format: "codex-turn-prefix-v1",
2530
+ inheritedTurnCount,
2531
+ lastInheritedTurnId: turns[inheritedTurnCount - 1]?.turnId ?? null,
2532
+ replayPrefixSha256,
2533
+ detectedBy
2534
+ };
2535
+ }
2536
+ function uuidV7Timestamp(id) {
2537
+ if (!id || !/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
2538
+ id
2539
+ )) {
2540
+ return null;
2541
+ }
2542
+ return `${id.slice(0, 8)}${id.slice(9, 13)}`.toLowerCase();
2543
+ }
2544
+ function uuidV7ReplayBoundary(meta, turns) {
2545
+ const childTimestamp = uuidV7Timestamp(meta.id);
2546
+ if (!childTimestamp || turns.length === 0) return void 0;
2547
+ const turnTimestamps = [];
2548
+ for (const turn of turns) {
2549
+ const timestamp = uuidV7Timestamp(turn.turnId);
2550
+ if (!timestamp) return void 0;
2551
+ turnTimestamps.push(timestamp);
2552
+ }
2553
+ let inheritedTurnCount = 0;
2554
+ while (inheritedTurnCount < turnTimestamps.length && (turnTimestamps[inheritedTurnCount] ?? childTimestamp) < childTimestamp) {
2555
+ inheritedTurnCount++;
2556
+ }
2557
+ if (inheritedTurnCount === 0) return void 0;
2558
+ if (turnTimestamps.slice(inheritedTurnCount).some((timestamp) => timestamp < childTimestamp)) {
2559
+ return void 0;
2560
+ }
2561
+ return boundary(turns, inheritedTurnCount, "signature-only");
2562
+ }
2563
+ async function earlierCandidates(childPath, childId) {
2564
+ const childCreatedAt = uuidV7Timestamp(childId);
2565
+ const candidates = [];
2566
+ async function walk(dir) {
2567
+ let entries;
2568
+ try {
2569
+ entries = await fs7.promises.readdir(dir, { withFileTypes: true });
2570
+ } catch {
2571
+ return;
2572
+ }
2573
+ for (const entry of entries) {
2574
+ const full = path9.join(dir, entry.name);
2575
+ if (entry.isDirectory()) {
2576
+ await walk(full);
2577
+ continue;
2578
+ }
2579
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
2580
+ if (path9.resolve(full) === path9.resolve(childPath)) continue;
2581
+ try {
2582
+ const stat = await fs7.promises.stat(full);
2583
+ if (stat.size > MAX_CANDIDATE_BYTES) continue;
2584
+ const filenameId = entry.name.match(
2585
+ /[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i
2586
+ )?.[0];
2587
+ const createdAt = uuidV7Timestamp(filenameId ?? null);
2588
+ if (childCreatedAt !== null && (createdAt === null || createdAt >= childCreatedAt)) {
2589
+ continue;
2590
+ }
2591
+ candidates.push({ filePath: full, createdAt });
2592
+ } catch {
2593
+ }
2594
+ }
2595
+ }
2596
+ await walk(sessionsDir());
2597
+ return candidates.sort(
2598
+ (a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? "") || b.filePath.localeCompare(a.filePath)
2599
+ ).slice(0, MAX_PREFIX_CANDIDATES).map((candidate) => candidate.filePath);
2600
+ }
2601
+ function withFields(lineage) {
2602
+ return lineage.forkedFromThreadId || lineage.replayBoundary ? lineage : void 0;
2603
+ }
2604
+ function mergeCodexLineage(existing, detected) {
2605
+ if (!existing) return detected;
2606
+ if (!detected) return existing;
2607
+ if (existing.forkedFromThreadId && detected.forkedFromThreadId && existing.forkedFromThreadId !== detected.forkedFromThreadId) {
2608
+ const existingIsDirect = !existing.replayBoundary || existing.replayBoundary.detectedBy !== "local-prefix";
2609
+ const detectedIsDirect = !detected.replayBoundary || detected.replayBoundary.detectedBy !== "local-prefix";
2610
+ return detectedIsDirect && !existingIsDirect ? detected : existing;
2611
+ }
2612
+ const confidence = {
2613
+ "signature-only": 0,
2614
+ "local-prefix": 1,
2615
+ "codex-metadata": 2
2616
+ };
2617
+ let replayBoundary = existing.replayBoundary ?? detected.replayBoundary;
2618
+ if (existing.replayBoundary && detected.replayBoundary) {
2619
+ const existingConfidence = confidence[existing.replayBoundary.detectedBy];
2620
+ const detectedConfidence = confidence[detected.replayBoundary.detectedBy];
2621
+ if (detectedConfidence > existingConfidence || detectedConfidence === existingConfidence && detected.replayBoundary.inheritedTurnCount > existing.replayBoundary.inheritedTurnCount) {
2622
+ replayBoundary = detected.replayBoundary;
2623
+ }
2624
+ }
2625
+ return withFields({
2626
+ forkedFromThreadId: existing.forkedFromThreadId ?? detected.forkedFromThreadId,
2627
+ replayBoundary
2628
+ });
2629
+ }
2630
+ async function detectCodexLineage(childPath) {
2631
+ try {
2632
+ const child = await parseRollout(childPath);
2633
+ if (!child.meta?.id) return { conclusive: false };
2634
+ if (child.meta.isSubagent) return { conclusive: true };
2635
+ const conclusive = !child.malformed && !child.incomplete;
2636
+ const metadataParent = child.meta.forkedFromThreadId;
2637
+ if (metadataParent && metadataParent !== child.meta.id) {
2638
+ if (child.malformed) {
2639
+ return {
2640
+ conclusive: false,
2641
+ lineage: { forkedFromThreadId: metadataParent }
2642
+ };
2643
+ }
2644
+ const parentPath = await findCodexRolloutPath(metadataParent);
2645
+ if (parentPath) {
2646
+ const parent = await parseRollout(parentPath, child.turns.length);
2647
+ if (!parent.malformed && !parent.incomplete) {
2648
+ const inheritedTurnCount = commonPrefixLength(
2649
+ parent.turns,
2650
+ child.turns
2651
+ );
2652
+ const replayBoundary = boundary(
2653
+ child.turns,
2654
+ inheritedTurnCount,
2655
+ "codex-metadata"
2656
+ );
2657
+ return {
2658
+ conclusive,
2659
+ lineage: withFields({
2660
+ forkedFromThreadId: metadataParent,
2661
+ replayBoundary: replayBoundary ?? uuidV7ReplayBoundary(child.meta, child.turns)
2662
+ })
2663
+ };
2664
+ }
2665
+ }
2666
+ return {
2667
+ conclusive: false,
2668
+ lineage: withFields({
2669
+ forkedFromThreadId: metadataParent,
2670
+ replayBoundary: uuidV7ReplayBoundary(child.meta, child.turns)
2671
+ })
2672
+ };
2673
+ }
2674
+ if (child.malformed) return { conclusive: false };
2675
+ const signatureOnly = uuidV7ReplayBoundary(child.meta, child.turns);
2676
+ if (child.turns.length < 2) {
2677
+ return {
2678
+ conclusive,
2679
+ lineage: withFields({ replayBoundary: signatureOnly })
2680
+ };
2681
+ }
2682
+ const matches = [];
2683
+ const childCreatedAt = uuidV7Timestamp(child.meta.id);
2684
+ if (childCreatedAt === null) {
2685
+ return {
2686
+ conclusive,
2687
+ lineage: withFields({ replayBoundary: signatureOnly })
2688
+ };
2689
+ }
2690
+ for (const candidatePath of await earlierCandidates(
2691
+ childPath,
2692
+ child.meta.id
2693
+ )) {
2694
+ const candidate = await parseRollout(candidatePath, child.turns.length);
2695
+ if (candidate.malformed || !candidate.meta?.id || candidate.meta.isSubagent || candidate.meta.id === child.meta.id) {
2696
+ continue;
2697
+ }
2698
+ const candidateCreatedAt = uuidV7Timestamp(candidate.meta.id);
2699
+ if (candidateCreatedAt === null || candidateCreatedAt >= childCreatedAt) {
2700
+ continue;
2701
+ }
2702
+ const inheritedTurnCount = commonPrefixLength(
2703
+ candidate.turns,
2704
+ child.turns
2705
+ );
2706
+ if (inheritedTurnCount > 0 && inheritedTurnCount < child.turns.length) {
2707
+ matches.push({
2708
+ parentId: candidate.meta.id,
2709
+ inheritedTurnCount
2710
+ });
2711
+ }
2712
+ }
2713
+ if (matches.length === 0) {
2714
+ return {
2715
+ conclusive,
2716
+ lineage: withFields({ replayBoundary: signatureOnly })
2717
+ };
2718
+ }
2719
+ const bestCount = Math.max(
2720
+ ...matches.map((match) => match.inheritedTurnCount)
2721
+ );
2722
+ const best = matches.filter(
2723
+ (match) => match.inheritedTurnCount === bestCount
2724
+ );
2725
+ if (best.length === 1 && bestCount >= 2) {
2726
+ return {
2727
+ conclusive,
2728
+ lineage: {
2729
+ forkedFromThreadId: best[0]?.parentId,
2730
+ replayBoundary: boundary(child.turns, bestCount, "local-prefix")
2731
+ }
2732
+ };
2733
+ }
2734
+ return {
2735
+ conclusive,
2736
+ lineage: withFields({ replayBoundary: signatureOnly })
2737
+ };
2738
+ } catch {
2739
+ return { conclusive: false };
2740
+ }
2741
+ }
2290
2742
 
2291
2743
  // src/debug-logs.ts
2292
- import crypto from "crypto";
2293
- import fs10 from "fs";
2294
- import path12 from "path";
2744
+ import crypto2 from "crypto";
2745
+ import fs12 from "fs";
2746
+ import path13 from "path";
2295
2747
 
2296
2748
  // src/hook-events.ts
2297
2749
  function classifyHookEvent(event) {
@@ -2340,21 +2792,73 @@ function inferSourceToolFromPayload(payload) {
2340
2792
  function fallbackSourceTool(payload) {
2341
2793
  const inferred = inferSourceToolFromPayload(payload);
2342
2794
  if (inferred) return inferred;
2343
- const eventKind = classifyHookEvent(payload.hook_event_name);
2344
- if (eventKind === "stop") return "codex";
2795
+ const eventKind2 = classifyHookEvent(payload.hook_event_name);
2796
+ if (eventKind2 === "stop") return "codex";
2345
2797
  return "claude";
2346
2798
  }
2347
2799
 
2800
+ // src/lock-reap.ts
2801
+ import fs8 from "fs";
2802
+ function isProcessAlive(pid) {
2803
+ try {
2804
+ process.kill(pid, 0);
2805
+ return true;
2806
+ } catch (err) {
2807
+ return err.code === "EPERM";
2808
+ }
2809
+ }
2810
+ async function readLockPid(lockPath) {
2811
+ let contents;
2812
+ try {
2813
+ contents = await fs8.promises.readFile(lockPath, "utf-8");
2814
+ } catch {
2815
+ return null;
2816
+ }
2817
+ const value = contents.trim();
2818
+ if (!/^[1-9]\d*$/.test(value)) return null;
2819
+ const pid = Number(value);
2820
+ return Number.isSafeInteger(pid) ? pid : null;
2821
+ }
2822
+ async function reapLockIfStaleImpl(lockPath, opts, beforeOwnerRecheck) {
2823
+ let mtimeMs;
2824
+ try {
2825
+ mtimeMs = (await fs8.promises.stat(lockPath)).mtimeMs;
2826
+ } catch {
2827
+ return false;
2828
+ }
2829
+ const age = (opts.now ?? Date.now()) - mtimeMs;
2830
+ if (age < (opts.graceMs ?? 15e3)) return false;
2831
+ const ownerPid = await readLockPid(lockPath);
2832
+ const shouldReap = ownerPid === null ? age > opts.maxAgeMs : !isProcessAlive(ownerPid) || age > opts.maxAgeMs;
2833
+ if (!shouldReap) return false;
2834
+ try {
2835
+ await beforeOwnerRecheck?.(lockPath);
2836
+ } catch {
2837
+ return false;
2838
+ }
2839
+ const currentOwnerPid = await readLockPid(lockPath);
2840
+ if (currentOwnerPid !== ownerPid) return false;
2841
+ try {
2842
+ await fs8.promises.unlink(lockPath);
2843
+ } catch (err) {
2844
+ if (err.code !== "ENOENT") return false;
2845
+ }
2846
+ return true;
2847
+ }
2848
+ async function reapLockIfStale(lockPath, opts) {
2849
+ return reapLockIfStaleImpl(lockPath, opts);
2850
+ }
2851
+
2348
2852
  // src/middleware/pattern-redact.ts
2349
- import os3 from "os";
2853
+ import os4 from "os";
2350
2854
  import { Worker } from "worker_threads";
2351
2855
 
2352
2856
  // src/middleware/file-utils.ts
2353
- import fs7 from "fs";
2857
+ import fs9 from "fs";
2354
2858
  async function checkBinary(filePath) {
2355
2859
  let handle = null;
2356
2860
  try {
2357
- handle = await fs7.promises.open(filePath, "r");
2861
+ handle = await fs9.promises.open(filePath, "r");
2358
2862
  const buf = Buffer.alloc(8192);
2359
2863
  const { bytesRead } = await handle.read(buf, 0, 8192, 0);
2360
2864
  for (let i = 0; i < bytesRead; i++) {
@@ -2379,7 +2883,7 @@ async function readFileContent(file) {
2379
2883
  return { kind: "binary" };
2380
2884
  }
2381
2885
  try {
2382
- const content = await fs7.promises.readFile(file.absolutePath, "utf-8");
2886
+ const content = await fs9.promises.readFile(file.absolutePath, "utf-8");
2383
2887
  return { kind: "text", content };
2384
2888
  } catch {
2385
2889
  return { kind: "error" };
@@ -10496,6 +11000,46 @@ var patterns_local_default = {
10496
11000
  name: "Hugging Face Organization API Token",
10497
11001
  regex: "(?:(?<![A-Za-z0-9])|(?<=\\\\[nrt]))api_org_[A-Za-z0-9]{34,40}",
10498
11002
  confidence: "high"
11003
+ },
11004
+ {
11005
+ name: "OpenRouter API Key",
11006
+ regex: "(?:(?<![A-Za-z0-9])|(?<=\\\\[nrt]))sk-or-v1-[a-fA-F0-9]{64}",
11007
+ confidence: "high"
11008
+ },
11009
+ {
11010
+ name: "OpenRouter API Key (generic)",
11011
+ regex: "(?:(?<![A-Za-z0-9])|(?<=\\\\[nrt]))sk-or-[A-Za-z0-9-]{32,128}",
11012
+ confidence: "high"
11013
+ },
11014
+ {
11015
+ name: "Anthropic API Key",
11016
+ regex: "(?:(?<![A-Za-z0-9])|(?<=\\\\[nrt]))sk-ant-[A-Za-z0-9_-]{32,256}",
11017
+ confidence: "high"
11018
+ },
11019
+ {
11020
+ name: "OpenAI Project API Key",
11021
+ regex: "(?:(?<![A-Za-z0-9])|(?<=\\\\[nrt]))sk-proj-[A-Za-z0-9_-]{32,256}",
11022
+ confidence: "high"
11023
+ },
11024
+ {
11025
+ name: "OpenAI API Key (legacy)",
11026
+ regex: "(?:(?<![A-Za-z0-9])|(?<=\\\\[nrt]))sk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}",
11027
+ confidence: "high"
11028
+ },
11029
+ {
11030
+ name: "sshpass Command-Line Password",
11031
+ regex: `sshpass\\s+-p\\s*(?:'[^']{1,128}'|"[^"]{1,128}"|[^\\s'"-][^\\s'"]{0,127})`,
11032
+ confidence: "high"
11033
+ },
11034
+ {
11035
+ name: "URL Userinfo Credentials",
11036
+ regex: ":\\/\\/([^\\s:@\\/]{1,64}:[^\\s@\\/]{1,128})@",
11037
+ confidence: "high"
11038
+ },
11039
+ {
11040
+ name: "SSH Root Host (rented GPU node)",
11041
+ regex: "(?:(?<![A-Za-z0-9@._-])|(?<=\\\\[nrt]))root@(?:\\d{1,3}\\.){3}\\d{1,3}",
11042
+ confidence: "high"
10499
11043
  }
10500
11044
  ]
10501
11045
  };
@@ -10681,7 +11225,7 @@ function redactPatternLine(line, patterns, memo, isJsonl) {
10681
11225
  return redactString(line, patterns);
10682
11226
  }
10683
11227
  var WORKER_COUNT = Math.min(
10684
- os3.availableParallelism?.() ?? os3.cpus().length,
11228
+ os4.availableParallelism?.() ?? os4.cpus().length,
10685
11229
  4
10686
11230
  );
10687
11231
  var WORKER_THRESHOLD = 4;
@@ -10985,8 +11529,8 @@ var RedactMiddleware = class {
10985
11529
  var middleware = [];
10986
11530
 
10987
11531
  // src/middleware/secrets.ts
10988
- import fs8 from "fs";
10989
- import path9 from "path";
11532
+ import fs10 from "fs";
11533
+ import path10 from "path";
10990
11534
  var KNOWN_NON_SECRETS = /* @__PURE__ */ new Set([
10991
11535
  "true",
10992
11536
  "false",
@@ -11066,7 +11610,7 @@ async function parseEnvFile(filePath) {
11066
11610
  const values = [];
11067
11611
  let content;
11068
11612
  try {
11069
- content = await fs8.promises.readFile(filePath, "utf-8");
11613
+ content = await fs10.promises.readFile(filePath, "utf-8");
11070
11614
  } catch {
11071
11615
  return values;
11072
11616
  }
@@ -11134,16 +11678,16 @@ function addWithVariants(set, value) {
11134
11678
  async function discoverEnvFiles(repoRoot) {
11135
11679
  let entries;
11136
11680
  try {
11137
- entries = await fs8.promises.readdir(repoRoot);
11681
+ entries = await fs10.promises.readdir(repoRoot);
11138
11682
  } catch {
11139
11683
  return [];
11140
11684
  }
11141
11685
  const envFiles = [];
11142
11686
  for (const name of entries) {
11143
11687
  if (!name.startsWith(".env")) continue;
11144
- const filePath = path9.join(repoRoot, name);
11688
+ const filePath = path10.join(repoRoot, name);
11145
11689
  try {
11146
- const stat = await fs8.promises.stat(filePath);
11690
+ const stat = await fs10.promises.stat(filePath);
11147
11691
  if (stat.isFile()) envFiles.push(name);
11148
11692
  } catch {
11149
11693
  }
@@ -11166,7 +11710,7 @@ async function collectSecrets(repoRoot, envFiles, additionalFiles) {
11166
11710
  }
11167
11711
  }
11168
11712
  for (const filePath of additionalFiles) {
11169
- const resolved = path9.resolve(repoRoot, filePath);
11713
+ const resolved = path10.resolve(repoRoot, filePath);
11170
11714
  sourceFiles.push(resolved);
11171
11715
  for (const value of await parseEnvFile(resolved)) {
11172
11716
  if (isUsableValue(value)) {
@@ -11195,25 +11739,25 @@ import { PassThrough } from "stream";
11195
11739
  import archiver from "archiver";
11196
11740
 
11197
11741
  // src/outputs/archive.ts
11198
- import os4 from "os";
11199
- import path10 from "path";
11742
+ import os5 from "os";
11743
+ import path11 from "path";
11200
11744
  function getSourceBaseDir(sourceName) {
11201
- const home = os4.homedir();
11745
+ const home = os5.homedir();
11202
11746
  switch (sourceName) {
11203
11747
  case "claude":
11204
- return path10.join(home, ".claude", "projects");
11748
+ return path11.join(home, ".claude", "projects");
11205
11749
  case "codex":
11206
- return path10.join(home, ".codex", "sessions");
11750
+ return path11.join(home, ".codex", "sessions");
11207
11751
  case "debug-logs":
11208
- return path10.join(configDir(), "logs");
11752
+ return path11.join(configDir(), "logs");
11209
11753
  default:
11210
11754
  return home;
11211
11755
  }
11212
11756
  }
11213
11757
  function archivePathFor(file) {
11214
11758
  const baseDir = getSourceBaseDir(file.sourceName);
11215
- const relativePath = file.absolutePath.startsWith(baseDir) ? path10.relative(baseDir, file.absolutePath) : path10.basename(file.absolutePath);
11216
- return path10.join(file.sourceName, relativePath);
11759
+ const relativePath = file.absolutePath.startsWith(baseDir) ? path11.relative(baseDir, file.absolutePath) : path11.basename(file.absolutePath);
11760
+ return path11.join(file.sourceName, relativePath);
11217
11761
  }
11218
11762
  function addGroupToArchive(archive, group, selectedSources) {
11219
11763
  for (const file of group.files) {
@@ -11331,21 +11875,21 @@ var PlatformUploadOutput = class {
11331
11875
  };
11332
11876
 
11333
11877
  // src/pipeline.ts
11334
- import fs9 from "fs";
11335
- import path11 from "path";
11878
+ import fs11 from "fs";
11879
+ import path12 from "path";
11336
11880
  function canonicalizePath(p7) {
11337
- let resolved = path11.resolve(p7);
11338
- if (resolved.endsWith(path11.sep) && resolved !== path11.sep) {
11881
+ let resolved = path12.resolve(p7);
11882
+ if (resolved.endsWith(path12.sep) && resolved !== path12.sep) {
11339
11883
  resolved = resolved.slice(0, -1);
11340
11884
  }
11341
11885
  return resolved;
11342
11886
  }
11343
11887
  function computeLabel(repoPath, allPaths) {
11344
- const segments = repoPath.split(path11.sep).filter(Boolean);
11888
+ const segments = repoPath.split(path12.sep).filter(Boolean);
11345
11889
  for (let depth = 1; depth <= segments.length; depth++) {
11346
11890
  const label = segments.slice(-depth).join("/");
11347
11891
  const matches = allPaths.filter((p7) => {
11348
- const s = p7.split(path11.sep).filter(Boolean);
11892
+ const s = p7.split(path12.sep).filter(Boolean);
11349
11893
  return s.slice(-depth).join("/") === label;
11350
11894
  });
11351
11895
  if (matches.length === 1) return label;
@@ -11367,7 +11911,7 @@ async function mergeByRepo(files) {
11367
11911
  const groups = [];
11368
11912
  for (const [repoPath, groupFiles] of grouped) {
11369
11913
  const stats = await Promise.all(
11370
- groupFiles.map((f) => fs9.promises.stat(f.absolutePath).catch(() => null))
11914
+ groupFiles.map((f) => fs11.promises.stat(f.absolutePath).catch(() => null))
11371
11915
  );
11372
11916
  let lastModified = /* @__PURE__ */ new Date(0);
11373
11917
  for (const stat of stats) {
@@ -11390,7 +11934,7 @@ async function preloadFiles(group) {
11390
11934
  group.files.map(async (file) => {
11391
11935
  if (file.content) return file;
11392
11936
  try {
11393
- const buf = await fs9.promises.readFile(file.absolutePath);
11937
+ const buf = await fs11.promises.readFile(file.absolutePath);
11394
11938
  const checkLen = Math.min(buf.length, 8192);
11395
11939
  for (let i = 0; i < checkLen; i++) {
11396
11940
  if (buf[i] === 0) {
@@ -11420,10 +11964,14 @@ async function runPipeline(group, middleware2, output, options, onProgress) {
11420
11964
  var DEBUG_LOGS_SLUG = "debug-logs";
11421
11965
  var CURRENT_SCHEMA_VERSION = 1;
11422
11966
  var DEFAULT_WAIT_MS = 6e4;
11423
- var LOCK_RETRIES = 100;
11967
+ var DEFAULT_LOCK_WAIT_MS = 5 * 60 * 1e3;
11424
11968
  var LOCK_RETRY_DELAY_MS = 100;
11969
+ var LOCK_RETRIES = Math.ceil(DEFAULT_LOCK_WAIT_MS / LOCK_RETRY_DELAY_MS);
11970
+ var STALE_LOCK_TTL_MS = 60 * 60 * 1e3;
11971
+ var EVENT_STATE_TTL_MS = 24 * 60 * 60 * 1e3;
11972
+ var SESSION_STATE_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
11425
11973
  function stateDir() {
11426
- return process.env.HILLCLIMB_DEBUG_LOG_STATE_DIR ?? path12.join(configDir(), "debug-log-uploads");
11974
+ return process.env.HILLCLIMB_DEBUG_LOG_STATE_DIR ?? path13.join(configDir(), "debug-log-uploads");
11427
11975
  }
11428
11976
  function waitMs() {
11429
11977
  const raw = process.env.HILLCLIMB_DEBUG_LOG_WAIT_MS;
@@ -11432,11 +11980,27 @@ function waitMs() {
11432
11980
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_WAIT_MS;
11433
11981
  }
11434
11982
  function stateFile(eventId) {
11435
- return path12.join(stateDir(), `${eventId}.json`);
11983
+ return path13.join(stateDir(), `${eventId}.json`);
11436
11984
  }
11437
11985
  function lockFile(eventId) {
11438
11986
  return `${stateFile(eventId)}.lock`;
11439
11987
  }
11988
+ function sessionKey(ctx) {
11989
+ if (!ctx.sessionId) return null;
11990
+ return crypto2.createHash("sha256").update(
11991
+ JSON.stringify({
11992
+ schema: "debug-log-session-v1",
11993
+ apiBaseUrl: ctx.config.apiBaseUrl,
11994
+ projectId: ctx.config.projectId,
11995
+ repoRoot: ctx.repoRoot,
11996
+ tool: ctx.tool,
11997
+ sessionId: ctx.sessionId
11998
+ })
11999
+ ).digest("hex");
12000
+ }
12001
+ function sessionStateId(key) {
12002
+ return `session-${key}`;
12003
+ }
11440
12004
  function sleep2(ms) {
11441
12005
  return new Promise((resolve) => setTimeout(resolve, ms));
11442
12006
  }
@@ -11465,8 +12029,8 @@ function resolveSessionId(payload) {
11465
12029
  function stringOrNull(value) {
11466
12030
  return typeof value === "string" && value.length > 0 ? value : null;
11467
12031
  }
11468
- function expectedKinds(tool, eventKind, payload) {
11469
- if (eventKind === "stop") {
12032
+ function expectedKinds(tool, eventKind2, payload) {
12033
+ if (eventKind2 === "stop") {
11470
12034
  return new Set(
11471
12035
  tool === "codex" || tool === "copilot-chat" || tool === "claude" || tool === "cursor" || tool === "opencode" ? ["agent", "git"] : ["git"]
11472
12036
  );
@@ -11476,12 +12040,10 @@ function expectedKinds(tool, eventKind, payload) {
11476
12040
  }
11477
12041
  return /* @__PURE__ */ new Set(["agent", "git"]);
11478
12042
  }
11479
- async function transcriptFingerprint(payload) {
11480
- const transcriptPath = stringOrNull(payload.transcript_path);
11481
- if (!transcriptPath) return {};
11482
- const resolved = path12.resolve(transcriptPath);
12043
+ var TRANSCRIPT_STAT_ENV = "HILLCLIMB_DEBUG_LOG_TRANSCRIPT_STAT";
12044
+ async function statTranscriptFingerprint(resolved) {
11483
12045
  try {
11484
- const stat = await fs10.promises.stat(resolved);
12046
+ const stat = await fs12.promises.stat(resolved);
11485
12047
  return {
11486
12048
  transcriptPath: resolved,
11487
12049
  transcriptMtimeMs: stat.mtimeMs,
@@ -11491,9 +12053,47 @@ async function transcriptFingerprint(payload) {
11491
12053
  return { transcriptPath: resolved };
11492
12054
  }
11493
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
+ }
11494
12094
  async function eventContext(tool, payload) {
11495
- const eventKind = classifyHookEvent(payload.hook_event_name);
11496
- if (!eventKind) return null;
12095
+ const eventKind2 = classifyHookEvent(payload.hook_event_name);
12096
+ if (!eventKind2) return null;
11497
12097
  const cwd = resolveCwd(payload);
11498
12098
  if (!cwd) return null;
11499
12099
  const project = await findProjectForCwd(cwd);
@@ -11504,15 +12104,15 @@ async function eventContext(tool, payload) {
11504
12104
  if (!sessionId && !turnId && !transcriptPath) {
11505
12105
  return null;
11506
12106
  }
11507
- const expected = expectedKinds(tool, eventKind, payload);
11508
- const eventNonce = eventKind === "stop" && expected.size === 1 && expected.has("git") && !turnId && !transcriptPath ? crypto.randomBytes(8).toString("hex") : null;
12107
+ const expected = expectedKinds(tool, eventKind2, payload);
12108
+ const eventNonce = eventKind2 === "stop" && expected.size === 1 && expected.has("git") && !turnId && !transcriptPath ? crypto2.randomBytes(8).toString("hex") : null;
11509
12109
  const fingerprint = {
11510
12110
  schema: "debug-log-event-v1",
11511
12111
  apiBaseUrl: project.config.apiBaseUrl,
11512
12112
  projectId: project.config.projectId,
11513
12113
  repoRoot: project.repoRoot,
11514
12114
  tool,
11515
- eventKind,
12115
+ eventKind: eventKind2,
11516
12116
  hookEventName: payload.hook_event_name ?? null,
11517
12117
  sessionId,
11518
12118
  conversationId: stringOrNull(payload.conversation_id),
@@ -11521,15 +12121,19 @@ async function eventContext(tool, payload) {
11521
12121
  // Only stop events without stable turn IDs need the transcript to
11522
12122
  // disambiguate. When a turn_id exists, including mutable transcript
11523
12123
  // mtime/size can split agent+git completions for the same logical event.
11524
- ...eventKind === "stop" && !turnId ? await transcriptFingerprint(payload) : {}
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).
12128
+ ...eventKind2 === "stop" && !turnId ? await transcriptFingerprint(payload) : {}
11525
12129
  };
11526
- const eventId = crypto.createHash("sha256").update(JSON.stringify(fingerprint)).digest("hex").slice(0, 32);
12130
+ const eventId = crypto2.createHash("sha256").update(JSON.stringify(fingerprint)).digest("hex").slice(0, 32);
11527
12131
  return {
11528
12132
  eventId,
11529
12133
  repoRoot: project.repoRoot,
11530
12134
  config: project.config,
11531
12135
  tool,
11532
- eventKind,
12136
+ eventKind: eventKind2,
11533
12137
  hookEventName: payload.hook_event_name ?? null,
11534
12138
  sessionId,
11535
12139
  expectedKinds: expected
@@ -11537,7 +12141,7 @@ async function eventContext(tool, payload) {
11537
12141
  }
11538
12142
  async function readState(eventId) {
11539
12143
  try {
11540
- const raw = await fs10.promises.readFile(stateFile(eventId), "utf-8");
12144
+ const raw = await fs12.promises.readFile(stateFile(eventId), "utf-8");
11541
12145
  const parsed = JSON.parse(raw);
11542
12146
  if (parsed.schemaVersion !== CURRENT_SCHEMA_VERSION) return null;
11543
12147
  return parsed;
@@ -11546,37 +12150,132 @@ async function readState(eventId) {
11546
12150
  }
11547
12151
  }
11548
12152
  async function writeState(state) {
11549
- await fs10.promises.mkdir(stateDir(), { recursive: true, mode: 448 });
12153
+ await fs12.promises.mkdir(stateDir(), { recursive: true, mode: 448 });
11550
12154
  const file = stateFile(state.eventId);
11551
12155
  const tmp = `${file}.tmp`;
11552
- await fs10.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
12156
+ await fs12.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
11553
12157
  mode: 384
11554
12158
  });
11555
- await fs10.promises.rename(tmp, file);
12159
+ await fs12.promises.rename(tmp, file);
11556
12160
  }
11557
- async function acquireLock(eventId) {
11558
- await fs10.promises.mkdir(stateDir(), { recursive: true, mode: 448 });
11559
- for (let i = 0; i < LOCK_RETRIES; i++) {
12161
+ async function readSessionState(key) {
12162
+ try {
12163
+ const raw = await fs12.promises.readFile(
12164
+ stateFile(sessionStateId(key)),
12165
+ "utf-8"
12166
+ );
12167
+ const parsed = JSON.parse(raw);
12168
+ if (parsed.schemaVersion !== CURRENT_SCHEMA_VERSION || parsed.sessionKey !== key || typeof parsed.contributionId !== "string") {
12169
+ return null;
12170
+ }
12171
+ return parsed;
12172
+ } catch {
12173
+ return null;
12174
+ }
12175
+ }
12176
+ async function writeSessionState(state) {
12177
+ await fs12.promises.mkdir(stateDir(), { recursive: true, mode: 448 });
12178
+ const file = stateFile(sessionStateId(state.sessionKey));
12179
+ const tmp = `${file}.tmp`;
12180
+ await fs12.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
12181
+ mode: 384
12182
+ });
12183
+ await fs12.promises.rename(tmp, file);
12184
+ }
12185
+ async function deleteSessionState(key) {
12186
+ try {
12187
+ await fs12.promises.unlink(stateFile(sessionStateId(key)));
12188
+ } catch (err) {
12189
+ if (err.code !== "ENOENT") throw err;
12190
+ }
12191
+ }
12192
+ async function tryCreateLock(lockId) {
12193
+ try {
12194
+ const fd = await fs12.promises.open(
12195
+ lockFile(lockId),
12196
+ fs12.constants.O_CREAT | fs12.constants.O_EXCL | fs12.constants.O_WRONLY
12197
+ );
11560
12198
  try {
11561
- const fd = await fs10.promises.open(
11562
- lockFile(eventId),
11563
- fs10.constants.O_CREAT | fs10.constants.O_EXCL | fs10.constants.O_WRONLY
11564
- );
11565
12199
  await fd.write(String(process.pid));
12200
+ } finally {
11566
12201
  await fd.close();
12202
+ }
12203
+ return true;
12204
+ } catch (err) {
12205
+ if (err.code === "EEXIST") return false;
12206
+ throw err;
12207
+ }
12208
+ }
12209
+ async function acquireLock(eventId) {
12210
+ await fs12.promises.mkdir(stateDir(), { recursive: true, mode: 448 });
12211
+ for (let i = 0; i < LOCK_RETRIES; i++) {
12212
+ if (await tryCreateLock(eventId)) return;
12213
+ if (await reapLockIfStale(lockFile(eventId), {
12214
+ maxAgeMs: STALE_LOCK_TTL_MS
12215
+ }) && await tryCreateLock(eventId)) {
11567
12216
  return;
11568
- } catch (err) {
11569
- if (err.code === "EEXIST" && i < LOCK_RETRIES - 1) {
11570
- await sleep2(LOCK_RETRY_DELAY_MS);
12217
+ }
12218
+ if (i < LOCK_RETRIES - 1) await sleep2(LOCK_RETRY_DELAY_MS);
12219
+ }
12220
+ throw new Error(
12221
+ `Failed to acquire debug-log lock after ${DEFAULT_LOCK_WAIT_MS}ms (lock=${lockFile(eventId)})`
12222
+ );
12223
+ }
12224
+ async function tryAcquireLock(eventId, now) {
12225
+ await fs12.promises.mkdir(stateDir(), { recursive: true, mode: 448 });
12226
+ if (await tryCreateLock(eventId)) return true;
12227
+ if (await reapLockIfStale(lockFile(eventId), {
12228
+ maxAgeMs: STALE_LOCK_TTL_MS,
12229
+ now
12230
+ })) {
12231
+ return tryCreateLock(eventId);
12232
+ }
12233
+ return false;
12234
+ }
12235
+ async function sweepStaleDebugLogState(now = Date.now()) {
12236
+ let entries;
12237
+ try {
12238
+ entries = await fs12.promises.readdir(stateDir(), { withFileTypes: true });
12239
+ } catch {
12240
+ return;
12241
+ }
12242
+ for (const entry of entries) {
12243
+ if (!entry.isFile()) continue;
12244
+ const file = path13.join(stateDir(), entry.name);
12245
+ try {
12246
+ if (entry.name.endsWith(".lock")) {
12247
+ await reapLockIfStale(file, {
12248
+ maxAgeMs: STALE_LOCK_TTL_MS,
12249
+ now
12250
+ });
11571
12251
  continue;
11572
12252
  }
11573
- throw err;
12253
+ if (entry.name.startsWith("session-") && entry.name.endsWith(".json")) {
12254
+ const stat2 = await fs12.promises.stat(file);
12255
+ if (now - stat2.mtimeMs <= SESSION_STATE_TTL_MS) continue;
12256
+ const sessionLockId = entry.name.slice(0, -".json".length);
12257
+ if (!await tryAcquireLock(sessionLockId, now)) continue;
12258
+ try {
12259
+ const lockedStat = await fs12.promises.stat(file);
12260
+ if (now - lockedStat.mtimeMs > SESSION_STATE_TTL_MS) {
12261
+ await fs12.promises.unlink(file);
12262
+ }
12263
+ } finally {
12264
+ await releaseLock(sessionLockId);
12265
+ }
12266
+ continue;
12267
+ }
12268
+ const stat = await fs12.promises.stat(file);
12269
+ if (now - stat.mtimeMs > EVENT_STATE_TTL_MS) {
12270
+ await fs12.promises.unlink(file);
12271
+ }
12272
+ } catch {
11574
12273
  }
11575
12274
  }
11576
12275
  }
11577
12276
  async function releaseLock(eventId) {
11578
12277
  try {
11579
- await fs10.promises.unlink(lockFile(eventId));
12278
+ await fs12.promises.unlink(lockFile(eventId));
11580
12279
  } catch {
11581
12280
  }
11582
12281
  }
@@ -11592,7 +12291,7 @@ function initialState(ctx, now) {
11592
12291
  eventKind: ctx.eventKind,
11593
12292
  hookEventName: ctx.hookEventName,
11594
12293
  sessionId: ctx.sessionId,
11595
- logDate: path12.basename(todayLogPath(), ".log"),
12294
+ logDate: path13.basename(todayLogPath(), ".log"),
11596
12295
  firstSeenAt: now.toISOString()
11597
12296
  };
11598
12297
  }
@@ -11654,7 +12353,7 @@ async function waitForExpectedKinds(ctx, state) {
11654
12353
  }
11655
12354
  async function buildMiddleware(repoRoot) {
11656
12355
  const envFileNames = await discoverEnvFiles(repoRoot);
11657
- const envFilePaths = envFileNames.map((n) => path12.join(repoRoot, n));
12356
+ const envFilePaths = envFileNames.map((n) => path13.join(repoRoot, n));
11658
12357
  const secretResult = await collectSecrets(repoRoot, envFilePaths, []);
11659
12358
  const middleware2 = [];
11660
12359
  if (secretResult.values.size > 0) {
@@ -11663,21 +12362,21 @@ async function buildMiddleware(repoRoot) {
11663
12362
  middleware2.push(new PatternRedactMiddleware());
11664
12363
  return middleware2;
11665
12364
  }
11666
- async function uploadDebugLog(ctx, state) {
12365
+ async function uploadDebugLog(ctx, state, sessionState, onContributionCreated) {
11667
12366
  const logPath = todayLogPath();
11668
12367
  let content;
11669
12368
  try {
11670
- content = await fs10.promises.readFile(logPath);
12369
+ content = await fs12.promises.readFile(logPath);
11671
12370
  } catch (err) {
11672
12371
  appendLog(
11673
12372
  "warn",
11674
12373
  `debug-logs: skipped upload; log file not readable (${logPath}): ${err instanceof Error ? err.message : String(err)}`
11675
12374
  );
11676
- return null;
12375
+ return { kind: "skipped" };
11677
12376
  }
11678
12377
  if (content.byteLength === 0) {
11679
12378
  appendLog("info", "debug-logs: skipped upload; log file is empty");
11680
- return null;
12379
+ return { kind: "skipped" };
11681
12380
  }
11682
12381
  const identity = await loadIdentity(ctx.config.apiBaseUrl);
11683
12382
  if (!identity) {
@@ -11685,7 +12384,7 @@ async function uploadDebugLog(ctx, state) {
11685
12384
  "warn",
11686
12385
  `debug-logs: skipped upload; no saved login for ${ctx.config.apiBaseUrl}`
11687
12386
  );
11688
- return null;
12387
+ return { kind: "skipped" };
11689
12388
  }
11690
12389
  const now = /* @__PURE__ */ new Date();
11691
12390
  appendLog(
@@ -11700,7 +12399,7 @@ async function uploadDebugLog(ctx, state) {
11700
12399
  };
11701
12400
  const group = {
11702
12401
  repoPath: ctx.repoRoot,
11703
- label: path12.basename(ctx.repoRoot),
12402
+ label: path13.basename(ctx.repoRoot),
11704
12403
  files: [sourceFile],
11705
12404
  sourceNames: [DEBUG_LOGS_SLUG],
11706
12405
  lastModified: now
@@ -11708,27 +12407,44 @@ async function uploadDebugLog(ctx, state) {
11708
12407
  const shortSession = (ctx.sessionId ?? ctx.eventId).slice(0, 12);
11709
12408
  const epochSeconds = formatEpochSeconds(now);
11710
12409
  const label = toolLabel(ctx.tool);
11711
- const client = new PlatformClient(
11712
- ctx.config.apiBaseUrl,
11713
- identity.sessionCookie
11714
- );
11715
- const output = new PlatformUploadOutput({
11716
- client,
11717
- projectId: ctx.config.projectId,
11718
- contributionTypeSlug: DEBUG_LOGS_SLUG,
12410
+ const sessionDescription = ctx.sessionId ? {
12411
+ contributionTitle: `${label} debug logs ${shortSession}`,
12412
+ contributionBody: [
12413
+ `Session ID: ${ctx.sessionId}`,
12414
+ `Tool: ${label}`,
12415
+ `Repo: ${ctx.repoRoot}`,
12416
+ "Log snapshots: machine-wide Hillclimb daily log snapshots captured at hook events; may include entries from other sessions on this machine"
12417
+ ].join("\n")
12418
+ } : {
11719
12419
  contributionTitle: `${label} debug log ${shortSession} - ${epochSeconds}`,
11720
12420
  contributionBody: [
11721
- `Session ID: ${ctx.sessionId ?? "<none>"}`,
12421
+ "Session ID: <none>",
11722
12422
  `Tool: ${label}`,
11723
12423
  `Event: ${ctx.hookEventName ?? ctx.eventKind}`,
11724
12424
  `Repo: ${ctx.repoRoot}`,
11725
- `Log: ${path12.basename(logPath)}`,
12425
+ `Log: ${path13.basename(logPath)}`,
11726
12426
  `Agent done: ${state.agentDoneAt ?? "<not observed>"}`,
11727
12427
  `Git done: ${state.gitDoneAt ?? "<not observed>"}`,
11728
12428
  `Uploaded: ${now.toISOString()}`
11729
- ].join("\n"),
11730
- zipFilename: `hillclimb-debug-log-${sanitize(ctx.tool)}-${sanitize(shortSession)}-${epochSeconds}.zip`,
11731
- autoSubmit: true
12429
+ ].join("\n")
12430
+ };
12431
+ const client = new PlatformClient(
12432
+ ctx.config.apiBaseUrl,
12433
+ identity.sessionCookie
12434
+ );
12435
+ let attemptContributionId = sessionState?.contributionId;
12436
+ const output = new PlatformUploadOutput({
12437
+ client,
12438
+ projectId: ctx.config.projectId,
12439
+ contributionTypeSlug: DEBUG_LOGS_SLUG,
12440
+ ...sessionDescription,
12441
+ zipFilename: `hillclimb-debug-log-${sanitize(ctx.tool)}-${sanitize(shortSession)}-${now.getTime()}-${ctx.eventId.slice(0, 8)}-${crypto2.randomBytes(6).toString("hex")}.zip`,
12442
+ autoSubmit: !sessionState?.firstUploadSubmittedAt,
12443
+ existingContributionId: sessionState?.contributionId,
12444
+ onContributionCreated: onContributionCreated ? async (contributionId) => {
12445
+ attemptContributionId = contributionId;
12446
+ await onContributionCreated(contributionId);
12447
+ } : void 0
11732
12448
  });
11733
12449
  try {
11734
12450
  const contributionId = await runPipeline(
@@ -11739,37 +12455,90 @@ async function uploadDebugLog(ctx, state) {
11739
12455
  );
11740
12456
  appendLog(
11741
12457
  "info",
11742
- `debug-logs: uploaded ${path12.basename(logPath)} to project ${ctx.config.projectSlug} (${ctx.config.projectId}) as contribution ${contributionId}`
12458
+ `debug-logs: uploaded ${path13.basename(logPath)} to project ${ctx.config.projectSlug} (${ctx.config.projectId}) as contribution ${contributionId}`
11743
12459
  );
11744
- return contributionId;
12460
+ return { kind: "uploaded", contributionId };
11745
12461
  } catch (err) {
12462
+ if (attemptContributionId && err instanceof PlatformError && (err.status === 404 && err.code === "CONTRIBUTION_NOT_FOUND" || err.status === 409 && err.code === "CONTRIBUTION_UPLOAD_NOT_UPLOADED")) {
12463
+ appendLog(
12464
+ "warn",
12465
+ `debug-logs: contribution ${attemptContributionId} abandoned after ${err.code}; the next hook event will create a fresh contribution`
12466
+ );
12467
+ return { kind: "abandon-session" };
12468
+ }
11746
12469
  if (err instanceof PlatformError && err.status === 404) {
11747
12470
  appendLog(
11748
12471
  "warn",
11749
12472
  "debug-logs: upload skipped; platform does not have the debug-logs contribution type yet"
11750
12473
  );
11751
- return null;
12474
+ return { kind: "skipped" };
11752
12475
  }
11753
12476
  appendLog(
11754
12477
  "error",
11755
12478
  `debug-logs: upload failed: ${err instanceof Error ? err.message : String(err)}`
11756
12479
  );
11757
- return null;
12480
+ return { kind: "skipped" };
11758
12481
  }
11759
12482
  }
11760
12483
  async function uploadOnce(ctx, state) {
11761
12484
  await acquireLock(ctx.eventId);
11762
12485
  try {
11763
12486
  const latest = await readState(ctx.eventId) ?? state;
11764
- if (latest.uploadedAt) return;
11765
- const contributionId = await uploadDebugLog(ctx, latest);
11766
- if (!contributionId) return;
11767
- const uploadedAt = (/* @__PURE__ */ new Date()).toISOString();
11768
- await writeState({
11769
- ...latest,
11770
- uploadedAt,
11771
- uploadContributionId: contributionId ?? void 0
11772
- });
12487
+ if (latest.uploadedAt || latest.sessionMappingAbandonedAt) return;
12488
+ const key = sessionKey(ctx);
12489
+ if (!key) {
12490
+ const result = await uploadDebugLog(ctx, latest, null);
12491
+ if (result.kind !== "uploaded") return;
12492
+ await writeState({
12493
+ ...latest,
12494
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString(),
12495
+ uploadContributionId: result.contributionId
12496
+ });
12497
+ return;
12498
+ }
12499
+ const sessionLockId = sessionStateId(key);
12500
+ await acquireLock(sessionLockId);
12501
+ try {
12502
+ const currentSession = await readSessionState(key);
12503
+ const result = await uploadDebugLog(
12504
+ ctx,
12505
+ latest,
12506
+ currentSession,
12507
+ async (contributionId) => {
12508
+ await writeSessionState({
12509
+ schemaVersion: CURRENT_SCHEMA_VERSION,
12510
+ sessionKey: key,
12511
+ contributionId,
12512
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
12513
+ });
12514
+ }
12515
+ );
12516
+ if (result.kind === "abandon-session") {
12517
+ await deleteSessionState(key);
12518
+ await writeState({
12519
+ ...latest,
12520
+ sessionMappingAbandonedAt: (/* @__PURE__ */ new Date()).toISOString()
12521
+ });
12522
+ return;
12523
+ }
12524
+ if (result.kind !== "uploaded") return;
12525
+ const uploadedAt = (/* @__PURE__ */ new Date()).toISOString();
12526
+ const persisted = await readSessionState(key);
12527
+ if (persisted?.contributionId === result.contributionId) {
12528
+ await writeSessionState({
12529
+ ...persisted,
12530
+ firstUploadSubmittedAt: persisted.firstUploadSubmittedAt ?? uploadedAt,
12531
+ lastUploadAt: uploadedAt
12532
+ });
12533
+ }
12534
+ await writeState({
12535
+ ...latest,
12536
+ uploadedAt,
12537
+ uploadContributionId: result.contributionId
12538
+ });
12539
+ } finally {
12540
+ await releaseLock(sessionLockId);
12541
+ }
11773
12542
  } finally {
11774
12543
  await releaseLock(ctx.eventId);
11775
12544
  }
@@ -11823,7 +12592,8 @@ function buildEpochMeta(params) {
11823
12592
  transcriptArchivePath: params.transcriptArchivePath,
11824
12593
  rawByteOffset: params.cursor.rawByteOffset,
11825
12594
  rawPrefixSha256: params.cursor.rawPrefixSha256,
11826
- recordedAt: new Date(params.recordedAt).toISOString()
12595
+ recordedAt: new Date(params.recordedAt).toISOString(),
12596
+ ...params.lineage
11827
12597
  };
11828
12598
  return Buffer.from(JSON.stringify(meta, null, 2));
11829
12599
  }
@@ -11852,16 +12622,16 @@ async function uploadArtifact(client, contributionId, filename, mimeType, buffer
11852
12622
  }
11853
12623
 
11854
12624
  // src/transcript-cursor.ts
11855
- import crypto2 from "crypto";
11856
- import fs11 from "fs";
12625
+ import crypto3 from "crypto";
12626
+ import fs13 from "fs";
11857
12627
  function sha256OfBuffer(buffer) {
11858
- return crypto2.createHash("sha256").update(buffer).digest("hex");
12628
+ return crypto3.createHash("sha256").update(buffer).digest("hex");
11859
12629
  }
11860
12630
  async function hashPrefix(filePath, byteLength) {
11861
- const hash = crypto2.createHash("sha256");
12631
+ const hash = crypto3.createHash("sha256");
11862
12632
  if (byteLength === 0) return hash;
11863
12633
  await new Promise((resolve, reject) => {
11864
- const stream = fs11.createReadStream(filePath, {
12634
+ const stream = fs13.createReadStream(filePath, {
11865
12635
  start: 0,
11866
12636
  end: byteLength - 1
11867
12637
  });
@@ -11872,7 +12642,7 @@ async function hashPrefix(filePath, byteLength) {
11872
12642
  return hash;
11873
12643
  }
11874
12644
  async function readRange(filePath, start, end) {
11875
- const fd = await fs11.promises.open(filePath, "r");
12645
+ const fd = await fs13.promises.open(filePath, "r");
11876
12646
  try {
11877
12647
  const buffer = Buffer.alloc(end - start);
11878
12648
  let filled = 0;
@@ -11892,7 +12662,7 @@ async function readRange(filePath, start, end) {
11892
12662
  }
11893
12663
  }
11894
12664
  async function evaluateTranscript(filePath, cursor) {
11895
- const stat = await fs11.promises.stat(filePath);
12665
+ const stat = await fs13.promises.stat(filePath);
11896
12666
  if (stat.size < cursor.rawByteOffset) return { kind: "truncate" };
11897
12667
  const prefixHash = await hashPrefix(filePath, cursor.rawByteOffset);
11898
12668
  const continuation = prefixHash.copy();
@@ -11927,12 +12697,12 @@ function truncateAtLastNewline(buffer) {
11927
12697
  }
11928
12698
  async function cursorMatchesFile(filePath, cursor) {
11929
12699
  try {
11930
- const stat = await fs11.promises.stat(filePath);
12700
+ const stat = await fs13.promises.stat(filePath);
11931
12701
  if (stat.size < cursor.rawByteOffset) return false;
11932
12702
  const prefixHash = await hashPrefix(filePath, cursor.rawByteOffset);
11933
12703
  if (prefixHash.digest("hex") !== cursor.rawPrefixSha256) return false;
11934
12704
  if (cursor.rawByteOffset === 0) return true;
11935
- const fd = await fs11.promises.open(filePath, "r");
12705
+ const fd = await fs13.promises.open(filePath, "r");
11936
12706
  try {
11937
12707
  const byte = Buffer.alloc(1);
11938
12708
  const { bytesRead } = await fd.read(byte, 0, 1, cursor.rawByteOffset - 1);
@@ -11946,20 +12716,20 @@ async function cursorMatchesFile(filePath, cursor) {
11946
12716
  }
11947
12717
 
11948
12718
  // src/upload-state.ts
11949
- import crypto3 from "crypto";
11950
- import fs12 from "fs";
11951
- import os5 from "os";
11952
- import path13 from "path";
12719
+ import crypto4 from "crypto";
12720
+ import fs14 from "fs";
12721
+ import os6 from "os";
12722
+ import path14 from "path";
11953
12723
  var CURRENT_SCHEMA_VERSION2 = 1;
11954
- var DEFAULT_STATE_DIR = path13.join(
11955
- os5.homedir(),
12724
+ var DEFAULT_STATE_DIR = path14.join(
12725
+ os6.homedir(),
11956
12726
  ".hillclimb",
11957
12727
  "agent-uploads"
11958
12728
  );
11959
- var DEFAULT_LOCK_WAIT_MS = 5 * 60 * 1e3;
12729
+ var DEFAULT_LOCK_WAIT_MS2 = 5 * 60 * 1e3;
11960
12730
  var DEFAULT_LOCK_RETRY_DELAY_MS = 500;
11961
12731
  var DEFAULT_STATE_TTL_MS = 24 * 60 * 60 * 1e3;
11962
- var STALE_LOCK_TTL_MS = 60 * 60 * 1e3;
12732
+ var STALE_LOCK_TTL_MS2 = 60 * 60 * 1e3;
11963
12733
  function stateDir2() {
11964
12734
  return process.env.HILLCLIMB_UPLOAD_STATE_DIR ?? DEFAULT_STATE_DIR;
11965
12735
  }
@@ -11988,20 +12758,20 @@ function lockRetries() {
11988
12758
  return Math.max(
11989
12759
  1,
11990
12760
  Math.ceil(
11991
- readPositiveEnvMs("HILLCLIMB_UPLOAD_LOCK_WAIT_MS", DEFAULT_LOCK_WAIT_MS) / lockRetryDelayMs()
12761
+ readPositiveEnvMs("HILLCLIMB_UPLOAD_LOCK_WAIT_MS", DEFAULT_LOCK_WAIT_MS2) / lockRetryDelayMs()
11992
12762
  )
11993
12763
  );
11994
12764
  }
11995
12765
  function stateFileFor(repoRoot, tool, sessionId) {
11996
- const hash = crypto3.createHash("sha256").update(`${path13.resolve(repoRoot)}\0${tool}\0${sessionId}`).digest("hex").slice(0, 16);
11997
- return path13.join(stateDir2(), `${hash}.json`);
12766
+ const hash = crypto4.createHash("sha256").update(`${path14.resolve(repoRoot)}\0${tool}\0${sessionId}`).digest("hex").slice(0, 16);
12767
+ return path14.join(stateDir2(), `${hash}.json`);
11998
12768
  }
11999
12769
  function lockFileFor(repoRoot, tool, sessionId) {
12000
12770
  return `${stateFileFor(repoRoot, tool, sessionId)}.lock`;
12001
12771
  }
12002
12772
  async function readUploadState(repoRoot, tool, sessionId) {
12003
12773
  try {
12004
- const raw = await fs12.promises.readFile(
12774
+ const raw = await fs14.promises.readFile(
12005
12775
  stateFileFor(repoRoot, tool, sessionId),
12006
12776
  "utf-8"
12007
12777
  );
@@ -12014,20 +12784,20 @@ async function readUploadState(repoRoot, tool, sessionId) {
12014
12784
  }
12015
12785
  async function writeUploadState(state) {
12016
12786
  const file = stateFileFor(state.repoRoot, state.tool, state.sessionId);
12017
- await fs12.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
12787
+ await fs14.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
12018
12788
  const tmp = `${file}.tmp`;
12019
- await fs12.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
12789
+ await fs14.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
12020
12790
  mode: 384
12021
12791
  });
12022
- await fs12.promises.rename(tmp, file);
12792
+ await fs14.promises.rename(tmp, file);
12023
12793
  }
12024
12794
  async function deleteUploadState(repoRoot, tool, sessionId) {
12025
12795
  try {
12026
- await fs12.promises.unlink(stateFileFor(repoRoot, tool, sessionId));
12796
+ await fs14.promises.unlink(stateFileFor(repoRoot, tool, sessionId));
12027
12797
  } catch {
12028
12798
  }
12029
12799
  try {
12030
- await fs12.promises.unlink(cursorFileFor(repoRoot, tool, sessionId));
12800
+ await fs14.promises.unlink(cursorFileFor(repoRoot, tool, sessionId));
12031
12801
  } catch {
12032
12802
  }
12033
12803
  }
@@ -12036,7 +12806,7 @@ function cursorFileFor(repoRoot, tool, sessionId) {
12036
12806
  }
12037
12807
  async function readCursorState(repoRoot, tool, sessionId) {
12038
12808
  try {
12039
- const raw = await fs12.promises.readFile(
12809
+ const raw = await fs14.promises.readFile(
12040
12810
  cursorFileFor(repoRoot, tool, sessionId),
12041
12811
  "utf-8"
12042
12812
  );
@@ -12051,21 +12821,36 @@ async function readCursorState(repoRoot, tool, sessionId) {
12051
12821
  }
12052
12822
  async function writeCursorState(repoRoot, tool, sessionId, cursor) {
12053
12823
  const file = cursorFileFor(repoRoot, tool, sessionId);
12054
- await fs12.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
12824
+ await fs14.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
12055
12825
  const tmp = `${file}.tmp`;
12056
- await fs12.promises.writeFile(tmp, JSON.stringify(cursor, null, 2), {
12826
+ await fs14.promises.writeFile(tmp, JSON.stringify(cursor, null, 2), {
12057
12827
  mode: 384
12058
12828
  });
12059
- await fs12.promises.rename(tmp, file);
12829
+ await fs14.promises.rename(tmp, file);
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;
12060
12845
  }
12061
12846
  async function acquireLock2(repoRoot, tool, sessionId, retries = lockRetries(), delayMs = lockRetryDelayMs()) {
12062
12847
  const lockPath = lockFileFor(repoRoot, tool, sessionId);
12063
- await fs12.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
12848
+ await fs14.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
12064
12849
  for (let i = 0; i < retries; i++) {
12065
12850
  try {
12066
- const fd = await fs12.promises.open(
12851
+ const fd = await fs14.promises.open(
12067
12852
  lockPath,
12068
- fs12.constants.O_CREAT | fs12.constants.O_EXCL | fs12.constants.O_WRONLY
12853
+ fs14.constants.O_CREAT | fs14.constants.O_EXCL | fs14.constants.O_WRONLY
12069
12854
  );
12070
12855
  try {
12071
12856
  await fd.write(String(process.pid));
@@ -12075,6 +12860,11 @@ async function acquireLock2(repoRoot, tool, sessionId, retries = lockRetries(),
12075
12860
  return;
12076
12861
  } catch (err) {
12077
12862
  if (err.code === "EEXIST" && i < retries - 1) {
12863
+ if (await reapLockIfStale(lockPath, {
12864
+ maxAgeMs: STALE_LOCK_TTL_MS2
12865
+ })) {
12866
+ continue;
12867
+ }
12078
12868
  await new Promise((r) => setTimeout(r, delayMs));
12079
12869
  continue;
12080
12870
  }
@@ -12088,7 +12878,7 @@ async function acquireLock2(repoRoot, tool, sessionId, retries = lockRetries(),
12088
12878
  }
12089
12879
  async function releaseLock2(repoRoot, tool, sessionId) {
12090
12880
  try {
12091
- await fs12.promises.unlink(lockFileFor(repoRoot, tool, sessionId));
12881
+ await fs14.promises.unlink(lockFileFor(repoRoot, tool, sessionId));
12092
12882
  } catch {
12093
12883
  }
12094
12884
  }
@@ -12100,43 +12890,27 @@ async function withUploadLock(repoRoot, tool, sessionId, fn) {
12100
12890
  await releaseLock2(repoRoot, tool, sessionId);
12101
12891
  }
12102
12892
  }
12103
- function isProcessAlive(pid) {
12104
- try {
12105
- process.kill(pid, 0);
12106
- return true;
12107
- } catch (err) {
12108
- return err.code === "EPERM";
12109
- }
12110
- }
12111
12893
  async function sweepStaleUploadStates(ttlMs = stateTtlMs(), now = Date.now()) {
12112
12894
  let entries;
12113
12895
  try {
12114
- entries = await fs12.promises.readdir(stateDir2(), { withFileTypes: true });
12896
+ entries = await fs14.promises.readdir(stateDir2(), { withFileTypes: true });
12115
12897
  } catch {
12116
12898
  return;
12117
12899
  }
12118
12900
  for (const entry of entries) {
12119
12901
  if (!entry.isFile()) continue;
12120
- const file = path13.join(stateDir2(), entry.name);
12902
+ const file = path14.join(stateDir2(), entry.name);
12121
12903
  try {
12122
12904
  if (entry.name.endsWith(".lock")) {
12123
- const raw = await fs12.promises.readFile(file, "utf-8").catch(() => "");
12124
- const pid = Number.parseInt(raw.trim(), 10);
12125
- if (Number.isInteger(pid) && pid > 0) {
12126
- if (!isProcessAlive(pid)) {
12127
- await fs12.promises.unlink(file);
12128
- }
12129
- } else {
12130
- const st2 = await fs12.promises.stat(file);
12131
- if (now - st2.mtimeMs > STALE_LOCK_TTL_MS) {
12132
- await fs12.promises.unlink(file);
12133
- }
12134
- }
12905
+ await reapLockIfStale(file, {
12906
+ maxAgeMs: STALE_LOCK_TTL_MS2,
12907
+ now
12908
+ });
12135
12909
  continue;
12136
12910
  }
12137
- const st = await fs12.promises.stat(file);
12911
+ const st = await fs14.promises.stat(file);
12138
12912
  if (now - st.mtimeMs > ttlMs) {
12139
- await fs12.promises.unlink(file);
12913
+ await fs14.promises.unlink(file);
12140
12914
  }
12141
12915
  } catch {
12142
12916
  }
@@ -12156,13 +12930,13 @@ function formatEpochSeconds2(date) {
12156
12930
  return String(Math.floor(date.getTime() / 1e3));
12157
12931
  }
12158
12932
  function newFlowId() {
12159
- return crypto4.randomBytes(3).toString("hex");
12933
+ return crypto5.randomBytes(3).toString("hex");
12160
12934
  }
12161
12935
  function lineHasAssistant(line) {
12162
12936
  return line.includes('"type":"assistant"') || line.includes('"role":"assistant"') || line.includes('"type":"assistant.');
12163
12937
  }
12164
12938
  async function hasAssistantMessage(transcriptPath) {
12165
- const stream = fs13.createReadStream(transcriptPath, { encoding: "utf-8" });
12939
+ const stream = fs15.createReadStream(transcriptPath, { encoding: "utf-8" });
12166
12940
  let buffer = "";
12167
12941
  try {
12168
12942
  for await (const chunk of stream) {
@@ -12223,53 +12997,8 @@ function summarizePayload(payload) {
12223
12997
  cursor_version_present: !!payload.cursor_version
12224
12998
  });
12225
12999
  }
12226
- async function codexSessionIdFromFile(filePath) {
12227
- const stream = fs13.createReadStream(filePath, { encoding: "utf-8" });
12228
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
12229
- try {
12230
- for await (const line of rl) {
12231
- if (!line.trim()) continue;
12232
- try {
12233
- const obj = JSON.parse(line);
12234
- if (obj.type === "session_meta") return obj.payload?.id ?? null;
12235
- } catch {
12236
- return null;
12237
- }
12238
- return null;
12239
- }
12240
- } finally {
12241
- rl.close();
12242
- stream.destroy();
12243
- }
12244
- return null;
12245
- }
12246
13000
  async function findCodexTranscriptPath(sessionId) {
12247
- const sessionsDir = process.env.HILLCLIMB_CODEX_SESSIONS_DIR ?? path14.join(os6.homedir(), ".codex", "sessions");
12248
- const candidates = [];
12249
- async function walk(dir) {
12250
- let entries;
12251
- try {
12252
- entries = await fs13.promises.readdir(dir, { withFileTypes: true });
12253
- } catch {
12254
- return;
12255
- }
12256
- for (const entry of entries) {
12257
- const full = path14.join(dir, entry.name);
12258
- if (entry.isDirectory()) {
12259
- await walk(full);
12260
- } else if (entry.isFile() && entry.name.endsWith(".jsonl") && entry.name.includes(sessionId)) {
12261
- candidates.push(full);
12262
- }
12263
- }
12264
- }
12265
- await walk(sessionsDir);
12266
- candidates.sort();
12267
- for (const filePath of candidates) {
12268
- if (await codexSessionIdFromFile(filePath) === sessionId) {
12269
- return filePath;
12270
- }
12271
- }
12272
- return void 0;
13001
+ return findCodexRolloutPath(sessionId);
12273
13002
  }
12274
13003
  async function selfHealHook(repoRoot, tool) {
12275
13004
  if (process.env.HILLCLIMB_SKIP_HOOK_SELF_HEAL === "1") return;
@@ -12294,8 +13023,8 @@ function resolveCursorTranscriptPath(payload) {
12294
13023
  const workspace = payload.workspace_roots?.[0];
12295
13024
  if (!id || !workspace) return void 0;
12296
13025
  const encoded = workspace.replace(/^\//, "").replace(/\//g, "-");
12297
- return path14.join(
12298
- os6.homedir(),
13026
+ return path15.join(
13027
+ os7.homedir(),
12299
13028
  ".cursor",
12300
13029
  "projects",
12301
13030
  encoded,
@@ -12322,7 +13051,7 @@ async function resolveTranscriptPath(payload, sourceTool, sessionId) {
12322
13051
  async function runUploadInner(payload) {
12323
13052
  const sessionId = resolveHookSessionId(payload);
12324
13053
  const cwd = resolveHookCwd(payload);
12325
- const eventKind = classifyHookEvent(payload.hook_event_name);
13054
+ const eventKind2 = classifyHookEvent(payload.hook_event_name);
12326
13055
  const recordedAt = Date.now();
12327
13056
  if (!sessionId || !cwd) {
12328
13057
  appendLog(
@@ -12348,7 +13077,7 @@ async function runUploadInner(payload) {
12348
13077
  payload.tool = sourceTool;
12349
13078
  appendLog(
12350
13079
  "info",
12351
- `[${sessionId}] payload parsed (tool=${sourceTool}, cwd=${cwd}, event=${eventKind ?? "?"})`
13080
+ `[${sessionId}] payload parsed (tool=${sourceTool}, cwd=${cwd}, event=${eventKind2 ?? "?"})`
12352
13081
  );
12353
13082
  await selfHealHook(repoRoot, sourceTool);
12354
13083
  const transcriptPath = await resolveTranscriptPath(
@@ -12363,9 +13092,9 @@ async function runUploadInner(payload) {
12363
13092
  );
12364
13093
  return false;
12365
13094
  }
12366
- const transcriptResolved = path14.resolve(transcriptPath);
13095
+ const transcriptResolved = path15.resolve(transcriptPath);
12367
13096
  try {
12368
- const stat = await fs13.promises.stat(transcriptResolved);
13097
+ const stat = await fs15.promises.stat(transcriptResolved);
12369
13098
  if (!stat.isFile()) {
12370
13099
  appendLog(
12371
13100
  "warn",
@@ -12393,12 +13122,12 @@ async function runUploadInner(payload) {
12393
13122
  repoRoot,
12394
13123
  config,
12395
13124
  sourceTool,
12396
- eventKind,
13125
+ eventKind: eventKind2,
12397
13126
  recordedAt
12398
13127
  });
12399
13128
  }
12400
13129
  var AGENT_MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
12401
- var CLI_VERSION = "0.7.0";
13130
+ var CLI_VERSION = "0.8.0";
12402
13131
  function makeTranscriptGroup(repoRoot, sourceTool, transcriptPath, content) {
12403
13132
  const sourceFile = {
12404
13133
  sourceName: sourceTool,
@@ -12408,7 +13137,7 @@ function makeTranscriptGroup(repoRoot, sourceTool, transcriptPath, content) {
12408
13137
  };
12409
13138
  return {
12410
13139
  repoPath: repoRoot,
12411
- label: path14.basename(repoRoot),
13140
+ label: path15.basename(repoRoot),
12412
13141
  files: [sourceFile],
12413
13142
  sourceNames: [sourceTool],
12414
13143
  lastModified: /* @__PURE__ */ new Date()
@@ -12416,7 +13145,7 @@ function makeTranscriptGroup(repoRoot, sourceTool, transcriptPath, content) {
12416
13145
  }
12417
13146
  async function buildRedactChain(repoRoot) {
12418
13147
  const envFileNames = await discoverEnvFiles(repoRoot);
12419
- const envFilePaths = envFileNames.map((n) => path14.join(repoRoot, n));
13148
+ const envFilePaths = envFileNames.map((n) => path15.join(repoRoot, n));
12420
13149
  const secretResult = await collectSecrets(repoRoot, envFilePaths, []);
12421
13150
  const chain = [];
12422
13151
  if (secretResult.values.size > 0) {
@@ -12466,6 +13195,14 @@ async function handleUploadFailure(err, sessionId, repoRoot, sourceTool) {
12466
13195
  );
12467
13196
  return false;
12468
13197
  }
13198
+ if (isContributionUploadNotUploaded(err)) {
13199
+ await deleteUploadState(repoRoot, sourceTool, sessionId);
13200
+ appendLog(
13201
+ "warn",
13202
+ `Session ${sessionId} upload failed: storage validation returned ${err.code}; contribution abandoned and local state cleared so the next hook uploads a complete snapshot to a fresh contribution.`
13203
+ );
13204
+ return false;
13205
+ }
12469
13206
  appendLog(
12470
13207
  "error",
12471
13208
  `Session ${sessionId} upload failed: ${err instanceof Error ? err.message : String(err)}`
@@ -12479,13 +13216,24 @@ async function uploadSession(args) {
12479
13216
  repoRoot,
12480
13217
  config,
12481
13218
  sourceTool,
12482
- eventKind,
13219
+ eventKind: eventKind2,
12483
13220
  recordedAt
12484
13221
  } = args;
12485
- const isSessionEnd = eventKind === "sessionEnd";
13222
+ const isSessionEnd = eventKind2 === "sessionEnd";
12486
13223
  const eventLabel = isSessionEnd ? "SessionEnd" : "Stop";
12487
13224
  return await withUploadLock(repoRoot, sourceTool, sessionId, async () => {
12488
13225
  const prior = await readUploadState(repoRoot, sourceTool, sessionId);
13226
+ const lineageDetection = sourceTool === "codex" && prior?.codexLineageChecked !== true ? await detectCodexLineage(transcriptPath) : void 0;
13227
+ const codexLineage = sourceTool === "codex" ? mergeCodexLineage(prior?.codexLineage, lineageDetection?.lineage) : void 0;
13228
+ const codexLineageChecked = sourceTool === "codex" ? prior?.codexLineageChecked === true || lineageDetection?.conclusive === true : void 0;
13229
+ const lineageNeedsStamp = codexLineage !== void 0 && JSON.stringify(codexLineage) !== JSON.stringify(prior?.codexLineageStamped);
13230
+ if (prior && sourceTool === "codex" && (JSON.stringify(codexLineage) !== JSON.stringify(prior.codexLineage) || codexLineageChecked !== prior.codexLineageChecked)) {
13231
+ await writeUploadState({
13232
+ ...prior,
13233
+ codexLineage,
13234
+ codexLineageChecked
13235
+ });
13236
+ }
12489
13237
  let cursor = null;
12490
13238
  let adoptedLegacy = false;
12491
13239
  let restored = null;
@@ -12539,16 +13287,34 @@ async function uploadSession(args) {
12539
13287
  if (cursor) {
12540
13288
  const evaluation = await evaluateTranscript(transcriptPath, cursor);
12541
13289
  if (evaluation.kind === "unchanged") {
12542
- appendLog(
12543
- "info",
12544
- `[${sessionId}] skipping ${sourceTool} ${eventLabel} upload (no new complete transcript lines past offset ${cursor.rawByteOffset}${isSessionEnd ? "; local state cleared" : ""})`
12545
- );
12546
- if (isSessionEnd) {
12547
- await deleteUploadState(repoRoot, sourceTool, sessionId);
13290
+ if (lineageNeedsStamp && prior?.contributionId && prior.epochMeta) {
13291
+ mode = "meta-refresh";
13292
+ } else if (lineageNeedsStamp) {
13293
+ mode = "snapshot";
13294
+ transition = "state-lost";
13295
+ } else {
13296
+ appendLog(
13297
+ "info",
13298
+ `[${sessionId}] skipping ${sourceTool} ${eventLabel} upload (no new complete transcript lines past offset ${cursor.rawByteOffset}${isSessionEnd ? "; session state retained for late hooks" : ""})`
13299
+ );
13300
+ if (isSessionEnd) {
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
+ );
13314
+ }
13315
+ return true;
12548
13316
  }
12549
- return true;
12550
- }
12551
- if (evaluation.kind === "append") {
13317
+ } else if (evaluation.kind === "append") {
12552
13318
  mode = "patch";
12553
13319
  tail = evaluation.tail;
12554
13320
  nextCursor = evaluation.nextCursor;
@@ -12580,6 +13346,57 @@ async function uploadSession(args) {
12580
13346
  });
12581
13347
  const alreadySubmitted = prior?.submitted ?? false;
12582
13348
  const submitThisUpload = config.autoSubmit && !alreadySubmitted;
13349
+ if (mode === "meta-refresh" && prior?.contributionId && prior.epochMeta && cursor) {
13350
+ const epoch2 = prior.epoch ?? restored?.epoch ?? 1;
13351
+ try {
13352
+ await uploadArtifact(
13353
+ client,
13354
+ prior.contributionId,
13355
+ metaFilename(epoch2, recordedAt),
13356
+ "application/json",
13357
+ buildEpochMeta({
13358
+ sessionId,
13359
+ tool: sourceTool,
13360
+ cliVersion: CLI_VERSION,
13361
+ epoch: epoch2,
13362
+ transitionKind: prior.epochMeta.transitionKind,
13363
+ baseline: prior.epochMeta.baseline,
13364
+ snapshotFilename: prior.epochMeta.snapshotFilename,
13365
+ transcriptArchivePath: prior.epochMeta.transcriptArchivePath,
13366
+ cursor: {
13367
+ rawByteOffset: prior.epochMeta.rawByteOffset,
13368
+ rawPrefixSha256: prior.epochMeta.rawPrefixSha256
13369
+ },
13370
+ recordedAt,
13371
+ lineage: codexLineage
13372
+ })
13373
+ );
13374
+ } catch (err) {
13375
+ return handleUploadFailure(err, sessionId, repoRoot, sourceTool);
13376
+ }
13377
+ await writeUploadState({
13378
+ ...prior,
13379
+ lastUploadedAt: now.toISOString(),
13380
+ uploadCount: prior.uploadCount + 1,
13381
+ codexLineage,
13382
+ codexLineageChecked,
13383
+ codexLineageStamped: codexLineage,
13384
+ sessionEndedAt: void 0
13385
+ });
13386
+ appendLog(
13387
+ "info",
13388
+ `[${sessionId}] refreshed ${sourceTool} lineage meta for unchanged epoch=${epoch2}`
13389
+ );
13390
+ if (isSessionEnd) {
13391
+ await setUploadSessionEndedAt(
13392
+ repoRoot,
13393
+ sourceTool,
13394
+ sessionId,
13395
+ new Date(recordedAt).toISOString()
13396
+ );
13397
+ }
13398
+ return true;
13399
+ }
12583
13400
  if (mode === "patch" && cursor && tail && nextCursor && prior?.contributionId) {
12584
13401
  const baseCursor = cursor;
12585
13402
  const contributionId2 = prior.contributionId;
@@ -12589,6 +13406,9 @@ async function uploadSession(args) {
12589
13406
  "info",
12590
13407
  `[${sessionId}] ${sourceTool} ${eventLabel} upload \u2192 patch epoch=${epoch2} turn=${turn} (${tail.length} raw tail bytes, contribution ${contributionId2})`
12591
13408
  );
13409
+ let metaUploaded2 = 0;
13410
+ let codexLineageStamped2 = prior.codexLineageStamped;
13411
+ let epochMeta2 = prior.epochMeta;
12592
13412
  try {
12593
13413
  const redactedTail = await redactTail(
12594
13414
  tail,
@@ -12598,6 +13418,13 @@ async function uploadSession(args) {
12598
13418
  redactChain
12599
13419
  );
12600
13420
  if (adoptedLegacy) {
13421
+ epochMeta2 = {
13422
+ transitionKind: "legacy-adopted",
13423
+ baseline: "legacy-latest-zip",
13424
+ transcriptArchivePath,
13425
+ rawByteOffset: baseCursor.rawByteOffset,
13426
+ rawPrefixSha256: baseCursor.rawPrefixSha256
13427
+ };
12601
13428
  await uploadArtifact(
12602
13429
  client,
12603
13430
  contributionId2,
@@ -12612,9 +13439,12 @@ async function uploadSession(args) {
12612
13439
  baseline: "legacy-latest-zip",
12613
13440
  transcriptArchivePath,
12614
13441
  cursor: baseCursor,
12615
- recordedAt
13442
+ recordedAt,
13443
+ lineage: codexLineage
12616
13444
  })
12617
13445
  );
13446
+ metaUploaded2 = 1;
13447
+ codexLineageStamped2 = codexLineage;
12618
13448
  }
12619
13449
  await uploadArtifact(
12620
13450
  client,
@@ -12636,7 +13466,7 @@ async function uploadSession(args) {
12636
13466
  projectId: config.projectId,
12637
13467
  contributionId: contributionId2,
12638
13468
  submitted: submitted2,
12639
- uploadCount: (prior.uploadCount ?? 0) + (adoptedLegacy ? 2 : 1),
13469
+ uploadCount: (prior.uploadCount ?? 0) + 1 + metaUploaded2,
12640
13470
  firstUploadedAt: prior.firstUploadedAt ?? now.toISOString(),
12641
13471
  lastUploadedAt: now.toISOString(),
12642
13472
  // Mirrors the cursor from here on: describes the raw bytes covered
@@ -12649,7 +13479,11 @@ async function uploadSession(args) {
12649
13479
  turnCount: turn,
12650
13480
  rawByteOffset: nextCursor.rawByteOffset,
12651
13481
  rawPrefixSha256: nextCursor.rawPrefixSha256,
12652
- snapshotUploaded: true
13482
+ snapshotUploaded: true,
13483
+ codexLineage,
13484
+ codexLineageChecked,
13485
+ codexLineageStamped: codexLineageStamped2,
13486
+ epochMeta: epochMeta2
12653
13487
  });
12654
13488
  await writeCursorState(repoRoot, sourceTool, sessionId, {
12655
13489
  contributionId: contributionId2,
@@ -12661,6 +13495,45 @@ async function uploadSession(args) {
12661
13495
  });
12662
13496
  };
12663
13497
  await persistState(alreadySubmitted);
13498
+ if (!adoptedLegacy && codexLineage && epochMeta2 && JSON.stringify(codexLineage) !== JSON.stringify(prior.codexLineageStamped)) {
13499
+ try {
13500
+ await uploadArtifact(
13501
+ client,
13502
+ contributionId2,
13503
+ metaFilename(epoch2, recordedAt),
13504
+ "application/json",
13505
+ buildEpochMeta({
13506
+ sessionId,
13507
+ tool: sourceTool,
13508
+ cliVersion: CLI_VERSION,
13509
+ epoch: epoch2,
13510
+ transitionKind: epochMeta2.transitionKind,
13511
+ baseline: epochMeta2.baseline,
13512
+ snapshotFilename: epochMeta2.snapshotFilename,
13513
+ transcriptArchivePath: epochMeta2.transcriptArchivePath,
13514
+ cursor: {
13515
+ rawByteOffset: epochMeta2.rawByteOffset,
13516
+ rawPrefixSha256: epochMeta2.rawPrefixSha256
13517
+ },
13518
+ recordedAt,
13519
+ lineage: codexLineage
13520
+ })
13521
+ );
13522
+ metaUploaded2 = 1;
13523
+ codexLineageStamped2 = codexLineage;
13524
+ await persistState(alreadySubmitted);
13525
+ } catch (err) {
13526
+ appendLog(
13527
+ "warn",
13528
+ `[${sessionId}] lineage meta refresh failed after durable patch capture; lineage remains pending: ${err instanceof Error ? err.message : String(err)}`
13529
+ );
13530
+ }
13531
+ } else if (lineageNeedsStamp && !epochMeta2) {
13532
+ appendLog(
13533
+ "info",
13534
+ `[${sessionId}] lineage publication deferred after durable patch capture (pre-feature state has no reconstructable epoch metadata)`
13535
+ );
13536
+ }
12664
13537
  let submitted = alreadySubmitted;
12665
13538
  if (submitThisUpload) {
12666
13539
  try {
@@ -12675,6 +13548,8 @@ async function uploadSession(args) {
12675
13548
  "info",
12676
13549
  `contribution ${contributionId2} was already submitted (409); recording locally`
12677
13550
  );
13551
+ } else if (isContributionUploadNotUploaded(err)) {
13552
+ return handleUploadFailure(err, sessionId, repoRoot, sourceTool);
12678
13553
  } else {
12679
13554
  appendLog(
12680
13555
  "warn",
@@ -12689,17 +13564,22 @@ async function uploadSession(args) {
12689
13564
  `Uploaded session ${sessionId} patch epoch=${epoch2} turn=${turn} to contribution ${contributionId2} (offset ${baseCursor.rawByteOffset} \u2192 ${nextCursor.rawByteOffset})`
12690
13565
  );
12691
13566
  if (isSessionEnd) {
12692
- await deleteUploadState(repoRoot, sourceTool, sessionId);
13567
+ await setUploadSessionEndedAt(
13568
+ repoRoot,
13569
+ sourceTool,
13570
+ sessionId,
13571
+ new Date(recordedAt).toISOString()
13572
+ );
12693
13573
  appendLog(
12694
13574
  "info",
12695
- `[${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`
12696
13576
  );
12697
13577
  }
12698
13578
  return true;
12699
13579
  }
12700
13580
  let raw;
12701
13581
  try {
12702
- raw = await fs13.promises.readFile(transcriptPath);
13582
+ raw = await fs15.promises.readFile(transcriptPath);
12703
13583
  } catch (err) {
12704
13584
  if (err.code === "ERR_FS_FILE_TOO_LARGE") {
12705
13585
  appendLog(
@@ -12720,8 +13600,21 @@ async function uploadSession(args) {
12720
13600
  "info",
12721
13601
  `[${sessionId}] skipping ${sourceTool} ${eventLabel} upload (no complete transcript line yet)`
12722
13602
  );
12723
- if (isSessionEnd)
12724
- 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
+ }
12725
13618
  return true;
12726
13619
  }
12727
13620
  const epoch = (prior?.epoch ?? restored?.epoch ?? 0) + 1;
@@ -12740,6 +13633,17 @@ Tool: ${toolLabel2}
12740
13633
  Repo: ${repoRoot}
12741
13634
  Uploaded: ${now.toISOString()}`;
12742
13635
  const zipFilename = snapshotFilename(epoch, recordedAt);
13636
+ const epochMeta = {
13637
+ transitionKind: transition,
13638
+ baseline: "snapshot",
13639
+ snapshotFilename: zipFilename,
13640
+ transcriptArchivePath: archivePathFor({
13641
+ sourceName: sourceTool,
13642
+ absolutePath: transcriptPath
13643
+ }),
13644
+ rawByteOffset: truncated.cursor.rawByteOffset,
13645
+ rawPrefixSha256: truncated.cursor.rawPrefixSha256
13646
+ };
12743
13647
  const group = makeTranscriptGroup(
12744
13648
  repoRoot,
12745
13649
  sourceTool,
@@ -12771,7 +13675,10 @@ Uploaded: ${now.toISOString()}`;
12771
13675
  firstUploadedAt: prior?.firstUploadedAt ?? now.toISOString(),
12772
13676
  lastUploadedAt: prior?.lastUploadedAt ?? now.toISOString(),
12773
13677
  lastTranscriptSize: prior?.lastTranscriptSize,
12774
- lastTranscriptSha256: prior?.lastTranscriptSha256
13678
+ lastTranscriptSha256: prior?.lastTranscriptSha256,
13679
+ codexLineage,
13680
+ codexLineageChecked,
13681
+ epochMeta
12775
13682
  })
12776
13683
  });
12777
13684
  const reuseDesc = prior?.contributionId ? `reusing contribution ${prior.contributionId}` : prior ? "new contribution (prior state had no contribution \u2014 earlier create may have failed)" : "new contribution (first upload for session)";
@@ -12788,6 +13695,7 @@ Uploaded: ${now.toISOString()}`;
12788
13695
  return handleUploadFailure(err, sessionId, repoRoot, sourceTool);
12789
13696
  }
12790
13697
  let metaUploaded = 0;
13698
+ let codexLineageStamped;
12791
13699
  try {
12792
13700
  await uploadArtifact(
12793
13701
  client,
@@ -12804,10 +13712,12 @@ Uploaded: ${now.toISOString()}`;
12804
13712
  snapshotFilename: zipFilename,
12805
13713
  transcriptArchivePath,
12806
13714
  cursor: truncated.cursor,
12807
- recordedAt
13715
+ recordedAt,
13716
+ lineage: codexLineage
12808
13717
  })
12809
13718
  );
12810
13719
  metaUploaded = 1;
13720
+ codexLineageStamped = codexLineage;
12811
13721
  } catch (err) {
12812
13722
  appendLog(
12813
13723
  "warn",
@@ -12832,7 +13742,11 @@ Uploaded: ${now.toISOString()}`;
12832
13742
  turnCount: 0,
12833
13743
  rawByteOffset: truncated.cursor.rawByteOffset,
12834
13744
  rawPrefixSha256: truncated.cursor.rawPrefixSha256,
12835
- snapshotUploaded: true
13745
+ snapshotUploaded: true,
13746
+ codexLineage,
13747
+ codexLineageChecked,
13748
+ codexLineageStamped,
13749
+ epochMeta
12836
13750
  });
12837
13751
  await writeCursorState(repoRoot, sourceTool, sessionId, {
12838
13752
  contributionId,
@@ -12847,10 +13761,15 @@ Uploaded: ${now.toISOString()}`;
12847
13761
  `Uploaded session ${sessionId} snapshot epoch=${epoch} to project ${config.projectSlug} (${config.projectId}) as contribution ${contributionId}`
12848
13762
  );
12849
13763
  if (isSessionEnd) {
12850
- await deleteUploadState(repoRoot, sourceTool, sessionId);
13764
+ await setUploadSessionEndedAt(
13765
+ repoRoot,
13766
+ sourceTool,
13767
+ sessionId,
13768
+ new Date(recordedAt).toISOString()
13769
+ );
12851
13770
  appendLog(
12852
13771
  "info",
12853
- `[${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`
12854
13773
  );
12855
13774
  }
12856
13775
  return true;
@@ -12904,6 +13823,7 @@ async function runUpload() {
12904
13823
  [FLOW_ID_ENV]: flowId
12905
13824
  };
12906
13825
  if (toolArg) workerEnv[TOOL_ENV_FLAG] = toolArg;
13826
+ await applyDebugLogParentEnv(workerEnv, raw);
12907
13827
  try {
12908
13828
  const child = spawn2(process.execPath, [entrypoint, "upload"], {
12909
13829
  detached: true,
@@ -12975,6 +13895,7 @@ async function runUploadWorker() {
12975
13895
  }
12976
13896
  try {
12977
13897
  await sweepStaleUploadStates();
13898
+ await sweepStaleDebugLogState();
12978
13899
  } catch {
12979
13900
  }
12980
13901
  }
@@ -12982,26 +13903,58 @@ async function runUploadWorker() {
12982
13903
 
12983
13904
  // src/git-traces/index.ts
12984
13905
  import { spawn as spawn3 } from "child_process";
12985
- import crypto6 from "crypto";
13906
+ import crypto7 from "crypto";
12986
13907
 
12987
13908
  // src/git-traces/handlers.ts
12988
13909
  import { execFileSync as execFileSync3 } from "child_process";
12989
- import path17 from "path";
13910
+ import path18 from "path";
12990
13911
 
12991
13912
  // src/git-traces/git-ops.ts
12992
13913
  import { execFileSync as execFileSync2, spawnSync } from "child_process";
12993
- import fs14 from "fs";
12994
- import os7 from "os";
12995
- import path15 from "path";
13914
+ import fs16 from "fs";
13915
+ import os8 from "os";
13916
+ import path16 from "path";
12996
13917
  import { gzipSync as gzipSync2 } from "zlib";
12997
13918
  var GIT_COMMAND_TIMEOUT_MS = 12e4;
12998
13919
  var EXEC_OPTS = {
12999
13920
  timeout: GIT_COMMAND_TIMEOUT_MS,
13000
- maxBuffer: 50 * 1024 * 1024
13921
+ // A rewrite diff for a text file capped at ≤100 MB must fit. This transient
13922
+ // allocation only grows as large as the actual command output.
13923
+ maxBuffer: 256 * 1024 * 1024
13001
13924
  };
13002
13925
  var MAX_ERROR_OUTPUT_CHARS = 2e3;
13003
- var MAX_SNAPSHOT_FILE_BYTES = 10 * 1024 * 1024;
13004
- var MAX_BINARY_SNAPSHOT_FILE_BYTES = 1024 * 1024;
13926
+ var DEFAULT_SNAPSHOT_LIMITS = {
13927
+ maxTrackedFileBytes: 25 * 1024 * 1024,
13928
+ // 25 MB
13929
+ maxUntrackedFileBytes: 10 * 1024 * 1024,
13930
+ // 10 MB
13931
+ maxBinaryFileBytes: 1024 * 1024
13932
+ // 1 MB
13933
+ };
13934
+ var LEGACY_SNAPSHOT_LIMITS = {
13935
+ maxTrackedFileBytes: 10 * 1024 * 1024,
13936
+ maxUntrackedFileBytes: 10 * 1024 * 1024,
13937
+ maxBinaryFileBytes: 1024 * 1024
13938
+ };
13939
+ function resolveSnapshotLimits(overrides) {
13940
+ return {
13941
+ maxTrackedFileBytes: positiveIntOrDefault(
13942
+ overrides?.maxTrackedFileBytes,
13943
+ DEFAULT_SNAPSHOT_LIMITS.maxTrackedFileBytes
13944
+ ),
13945
+ maxUntrackedFileBytes: positiveIntOrDefault(
13946
+ overrides?.maxUntrackedFileBytes,
13947
+ DEFAULT_SNAPSHOT_LIMITS.maxUntrackedFileBytes
13948
+ ),
13949
+ maxBinaryFileBytes: positiveIntOrDefault(
13950
+ overrides?.maxBinaryFileBytes,
13951
+ DEFAULT_SNAPSHOT_LIMITS.maxBinaryFileBytes
13952
+ )
13953
+ };
13954
+ }
13955
+ function positiveIntOrDefault(value, fallback) {
13956
+ return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= MAX_SNAPSHOT_LIMIT_OVERRIDE_BYTES ? value : fallback;
13957
+ }
13005
13958
  var BINARY_SNIFF_BYTES = 8e3;
13006
13959
  var EXCLUDED_SNAPSHOT_EXTENSIONS = /* @__PURE__ */ new Set([
13007
13960
  // Documents
@@ -13098,8 +14051,8 @@ var EXCLUDED_SNAPSHOT_BASENAMES = /* @__PURE__ */ new Set([
13098
14051
  ]);
13099
14052
  var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
13100
14053
  function isExcludedSnapshotPath(filePath) {
13101
- if (EXCLUDED_SNAPSHOT_BASENAMES.has(path15.basename(filePath))) return true;
13102
- return EXCLUDED_SNAPSHOT_EXTENSIONS.has(path15.extname(filePath).toLowerCase());
14054
+ if (EXCLUDED_SNAPSHOT_BASENAMES.has(path16.basename(filePath))) return true;
14055
+ return EXCLUDED_SNAPSHOT_EXTENSIONS.has(path16.extname(filePath).toLowerCase());
13103
14056
  }
13104
14057
  function isBinaryBuffer(buffer) {
13105
14058
  return buffer.includes(0);
@@ -13119,25 +14072,26 @@ function readTreeBlobHead(repoRoot, sha) {
13119
14072
  function readWorkingFileHead(absPath) {
13120
14073
  let fd = null;
13121
14074
  try {
13122
- fd = fs14.openSync(absPath, "r");
14075
+ fd = fs16.openSync(absPath, "r");
13123
14076
  const buffer = Buffer.alloc(BINARY_SNIFF_BYTES);
13124
- const bytesRead = fs14.readSync(fd, buffer, 0, BINARY_SNIFF_BYTES, 0);
14077
+ const bytesRead = fs16.readSync(fd, buffer, 0, BINARY_SNIFF_BYTES, 0);
13125
14078
  return buffer.subarray(0, bytesRead);
13126
14079
  } catch {
13127
14080
  return null;
13128
14081
  } finally {
13129
14082
  if (fd !== null) {
13130
14083
  try {
13131
- fs14.closeSync(fd);
14084
+ fs16.closeSync(fd);
13132
14085
  } catch {
13133
14086
  }
13134
14087
  }
13135
14088
  }
13136
14089
  }
13137
- function classifyOmission(filePath, sizeBytes, readHead) {
14090
+ function classifyOmission(filePath, sizeBytes, readHead, limits, tracked) {
14091
+ const maxFileBytes = tracked ? limits.maxTrackedFileBytes : limits.maxUntrackedFileBytes;
13138
14092
  if (isExcludedSnapshotPath(filePath)) return "excluded-extension";
13139
- if (sizeBytes > MAX_SNAPSHOT_FILE_BYTES) return "file-over-limit";
13140
- if (sizeBytes > MAX_BINARY_SNAPSHOT_FILE_BYTES) {
14093
+ if (sizeBytes > maxFileBytes) return "file-over-limit";
14094
+ if (sizeBytes > limits.maxBinaryFileBytes) {
13141
14095
  const head = readHead();
13142
14096
  if (!head) {
13143
14097
  appendLog(
@@ -13249,12 +14203,32 @@ function deleteRef(repoRoot, refName) {
13249
14203
  function captureSnapshotSha(repoRoot) {
13250
14204
  return captureWorkingCommitSha(repoRoot);
13251
14205
  }
13252
- function recordOmittedSnapshotFile(omittedFiles, file, options = {}) {
13253
- omittedFiles?.push(file);
13254
- if (options.log === false) return;
13255
- const action = file.tracked ? "omitting tracked" : "skipping untracked";
13256
- const reason = file.reason === "file-over-limit" ? `${file.sizeBytes} bytes > ${MAX_SNAPSHOT_FILE_BYTES} limit` : file.reason === "binary-over-limit" ? `binary ${file.sizeBytes} bytes > ${MAX_BINARY_SNAPSHOT_FILE_BYTES} binary limit` : "excluded extension";
13257
- appendLog("warn", `git-traces: ${action} ${file.path} (${reason})`);
14206
+ function formatSize(bytes) {
14207
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
14208
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
14209
+ }
14210
+ function summarizeOmittedFiles(files) {
14211
+ const totalBytes = files.reduce((sum, file) => sum + file.sizeBytes, 0);
14212
+ const top = [...files].sort((a, b) => b.sizeBytes - a.sizeBytes).slice(0, 3).map((file) => `${file.path} (${formatSize(file.sizeBytes)})`).join(", ");
14213
+ const more = files.length > 3 ? ` +${files.length - 3} more` : "";
14214
+ const plural = files.length === 1 ? "" : "s";
14215
+ return `${files.length} file${plural} (${formatSize(totalBytes)}): ${top}${more}`;
14216
+ }
14217
+ function logOmittedSnapshotSummary(omittedFiles) {
14218
+ const tracked = omittedFiles.filter((file) => file.tracked);
14219
+ const untracked = omittedFiles.filter((file) => !file.tracked);
14220
+ if (tracked.length > 0) {
14221
+ appendLog(
14222
+ "warn",
14223
+ `git-traces: snapshot omitted tracked ${summarizeOmittedFiles(tracked)} \u2014 content excluded from the baseline bundle and all diffs; full list in baseline.json git.omittedFiles`
14224
+ );
14225
+ }
14226
+ if (untracked.length > 0) {
14227
+ appendLog(
14228
+ "info",
14229
+ `git-traces: snapshot skipped untracked ${summarizeOmittedFiles(untracked)}; full list in baseline.json git.omittedFiles`
14230
+ );
14231
+ }
13258
14232
  }
13259
14233
  function parseLsTreeLongZ(output) {
13260
14234
  const entries = [];
@@ -13272,26 +14246,24 @@ function parseLsTreeLongZ(output) {
13272
14246
  }
13273
14247
  return entries;
13274
14248
  }
13275
- function listOmittedTreeFiles(repoRoot, treeSha, options = {}) {
14249
+ function listOmittedTreeFiles(repoRoot, treeSha, limits) {
13276
14250
  const output = gitBuffer(repoRoot, ["ls-tree", "-r", "-l", "-z", treeSha]);
13277
14251
  const omitted = [];
13278
14252
  for (const entry of parseLsTreeLongZ(output)) {
13279
14253
  const reason = classifyOmission(
13280
14254
  entry.path,
13281
14255
  entry.sizeBytes,
13282
- () => readTreeBlobHead(repoRoot, entry.sha)
14256
+ () => readTreeBlobHead(repoRoot, entry.sha),
14257
+ limits,
14258
+ true
13283
14259
  );
13284
14260
  if (!reason) continue;
13285
- const file = {
14261
+ omitted.push({
13286
14262
  path: entry.path,
13287
14263
  sizeBytes: entry.sizeBytes,
13288
14264
  tracked: true,
13289
14265
  reason,
13290
14266
  gitObjectSha: entry.sha
13291
- };
13292
- omitted.push(file);
13293
- recordOmittedSnapshotFile(options.omittedFiles, file, {
13294
- log: options.log
13295
14267
  });
13296
14268
  }
13297
14269
  return omitted;
@@ -13304,10 +14276,15 @@ function removePathsFromIndex(repoRoot, env, paths) {
13304
14276
  });
13305
14277
  }
13306
14278
  function filterOmittedFilesFromTree(repoRoot, treeSha, options = {}) {
13307
- const omittedFiles = listOmittedTreeFiles(repoRoot, treeSha, options);
14279
+ const omittedFiles = listOmittedTreeFiles(
14280
+ repoRoot,
14281
+ treeSha,
14282
+ options.limits ?? DEFAULT_SNAPSHOT_LIMITS
14283
+ );
14284
+ options.omittedFiles?.push(...omittedFiles);
13308
14285
  if (omittedFiles.length === 0) return treeSha;
13309
- const tmpIndex = path15.join(
13310
- os7.tmpdir(),
14286
+ const tmpIndex = path16.join(
14287
+ os8.tmpdir(),
13311
14288
  `hillclimb-filter-${Date.now()}-${process.pid}`
13312
14289
  );
13313
14290
  const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
@@ -13321,12 +14298,13 @@ function filterOmittedFilesFromTree(repoRoot, treeSha, options = {}) {
13321
14298
  return gitWithEnv(repoRoot, ["write-tree"], env);
13322
14299
  } finally {
13323
14300
  try {
13324
- fs14.unlinkSync(tmpIndex);
14301
+ fs16.unlinkSync(tmpIndex);
13325
14302
  } catch {
13326
14303
  }
13327
14304
  }
13328
14305
  }
13329
- function buildUntrackedTree(repoRoot, omittedFiles) {
14306
+ function buildUntrackedTree(repoRoot, options = {}) {
14307
+ const limits = options.limits ?? DEFAULT_SNAPSHOT_LIMITS;
13330
14308
  const list = git(repoRoot, [
13331
14309
  "ls-files",
13332
14310
  "--others",
@@ -13338,15 +14316,17 @@ function buildUntrackedTree(repoRoot, omittedFiles) {
13338
14316
  for (const relPath of list.split("\0")) {
13339
14317
  if (!relPath) continue;
13340
14318
  try {
13341
- const absPath = path15.join(repoRoot, relPath);
13342
- const stat = fs14.lstatSync(absPath);
14319
+ const absPath = path16.join(repoRoot, relPath);
14320
+ const stat = fs16.lstatSync(absPath);
13343
14321
  const reason = classifyOmission(
13344
14322
  relPath,
13345
14323
  stat.size,
13346
- () => readWorkingFileHead(absPath)
14324
+ () => readWorkingFileHead(absPath),
14325
+ limits,
14326
+ false
13347
14327
  );
13348
14328
  if (reason) {
13349
- recordOmittedSnapshotFile(omittedFiles, {
14329
+ options.omittedFiles?.push({
13350
14330
  path: relPath,
13351
14331
  sizeBytes: stat.size,
13352
14332
  tracked: false,
@@ -13359,8 +14339,8 @@ function buildUntrackedTree(repoRoot, omittedFiles) {
13359
14339
  }
13360
14340
  }
13361
14341
  if (kept.length === 0) return null;
13362
- const tmpIndex = path15.join(
13363
- os7.tmpdir(),
14342
+ const tmpIndex = path16.join(
14343
+ os8.tmpdir(),
13364
14344
  `hillclimb-untracked-${Date.now()}-${process.pid}`
13365
14345
  );
13366
14346
  const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
@@ -13372,25 +14352,26 @@ function buildUntrackedTree(repoRoot, omittedFiles) {
13372
14352
  return gitWithEnv(repoRoot, ["write-tree"], env);
13373
14353
  } finally {
13374
14354
  try {
13375
- fs14.unlinkSync(tmpIndex);
14355
+ fs16.unlinkSync(tmpIndex);
13376
14356
  } catch {
13377
14357
  }
13378
14358
  }
13379
14359
  }
13380
14360
  function buildSnapshotTree(repoRoot, stashSha, options = {}) {
14361
+ const omittedFiles = options.omittedFiles ?? [];
14362
+ const limits = options.limits ?? DEFAULT_SNAPSHOT_LIMITS;
13381
14363
  const trackedTree = git(repoRoot, ["rev-parse", `${stashSha}^{tree}`]);
13382
14364
  const filteredTrackedTree = filterOmittedFilesFromTree(
13383
14365
  repoRoot,
13384
14366
  trackedTree,
13385
- {
13386
- omittedFiles: options.omittedFiles
13387
- }
14367
+ { omittedFiles, limits }
13388
14368
  );
13389
- const untrackedTree = buildUntrackedTree(repoRoot, options.omittedFiles);
14369
+ const untrackedTree = buildUntrackedTree(repoRoot, { omittedFiles, limits });
14370
+ if (options.log !== false) logOmittedSnapshotSummary(omittedFiles);
13390
14371
  if (!untrackedTree || untrackedTree === EMPTY_TREE_SHA)
13391
14372
  return filteredTrackedTree;
13392
- const tmpIndex = path15.join(
13393
- os7.tmpdir(),
14373
+ const tmpIndex = path16.join(
14374
+ os8.tmpdir(),
13394
14375
  `hillclimb-index-${Date.now()}-${process.pid}`
13395
14376
  );
13396
14377
  const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
@@ -13417,7 +14398,7 @@ function buildSnapshotTree(repoRoot, stashSha, options = {}) {
13417
14398
  return gitWithEnv(repoRoot, ["write-tree"], env);
13418
14399
  } finally {
13419
14400
  try {
13420
- fs14.unlinkSync(tmpIndex);
14401
+ fs16.unlinkSync(tmpIndex);
13421
14402
  } catch {
13422
14403
  }
13423
14404
  }
@@ -13431,8 +14412,8 @@ function createBundleFromTree(repoRoot, treeSha, sessionId, label) {
13431
14412
  ]);
13432
14413
  const orphanRef = `refs/hillclimb/bundle/${sessionId}`;
13433
14414
  pinRef(repoRoot, orphanRef, orphanCommit);
13434
- const tmpFile = path15.join(
13435
- os7.tmpdir(),
14415
+ const tmpFile = path16.join(
14416
+ os8.tmpdir(),
13436
14417
  // Include the pid (like the other temp files in this module) so concurrent
13437
14418
  // git-traces workers — e.g. two sessions, or a parent + subagent — don't
13438
14419
  // collide on the same `git bundle create` path and its `.lock`.
@@ -13440,26 +14421,24 @@ function createBundleFromTree(repoRoot, treeSha, sessionId, label) {
13440
14421
  );
13441
14422
  try {
13442
14423
  git(repoRoot, ["bundle", "create", tmpFile, orphanRef]);
13443
- return fs14.readFileSync(tmpFile);
14424
+ return fs16.readFileSync(tmpFile);
13444
14425
  } finally {
13445
14426
  try {
13446
- fs14.unlinkSync(tmpFile);
14427
+ fs16.unlinkSync(tmpFile);
13447
14428
  } catch {
13448
14429
  }
13449
14430
  deleteRef(repoRoot, orphanRef);
13450
14431
  }
13451
14432
  }
13452
- function createTreeDiffPatchGz(repoRoot, fromTreeSha, toTreeSha) {
14433
+ function createTreeDiffPatchGz(repoRoot, fromTreeSha, toTreeSha, options = {}) {
13453
14434
  if (fromTreeSha === toTreeSha) return null;
13454
14435
  const filteredFromTreeSha = filterOmittedFilesFromTree(
13455
14436
  repoRoot,
13456
14437
  fromTreeSha,
13457
- {
13458
- log: false
13459
- }
14438
+ { limits: options.limits }
13460
14439
  );
13461
14440
  const filteredToTreeSha = filterOmittedFilesFromTree(repoRoot, toTreeSha, {
13462
- log: false
14441
+ limits: options.limits
13463
14442
  });
13464
14443
  if (filteredFromTreeSha === filteredToTreeSha) return null;
13465
14444
  const diff = gitBuffer(repoRoot, [
@@ -13513,7 +14492,7 @@ function parseDirtyFilesFromStatus(status) {
13513
14492
  return pathPart;
13514
14493
  }).filter(Boolean);
13515
14494
  }
13516
- function buildBaselineMetadata(repoRoot, sessionId, tool, baselineSha, cliVersion, epoch, prevHeadSha, transitionKind, startedAt, omittedFiles = []) {
14495
+ function buildBaselineMetadata(repoRoot, sessionId, tool, baselineSha, cliVersion, epoch, prevHeadSha, transitionKind, startedAt, omittedFiles = [], lineage) {
13517
14496
  const headSha = safeGit(repoRoot, ["rev-parse", "HEAD"]) ?? "unknown";
13518
14497
  const branch = safeGit(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]) ?? null;
13519
14498
  const remoteUrl = safeGit(repoRoot, ["config", "--get", "remote.origin.url"]) ?? null;
@@ -13541,9 +14520,10 @@ function buildBaselineMetadata(repoRoot, sessionId, tool, baselineSha, cliVersio
13541
14520
  ...omittedFiles.length > 0 ? { omittedFiles } : {}
13542
14521
  },
13543
14522
  author: { name: authorName, email: authorEmail },
13544
- hostname: os7.hostname(),
14523
+ hostname: os8.hostname(),
13545
14524
  cliVersion,
13546
- commits
14525
+ commits,
14526
+ ...lineage
13547
14527
  };
13548
14528
  }
13549
14529
  function enumerateCommits(repoRoot, fromSha, toSha) {
@@ -13630,9 +14610,9 @@ function parseCommitFiles(repoRoot, sha) {
13630
14610
  oldPath
13631
14611
  });
13632
14612
  } else {
13633
- const path24 = parts[parts.length - 1];
13634
- indexByPath.set(path24, files.length);
13635
- files.push({ path: path24, status, additions: 0, deletions: 0 });
14613
+ const path25 = parts[parts.length - 1];
14614
+ indexByPath.set(path25, files.length);
14615
+ files.push({ path: path25, status, additions: 0, deletions: 0 });
13636
14616
  }
13637
14617
  }
13638
14618
  for (const line of numstat.split("\n")) {
@@ -13680,7 +14660,8 @@ function cleanupSessionRefs(repoRoot, sessionId) {
13680
14660
  for (const prefix of [
13681
14661
  `refs/hillclimb/baseline/${sessionId}`,
13682
14662
  `refs/hillclimb/turns/${sessionId}`,
13683
- `refs/hillclimb/bundle/${sessionId}`
14663
+ `refs/hillclimb/bundle/${sessionId}`,
14664
+ `refs/hillclimb/scoped/${sessionId}`
13684
14665
  ]) {
13685
14666
  deleteRef(repoRoot, prefix);
13686
14667
  try {
@@ -13696,32 +14677,51 @@ function cleanupSessionRefs(repoRoot, sessionId) {
13696
14677
  }
13697
14678
  }
13698
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
+ }
13699
14692
 
13700
14693
  // src/git-traces/session-state.ts
13701
- import crypto5 from "crypto";
13702
- import fs15 from "fs";
13703
- import os8 from "os";
13704
- import path16 from "path";
14694
+ import crypto6 from "crypto";
14695
+ import fs17 from "fs";
14696
+ import os9 from "os";
14697
+ import path17 from "path";
13705
14698
  var CURRENT_SCHEMA_VERSION3 = 3;
13706
- var DEFAULT_STATE_DIR2 = path16.join(os8.homedir(), ".hillclimb", "git-traces");
14699
+ var DEFAULT_STATE_DIR2 = path17.join(os9.homedir(), ".hillclimb", "git-traces");
13707
14700
  var LOCK_RETRIES2 = 120;
13708
14701
  var LOCK_RETRY_DELAY_MS2 = 500;
13709
- var STALE_LOCK_TTL_MS2 = 60 * 60 * 1e3;
14702
+ var STALE_LOCK_TTL_MS3 = 60 * 60 * 1e3;
14703
+ var LockContentionError = class extends Error {
14704
+ constructor(lockPath, options) {
14705
+ super(`Lock remains held: ${lockPath}`, options);
14706
+ this.lockPath = lockPath;
14707
+ this.name = "LockContentionError";
14708
+ }
14709
+ };
13710
14710
  function stateDir3() {
13711
14711
  return process.env.HILLCLIMB_GIT_TRACES_STATE_DIR ?? DEFAULT_STATE_DIR2;
13712
14712
  }
13713
14713
  function stateFileForRepo(repoRoot, tool, sessionId) {
13714
- const hash = crypto5.createHash("sha256").update(
13715
- sessionId ? `${path16.resolve(repoRoot)}\0${tool}\0${sessionId}` : `${path16.resolve(repoRoot)}\0${tool}`
14714
+ const hash = crypto6.createHash("sha256").update(
14715
+ sessionId ? `${path17.resolve(repoRoot)}\0${tool}\0${sessionId}` : `${path17.resolve(repoRoot)}\0${tool}`
13716
14716
  ).digest("hex").slice(0, 16);
13717
- return path16.join(stateDir3(), `${hash}.json`);
14717
+ return path17.join(stateDir3(), `${hash}.json`);
13718
14718
  }
13719
14719
  function lockFileForRepo(repoRoot, tool, sessionId) {
13720
14720
  return `${stateFileForRepo(repoRoot, tool, sessionId)}.lock`;
13721
14721
  }
13722
14722
  async function readStateFile(file) {
13723
14723
  try {
13724
- const raw = await fs15.promises.readFile(file, "utf-8");
14724
+ const raw = await fs17.promises.readFile(file, "utf-8");
13725
14725
  const parsed = JSON.parse(raw);
13726
14726
  if (parsed.schemaVersion !== CURRENT_SCHEMA_VERSION3) {
13727
14727
  return null;
@@ -13731,29 +14731,38 @@ async function readStateFile(file) {
13731
14731
  return null;
13732
14732
  }
13733
14733
  }
14734
+ async function readStoredStateFile(file) {
14735
+ const state = await readStateFile(file);
14736
+ if (!state) return null;
14737
+ try {
14738
+ return { state, mtimeMs: (await fs17.promises.stat(file)).mtimeMs };
14739
+ } catch {
14740
+ return null;
14741
+ }
14742
+ }
13734
14743
  async function listScopedSessionStates(repoRoot, tool) {
13735
14744
  let entries;
13736
14745
  try {
13737
- entries = await fs15.promises.readdir(stateDir3(), { withFileTypes: true });
14746
+ entries = await fs17.promises.readdir(stateDir3(), { withFileTypes: true });
13738
14747
  } catch {
13739
14748
  return [];
13740
14749
  }
13741
14750
  const states = [];
13742
14751
  for (const entry of entries) {
13743
14752
  if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
13744
- const file = path16.join(stateDir3(), entry.name);
14753
+ const file = path17.join(stateDir3(), entry.name);
13745
14754
  const state = await readStateFile(file);
13746
14755
  if (!state) continue;
13747
14756
  if (typeof state.repoRoot !== "string" || typeof state.sessionId !== "string") {
13748
14757
  continue;
13749
14758
  }
13750
- if (path16.resolve(state.repoRoot) !== path16.resolve(repoRoot)) continue;
13751
- if (path16.resolve(file) !== path16.resolve(stateFileForRepo(repoRoot, tool, state.sessionId))) {
14759
+ if (path17.resolve(state.repoRoot) !== path17.resolve(repoRoot)) continue;
14760
+ if (path17.resolve(file) !== path17.resolve(stateFileForRepo(repoRoot, tool, state.sessionId))) {
13752
14761
  continue;
13753
14762
  }
13754
14763
  let mtimeMs = 0;
13755
14764
  try {
13756
- mtimeMs = (await fs15.promises.stat(file)).mtimeMs;
14765
+ mtimeMs = (await fs17.promises.stat(file)).mtimeMs;
13757
14766
  } catch {
13758
14767
  continue;
13759
14768
  }
@@ -13764,26 +14773,26 @@ async function listScopedSessionStates(repoRoot, tool) {
13764
14773
  async function listSessionStatesForSession(tool, sessionId) {
13765
14774
  let entries;
13766
14775
  try {
13767
- entries = await fs15.promises.readdir(stateDir3(), { withFileTypes: true });
14776
+ entries = await fs17.promises.readdir(stateDir3(), { withFileTypes: true });
13768
14777
  } catch {
13769
14778
  return [];
13770
14779
  }
13771
14780
  const states = [];
13772
14781
  for (const entry of entries) {
13773
14782
  if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
13774
- const file = path16.join(stateDir3(), entry.name);
14783
+ const file = path17.join(stateDir3(), entry.name);
13775
14784
  const state = await readStateFile(file);
13776
14785
  if (!state) continue;
13777
14786
  if (typeof state.repoRoot !== "string" || typeof state.sessionId !== "string") {
13778
14787
  continue;
13779
14788
  }
13780
14789
  if (state.sessionId !== sessionId) continue;
13781
- if (path16.resolve(file) !== path16.resolve(stateFileForRepo(state.repoRoot, tool, sessionId))) {
14790
+ if (path17.resolve(file) !== path17.resolve(stateFileForRepo(state.repoRoot, tool, sessionId))) {
13782
14791
  continue;
13783
14792
  }
13784
14793
  let mtimeMs = 0;
13785
14794
  try {
13786
- mtimeMs = (await fs15.promises.stat(file)).mtimeMs;
14795
+ mtimeMs = (await fs17.promises.stat(file)).mtimeMs;
13787
14796
  } catch {
13788
14797
  continue;
13789
14798
  }
@@ -13791,7 +14800,7 @@ async function listSessionStatesForSession(tool, sessionId) {
13791
14800
  }
13792
14801
  return states;
13793
14802
  }
13794
- async function readSessionState(repoRoot, tool, sessionId) {
14803
+ async function readSessionState2(repoRoot, tool, sessionId) {
13795
14804
  if (sessionId) {
13796
14805
  const scoped = await readStateFile(
13797
14806
  stateFileForRepo(repoRoot, tool, sessionId)
@@ -13802,108 +14811,141 @@ async function readSessionState(repoRoot, tool, sessionId) {
13802
14811
  }
13803
14812
  return readStateFile(stateFileForRepo(repoRoot, tool));
13804
14813
  }
13805
- async function writeSessionState(state, tool) {
14814
+ async function readLegacySessionState(repoRoot, tool, sessionId) {
14815
+ const legacy = await readStateFile(stateFileForRepo(repoRoot, tool));
14816
+ return legacy?.sessionId === sessionId ? legacy : null;
14817
+ }
14818
+ async function readSessionStateCandidates(repoRoot, tool, sessionId) {
14819
+ const [scoped, legacyCandidate] = await Promise.all([
14820
+ readStoredStateFile(stateFileForRepo(repoRoot, tool, sessionId)),
14821
+ readStoredStateFile(stateFileForRepo(repoRoot, tool))
14822
+ ]);
14823
+ return {
14824
+ scoped,
14825
+ legacy: legacyCandidate?.state.sessionId === sessionId ? legacyCandidate : null
14826
+ };
14827
+ }
14828
+ async function writeSessionState2(state, tool) {
13806
14829
  const file = stateFileForRepo(state.repoRoot, tool, state.sessionId);
13807
- await fs15.promises.mkdir(stateDir3(), { recursive: true, mode: 448 });
14830
+ await writeStateFile(file, state);
14831
+ }
14832
+ async function writeLegacySessionState(state, tool) {
14833
+ await writeStateFile(stateFileForRepo(state.repoRoot, tool), state);
14834
+ }
14835
+ async function writeStateFile(file, state) {
14836
+ await fs17.promises.mkdir(stateDir3(), { recursive: true, mode: 448 });
13808
14837
  const tmp = `${file}.tmp`;
13809
- await fs15.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
14838
+ await fs17.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
13810
14839
  mode: 384
13811
14840
  });
13812
- await fs15.promises.rename(tmp, file);
13813
- const legacyFile = stateFileForRepo(state.repoRoot, tool);
13814
- const legacy = await readStateFile(legacyFile);
13815
- if (legacy?.sessionId === state.sessionId) {
13816
- await deleteStateFile(legacyFile);
14841
+ await fs17.promises.rename(tmp, file);
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;
13817
14862
  }
13818
14863
  }
13819
14864
  async function deleteStateFile(file) {
13820
14865
  try {
13821
- await fs15.promises.unlink(file);
14866
+ await fs17.promises.unlink(file);
13822
14867
  } catch {
13823
14868
  }
13824
14869
  }
13825
- async function deleteSessionState(repoRoot, tool, sessionId) {
14870
+ async function deleteSessionState2(repoRoot, tool, sessionId) {
13826
14871
  if (sessionId) {
13827
14872
  await deleteStateFile(stateFileForRepo(repoRoot, tool, sessionId));
13828
- const legacyFile = stateFileForRepo(repoRoot, tool);
13829
- const legacy = await readStateFile(legacyFile);
13830
- if (legacy?.sessionId === sessionId) {
13831
- await deleteStateFile(legacyFile);
13832
- }
13833
14873
  return;
13834
14874
  }
13835
14875
  await deleteStateFile(stateFileForRepo(repoRoot, tool));
13836
14876
  }
14877
+ async function deleteLegacySessionState(repoRoot, tool, sessionId) {
14878
+ const legacyFile = stateFileForRepo(repoRoot, tool);
14879
+ const legacy = await readStateFile(legacyFile);
14880
+ if (legacy?.sessionId === sessionId) await deleteStateFile(legacyFile);
14881
+ }
13837
14882
  async function acquireLock3(repoRoot, tool, sessionId, retries = LOCK_RETRIES2, delayMs = LOCK_RETRY_DELAY_MS2) {
13838
14883
  const lockPath = lockFileForRepo(repoRoot, tool, sessionId);
13839
- await fs15.promises.mkdir(stateDir3(), { recursive: true, mode: 448 });
14884
+ await fs17.promises.mkdir(stateDir3(), { recursive: true, mode: 448 });
13840
14885
  for (let i = 0; i < retries; i++) {
13841
14886
  try {
13842
- const fd = await fs15.promises.open(
14887
+ const fd = await fs17.promises.open(
13843
14888
  lockPath,
13844
- fs15.constants.O_CREAT | fs15.constants.O_EXCL | fs15.constants.O_WRONLY
14889
+ fs17.constants.O_CREAT | fs17.constants.O_EXCL | fs17.constants.O_WRONLY
13845
14890
  );
13846
14891
  await fd.write(String(process.pid));
13847
14892
  await fd.close();
13848
14893
  return fd.fd;
13849
14894
  } catch (err) {
13850
- if (err.code === "EEXIST" && i < retries - 1) {
14895
+ if (err.code !== "EEXIST") throw err;
14896
+ let regularLockFile = false;
14897
+ try {
14898
+ regularLockFile = (await fs17.promises.lstat(lockPath)).isFile();
14899
+ } catch (statErr) {
14900
+ if (statErr.code === "ENOENT") {
14901
+ i--;
14902
+ continue;
14903
+ }
14904
+ }
14905
+ if (!regularLockFile) throw err;
14906
+ if (i < retries - 1) {
14907
+ if (await reapLockIfStale(lockPath, {
14908
+ maxAgeMs: STALE_LOCK_TTL_MS3
14909
+ })) {
14910
+ continue;
14911
+ }
13851
14912
  await new Promise((r) => setTimeout(r, delayMs));
13852
14913
  continue;
13853
14914
  }
13854
- throw err;
14915
+ throw new LockContentionError(lockPath, { cause: err });
13855
14916
  }
13856
14917
  }
13857
14918
  throw new Error(`Failed to acquire lock after ${retries} retries`);
13858
14919
  }
13859
14920
  async function releaseLock3(repoRoot, tool, sessionId) {
13860
14921
  try {
13861
- await fs15.promises.unlink(lockFileForRepo(repoRoot, tool, sessionId));
14922
+ await fs17.promises.unlink(lockFileForRepo(repoRoot, tool, sessionId));
13862
14923
  } catch {
13863
14924
  }
13864
14925
  }
13865
- function isProcessAlive2(pid) {
13866
- try {
13867
- process.kill(pid, 0);
13868
- return true;
13869
- } catch (err) {
13870
- return err.code === "EPERM";
13871
- }
13872
- }
13873
- async function sweepStaleLockFiles(ttlMs = STALE_LOCK_TTL_MS2, now = Date.now()) {
14926
+ async function sweepStaleLockFiles(ttlMs = STALE_LOCK_TTL_MS3, now = Date.now()) {
13874
14927
  let entries;
13875
14928
  try {
13876
- entries = await fs15.promises.readdir(stateDir3(), { withFileTypes: true });
14929
+ entries = await fs17.promises.readdir(stateDir3(), { withFileTypes: true });
13877
14930
  } catch {
13878
14931
  return 0;
13879
14932
  }
13880
14933
  let removed = 0;
13881
14934
  for (const entry of entries) {
13882
14935
  if (!entry.isFile() || !entry.name.endsWith(".lock")) continue;
13883
- const file = path16.join(stateDir3(), entry.name);
13884
- try {
13885
- const raw = await fs15.promises.readFile(file, "utf-8").catch(() => "");
13886
- const pid = Number.parseInt(raw.trim(), 10);
13887
- const havePid = Number.isInteger(pid) && pid > 0;
13888
- let reap;
13889
- if (havePid) {
13890
- reap = !isProcessAlive2(pid);
13891
- } else {
13892
- const st = await fs15.promises.stat(file);
13893
- reap = now - st.mtimeMs > ttlMs;
13894
- }
13895
- if (reap) {
13896
- await fs15.promises.unlink(file);
13897
- removed++;
13898
- }
13899
- } catch {
14936
+ const file = path17.join(stateDir3(), entry.name);
14937
+ if (await reapLockIfStale(file, {
14938
+ maxAgeMs: ttlMs,
14939
+ now
14940
+ })) {
14941
+ removed++;
13900
14942
  }
13901
14943
  }
13902
14944
  return removed;
13903
14945
  }
13904
14946
 
13905
14947
  // src/git-traces/handlers.ts
13906
- var CLI_VERSION2 = "0.7.0";
14948
+ var CLI_VERSION2 = "0.8.0";
13907
14949
  var GIT_TRACES_SLUG = "git-traces";
13908
14950
  var MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
13909
14951
  var STALE_SCOPED_STATE_TTL_MS = 24 * 60 * 60 * 1e3;
@@ -13920,12 +14962,12 @@ var TOOL_LABELS = {
13920
14962
  async function loadConfiguredRepos() {
13921
14963
  const file = await loadProjects();
13922
14964
  return Object.entries(file.projects).map(([repoRoot, config]) => ({
13923
- repoRoot: path17.resolve(repoRoot),
14965
+ repoRoot: path18.resolve(repoRoot),
13924
14966
  config
13925
14967
  })).sort((a, b) => a.repoRoot.localeCompare(b.repoRoot));
13926
14968
  }
13927
14969
  function repoLabel(repoRoot) {
13928
- return path17.basename(repoRoot) || repoRoot;
14970
+ return path18.basename(repoRoot) || repoRoot;
13929
14971
  }
13930
14972
  function resolveCwd2(payload) {
13931
14973
  return resolveHookCwd(payload);
@@ -13933,6 +14975,11 @@ function resolveCwd2(payload) {
13933
14975
  function resolveSessionId2(payload) {
13934
14976
  return resolveHookSessionId(payload);
13935
14977
  }
14978
+ async function detectCodexLineageForHook(payload, tool, sessionId) {
14979
+ if (tool !== "codex" || !sessionId) return { conclusive: false };
14980
+ const transcriptPath = payload.transcript_path ?? await findCodexRolloutPath(sessionId);
14981
+ return transcriptPath ? detectCodexLineage(transcriptPath) : { conclusive: false };
14982
+ }
13936
14983
  function epochPrefix2(epoch) {
13937
14984
  return `epoch-${String(epoch).padStart(3, "0")}`;
13938
14985
  }
@@ -13980,18 +15027,48 @@ async function uploadFile(client, contributionId, filename, mimeType, buffer) {
13980
15027
  );
13981
15028
  return true;
13982
15029
  }
15030
+ function omissionKey(file) {
15031
+ return `${file.tracked ? "tracked" : "untracked"}:${file.path}`;
15032
+ }
15033
+ function omissionKeys(files) {
15034
+ return [...new Set(files.map(omissionKey))].sort();
15035
+ }
15036
+ function omissionKeysFromMetadata(metadata) {
15037
+ const git2 = metadata.git;
15038
+ if (!git2 || typeof git2 !== "object") return [];
15039
+ const omitted = git2.omittedFiles;
15040
+ if (!Array.isArray(omitted)) return [];
15041
+ const keys = omitted.map(
15042
+ (file) => file && typeof file === "object" ? metadataOmissionKey(file) : void 0
15043
+ ).filter((key) => key !== void 0);
15044
+ return [...new Set(keys)].sort();
15045
+ }
15046
+ function metadataOmissionKey(file) {
15047
+ const { path: filePath, tracked } = file;
15048
+ if (typeof filePath !== "string") return void 0;
15049
+ return omissionKey({ path: filePath, tracked: tracked !== false });
15050
+ }
13983
15051
  function canUploadEpochBaselineArtifacts(epoch, artifacts) {
13984
15052
  const prefix = epochPrefix2(epoch);
13985
15053
  return canUploadFile(`${prefix}-baseline.bundle`, artifacts.bundleBuffer) && canUploadFile(`${prefix}-baseline.json`, artifacts.metadataBuffer);
13986
15054
  }
13987
- function pinEpochBaseline(repoRoot, sessionId, epoch) {
15055
+ function pinEpochBaseline(repoRoot, sessionId, epoch, pinCanonicalRefs = true) {
13988
15056
  const baselineSha = captureBaselineSha(repoRoot);
13989
15057
  const baselineRefPrefix = `refs/hillclimb/baseline/${sessionId}/${epochPrefix2(epoch)}`;
13990
- deleteRef(repoRoot, baselineRefPrefix);
13991
- pinRef(repoRoot, `${baselineRefPrefix}/tracked`, baselineSha);
15058
+ const scopedRefPrefix = `refs/hillclimb/scoped/${sessionId}/baseline/${epochPrefix2(epoch)}`;
15059
+ if (pinCanonicalRefs) {
15060
+ deleteRef(repoRoot, baselineRefPrefix);
15061
+ pinRef(repoRoot, `${baselineRefPrefix}/tracked`, baselineSha);
15062
+ }
15063
+ pinRef(repoRoot, `${scopedRefPrefix}/tracked`, baselineSha);
13992
15064
  return { baselineSha, headSha: captureHeadSha(repoRoot) };
13993
15065
  }
13994
- function pinFrozenBaselineTree(repoRoot, sessionId, epoch, baselineTreeSha) {
15066
+ function pinScopedTreeRef(repoRoot, refName, treeSha, message) {
15067
+ const commit = execGit(repoRoot, ["commit-tree", treeSha, "-m", message]);
15068
+ pinRef(repoRoot, refName, commit);
15069
+ return commit;
15070
+ }
15071
+ function pinFrozenBaselineTree(repoRoot, sessionId, epoch, baselineTreeSha, pinCanonicalRefs = true) {
13995
15072
  const prefix = epochPrefix2(epoch);
13996
15073
  const commit = execGit(repoRoot, [
13997
15074
  "commit-tree",
@@ -13999,12 +15076,31 @@ function pinFrozenBaselineTree(repoRoot, sessionId, epoch, baselineTreeSha) {
13999
15076
  "-m",
14000
15077
  `frozen baseline ${prefix} for session ${sessionId}`
14001
15078
  ]);
15079
+ if (pinCanonicalRefs) {
15080
+ pinRef(
15081
+ repoRoot,
15082
+ `refs/hillclimb/baseline/${sessionId}/${prefix}/tree`,
15083
+ commit
15084
+ );
15085
+ }
14002
15086
  pinRef(
14003
15087
  repoRoot,
14004
- `refs/hillclimb/baseline/${sessionId}/${prefix}/tree`,
15088
+ `refs/hillclimb/scoped/${sessionId}/baseline/${prefix}/tree`,
14005
15089
  commit
14006
15090
  );
14007
15091
  }
15092
+ function pinScopedTurnSnapshot(repoRoot, sessionId, epoch, turnCount, snapshotSha, treeSha) {
15093
+ const prefix = epochPrefix2(epoch);
15094
+ const turnLabel = turnSuffix2(turnCount);
15095
+ const refPrefix = `refs/hillclimb/scoped/${sessionId}/turns/${prefix}/${turnLabel}`;
15096
+ pinRef(repoRoot, `${refPrefix}/tracked`, snapshotSha);
15097
+ pinScopedTreeRef(
15098
+ repoRoot,
15099
+ `${refPrefix}/tree`,
15100
+ treeSha,
15101
+ `protected ${turnLabel} ${prefix} for session ${sessionId}`
15102
+ );
15103
+ }
14008
15104
  function freezeEpochBaseline(params) {
14009
15105
  const {
14010
15106
  repoRoot,
@@ -14013,18 +15109,23 @@ function freezeEpochBaseline(params) {
14013
15109
  epoch,
14014
15110
  prevHeadSha,
14015
15111
  transitionKind,
14016
- startedAt
15112
+ startedAt,
15113
+ limits,
15114
+ lineage,
15115
+ pinCanonicalRefs = true
14017
15116
  } = params;
14018
15117
  const prefix = epochPrefix2(epoch);
14019
15118
  try {
14020
15119
  const { baselineSha, headSha } = pinEpochBaseline(
14021
15120
  repoRoot,
14022
15121
  sessionId,
14023
- epoch
15122
+ epoch,
15123
+ pinCanonicalRefs
14024
15124
  );
14025
15125
  const omittedFiles = [];
14026
15126
  const baselineTreeSha = buildSnapshotTree(repoRoot, baselineSha, {
14027
- omittedFiles
15127
+ omittedFiles,
15128
+ limits
14028
15129
  });
14029
15130
  const baselineMetadata = buildBaselineMetadata(
14030
15131
  repoRoot,
@@ -14036,10 +15137,23 @@ function freezeEpochBaseline(params) {
14036
15137
  prevHeadSha,
14037
15138
  transitionKind,
14038
15139
  startedAt,
14039
- omittedFiles
15140
+ omittedFiles,
15141
+ lineage
14040
15142
  );
14041
- pinFrozenBaselineTree(repoRoot, sessionId, epoch, baselineTreeSha);
14042
- return { baselineSha, baselineTreeSha, baselineMetadata, headSha };
15143
+ pinFrozenBaselineTree(
15144
+ repoRoot,
15145
+ sessionId,
15146
+ epoch,
15147
+ baselineTreeSha,
15148
+ pinCanonicalRefs
15149
+ );
15150
+ return {
15151
+ baselineSha,
15152
+ baselineTreeSha,
15153
+ baselineMetadata,
15154
+ headSha,
15155
+ omittedFiles
15156
+ };
14043
15157
  } catch (err) {
14044
15158
  appendLog(
14045
15159
  "error",
@@ -14061,7 +15175,12 @@ function buildFrozenEpochBaselineArtifacts(params) {
14061
15175
  const metadataBuffer = Buffer.from(
14062
15176
  JSON.stringify(baselineMetadata, null, 2)
14063
15177
  );
14064
- return { baselineTreeSha, metadataBuffer, bundleBuffer };
15178
+ return {
15179
+ baselineTreeSha,
15180
+ metadataBuffer,
15181
+ bundleBuffer,
15182
+ omittedKeys: omissionKeysFromMetadata(baselineMetadata)
15183
+ };
14065
15184
  } catch (err) {
14066
15185
  appendLog(
14067
15186
  "error",
@@ -14079,13 +15198,16 @@ function buildEpochBaselineArtifacts(params) {
14079
15198
  baselineSha,
14080
15199
  prevHeadSha,
14081
15200
  transitionKind,
14082
- startedAt
15201
+ startedAt,
15202
+ limits,
15203
+ lineage
14083
15204
  } = params;
14084
15205
  const prefix = epochPrefix2(epoch);
14085
15206
  try {
14086
15207
  const omittedFiles = [];
14087
15208
  const baselineTreeSha = buildSnapshotTree(repoRoot, baselineSha, {
14088
- omittedFiles
15209
+ omittedFiles,
15210
+ limits
14089
15211
  });
14090
15212
  const metadata = buildBaselineMetadata(
14091
15213
  repoRoot,
@@ -14097,7 +15219,8 @@ function buildEpochBaselineArtifacts(params) {
14097
15219
  prevHeadSha,
14098
15220
  transitionKind,
14099
15221
  startedAt,
14100
- omittedFiles
15222
+ omittedFiles,
15223
+ lineage
14101
15224
  );
14102
15225
  return buildFrozenEpochBaselineArtifacts({
14103
15226
  repoRoot,
@@ -14135,7 +15258,16 @@ async function uploadEpochBaselineArtifacts(params) {
14135
15258
  );
14136
15259
  }
14137
15260
  async function createGitTracesContribution(params) {
14138
- const { client, config, repoRoot, state, tool, now, artifacts } = params;
15261
+ const {
15262
+ client,
15263
+ config,
15264
+ repoRoot,
15265
+ state,
15266
+ tool,
15267
+ persistState,
15268
+ now,
15269
+ artifacts
15270
+ } = params;
14139
15271
  const toolLabel2 = TOOL_LABELS[tool] ?? "Claude";
14140
15272
  const epochSeconds = formatEpochSeconds3(now);
14141
15273
  const shortId = state.sessionId.slice(0, 12);
@@ -14153,7 +15285,7 @@ Uploaded: ${now.toISOString()}`
14153
15285
  contributionId = contribution.id;
14154
15286
  state.contributionId = contributionId;
14155
15287
  state.baselineUploaded = false;
14156
- await writeSessionState(state, tool);
15288
+ await persistState(state);
14157
15289
  }
14158
15290
  const uploaded = await uploadEpochBaselineArtifacts({
14159
15291
  client,
@@ -14172,6 +15304,12 @@ Uploaded: ${now.toISOString()}`
14172
15304
  async function uploadEpochBaseline(params) {
14173
15305
  const artifacts = buildEpochBaselineArtifacts(params);
14174
15306
  if (!artifacts) return null;
15307
+ pinScopedTreeRef(
15308
+ params.repoRoot,
15309
+ `refs/hillclimb/scoped/${params.sessionId}/baseline/${epochPrefix2(params.epoch)}/tree`,
15310
+ artifacts.baselineTreeSha,
15311
+ `protected baseline ${epochPrefix2(params.epoch)} for session ${params.sessionId}`
15312
+ );
14175
15313
  if (!canUploadEpochBaselineArtifacts(params.epoch, artifacts)) return null;
14176
15314
  const uploaded = await uploadEpochBaselineArtifacts({
14177
15315
  client: params.client,
@@ -14180,7 +15318,10 @@ async function uploadEpochBaseline(params) {
14180
15318
  artifacts
14181
15319
  });
14182
15320
  if (!uploaded) return null;
14183
- return { baselineTreeSha: artifacts.baselineTreeSha };
15321
+ return {
15322
+ baselineTreeSha: artifacts.baselineTreeSha,
15323
+ omittedKeys: artifacts.omittedKeys
15324
+ };
14184
15325
  }
14185
15326
  async function openEpoch(params) {
14186
15327
  const { baselineSha, headSha } = pinEpochBaseline(
@@ -14190,10 +15331,28 @@ async function openEpoch(params) {
14190
15331
  );
14191
15332
  const uploaded = await uploadEpochBaseline({ ...params, baselineSha });
14192
15333
  if (!uploaded) return null;
14193
- return { baselineSha, baselineTreeSha: uploaded.baselineTreeSha, headSha };
15334
+ return {
15335
+ baselineSha,
15336
+ baselineTreeSha: uploaded.baselineTreeSha,
15337
+ headSha,
15338
+ omittedKeys: uploaded.omittedKeys
15339
+ };
14194
15340
  }
14195
- async function initializeSession(repoRoot, tool, sessionId) {
15341
+ function isPositiveInteger(value) {
15342
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
15343
+ }
15344
+ function sessionSnapshotLimits(state) {
15345
+ const limits = state.snapshotLimits;
15346
+ if (!limits || typeof limits !== "object") return LEGACY_SNAPSHOT_LIMITS;
15347
+ const candidate = limits;
15348
+ if (!isPositiveInteger(candidate.maxTrackedFileBytes) || !isPositiveInteger(candidate.maxUntrackedFileBytes) || !isPositiveInteger(candidate.maxBinaryFileBytes)) {
15349
+ return LEGACY_SNAPSHOT_LIMITS;
15350
+ }
15351
+ return limits;
15352
+ }
15353
+ async function initializeSession(repoRoot, tool, sessionId, limits, lineage, legacyBoundaryPending = false) {
14196
15354
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
15355
+ const effectiveLimits = limits ?? resolveSnapshotLimits(void 0);
14197
15356
  const frozen = freezeEpochBaseline({
14198
15357
  repoRoot,
14199
15358
  sessionId,
@@ -14201,7 +15360,10 @@ async function initializeSession(repoRoot, tool, sessionId) {
14201
15360
  epoch: 1,
14202
15361
  prevHeadSha: null,
14203
15362
  transitionKind: "initial",
14204
- startedAt
15363
+ startedAt,
15364
+ limits: effectiveLimits,
15365
+ lineage,
15366
+ pinCanonicalRefs: !legacyBoundaryPending
14205
15367
  });
14206
15368
  if (!frozen) return null;
14207
15369
  const state = {
@@ -14216,17 +15378,67 @@ async function initializeSession(repoRoot, tool, sessionId) {
14216
15378
  lastSnapshotTreeSha: frozen.baselineTreeSha,
14217
15379
  startedAt,
14218
15380
  epoch: 1,
15381
+ nextEpoch: 2,
14219
15382
  turnCount: 0,
14220
15383
  headSha: frozen.headSha,
14221
- repoRoot
15384
+ repoRoot,
15385
+ snapshotLimits: effectiveLimits,
15386
+ omittedKeys: omissionKeys(frozen.omittedFiles),
15387
+ codexLineage: lineage,
15388
+ codexLineageChecked: tool === "codex" ? false : void 0,
15389
+ legacyBoundaryPending: legacyBoundaryPending || void 0
14222
15390
  };
14223
- await writeSessionState(state, tool);
15391
+ await writeSessionState2(state, tool);
14224
15392
  appendLog(
14225
15393
  "info",
14226
15394
  `git-traces: session pending (repo=${repoRoot}, baseline=${frozen.baselineSha.slice(0, 8)}, awaiting first turn)`
14227
15395
  );
14228
15396
  return state;
14229
15397
  }
15398
+ function pinScopedStateRefs(repoRoot, state) {
15399
+ const prefix = epochPrefix2(state.epoch);
15400
+ const baselinePrefix = `refs/hillclimb/scoped/${state.sessionId}/baseline/${prefix}`;
15401
+ pinRef(repoRoot, `${baselinePrefix}/tracked`, state.baselineSha);
15402
+ if (state.baselineTreeSha) {
15403
+ pinScopedTreeRef(
15404
+ repoRoot,
15405
+ `${baselinePrefix}/tree`,
15406
+ state.baselineTreeSha,
15407
+ `protected baseline ${prefix} for session ${state.sessionId}`
15408
+ );
15409
+ }
15410
+ if (state.turnCount > 0 && state.lastSnapshotTreeSha) {
15411
+ pinScopedTurnSnapshot(
15412
+ repoRoot,
15413
+ state.sessionId,
15414
+ state.epoch,
15415
+ state.turnCount,
15416
+ state.lastSnapshotSha,
15417
+ state.lastSnapshotTreeSha
15418
+ );
15419
+ }
15420
+ }
15421
+ function pinCanonicalStateRefs(repoRoot, state) {
15422
+ pinScopedStateRefs(repoRoot, state);
15423
+ const prefix = epochPrefix2(state.epoch);
15424
+ const baselinePrefix = `refs/hillclimb/baseline/${state.sessionId}/${prefix}`;
15425
+ pinRef(repoRoot, `${baselinePrefix}/tracked`, state.baselineSha);
15426
+ if (state.baselineTreeSha) {
15427
+ pinScopedTreeRef(
15428
+ repoRoot,
15429
+ `${baselinePrefix}/tree`,
15430
+ state.baselineTreeSha,
15431
+ `canonical baseline ${prefix} for session ${state.sessionId}`
15432
+ );
15433
+ }
15434
+ if (state.turnCount > 0) {
15435
+ pinRef(
15436
+ repoRoot,
15437
+ `refs/hillclimb/turns/${state.sessionId}/${prefix}/${turnSuffix2(state.turnCount)}`,
15438
+ state.lastSnapshotSha
15439
+ );
15440
+ }
15441
+ }
14230
15442
  async function cleanupStaleScopedSessionStates(repoRoot, tool, currentSessionId, options = {}) {
14231
15443
  const nowMs = options.nowMs ?? Date.now();
14232
15444
  const ttlMs = options.ttlMs ?? STALE_SCOPED_STATE_TTL_MS;
@@ -14235,8 +15447,15 @@ async function cleanupStaleScopedSessionStates(repoRoot, tool, currentSessionId,
14235
15447
  for (const { state, mtimeMs } of states) {
14236
15448
  if (state.sessionId === currentSessionId) continue;
14237
15449
  const startedAtMs = Date.parse(state.startedAt);
14238
- const ageBaseMs = Number.isNaN(startedAtMs) ? mtimeMs : startedAtMs;
15450
+ const ageBaseMs = Number.isNaN(startedAtMs) ? mtimeMs : Math.max(startedAtMs, mtimeMs);
14239
15451
  if (nowMs - ageBaseMs <= ttlMs) continue;
15452
+ if (await readLegacySessionState(repoRoot, tool, state.sessionId)) {
15453
+ appendLog(
15454
+ "info",
15455
+ `git-traces: preserving stale scoped session ${state.sessionId} while matching legacy state exists`
15456
+ );
15457
+ continue;
15458
+ }
14240
15459
  try {
14241
15460
  await acquireLock3(repoRoot, tool, state.sessionId, 1, 0);
14242
15461
  } catch {
@@ -14244,15 +15463,38 @@ async function cleanupStaleScopedSessionStates(repoRoot, tool, currentSessionId,
14244
15463
  "info",
14245
15464
  `git-traces: leaving session ${state.sessionId} (lock held \u2014 still active)`
14246
15465
  );
14247
- continue;
14248
- }
14249
- try {
15466
+ continue;
15467
+ }
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
+ );
14250
15492
  appendLog(
14251
15493
  "info",
14252
- `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>"})`
14253
15495
  );
14254
15496
  cleanupSessionRefs(repoRoot, state.sessionId);
14255
- await deleteSessionState(repoRoot, tool, state.sessionId);
15497
+ await deleteSessionState2(repoRoot, tool, state.sessionId);
14256
15498
  removed++;
14257
15499
  } finally {
14258
15500
  await releaseLock3(repoRoot, tool, state.sessionId);
@@ -14260,7 +15502,26 @@ async function cleanupStaleScopedSessionStates(repoRoot, tool, currentSessionId,
14260
15502
  }
14261
15503
  return removed;
14262
15504
  }
14263
- async function processSessionStartRepo(repo, tool, sessionId) {
15505
+ async function acquireBoundaryLegacyLock(repoRoot, tool, timing) {
15506
+ for (; ; ) {
15507
+ try {
15508
+ if (timing) {
15509
+ await acquireLock3(repoRoot, tool, null, timing.retries, timing.delayMs);
15510
+ } else {
15511
+ await acquireLock3(repoRoot, tool, null);
15512
+ }
15513
+ return;
15514
+ } catch (err) {
15515
+ if (!(err instanceof LockContentionError)) throw err;
15516
+ appendLog(
15517
+ "info",
15518
+ `git-traces: detached lifecycle worker still waiting for legacy boundary lock (repo=${repoRoot}, tool=${tool})`
15519
+ );
15520
+ timing?.onChunkExhausted?.();
15521
+ }
15522
+ }
15523
+ }
15524
+ async function prepareSessionStartRepo(repo, tool, sessionId, lineage) {
14264
15525
  const { repoRoot } = repo;
14265
15526
  if (!isGitRepo(repoRoot)) {
14266
15527
  appendLog(
@@ -14269,34 +15530,72 @@ async function processSessionStartRepo(repo, tool, sessionId) {
14269
15530
  );
14270
15531
  return "skipped";
14271
15532
  }
14272
- let acquired = false;
15533
+ let scopedLockAcquired = false;
14273
15534
  try {
14274
15535
  await acquireLock3(repoRoot, tool, sessionId);
14275
- acquired = true;
14276
- const staleLegacy = await readSessionState(repoRoot, tool);
14277
- if (staleLegacy && staleLegacy.sessionId !== sessionId) {
14278
- let acquiredLegacy = false;
14279
- try {
14280
- await acquireLock3(repoRoot, tool, staleLegacy.sessionId, 1, 0);
14281
- acquiredLegacy = true;
14282
- } catch {
14283
- appendLog(
14284
- "info",
14285
- `git-traces: leaving legacy session ${staleLegacy.sessionId} (lock held \u2014 still active)`
14286
- );
14287
- }
14288
- if (acquiredLegacy) {
14289
- try {
14290
- appendLog(
14291
- "info",
14292
- `git-traces: cleaning up stale session ${staleLegacy.sessionId} (repo=${repoRoot})`
14293
- );
14294
- cleanupSessionRefs(repoRoot, staleLegacy.sessionId);
14295
- await deleteSessionState(repoRoot, tool);
14296
- } finally {
14297
- await releaseLock3(repoRoot, tool, staleLegacy.sessionId);
14298
- }
14299
- }
15536
+ scopedLockAcquired = true;
15537
+ const scoped = (await readSessionStateCandidates(repoRoot, tool, sessionId)).scoped?.state;
15538
+ if (scoped) {
15539
+ pinScopedStateRefs(repoRoot, scoped);
15540
+ await touchSessionState(repoRoot, tool, sessionId);
15541
+ return "initialized";
15542
+ }
15543
+ const state = await initializeSession(
15544
+ repoRoot,
15545
+ tool,
15546
+ sessionId,
15547
+ resolveSnapshotLimits(repo.config.snapshotLimits),
15548
+ lineage,
15549
+ true
15550
+ );
15551
+ return state ? "initialized" : "failed";
15552
+ } catch (err) {
15553
+ appendLog(
15554
+ "error",
15555
+ `git-traces: SessionStart failed for repo ${repoRoot}: ${formatError(err)}`
15556
+ );
15557
+ return "failed";
15558
+ } finally {
15559
+ if (scopedLockAcquired) await releaseLock3(repoRoot, tool, sessionId);
15560
+ }
15561
+ }
15562
+ function hasPreparedSessionCaptureProgress(state) {
15563
+ return state.contributionId !== null || state.baselineUploaded === true || state.epoch > 1 || state.turnCount > 0;
15564
+ }
15565
+ async function completeSessionStartRepo(repo, tool, sessionId, lineage, timing) {
15566
+ const { repoRoot } = repo;
15567
+ let legacyLockAcquired = false;
15568
+ let scopedLockAcquired = false;
15569
+ try {
15570
+ await acquireBoundaryLegacyLock(repoRoot, tool, timing);
15571
+ legacyLockAcquired = true;
15572
+ await acquireLock3(repoRoot, tool, sessionId);
15573
+ scopedLockAcquired = true;
15574
+ const candidates = await readSessionStateCandidates(
15575
+ repoRoot,
15576
+ tool,
15577
+ sessionId
15578
+ );
15579
+ if (!candidates.scoped) {
15580
+ appendLog(
15581
+ "info",
15582
+ `git-traces: SessionStart state already cleaned before legacy reconciliation (repo=${repoRoot}, session=${sessionId})`
15583
+ );
15584
+ return "skipped";
15585
+ }
15586
+ const reconciled = reconcileLockedSessionState(candidates);
15587
+ if (!reconciled.state) return "skipped";
15588
+ const lineageChanged = mergeLineageIntoState(reconciled.state, lineage);
15589
+ pinCanonicalStateRefs(repoRoot, reconciled.state);
15590
+ if (reconciled.needsWrite || lineageChanged) {
15591
+ await writeSessionState2(reconciled.state, tool);
15592
+ }
15593
+ if (candidates.legacy) {
15594
+ await deleteLegacySessionState(repoRoot, tool, sessionId);
15595
+ appendLog(
15596
+ "info",
15597
+ `git-traces: migrated legacy session on SessionStart (repo=${repoRoot}, session=${sessionId})`
15598
+ );
14300
15599
  }
14301
15600
  const staleCount = await cleanupStaleScopedSessionStates(
14302
15601
  repoRoot,
@@ -14309,16 +15608,16 @@ async function processSessionStartRepo(repo, tool, sessionId) {
14309
15608
  `git-traces: cleaned stale scoped sessions (repo=${repoRoot}, count=${staleCount})`
14310
15609
  );
14311
15610
  }
14312
- const state = await initializeSession(repoRoot, tool, sessionId);
14313
- return state ? "initialized" : "failed";
15611
+ return "initialized";
14314
15612
  } catch (err) {
14315
15613
  appendLog(
14316
15614
  "error",
14317
- `git-traces: SessionStart failed for repo ${repoRoot}: ${formatError(err)}`
15615
+ `git-traces: SessionStart reconciliation failed for repo ${repoRoot}: ${formatError(err)}`
14318
15616
  );
14319
15617
  return "failed";
14320
15618
  } finally {
14321
- if (acquired) await releaseLock3(repoRoot, tool, sessionId);
15619
+ if (scopedLockAcquired) await releaseLock3(repoRoot, tool, sessionId);
15620
+ if (legacyLockAcquired) await releaseLock3(repoRoot, tool, null);
14322
15621
  }
14323
15622
  }
14324
15623
  async function handleSessionStart(payload, tool) {
@@ -14345,11 +15644,24 @@ async function handleSessionStart(payload, tool) {
14345
15644
  appendLog("info", `git-traces: reaped ${reaped} stale lock file(s)`);
14346
15645
  }
14347
15646
  const repos = await loadConfiguredRepos();
14348
- let initialized = 0;
15647
+ const prepared = [];
14349
15648
  let skipped = 0;
14350
15649
  let failed = 0;
14351
15650
  for (const repo of repos) {
14352
- const outcome = await processSessionStartRepo(repo, tool, sessionId);
15651
+ const outcome = await prepareSessionStartRepo(repo, tool, sessionId);
15652
+ if (outcome === "initialized") prepared.push(repo);
15653
+ else if (outcome === "skipped") skipped++;
15654
+ else failed++;
15655
+ }
15656
+ const lineage = (await detectCodexLineageForHook(payload, tool, sessionId)).lineage;
15657
+ let initialized = 0;
15658
+ for (const repo of prepared) {
15659
+ const outcome = await completeSessionStartRepo(
15660
+ repo,
15661
+ tool,
15662
+ sessionId,
15663
+ lineage
15664
+ );
14353
15665
  if (outcome === "initialized") initialized++;
14354
15666
  else if (outcome === "skipped") skipped++;
14355
15667
  else failed++;
@@ -14359,7 +15671,7 @@ async function handleSessionStart(payload, tool) {
14359
15671
  `git-traces: SessionStart summary (session=${sessionId}, tool=${tool}, triggerRepo=${startingProject.repoRoot}, configured=${repos.length}, initialized=${initialized}, skipped=${skipped}, failed=${failed})`
14360
15672
  );
14361
15673
  }
14362
- function buildInitialBaselineArtifactsForState(repoRoot, tool, state) {
15674
+ function buildInitialBaselineArtifactsForState(repoRoot, tool, state, limits) {
14363
15675
  if (state.baselineTreeSha && state.baselineMetadata) {
14364
15676
  return buildFrozenEpochBaselineArtifacts({
14365
15677
  repoRoot,
@@ -14377,9 +15689,81 @@ function buildInitialBaselineArtifactsForState(repoRoot, tool, state) {
14377
15689
  baselineSha: state.baselineSha,
14378
15690
  prevHeadSha: null,
14379
15691
  transitionKind: "initial",
14380
- startedAt: state.startedAt
15692
+ startedAt: state.startedAt,
15693
+ limits,
15694
+ lineage: state.codexLineage
14381
15695
  });
14382
15696
  }
15697
+ function mergeLineageIntoState(state, detected) {
15698
+ const merged = mergeCodexLineage(state.codexLineage, detected);
15699
+ if (!merged || JSON.stringify(merged) === JSON.stringify(state.codexLineage)) {
15700
+ return false;
15701
+ }
15702
+ state.codexLineage = merged;
15703
+ if (state.baselineMetadata) {
15704
+ const baselineMetadata = { ...state.baselineMetadata };
15705
+ delete baselineMetadata.forkedFromThreadId;
15706
+ delete baselineMetadata.replayBoundary;
15707
+ state.baselineMetadata = {
15708
+ ...baselineMetadata,
15709
+ ...merged
15710
+ };
15711
+ }
15712
+ return true;
15713
+ }
15714
+ function reconcileSessionStateCandidates(candidates) {
15715
+ const { scoped, legacy } = candidates;
15716
+ if (!scoped) {
15717
+ if (!legacy) return { state: null, needsMigration: false };
15718
+ return { state: { ...legacy.state }, needsMigration: true };
15719
+ }
15720
+ if (!legacy) return { state: { ...scoped.state }, needsMigration: false };
15721
+ let authoritative = scoped;
15722
+ let other = legacy;
15723
+ const preferLegacy = scoped.state.legacyBoundaryPending === true && !hasPreparedSessionCaptureProgress(scoped.state);
15724
+ if (preferLegacy || legacy.mtimeMs > scoped.mtimeMs || legacy.mtimeMs === scoped.mtimeMs && (legacy.state.epoch > scoped.state.epoch || legacy.state.epoch === scoped.state.epoch && legacy.state.turnCount > scoped.state.turnCount)) {
15725
+ authoritative = legacy;
15726
+ other = scoped;
15727
+ }
15728
+ const state = { ...authoritative.state };
15729
+ mergeLineageIntoState(state, other.state.codexLineage);
15730
+ if (other.state.codexLineageChecked === true) {
15731
+ state.codexLineageChecked = true;
15732
+ }
15733
+ if (state.contributionId === other.state.contributionId) {
15734
+ state.nextEpoch = Math.max(
15735
+ state.nextEpoch ?? state.epoch + 1,
15736
+ other.state.nextEpoch ?? other.state.epoch + 1
15737
+ );
15738
+ state.codexLineageStamped = mergeCodexLineage(
15739
+ state.codexLineageStamped,
15740
+ other.state.codexLineageStamped
15741
+ );
15742
+ }
15743
+ return { state, needsMigration: true };
15744
+ }
15745
+ function reconcileLockedSessionState(candidates) {
15746
+ const reconciled = reconcileSessionStateCandidates(candidates);
15747
+ if (!reconciled.state) return { state: null, needsWrite: false };
15748
+ const boundaryCleared = reconciled.state.legacyBoundaryPending === true;
15749
+ delete reconciled.state.legacyBoundaryPending;
15750
+ return {
15751
+ state: reconciled.state,
15752
+ needsWrite: reconciled.needsMigration || boundaryCleared
15753
+ };
15754
+ }
15755
+ async function reserveEpoch(state, persistState) {
15756
+ const reservedEpoch = Math.max(
15757
+ state.nextEpoch ?? state.epoch + 1,
15758
+ state.epoch + 1
15759
+ );
15760
+ state.nextEpoch = reservedEpoch + 1;
15761
+ await persistState(state);
15762
+ return reservedEpoch;
15763
+ }
15764
+ function isTerminalContributionError(err) {
15765
+ return err instanceof PlatformError && err.status === 404 && err.code === "CONTRIBUTION_NOT_FOUND" || isContributionUploadNotUploaded(err);
15766
+ }
14383
15767
  async function loadRepoClient(repo) {
14384
15768
  const identity = await loadIdentity(repo.config.apiBaseUrl);
14385
15769
  if (!identity) {
@@ -14391,14 +15775,71 @@ async function loadRepoClient(repo) {
14391
15775
  }
14392
15776
  return new PlatformClient(repo.config.apiBaseUrl, identity.sessionCookie);
14393
15777
  }
15778
+ async function refreshLineageBaseline(params) {
15779
+ const { repo, state, tool, persistState, client, limits } = params;
15780
+ if (state.codexLineage === void 0 || JSON.stringify(state.codexLineage) === JSON.stringify(state.codexLineageStamped) || state.contributionId === null || !state.baselineUploaded) {
15781
+ return false;
15782
+ }
15783
+ try {
15784
+ const nextEpoch = await reserveEpoch(state, persistState);
15785
+ const artifacts = await openEpoch({
15786
+ repoRoot: repo.repoRoot,
15787
+ client,
15788
+ contributionId: state.contributionId,
15789
+ sessionId: state.sessionId,
15790
+ tool,
15791
+ epoch: nextEpoch,
15792
+ prevHeadSha: state.headSha || null,
15793
+ // The downstream contract has no metadata-only transition. This is a
15794
+ // complete baseline at the current HEAD, so use its compatible initial
15795
+ // baseline shape and explain the refresh only in collector state/logs.
15796
+ transitionKind: "initial",
15797
+ startedAt: state.startedAt,
15798
+ limits,
15799
+ lineage: state.codexLineage
15800
+ });
15801
+ if (!artifacts) {
15802
+ appendLog(
15803
+ "warn",
15804
+ `git-traces: lineage baseline refresh deferred after capture (repo=${repo.repoRoot}, project=${repo.config.projectId}, epoch=${nextEpoch})`
15805
+ );
15806
+ return false;
15807
+ }
15808
+ Object.assign(state, {
15809
+ baselineSha: artifacts.baselineSha,
15810
+ baselineTreeSha: artifacts.baselineTreeSha,
15811
+ lastSnapshotSha: artifacts.baselineSha,
15812
+ lastSnapshotTreeSha: artifacts.baselineTreeSha,
15813
+ epoch: nextEpoch,
15814
+ turnCount: 0,
15815
+ headSha: artifacts.headSha,
15816
+ omittedKeys: artifacts.omittedKeys,
15817
+ codexLineageStamped: state.codexLineage
15818
+ });
15819
+ await persistState(state);
15820
+ appendLog(
15821
+ "info",
15822
+ `git-traces: lineage metadata refresh baseline uploaded (repo=${repo.repoRoot}, project=${repo.config.projectId}, contribution=${state.contributionId}, epoch=${nextEpoch}, baseline=${artifacts.baselineSha.slice(0, 8)})`
15823
+ );
15824
+ return true;
15825
+ } catch (err) {
15826
+ if (isTerminalContributionError(err)) throw err;
15827
+ appendLog(
15828
+ "warn",
15829
+ `git-traces: lineage baseline refresh failed after capture; lineage remains pending (repo=${repo.repoRoot}, project=${repo.config.projectId}): ${formatError(err)}`
15830
+ );
15831
+ return false;
15832
+ }
15833
+ }
14394
15834
  async function registerInitialContribution(params) {
14395
- const { repo, state, tool, client, artifacts } = params;
15835
+ const { repo, state, tool, persistState, client, artifacts } = params;
14396
15836
  const contributionId = await createGitTracesContribution({
14397
15837
  client,
14398
15838
  config: repo.config,
14399
15839
  repoRoot: repo.repoRoot,
14400
15840
  state,
14401
15841
  tool,
15842
+ persistState,
14402
15843
  now: /* @__PURE__ */ new Date(),
14403
15844
  artifacts
14404
15845
  });
@@ -14407,14 +15848,44 @@ async function registerInitialContribution(params) {
14407
15848
  state.baselineUploaded = true;
14408
15849
  state.baselineTreeSha = artifacts.baselineTreeSha;
14409
15850
  state.lastSnapshotTreeSha = artifacts.baselineTreeSha;
14410
- await writeSessionState(state, tool);
15851
+ state.codexLineageStamped = state.codexLineage;
15852
+ await persistState(state);
14411
15853
  appendLog(
14412
15854
  "info",
14413
15855
  `git-traces: session registered on first changed turn (repo=${repo.repoRoot}, project=${repo.config.projectId}, contribution=${contributionId}, epoch=1, baseline=${state.baselineSha.slice(0, 8)})`
14414
15856
  );
14415
15857
  return true;
14416
15858
  }
14417
- async function processStopRepo(repo, tool, sessionId, recordedAt) {
15859
+ function reportNewlyOmittedFiles(state, omittedFiles, repoRoot) {
15860
+ const currentKeys = omissionKeys(omittedFiles);
15861
+ const previousKeys = Array.isArray(state.omittedKeys) && state.omittedKeys.every((key) => typeof key === "string") ? state.omittedKeys : void 0;
15862
+ if (previousKeys === void 0) {
15863
+ state.omittedKeys = currentKeys;
15864
+ return true;
15865
+ }
15866
+ const known = new Set(previousKeys);
15867
+ const fresh = omittedFiles.filter((file) => !known.has(omissionKey(file)));
15868
+ if (fresh.length > 0) {
15869
+ const tracked = fresh.filter((file) => file.tracked);
15870
+ const untracked = fresh.filter((file) => !file.tracked);
15871
+ if (tracked.length > 0) {
15872
+ appendLog(
15873
+ "warn",
15874
+ `git-traces: turn newly omitted tracked ${summarizeOmittedFiles(tracked)} \u2014 content excluded from bundle and diffs from here on (repo=${repoRoot})`
15875
+ );
15876
+ }
15877
+ if (untracked.length > 0) {
15878
+ appendLog(
15879
+ "info",
15880
+ `git-traces: turn newly skipped untracked ${summarizeOmittedFiles(untracked)} (repo=${repoRoot})`
15881
+ );
15882
+ }
15883
+ }
15884
+ const changed = currentKeys.length !== previousKeys.length || currentKeys.some((key, i) => key !== previousKeys[i]);
15885
+ if (changed) state.omittedKeys = currentKeys;
15886
+ return changed;
15887
+ }
15888
+ async function processStopRepo(repo, tool, sessionId, recordedAt, lineageCheck, options = {}) {
14418
15889
  const { repoRoot, config } = repo;
14419
15890
  if (!isGitRepo(repoRoot)) {
14420
15891
  appendLog(
@@ -14424,11 +15895,56 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14424
15895
  return "skipped";
14425
15896
  }
14426
15897
  let state = null;
14427
- let acquired = false;
15898
+ let legacyLockAcquired = false;
15899
+ let scopedLockAcquired = false;
15900
+ const persistState = sessionId ? (next) => writeSessionState2(next, tool) : (next) => writeLegacySessionState(next, tool);
14428
15901
  try {
14429
- await acquireLock3(repoRoot, tool, sessionId);
14430
- acquired = true;
14431
- state = await readSessionState(repoRoot, tool, sessionId);
15902
+ if (options.waitForLegacyBoundary) {
15903
+ await acquireBoundaryLegacyLock(repoRoot, tool);
15904
+ legacyLockAcquired = true;
15905
+ } else {
15906
+ const matchingLegacyHint = sessionId ? await readLegacySessionState(repoRoot, tool, sessionId) : await readSessionState2(repoRoot, tool);
15907
+ if (matchingLegacyHint) {
15908
+ try {
15909
+ await acquireLock3(repoRoot, tool, null, 2, 0);
15910
+ legacyLockAcquired = true;
15911
+ } catch {
15912
+ appendLog(
15913
+ "info",
15914
+ `git-traces: preserving repo on Stop (repo=${repoRoot}, project=${config.projectId}, reason=legacy-lock-held)`
15915
+ );
15916
+ return "skipped";
15917
+ }
15918
+ }
15919
+ }
15920
+ if (sessionId) {
15921
+ await acquireLock3(repoRoot, tool, sessionId);
15922
+ scopedLockAcquired = true;
15923
+ const candidates = await readSessionStateCandidates(
15924
+ repoRoot,
15925
+ tool,
15926
+ sessionId
15927
+ );
15928
+ if (legacyLockAcquired) {
15929
+ const reconciled = reconcileLockedSessionState(candidates);
15930
+ state = reconciled.state;
15931
+ if (state) {
15932
+ pinCanonicalStateRefs(repoRoot, state);
15933
+ if (reconciled.needsWrite) await writeSessionState2(state, tool);
15934
+ }
15935
+ if (state && candidates.legacy) {
15936
+ await deleteLegacySessionState(repoRoot, tool, state.sessionId);
15937
+ }
15938
+ } else {
15939
+ state = candidates.scoped?.state ?? null;
15940
+ }
15941
+ } else {
15942
+ if (!legacyLockAcquired) {
15943
+ await acquireLock3(repoRoot, tool, null, 2, 0);
15944
+ legacyLockAcquired = true;
15945
+ }
15946
+ state = await readSessionState2(repoRoot, tool);
15947
+ }
14432
15948
  if (!state) {
14433
15949
  appendLog(
14434
15950
  "info",
@@ -14436,6 +15952,40 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14436
15952
  );
14437
15953
  return "no-state";
14438
15954
  }
15955
+ const cleanupState = state;
15956
+ const finishSuccessfulStop = async (outcome) => {
15957
+ if (!options.cleanupOnSuccess) return outcome;
15958
+ await options.onBeforeCleanup?.();
15959
+ cleanupSessionRefs(repoRoot, cleanupState.sessionId);
15960
+ await deleteSessionState2(
15961
+ repoRoot,
15962
+ tool,
15963
+ sessionId ? cleanupState.sessionId : null
15964
+ );
15965
+ if (sessionId && legacyLockAcquired) {
15966
+ await deleteLegacySessionState(repoRoot, tool, cleanupState.sessionId);
15967
+ }
15968
+ appendLog(
15969
+ "info",
15970
+ `git-traces: session ${cleanupState.sessionId} cleaned up (repo=${repoRoot}, epochCount=${cleanupState.epoch}, lastTurnCount=${cleanupState.turnCount})`
15971
+ );
15972
+ return outcome;
15973
+ };
15974
+ let lineageStateChanged = false;
15975
+ if (lineageCheck?.performed) {
15976
+ lineageStateChanged = mergeLineageIntoState(
15977
+ state,
15978
+ lineageCheck.detection?.lineage
15979
+ );
15980
+ if (tool === "codex" && lineageCheck.detection?.conclusive === true && state.codexLineageChecked !== true) {
15981
+ state.codexLineageChecked = true;
15982
+ lineageStateChanged = true;
15983
+ }
15984
+ }
15985
+ if (lineageStateChanged) {
15986
+ await persistState(state);
15987
+ }
15988
+ const limits = sessionSnapshotLimits(state);
14439
15989
  const lastSnapshotTreeSha = state.lastSnapshotTreeSha ?? state.baselineTreeSha;
14440
15990
  if (!lastSnapshotTreeSha) {
14441
15991
  appendLog(
@@ -14445,6 +15995,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14445
15995
  return "failed";
14446
15996
  }
14447
15997
  const currentHeadSha = captureHeadSha(repoRoot);
15998
+ const lineageNeedsRefresh = state.codexLineage !== void 0 && JSON.stringify(state.codexLineage) !== JSON.stringify(state.codexLineageStamped);
14448
15999
  if (currentHeadSha !== state.headSha) {
14449
16000
  const client2 = await loadRepoClient(repo);
14450
16001
  if (!client2) return "skipped";
@@ -14452,7 +16003,8 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14452
16003
  const artifacts2 = buildInitialBaselineArtifactsForState(
14453
16004
  repoRoot,
14454
16005
  tool,
14455
- state
16006
+ state,
16007
+ limits
14456
16008
  );
14457
16009
  if (!artifacts2 || !canUploadEpochBaselineArtifacts(1, artifacts2)) {
14458
16010
  return "skipped";
@@ -14461,6 +16013,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14461
16013
  repo,
14462
16014
  state,
14463
16015
  tool,
16016
+ persistState,
14464
16017
  client: client2,
14465
16018
  artifacts: artifacts2
14466
16019
  });
@@ -14479,7 +16032,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14479
16032
  state.headSha || null,
14480
16033
  currentHeadSha
14481
16034
  );
14482
- const nextEpoch = state.epoch + 1;
16035
+ const nextEpoch = await reserveEpoch(state, persistState);
14483
16036
  appendLog(
14484
16037
  "info",
14485
16038
  `git-traces: HEAD moved (repo=${repoRoot}, project=${config.projectId}, contribution=${contributionId2}, transition=${transitionKind}, from=${state.headSha.slice(0, 8)}, to=${currentHeadSha.slice(0, 8)}, openingEpoch=${nextEpoch})`
@@ -14493,7 +16046,9 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14493
16046
  epoch: nextEpoch,
14494
16047
  prevHeadSha: state.headSha || null,
14495
16048
  transitionKind,
14496
- startedAt: state.startedAt
16049
+ startedAt: state.startedAt,
16050
+ limits,
16051
+ lineage: state.codexLineage
14497
16052
  });
14498
16053
  if (!artifacts) return "failed";
14499
16054
  const next = {
@@ -14504,28 +16059,61 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14504
16059
  lastSnapshotTreeSha: artifacts.baselineTreeSha,
14505
16060
  epoch: nextEpoch,
14506
16061
  turnCount: 0,
14507
- headSha: artifacts.headSha
16062
+ headSha: artifacts.headSha,
16063
+ omittedKeys: artifacts.omittedKeys,
16064
+ codexLineageStamped: state.codexLineage
14508
16065
  };
14509
- await writeSessionState(next, tool);
16066
+ await persistState(next);
14510
16067
  appendLog(
14511
16068
  "info",
14512
16069
  `git-traces: epoch baseline uploaded (repo=${repoRoot}, project=${config.projectId}, contribution=${contributionId2}, epoch=${nextEpoch}, baseline=${artifacts.baselineSha.slice(0, 8)})`
14513
16070
  );
14514
- return "uploaded";
16071
+ return await finishSuccessfulStop("uploaded");
14515
16072
  }
14516
16073
  const currentSha = captureSnapshotSha(repoRoot);
14517
- const currentTreeSha = buildSnapshotTree(repoRoot, currentSha);
16074
+ const turnOmittedFiles = [];
16075
+ const currentTreeSha = buildSnapshotTree(repoRoot, currentSha, {
16076
+ omittedFiles: turnOmittedFiles,
16077
+ log: false,
16078
+ limits
16079
+ });
16080
+ const omissionsChanged = reportNewlyOmittedFiles(
16081
+ state,
16082
+ turnOmittedFiles,
16083
+ repoRoot
16084
+ );
14518
16085
  const patchBuffer = createTreeDiffPatchGz(
14519
16086
  repoRoot,
14520
16087
  lastSnapshotTreeSha,
14521
- currentTreeSha
16088
+ currentTreeSha,
16089
+ { limits }
14522
16090
  );
14523
16091
  if (!patchBuffer) {
16092
+ if (omissionsChanged) {
16093
+ await persistState(state);
16094
+ } else {
16095
+ await touchSessionState(state.repoRoot, tool, sessionId);
16096
+ }
14524
16097
  appendLog(
14525
16098
  "info",
14526
16099
  `git-traces: turn produced identical snapshot tree, skipping upload (repo=${repoRoot}, project=${config.projectId})`
14527
16100
  );
14528
- return "unchanged";
16101
+ if (lineageNeedsRefresh && state.contributionId !== null && state.baselineUploaded) {
16102
+ const client2 = await loadRepoClient(repo);
16103
+ const refreshed = client2 !== null && await refreshLineageBaseline({
16104
+ repo,
16105
+ state,
16106
+ tool,
16107
+ persistState,
16108
+ client: client2,
16109
+ limits
16110
+ });
16111
+ if (refreshed) {
16112
+ return await finishSuccessfulStop("uploaded");
16113
+ }
16114
+ if (options.cleanupOnSuccess) return "skipped";
16115
+ }
16116
+ return await finishSuccessfulStop("unchanged");
14529
16117
  }
14530
16118
  const prefix = epochPrefix2(state.epoch);
14531
16119
  const nextTurnCount = state.turnCount + 1;
@@ -14544,7 +16132,8 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14544
16132
  const artifacts = buildInitialBaselineArtifactsForState(
14545
16133
  repoRoot,
14546
16134
  tool,
14547
- state
16135
+ state,
16136
+ limits
14548
16137
  );
14549
16138
  if (!artifacts || !canUploadEpochBaselineArtifacts(1, artifacts)) {
14550
16139
  return "skipped";
@@ -14553,6 +16142,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14553
16142
  repo,
14554
16143
  state,
14555
16144
  tool,
16145
+ persistState,
14556
16146
  client,
14557
16147
  artifacts
14558
16148
  });
@@ -14571,6 +16161,14 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14571
16161
  `refs/hillclimb/turns/${state.sessionId}/${prefix}/${turnLabel}`,
14572
16162
  currentSha
14573
16163
  );
16164
+ pinScopedTurnSnapshot(
16165
+ repoRoot,
16166
+ state.sessionId,
16167
+ state.epoch,
16168
+ nextTurnCount,
16169
+ currentSha,
16170
+ currentTreeSha
16171
+ );
14574
16172
  const uploaded = await uploadFile(
14575
16173
  client,
14576
16174
  contributionId,
@@ -14588,46 +16186,63 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14588
16186
  state.turnCount = nextTurnCount;
14589
16187
  state.lastSnapshotSha = currentSha;
14590
16188
  state.lastSnapshotTreeSha = currentTreeSha;
14591
- await writeSessionState(state, tool);
16189
+ await persistState(state);
14592
16190
  appendLog(
14593
16191
  "info",
14594
16192
  `git-traces: patch uploaded (repo=${repoRoot}, project=${config.projectId}, contribution=${contributionId}, epoch=${state.epoch}, turn=${turnLabel}, bytes=${patchBuffer.byteLength})`
14595
16193
  );
14596
- return "uploaded";
16194
+ const lineageStillNeedsRefresh = state.codexLineage !== void 0 && JSON.stringify(state.codexLineage) !== JSON.stringify(state.codexLineageStamped);
16195
+ if (lineageStillNeedsRefresh) {
16196
+ const refreshed = await refreshLineageBaseline({
16197
+ repo,
16198
+ state,
16199
+ tool,
16200
+ persistState,
16201
+ client,
16202
+ limits
16203
+ });
16204
+ if (!refreshed && options.cleanupOnSuccess) return "skipped";
16205
+ }
16206
+ return await finishSuccessfulStop("uploaded");
14597
16207
  } catch (err) {
14598
16208
  appendLog(
14599
16209
  "error",
14600
16210
  `git-traces: Stop failed for repo ${repoRoot}: ${formatError(err)}`
14601
16211
  );
14602
- if (state && err instanceof PlatformError && err.status === 404 && err.code === "CONTRIBUTION_NOT_FOUND") {
16212
+ if (state && isTerminalContributionError(err)) {
16213
+ const poisoned = isContributionUploadNotUploaded(err);
14603
16214
  if (state.baselineTreeSha) {
14604
- await writeSessionState(
14605
- {
14606
- ...state,
14607
- contributionId: null,
14608
- baselineUploaded: false,
14609
- lastSnapshotSha: state.baselineSha,
14610
- lastSnapshotTreeSha: state.baselineTreeSha,
14611
- turnCount: 0
14612
- },
14613
- tool
14614
- );
16215
+ await persistState({
16216
+ ...state,
16217
+ contributionId: null,
16218
+ baselineUploaded: false,
16219
+ lastSnapshotSha: state.baselineSha,
16220
+ lastSnapshotTreeSha: state.baselineTreeSha,
16221
+ turnCount: 0
16222
+ });
14615
16223
  appendLog(
14616
16224
  "warn",
14617
- `git-traces: cleared stale contribution state (repo=${repoRoot}, project=${config.projectId}, missingContribution=${state.contributionId})`
16225
+ poisoned ? `git-traces: cleared abandoned contribution state (repo=${repoRoot}, project=${config.projectId}, contribution=${state.contributionId}, reason=${err.code})` : `git-traces: cleared stale contribution state (repo=${repoRoot}, project=${config.projectId}, missingContribution=${state.contributionId})`
14618
16226
  );
14619
16227
  } else {
14620
16228
  cleanupSessionRefs(repoRoot, state.sessionId);
14621
- await deleteSessionState(repoRoot, tool, state.sessionId);
16229
+ await deleteSessionState2(
16230
+ repoRoot,
16231
+ tool,
16232
+ sessionId ? state.sessionId : null
16233
+ );
14622
16234
  appendLog(
14623
16235
  "warn",
14624
- `git-traces: deleted stale contribution state without a frozen baseline (repo=${repoRoot}, project=${config.projectId}, missingContribution=${state.contributionId})`
16236
+ poisoned ? `git-traces: deleted abandoned contribution state without a frozen baseline (repo=${repoRoot}, project=${config.projectId}, contribution=${state.contributionId}, reason=${err.code})` : `git-traces: deleted stale contribution state without a frozen baseline (repo=${repoRoot}, project=${config.projectId}, missingContribution=${state.contributionId})`
14625
16237
  );
14626
16238
  }
14627
16239
  }
14628
16240
  return "failed";
14629
16241
  } finally {
14630
- if (acquired) await releaseLock3(repoRoot, tool, sessionId);
16242
+ if (scopedLockAcquired) {
16243
+ await releaseLock3(repoRoot, tool, sessionId);
16244
+ }
16245
+ if (legacyLockAcquired) await releaseLock3(repoRoot, tool, null);
14631
16246
  }
14632
16247
  }
14633
16248
  async function handleStop(payload, tool) {
@@ -14639,12 +16254,13 @@ async function handleStop(payload, tool) {
14639
16254
  const recordedAt = Date.now();
14640
16255
  const repos = await loadConfiguredRepos();
14641
16256
  const repoByRoot = new Map(repos.map((repo) => [repo.repoRoot, repo]));
14642
- const targets = [];
16257
+ const targetsByRoot = /* @__PURE__ */ new Map();
14643
16258
  let missingConfig = 0;
16259
+ let needsLineageCheck = false;
14644
16260
  if (sessionId) {
14645
16261
  const storedStates = await listSessionStatesForSession(tool, sessionId);
14646
16262
  for (const { state } of storedStates) {
14647
- const repo = repoByRoot.get(path17.resolve(state.repoRoot));
16263
+ const repo = repoByRoot.get(path18.resolve(state.repoRoot));
14648
16264
  if (!repo) {
14649
16265
  missingConfig++;
14650
16266
  appendLog(
@@ -14653,21 +16269,53 @@ async function handleStop(payload, tool) {
14653
16269
  );
14654
16270
  continue;
14655
16271
  }
14656
- targets.push(repo);
16272
+ if (tool === "codex" && state.codexLineageChecked !== true) {
16273
+ needsLineageCheck = true;
16274
+ }
16275
+ targetsByRoot.set(repo.repoRoot, repo);
14657
16276
  }
14658
- if (targets.length === 0 && missingConfig === 0) {
14659
- targets.push({ repoRoot: project.repoRoot, config: project.config });
16277
+ for (const repo of repos) {
16278
+ const legacyState = await readLegacySessionState(
16279
+ repo.repoRoot,
16280
+ tool,
16281
+ sessionId
16282
+ );
16283
+ if (!legacyState) continue;
16284
+ targetsByRoot.set(repo.repoRoot, repo);
16285
+ if (tool === "codex" && legacyState.codexLineageChecked !== true) {
16286
+ needsLineageCheck = true;
16287
+ }
16288
+ }
16289
+ if (targetsByRoot.size === 0 && missingConfig === 0) {
16290
+ targetsByRoot.set(project.repoRoot, {
16291
+ repoRoot: project.repoRoot,
16292
+ config: project.config
16293
+ });
14660
16294
  }
14661
16295
  } else {
14662
- targets.push({ repoRoot: project.repoRoot, config: project.config });
16296
+ targetsByRoot.set(project.repoRoot, {
16297
+ repoRoot: project.repoRoot,
16298
+ config: project.config
16299
+ });
14663
16300
  }
16301
+ const targets = [...targetsByRoot.values()];
16302
+ const lineageCheck = needsLineageCheck ? {
16303
+ performed: true,
16304
+ detection: await detectCodexLineageForHook(payload, tool, sessionId)
16305
+ } : { performed: false };
14664
16306
  let uploaded = 0;
14665
16307
  let unchanged = 0;
14666
16308
  let skipped = missingConfig;
14667
16309
  let noState = 0;
14668
16310
  let failed = 0;
14669
16311
  for (const repo of targets) {
14670
- const outcome = await processStopRepo(repo, tool, sessionId, recordedAt);
16312
+ const outcome = await processStopRepo(
16313
+ repo,
16314
+ tool,
16315
+ sessionId,
16316
+ recordedAt,
16317
+ lineageCheck
16318
+ );
14671
16319
  if (outcome === "uploaded") uploaded++;
14672
16320
  else if (outcome === "unchanged") unchanged++;
14673
16321
  else if (outcome === "skipped") skipped++;
@@ -14679,49 +16327,48 @@ async function handleStop(payload, tool) {
14679
16327
  `git-traces: Stop summary (session=${sessionId ?? "<none>"}, tool=${tool}, triggerRepo=${project.repoRoot}, configured=${repos.length}, frozenStates=${targets.length + missingConfig}, uploaded=${uploaded}, unchanged=${unchanged}, skipped=${skipped}, noState=${noState}, failed=${failed})`
14680
16328
  );
14681
16329
  }
14682
- async function cleanupSessionStateForRepo(repoRoot, tool, sessionId) {
14683
- let acquired = false;
14684
- try {
14685
- await acquireLock3(repoRoot, tool, sessionId);
14686
- acquired = true;
14687
- const state = await readSessionState(repoRoot, tool, sessionId);
14688
- if (!state) return "no-state";
14689
- cleanupSessionRefs(repoRoot, state.sessionId);
14690
- await deleteSessionState(repoRoot, tool, state.sessionId);
14691
- appendLog(
14692
- "info",
14693
- `git-traces: session ${state.sessionId} cleaned up (repo=${repoRoot}, epochCount=${state.epoch}, lastTurnCount=${state.turnCount})`
14694
- );
14695
- return "cleaned";
14696
- } catch (err) {
14697
- appendLog(
14698
- "error",
14699
- `git-traces: SessionEnd cleanup failed for repo ${repoRoot}: ${formatError(err)}`
14700
- );
14701
- return "failed";
14702
- } finally {
14703
- if (acquired) await releaseLock3(repoRoot, tool, sessionId);
14704
- }
14705
- }
14706
16330
  async function handleSessionEnd(payload, tool) {
14707
16331
  const cwd = resolveCwd2(payload);
14708
16332
  const sessionId = resolveSessionId2(payload);
14709
16333
  const project = cwd ? await findProjectForCwd(cwd) : null;
14710
16334
  const triggerRepo = project?.repoRoot ?? cwd ?? "<none>";
14711
16335
  const recordedAt = Date.now();
14712
- const repoRoots = [];
16336
+ const repos = await loadConfiguredRepos();
16337
+ const repoByRoot = new Map(repos.map((repo) => [repo.repoRoot, repo]));
16338
+ const repoRoots = /* @__PURE__ */ new Set();
16339
+ let needsLineageCheck = false;
14713
16340
  if (sessionId) {
14714
16341
  const states = await listSessionStatesForSession(tool, sessionId);
14715
16342
  for (const { state } of states) {
14716
- repoRoots.push(path17.resolve(state.repoRoot));
16343
+ const repoRoot = path18.resolve(state.repoRoot);
16344
+ repoRoots.add(repoRoot);
16345
+ const canProcess = repoByRoot.has(repoRoot) || project && path18.resolve(project.repoRoot) === repoRoot;
16346
+ if (canProcess && tool === "codex" && state.codexLineageChecked !== true) {
16347
+ needsLineageCheck = true;
16348
+ }
16349
+ }
16350
+ for (const repo of repos) {
16351
+ const legacyState = await readLegacySessionState(
16352
+ repo.repoRoot,
16353
+ tool,
16354
+ sessionId
16355
+ );
16356
+ if (!legacyState) continue;
16357
+ repoRoots.add(repo.repoRoot);
16358
+ if (tool === "codex" && legacyState.codexLineageChecked !== true) {
16359
+ needsLineageCheck = true;
16360
+ }
14717
16361
  }
14718
16362
  }
14719
- if (repoRoots.length === 0 && cwd) {
14720
- repoRoots.push(project?.repoRoot ?? cwd);
16363
+ if (repoRoots.size === 0 && cwd) {
16364
+ const fallbackRepoRoot = project?.repoRoot ?? cwd;
16365
+ repoRoots.add(fallbackRepoRoot);
14721
16366
  }
14722
- if (repoRoots.length === 0) return;
14723
- const repos = await loadConfiguredRepos();
14724
- const repoByRoot = new Map(repos.map((repo) => [repo.repoRoot, repo]));
16367
+ if (repoRoots.size === 0) return;
16368
+ const lineageCheck = needsLineageCheck ? {
16369
+ performed: true,
16370
+ detection: await detectCodexLineageForHook(payload, tool, sessionId)
16371
+ } : { performed: false };
14725
16372
  let cleaned = 0;
14726
16373
  let noState = 0;
14727
16374
  let uploaded = 0;
@@ -14729,7 +16376,7 @@ async function handleSessionEnd(payload, tool) {
14729
16376
  let skipped = 0;
14730
16377
  let failed = 0;
14731
16378
  for (const repoRoot of repoRoots) {
14732
- const repo = repoByRoot.get(path17.resolve(repoRoot)) ?? (project && path17.resolve(project.repoRoot) === path17.resolve(repoRoot) ? { repoRoot: project.repoRoot, config: project.config } : null);
16379
+ const repo = repoByRoot.get(path18.resolve(repoRoot)) ?? (project && path18.resolve(project.repoRoot) === path18.resolve(repoRoot) ? { repoRoot: project.repoRoot, config: project.config } : null);
14733
16380
  if (!repo) {
14734
16381
  skipped++;
14735
16382
  appendLog(
@@ -14742,7 +16389,9 @@ async function handleSessionEnd(payload, tool) {
14742
16389
  repo,
14743
16390
  tool,
14744
16391
  sessionId,
14745
- recordedAt
16392
+ recordedAt,
16393
+ lineageCheck,
16394
+ { waitForLegacyBoundary: true, cleanupOnSuccess: true }
14746
16395
  );
14747
16396
  if (finalOutcome === "uploaded") uploaded++;
14748
16397
  else if (finalOutcome === "unchanged") unchanged++;
@@ -14764,14 +16413,11 @@ async function handleSessionEnd(payload, tool) {
14764
16413
  noState++;
14765
16414
  continue;
14766
16415
  }
14767
- const outcome = await cleanupSessionStateForRepo(repoRoot, tool, sessionId);
14768
- if (outcome === "cleaned") cleaned++;
14769
- else if (outcome === "no-state") noState++;
14770
- else failed++;
16416
+ cleaned++;
14771
16417
  }
14772
16418
  appendLog(
14773
16419
  "info",
14774
- `git-traces: SessionEnd summary (session=${sessionId ?? "<none>"}, tool=${tool}, triggerRepo=${triggerRepo}, states=${repoRoots.length}, uploaded=${uploaded}, unchanged=${unchanged}, skipped=${skipped}, cleaned=${cleaned}, noState=${noState}, failed=${failed})`
16420
+ `git-traces: SessionEnd summary (session=${sessionId ?? "<none>"}, tool=${tool}, triggerRepo=${triggerRepo}, states=${repoRoots.size}, uploaded=${uploaded}, unchanged=${unchanged}, skipped=${skipped}, cleaned=${cleaned}, noState=${noState}, failed=${failed})`
14775
16421
  );
14776
16422
  }
14777
16423
 
@@ -14780,7 +16426,7 @@ var WORKER_ENV_FLAG2 = "HILLCLIMB_GIT_TRACES_WORKER";
14780
16426
  var TOOL_ENV_FLAG2 = "HILLCLIMB_GIT_TRACES_TOOL";
14781
16427
  var FLOW_ID_ENV2 = "HILLCLIMB_GIT_TRACES_FLOW";
14782
16428
  function newFlowId2() {
14783
- return crypto6.randomBytes(3).toString("hex");
16429
+ return crypto7.randomBytes(3).toString("hex");
14784
16430
  }
14785
16431
  var KNOWN_TOOLS = /* @__PURE__ */ new Set([
14786
16432
  "claude",
@@ -14951,6 +16597,13 @@ async function runGitTraces() {
14951
16597
  appendLog("error", "git-traces: process.argv[1] is empty");
14952
16598
  return;
14953
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);
14954
16607
  try {
14955
16608
  const child = spawn3(
14956
16609
  process.execPath,
@@ -14958,12 +16611,7 @@ async function runGitTraces() {
14958
16611
  {
14959
16612
  detached: true,
14960
16613
  stdio: ["pipe", "ignore", "ignore"],
14961
- env: {
14962
- ...process.env,
14963
- [WORKER_ENV_FLAG2]: "1",
14964
- [TOOL_ENV_FLAG2]: tool,
14965
- [FLOW_ID_ENV2]: flowId
14966
- }
16614
+ env: workerEnv
14967
16615
  }
14968
16616
  );
14969
16617
  child.on("error", (err) => {
@@ -15022,7 +16670,7 @@ async function runGitTracesWorker() {
15022
16670
  return;
15023
16671
  }
15024
16672
  const event = payload.hook_event_name;
15025
- const eventKind = classifyHookEvent2(event);
16673
+ const eventKind2 = classifyHookEvent2(event);
15026
16674
  appendLog("info", `git-traces worker: handling event=${event}`);
15027
16675
  if (tool === "claude" && typeof payload.cursor_version === "string") {
15028
16676
  appendLog(
@@ -15033,7 +16681,7 @@ async function runGitTracesWorker() {
15033
16681
  }
15034
16682
  try {
15035
16683
  await selfHealHook2(payload, tool);
15036
- switch (eventKind) {
16684
+ switch (eventKind2) {
15037
16685
  case "sessionStart":
15038
16686
  await handleSessionStart(payload, tool);
15039
16687
  break;
@@ -15055,7 +16703,7 @@ async function runGitTracesWorker() {
15055
16703
  ${stack}` : ""}`
15056
16704
  );
15057
16705
  } finally {
15058
- if (eventKind === "stop" || eventKind === "sessionEnd") {
16706
+ if (eventKind2 === "stop" || eventKind2 === "sessionEnd") {
15059
16707
  await recordDebugLogCompletion({
15060
16708
  kind: "git",
15061
16709
  tool,
@@ -15066,29 +16714,29 @@ ${stack}` : ""}`
15066
16714
  }
15067
16715
 
15068
16716
  // src/outputs/zip.ts
15069
- import fs17 from "fs";
15070
- import path19 from "path";
16717
+ import fs19 from "fs";
16718
+ import path20 from "path";
15071
16719
  import archiver2 from "archiver";
15072
16720
 
15073
16721
  // src/outputs/downloads.ts
15074
16722
  import { execSync as execSync2 } from "child_process";
15075
- import fs16 from "fs";
15076
- import os9 from "os";
15077
- import path18 from "path";
16723
+ import fs18 from "fs";
16724
+ import os10 from "os";
16725
+ import path19 from "path";
15078
16726
  function getDownloadsFolder() {
15079
- const home = os9.homedir();
16727
+ const home = os10.homedir();
15080
16728
  if (process.platform === "linux") {
15081
16729
  try {
15082
16730
  const xdgDir = execSync2("xdg-user-dir DOWNLOAD", {
15083
16731
  encoding: "utf-8",
15084
16732
  timeout: 3e3
15085
16733
  }).trim();
15086
- if (xdgDir && fs16.existsSync(xdgDir)) return xdgDir;
16734
+ if (xdgDir && fs18.existsSync(xdgDir)) return xdgDir;
15087
16735
  } catch {
15088
16736
  }
15089
16737
  }
15090
- const downloads = path18.join(home, "Downloads");
15091
- if (fs16.existsSync(downloads)) return downloads;
16738
+ const downloads = path19.join(home, "Downloads");
16739
+ if (fs18.existsSync(downloads)) return downloads;
15092
16740
  return home;
15093
16741
  }
15094
16742
 
@@ -15097,11 +16745,11 @@ function sanitizeFilename(name) {
15097
16745
  return name.replace(/[^a-zA-Z0-9._-]/g, "_");
15098
16746
  }
15099
16747
  function getUniqueFilename(dir, base, ext) {
15100
- let candidate = path19.join(dir, `${base}${ext}`);
15101
- if (!fs17.existsSync(candidate)) return candidate;
16748
+ let candidate = path20.join(dir, `${base}${ext}`);
16749
+ if (!fs19.existsSync(candidate)) return candidate;
15102
16750
  let i = 1;
15103
- while (fs17.existsSync(candidate)) {
15104
- candidate = path19.join(dir, `${base}-${i}${ext}`);
16751
+ while (fs19.existsSync(candidate)) {
16752
+ candidate = path20.join(dir, `${base}-${i}${ext}`);
15105
16753
  i++;
15106
16754
  }
15107
16755
  return candidate;
@@ -15111,13 +16759,13 @@ var ZipOutput = class {
15111
16759
  label = "Save as .zip to Downloads";
15112
16760
  async emit(group, options) {
15113
16761
  const downloadsDir = getDownloadsFolder();
15114
- const repoName = sanitizeFilename(path19.basename(group.repoPath));
16762
+ const repoName = sanitizeFilename(path20.basename(group.repoPath));
15115
16763
  const timeRange = options.timeRange;
15116
16764
  const rangePart = timeRange?.label ?? "all";
15117
16765
  const epochSeconds = Math.floor(Date.now() / 1e3);
15118
16766
  const baseName = `logs-${repoName}-${rangePart}-${epochSeconds}`;
15119
16767
  const outputPath = getUniqueFilename(downloadsDir, baseName, ".zip");
15120
- const output = fs17.createWriteStream(outputPath);
16768
+ const output = fs19.createWriteStream(outputPath);
15121
16769
  const archive = archiver2("zip", { zlib: { level: 6 } });
15122
16770
  const done = new Promise((resolve, reject) => {
15123
16771
  output.on("close", resolve);
@@ -15311,15 +16959,15 @@ async function confirmExport(group, output) {
15311
16959
  }
15312
16960
 
15313
16961
  // src/sources/claude.ts
15314
- import fs18 from "fs";
15315
- import os10 from "os";
15316
- import path20 from "path";
16962
+ import fs20 from "fs";
16963
+ import os11 from "os";
16964
+ import path21 from "path";
15317
16965
  import readline2 from "readline";
15318
16966
  var SKIP_DIRS = /* @__PURE__ */ new Set(["tool-results"]);
15319
16967
  async function resolveRepoPath(projectDir) {
15320
- const indexPath = path20.join(projectDir, "sessions-index.json");
16968
+ const indexPath = path21.join(projectDir, "sessions-index.json");
15321
16969
  try {
15322
- const raw = await fs18.promises.readFile(indexPath, "utf-8");
16970
+ const raw = await fs20.promises.readFile(indexPath, "utf-8");
15323
16971
  const data = JSON.parse(raw);
15324
16972
  if (data.originalPath && typeof data.originalPath === "string") {
15325
16973
  return data.originalPath;
@@ -15327,12 +16975,12 @@ async function resolveRepoPath(projectDir) {
15327
16975
  } catch {
15328
16976
  }
15329
16977
  const cwdCounts = /* @__PURE__ */ new Map();
15330
- const entries = await fs18.promises.readdir(projectDir, {
16978
+ const entries = await fs20.promises.readdir(projectDir, {
15331
16979
  withFileTypes: true
15332
16980
  });
15333
16981
  for (const entry of entries) {
15334
16982
  if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
15335
- const cwd = await extractCwdFromJsonl(path20.join(projectDir, entry.name));
16983
+ const cwd = await extractCwdFromJsonl(path21.join(projectDir, entry.name));
15336
16984
  if (cwd) {
15337
16985
  cwdCounts.set(cwd, (cwdCounts.get(cwd) ?? 0) + 1);
15338
16986
  }
@@ -15351,7 +16999,7 @@ async function resolveRepoPath(projectDir) {
15351
16999
  return null;
15352
17000
  }
15353
17001
  async function extractCwdFromJsonl(filePath) {
15354
- const stream = fs18.createReadStream(filePath, { encoding: "utf-8" });
17002
+ const stream = fs20.createReadStream(filePath, { encoding: "utf-8" });
15355
17003
  const rl = readline2.createInterface({ input: stream, crlfDelay: Infinity });
15356
17004
  try {
15357
17005
  for await (const line of rl) {
@@ -15373,12 +17021,12 @@ async function extractCwdFromJsonl(filePath) {
15373
17021
  async function collectFiles(dir, baseDir, sourceName, repoPath, results) {
15374
17022
  let entries;
15375
17023
  try {
15376
- entries = await fs18.promises.readdir(dir, { withFileTypes: true });
17024
+ entries = await fs20.promises.readdir(dir, { withFileTypes: true });
15377
17025
  } catch {
15378
17026
  return;
15379
17027
  }
15380
17028
  for (const entry of entries) {
15381
- const fullPath = path20.join(dir, entry.name);
17029
+ const fullPath = path21.join(dir, entry.name);
15382
17030
  if (entry.isDirectory()) {
15383
17031
  if (SKIP_DIRS.has(entry.name)) continue;
15384
17032
  await collectFiles(fullPath, baseDir, sourceName, repoPath, results);
@@ -15400,19 +17048,19 @@ function fallbackDecode(encodedName) {
15400
17048
  var ClaudeSource = class {
15401
17049
  name = "claude";
15402
17050
  async scan() {
15403
- const baseDir = path20.join(os10.homedir(), ".claude", "projects");
17051
+ const baseDir = path21.join(os11.homedir(), ".claude", "projects");
15404
17052
  try {
15405
- await fs18.promises.access(baseDir);
17053
+ await fs20.promises.access(baseDir);
15406
17054
  } catch {
15407
17055
  return [];
15408
17056
  }
15409
- const projectDirs = await fs18.promises.readdir(baseDir, {
17057
+ const projectDirs = await fs20.promises.readdir(baseDir, {
15410
17058
  withFileTypes: true
15411
17059
  });
15412
17060
  const dirEntries = projectDirs.filter((d) => d.isDirectory());
15413
17061
  const resultArrays = await Promise.all(
15414
17062
  dirEntries.map(async (dir) => {
15415
- const projectPath = path20.join(baseDir, dir.name);
17063
+ const projectPath = path21.join(baseDir, dir.name);
15416
17064
  const repoPath = await resolveRepoPath(projectPath) ?? fallbackDecode(dir.name);
15417
17065
  const files = [];
15418
17066
  await collectFiles(
@@ -15430,12 +17078,12 @@ var ClaudeSource = class {
15430
17078
  };
15431
17079
 
15432
17080
  // src/sources/codex.ts
15433
- import fs19 from "fs";
15434
- import os11 from "os";
15435
- import path21 from "path";
17081
+ import fs21 from "fs";
17082
+ import os12 from "os";
17083
+ import path22 from "path";
15436
17084
  import readline3 from "readline";
15437
- async function parseSessionMeta(filePath) {
15438
- const stream = fs19.createReadStream(filePath, { encoding: "utf-8" });
17085
+ async function parseSessionMeta2(filePath) {
17086
+ const stream = fs21.createReadStream(filePath, { encoding: "utf-8" });
15439
17087
  const rl = readline3.createInterface({ input: stream, crlfDelay: Infinity });
15440
17088
  try {
15441
17089
  for await (const line of rl) {
@@ -15460,12 +17108,12 @@ async function findJsonlFiles(dir) {
15460
17108
  async function walk(d) {
15461
17109
  let entries;
15462
17110
  try {
15463
- entries = await fs19.promises.readdir(d, { withFileTypes: true });
17111
+ entries = await fs21.promises.readdir(d, { withFileTypes: true });
15464
17112
  } catch {
15465
17113
  return;
15466
17114
  }
15467
17115
  for (const entry of entries) {
15468
- const full = path21.join(d, entry.name);
17116
+ const full = path22.join(d, entry.name);
15469
17117
  if (entry.isDirectory()) {
15470
17118
  await walk(full);
15471
17119
  } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
@@ -15479,11 +17127,11 @@ async function findJsonlFiles(dir) {
15479
17127
  async function loadHistory(historyPath) {
15480
17128
  const map = /* @__PURE__ */ new Map();
15481
17129
  try {
15482
- await fs19.promises.access(historyPath);
17130
+ await fs21.promises.access(historyPath);
15483
17131
  } catch {
15484
17132
  return map;
15485
17133
  }
15486
- const stream = fs19.createReadStream(historyPath, { encoding: "utf-8" });
17134
+ const stream = fs21.createReadStream(historyPath, { encoding: "utf-8" });
15487
17135
  const rl = readline3.createInterface({ input: stream, crlfDelay: Infinity });
15488
17136
  try {
15489
17137
  for await (const line of rl) {
@@ -15510,22 +17158,22 @@ async function loadHistory(historyPath) {
15510
17158
  var CodexSource = class {
15511
17159
  name = "codex";
15512
17160
  async scan() {
15513
- const codexDir = path21.join(os11.homedir(), ".codex");
15514
- const sessionsDir = path21.join(codexDir, "sessions");
17161
+ const codexDir = path22.join(os12.homedir(), ".codex");
17162
+ const sessionsDir2 = path22.join(codexDir, "sessions");
15515
17163
  try {
15516
- await fs19.promises.access(sessionsDir);
17164
+ await fs21.promises.access(sessionsDir2);
15517
17165
  } catch {
15518
17166
  return [];
15519
17167
  }
15520
- const historyPath = path21.join(codexDir, "history.jsonl");
17168
+ const historyPath = path22.join(codexDir, "history.jsonl");
15521
17169
  const [jsonlFiles, historyMap] = await Promise.all([
15522
- findJsonlFiles(sessionsDir),
17170
+ findJsonlFiles(sessionsDir2),
15523
17171
  loadHistory(historyPath)
15524
17172
  ]);
15525
17173
  const results = [];
15526
17174
  const metaResults = await Promise.all(
15527
17175
  jsonlFiles.map(async (filePath) => {
15528
- const meta = await parseSessionMeta(filePath);
17176
+ const meta = await parseSessionMeta2(filePath);
15529
17177
  return meta ? { filePath, meta } : null;
15530
17178
  })
15531
17179
  );
@@ -15540,9 +17188,9 @@ var CodexSource = class {
15540
17188
  });
15541
17189
  const historyLines = historyMap.get(meta.sessionId);
15542
17190
  if (historyLines) {
15543
- const sessionDir = path21.relative(sessionsDir, path21.dirname(filePath));
15544
- const historyAbsPath = path21.join(
15545
- sessionsDir,
17191
+ const sessionDir = path22.relative(sessionsDir2, path22.dirname(filePath));
17192
+ const historyAbsPath = path22.join(
17193
+ sessionsDir2,
15546
17194
  sessionDir,
15547
17195
  `history-${meta.sessionId}.jsonl`
15548
17196
  );
@@ -15561,18 +17209,18 @@ var CodexSource = class {
15561
17209
  };
15562
17210
 
15563
17211
  // src/sources/copilotChat.ts
15564
- import fs20 from "fs";
15565
- import os12 from "os";
15566
- import path22 from "path";
17212
+ import fs22 from "fs";
17213
+ import os13 from "os";
17214
+ import path23 from "path";
15567
17215
  import { fileURLToPath } from "url";
15568
17216
  function vsCodeUserDirs() {
15569
- const home = os12.homedir();
17217
+ const home = os13.homedir();
15570
17218
  const dirs = [
15571
- path22.join(home, "Library", "Application Support", "Code", "User"),
15572
- path22.join(home, ".config", "Code", "User")
17219
+ path23.join(home, "Library", "Application Support", "Code", "User"),
17220
+ path23.join(home, ".config", "Code", "User")
15573
17221
  ];
15574
17222
  if (process.env.APPDATA) {
15575
- dirs.push(path22.join(process.env.APPDATA, "Code", "User"));
17223
+ dirs.push(path23.join(process.env.APPDATA, "Code", "User"));
15576
17224
  }
15577
17225
  return dirs;
15578
17226
  }
@@ -15587,7 +17235,7 @@ function uriToFsPath(uri) {
15587
17235
  async function readWorkspaceFolder(workspaceJsonPath) {
15588
17236
  let raw;
15589
17237
  try {
15590
- raw = await fs20.promises.readFile(workspaceJsonPath, "utf-8");
17238
+ raw = await fs22.promises.readFile(workspaceJsonPath, "utf-8");
15591
17239
  } catch {
15592
17240
  return null;
15593
17241
  }
@@ -15609,10 +17257,10 @@ var CopilotChatSource = class {
15609
17257
  async scan() {
15610
17258
  const results = [];
15611
17259
  for (const userDir of vsCodeUserDirs()) {
15612
- const workspaceStorage = path22.join(userDir, "workspaceStorage");
17260
+ const workspaceStorage = path23.join(userDir, "workspaceStorage");
15613
17261
  let hashDirs;
15614
17262
  try {
15615
- hashDirs = await fs20.promises.readdir(workspaceStorage, {
17263
+ hashDirs = await fs22.promises.readdir(workspaceStorage, {
15616
17264
  withFileTypes: true
15617
17265
  });
15618
17266
  } catch {
@@ -15620,22 +17268,22 @@ var CopilotChatSource = class {
15620
17268
  }
15621
17269
  for (const hash of hashDirs) {
15622
17270
  if (!hash.isDirectory()) continue;
15623
- const wsRoot = path22.join(workspaceStorage, hash.name);
15624
- const transcriptsDir = path22.join(
17271
+ const wsRoot = path23.join(workspaceStorage, hash.name);
17272
+ const transcriptsDir = path23.join(
15625
17273
  wsRoot,
15626
17274
  "GitHub.copilot-chat",
15627
17275
  "transcripts"
15628
17276
  );
15629
17277
  let transcriptEntries;
15630
17278
  try {
15631
- transcriptEntries = await fs20.promises.readdir(transcriptsDir, {
17279
+ transcriptEntries = await fs22.promises.readdir(transcriptsDir, {
15632
17280
  withFileTypes: true
15633
17281
  });
15634
17282
  } catch {
15635
17283
  continue;
15636
17284
  }
15637
17285
  const repoPath = await readWorkspaceFolder(
15638
- path22.join(wsRoot, "workspace.json")
17286
+ path23.join(wsRoot, "workspace.json")
15639
17287
  );
15640
17288
  if (!repoPath) continue;
15641
17289
  for (const entry of transcriptEntries) {
@@ -15643,7 +17291,7 @@ var CopilotChatSource = class {
15643
17291
  const sessionId = entry.name.slice(0, -".jsonl".length);
15644
17292
  results.push({
15645
17293
  sourceName: this.name,
15646
- absolutePath: path22.join(transcriptsDir, entry.name),
17294
+ absolutePath: path23.join(transcriptsDir, entry.name),
15647
17295
  repoPath,
15648
17296
  metadata: { sessionId }
15649
17297
  });
@@ -15683,7 +17331,7 @@ function reportRedactionStats(noun, stats) {
15683
17331
  async function filterByTimeRange(group, range) {
15684
17332
  const results = await Promise.all(
15685
17333
  group.files.map(
15686
- (f) => fs21.promises.stat(f.absolutePath).then((s) => ({ file: f, stat: s })).catch(() => null)
17334
+ (f) => fs23.promises.stat(f.absolutePath).then((s) => ({ file: f, stat: s })).catch(() => null)
15687
17335
  )
15688
17336
  );
15689
17337
  const filtered = [];
@@ -15710,10 +17358,10 @@ async function runInteractive() {
15710
17358
  s.start(`Scanning ${source.name} logs...`);
15711
17359
  const allFiles = await source.scan();
15712
17360
  const allGroups = await mergeByRepo(allFiles);
15713
- const repoRoot = path23.resolve(repo.root);
17361
+ const repoRoot = path24.resolve(repo.root);
15714
17362
  const matching = allGroups.filter((g) => {
15715
- const resolved = path23.resolve(g.repoPath);
15716
- return resolved === repoRoot || resolved.startsWith(repoRoot + path23.sep);
17363
+ const resolved = path24.resolve(g.repoPath);
17364
+ return resolved === repoRoot || resolved.startsWith(repoRoot + path24.sep);
15717
17365
  });
15718
17366
  if (matching.length === 0) {
15719
17367
  s.stop(`No ${source.name} logs found for ${repo.name}.`);
@@ -15744,7 +17392,7 @@ async function runInteractive() {
15744
17392
  }
15745
17393
  }
15746
17394
  const envFileNames = await discoverEnvFiles(repoRoot);
15747
- const envFilePaths = envFileNames.map((n) => path23.join(repoRoot, n));
17395
+ const envFilePaths = envFileNames.map((n) => path24.join(repoRoot, n));
15748
17396
  const additionalFiles = await promptSecretFiles(envFileNames);
15749
17397
  const secretResult = await collectSecrets(
15750
17398
  repoRoot,