hillclimb 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
  );
@@ -11479,9 +12043,9 @@ function expectedKinds(tool, eventKind, payload) {
11479
12043
  async function transcriptFingerprint(payload) {
11480
12044
  const transcriptPath = stringOrNull(payload.transcript_path);
11481
12045
  if (!transcriptPath) return {};
11482
- const resolved = path12.resolve(transcriptPath);
12046
+ const resolved = path13.resolve(transcriptPath);
11483
12047
  try {
11484
- const stat = await fs10.promises.stat(resolved);
12048
+ const stat = await fs12.promises.stat(resolved);
11485
12049
  return {
11486
12050
  transcriptPath: resolved,
11487
12051
  transcriptMtimeMs: stat.mtimeMs,
@@ -11492,8 +12056,8 @@ async function transcriptFingerprint(payload) {
11492
12056
  }
11493
12057
  }
11494
12058
  async function eventContext(tool, payload) {
11495
- const eventKind = classifyHookEvent(payload.hook_event_name);
11496
- if (!eventKind) return null;
12059
+ const eventKind2 = classifyHookEvent(payload.hook_event_name);
12060
+ if (!eventKind2) return null;
11497
12061
  const cwd = resolveCwd(payload);
11498
12062
  if (!cwd) return null;
11499
12063
  const project = await findProjectForCwd(cwd);
@@ -11504,15 +12068,15 @@ async function eventContext(tool, payload) {
11504
12068
  if (!sessionId && !turnId && !transcriptPath) {
11505
12069
  return null;
11506
12070
  }
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;
12071
+ const expected = expectedKinds(tool, eventKind2, payload);
12072
+ const eventNonce = eventKind2 === "stop" && expected.size === 1 && expected.has("git") && !turnId && !transcriptPath ? crypto2.randomBytes(8).toString("hex") : null;
11509
12073
  const fingerprint = {
11510
12074
  schema: "debug-log-event-v1",
11511
12075
  apiBaseUrl: project.config.apiBaseUrl,
11512
12076
  projectId: project.config.projectId,
11513
12077
  repoRoot: project.repoRoot,
11514
12078
  tool,
11515
- eventKind,
12079
+ eventKind: eventKind2,
11516
12080
  hookEventName: payload.hook_event_name ?? null,
11517
12081
  sessionId,
11518
12082
  conversationId: stringOrNull(payload.conversation_id),
@@ -11521,15 +12085,15 @@ async function eventContext(tool, payload) {
11521
12085
  // Only stop events without stable turn IDs need the transcript to
11522
12086
  // disambiguate. When a turn_id exists, including mutable transcript
11523
12087
  // mtime/size can split agent+git completions for the same logical event.
11524
- ...eventKind === "stop" && !turnId ? await transcriptFingerprint(payload) : {}
12088
+ ...eventKind2 === "stop" && !turnId ? await transcriptFingerprint(payload) : {}
11525
12089
  };
11526
- const eventId = crypto.createHash("sha256").update(JSON.stringify(fingerprint)).digest("hex").slice(0, 32);
12090
+ const eventId = crypto2.createHash("sha256").update(JSON.stringify(fingerprint)).digest("hex").slice(0, 32);
11527
12091
  return {
11528
12092
  eventId,
11529
12093
  repoRoot: project.repoRoot,
11530
12094
  config: project.config,
11531
12095
  tool,
11532
- eventKind,
12096
+ eventKind: eventKind2,
11533
12097
  hookEventName: payload.hook_event_name ?? null,
11534
12098
  sessionId,
11535
12099
  expectedKinds: expected
@@ -11537,7 +12101,7 @@ async function eventContext(tool, payload) {
11537
12101
  }
11538
12102
  async function readState(eventId) {
11539
12103
  try {
11540
- const raw = await fs10.promises.readFile(stateFile(eventId), "utf-8");
12104
+ const raw = await fs12.promises.readFile(stateFile(eventId), "utf-8");
11541
12105
  const parsed = JSON.parse(raw);
11542
12106
  if (parsed.schemaVersion !== CURRENT_SCHEMA_VERSION) return null;
11543
12107
  return parsed;
@@ -11546,37 +12110,132 @@ async function readState(eventId) {
11546
12110
  }
11547
12111
  }
11548
12112
  async function writeState(state) {
11549
- await fs10.promises.mkdir(stateDir(), { recursive: true, mode: 448 });
12113
+ await fs12.promises.mkdir(stateDir(), { recursive: true, mode: 448 });
11550
12114
  const file = stateFile(state.eventId);
11551
12115
  const tmp = `${file}.tmp`;
11552
- await fs10.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
12116
+ await fs12.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
11553
12117
  mode: 384
11554
12118
  });
11555
- await fs10.promises.rename(tmp, file);
12119
+ await fs12.promises.rename(tmp, file);
11556
12120
  }
11557
- async function acquireLock(eventId) {
11558
- await fs10.promises.mkdir(stateDir(), { recursive: true, mode: 448 });
11559
- for (let i = 0; i < LOCK_RETRIES; i++) {
12121
+ async function readSessionState(key) {
12122
+ try {
12123
+ const raw = await fs12.promises.readFile(
12124
+ stateFile(sessionStateId(key)),
12125
+ "utf-8"
12126
+ );
12127
+ const parsed = JSON.parse(raw);
12128
+ if (parsed.schemaVersion !== CURRENT_SCHEMA_VERSION || parsed.sessionKey !== key || typeof parsed.contributionId !== "string") {
12129
+ return null;
12130
+ }
12131
+ return parsed;
12132
+ } catch {
12133
+ return null;
12134
+ }
12135
+ }
12136
+ async function writeSessionState(state) {
12137
+ await fs12.promises.mkdir(stateDir(), { recursive: true, mode: 448 });
12138
+ const file = stateFile(sessionStateId(state.sessionKey));
12139
+ const tmp = `${file}.tmp`;
12140
+ await fs12.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
12141
+ mode: 384
12142
+ });
12143
+ await fs12.promises.rename(tmp, file);
12144
+ }
12145
+ async function deleteSessionState(key) {
12146
+ try {
12147
+ await fs12.promises.unlink(stateFile(sessionStateId(key)));
12148
+ } catch (err) {
12149
+ if (err.code !== "ENOENT") throw err;
12150
+ }
12151
+ }
12152
+ async function tryCreateLock(lockId) {
12153
+ try {
12154
+ const fd = await fs12.promises.open(
12155
+ lockFile(lockId),
12156
+ fs12.constants.O_CREAT | fs12.constants.O_EXCL | fs12.constants.O_WRONLY
12157
+ );
11560
12158
  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
12159
  await fd.write(String(process.pid));
12160
+ } finally {
11566
12161
  await fd.close();
12162
+ }
12163
+ return true;
12164
+ } catch (err) {
12165
+ if (err.code === "EEXIST") return false;
12166
+ throw err;
12167
+ }
12168
+ }
12169
+ async function acquireLock(eventId) {
12170
+ await fs12.promises.mkdir(stateDir(), { recursive: true, mode: 448 });
12171
+ for (let i = 0; i < LOCK_RETRIES; i++) {
12172
+ if (await tryCreateLock(eventId)) return;
12173
+ if (await reapLockIfStale(lockFile(eventId), {
12174
+ maxAgeMs: STALE_LOCK_TTL_MS
12175
+ }) && await tryCreateLock(eventId)) {
11567
12176
  return;
11568
- } catch (err) {
11569
- if (err.code === "EEXIST" && i < LOCK_RETRIES - 1) {
11570
- await sleep2(LOCK_RETRY_DELAY_MS);
12177
+ }
12178
+ if (i < LOCK_RETRIES - 1) await sleep2(LOCK_RETRY_DELAY_MS);
12179
+ }
12180
+ throw new Error(
12181
+ `Failed to acquire debug-log lock after ${DEFAULT_LOCK_WAIT_MS}ms (lock=${lockFile(eventId)})`
12182
+ );
12183
+ }
12184
+ async function tryAcquireLock(eventId, now) {
12185
+ await fs12.promises.mkdir(stateDir(), { recursive: true, mode: 448 });
12186
+ if (await tryCreateLock(eventId)) return true;
12187
+ if (await reapLockIfStale(lockFile(eventId), {
12188
+ maxAgeMs: STALE_LOCK_TTL_MS,
12189
+ now
12190
+ })) {
12191
+ return tryCreateLock(eventId);
12192
+ }
12193
+ return false;
12194
+ }
12195
+ async function sweepStaleDebugLogState(now = Date.now()) {
12196
+ let entries;
12197
+ try {
12198
+ entries = await fs12.promises.readdir(stateDir(), { withFileTypes: true });
12199
+ } catch {
12200
+ return;
12201
+ }
12202
+ for (const entry of entries) {
12203
+ if (!entry.isFile()) continue;
12204
+ const file = path13.join(stateDir(), entry.name);
12205
+ try {
12206
+ if (entry.name.endsWith(".lock")) {
12207
+ await reapLockIfStale(file, {
12208
+ maxAgeMs: STALE_LOCK_TTL_MS,
12209
+ now
12210
+ });
11571
12211
  continue;
11572
12212
  }
11573
- throw err;
12213
+ if (entry.name.startsWith("session-") && entry.name.endsWith(".json")) {
12214
+ const stat2 = await fs12.promises.stat(file);
12215
+ if (now - stat2.mtimeMs <= SESSION_STATE_TTL_MS) continue;
12216
+ const sessionLockId = entry.name.slice(0, -".json".length);
12217
+ if (!await tryAcquireLock(sessionLockId, now)) continue;
12218
+ try {
12219
+ const lockedStat = await fs12.promises.stat(file);
12220
+ if (now - lockedStat.mtimeMs > SESSION_STATE_TTL_MS) {
12221
+ await fs12.promises.unlink(file);
12222
+ }
12223
+ } finally {
12224
+ await releaseLock(sessionLockId);
12225
+ }
12226
+ continue;
12227
+ }
12228
+ const stat = await fs12.promises.stat(file);
12229
+ if (now - stat.mtimeMs > EVENT_STATE_TTL_MS) {
12230
+ await fs12.promises.unlink(file);
12231
+ }
12232
+ } catch {
11574
12233
  }
11575
12234
  }
11576
12235
  }
11577
12236
  async function releaseLock(eventId) {
11578
12237
  try {
11579
- await fs10.promises.unlink(lockFile(eventId));
12238
+ await fs12.promises.unlink(lockFile(eventId));
11580
12239
  } catch {
11581
12240
  }
11582
12241
  }
@@ -11592,7 +12251,7 @@ function initialState(ctx, now) {
11592
12251
  eventKind: ctx.eventKind,
11593
12252
  hookEventName: ctx.hookEventName,
11594
12253
  sessionId: ctx.sessionId,
11595
- logDate: path12.basename(todayLogPath(), ".log"),
12254
+ logDate: path13.basename(todayLogPath(), ".log"),
11596
12255
  firstSeenAt: now.toISOString()
11597
12256
  };
11598
12257
  }
@@ -11654,7 +12313,7 @@ async function waitForExpectedKinds(ctx, state) {
11654
12313
  }
11655
12314
  async function buildMiddleware(repoRoot) {
11656
12315
  const envFileNames = await discoverEnvFiles(repoRoot);
11657
- const envFilePaths = envFileNames.map((n) => path12.join(repoRoot, n));
12316
+ const envFilePaths = envFileNames.map((n) => path13.join(repoRoot, n));
11658
12317
  const secretResult = await collectSecrets(repoRoot, envFilePaths, []);
11659
12318
  const middleware2 = [];
11660
12319
  if (secretResult.values.size > 0) {
@@ -11663,21 +12322,21 @@ async function buildMiddleware(repoRoot) {
11663
12322
  middleware2.push(new PatternRedactMiddleware());
11664
12323
  return middleware2;
11665
12324
  }
11666
- async function uploadDebugLog(ctx, state) {
12325
+ async function uploadDebugLog(ctx, state, sessionState, onContributionCreated) {
11667
12326
  const logPath = todayLogPath();
11668
12327
  let content;
11669
12328
  try {
11670
- content = await fs10.promises.readFile(logPath);
12329
+ content = await fs12.promises.readFile(logPath);
11671
12330
  } catch (err) {
11672
12331
  appendLog(
11673
12332
  "warn",
11674
12333
  `debug-logs: skipped upload; log file not readable (${logPath}): ${err instanceof Error ? err.message : String(err)}`
11675
12334
  );
11676
- return null;
12335
+ return { kind: "skipped" };
11677
12336
  }
11678
12337
  if (content.byteLength === 0) {
11679
12338
  appendLog("info", "debug-logs: skipped upload; log file is empty");
11680
- return null;
12339
+ return { kind: "skipped" };
11681
12340
  }
11682
12341
  const identity = await loadIdentity(ctx.config.apiBaseUrl);
11683
12342
  if (!identity) {
@@ -11685,7 +12344,7 @@ async function uploadDebugLog(ctx, state) {
11685
12344
  "warn",
11686
12345
  `debug-logs: skipped upload; no saved login for ${ctx.config.apiBaseUrl}`
11687
12346
  );
11688
- return null;
12347
+ return { kind: "skipped" };
11689
12348
  }
11690
12349
  const now = /* @__PURE__ */ new Date();
11691
12350
  appendLog(
@@ -11700,7 +12359,7 @@ async function uploadDebugLog(ctx, state) {
11700
12359
  };
11701
12360
  const group = {
11702
12361
  repoPath: ctx.repoRoot,
11703
- label: path12.basename(ctx.repoRoot),
12362
+ label: path13.basename(ctx.repoRoot),
11704
12363
  files: [sourceFile],
11705
12364
  sourceNames: [DEBUG_LOGS_SLUG],
11706
12365
  lastModified: now
@@ -11708,27 +12367,44 @@ async function uploadDebugLog(ctx, state) {
11708
12367
  const shortSession = (ctx.sessionId ?? ctx.eventId).slice(0, 12);
11709
12368
  const epochSeconds = formatEpochSeconds(now);
11710
12369
  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,
12370
+ const sessionDescription = ctx.sessionId ? {
12371
+ contributionTitle: `${label} debug logs ${shortSession}`,
12372
+ contributionBody: [
12373
+ `Session ID: ${ctx.sessionId}`,
12374
+ `Tool: ${label}`,
12375
+ `Repo: ${ctx.repoRoot}`,
12376
+ "Log snapshots: complete Hillclimb daily log snapshots captured at hook events"
12377
+ ].join("\n")
12378
+ } : {
11719
12379
  contributionTitle: `${label} debug log ${shortSession} - ${epochSeconds}`,
11720
12380
  contributionBody: [
11721
- `Session ID: ${ctx.sessionId ?? "<none>"}`,
12381
+ "Session ID: <none>",
11722
12382
  `Tool: ${label}`,
11723
12383
  `Event: ${ctx.hookEventName ?? ctx.eventKind}`,
11724
12384
  `Repo: ${ctx.repoRoot}`,
11725
- `Log: ${path12.basename(logPath)}`,
12385
+ `Log: ${path13.basename(logPath)}`,
11726
12386
  `Agent done: ${state.agentDoneAt ?? "<not observed>"}`,
11727
12387
  `Git done: ${state.gitDoneAt ?? "<not observed>"}`,
11728
12388
  `Uploaded: ${now.toISOString()}`
11729
- ].join("\n"),
11730
- zipFilename: `hillclimb-debug-log-${sanitize(ctx.tool)}-${sanitize(shortSession)}-${epochSeconds}.zip`,
11731
- autoSubmit: true
12389
+ ].join("\n")
12390
+ };
12391
+ const client = new PlatformClient(
12392
+ ctx.config.apiBaseUrl,
12393
+ identity.sessionCookie
12394
+ );
12395
+ let attemptContributionId = sessionState?.contributionId;
12396
+ const output = new PlatformUploadOutput({
12397
+ client,
12398
+ projectId: ctx.config.projectId,
12399
+ contributionTypeSlug: DEBUG_LOGS_SLUG,
12400
+ ...sessionDescription,
12401
+ zipFilename: `hillclimb-debug-log-${sanitize(ctx.tool)}-${sanitize(shortSession)}-${now.getTime()}-${ctx.eventId.slice(0, 8)}-${crypto2.randomBytes(6).toString("hex")}.zip`,
12402
+ autoSubmit: !sessionState?.firstUploadSubmittedAt,
12403
+ existingContributionId: sessionState?.contributionId,
12404
+ onContributionCreated: onContributionCreated ? async (contributionId) => {
12405
+ attemptContributionId = contributionId;
12406
+ await onContributionCreated(contributionId);
12407
+ } : void 0
11732
12408
  });
11733
12409
  try {
11734
12410
  const contributionId = await runPipeline(
@@ -11739,37 +12415,90 @@ async function uploadDebugLog(ctx, state) {
11739
12415
  );
11740
12416
  appendLog(
11741
12417
  "info",
11742
- `debug-logs: uploaded ${path12.basename(logPath)} to project ${ctx.config.projectSlug} (${ctx.config.projectId}) as contribution ${contributionId}`
12418
+ `debug-logs: uploaded ${path13.basename(logPath)} to project ${ctx.config.projectSlug} (${ctx.config.projectId}) as contribution ${contributionId}`
11743
12419
  );
11744
- return contributionId;
12420
+ return { kind: "uploaded", contributionId };
11745
12421
  } catch (err) {
12422
+ if (attemptContributionId && err instanceof PlatformError && (err.status === 404 && err.code === "CONTRIBUTION_NOT_FOUND" || err.status === 409 && err.code === "CONTRIBUTION_UPLOAD_NOT_UPLOADED")) {
12423
+ appendLog(
12424
+ "warn",
12425
+ `debug-logs: contribution ${attemptContributionId} abandoned after ${err.code}; the next hook event will create a fresh contribution`
12426
+ );
12427
+ return { kind: "abandon-session" };
12428
+ }
11746
12429
  if (err instanceof PlatformError && err.status === 404) {
11747
12430
  appendLog(
11748
12431
  "warn",
11749
12432
  "debug-logs: upload skipped; platform does not have the debug-logs contribution type yet"
11750
12433
  );
11751
- return null;
12434
+ return { kind: "skipped" };
11752
12435
  }
11753
12436
  appendLog(
11754
12437
  "error",
11755
12438
  `debug-logs: upload failed: ${err instanceof Error ? err.message : String(err)}`
11756
12439
  );
11757
- return null;
12440
+ return { kind: "skipped" };
11758
12441
  }
11759
12442
  }
11760
12443
  async function uploadOnce(ctx, state) {
11761
12444
  await acquireLock(ctx.eventId);
11762
12445
  try {
11763
12446
  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
- });
12447
+ if (latest.uploadedAt || latest.sessionMappingAbandonedAt) return;
12448
+ const key = sessionKey(ctx);
12449
+ if (!key) {
12450
+ const result = await uploadDebugLog(ctx, latest, null);
12451
+ if (result.kind !== "uploaded") return;
12452
+ await writeState({
12453
+ ...latest,
12454
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString(),
12455
+ uploadContributionId: result.contributionId
12456
+ });
12457
+ return;
12458
+ }
12459
+ const sessionLockId = sessionStateId(key);
12460
+ await acquireLock(sessionLockId);
12461
+ try {
12462
+ const currentSession = await readSessionState(key);
12463
+ const result = await uploadDebugLog(
12464
+ ctx,
12465
+ latest,
12466
+ currentSession,
12467
+ async (contributionId) => {
12468
+ await writeSessionState({
12469
+ schemaVersion: CURRENT_SCHEMA_VERSION,
12470
+ sessionKey: key,
12471
+ contributionId,
12472
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
12473
+ });
12474
+ }
12475
+ );
12476
+ if (result.kind === "abandon-session") {
12477
+ await deleteSessionState(key);
12478
+ await writeState({
12479
+ ...latest,
12480
+ sessionMappingAbandonedAt: (/* @__PURE__ */ new Date()).toISOString()
12481
+ });
12482
+ return;
12483
+ }
12484
+ if (result.kind !== "uploaded") return;
12485
+ const uploadedAt = (/* @__PURE__ */ new Date()).toISOString();
12486
+ const persisted = await readSessionState(key);
12487
+ if (persisted?.contributionId === result.contributionId) {
12488
+ await writeSessionState({
12489
+ ...persisted,
12490
+ firstUploadSubmittedAt: persisted.firstUploadSubmittedAt ?? uploadedAt,
12491
+ lastUploadAt: uploadedAt
12492
+ });
12493
+ }
12494
+ await writeState({
12495
+ ...latest,
12496
+ uploadedAt,
12497
+ uploadContributionId: result.contributionId
12498
+ });
12499
+ } finally {
12500
+ await releaseLock(sessionLockId);
12501
+ }
11773
12502
  } finally {
11774
12503
  await releaseLock(ctx.eventId);
11775
12504
  }
@@ -11823,7 +12552,8 @@ function buildEpochMeta(params) {
11823
12552
  transcriptArchivePath: params.transcriptArchivePath,
11824
12553
  rawByteOffset: params.cursor.rawByteOffset,
11825
12554
  rawPrefixSha256: params.cursor.rawPrefixSha256,
11826
- recordedAt: new Date(params.recordedAt).toISOString()
12555
+ recordedAt: new Date(params.recordedAt).toISOString(),
12556
+ ...params.lineage
11827
12557
  };
11828
12558
  return Buffer.from(JSON.stringify(meta, null, 2));
11829
12559
  }
@@ -11852,16 +12582,16 @@ async function uploadArtifact(client, contributionId, filename, mimeType, buffer
11852
12582
  }
11853
12583
 
11854
12584
  // src/transcript-cursor.ts
11855
- import crypto2 from "crypto";
11856
- import fs11 from "fs";
12585
+ import crypto3 from "crypto";
12586
+ import fs13 from "fs";
11857
12587
  function sha256OfBuffer(buffer) {
11858
- return crypto2.createHash("sha256").update(buffer).digest("hex");
12588
+ return crypto3.createHash("sha256").update(buffer).digest("hex");
11859
12589
  }
11860
12590
  async function hashPrefix(filePath, byteLength) {
11861
- const hash = crypto2.createHash("sha256");
12591
+ const hash = crypto3.createHash("sha256");
11862
12592
  if (byteLength === 0) return hash;
11863
12593
  await new Promise((resolve, reject) => {
11864
- const stream = fs11.createReadStream(filePath, {
12594
+ const stream = fs13.createReadStream(filePath, {
11865
12595
  start: 0,
11866
12596
  end: byteLength - 1
11867
12597
  });
@@ -11872,7 +12602,7 @@ async function hashPrefix(filePath, byteLength) {
11872
12602
  return hash;
11873
12603
  }
11874
12604
  async function readRange(filePath, start, end) {
11875
- const fd = await fs11.promises.open(filePath, "r");
12605
+ const fd = await fs13.promises.open(filePath, "r");
11876
12606
  try {
11877
12607
  const buffer = Buffer.alloc(end - start);
11878
12608
  let filled = 0;
@@ -11892,7 +12622,7 @@ async function readRange(filePath, start, end) {
11892
12622
  }
11893
12623
  }
11894
12624
  async function evaluateTranscript(filePath, cursor) {
11895
- const stat = await fs11.promises.stat(filePath);
12625
+ const stat = await fs13.promises.stat(filePath);
11896
12626
  if (stat.size < cursor.rawByteOffset) return { kind: "truncate" };
11897
12627
  const prefixHash = await hashPrefix(filePath, cursor.rawByteOffset);
11898
12628
  const continuation = prefixHash.copy();
@@ -11927,12 +12657,12 @@ function truncateAtLastNewline(buffer) {
11927
12657
  }
11928
12658
  async function cursorMatchesFile(filePath, cursor) {
11929
12659
  try {
11930
- const stat = await fs11.promises.stat(filePath);
12660
+ const stat = await fs13.promises.stat(filePath);
11931
12661
  if (stat.size < cursor.rawByteOffset) return false;
11932
12662
  const prefixHash = await hashPrefix(filePath, cursor.rawByteOffset);
11933
12663
  if (prefixHash.digest("hex") !== cursor.rawPrefixSha256) return false;
11934
12664
  if (cursor.rawByteOffset === 0) return true;
11935
- const fd = await fs11.promises.open(filePath, "r");
12665
+ const fd = await fs13.promises.open(filePath, "r");
11936
12666
  try {
11937
12667
  const byte = Buffer.alloc(1);
11938
12668
  const { bytesRead } = await fd.read(byte, 0, 1, cursor.rawByteOffset - 1);
@@ -11946,20 +12676,20 @@ async function cursorMatchesFile(filePath, cursor) {
11946
12676
  }
11947
12677
 
11948
12678
  // src/upload-state.ts
11949
- import crypto3 from "crypto";
11950
- import fs12 from "fs";
11951
- import os5 from "os";
11952
- import path13 from "path";
12679
+ import crypto4 from "crypto";
12680
+ import fs14 from "fs";
12681
+ import os6 from "os";
12682
+ import path14 from "path";
11953
12683
  var CURRENT_SCHEMA_VERSION2 = 1;
11954
- var DEFAULT_STATE_DIR = path13.join(
11955
- os5.homedir(),
12684
+ var DEFAULT_STATE_DIR = path14.join(
12685
+ os6.homedir(),
11956
12686
  ".hillclimb",
11957
12687
  "agent-uploads"
11958
12688
  );
11959
- var DEFAULT_LOCK_WAIT_MS = 5 * 60 * 1e3;
12689
+ var DEFAULT_LOCK_WAIT_MS2 = 5 * 60 * 1e3;
11960
12690
  var DEFAULT_LOCK_RETRY_DELAY_MS = 500;
11961
12691
  var DEFAULT_STATE_TTL_MS = 24 * 60 * 60 * 1e3;
11962
- var STALE_LOCK_TTL_MS = 60 * 60 * 1e3;
12692
+ var STALE_LOCK_TTL_MS2 = 60 * 60 * 1e3;
11963
12693
  function stateDir2() {
11964
12694
  return process.env.HILLCLIMB_UPLOAD_STATE_DIR ?? DEFAULT_STATE_DIR;
11965
12695
  }
@@ -11988,20 +12718,20 @@ function lockRetries() {
11988
12718
  return Math.max(
11989
12719
  1,
11990
12720
  Math.ceil(
11991
- readPositiveEnvMs("HILLCLIMB_UPLOAD_LOCK_WAIT_MS", DEFAULT_LOCK_WAIT_MS) / lockRetryDelayMs()
12721
+ readPositiveEnvMs("HILLCLIMB_UPLOAD_LOCK_WAIT_MS", DEFAULT_LOCK_WAIT_MS2) / lockRetryDelayMs()
11992
12722
  )
11993
12723
  );
11994
12724
  }
11995
12725
  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`);
12726
+ const hash = crypto4.createHash("sha256").update(`${path14.resolve(repoRoot)}\0${tool}\0${sessionId}`).digest("hex").slice(0, 16);
12727
+ return path14.join(stateDir2(), `${hash}.json`);
11998
12728
  }
11999
12729
  function lockFileFor(repoRoot, tool, sessionId) {
12000
12730
  return `${stateFileFor(repoRoot, tool, sessionId)}.lock`;
12001
12731
  }
12002
12732
  async function readUploadState(repoRoot, tool, sessionId) {
12003
12733
  try {
12004
- const raw = await fs12.promises.readFile(
12734
+ const raw = await fs14.promises.readFile(
12005
12735
  stateFileFor(repoRoot, tool, sessionId),
12006
12736
  "utf-8"
12007
12737
  );
@@ -12014,20 +12744,20 @@ async function readUploadState(repoRoot, tool, sessionId) {
12014
12744
  }
12015
12745
  async function writeUploadState(state) {
12016
12746
  const file = stateFileFor(state.repoRoot, state.tool, state.sessionId);
12017
- await fs12.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
12747
+ await fs14.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
12018
12748
  const tmp = `${file}.tmp`;
12019
- await fs12.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
12749
+ await fs14.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
12020
12750
  mode: 384
12021
12751
  });
12022
- await fs12.promises.rename(tmp, file);
12752
+ await fs14.promises.rename(tmp, file);
12023
12753
  }
12024
12754
  async function deleteUploadState(repoRoot, tool, sessionId) {
12025
12755
  try {
12026
- await fs12.promises.unlink(stateFileFor(repoRoot, tool, sessionId));
12756
+ await fs14.promises.unlink(stateFileFor(repoRoot, tool, sessionId));
12027
12757
  } catch {
12028
12758
  }
12029
12759
  try {
12030
- await fs12.promises.unlink(cursorFileFor(repoRoot, tool, sessionId));
12760
+ await fs14.promises.unlink(cursorFileFor(repoRoot, tool, sessionId));
12031
12761
  } catch {
12032
12762
  }
12033
12763
  }
@@ -12036,7 +12766,7 @@ function cursorFileFor(repoRoot, tool, sessionId) {
12036
12766
  }
12037
12767
  async function readCursorState(repoRoot, tool, sessionId) {
12038
12768
  try {
12039
- const raw = await fs12.promises.readFile(
12769
+ const raw = await fs14.promises.readFile(
12040
12770
  cursorFileFor(repoRoot, tool, sessionId),
12041
12771
  "utf-8"
12042
12772
  );
@@ -12051,21 +12781,21 @@ async function readCursorState(repoRoot, tool, sessionId) {
12051
12781
  }
12052
12782
  async function writeCursorState(repoRoot, tool, sessionId, cursor) {
12053
12783
  const file = cursorFileFor(repoRoot, tool, sessionId);
12054
- await fs12.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
12784
+ await fs14.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
12055
12785
  const tmp = `${file}.tmp`;
12056
- await fs12.promises.writeFile(tmp, JSON.stringify(cursor, null, 2), {
12786
+ await fs14.promises.writeFile(tmp, JSON.stringify(cursor, null, 2), {
12057
12787
  mode: 384
12058
12788
  });
12059
- await fs12.promises.rename(tmp, file);
12789
+ await fs14.promises.rename(tmp, file);
12060
12790
  }
12061
12791
  async function acquireLock2(repoRoot, tool, sessionId, retries = lockRetries(), delayMs = lockRetryDelayMs()) {
12062
12792
  const lockPath = lockFileFor(repoRoot, tool, sessionId);
12063
- await fs12.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
12793
+ await fs14.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
12064
12794
  for (let i = 0; i < retries; i++) {
12065
12795
  try {
12066
- const fd = await fs12.promises.open(
12796
+ const fd = await fs14.promises.open(
12067
12797
  lockPath,
12068
- fs12.constants.O_CREAT | fs12.constants.O_EXCL | fs12.constants.O_WRONLY
12798
+ fs14.constants.O_CREAT | fs14.constants.O_EXCL | fs14.constants.O_WRONLY
12069
12799
  );
12070
12800
  try {
12071
12801
  await fd.write(String(process.pid));
@@ -12075,6 +12805,11 @@ async function acquireLock2(repoRoot, tool, sessionId, retries = lockRetries(),
12075
12805
  return;
12076
12806
  } catch (err) {
12077
12807
  if (err.code === "EEXIST" && i < retries - 1) {
12808
+ if (await reapLockIfStale(lockPath, {
12809
+ maxAgeMs: STALE_LOCK_TTL_MS2
12810
+ })) {
12811
+ continue;
12812
+ }
12078
12813
  await new Promise((r) => setTimeout(r, delayMs));
12079
12814
  continue;
12080
12815
  }
@@ -12088,7 +12823,7 @@ async function acquireLock2(repoRoot, tool, sessionId, retries = lockRetries(),
12088
12823
  }
12089
12824
  async function releaseLock2(repoRoot, tool, sessionId) {
12090
12825
  try {
12091
- await fs12.promises.unlink(lockFileFor(repoRoot, tool, sessionId));
12826
+ await fs14.promises.unlink(lockFileFor(repoRoot, tool, sessionId));
12092
12827
  } catch {
12093
12828
  }
12094
12829
  }
@@ -12100,43 +12835,27 @@ async function withUploadLock(repoRoot, tool, sessionId, fn) {
12100
12835
  await releaseLock2(repoRoot, tool, sessionId);
12101
12836
  }
12102
12837
  }
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
12838
  async function sweepStaleUploadStates(ttlMs = stateTtlMs(), now = Date.now()) {
12112
12839
  let entries;
12113
12840
  try {
12114
- entries = await fs12.promises.readdir(stateDir2(), { withFileTypes: true });
12841
+ entries = await fs14.promises.readdir(stateDir2(), { withFileTypes: true });
12115
12842
  } catch {
12116
12843
  return;
12117
12844
  }
12118
12845
  for (const entry of entries) {
12119
12846
  if (!entry.isFile()) continue;
12120
- const file = path13.join(stateDir2(), entry.name);
12847
+ const file = path14.join(stateDir2(), entry.name);
12121
12848
  try {
12122
12849
  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
- }
12850
+ await reapLockIfStale(file, {
12851
+ maxAgeMs: STALE_LOCK_TTL_MS2,
12852
+ now
12853
+ });
12135
12854
  continue;
12136
12855
  }
12137
- const st = await fs12.promises.stat(file);
12856
+ const st = await fs14.promises.stat(file);
12138
12857
  if (now - st.mtimeMs > ttlMs) {
12139
- await fs12.promises.unlink(file);
12858
+ await fs14.promises.unlink(file);
12140
12859
  }
12141
12860
  } catch {
12142
12861
  }
@@ -12156,13 +12875,13 @@ function formatEpochSeconds2(date) {
12156
12875
  return String(Math.floor(date.getTime() / 1e3));
12157
12876
  }
12158
12877
  function newFlowId() {
12159
- return crypto4.randomBytes(3).toString("hex");
12878
+ return crypto5.randomBytes(3).toString("hex");
12160
12879
  }
12161
12880
  function lineHasAssistant(line) {
12162
12881
  return line.includes('"type":"assistant"') || line.includes('"role":"assistant"') || line.includes('"type":"assistant.');
12163
12882
  }
12164
12883
  async function hasAssistantMessage(transcriptPath) {
12165
- const stream = fs13.createReadStream(transcriptPath, { encoding: "utf-8" });
12884
+ const stream = fs15.createReadStream(transcriptPath, { encoding: "utf-8" });
12166
12885
  let buffer = "";
12167
12886
  try {
12168
12887
  for await (const chunk of stream) {
@@ -12223,53 +12942,8 @@ function summarizePayload(payload) {
12223
12942
  cursor_version_present: !!payload.cursor_version
12224
12943
  });
12225
12944
  }
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
12945
  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;
12946
+ return findCodexRolloutPath(sessionId);
12273
12947
  }
12274
12948
  async function selfHealHook(repoRoot, tool) {
12275
12949
  if (process.env.HILLCLIMB_SKIP_HOOK_SELF_HEAL === "1") return;
@@ -12294,8 +12968,8 @@ function resolveCursorTranscriptPath(payload) {
12294
12968
  const workspace = payload.workspace_roots?.[0];
12295
12969
  if (!id || !workspace) return void 0;
12296
12970
  const encoded = workspace.replace(/^\//, "").replace(/\//g, "-");
12297
- return path14.join(
12298
- os6.homedir(),
12971
+ return path15.join(
12972
+ os7.homedir(),
12299
12973
  ".cursor",
12300
12974
  "projects",
12301
12975
  encoded,
@@ -12322,7 +12996,7 @@ async function resolveTranscriptPath(payload, sourceTool, sessionId) {
12322
12996
  async function runUploadInner(payload) {
12323
12997
  const sessionId = resolveHookSessionId(payload);
12324
12998
  const cwd = resolveHookCwd(payload);
12325
- const eventKind = classifyHookEvent(payload.hook_event_name);
12999
+ const eventKind2 = classifyHookEvent(payload.hook_event_name);
12326
13000
  const recordedAt = Date.now();
12327
13001
  if (!sessionId || !cwd) {
12328
13002
  appendLog(
@@ -12348,7 +13022,7 @@ async function runUploadInner(payload) {
12348
13022
  payload.tool = sourceTool;
12349
13023
  appendLog(
12350
13024
  "info",
12351
- `[${sessionId}] payload parsed (tool=${sourceTool}, cwd=${cwd}, event=${eventKind ?? "?"})`
13025
+ `[${sessionId}] payload parsed (tool=${sourceTool}, cwd=${cwd}, event=${eventKind2 ?? "?"})`
12352
13026
  );
12353
13027
  await selfHealHook(repoRoot, sourceTool);
12354
13028
  const transcriptPath = await resolveTranscriptPath(
@@ -12363,9 +13037,9 @@ async function runUploadInner(payload) {
12363
13037
  );
12364
13038
  return false;
12365
13039
  }
12366
- const transcriptResolved = path14.resolve(transcriptPath);
13040
+ const transcriptResolved = path15.resolve(transcriptPath);
12367
13041
  try {
12368
- const stat = await fs13.promises.stat(transcriptResolved);
13042
+ const stat = await fs15.promises.stat(transcriptResolved);
12369
13043
  if (!stat.isFile()) {
12370
13044
  appendLog(
12371
13045
  "warn",
@@ -12393,12 +13067,12 @@ async function runUploadInner(payload) {
12393
13067
  repoRoot,
12394
13068
  config,
12395
13069
  sourceTool,
12396
- eventKind,
13070
+ eventKind: eventKind2,
12397
13071
  recordedAt
12398
13072
  });
12399
13073
  }
12400
13074
  var AGENT_MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
12401
- var CLI_VERSION = "0.7.0";
13075
+ var CLI_VERSION = "0.8.0";
12402
13076
  function makeTranscriptGroup(repoRoot, sourceTool, transcriptPath, content) {
12403
13077
  const sourceFile = {
12404
13078
  sourceName: sourceTool,
@@ -12408,7 +13082,7 @@ function makeTranscriptGroup(repoRoot, sourceTool, transcriptPath, content) {
12408
13082
  };
12409
13083
  return {
12410
13084
  repoPath: repoRoot,
12411
- label: path14.basename(repoRoot),
13085
+ label: path15.basename(repoRoot),
12412
13086
  files: [sourceFile],
12413
13087
  sourceNames: [sourceTool],
12414
13088
  lastModified: /* @__PURE__ */ new Date()
@@ -12416,7 +13090,7 @@ function makeTranscriptGroup(repoRoot, sourceTool, transcriptPath, content) {
12416
13090
  }
12417
13091
  async function buildRedactChain(repoRoot) {
12418
13092
  const envFileNames = await discoverEnvFiles(repoRoot);
12419
- const envFilePaths = envFileNames.map((n) => path14.join(repoRoot, n));
13093
+ const envFilePaths = envFileNames.map((n) => path15.join(repoRoot, n));
12420
13094
  const secretResult = await collectSecrets(repoRoot, envFilePaths, []);
12421
13095
  const chain = [];
12422
13096
  if (secretResult.values.size > 0) {
@@ -12466,6 +13140,14 @@ async function handleUploadFailure(err, sessionId, repoRoot, sourceTool) {
12466
13140
  );
12467
13141
  return false;
12468
13142
  }
13143
+ if (isContributionUploadNotUploaded(err)) {
13144
+ await deleteUploadState(repoRoot, sourceTool, sessionId);
13145
+ appendLog(
13146
+ "warn",
13147
+ `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.`
13148
+ );
13149
+ return false;
13150
+ }
12469
13151
  appendLog(
12470
13152
  "error",
12471
13153
  `Session ${sessionId} upload failed: ${err instanceof Error ? err.message : String(err)}`
@@ -12479,13 +13161,24 @@ async function uploadSession(args) {
12479
13161
  repoRoot,
12480
13162
  config,
12481
13163
  sourceTool,
12482
- eventKind,
13164
+ eventKind: eventKind2,
12483
13165
  recordedAt
12484
13166
  } = args;
12485
- const isSessionEnd = eventKind === "sessionEnd";
13167
+ const isSessionEnd = eventKind2 === "sessionEnd";
12486
13168
  const eventLabel = isSessionEnd ? "SessionEnd" : "Stop";
12487
13169
  return await withUploadLock(repoRoot, sourceTool, sessionId, async () => {
12488
13170
  const prior = await readUploadState(repoRoot, sourceTool, sessionId);
13171
+ const lineageDetection = sourceTool === "codex" && prior?.codexLineageChecked !== true ? await detectCodexLineage(transcriptPath) : void 0;
13172
+ const codexLineage = sourceTool === "codex" ? mergeCodexLineage(prior?.codexLineage, lineageDetection?.lineage) : void 0;
13173
+ const codexLineageChecked = sourceTool === "codex" ? prior?.codexLineageChecked === true || lineageDetection?.conclusive === true : void 0;
13174
+ const lineageNeedsStamp = codexLineage !== void 0 && JSON.stringify(codexLineage) !== JSON.stringify(prior?.codexLineageStamped);
13175
+ if (prior && sourceTool === "codex" && (JSON.stringify(codexLineage) !== JSON.stringify(prior.codexLineage) || codexLineageChecked !== prior.codexLineageChecked)) {
13176
+ await writeUploadState({
13177
+ ...prior,
13178
+ codexLineage,
13179
+ codexLineageChecked
13180
+ });
13181
+ }
12489
13182
  let cursor = null;
12490
13183
  let adoptedLegacy = false;
12491
13184
  let restored = null;
@@ -12539,16 +13232,22 @@ async function uploadSession(args) {
12539
13232
  if (cursor) {
12540
13233
  const evaluation = await evaluateTranscript(transcriptPath, cursor);
12541
13234
  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);
13235
+ if (lineageNeedsStamp && prior?.contributionId && prior.epochMeta) {
13236
+ mode = "meta-refresh";
13237
+ } else if (lineageNeedsStamp) {
13238
+ mode = "snapshot";
13239
+ transition = "state-lost";
13240
+ } else {
13241
+ appendLog(
13242
+ "info",
13243
+ `[${sessionId}] skipping ${sourceTool} ${eventLabel} upload (no new complete transcript lines past offset ${cursor.rawByteOffset}${isSessionEnd ? "; local state cleared" : ""})`
13244
+ );
13245
+ if (isSessionEnd) {
13246
+ await deleteUploadState(repoRoot, sourceTool, sessionId);
13247
+ }
13248
+ return true;
12548
13249
  }
12549
- return true;
12550
- }
12551
- if (evaluation.kind === "append") {
13250
+ } else if (evaluation.kind === "append") {
12552
13251
  mode = "patch";
12553
13252
  tail = evaluation.tail;
12554
13253
  nextCursor = evaluation.nextCursor;
@@ -12580,6 +13279,51 @@ async function uploadSession(args) {
12580
13279
  });
12581
13280
  const alreadySubmitted = prior?.submitted ?? false;
12582
13281
  const submitThisUpload = config.autoSubmit && !alreadySubmitted;
13282
+ if (mode === "meta-refresh" && prior?.contributionId && prior.epochMeta && cursor) {
13283
+ const epoch2 = prior.epoch ?? restored?.epoch ?? 1;
13284
+ try {
13285
+ await uploadArtifact(
13286
+ client,
13287
+ prior.contributionId,
13288
+ metaFilename(epoch2, recordedAt),
13289
+ "application/json",
13290
+ buildEpochMeta({
13291
+ sessionId,
13292
+ tool: sourceTool,
13293
+ cliVersion: CLI_VERSION,
13294
+ epoch: epoch2,
13295
+ transitionKind: prior.epochMeta.transitionKind,
13296
+ baseline: prior.epochMeta.baseline,
13297
+ snapshotFilename: prior.epochMeta.snapshotFilename,
13298
+ transcriptArchivePath: prior.epochMeta.transcriptArchivePath,
13299
+ cursor: {
13300
+ rawByteOffset: prior.epochMeta.rawByteOffset,
13301
+ rawPrefixSha256: prior.epochMeta.rawPrefixSha256
13302
+ },
13303
+ recordedAt,
13304
+ lineage: codexLineage
13305
+ })
13306
+ );
13307
+ } catch (err) {
13308
+ return handleUploadFailure(err, sessionId, repoRoot, sourceTool);
13309
+ }
13310
+ await writeUploadState({
13311
+ ...prior,
13312
+ lastUploadedAt: now.toISOString(),
13313
+ uploadCount: prior.uploadCount + 1,
13314
+ codexLineage,
13315
+ codexLineageChecked,
13316
+ codexLineageStamped: codexLineage
13317
+ });
13318
+ appendLog(
13319
+ "info",
13320
+ `[${sessionId}] refreshed ${sourceTool} lineage meta for unchanged epoch=${epoch2}`
13321
+ );
13322
+ if (isSessionEnd) {
13323
+ await deleteUploadState(repoRoot, sourceTool, sessionId);
13324
+ }
13325
+ return true;
13326
+ }
12583
13327
  if (mode === "patch" && cursor && tail && nextCursor && prior?.contributionId) {
12584
13328
  const baseCursor = cursor;
12585
13329
  const contributionId2 = prior.contributionId;
@@ -12589,6 +13333,9 @@ async function uploadSession(args) {
12589
13333
  "info",
12590
13334
  `[${sessionId}] ${sourceTool} ${eventLabel} upload \u2192 patch epoch=${epoch2} turn=${turn} (${tail.length} raw tail bytes, contribution ${contributionId2})`
12591
13335
  );
13336
+ let metaUploaded2 = 0;
13337
+ let codexLineageStamped2 = prior.codexLineageStamped;
13338
+ let epochMeta2 = prior.epochMeta;
12592
13339
  try {
12593
13340
  const redactedTail = await redactTail(
12594
13341
  tail,
@@ -12598,6 +13345,13 @@ async function uploadSession(args) {
12598
13345
  redactChain
12599
13346
  );
12600
13347
  if (adoptedLegacy) {
13348
+ epochMeta2 = {
13349
+ transitionKind: "legacy-adopted",
13350
+ baseline: "legacy-latest-zip",
13351
+ transcriptArchivePath,
13352
+ rawByteOffset: baseCursor.rawByteOffset,
13353
+ rawPrefixSha256: baseCursor.rawPrefixSha256
13354
+ };
12601
13355
  await uploadArtifact(
12602
13356
  client,
12603
13357
  contributionId2,
@@ -12612,9 +13366,12 @@ async function uploadSession(args) {
12612
13366
  baseline: "legacy-latest-zip",
12613
13367
  transcriptArchivePath,
12614
13368
  cursor: baseCursor,
12615
- recordedAt
13369
+ recordedAt,
13370
+ lineage: codexLineage
12616
13371
  })
12617
13372
  );
13373
+ metaUploaded2 = 1;
13374
+ codexLineageStamped2 = codexLineage;
12618
13375
  }
12619
13376
  await uploadArtifact(
12620
13377
  client,
@@ -12636,7 +13393,7 @@ async function uploadSession(args) {
12636
13393
  projectId: config.projectId,
12637
13394
  contributionId: contributionId2,
12638
13395
  submitted: submitted2,
12639
- uploadCount: (prior.uploadCount ?? 0) + (adoptedLegacy ? 2 : 1),
13396
+ uploadCount: (prior.uploadCount ?? 0) + 1 + metaUploaded2,
12640
13397
  firstUploadedAt: prior.firstUploadedAt ?? now.toISOString(),
12641
13398
  lastUploadedAt: now.toISOString(),
12642
13399
  // Mirrors the cursor from here on: describes the raw bytes covered
@@ -12649,7 +13406,11 @@ async function uploadSession(args) {
12649
13406
  turnCount: turn,
12650
13407
  rawByteOffset: nextCursor.rawByteOffset,
12651
13408
  rawPrefixSha256: nextCursor.rawPrefixSha256,
12652
- snapshotUploaded: true
13409
+ snapshotUploaded: true,
13410
+ codexLineage,
13411
+ codexLineageChecked,
13412
+ codexLineageStamped: codexLineageStamped2,
13413
+ epochMeta: epochMeta2
12653
13414
  });
12654
13415
  await writeCursorState(repoRoot, sourceTool, sessionId, {
12655
13416
  contributionId: contributionId2,
@@ -12661,6 +13422,45 @@ async function uploadSession(args) {
12661
13422
  });
12662
13423
  };
12663
13424
  await persistState(alreadySubmitted);
13425
+ if (!adoptedLegacy && codexLineage && epochMeta2 && JSON.stringify(codexLineage) !== JSON.stringify(prior.codexLineageStamped)) {
13426
+ try {
13427
+ await uploadArtifact(
13428
+ client,
13429
+ contributionId2,
13430
+ metaFilename(epoch2, recordedAt),
13431
+ "application/json",
13432
+ buildEpochMeta({
13433
+ sessionId,
13434
+ tool: sourceTool,
13435
+ cliVersion: CLI_VERSION,
13436
+ epoch: epoch2,
13437
+ transitionKind: epochMeta2.transitionKind,
13438
+ baseline: epochMeta2.baseline,
13439
+ snapshotFilename: epochMeta2.snapshotFilename,
13440
+ transcriptArchivePath: epochMeta2.transcriptArchivePath,
13441
+ cursor: {
13442
+ rawByteOffset: epochMeta2.rawByteOffset,
13443
+ rawPrefixSha256: epochMeta2.rawPrefixSha256
13444
+ },
13445
+ recordedAt,
13446
+ lineage: codexLineage
13447
+ })
13448
+ );
13449
+ metaUploaded2 = 1;
13450
+ codexLineageStamped2 = codexLineage;
13451
+ await persistState(alreadySubmitted);
13452
+ } catch (err) {
13453
+ appendLog(
13454
+ "warn",
13455
+ `[${sessionId}] lineage meta refresh failed after durable patch capture; lineage remains pending: ${err instanceof Error ? err.message : String(err)}`
13456
+ );
13457
+ }
13458
+ } else if (lineageNeedsStamp && !epochMeta2) {
13459
+ appendLog(
13460
+ "info",
13461
+ `[${sessionId}] lineage publication deferred after durable patch capture (pre-feature state has no reconstructable epoch metadata)`
13462
+ );
13463
+ }
12664
13464
  let submitted = alreadySubmitted;
12665
13465
  if (submitThisUpload) {
12666
13466
  try {
@@ -12675,6 +13475,8 @@ async function uploadSession(args) {
12675
13475
  "info",
12676
13476
  `contribution ${contributionId2} was already submitted (409); recording locally`
12677
13477
  );
13478
+ } else if (isContributionUploadNotUploaded(err)) {
13479
+ return handleUploadFailure(err, sessionId, repoRoot, sourceTool);
12678
13480
  } else {
12679
13481
  appendLog(
12680
13482
  "warn",
@@ -12699,7 +13501,7 @@ async function uploadSession(args) {
12699
13501
  }
12700
13502
  let raw;
12701
13503
  try {
12702
- raw = await fs13.promises.readFile(transcriptPath);
13504
+ raw = await fs15.promises.readFile(transcriptPath);
12703
13505
  } catch (err) {
12704
13506
  if (err.code === "ERR_FS_FILE_TOO_LARGE") {
12705
13507
  appendLog(
@@ -12740,6 +13542,17 @@ Tool: ${toolLabel2}
12740
13542
  Repo: ${repoRoot}
12741
13543
  Uploaded: ${now.toISOString()}`;
12742
13544
  const zipFilename = snapshotFilename(epoch, recordedAt);
13545
+ const epochMeta = {
13546
+ transitionKind: transition,
13547
+ baseline: "snapshot",
13548
+ snapshotFilename: zipFilename,
13549
+ transcriptArchivePath: archivePathFor({
13550
+ sourceName: sourceTool,
13551
+ absolutePath: transcriptPath
13552
+ }),
13553
+ rawByteOffset: truncated.cursor.rawByteOffset,
13554
+ rawPrefixSha256: truncated.cursor.rawPrefixSha256
13555
+ };
12743
13556
  const group = makeTranscriptGroup(
12744
13557
  repoRoot,
12745
13558
  sourceTool,
@@ -12771,7 +13584,10 @@ Uploaded: ${now.toISOString()}`;
12771
13584
  firstUploadedAt: prior?.firstUploadedAt ?? now.toISOString(),
12772
13585
  lastUploadedAt: prior?.lastUploadedAt ?? now.toISOString(),
12773
13586
  lastTranscriptSize: prior?.lastTranscriptSize,
12774
- lastTranscriptSha256: prior?.lastTranscriptSha256
13587
+ lastTranscriptSha256: prior?.lastTranscriptSha256,
13588
+ codexLineage,
13589
+ codexLineageChecked,
13590
+ epochMeta
12775
13591
  })
12776
13592
  });
12777
13593
  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 +13604,7 @@ Uploaded: ${now.toISOString()}`;
12788
13604
  return handleUploadFailure(err, sessionId, repoRoot, sourceTool);
12789
13605
  }
12790
13606
  let metaUploaded = 0;
13607
+ let codexLineageStamped;
12791
13608
  try {
12792
13609
  await uploadArtifact(
12793
13610
  client,
@@ -12804,10 +13621,12 @@ Uploaded: ${now.toISOString()}`;
12804
13621
  snapshotFilename: zipFilename,
12805
13622
  transcriptArchivePath,
12806
13623
  cursor: truncated.cursor,
12807
- recordedAt
13624
+ recordedAt,
13625
+ lineage: codexLineage
12808
13626
  })
12809
13627
  );
12810
13628
  metaUploaded = 1;
13629
+ codexLineageStamped = codexLineage;
12811
13630
  } catch (err) {
12812
13631
  appendLog(
12813
13632
  "warn",
@@ -12832,7 +13651,11 @@ Uploaded: ${now.toISOString()}`;
12832
13651
  turnCount: 0,
12833
13652
  rawByteOffset: truncated.cursor.rawByteOffset,
12834
13653
  rawPrefixSha256: truncated.cursor.rawPrefixSha256,
12835
- snapshotUploaded: true
13654
+ snapshotUploaded: true,
13655
+ codexLineage,
13656
+ codexLineageChecked,
13657
+ codexLineageStamped,
13658
+ epochMeta
12836
13659
  });
12837
13660
  await writeCursorState(repoRoot, sourceTool, sessionId, {
12838
13661
  contributionId,
@@ -12975,6 +13798,7 @@ async function runUploadWorker() {
12975
13798
  }
12976
13799
  try {
12977
13800
  await sweepStaleUploadStates();
13801
+ await sweepStaleDebugLogState();
12978
13802
  } catch {
12979
13803
  }
12980
13804
  }
@@ -12982,26 +13806,58 @@ async function runUploadWorker() {
12982
13806
 
12983
13807
  // src/git-traces/index.ts
12984
13808
  import { spawn as spawn3 } from "child_process";
12985
- import crypto6 from "crypto";
13809
+ import crypto7 from "crypto";
12986
13810
 
12987
13811
  // src/git-traces/handlers.ts
12988
13812
  import { execFileSync as execFileSync3 } from "child_process";
12989
- import path17 from "path";
13813
+ import path18 from "path";
12990
13814
 
12991
13815
  // src/git-traces/git-ops.ts
12992
13816
  import { execFileSync as execFileSync2, spawnSync } from "child_process";
12993
- import fs14 from "fs";
12994
- import os7 from "os";
12995
- import path15 from "path";
13817
+ import fs16 from "fs";
13818
+ import os8 from "os";
13819
+ import path16 from "path";
12996
13820
  import { gzipSync as gzipSync2 } from "zlib";
12997
13821
  var GIT_COMMAND_TIMEOUT_MS = 12e4;
12998
13822
  var EXEC_OPTS = {
12999
13823
  timeout: GIT_COMMAND_TIMEOUT_MS,
13000
- maxBuffer: 50 * 1024 * 1024
13824
+ // A rewrite diff for a text file capped at ≤100 MB must fit. This transient
13825
+ // allocation only grows as large as the actual command output.
13826
+ maxBuffer: 256 * 1024 * 1024
13001
13827
  };
13002
13828
  var MAX_ERROR_OUTPUT_CHARS = 2e3;
13003
- var MAX_SNAPSHOT_FILE_BYTES = 10 * 1024 * 1024;
13004
- var MAX_BINARY_SNAPSHOT_FILE_BYTES = 1024 * 1024;
13829
+ var DEFAULT_SNAPSHOT_LIMITS = {
13830
+ maxTrackedFileBytes: 25 * 1024 * 1024,
13831
+ // 25 MB
13832
+ maxUntrackedFileBytes: 10 * 1024 * 1024,
13833
+ // 10 MB
13834
+ maxBinaryFileBytes: 1024 * 1024
13835
+ // 1 MB
13836
+ };
13837
+ var LEGACY_SNAPSHOT_LIMITS = {
13838
+ maxTrackedFileBytes: 10 * 1024 * 1024,
13839
+ maxUntrackedFileBytes: 10 * 1024 * 1024,
13840
+ maxBinaryFileBytes: 1024 * 1024
13841
+ };
13842
+ function resolveSnapshotLimits(overrides) {
13843
+ return {
13844
+ maxTrackedFileBytes: positiveIntOrDefault(
13845
+ overrides?.maxTrackedFileBytes,
13846
+ DEFAULT_SNAPSHOT_LIMITS.maxTrackedFileBytes
13847
+ ),
13848
+ maxUntrackedFileBytes: positiveIntOrDefault(
13849
+ overrides?.maxUntrackedFileBytes,
13850
+ DEFAULT_SNAPSHOT_LIMITS.maxUntrackedFileBytes
13851
+ ),
13852
+ maxBinaryFileBytes: positiveIntOrDefault(
13853
+ overrides?.maxBinaryFileBytes,
13854
+ DEFAULT_SNAPSHOT_LIMITS.maxBinaryFileBytes
13855
+ )
13856
+ };
13857
+ }
13858
+ function positiveIntOrDefault(value, fallback) {
13859
+ return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= MAX_SNAPSHOT_LIMIT_OVERRIDE_BYTES ? value : fallback;
13860
+ }
13005
13861
  var BINARY_SNIFF_BYTES = 8e3;
13006
13862
  var EXCLUDED_SNAPSHOT_EXTENSIONS = /* @__PURE__ */ new Set([
13007
13863
  // Documents
@@ -13098,8 +13954,8 @@ var EXCLUDED_SNAPSHOT_BASENAMES = /* @__PURE__ */ new Set([
13098
13954
  ]);
13099
13955
  var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
13100
13956
  function isExcludedSnapshotPath(filePath) {
13101
- if (EXCLUDED_SNAPSHOT_BASENAMES.has(path15.basename(filePath))) return true;
13102
- return EXCLUDED_SNAPSHOT_EXTENSIONS.has(path15.extname(filePath).toLowerCase());
13957
+ if (EXCLUDED_SNAPSHOT_BASENAMES.has(path16.basename(filePath))) return true;
13958
+ return EXCLUDED_SNAPSHOT_EXTENSIONS.has(path16.extname(filePath).toLowerCase());
13103
13959
  }
13104
13960
  function isBinaryBuffer(buffer) {
13105
13961
  return buffer.includes(0);
@@ -13119,25 +13975,26 @@ function readTreeBlobHead(repoRoot, sha) {
13119
13975
  function readWorkingFileHead(absPath) {
13120
13976
  let fd = null;
13121
13977
  try {
13122
- fd = fs14.openSync(absPath, "r");
13978
+ fd = fs16.openSync(absPath, "r");
13123
13979
  const buffer = Buffer.alloc(BINARY_SNIFF_BYTES);
13124
- const bytesRead = fs14.readSync(fd, buffer, 0, BINARY_SNIFF_BYTES, 0);
13980
+ const bytesRead = fs16.readSync(fd, buffer, 0, BINARY_SNIFF_BYTES, 0);
13125
13981
  return buffer.subarray(0, bytesRead);
13126
13982
  } catch {
13127
13983
  return null;
13128
13984
  } finally {
13129
13985
  if (fd !== null) {
13130
13986
  try {
13131
- fs14.closeSync(fd);
13987
+ fs16.closeSync(fd);
13132
13988
  } catch {
13133
13989
  }
13134
13990
  }
13135
13991
  }
13136
13992
  }
13137
- function classifyOmission(filePath, sizeBytes, readHead) {
13993
+ function classifyOmission(filePath, sizeBytes, readHead, limits, tracked) {
13994
+ const maxFileBytes = tracked ? limits.maxTrackedFileBytes : limits.maxUntrackedFileBytes;
13138
13995
  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) {
13996
+ if (sizeBytes > maxFileBytes) return "file-over-limit";
13997
+ if (sizeBytes > limits.maxBinaryFileBytes) {
13141
13998
  const head = readHead();
13142
13999
  if (!head) {
13143
14000
  appendLog(
@@ -13249,12 +14106,32 @@ function deleteRef(repoRoot, refName) {
13249
14106
  function captureSnapshotSha(repoRoot) {
13250
14107
  return captureWorkingCommitSha(repoRoot);
13251
14108
  }
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})`);
14109
+ function formatSize(bytes) {
14110
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
14111
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
14112
+ }
14113
+ function summarizeOmittedFiles(files) {
14114
+ const totalBytes = files.reduce((sum, file) => sum + file.sizeBytes, 0);
14115
+ const top = [...files].sort((a, b) => b.sizeBytes - a.sizeBytes).slice(0, 3).map((file) => `${file.path} (${formatSize(file.sizeBytes)})`).join(", ");
14116
+ const more = files.length > 3 ? ` +${files.length - 3} more` : "";
14117
+ const plural = files.length === 1 ? "" : "s";
14118
+ return `${files.length} file${plural} (${formatSize(totalBytes)}): ${top}${more}`;
14119
+ }
14120
+ function logOmittedSnapshotSummary(omittedFiles) {
14121
+ const tracked = omittedFiles.filter((file) => file.tracked);
14122
+ const untracked = omittedFiles.filter((file) => !file.tracked);
14123
+ if (tracked.length > 0) {
14124
+ appendLog(
14125
+ "warn",
14126
+ `git-traces: snapshot omitted tracked ${summarizeOmittedFiles(tracked)} \u2014 content excluded from the baseline bundle and all diffs; full list in baseline.json git.omittedFiles`
14127
+ );
14128
+ }
14129
+ if (untracked.length > 0) {
14130
+ appendLog(
14131
+ "info",
14132
+ `git-traces: snapshot skipped untracked ${summarizeOmittedFiles(untracked)}; full list in baseline.json git.omittedFiles`
14133
+ );
14134
+ }
13258
14135
  }
13259
14136
  function parseLsTreeLongZ(output) {
13260
14137
  const entries = [];
@@ -13272,26 +14149,24 @@ function parseLsTreeLongZ(output) {
13272
14149
  }
13273
14150
  return entries;
13274
14151
  }
13275
- function listOmittedTreeFiles(repoRoot, treeSha, options = {}) {
14152
+ function listOmittedTreeFiles(repoRoot, treeSha, limits) {
13276
14153
  const output = gitBuffer(repoRoot, ["ls-tree", "-r", "-l", "-z", treeSha]);
13277
14154
  const omitted = [];
13278
14155
  for (const entry of parseLsTreeLongZ(output)) {
13279
14156
  const reason = classifyOmission(
13280
14157
  entry.path,
13281
14158
  entry.sizeBytes,
13282
- () => readTreeBlobHead(repoRoot, entry.sha)
14159
+ () => readTreeBlobHead(repoRoot, entry.sha),
14160
+ limits,
14161
+ true
13283
14162
  );
13284
14163
  if (!reason) continue;
13285
- const file = {
14164
+ omitted.push({
13286
14165
  path: entry.path,
13287
14166
  sizeBytes: entry.sizeBytes,
13288
14167
  tracked: true,
13289
14168
  reason,
13290
14169
  gitObjectSha: entry.sha
13291
- };
13292
- omitted.push(file);
13293
- recordOmittedSnapshotFile(options.omittedFiles, file, {
13294
- log: options.log
13295
14170
  });
13296
14171
  }
13297
14172
  return omitted;
@@ -13304,10 +14179,15 @@ function removePathsFromIndex(repoRoot, env, paths) {
13304
14179
  });
13305
14180
  }
13306
14181
  function filterOmittedFilesFromTree(repoRoot, treeSha, options = {}) {
13307
- const omittedFiles = listOmittedTreeFiles(repoRoot, treeSha, options);
14182
+ const omittedFiles = listOmittedTreeFiles(
14183
+ repoRoot,
14184
+ treeSha,
14185
+ options.limits ?? DEFAULT_SNAPSHOT_LIMITS
14186
+ );
14187
+ options.omittedFiles?.push(...omittedFiles);
13308
14188
  if (omittedFiles.length === 0) return treeSha;
13309
- const tmpIndex = path15.join(
13310
- os7.tmpdir(),
14189
+ const tmpIndex = path16.join(
14190
+ os8.tmpdir(),
13311
14191
  `hillclimb-filter-${Date.now()}-${process.pid}`
13312
14192
  );
13313
14193
  const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
@@ -13321,12 +14201,13 @@ function filterOmittedFilesFromTree(repoRoot, treeSha, options = {}) {
13321
14201
  return gitWithEnv(repoRoot, ["write-tree"], env);
13322
14202
  } finally {
13323
14203
  try {
13324
- fs14.unlinkSync(tmpIndex);
14204
+ fs16.unlinkSync(tmpIndex);
13325
14205
  } catch {
13326
14206
  }
13327
14207
  }
13328
14208
  }
13329
- function buildUntrackedTree(repoRoot, omittedFiles) {
14209
+ function buildUntrackedTree(repoRoot, options = {}) {
14210
+ const limits = options.limits ?? DEFAULT_SNAPSHOT_LIMITS;
13330
14211
  const list = git(repoRoot, [
13331
14212
  "ls-files",
13332
14213
  "--others",
@@ -13338,15 +14219,17 @@ function buildUntrackedTree(repoRoot, omittedFiles) {
13338
14219
  for (const relPath of list.split("\0")) {
13339
14220
  if (!relPath) continue;
13340
14221
  try {
13341
- const absPath = path15.join(repoRoot, relPath);
13342
- const stat = fs14.lstatSync(absPath);
14222
+ const absPath = path16.join(repoRoot, relPath);
14223
+ const stat = fs16.lstatSync(absPath);
13343
14224
  const reason = classifyOmission(
13344
14225
  relPath,
13345
14226
  stat.size,
13346
- () => readWorkingFileHead(absPath)
14227
+ () => readWorkingFileHead(absPath),
14228
+ limits,
14229
+ false
13347
14230
  );
13348
14231
  if (reason) {
13349
- recordOmittedSnapshotFile(omittedFiles, {
14232
+ options.omittedFiles?.push({
13350
14233
  path: relPath,
13351
14234
  sizeBytes: stat.size,
13352
14235
  tracked: false,
@@ -13359,8 +14242,8 @@ function buildUntrackedTree(repoRoot, omittedFiles) {
13359
14242
  }
13360
14243
  }
13361
14244
  if (kept.length === 0) return null;
13362
- const tmpIndex = path15.join(
13363
- os7.tmpdir(),
14245
+ const tmpIndex = path16.join(
14246
+ os8.tmpdir(),
13364
14247
  `hillclimb-untracked-${Date.now()}-${process.pid}`
13365
14248
  );
13366
14249
  const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
@@ -13372,25 +14255,26 @@ function buildUntrackedTree(repoRoot, omittedFiles) {
13372
14255
  return gitWithEnv(repoRoot, ["write-tree"], env);
13373
14256
  } finally {
13374
14257
  try {
13375
- fs14.unlinkSync(tmpIndex);
14258
+ fs16.unlinkSync(tmpIndex);
13376
14259
  } catch {
13377
14260
  }
13378
14261
  }
13379
14262
  }
13380
14263
  function buildSnapshotTree(repoRoot, stashSha, options = {}) {
14264
+ const omittedFiles = options.omittedFiles ?? [];
14265
+ const limits = options.limits ?? DEFAULT_SNAPSHOT_LIMITS;
13381
14266
  const trackedTree = git(repoRoot, ["rev-parse", `${stashSha}^{tree}`]);
13382
14267
  const filteredTrackedTree = filterOmittedFilesFromTree(
13383
14268
  repoRoot,
13384
14269
  trackedTree,
13385
- {
13386
- omittedFiles: options.omittedFiles
13387
- }
14270
+ { omittedFiles, limits }
13388
14271
  );
13389
- const untrackedTree = buildUntrackedTree(repoRoot, options.omittedFiles);
14272
+ const untrackedTree = buildUntrackedTree(repoRoot, { omittedFiles, limits });
14273
+ if (options.log !== false) logOmittedSnapshotSummary(omittedFiles);
13390
14274
  if (!untrackedTree || untrackedTree === EMPTY_TREE_SHA)
13391
14275
  return filteredTrackedTree;
13392
- const tmpIndex = path15.join(
13393
- os7.tmpdir(),
14276
+ const tmpIndex = path16.join(
14277
+ os8.tmpdir(),
13394
14278
  `hillclimb-index-${Date.now()}-${process.pid}`
13395
14279
  );
13396
14280
  const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
@@ -13417,7 +14301,7 @@ function buildSnapshotTree(repoRoot, stashSha, options = {}) {
13417
14301
  return gitWithEnv(repoRoot, ["write-tree"], env);
13418
14302
  } finally {
13419
14303
  try {
13420
- fs14.unlinkSync(tmpIndex);
14304
+ fs16.unlinkSync(tmpIndex);
13421
14305
  } catch {
13422
14306
  }
13423
14307
  }
@@ -13431,8 +14315,8 @@ function createBundleFromTree(repoRoot, treeSha, sessionId, label) {
13431
14315
  ]);
13432
14316
  const orphanRef = `refs/hillclimb/bundle/${sessionId}`;
13433
14317
  pinRef(repoRoot, orphanRef, orphanCommit);
13434
- const tmpFile = path15.join(
13435
- os7.tmpdir(),
14318
+ const tmpFile = path16.join(
14319
+ os8.tmpdir(),
13436
14320
  // Include the pid (like the other temp files in this module) so concurrent
13437
14321
  // git-traces workers — e.g. two sessions, or a parent + subagent — don't
13438
14322
  // collide on the same `git bundle create` path and its `.lock`.
@@ -13440,26 +14324,24 @@ function createBundleFromTree(repoRoot, treeSha, sessionId, label) {
13440
14324
  );
13441
14325
  try {
13442
14326
  git(repoRoot, ["bundle", "create", tmpFile, orphanRef]);
13443
- return fs14.readFileSync(tmpFile);
14327
+ return fs16.readFileSync(tmpFile);
13444
14328
  } finally {
13445
14329
  try {
13446
- fs14.unlinkSync(tmpFile);
14330
+ fs16.unlinkSync(tmpFile);
13447
14331
  } catch {
13448
14332
  }
13449
14333
  deleteRef(repoRoot, orphanRef);
13450
14334
  }
13451
14335
  }
13452
- function createTreeDiffPatchGz(repoRoot, fromTreeSha, toTreeSha) {
14336
+ function createTreeDiffPatchGz(repoRoot, fromTreeSha, toTreeSha, options = {}) {
13453
14337
  if (fromTreeSha === toTreeSha) return null;
13454
14338
  const filteredFromTreeSha = filterOmittedFilesFromTree(
13455
14339
  repoRoot,
13456
14340
  fromTreeSha,
13457
- {
13458
- log: false
13459
- }
14341
+ { limits: options.limits }
13460
14342
  );
13461
14343
  const filteredToTreeSha = filterOmittedFilesFromTree(repoRoot, toTreeSha, {
13462
- log: false
14344
+ limits: options.limits
13463
14345
  });
13464
14346
  if (filteredFromTreeSha === filteredToTreeSha) return null;
13465
14347
  const diff = gitBuffer(repoRoot, [
@@ -13513,7 +14395,7 @@ function parseDirtyFilesFromStatus(status) {
13513
14395
  return pathPart;
13514
14396
  }).filter(Boolean);
13515
14397
  }
13516
- function buildBaselineMetadata(repoRoot, sessionId, tool, baselineSha, cliVersion, epoch, prevHeadSha, transitionKind, startedAt, omittedFiles = []) {
14398
+ function buildBaselineMetadata(repoRoot, sessionId, tool, baselineSha, cliVersion, epoch, prevHeadSha, transitionKind, startedAt, omittedFiles = [], lineage) {
13517
14399
  const headSha = safeGit(repoRoot, ["rev-parse", "HEAD"]) ?? "unknown";
13518
14400
  const branch = safeGit(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]) ?? null;
13519
14401
  const remoteUrl = safeGit(repoRoot, ["config", "--get", "remote.origin.url"]) ?? null;
@@ -13541,9 +14423,10 @@ function buildBaselineMetadata(repoRoot, sessionId, tool, baselineSha, cliVersio
13541
14423
  ...omittedFiles.length > 0 ? { omittedFiles } : {}
13542
14424
  },
13543
14425
  author: { name: authorName, email: authorEmail },
13544
- hostname: os7.hostname(),
14426
+ hostname: os8.hostname(),
13545
14427
  cliVersion,
13546
- commits
14428
+ commits,
14429
+ ...lineage
13547
14430
  };
13548
14431
  }
13549
14432
  function enumerateCommits(repoRoot, fromSha, toSha) {
@@ -13630,9 +14513,9 @@ function parseCommitFiles(repoRoot, sha) {
13630
14513
  oldPath
13631
14514
  });
13632
14515
  } else {
13633
- const path24 = parts[parts.length - 1];
13634
- indexByPath.set(path24, files.length);
13635
- files.push({ path: path24, status, additions: 0, deletions: 0 });
14516
+ const path25 = parts[parts.length - 1];
14517
+ indexByPath.set(path25, files.length);
14518
+ files.push({ path: path25, status, additions: 0, deletions: 0 });
13636
14519
  }
13637
14520
  }
13638
14521
  for (const line of numstat.split("\n")) {
@@ -13680,7 +14563,8 @@ function cleanupSessionRefs(repoRoot, sessionId) {
13680
14563
  for (const prefix of [
13681
14564
  `refs/hillclimb/baseline/${sessionId}`,
13682
14565
  `refs/hillclimb/turns/${sessionId}`,
13683
- `refs/hillclimb/bundle/${sessionId}`
14566
+ `refs/hillclimb/bundle/${sessionId}`,
14567
+ `refs/hillclimb/scoped/${sessionId}`
13684
14568
  ]) {
13685
14569
  deleteRef(repoRoot, prefix);
13686
14570
  try {
@@ -13698,30 +14582,37 @@ function cleanupSessionRefs(repoRoot, sessionId) {
13698
14582
  }
13699
14583
 
13700
14584
  // 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";
14585
+ import crypto6 from "crypto";
14586
+ import fs17 from "fs";
14587
+ import os9 from "os";
14588
+ import path17 from "path";
13705
14589
  var CURRENT_SCHEMA_VERSION3 = 3;
13706
- var DEFAULT_STATE_DIR2 = path16.join(os8.homedir(), ".hillclimb", "git-traces");
14590
+ var DEFAULT_STATE_DIR2 = path17.join(os9.homedir(), ".hillclimb", "git-traces");
13707
14591
  var LOCK_RETRIES2 = 120;
13708
14592
  var LOCK_RETRY_DELAY_MS2 = 500;
13709
- var STALE_LOCK_TTL_MS2 = 60 * 60 * 1e3;
14593
+ var STALE_LOCK_TTL_MS3 = 60 * 60 * 1e3;
14594
+ var LockContentionError = class extends Error {
14595
+ constructor(lockPath, options) {
14596
+ super(`Lock remains held: ${lockPath}`, options);
14597
+ this.lockPath = lockPath;
14598
+ this.name = "LockContentionError";
14599
+ }
14600
+ };
13710
14601
  function stateDir3() {
13711
14602
  return process.env.HILLCLIMB_GIT_TRACES_STATE_DIR ?? DEFAULT_STATE_DIR2;
13712
14603
  }
13713
14604
  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}`
14605
+ const hash = crypto6.createHash("sha256").update(
14606
+ sessionId ? `${path17.resolve(repoRoot)}\0${tool}\0${sessionId}` : `${path17.resolve(repoRoot)}\0${tool}`
13716
14607
  ).digest("hex").slice(0, 16);
13717
- return path16.join(stateDir3(), `${hash}.json`);
14608
+ return path17.join(stateDir3(), `${hash}.json`);
13718
14609
  }
13719
14610
  function lockFileForRepo(repoRoot, tool, sessionId) {
13720
14611
  return `${stateFileForRepo(repoRoot, tool, sessionId)}.lock`;
13721
14612
  }
13722
14613
  async function readStateFile(file) {
13723
14614
  try {
13724
- const raw = await fs15.promises.readFile(file, "utf-8");
14615
+ const raw = await fs17.promises.readFile(file, "utf-8");
13725
14616
  const parsed = JSON.parse(raw);
13726
14617
  if (parsed.schemaVersion !== CURRENT_SCHEMA_VERSION3) {
13727
14618
  return null;
@@ -13731,29 +14622,38 @@ async function readStateFile(file) {
13731
14622
  return null;
13732
14623
  }
13733
14624
  }
14625
+ async function readStoredStateFile(file) {
14626
+ const state = await readStateFile(file);
14627
+ if (!state) return null;
14628
+ try {
14629
+ return { state, mtimeMs: (await fs17.promises.stat(file)).mtimeMs };
14630
+ } catch {
14631
+ return null;
14632
+ }
14633
+ }
13734
14634
  async function listScopedSessionStates(repoRoot, tool) {
13735
14635
  let entries;
13736
14636
  try {
13737
- entries = await fs15.promises.readdir(stateDir3(), { withFileTypes: true });
14637
+ entries = await fs17.promises.readdir(stateDir3(), { withFileTypes: true });
13738
14638
  } catch {
13739
14639
  return [];
13740
14640
  }
13741
14641
  const states = [];
13742
14642
  for (const entry of entries) {
13743
14643
  if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
13744
- const file = path16.join(stateDir3(), entry.name);
14644
+ const file = path17.join(stateDir3(), entry.name);
13745
14645
  const state = await readStateFile(file);
13746
14646
  if (!state) continue;
13747
14647
  if (typeof state.repoRoot !== "string" || typeof state.sessionId !== "string") {
13748
14648
  continue;
13749
14649
  }
13750
- if (path16.resolve(state.repoRoot) !== path16.resolve(repoRoot)) continue;
13751
- if (path16.resolve(file) !== path16.resolve(stateFileForRepo(repoRoot, tool, state.sessionId))) {
14650
+ if (path17.resolve(state.repoRoot) !== path17.resolve(repoRoot)) continue;
14651
+ if (path17.resolve(file) !== path17.resolve(stateFileForRepo(repoRoot, tool, state.sessionId))) {
13752
14652
  continue;
13753
14653
  }
13754
14654
  let mtimeMs = 0;
13755
14655
  try {
13756
- mtimeMs = (await fs15.promises.stat(file)).mtimeMs;
14656
+ mtimeMs = (await fs17.promises.stat(file)).mtimeMs;
13757
14657
  } catch {
13758
14658
  continue;
13759
14659
  }
@@ -13764,26 +14664,26 @@ async function listScopedSessionStates(repoRoot, tool) {
13764
14664
  async function listSessionStatesForSession(tool, sessionId) {
13765
14665
  let entries;
13766
14666
  try {
13767
- entries = await fs15.promises.readdir(stateDir3(), { withFileTypes: true });
14667
+ entries = await fs17.promises.readdir(stateDir3(), { withFileTypes: true });
13768
14668
  } catch {
13769
14669
  return [];
13770
14670
  }
13771
14671
  const states = [];
13772
14672
  for (const entry of entries) {
13773
14673
  if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
13774
- const file = path16.join(stateDir3(), entry.name);
14674
+ const file = path17.join(stateDir3(), entry.name);
13775
14675
  const state = await readStateFile(file);
13776
14676
  if (!state) continue;
13777
14677
  if (typeof state.repoRoot !== "string" || typeof state.sessionId !== "string") {
13778
14678
  continue;
13779
14679
  }
13780
14680
  if (state.sessionId !== sessionId) continue;
13781
- if (path16.resolve(file) !== path16.resolve(stateFileForRepo(state.repoRoot, tool, sessionId))) {
14681
+ if (path17.resolve(file) !== path17.resolve(stateFileForRepo(state.repoRoot, tool, sessionId))) {
13782
14682
  continue;
13783
14683
  }
13784
14684
  let mtimeMs = 0;
13785
14685
  try {
13786
- mtimeMs = (await fs15.promises.stat(file)).mtimeMs;
14686
+ mtimeMs = (await fs17.promises.stat(file)).mtimeMs;
13787
14687
  } catch {
13788
14688
  continue;
13789
14689
  }
@@ -13791,7 +14691,7 @@ async function listSessionStatesForSession(tool, sessionId) {
13791
14691
  }
13792
14692
  return states;
13793
14693
  }
13794
- async function readSessionState(repoRoot, tool, sessionId) {
14694
+ async function readSessionState2(repoRoot, tool, sessionId) {
13795
14695
  if (sessionId) {
13796
14696
  const scoped = await readStateFile(
13797
14697
  stateFileForRepo(repoRoot, tool, sessionId)
@@ -13802,108 +14702,120 @@ async function readSessionState(repoRoot, tool, sessionId) {
13802
14702
  }
13803
14703
  return readStateFile(stateFileForRepo(repoRoot, tool));
13804
14704
  }
13805
- async function writeSessionState(state, tool) {
14705
+ async function readLegacySessionState(repoRoot, tool, sessionId) {
14706
+ const legacy = await readStateFile(stateFileForRepo(repoRoot, tool));
14707
+ return legacy?.sessionId === sessionId ? legacy : null;
14708
+ }
14709
+ async function readSessionStateCandidates(repoRoot, tool, sessionId) {
14710
+ const [scoped, legacyCandidate] = await Promise.all([
14711
+ readStoredStateFile(stateFileForRepo(repoRoot, tool, sessionId)),
14712
+ readStoredStateFile(stateFileForRepo(repoRoot, tool))
14713
+ ]);
14714
+ return {
14715
+ scoped,
14716
+ legacy: legacyCandidate?.state.sessionId === sessionId ? legacyCandidate : null
14717
+ };
14718
+ }
14719
+ async function writeSessionState2(state, tool) {
13806
14720
  const file = stateFileForRepo(state.repoRoot, tool, state.sessionId);
13807
- await fs15.promises.mkdir(stateDir3(), { recursive: true, mode: 448 });
14721
+ await writeStateFile(file, state);
14722
+ }
14723
+ async function writeLegacySessionState(state, tool) {
14724
+ await writeStateFile(stateFileForRepo(state.repoRoot, tool), state);
14725
+ }
14726
+ async function writeStateFile(file, state) {
14727
+ await fs17.promises.mkdir(stateDir3(), { recursive: true, mode: 448 });
13808
14728
  const tmp = `${file}.tmp`;
13809
- await fs15.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
14729
+ await fs17.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
13810
14730
  mode: 384
13811
14731
  });
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);
13817
- }
14732
+ await fs17.promises.rename(tmp, file);
13818
14733
  }
13819
14734
  async function deleteStateFile(file) {
13820
14735
  try {
13821
- await fs15.promises.unlink(file);
14736
+ await fs17.promises.unlink(file);
13822
14737
  } catch {
13823
14738
  }
13824
14739
  }
13825
- async function deleteSessionState(repoRoot, tool, sessionId) {
14740
+ async function deleteSessionState2(repoRoot, tool, sessionId) {
13826
14741
  if (sessionId) {
13827
14742
  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
14743
  return;
13834
14744
  }
13835
14745
  await deleteStateFile(stateFileForRepo(repoRoot, tool));
13836
14746
  }
14747
+ async function deleteLegacySessionState(repoRoot, tool, sessionId) {
14748
+ const legacyFile = stateFileForRepo(repoRoot, tool);
14749
+ const legacy = await readStateFile(legacyFile);
14750
+ if (legacy?.sessionId === sessionId) await deleteStateFile(legacyFile);
14751
+ }
13837
14752
  async function acquireLock3(repoRoot, tool, sessionId, retries = LOCK_RETRIES2, delayMs = LOCK_RETRY_DELAY_MS2) {
13838
14753
  const lockPath = lockFileForRepo(repoRoot, tool, sessionId);
13839
- await fs15.promises.mkdir(stateDir3(), { recursive: true, mode: 448 });
14754
+ await fs17.promises.mkdir(stateDir3(), { recursive: true, mode: 448 });
13840
14755
  for (let i = 0; i < retries; i++) {
13841
14756
  try {
13842
- const fd = await fs15.promises.open(
14757
+ const fd = await fs17.promises.open(
13843
14758
  lockPath,
13844
- fs15.constants.O_CREAT | fs15.constants.O_EXCL | fs15.constants.O_WRONLY
14759
+ fs17.constants.O_CREAT | fs17.constants.O_EXCL | fs17.constants.O_WRONLY
13845
14760
  );
13846
14761
  await fd.write(String(process.pid));
13847
14762
  await fd.close();
13848
14763
  return fd.fd;
13849
14764
  } catch (err) {
13850
- if (err.code === "EEXIST" && i < retries - 1) {
14765
+ if (err.code !== "EEXIST") throw err;
14766
+ let regularLockFile = false;
14767
+ try {
14768
+ regularLockFile = (await fs17.promises.lstat(lockPath)).isFile();
14769
+ } catch (statErr) {
14770
+ if (statErr.code === "ENOENT") {
14771
+ i--;
14772
+ continue;
14773
+ }
14774
+ }
14775
+ if (!regularLockFile) throw err;
14776
+ if (i < retries - 1) {
14777
+ if (await reapLockIfStale(lockPath, {
14778
+ maxAgeMs: STALE_LOCK_TTL_MS3
14779
+ })) {
14780
+ continue;
14781
+ }
13851
14782
  await new Promise((r) => setTimeout(r, delayMs));
13852
14783
  continue;
13853
14784
  }
13854
- throw err;
14785
+ throw new LockContentionError(lockPath, { cause: err });
13855
14786
  }
13856
14787
  }
13857
14788
  throw new Error(`Failed to acquire lock after ${retries} retries`);
13858
14789
  }
13859
14790
  async function releaseLock3(repoRoot, tool, sessionId) {
13860
14791
  try {
13861
- await fs15.promises.unlink(lockFileForRepo(repoRoot, tool, sessionId));
14792
+ await fs17.promises.unlink(lockFileForRepo(repoRoot, tool, sessionId));
13862
14793
  } catch {
13863
14794
  }
13864
14795
  }
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()) {
14796
+ async function sweepStaleLockFiles(ttlMs = STALE_LOCK_TTL_MS3, now = Date.now()) {
13874
14797
  let entries;
13875
14798
  try {
13876
- entries = await fs15.promises.readdir(stateDir3(), { withFileTypes: true });
14799
+ entries = await fs17.promises.readdir(stateDir3(), { withFileTypes: true });
13877
14800
  } catch {
13878
14801
  return 0;
13879
14802
  }
13880
14803
  let removed = 0;
13881
14804
  for (const entry of entries) {
13882
14805
  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 {
14806
+ const file = path17.join(stateDir3(), entry.name);
14807
+ if (await reapLockIfStale(file, {
14808
+ maxAgeMs: ttlMs,
14809
+ now
14810
+ })) {
14811
+ removed++;
13900
14812
  }
13901
14813
  }
13902
14814
  return removed;
13903
14815
  }
13904
14816
 
13905
14817
  // src/git-traces/handlers.ts
13906
- var CLI_VERSION2 = "0.7.0";
14818
+ var CLI_VERSION2 = "0.8.0";
13907
14819
  var GIT_TRACES_SLUG = "git-traces";
13908
14820
  var MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
13909
14821
  var STALE_SCOPED_STATE_TTL_MS = 24 * 60 * 60 * 1e3;
@@ -13920,12 +14832,12 @@ var TOOL_LABELS = {
13920
14832
  async function loadConfiguredRepos() {
13921
14833
  const file = await loadProjects();
13922
14834
  return Object.entries(file.projects).map(([repoRoot, config]) => ({
13923
- repoRoot: path17.resolve(repoRoot),
14835
+ repoRoot: path18.resolve(repoRoot),
13924
14836
  config
13925
14837
  })).sort((a, b) => a.repoRoot.localeCompare(b.repoRoot));
13926
14838
  }
13927
14839
  function repoLabel(repoRoot) {
13928
- return path17.basename(repoRoot) || repoRoot;
14840
+ return path18.basename(repoRoot) || repoRoot;
13929
14841
  }
13930
14842
  function resolveCwd2(payload) {
13931
14843
  return resolveHookCwd(payload);
@@ -13933,6 +14845,11 @@ function resolveCwd2(payload) {
13933
14845
  function resolveSessionId2(payload) {
13934
14846
  return resolveHookSessionId(payload);
13935
14847
  }
14848
+ async function detectCodexLineageForHook(payload, tool, sessionId) {
14849
+ if (tool !== "codex" || !sessionId) return { conclusive: false };
14850
+ const transcriptPath = payload.transcript_path ?? await findCodexRolloutPath(sessionId);
14851
+ return transcriptPath ? detectCodexLineage(transcriptPath) : { conclusive: false };
14852
+ }
13936
14853
  function epochPrefix2(epoch) {
13937
14854
  return `epoch-${String(epoch).padStart(3, "0")}`;
13938
14855
  }
@@ -13980,18 +14897,48 @@ async function uploadFile(client, contributionId, filename, mimeType, buffer) {
13980
14897
  );
13981
14898
  return true;
13982
14899
  }
14900
+ function omissionKey(file) {
14901
+ return `${file.tracked ? "tracked" : "untracked"}:${file.path}`;
14902
+ }
14903
+ function omissionKeys(files) {
14904
+ return [...new Set(files.map(omissionKey))].sort();
14905
+ }
14906
+ function omissionKeysFromMetadata(metadata) {
14907
+ const git2 = metadata.git;
14908
+ if (!git2 || typeof git2 !== "object") return [];
14909
+ const omitted = git2.omittedFiles;
14910
+ if (!Array.isArray(omitted)) return [];
14911
+ const keys = omitted.map(
14912
+ (file) => file && typeof file === "object" ? metadataOmissionKey(file) : void 0
14913
+ ).filter((key) => key !== void 0);
14914
+ return [...new Set(keys)].sort();
14915
+ }
14916
+ function metadataOmissionKey(file) {
14917
+ const { path: filePath, tracked } = file;
14918
+ if (typeof filePath !== "string") return void 0;
14919
+ return omissionKey({ path: filePath, tracked: tracked !== false });
14920
+ }
13983
14921
  function canUploadEpochBaselineArtifacts(epoch, artifacts) {
13984
14922
  const prefix = epochPrefix2(epoch);
13985
14923
  return canUploadFile(`${prefix}-baseline.bundle`, artifacts.bundleBuffer) && canUploadFile(`${prefix}-baseline.json`, artifacts.metadataBuffer);
13986
14924
  }
13987
- function pinEpochBaseline(repoRoot, sessionId, epoch) {
14925
+ function pinEpochBaseline(repoRoot, sessionId, epoch, pinCanonicalRefs = true) {
13988
14926
  const baselineSha = captureBaselineSha(repoRoot);
13989
14927
  const baselineRefPrefix = `refs/hillclimb/baseline/${sessionId}/${epochPrefix2(epoch)}`;
13990
- deleteRef(repoRoot, baselineRefPrefix);
13991
- pinRef(repoRoot, `${baselineRefPrefix}/tracked`, baselineSha);
14928
+ const scopedRefPrefix = `refs/hillclimb/scoped/${sessionId}/baseline/${epochPrefix2(epoch)}`;
14929
+ if (pinCanonicalRefs) {
14930
+ deleteRef(repoRoot, baselineRefPrefix);
14931
+ pinRef(repoRoot, `${baselineRefPrefix}/tracked`, baselineSha);
14932
+ }
14933
+ pinRef(repoRoot, `${scopedRefPrefix}/tracked`, baselineSha);
13992
14934
  return { baselineSha, headSha: captureHeadSha(repoRoot) };
13993
14935
  }
13994
- function pinFrozenBaselineTree(repoRoot, sessionId, epoch, baselineTreeSha) {
14936
+ function pinScopedTreeRef(repoRoot, refName, treeSha, message) {
14937
+ const commit = execGit(repoRoot, ["commit-tree", treeSha, "-m", message]);
14938
+ pinRef(repoRoot, refName, commit);
14939
+ return commit;
14940
+ }
14941
+ function pinFrozenBaselineTree(repoRoot, sessionId, epoch, baselineTreeSha, pinCanonicalRefs = true) {
13995
14942
  const prefix = epochPrefix2(epoch);
13996
14943
  const commit = execGit(repoRoot, [
13997
14944
  "commit-tree",
@@ -13999,12 +14946,31 @@ function pinFrozenBaselineTree(repoRoot, sessionId, epoch, baselineTreeSha) {
13999
14946
  "-m",
14000
14947
  `frozen baseline ${prefix} for session ${sessionId}`
14001
14948
  ]);
14949
+ if (pinCanonicalRefs) {
14950
+ pinRef(
14951
+ repoRoot,
14952
+ `refs/hillclimb/baseline/${sessionId}/${prefix}/tree`,
14953
+ commit
14954
+ );
14955
+ }
14002
14956
  pinRef(
14003
14957
  repoRoot,
14004
- `refs/hillclimb/baseline/${sessionId}/${prefix}/tree`,
14958
+ `refs/hillclimb/scoped/${sessionId}/baseline/${prefix}/tree`,
14005
14959
  commit
14006
14960
  );
14007
14961
  }
14962
+ function pinScopedTurnSnapshot(repoRoot, sessionId, epoch, turnCount, snapshotSha, treeSha) {
14963
+ const prefix = epochPrefix2(epoch);
14964
+ const turnLabel = turnSuffix2(turnCount);
14965
+ const refPrefix = `refs/hillclimb/scoped/${sessionId}/turns/${prefix}/${turnLabel}`;
14966
+ pinRef(repoRoot, `${refPrefix}/tracked`, snapshotSha);
14967
+ pinScopedTreeRef(
14968
+ repoRoot,
14969
+ `${refPrefix}/tree`,
14970
+ treeSha,
14971
+ `protected ${turnLabel} ${prefix} for session ${sessionId}`
14972
+ );
14973
+ }
14008
14974
  function freezeEpochBaseline(params) {
14009
14975
  const {
14010
14976
  repoRoot,
@@ -14013,18 +14979,23 @@ function freezeEpochBaseline(params) {
14013
14979
  epoch,
14014
14980
  prevHeadSha,
14015
14981
  transitionKind,
14016
- startedAt
14982
+ startedAt,
14983
+ limits,
14984
+ lineage,
14985
+ pinCanonicalRefs = true
14017
14986
  } = params;
14018
14987
  const prefix = epochPrefix2(epoch);
14019
14988
  try {
14020
14989
  const { baselineSha, headSha } = pinEpochBaseline(
14021
14990
  repoRoot,
14022
14991
  sessionId,
14023
- epoch
14992
+ epoch,
14993
+ pinCanonicalRefs
14024
14994
  );
14025
14995
  const omittedFiles = [];
14026
14996
  const baselineTreeSha = buildSnapshotTree(repoRoot, baselineSha, {
14027
- omittedFiles
14997
+ omittedFiles,
14998
+ limits
14028
14999
  });
14029
15000
  const baselineMetadata = buildBaselineMetadata(
14030
15001
  repoRoot,
@@ -14036,10 +15007,23 @@ function freezeEpochBaseline(params) {
14036
15007
  prevHeadSha,
14037
15008
  transitionKind,
14038
15009
  startedAt,
14039
- omittedFiles
15010
+ omittedFiles,
15011
+ lineage
15012
+ );
15013
+ pinFrozenBaselineTree(
15014
+ repoRoot,
15015
+ sessionId,
15016
+ epoch,
15017
+ baselineTreeSha,
15018
+ pinCanonicalRefs
14040
15019
  );
14041
- pinFrozenBaselineTree(repoRoot, sessionId, epoch, baselineTreeSha);
14042
- return { baselineSha, baselineTreeSha, baselineMetadata, headSha };
15020
+ return {
15021
+ baselineSha,
15022
+ baselineTreeSha,
15023
+ baselineMetadata,
15024
+ headSha,
15025
+ omittedFiles
15026
+ };
14043
15027
  } catch (err) {
14044
15028
  appendLog(
14045
15029
  "error",
@@ -14061,7 +15045,12 @@ function buildFrozenEpochBaselineArtifacts(params) {
14061
15045
  const metadataBuffer = Buffer.from(
14062
15046
  JSON.stringify(baselineMetadata, null, 2)
14063
15047
  );
14064
- return { baselineTreeSha, metadataBuffer, bundleBuffer };
15048
+ return {
15049
+ baselineTreeSha,
15050
+ metadataBuffer,
15051
+ bundleBuffer,
15052
+ omittedKeys: omissionKeysFromMetadata(baselineMetadata)
15053
+ };
14065
15054
  } catch (err) {
14066
15055
  appendLog(
14067
15056
  "error",
@@ -14079,13 +15068,16 @@ function buildEpochBaselineArtifacts(params) {
14079
15068
  baselineSha,
14080
15069
  prevHeadSha,
14081
15070
  transitionKind,
14082
- startedAt
15071
+ startedAt,
15072
+ limits,
15073
+ lineage
14083
15074
  } = params;
14084
15075
  const prefix = epochPrefix2(epoch);
14085
15076
  try {
14086
15077
  const omittedFiles = [];
14087
15078
  const baselineTreeSha = buildSnapshotTree(repoRoot, baselineSha, {
14088
- omittedFiles
15079
+ omittedFiles,
15080
+ limits
14089
15081
  });
14090
15082
  const metadata = buildBaselineMetadata(
14091
15083
  repoRoot,
@@ -14097,7 +15089,8 @@ function buildEpochBaselineArtifacts(params) {
14097
15089
  prevHeadSha,
14098
15090
  transitionKind,
14099
15091
  startedAt,
14100
- omittedFiles
15092
+ omittedFiles,
15093
+ lineage
14101
15094
  );
14102
15095
  return buildFrozenEpochBaselineArtifacts({
14103
15096
  repoRoot,
@@ -14135,7 +15128,16 @@ async function uploadEpochBaselineArtifacts(params) {
14135
15128
  );
14136
15129
  }
14137
15130
  async function createGitTracesContribution(params) {
14138
- const { client, config, repoRoot, state, tool, now, artifacts } = params;
15131
+ const {
15132
+ client,
15133
+ config,
15134
+ repoRoot,
15135
+ state,
15136
+ tool,
15137
+ persistState,
15138
+ now,
15139
+ artifacts
15140
+ } = params;
14139
15141
  const toolLabel2 = TOOL_LABELS[tool] ?? "Claude";
14140
15142
  const epochSeconds = formatEpochSeconds3(now);
14141
15143
  const shortId = state.sessionId.slice(0, 12);
@@ -14153,7 +15155,7 @@ Uploaded: ${now.toISOString()}`
14153
15155
  contributionId = contribution.id;
14154
15156
  state.contributionId = contributionId;
14155
15157
  state.baselineUploaded = false;
14156
- await writeSessionState(state, tool);
15158
+ await persistState(state);
14157
15159
  }
14158
15160
  const uploaded = await uploadEpochBaselineArtifacts({
14159
15161
  client,
@@ -14172,6 +15174,12 @@ Uploaded: ${now.toISOString()}`
14172
15174
  async function uploadEpochBaseline(params) {
14173
15175
  const artifacts = buildEpochBaselineArtifacts(params);
14174
15176
  if (!artifacts) return null;
15177
+ pinScopedTreeRef(
15178
+ params.repoRoot,
15179
+ `refs/hillclimb/scoped/${params.sessionId}/baseline/${epochPrefix2(params.epoch)}/tree`,
15180
+ artifacts.baselineTreeSha,
15181
+ `protected baseline ${epochPrefix2(params.epoch)} for session ${params.sessionId}`
15182
+ );
14175
15183
  if (!canUploadEpochBaselineArtifacts(params.epoch, artifacts)) return null;
14176
15184
  const uploaded = await uploadEpochBaselineArtifacts({
14177
15185
  client: params.client,
@@ -14180,7 +15188,10 @@ async function uploadEpochBaseline(params) {
14180
15188
  artifacts
14181
15189
  });
14182
15190
  if (!uploaded) return null;
14183
- return { baselineTreeSha: artifacts.baselineTreeSha };
15191
+ return {
15192
+ baselineTreeSha: artifacts.baselineTreeSha,
15193
+ omittedKeys: artifacts.omittedKeys
15194
+ };
14184
15195
  }
14185
15196
  async function openEpoch(params) {
14186
15197
  const { baselineSha, headSha } = pinEpochBaseline(
@@ -14190,10 +15201,28 @@ async function openEpoch(params) {
14190
15201
  );
14191
15202
  const uploaded = await uploadEpochBaseline({ ...params, baselineSha });
14192
15203
  if (!uploaded) return null;
14193
- return { baselineSha, baselineTreeSha: uploaded.baselineTreeSha, headSha };
15204
+ return {
15205
+ baselineSha,
15206
+ baselineTreeSha: uploaded.baselineTreeSha,
15207
+ headSha,
15208
+ omittedKeys: uploaded.omittedKeys
15209
+ };
15210
+ }
15211
+ function isPositiveInteger(value) {
15212
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
14194
15213
  }
14195
- async function initializeSession(repoRoot, tool, sessionId) {
15214
+ function sessionSnapshotLimits(state) {
15215
+ const limits = state.snapshotLimits;
15216
+ if (!limits || typeof limits !== "object") return LEGACY_SNAPSHOT_LIMITS;
15217
+ const candidate = limits;
15218
+ if (!isPositiveInteger(candidate.maxTrackedFileBytes) || !isPositiveInteger(candidate.maxUntrackedFileBytes) || !isPositiveInteger(candidate.maxBinaryFileBytes)) {
15219
+ return LEGACY_SNAPSHOT_LIMITS;
15220
+ }
15221
+ return limits;
15222
+ }
15223
+ async function initializeSession(repoRoot, tool, sessionId, limits, lineage, legacyBoundaryPending = false) {
14196
15224
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
15225
+ const effectiveLimits = limits ?? resolveSnapshotLimits(void 0);
14197
15226
  const frozen = freezeEpochBaseline({
14198
15227
  repoRoot,
14199
15228
  sessionId,
@@ -14201,7 +15230,10 @@ async function initializeSession(repoRoot, tool, sessionId) {
14201
15230
  epoch: 1,
14202
15231
  prevHeadSha: null,
14203
15232
  transitionKind: "initial",
14204
- startedAt
15233
+ startedAt,
15234
+ limits: effectiveLimits,
15235
+ lineage,
15236
+ pinCanonicalRefs: !legacyBoundaryPending
14205
15237
  });
14206
15238
  if (!frozen) return null;
14207
15239
  const state = {
@@ -14216,17 +15248,67 @@ async function initializeSession(repoRoot, tool, sessionId) {
14216
15248
  lastSnapshotTreeSha: frozen.baselineTreeSha,
14217
15249
  startedAt,
14218
15250
  epoch: 1,
15251
+ nextEpoch: 2,
14219
15252
  turnCount: 0,
14220
15253
  headSha: frozen.headSha,
14221
- repoRoot
15254
+ repoRoot,
15255
+ snapshotLimits: effectiveLimits,
15256
+ omittedKeys: omissionKeys(frozen.omittedFiles),
15257
+ codexLineage: lineage,
15258
+ codexLineageChecked: tool === "codex" ? false : void 0,
15259
+ legacyBoundaryPending: legacyBoundaryPending || void 0
14222
15260
  };
14223
- await writeSessionState(state, tool);
15261
+ await writeSessionState2(state, tool);
14224
15262
  appendLog(
14225
15263
  "info",
14226
15264
  `git-traces: session pending (repo=${repoRoot}, baseline=${frozen.baselineSha.slice(0, 8)}, awaiting first turn)`
14227
15265
  );
14228
15266
  return state;
14229
15267
  }
15268
+ function pinScopedStateRefs(repoRoot, state) {
15269
+ const prefix = epochPrefix2(state.epoch);
15270
+ const baselinePrefix = `refs/hillclimb/scoped/${state.sessionId}/baseline/${prefix}`;
15271
+ pinRef(repoRoot, `${baselinePrefix}/tracked`, state.baselineSha);
15272
+ if (state.baselineTreeSha) {
15273
+ pinScopedTreeRef(
15274
+ repoRoot,
15275
+ `${baselinePrefix}/tree`,
15276
+ state.baselineTreeSha,
15277
+ `protected baseline ${prefix} for session ${state.sessionId}`
15278
+ );
15279
+ }
15280
+ if (state.turnCount > 0 && state.lastSnapshotTreeSha) {
15281
+ pinScopedTurnSnapshot(
15282
+ repoRoot,
15283
+ state.sessionId,
15284
+ state.epoch,
15285
+ state.turnCount,
15286
+ state.lastSnapshotSha,
15287
+ state.lastSnapshotTreeSha
15288
+ );
15289
+ }
15290
+ }
15291
+ function pinCanonicalStateRefs(repoRoot, state) {
15292
+ pinScopedStateRefs(repoRoot, state);
15293
+ const prefix = epochPrefix2(state.epoch);
15294
+ const baselinePrefix = `refs/hillclimb/baseline/${state.sessionId}/${prefix}`;
15295
+ pinRef(repoRoot, `${baselinePrefix}/tracked`, state.baselineSha);
15296
+ if (state.baselineTreeSha) {
15297
+ pinScopedTreeRef(
15298
+ repoRoot,
15299
+ `${baselinePrefix}/tree`,
15300
+ state.baselineTreeSha,
15301
+ `canonical baseline ${prefix} for session ${state.sessionId}`
15302
+ );
15303
+ }
15304
+ if (state.turnCount > 0) {
15305
+ pinRef(
15306
+ repoRoot,
15307
+ `refs/hillclimb/turns/${state.sessionId}/${prefix}/${turnSuffix2(state.turnCount)}`,
15308
+ state.lastSnapshotSha
15309
+ );
15310
+ }
15311
+ }
14230
15312
  async function cleanupStaleScopedSessionStates(repoRoot, tool, currentSessionId, options = {}) {
14231
15313
  const nowMs = options.nowMs ?? Date.now();
14232
15314
  const ttlMs = options.ttlMs ?? STALE_SCOPED_STATE_TTL_MS;
@@ -14237,6 +15319,13 @@ async function cleanupStaleScopedSessionStates(repoRoot, tool, currentSessionId,
14237
15319
  const startedAtMs = Date.parse(state.startedAt);
14238
15320
  const ageBaseMs = Number.isNaN(startedAtMs) ? mtimeMs : startedAtMs;
14239
15321
  if (nowMs - ageBaseMs <= ttlMs) continue;
15322
+ if (await readLegacySessionState(repoRoot, tool, state.sessionId)) {
15323
+ appendLog(
15324
+ "info",
15325
+ `git-traces: preserving stale scoped session ${state.sessionId} while matching legacy state exists`
15326
+ );
15327
+ continue;
15328
+ }
14240
15329
  try {
14241
15330
  await acquireLock3(repoRoot, tool, state.sessionId, 1, 0);
14242
15331
  } catch {
@@ -14252,7 +15341,7 @@ async function cleanupStaleScopedSessionStates(repoRoot, tool, currentSessionId,
14252
15341
  `git-traces: cleaning up stale scoped session ${state.sessionId}`
14253
15342
  );
14254
15343
  cleanupSessionRefs(repoRoot, state.sessionId);
14255
- await deleteSessionState(repoRoot, tool, state.sessionId);
15344
+ await deleteSessionState2(repoRoot, tool, state.sessionId);
14256
15345
  removed++;
14257
15346
  } finally {
14258
15347
  await releaseLock3(repoRoot, tool, state.sessionId);
@@ -14260,7 +15349,26 @@ async function cleanupStaleScopedSessionStates(repoRoot, tool, currentSessionId,
14260
15349
  }
14261
15350
  return removed;
14262
15351
  }
14263
- async function processSessionStartRepo(repo, tool, sessionId) {
15352
+ async function acquireBoundaryLegacyLock(repoRoot, tool, timing) {
15353
+ for (; ; ) {
15354
+ try {
15355
+ if (timing) {
15356
+ await acquireLock3(repoRoot, tool, null, timing.retries, timing.delayMs);
15357
+ } else {
15358
+ await acquireLock3(repoRoot, tool, null);
15359
+ }
15360
+ return;
15361
+ } catch (err) {
15362
+ if (!(err instanceof LockContentionError)) throw err;
15363
+ appendLog(
15364
+ "info",
15365
+ `git-traces: detached lifecycle worker still waiting for legacy boundary lock (repo=${repoRoot}, tool=${tool})`
15366
+ );
15367
+ timing?.onChunkExhausted?.();
15368
+ }
15369
+ }
15370
+ }
15371
+ async function prepareSessionStartRepo(repo, tool, sessionId, lineage) {
14264
15372
  const { repoRoot } = repo;
14265
15373
  if (!isGitRepo(repoRoot)) {
14266
15374
  appendLog(
@@ -14269,34 +15377,71 @@ async function processSessionStartRepo(repo, tool, sessionId) {
14269
15377
  );
14270
15378
  return "skipped";
14271
15379
  }
14272
- let acquired = false;
15380
+ let scopedLockAcquired = false;
14273
15381
  try {
14274
15382
  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
- }
15383
+ scopedLockAcquired = true;
15384
+ const scoped = (await readSessionStateCandidates(repoRoot, tool, sessionId)).scoped?.state;
15385
+ if (scoped) {
15386
+ pinScopedStateRefs(repoRoot, scoped);
15387
+ return "initialized";
15388
+ }
15389
+ const state = await initializeSession(
15390
+ repoRoot,
15391
+ tool,
15392
+ sessionId,
15393
+ resolveSnapshotLimits(repo.config.snapshotLimits),
15394
+ lineage,
15395
+ true
15396
+ );
15397
+ return state ? "initialized" : "failed";
15398
+ } catch (err) {
15399
+ appendLog(
15400
+ "error",
15401
+ `git-traces: SessionStart failed for repo ${repoRoot}: ${formatError(err)}`
15402
+ );
15403
+ return "failed";
15404
+ } finally {
15405
+ if (scopedLockAcquired) await releaseLock3(repoRoot, tool, sessionId);
15406
+ }
15407
+ }
15408
+ function hasPreparedSessionCaptureProgress(state) {
15409
+ return state.contributionId !== null || state.baselineUploaded === true || state.epoch > 1 || state.turnCount > 0;
15410
+ }
15411
+ async function completeSessionStartRepo(repo, tool, sessionId, lineage, timing) {
15412
+ const { repoRoot } = repo;
15413
+ let legacyLockAcquired = false;
15414
+ let scopedLockAcquired = false;
15415
+ try {
15416
+ await acquireBoundaryLegacyLock(repoRoot, tool, timing);
15417
+ legacyLockAcquired = true;
15418
+ await acquireLock3(repoRoot, tool, sessionId);
15419
+ scopedLockAcquired = true;
15420
+ const candidates = await readSessionStateCandidates(
15421
+ repoRoot,
15422
+ tool,
15423
+ sessionId
15424
+ );
15425
+ if (!candidates.scoped) {
15426
+ appendLog(
15427
+ "info",
15428
+ `git-traces: SessionStart state already cleaned before legacy reconciliation (repo=${repoRoot}, session=${sessionId})`
15429
+ );
15430
+ return "skipped";
15431
+ }
15432
+ const reconciled = reconcileLockedSessionState(candidates);
15433
+ if (!reconciled.state) return "skipped";
15434
+ const lineageChanged = mergeLineageIntoState(reconciled.state, lineage);
15435
+ pinCanonicalStateRefs(repoRoot, reconciled.state);
15436
+ if (reconciled.needsWrite || lineageChanged) {
15437
+ await writeSessionState2(reconciled.state, tool);
15438
+ }
15439
+ if (candidates.legacy) {
15440
+ await deleteLegacySessionState(repoRoot, tool, sessionId);
15441
+ appendLog(
15442
+ "info",
15443
+ `git-traces: migrated legacy session on SessionStart (repo=${repoRoot}, session=${sessionId})`
15444
+ );
14300
15445
  }
14301
15446
  const staleCount = await cleanupStaleScopedSessionStates(
14302
15447
  repoRoot,
@@ -14309,16 +15454,16 @@ async function processSessionStartRepo(repo, tool, sessionId) {
14309
15454
  `git-traces: cleaned stale scoped sessions (repo=${repoRoot}, count=${staleCount})`
14310
15455
  );
14311
15456
  }
14312
- const state = await initializeSession(repoRoot, tool, sessionId);
14313
- return state ? "initialized" : "failed";
15457
+ return "initialized";
14314
15458
  } catch (err) {
14315
15459
  appendLog(
14316
15460
  "error",
14317
- `git-traces: SessionStart failed for repo ${repoRoot}: ${formatError(err)}`
15461
+ `git-traces: SessionStart reconciliation failed for repo ${repoRoot}: ${formatError(err)}`
14318
15462
  );
14319
15463
  return "failed";
14320
15464
  } finally {
14321
- if (acquired) await releaseLock3(repoRoot, tool, sessionId);
15465
+ if (scopedLockAcquired) await releaseLock3(repoRoot, tool, sessionId);
15466
+ if (legacyLockAcquired) await releaseLock3(repoRoot, tool, null);
14322
15467
  }
14323
15468
  }
14324
15469
  async function handleSessionStart(payload, tool) {
@@ -14345,11 +15490,24 @@ async function handleSessionStart(payload, tool) {
14345
15490
  appendLog("info", `git-traces: reaped ${reaped} stale lock file(s)`);
14346
15491
  }
14347
15492
  const repos = await loadConfiguredRepos();
14348
- let initialized = 0;
15493
+ const prepared = [];
14349
15494
  let skipped = 0;
14350
15495
  let failed = 0;
14351
15496
  for (const repo of repos) {
14352
- const outcome = await processSessionStartRepo(repo, tool, sessionId);
15497
+ const outcome = await prepareSessionStartRepo(repo, tool, sessionId);
15498
+ if (outcome === "initialized") prepared.push(repo);
15499
+ else if (outcome === "skipped") skipped++;
15500
+ else failed++;
15501
+ }
15502
+ const lineage = (await detectCodexLineageForHook(payload, tool, sessionId)).lineage;
15503
+ let initialized = 0;
15504
+ for (const repo of prepared) {
15505
+ const outcome = await completeSessionStartRepo(
15506
+ repo,
15507
+ tool,
15508
+ sessionId,
15509
+ lineage
15510
+ );
14353
15511
  if (outcome === "initialized") initialized++;
14354
15512
  else if (outcome === "skipped") skipped++;
14355
15513
  else failed++;
@@ -14359,7 +15517,7 @@ async function handleSessionStart(payload, tool) {
14359
15517
  `git-traces: SessionStart summary (session=${sessionId}, tool=${tool}, triggerRepo=${startingProject.repoRoot}, configured=${repos.length}, initialized=${initialized}, skipped=${skipped}, failed=${failed})`
14360
15518
  );
14361
15519
  }
14362
- function buildInitialBaselineArtifactsForState(repoRoot, tool, state) {
15520
+ function buildInitialBaselineArtifactsForState(repoRoot, tool, state, limits) {
14363
15521
  if (state.baselineTreeSha && state.baselineMetadata) {
14364
15522
  return buildFrozenEpochBaselineArtifacts({
14365
15523
  repoRoot,
@@ -14377,9 +15535,81 @@ function buildInitialBaselineArtifactsForState(repoRoot, tool, state) {
14377
15535
  baselineSha: state.baselineSha,
14378
15536
  prevHeadSha: null,
14379
15537
  transitionKind: "initial",
14380
- startedAt: state.startedAt
15538
+ startedAt: state.startedAt,
15539
+ limits,
15540
+ lineage: state.codexLineage
14381
15541
  });
14382
15542
  }
15543
+ function mergeLineageIntoState(state, detected) {
15544
+ const merged = mergeCodexLineage(state.codexLineage, detected);
15545
+ if (!merged || JSON.stringify(merged) === JSON.stringify(state.codexLineage)) {
15546
+ return false;
15547
+ }
15548
+ state.codexLineage = merged;
15549
+ if (state.baselineMetadata) {
15550
+ const baselineMetadata = { ...state.baselineMetadata };
15551
+ delete baselineMetadata.forkedFromThreadId;
15552
+ delete baselineMetadata.replayBoundary;
15553
+ state.baselineMetadata = {
15554
+ ...baselineMetadata,
15555
+ ...merged
15556
+ };
15557
+ }
15558
+ return true;
15559
+ }
15560
+ function reconcileSessionStateCandidates(candidates) {
15561
+ const { scoped, legacy } = candidates;
15562
+ if (!scoped) {
15563
+ if (!legacy) return { state: null, needsMigration: false };
15564
+ return { state: { ...legacy.state }, needsMigration: true };
15565
+ }
15566
+ if (!legacy) return { state: { ...scoped.state }, needsMigration: false };
15567
+ let authoritative = scoped;
15568
+ let other = legacy;
15569
+ const preferLegacy = scoped.state.legacyBoundaryPending === true && !hasPreparedSessionCaptureProgress(scoped.state);
15570
+ 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)) {
15571
+ authoritative = legacy;
15572
+ other = scoped;
15573
+ }
15574
+ const state = { ...authoritative.state };
15575
+ mergeLineageIntoState(state, other.state.codexLineage);
15576
+ if (other.state.codexLineageChecked === true) {
15577
+ state.codexLineageChecked = true;
15578
+ }
15579
+ if (state.contributionId === other.state.contributionId) {
15580
+ state.nextEpoch = Math.max(
15581
+ state.nextEpoch ?? state.epoch + 1,
15582
+ other.state.nextEpoch ?? other.state.epoch + 1
15583
+ );
15584
+ state.codexLineageStamped = mergeCodexLineage(
15585
+ state.codexLineageStamped,
15586
+ other.state.codexLineageStamped
15587
+ );
15588
+ }
15589
+ return { state, needsMigration: true };
15590
+ }
15591
+ function reconcileLockedSessionState(candidates) {
15592
+ const reconciled = reconcileSessionStateCandidates(candidates);
15593
+ if (!reconciled.state) return { state: null, needsWrite: false };
15594
+ const boundaryCleared = reconciled.state.legacyBoundaryPending === true;
15595
+ delete reconciled.state.legacyBoundaryPending;
15596
+ return {
15597
+ state: reconciled.state,
15598
+ needsWrite: reconciled.needsMigration || boundaryCleared
15599
+ };
15600
+ }
15601
+ async function reserveEpoch(state, persistState) {
15602
+ const reservedEpoch = Math.max(
15603
+ state.nextEpoch ?? state.epoch + 1,
15604
+ state.epoch + 1
15605
+ );
15606
+ state.nextEpoch = reservedEpoch + 1;
15607
+ await persistState(state);
15608
+ return reservedEpoch;
15609
+ }
15610
+ function isTerminalContributionError(err) {
15611
+ return err instanceof PlatformError && err.status === 404 && err.code === "CONTRIBUTION_NOT_FOUND" || isContributionUploadNotUploaded(err);
15612
+ }
14383
15613
  async function loadRepoClient(repo) {
14384
15614
  const identity = await loadIdentity(repo.config.apiBaseUrl);
14385
15615
  if (!identity) {
@@ -14391,14 +15621,71 @@ async function loadRepoClient(repo) {
14391
15621
  }
14392
15622
  return new PlatformClient(repo.config.apiBaseUrl, identity.sessionCookie);
14393
15623
  }
15624
+ async function refreshLineageBaseline(params) {
15625
+ const { repo, state, tool, persistState, client, limits } = params;
15626
+ if (state.codexLineage === void 0 || JSON.stringify(state.codexLineage) === JSON.stringify(state.codexLineageStamped) || state.contributionId === null || !state.baselineUploaded) {
15627
+ return false;
15628
+ }
15629
+ try {
15630
+ const nextEpoch = await reserveEpoch(state, persistState);
15631
+ const artifacts = await openEpoch({
15632
+ repoRoot: repo.repoRoot,
15633
+ client,
15634
+ contributionId: state.contributionId,
15635
+ sessionId: state.sessionId,
15636
+ tool,
15637
+ epoch: nextEpoch,
15638
+ prevHeadSha: state.headSha || null,
15639
+ // The downstream contract has no metadata-only transition. This is a
15640
+ // complete baseline at the current HEAD, so use its compatible initial
15641
+ // baseline shape and explain the refresh only in collector state/logs.
15642
+ transitionKind: "initial",
15643
+ startedAt: state.startedAt,
15644
+ limits,
15645
+ lineage: state.codexLineage
15646
+ });
15647
+ if (!artifacts) {
15648
+ appendLog(
15649
+ "warn",
15650
+ `git-traces: lineage baseline refresh deferred after capture (repo=${repo.repoRoot}, project=${repo.config.projectId}, epoch=${nextEpoch})`
15651
+ );
15652
+ return false;
15653
+ }
15654
+ Object.assign(state, {
15655
+ baselineSha: artifacts.baselineSha,
15656
+ baselineTreeSha: artifacts.baselineTreeSha,
15657
+ lastSnapshotSha: artifacts.baselineSha,
15658
+ lastSnapshotTreeSha: artifacts.baselineTreeSha,
15659
+ epoch: nextEpoch,
15660
+ turnCount: 0,
15661
+ headSha: artifacts.headSha,
15662
+ omittedKeys: artifacts.omittedKeys,
15663
+ codexLineageStamped: state.codexLineage
15664
+ });
15665
+ await persistState(state);
15666
+ appendLog(
15667
+ "info",
15668
+ `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)})`
15669
+ );
15670
+ return true;
15671
+ } catch (err) {
15672
+ if (isTerminalContributionError(err)) throw err;
15673
+ appendLog(
15674
+ "warn",
15675
+ `git-traces: lineage baseline refresh failed after capture; lineage remains pending (repo=${repo.repoRoot}, project=${repo.config.projectId}): ${formatError(err)}`
15676
+ );
15677
+ return false;
15678
+ }
15679
+ }
14394
15680
  async function registerInitialContribution(params) {
14395
- const { repo, state, tool, client, artifacts } = params;
15681
+ const { repo, state, tool, persistState, client, artifacts } = params;
14396
15682
  const contributionId = await createGitTracesContribution({
14397
15683
  client,
14398
15684
  config: repo.config,
14399
15685
  repoRoot: repo.repoRoot,
14400
15686
  state,
14401
15687
  tool,
15688
+ persistState,
14402
15689
  now: /* @__PURE__ */ new Date(),
14403
15690
  artifacts
14404
15691
  });
@@ -14407,14 +15694,44 @@ async function registerInitialContribution(params) {
14407
15694
  state.baselineUploaded = true;
14408
15695
  state.baselineTreeSha = artifacts.baselineTreeSha;
14409
15696
  state.lastSnapshotTreeSha = artifacts.baselineTreeSha;
14410
- await writeSessionState(state, tool);
15697
+ state.codexLineageStamped = state.codexLineage;
15698
+ await persistState(state);
14411
15699
  appendLog(
14412
15700
  "info",
14413
15701
  `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
15702
  );
14415
15703
  return true;
14416
15704
  }
14417
- async function processStopRepo(repo, tool, sessionId, recordedAt) {
15705
+ function reportNewlyOmittedFiles(state, omittedFiles, repoRoot) {
15706
+ const currentKeys = omissionKeys(omittedFiles);
15707
+ const previousKeys = Array.isArray(state.omittedKeys) && state.omittedKeys.every((key) => typeof key === "string") ? state.omittedKeys : void 0;
15708
+ if (previousKeys === void 0) {
15709
+ state.omittedKeys = currentKeys;
15710
+ return true;
15711
+ }
15712
+ const known = new Set(previousKeys);
15713
+ const fresh = omittedFiles.filter((file) => !known.has(omissionKey(file)));
15714
+ if (fresh.length > 0) {
15715
+ const tracked = fresh.filter((file) => file.tracked);
15716
+ const untracked = fresh.filter((file) => !file.tracked);
15717
+ if (tracked.length > 0) {
15718
+ appendLog(
15719
+ "warn",
15720
+ `git-traces: turn newly omitted tracked ${summarizeOmittedFiles(tracked)} \u2014 content excluded from bundle and diffs from here on (repo=${repoRoot})`
15721
+ );
15722
+ }
15723
+ if (untracked.length > 0) {
15724
+ appendLog(
15725
+ "info",
15726
+ `git-traces: turn newly skipped untracked ${summarizeOmittedFiles(untracked)} (repo=${repoRoot})`
15727
+ );
15728
+ }
15729
+ }
15730
+ const changed = currentKeys.length !== previousKeys.length || currentKeys.some((key, i) => key !== previousKeys[i]);
15731
+ if (changed) state.omittedKeys = currentKeys;
15732
+ return changed;
15733
+ }
15734
+ async function processStopRepo(repo, tool, sessionId, recordedAt, lineageCheck, options = {}) {
14418
15735
  const { repoRoot, config } = repo;
14419
15736
  if (!isGitRepo(repoRoot)) {
14420
15737
  appendLog(
@@ -14424,11 +15741,56 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14424
15741
  return "skipped";
14425
15742
  }
14426
15743
  let state = null;
14427
- let acquired = false;
15744
+ let legacyLockAcquired = false;
15745
+ let scopedLockAcquired = false;
15746
+ const persistState = sessionId ? (next) => writeSessionState2(next, tool) : (next) => writeLegacySessionState(next, tool);
14428
15747
  try {
14429
- await acquireLock3(repoRoot, tool, sessionId);
14430
- acquired = true;
14431
- state = await readSessionState(repoRoot, tool, sessionId);
15748
+ if (options.waitForLegacyBoundary) {
15749
+ await acquireBoundaryLegacyLock(repoRoot, tool);
15750
+ legacyLockAcquired = true;
15751
+ } else {
15752
+ const matchingLegacyHint = sessionId ? await readLegacySessionState(repoRoot, tool, sessionId) : await readSessionState2(repoRoot, tool);
15753
+ if (matchingLegacyHint) {
15754
+ try {
15755
+ await acquireLock3(repoRoot, tool, null, 2, 0);
15756
+ legacyLockAcquired = true;
15757
+ } catch {
15758
+ appendLog(
15759
+ "info",
15760
+ `git-traces: preserving repo on Stop (repo=${repoRoot}, project=${config.projectId}, reason=legacy-lock-held)`
15761
+ );
15762
+ return "skipped";
15763
+ }
15764
+ }
15765
+ }
15766
+ if (sessionId) {
15767
+ await acquireLock3(repoRoot, tool, sessionId);
15768
+ scopedLockAcquired = true;
15769
+ const candidates = await readSessionStateCandidates(
15770
+ repoRoot,
15771
+ tool,
15772
+ sessionId
15773
+ );
15774
+ if (legacyLockAcquired) {
15775
+ const reconciled = reconcileLockedSessionState(candidates);
15776
+ state = reconciled.state;
15777
+ if (state) {
15778
+ pinCanonicalStateRefs(repoRoot, state);
15779
+ if (reconciled.needsWrite) await writeSessionState2(state, tool);
15780
+ }
15781
+ if (state && candidates.legacy) {
15782
+ await deleteLegacySessionState(repoRoot, tool, state.sessionId);
15783
+ }
15784
+ } else {
15785
+ state = candidates.scoped?.state ?? null;
15786
+ }
15787
+ } else {
15788
+ if (!legacyLockAcquired) {
15789
+ await acquireLock3(repoRoot, tool, null, 2, 0);
15790
+ legacyLockAcquired = true;
15791
+ }
15792
+ state = await readSessionState2(repoRoot, tool);
15793
+ }
14432
15794
  if (!state) {
14433
15795
  appendLog(
14434
15796
  "info",
@@ -14436,6 +15798,40 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14436
15798
  );
14437
15799
  return "no-state";
14438
15800
  }
15801
+ const cleanupState = state;
15802
+ const finishSuccessfulStop = async (outcome) => {
15803
+ if (!options.cleanupOnSuccess) return outcome;
15804
+ await options.onBeforeCleanup?.();
15805
+ cleanupSessionRefs(repoRoot, cleanupState.sessionId);
15806
+ await deleteSessionState2(
15807
+ repoRoot,
15808
+ tool,
15809
+ sessionId ? cleanupState.sessionId : null
15810
+ );
15811
+ if (sessionId && legacyLockAcquired) {
15812
+ await deleteLegacySessionState(repoRoot, tool, cleanupState.sessionId);
15813
+ }
15814
+ appendLog(
15815
+ "info",
15816
+ `git-traces: session ${cleanupState.sessionId} cleaned up (repo=${repoRoot}, epochCount=${cleanupState.epoch}, lastTurnCount=${cleanupState.turnCount})`
15817
+ );
15818
+ return outcome;
15819
+ };
15820
+ let lineageStateChanged = false;
15821
+ if (lineageCheck?.performed) {
15822
+ lineageStateChanged = mergeLineageIntoState(
15823
+ state,
15824
+ lineageCheck.detection?.lineage
15825
+ );
15826
+ if (tool === "codex" && lineageCheck.detection?.conclusive === true && state.codexLineageChecked !== true) {
15827
+ state.codexLineageChecked = true;
15828
+ lineageStateChanged = true;
15829
+ }
15830
+ }
15831
+ if (lineageStateChanged) {
15832
+ await persistState(state);
15833
+ }
15834
+ const limits = sessionSnapshotLimits(state);
14439
15835
  const lastSnapshotTreeSha = state.lastSnapshotTreeSha ?? state.baselineTreeSha;
14440
15836
  if (!lastSnapshotTreeSha) {
14441
15837
  appendLog(
@@ -14445,6 +15841,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14445
15841
  return "failed";
14446
15842
  }
14447
15843
  const currentHeadSha = captureHeadSha(repoRoot);
15844
+ const lineageNeedsRefresh = state.codexLineage !== void 0 && JSON.stringify(state.codexLineage) !== JSON.stringify(state.codexLineageStamped);
14448
15845
  if (currentHeadSha !== state.headSha) {
14449
15846
  const client2 = await loadRepoClient(repo);
14450
15847
  if (!client2) return "skipped";
@@ -14452,7 +15849,8 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14452
15849
  const artifacts2 = buildInitialBaselineArtifactsForState(
14453
15850
  repoRoot,
14454
15851
  tool,
14455
- state
15852
+ state,
15853
+ limits
14456
15854
  );
14457
15855
  if (!artifacts2 || !canUploadEpochBaselineArtifacts(1, artifacts2)) {
14458
15856
  return "skipped";
@@ -14461,6 +15859,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14461
15859
  repo,
14462
15860
  state,
14463
15861
  tool,
15862
+ persistState,
14464
15863
  client: client2,
14465
15864
  artifacts: artifacts2
14466
15865
  });
@@ -14479,7 +15878,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14479
15878
  state.headSha || null,
14480
15879
  currentHeadSha
14481
15880
  );
14482
- const nextEpoch = state.epoch + 1;
15881
+ const nextEpoch = await reserveEpoch(state, persistState);
14483
15882
  appendLog(
14484
15883
  "info",
14485
15884
  `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 +15892,9 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14493
15892
  epoch: nextEpoch,
14494
15893
  prevHeadSha: state.headSha || null,
14495
15894
  transitionKind,
14496
- startedAt: state.startedAt
15895
+ startedAt: state.startedAt,
15896
+ limits,
15897
+ lineage: state.codexLineage
14497
15898
  });
14498
15899
  if (!artifacts) return "failed";
14499
15900
  const next = {
@@ -14504,28 +15905,57 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14504
15905
  lastSnapshotTreeSha: artifacts.baselineTreeSha,
14505
15906
  epoch: nextEpoch,
14506
15907
  turnCount: 0,
14507
- headSha: artifacts.headSha
15908
+ headSha: artifacts.headSha,
15909
+ omittedKeys: artifacts.omittedKeys,
15910
+ codexLineageStamped: state.codexLineage
14508
15911
  };
14509
- await writeSessionState(next, tool);
15912
+ await persistState(next);
14510
15913
  appendLog(
14511
15914
  "info",
14512
15915
  `git-traces: epoch baseline uploaded (repo=${repoRoot}, project=${config.projectId}, contribution=${contributionId2}, epoch=${nextEpoch}, baseline=${artifacts.baselineSha.slice(0, 8)})`
14513
15916
  );
14514
- return "uploaded";
15917
+ return await finishSuccessfulStop("uploaded");
14515
15918
  }
14516
15919
  const currentSha = captureSnapshotSha(repoRoot);
14517
- const currentTreeSha = buildSnapshotTree(repoRoot, currentSha);
15920
+ const turnOmittedFiles = [];
15921
+ const currentTreeSha = buildSnapshotTree(repoRoot, currentSha, {
15922
+ omittedFiles: turnOmittedFiles,
15923
+ log: false,
15924
+ limits
15925
+ });
15926
+ const omissionsChanged = reportNewlyOmittedFiles(
15927
+ state,
15928
+ turnOmittedFiles,
15929
+ repoRoot
15930
+ );
14518
15931
  const patchBuffer = createTreeDiffPatchGz(
14519
15932
  repoRoot,
14520
15933
  lastSnapshotTreeSha,
14521
- currentTreeSha
15934
+ currentTreeSha,
15935
+ { limits }
14522
15936
  );
14523
15937
  if (!patchBuffer) {
15938
+ if (omissionsChanged) await persistState(state);
14524
15939
  appendLog(
14525
15940
  "info",
14526
15941
  `git-traces: turn produced identical snapshot tree, skipping upload (repo=${repoRoot}, project=${config.projectId})`
14527
15942
  );
14528
- return "unchanged";
15943
+ if (lineageNeedsRefresh && state.contributionId !== null && state.baselineUploaded) {
15944
+ const client2 = await loadRepoClient(repo);
15945
+ const refreshed = client2 !== null && await refreshLineageBaseline({
15946
+ repo,
15947
+ state,
15948
+ tool,
15949
+ persistState,
15950
+ client: client2,
15951
+ limits
15952
+ });
15953
+ if (refreshed) {
15954
+ return await finishSuccessfulStop("uploaded");
15955
+ }
15956
+ if (options.cleanupOnSuccess) return "skipped";
15957
+ }
15958
+ return await finishSuccessfulStop("unchanged");
14529
15959
  }
14530
15960
  const prefix = epochPrefix2(state.epoch);
14531
15961
  const nextTurnCount = state.turnCount + 1;
@@ -14544,7 +15974,8 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14544
15974
  const artifacts = buildInitialBaselineArtifactsForState(
14545
15975
  repoRoot,
14546
15976
  tool,
14547
- state
15977
+ state,
15978
+ limits
14548
15979
  );
14549
15980
  if (!artifacts || !canUploadEpochBaselineArtifacts(1, artifacts)) {
14550
15981
  return "skipped";
@@ -14553,6 +15984,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14553
15984
  repo,
14554
15985
  state,
14555
15986
  tool,
15987
+ persistState,
14556
15988
  client,
14557
15989
  artifacts
14558
15990
  });
@@ -14571,6 +16003,14 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14571
16003
  `refs/hillclimb/turns/${state.sessionId}/${prefix}/${turnLabel}`,
14572
16004
  currentSha
14573
16005
  );
16006
+ pinScopedTurnSnapshot(
16007
+ repoRoot,
16008
+ state.sessionId,
16009
+ state.epoch,
16010
+ nextTurnCount,
16011
+ currentSha,
16012
+ currentTreeSha
16013
+ );
14574
16014
  const uploaded = await uploadFile(
14575
16015
  client,
14576
16016
  contributionId,
@@ -14588,46 +16028,63 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14588
16028
  state.turnCount = nextTurnCount;
14589
16029
  state.lastSnapshotSha = currentSha;
14590
16030
  state.lastSnapshotTreeSha = currentTreeSha;
14591
- await writeSessionState(state, tool);
16031
+ await persistState(state);
14592
16032
  appendLog(
14593
16033
  "info",
14594
16034
  `git-traces: patch uploaded (repo=${repoRoot}, project=${config.projectId}, contribution=${contributionId}, epoch=${state.epoch}, turn=${turnLabel}, bytes=${patchBuffer.byteLength})`
14595
16035
  );
14596
- return "uploaded";
16036
+ const lineageStillNeedsRefresh = state.codexLineage !== void 0 && JSON.stringify(state.codexLineage) !== JSON.stringify(state.codexLineageStamped);
16037
+ if (lineageStillNeedsRefresh) {
16038
+ const refreshed = await refreshLineageBaseline({
16039
+ repo,
16040
+ state,
16041
+ tool,
16042
+ persistState,
16043
+ client,
16044
+ limits
16045
+ });
16046
+ if (!refreshed && options.cleanupOnSuccess) return "skipped";
16047
+ }
16048
+ return await finishSuccessfulStop("uploaded");
14597
16049
  } catch (err) {
14598
16050
  appendLog(
14599
16051
  "error",
14600
16052
  `git-traces: Stop failed for repo ${repoRoot}: ${formatError(err)}`
14601
16053
  );
14602
- if (state && err instanceof PlatformError && err.status === 404 && err.code === "CONTRIBUTION_NOT_FOUND") {
16054
+ if (state && isTerminalContributionError(err)) {
16055
+ const poisoned = isContributionUploadNotUploaded(err);
14603
16056
  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
- );
16057
+ await persistState({
16058
+ ...state,
16059
+ contributionId: null,
16060
+ baselineUploaded: false,
16061
+ lastSnapshotSha: state.baselineSha,
16062
+ lastSnapshotTreeSha: state.baselineTreeSha,
16063
+ turnCount: 0
16064
+ });
14615
16065
  appendLog(
14616
16066
  "warn",
14617
- `git-traces: cleared stale contribution state (repo=${repoRoot}, project=${config.projectId}, missingContribution=${state.contributionId})`
16067
+ 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
16068
  );
14619
16069
  } else {
14620
16070
  cleanupSessionRefs(repoRoot, state.sessionId);
14621
- await deleteSessionState(repoRoot, tool, state.sessionId);
16071
+ await deleteSessionState2(
16072
+ repoRoot,
16073
+ tool,
16074
+ sessionId ? state.sessionId : null
16075
+ );
14622
16076
  appendLog(
14623
16077
  "warn",
14624
- `git-traces: deleted stale contribution state without a frozen baseline (repo=${repoRoot}, project=${config.projectId}, missingContribution=${state.contributionId})`
16078
+ 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
16079
  );
14626
16080
  }
14627
16081
  }
14628
16082
  return "failed";
14629
16083
  } finally {
14630
- if (acquired) await releaseLock3(repoRoot, tool, sessionId);
16084
+ if (scopedLockAcquired) {
16085
+ await releaseLock3(repoRoot, tool, sessionId);
16086
+ }
16087
+ if (legacyLockAcquired) await releaseLock3(repoRoot, tool, null);
14631
16088
  }
14632
16089
  }
14633
16090
  async function handleStop(payload, tool) {
@@ -14639,12 +16096,13 @@ async function handleStop(payload, tool) {
14639
16096
  const recordedAt = Date.now();
14640
16097
  const repos = await loadConfiguredRepos();
14641
16098
  const repoByRoot = new Map(repos.map((repo) => [repo.repoRoot, repo]));
14642
- const targets = [];
16099
+ const targetsByRoot = /* @__PURE__ */ new Map();
14643
16100
  let missingConfig = 0;
16101
+ let needsLineageCheck = false;
14644
16102
  if (sessionId) {
14645
16103
  const storedStates = await listSessionStatesForSession(tool, sessionId);
14646
16104
  for (const { state } of storedStates) {
14647
- const repo = repoByRoot.get(path17.resolve(state.repoRoot));
16105
+ const repo = repoByRoot.get(path18.resolve(state.repoRoot));
14648
16106
  if (!repo) {
14649
16107
  missingConfig++;
14650
16108
  appendLog(
@@ -14653,21 +16111,53 @@ async function handleStop(payload, tool) {
14653
16111
  );
14654
16112
  continue;
14655
16113
  }
14656
- targets.push(repo);
16114
+ if (tool === "codex" && state.codexLineageChecked !== true) {
16115
+ needsLineageCheck = true;
16116
+ }
16117
+ targetsByRoot.set(repo.repoRoot, repo);
16118
+ }
16119
+ for (const repo of repos) {
16120
+ const legacyState = await readLegacySessionState(
16121
+ repo.repoRoot,
16122
+ tool,
16123
+ sessionId
16124
+ );
16125
+ if (!legacyState) continue;
16126
+ targetsByRoot.set(repo.repoRoot, repo);
16127
+ if (tool === "codex" && legacyState.codexLineageChecked !== true) {
16128
+ needsLineageCheck = true;
16129
+ }
14657
16130
  }
14658
- if (targets.length === 0 && missingConfig === 0) {
14659
- targets.push({ repoRoot: project.repoRoot, config: project.config });
16131
+ if (targetsByRoot.size === 0 && missingConfig === 0) {
16132
+ targetsByRoot.set(project.repoRoot, {
16133
+ repoRoot: project.repoRoot,
16134
+ config: project.config
16135
+ });
14660
16136
  }
14661
16137
  } else {
14662
- targets.push({ repoRoot: project.repoRoot, config: project.config });
16138
+ targetsByRoot.set(project.repoRoot, {
16139
+ repoRoot: project.repoRoot,
16140
+ config: project.config
16141
+ });
14663
16142
  }
16143
+ const targets = [...targetsByRoot.values()];
16144
+ const lineageCheck = needsLineageCheck ? {
16145
+ performed: true,
16146
+ detection: await detectCodexLineageForHook(payload, tool, sessionId)
16147
+ } : { performed: false };
14664
16148
  let uploaded = 0;
14665
16149
  let unchanged = 0;
14666
16150
  let skipped = missingConfig;
14667
16151
  let noState = 0;
14668
16152
  let failed = 0;
14669
16153
  for (const repo of targets) {
14670
- const outcome = await processStopRepo(repo, tool, sessionId, recordedAt);
16154
+ const outcome = await processStopRepo(
16155
+ repo,
16156
+ tool,
16157
+ sessionId,
16158
+ recordedAt,
16159
+ lineageCheck
16160
+ );
14671
16161
  if (outcome === "uploaded") uploaded++;
14672
16162
  else if (outcome === "unchanged") unchanged++;
14673
16163
  else if (outcome === "skipped") skipped++;
@@ -14679,49 +16169,48 @@ async function handleStop(payload, tool) {
14679
16169
  `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
16170
  );
14681
16171
  }
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
16172
  async function handleSessionEnd(payload, tool) {
14707
16173
  const cwd = resolveCwd2(payload);
14708
16174
  const sessionId = resolveSessionId2(payload);
14709
16175
  const project = cwd ? await findProjectForCwd(cwd) : null;
14710
16176
  const triggerRepo = project?.repoRoot ?? cwd ?? "<none>";
14711
16177
  const recordedAt = Date.now();
14712
- const repoRoots = [];
16178
+ const repos = await loadConfiguredRepos();
16179
+ const repoByRoot = new Map(repos.map((repo) => [repo.repoRoot, repo]));
16180
+ const repoRoots = /* @__PURE__ */ new Set();
16181
+ let needsLineageCheck = false;
14713
16182
  if (sessionId) {
14714
16183
  const states = await listSessionStatesForSession(tool, sessionId);
14715
16184
  for (const { state } of states) {
14716
- repoRoots.push(path17.resolve(state.repoRoot));
16185
+ const repoRoot = path18.resolve(state.repoRoot);
16186
+ repoRoots.add(repoRoot);
16187
+ const canProcess = repoByRoot.has(repoRoot) || project && path18.resolve(project.repoRoot) === repoRoot;
16188
+ if (canProcess && tool === "codex" && state.codexLineageChecked !== true) {
16189
+ needsLineageCheck = true;
16190
+ }
16191
+ }
16192
+ for (const repo of repos) {
16193
+ const legacyState = await readLegacySessionState(
16194
+ repo.repoRoot,
16195
+ tool,
16196
+ sessionId
16197
+ );
16198
+ if (!legacyState) continue;
16199
+ repoRoots.add(repo.repoRoot);
16200
+ if (tool === "codex" && legacyState.codexLineageChecked !== true) {
16201
+ needsLineageCheck = true;
16202
+ }
14717
16203
  }
14718
16204
  }
14719
- if (repoRoots.length === 0 && cwd) {
14720
- repoRoots.push(project?.repoRoot ?? cwd);
16205
+ if (repoRoots.size === 0 && cwd) {
16206
+ const fallbackRepoRoot = project?.repoRoot ?? cwd;
16207
+ repoRoots.add(fallbackRepoRoot);
14721
16208
  }
14722
- if (repoRoots.length === 0) return;
14723
- const repos = await loadConfiguredRepos();
14724
- const repoByRoot = new Map(repos.map((repo) => [repo.repoRoot, repo]));
16209
+ if (repoRoots.size === 0) return;
16210
+ const lineageCheck = needsLineageCheck ? {
16211
+ performed: true,
16212
+ detection: await detectCodexLineageForHook(payload, tool, sessionId)
16213
+ } : { performed: false };
14725
16214
  let cleaned = 0;
14726
16215
  let noState = 0;
14727
16216
  let uploaded = 0;
@@ -14729,7 +16218,7 @@ async function handleSessionEnd(payload, tool) {
14729
16218
  let skipped = 0;
14730
16219
  let failed = 0;
14731
16220
  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);
16221
+ const repo = repoByRoot.get(path18.resolve(repoRoot)) ?? (project && path18.resolve(project.repoRoot) === path18.resolve(repoRoot) ? { repoRoot: project.repoRoot, config: project.config } : null);
14733
16222
  if (!repo) {
14734
16223
  skipped++;
14735
16224
  appendLog(
@@ -14742,7 +16231,9 @@ async function handleSessionEnd(payload, tool) {
14742
16231
  repo,
14743
16232
  tool,
14744
16233
  sessionId,
14745
- recordedAt
16234
+ recordedAt,
16235
+ lineageCheck,
16236
+ { waitForLegacyBoundary: true, cleanupOnSuccess: true }
14746
16237
  );
14747
16238
  if (finalOutcome === "uploaded") uploaded++;
14748
16239
  else if (finalOutcome === "unchanged") unchanged++;
@@ -14764,14 +16255,11 @@ async function handleSessionEnd(payload, tool) {
14764
16255
  noState++;
14765
16256
  continue;
14766
16257
  }
14767
- const outcome = await cleanupSessionStateForRepo(repoRoot, tool, sessionId);
14768
- if (outcome === "cleaned") cleaned++;
14769
- else if (outcome === "no-state") noState++;
14770
- else failed++;
16258
+ cleaned++;
14771
16259
  }
14772
16260
  appendLog(
14773
16261
  "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})`
16262
+ `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
16263
  );
14776
16264
  }
14777
16265
 
@@ -14780,7 +16268,7 @@ var WORKER_ENV_FLAG2 = "HILLCLIMB_GIT_TRACES_WORKER";
14780
16268
  var TOOL_ENV_FLAG2 = "HILLCLIMB_GIT_TRACES_TOOL";
14781
16269
  var FLOW_ID_ENV2 = "HILLCLIMB_GIT_TRACES_FLOW";
14782
16270
  function newFlowId2() {
14783
- return crypto6.randomBytes(3).toString("hex");
16271
+ return crypto7.randomBytes(3).toString("hex");
14784
16272
  }
14785
16273
  var KNOWN_TOOLS = /* @__PURE__ */ new Set([
14786
16274
  "claude",
@@ -15022,7 +16510,7 @@ async function runGitTracesWorker() {
15022
16510
  return;
15023
16511
  }
15024
16512
  const event = payload.hook_event_name;
15025
- const eventKind = classifyHookEvent2(event);
16513
+ const eventKind2 = classifyHookEvent2(event);
15026
16514
  appendLog("info", `git-traces worker: handling event=${event}`);
15027
16515
  if (tool === "claude" && typeof payload.cursor_version === "string") {
15028
16516
  appendLog(
@@ -15033,7 +16521,7 @@ async function runGitTracesWorker() {
15033
16521
  }
15034
16522
  try {
15035
16523
  await selfHealHook2(payload, tool);
15036
- switch (eventKind) {
16524
+ switch (eventKind2) {
15037
16525
  case "sessionStart":
15038
16526
  await handleSessionStart(payload, tool);
15039
16527
  break;
@@ -15055,7 +16543,7 @@ async function runGitTracesWorker() {
15055
16543
  ${stack}` : ""}`
15056
16544
  );
15057
16545
  } finally {
15058
- if (eventKind === "stop" || eventKind === "sessionEnd") {
16546
+ if (eventKind2 === "stop" || eventKind2 === "sessionEnd") {
15059
16547
  await recordDebugLogCompletion({
15060
16548
  kind: "git",
15061
16549
  tool,
@@ -15066,29 +16554,29 @@ ${stack}` : ""}`
15066
16554
  }
15067
16555
 
15068
16556
  // src/outputs/zip.ts
15069
- import fs17 from "fs";
15070
- import path19 from "path";
16557
+ import fs19 from "fs";
16558
+ import path20 from "path";
15071
16559
  import archiver2 from "archiver";
15072
16560
 
15073
16561
  // src/outputs/downloads.ts
15074
16562
  import { execSync as execSync2 } from "child_process";
15075
- import fs16 from "fs";
15076
- import os9 from "os";
15077
- import path18 from "path";
16563
+ import fs18 from "fs";
16564
+ import os10 from "os";
16565
+ import path19 from "path";
15078
16566
  function getDownloadsFolder() {
15079
- const home = os9.homedir();
16567
+ const home = os10.homedir();
15080
16568
  if (process.platform === "linux") {
15081
16569
  try {
15082
16570
  const xdgDir = execSync2("xdg-user-dir DOWNLOAD", {
15083
16571
  encoding: "utf-8",
15084
16572
  timeout: 3e3
15085
16573
  }).trim();
15086
- if (xdgDir && fs16.existsSync(xdgDir)) return xdgDir;
16574
+ if (xdgDir && fs18.existsSync(xdgDir)) return xdgDir;
15087
16575
  } catch {
15088
16576
  }
15089
16577
  }
15090
- const downloads = path18.join(home, "Downloads");
15091
- if (fs16.existsSync(downloads)) return downloads;
16578
+ const downloads = path19.join(home, "Downloads");
16579
+ if (fs18.existsSync(downloads)) return downloads;
15092
16580
  return home;
15093
16581
  }
15094
16582
 
@@ -15097,11 +16585,11 @@ function sanitizeFilename(name) {
15097
16585
  return name.replace(/[^a-zA-Z0-9._-]/g, "_");
15098
16586
  }
15099
16587
  function getUniqueFilename(dir, base, ext) {
15100
- let candidate = path19.join(dir, `${base}${ext}`);
15101
- if (!fs17.existsSync(candidate)) return candidate;
16588
+ let candidate = path20.join(dir, `${base}${ext}`);
16589
+ if (!fs19.existsSync(candidate)) return candidate;
15102
16590
  let i = 1;
15103
- while (fs17.existsSync(candidate)) {
15104
- candidate = path19.join(dir, `${base}-${i}${ext}`);
16591
+ while (fs19.existsSync(candidate)) {
16592
+ candidate = path20.join(dir, `${base}-${i}${ext}`);
15105
16593
  i++;
15106
16594
  }
15107
16595
  return candidate;
@@ -15111,13 +16599,13 @@ var ZipOutput = class {
15111
16599
  label = "Save as .zip to Downloads";
15112
16600
  async emit(group, options) {
15113
16601
  const downloadsDir = getDownloadsFolder();
15114
- const repoName = sanitizeFilename(path19.basename(group.repoPath));
16602
+ const repoName = sanitizeFilename(path20.basename(group.repoPath));
15115
16603
  const timeRange = options.timeRange;
15116
16604
  const rangePart = timeRange?.label ?? "all";
15117
16605
  const epochSeconds = Math.floor(Date.now() / 1e3);
15118
16606
  const baseName = `logs-${repoName}-${rangePart}-${epochSeconds}`;
15119
16607
  const outputPath = getUniqueFilename(downloadsDir, baseName, ".zip");
15120
- const output = fs17.createWriteStream(outputPath);
16608
+ const output = fs19.createWriteStream(outputPath);
15121
16609
  const archive = archiver2("zip", { zlib: { level: 6 } });
15122
16610
  const done = new Promise((resolve, reject) => {
15123
16611
  output.on("close", resolve);
@@ -15311,15 +16799,15 @@ async function confirmExport(group, output) {
15311
16799
  }
15312
16800
 
15313
16801
  // src/sources/claude.ts
15314
- import fs18 from "fs";
15315
- import os10 from "os";
15316
- import path20 from "path";
16802
+ import fs20 from "fs";
16803
+ import os11 from "os";
16804
+ import path21 from "path";
15317
16805
  import readline2 from "readline";
15318
16806
  var SKIP_DIRS = /* @__PURE__ */ new Set(["tool-results"]);
15319
16807
  async function resolveRepoPath(projectDir) {
15320
- const indexPath = path20.join(projectDir, "sessions-index.json");
16808
+ const indexPath = path21.join(projectDir, "sessions-index.json");
15321
16809
  try {
15322
- const raw = await fs18.promises.readFile(indexPath, "utf-8");
16810
+ const raw = await fs20.promises.readFile(indexPath, "utf-8");
15323
16811
  const data = JSON.parse(raw);
15324
16812
  if (data.originalPath && typeof data.originalPath === "string") {
15325
16813
  return data.originalPath;
@@ -15327,12 +16815,12 @@ async function resolveRepoPath(projectDir) {
15327
16815
  } catch {
15328
16816
  }
15329
16817
  const cwdCounts = /* @__PURE__ */ new Map();
15330
- const entries = await fs18.promises.readdir(projectDir, {
16818
+ const entries = await fs20.promises.readdir(projectDir, {
15331
16819
  withFileTypes: true
15332
16820
  });
15333
16821
  for (const entry of entries) {
15334
16822
  if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
15335
- const cwd = await extractCwdFromJsonl(path20.join(projectDir, entry.name));
16823
+ const cwd = await extractCwdFromJsonl(path21.join(projectDir, entry.name));
15336
16824
  if (cwd) {
15337
16825
  cwdCounts.set(cwd, (cwdCounts.get(cwd) ?? 0) + 1);
15338
16826
  }
@@ -15351,7 +16839,7 @@ async function resolveRepoPath(projectDir) {
15351
16839
  return null;
15352
16840
  }
15353
16841
  async function extractCwdFromJsonl(filePath) {
15354
- const stream = fs18.createReadStream(filePath, { encoding: "utf-8" });
16842
+ const stream = fs20.createReadStream(filePath, { encoding: "utf-8" });
15355
16843
  const rl = readline2.createInterface({ input: stream, crlfDelay: Infinity });
15356
16844
  try {
15357
16845
  for await (const line of rl) {
@@ -15373,12 +16861,12 @@ async function extractCwdFromJsonl(filePath) {
15373
16861
  async function collectFiles(dir, baseDir, sourceName, repoPath, results) {
15374
16862
  let entries;
15375
16863
  try {
15376
- entries = await fs18.promises.readdir(dir, { withFileTypes: true });
16864
+ entries = await fs20.promises.readdir(dir, { withFileTypes: true });
15377
16865
  } catch {
15378
16866
  return;
15379
16867
  }
15380
16868
  for (const entry of entries) {
15381
- const fullPath = path20.join(dir, entry.name);
16869
+ const fullPath = path21.join(dir, entry.name);
15382
16870
  if (entry.isDirectory()) {
15383
16871
  if (SKIP_DIRS.has(entry.name)) continue;
15384
16872
  await collectFiles(fullPath, baseDir, sourceName, repoPath, results);
@@ -15400,19 +16888,19 @@ function fallbackDecode(encodedName) {
15400
16888
  var ClaudeSource = class {
15401
16889
  name = "claude";
15402
16890
  async scan() {
15403
- const baseDir = path20.join(os10.homedir(), ".claude", "projects");
16891
+ const baseDir = path21.join(os11.homedir(), ".claude", "projects");
15404
16892
  try {
15405
- await fs18.promises.access(baseDir);
16893
+ await fs20.promises.access(baseDir);
15406
16894
  } catch {
15407
16895
  return [];
15408
16896
  }
15409
- const projectDirs = await fs18.promises.readdir(baseDir, {
16897
+ const projectDirs = await fs20.promises.readdir(baseDir, {
15410
16898
  withFileTypes: true
15411
16899
  });
15412
16900
  const dirEntries = projectDirs.filter((d) => d.isDirectory());
15413
16901
  const resultArrays = await Promise.all(
15414
16902
  dirEntries.map(async (dir) => {
15415
- const projectPath = path20.join(baseDir, dir.name);
16903
+ const projectPath = path21.join(baseDir, dir.name);
15416
16904
  const repoPath = await resolveRepoPath(projectPath) ?? fallbackDecode(dir.name);
15417
16905
  const files = [];
15418
16906
  await collectFiles(
@@ -15430,12 +16918,12 @@ var ClaudeSource = class {
15430
16918
  };
15431
16919
 
15432
16920
  // src/sources/codex.ts
15433
- import fs19 from "fs";
15434
- import os11 from "os";
15435
- import path21 from "path";
16921
+ import fs21 from "fs";
16922
+ import os12 from "os";
16923
+ import path22 from "path";
15436
16924
  import readline3 from "readline";
15437
- async function parseSessionMeta(filePath) {
15438
- const stream = fs19.createReadStream(filePath, { encoding: "utf-8" });
16925
+ async function parseSessionMeta2(filePath) {
16926
+ const stream = fs21.createReadStream(filePath, { encoding: "utf-8" });
15439
16927
  const rl = readline3.createInterface({ input: stream, crlfDelay: Infinity });
15440
16928
  try {
15441
16929
  for await (const line of rl) {
@@ -15460,12 +16948,12 @@ async function findJsonlFiles(dir) {
15460
16948
  async function walk(d) {
15461
16949
  let entries;
15462
16950
  try {
15463
- entries = await fs19.promises.readdir(d, { withFileTypes: true });
16951
+ entries = await fs21.promises.readdir(d, { withFileTypes: true });
15464
16952
  } catch {
15465
16953
  return;
15466
16954
  }
15467
16955
  for (const entry of entries) {
15468
- const full = path21.join(d, entry.name);
16956
+ const full = path22.join(d, entry.name);
15469
16957
  if (entry.isDirectory()) {
15470
16958
  await walk(full);
15471
16959
  } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
@@ -15479,11 +16967,11 @@ async function findJsonlFiles(dir) {
15479
16967
  async function loadHistory(historyPath) {
15480
16968
  const map = /* @__PURE__ */ new Map();
15481
16969
  try {
15482
- await fs19.promises.access(historyPath);
16970
+ await fs21.promises.access(historyPath);
15483
16971
  } catch {
15484
16972
  return map;
15485
16973
  }
15486
- const stream = fs19.createReadStream(historyPath, { encoding: "utf-8" });
16974
+ const stream = fs21.createReadStream(historyPath, { encoding: "utf-8" });
15487
16975
  const rl = readline3.createInterface({ input: stream, crlfDelay: Infinity });
15488
16976
  try {
15489
16977
  for await (const line of rl) {
@@ -15510,22 +16998,22 @@ async function loadHistory(historyPath) {
15510
16998
  var CodexSource = class {
15511
16999
  name = "codex";
15512
17000
  async scan() {
15513
- const codexDir = path21.join(os11.homedir(), ".codex");
15514
- const sessionsDir = path21.join(codexDir, "sessions");
17001
+ const codexDir = path22.join(os12.homedir(), ".codex");
17002
+ const sessionsDir2 = path22.join(codexDir, "sessions");
15515
17003
  try {
15516
- await fs19.promises.access(sessionsDir);
17004
+ await fs21.promises.access(sessionsDir2);
15517
17005
  } catch {
15518
17006
  return [];
15519
17007
  }
15520
- const historyPath = path21.join(codexDir, "history.jsonl");
17008
+ const historyPath = path22.join(codexDir, "history.jsonl");
15521
17009
  const [jsonlFiles, historyMap] = await Promise.all([
15522
- findJsonlFiles(sessionsDir),
17010
+ findJsonlFiles(sessionsDir2),
15523
17011
  loadHistory(historyPath)
15524
17012
  ]);
15525
17013
  const results = [];
15526
17014
  const metaResults = await Promise.all(
15527
17015
  jsonlFiles.map(async (filePath) => {
15528
- const meta = await parseSessionMeta(filePath);
17016
+ const meta = await parseSessionMeta2(filePath);
15529
17017
  return meta ? { filePath, meta } : null;
15530
17018
  })
15531
17019
  );
@@ -15540,9 +17028,9 @@ var CodexSource = class {
15540
17028
  });
15541
17029
  const historyLines = historyMap.get(meta.sessionId);
15542
17030
  if (historyLines) {
15543
- const sessionDir = path21.relative(sessionsDir, path21.dirname(filePath));
15544
- const historyAbsPath = path21.join(
15545
- sessionsDir,
17031
+ const sessionDir = path22.relative(sessionsDir2, path22.dirname(filePath));
17032
+ const historyAbsPath = path22.join(
17033
+ sessionsDir2,
15546
17034
  sessionDir,
15547
17035
  `history-${meta.sessionId}.jsonl`
15548
17036
  );
@@ -15561,18 +17049,18 @@ var CodexSource = class {
15561
17049
  };
15562
17050
 
15563
17051
  // src/sources/copilotChat.ts
15564
- import fs20 from "fs";
15565
- import os12 from "os";
15566
- import path22 from "path";
17052
+ import fs22 from "fs";
17053
+ import os13 from "os";
17054
+ import path23 from "path";
15567
17055
  import { fileURLToPath } from "url";
15568
17056
  function vsCodeUserDirs() {
15569
- const home = os12.homedir();
17057
+ const home = os13.homedir();
15570
17058
  const dirs = [
15571
- path22.join(home, "Library", "Application Support", "Code", "User"),
15572
- path22.join(home, ".config", "Code", "User")
17059
+ path23.join(home, "Library", "Application Support", "Code", "User"),
17060
+ path23.join(home, ".config", "Code", "User")
15573
17061
  ];
15574
17062
  if (process.env.APPDATA) {
15575
- dirs.push(path22.join(process.env.APPDATA, "Code", "User"));
17063
+ dirs.push(path23.join(process.env.APPDATA, "Code", "User"));
15576
17064
  }
15577
17065
  return dirs;
15578
17066
  }
@@ -15587,7 +17075,7 @@ function uriToFsPath(uri) {
15587
17075
  async function readWorkspaceFolder(workspaceJsonPath) {
15588
17076
  let raw;
15589
17077
  try {
15590
- raw = await fs20.promises.readFile(workspaceJsonPath, "utf-8");
17078
+ raw = await fs22.promises.readFile(workspaceJsonPath, "utf-8");
15591
17079
  } catch {
15592
17080
  return null;
15593
17081
  }
@@ -15609,10 +17097,10 @@ var CopilotChatSource = class {
15609
17097
  async scan() {
15610
17098
  const results = [];
15611
17099
  for (const userDir of vsCodeUserDirs()) {
15612
- const workspaceStorage = path22.join(userDir, "workspaceStorage");
17100
+ const workspaceStorage = path23.join(userDir, "workspaceStorage");
15613
17101
  let hashDirs;
15614
17102
  try {
15615
- hashDirs = await fs20.promises.readdir(workspaceStorage, {
17103
+ hashDirs = await fs22.promises.readdir(workspaceStorage, {
15616
17104
  withFileTypes: true
15617
17105
  });
15618
17106
  } catch {
@@ -15620,22 +17108,22 @@ var CopilotChatSource = class {
15620
17108
  }
15621
17109
  for (const hash of hashDirs) {
15622
17110
  if (!hash.isDirectory()) continue;
15623
- const wsRoot = path22.join(workspaceStorage, hash.name);
15624
- const transcriptsDir = path22.join(
17111
+ const wsRoot = path23.join(workspaceStorage, hash.name);
17112
+ const transcriptsDir = path23.join(
15625
17113
  wsRoot,
15626
17114
  "GitHub.copilot-chat",
15627
17115
  "transcripts"
15628
17116
  );
15629
17117
  let transcriptEntries;
15630
17118
  try {
15631
- transcriptEntries = await fs20.promises.readdir(transcriptsDir, {
17119
+ transcriptEntries = await fs22.promises.readdir(transcriptsDir, {
15632
17120
  withFileTypes: true
15633
17121
  });
15634
17122
  } catch {
15635
17123
  continue;
15636
17124
  }
15637
17125
  const repoPath = await readWorkspaceFolder(
15638
- path22.join(wsRoot, "workspace.json")
17126
+ path23.join(wsRoot, "workspace.json")
15639
17127
  );
15640
17128
  if (!repoPath) continue;
15641
17129
  for (const entry of transcriptEntries) {
@@ -15643,7 +17131,7 @@ var CopilotChatSource = class {
15643
17131
  const sessionId = entry.name.slice(0, -".jsonl".length);
15644
17132
  results.push({
15645
17133
  sourceName: this.name,
15646
- absolutePath: path22.join(transcriptsDir, entry.name),
17134
+ absolutePath: path23.join(transcriptsDir, entry.name),
15647
17135
  repoPath,
15648
17136
  metadata: { sessionId }
15649
17137
  });
@@ -15683,7 +17171,7 @@ function reportRedactionStats(noun, stats) {
15683
17171
  async function filterByTimeRange(group, range) {
15684
17172
  const results = await Promise.all(
15685
17173
  group.files.map(
15686
- (f) => fs21.promises.stat(f.absolutePath).then((s) => ({ file: f, stat: s })).catch(() => null)
17174
+ (f) => fs23.promises.stat(f.absolutePath).then((s) => ({ file: f, stat: s })).catch(() => null)
15687
17175
  )
15688
17176
  );
15689
17177
  const filtered = [];
@@ -15710,10 +17198,10 @@ async function runInteractive() {
15710
17198
  s.start(`Scanning ${source.name} logs...`);
15711
17199
  const allFiles = await source.scan();
15712
17200
  const allGroups = await mergeByRepo(allFiles);
15713
- const repoRoot = path23.resolve(repo.root);
17201
+ const repoRoot = path24.resolve(repo.root);
15714
17202
  const matching = allGroups.filter((g) => {
15715
- const resolved = path23.resolve(g.repoPath);
15716
- return resolved === repoRoot || resolved.startsWith(repoRoot + path23.sep);
17203
+ const resolved = path24.resolve(g.repoPath);
17204
+ return resolved === repoRoot || resolved.startsWith(repoRoot + path24.sep);
15717
17205
  });
15718
17206
  if (matching.length === 0) {
15719
17207
  s.stop(`No ${source.name} logs found for ${repo.name}.`);
@@ -15744,7 +17232,7 @@ async function runInteractive() {
15744
17232
  }
15745
17233
  }
15746
17234
  const envFileNames = await discoverEnvFiles(repoRoot);
15747
- const envFilePaths = envFileNames.map((n) => path23.join(repoRoot, n));
17235
+ const envFilePaths = envFileNames.map((n) => path24.join(repoRoot, n));
15748
17236
  const additionalFiles = await promptSecretFiles(envFileNames);
15749
17237
  const secretResult = await collectSecrets(
15750
17238
  repoRoot,