@plune-ai/cli 0.14.3 → 0.16.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.cjs CHANGED
@@ -75,8 +75,8 @@ var env_exports = {};
75
75
  __export(env_exports, {
76
76
  loadEnv: () => loadEnv
77
77
  });
78
- function loadEnv(configDir) {
79
- (0, import_dotenv.config)({ path: path.join(configDir, ".env"), override: false });
78
+ function loadEnv(dir) {
79
+ (0, import_dotenv.config)({ path: path.join(dir, ".env"), override: false });
80
80
  }
81
81
  var import_dotenv, path;
82
82
  var init_env = __esm({
@@ -523,11 +523,25 @@ var init_prices = __esm({
523
523
  "use strict";
524
524
  init_cjs_shims();
525
525
  PRICE_TABLE = {
526
- // Anthropic
526
+ // Anthropic, current models. Source: platform.claude.com/docs/en/about-claude/pricing — base
527
+ // input and output tokens per million, read on 2026-09-29, kept here per thousand.
528
+ "claude-fable-5-1": { input_per_1k_usd: 0.01, output_per_1k_usd: 0.05 },
529
+ "claude-opus-5-5": { input_per_1k_usd: 4e-3, output_per_1k_usd: 0.02 },
530
+ "claude-sonnet-5-5": { input_per_1k_usd: 2e-3, output_per_1k_usd: 0.01 },
531
+ "claude-haiku-4-5": { input_per_1k_usd: 1e-3, output_per_1k_usd: 5e-3 },
532
+ // The dated id of the same model; a config may name either.
533
+ "claude-haiku-4-5-20251001": { input_per_1k_usd: 1e-3, output_per_1k_usd: 5e-3 },
534
+ // Anthropic, older ids: unchanged, so a config that still names one prices as it did. Anthropic has
535
+ // retired these models — a new project should not start on them.
527
536
  "claude-3-5-haiku-latest": { input_per_1k_usd: 8e-4, output_per_1k_usd: 4e-3 },
528
537
  "claude-3-5-sonnet-latest": { input_per_1k_usd: 3e-3, output_per_1k_usd: 0.015 },
529
538
  "claude-3-opus-latest": { input_per_1k_usd: 0.015, output_per_1k_usd: 0.075 },
530
- // OpenAI
539
+ // OpenAI, current models. Source: developers.openai.com/api/docs/pricing — standard tier, the
540
+ // short-context rates (a long-context request costs more), read on 2026-09-29.
541
+ "gpt-6-astra": { input_per_1k_usd: 0.01, output_per_1k_usd: 0.05 },
542
+ "gpt-6.1-sol": { input_per_1k_usd: 2e-3, output_per_1k_usd: 0.01 },
543
+ "gpt-6-luna": { input_per_1k_usd: 1e-4, output_per_1k_usd: 5e-4 },
544
+ // OpenAI, older ids: unchanged (the same rates the page above still lists for both).
531
545
  "gpt-4o": { input_per_1k_usd: 25e-4, output_per_1k_usd: 0.01 },
532
546
  "gpt-4o-mini": { input_per_1k_usd: 15e-5, output_per_1k_usd: 6e-4 },
533
547
  // OpenAI via OpenRouter (namespaced ids). OpenRouter passes OpenAI list price through for
@@ -776,6 +790,25 @@ var init_providers = __esm({
776
790
  }
777
791
  });
778
792
 
793
+ // src/providers/estimate.ts
794
+ function makeEstimateProvider(config, pricing) {
795
+ return {
796
+ complete: () => Promise.reject(
797
+ new Error("The --dry-run provider only estimates cost; it never calls a model.")
798
+ ),
799
+ estimateCost: (usage, reportedCostUsd) => ({
800
+ cost_usd: resolveCost(usage, config.model, reportedCostUsd, pricing)
801
+ })
802
+ };
803
+ }
804
+ var init_estimate = __esm({
805
+ "src/providers/estimate.ts"() {
806
+ "use strict";
807
+ init_cjs_shims();
808
+ init_cost();
809
+ }
810
+ });
811
+
779
812
  // src/providers/mock.ts
780
813
  function makeMockProvider() {
781
814
  return {
@@ -1878,11 +1911,16 @@ __export(run_exports, {
1878
1911
  function isMockMode(env) {
1879
1912
  return env["PLUNE_MOCK_PROVIDER"] === "1";
1880
1913
  }
1914
+ function resolveProviderFor(config, dryRun) {
1915
+ if (isMockMode(process.env)) return () => makeMockProvider();
1916
+ if (dryRun) return (cfg) => makeEstimateProvider(cfg, config.pricing);
1917
+ return (cfg) => getProvider(cfg, process.env, config.pricing);
1918
+ }
1881
1919
  function buildRealDeps(config, baseDir, dryRun) {
1882
1920
  const dir = path5.join(baseDir, ".plune");
1883
1921
  fs5.mkdirSync(dir, { recursive: true });
1884
1922
  return {
1885
- resolveProvider: isMockMode(process.env) ? () => makeMockProvider() : (cfg) => getProvider(cfg, process.env, config.pricing),
1923
+ resolveProvider: resolveProviderFor(config, dryRun),
1886
1924
  embedder: getDefaultEmbedder(),
1887
1925
  cache: dryRun ? NOOP_CACHE : openCache(path5.join(dir, "cache.db")),
1888
1926
  now: Date.now,
@@ -1929,6 +1967,7 @@ var init_run2 = __esm({
1929
1967
  path5 = __toESM(require("path"), 1);
1930
1968
  init_loader();
1931
1969
  init_providers();
1970
+ init_estimate();
1932
1971
  init_mock();
1933
1972
  init_embeddings();
1934
1973
  init_cache2();
@@ -2241,7 +2280,7 @@ function createClient(opts) {
2241
2280
  const wait = opts.wait ?? sleep;
2242
2281
  const base = opts.apiUrl.replace(/\/+$/, "");
2243
2282
  const scrub = (text2) => opts.token === "" ? text2 : text2.split(opts.token).join("***");
2244
- async function send(method, path14, body) {
2283
+ async function send(method, path15, payload) {
2245
2284
  let last = {
2246
2285
  kind: "unavailable",
2247
2286
  status: null,
@@ -2250,13 +2289,13 @@ function createClient(opts) {
2250
2289
  for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
2251
2290
  let res;
2252
2291
  try {
2253
- res = await doFetch(`${base}${path14}`, {
2292
+ res = await doFetch(`${base}${path15}`, {
2254
2293
  method,
2255
- headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
2294
+ headers: { "content-type": payload?.type ?? "application/json", authorization: `Bearer ${opts.token}` },
2256
2295
  // A DELETE carries none. Sending `"undefined"` as a body is what a naive
2257
2296
  // `JSON.stringify(body)` would do, and some proxies answer that with a 400 nobody can
2258
2297
  // explain.
2259
- ...body === void 0 ? {} : { body: JSON.stringify(body) }
2298
+ ...payload === void 0 ? {} : { body: payload.body }
2260
2299
  });
2261
2300
  } catch (err) {
2262
2301
  last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
@@ -2282,8 +2321,9 @@ function createClient(opts) {
2282
2321
  return { ok: false, ...last };
2283
2322
  }
2284
2323
  return {
2285
- post: (path14, body) => send("POST", path14, body),
2286
- del: (path14) => send("DELETE", path14)
2324
+ post: (path15, body) => send("POST", path15, { body: JSON.stringify(body), type: "application/json" }),
2325
+ upload: (path15, bytes, contentType) => send("POST", path15, { body: bytes, type: contentType }),
2326
+ del: (path15) => send("DELETE", path15)
2287
2327
  };
2288
2328
  }
2289
2329
  var import_node_http, import_node_https, RETRY_DELAYS_MS, IDLE_MS, sleep;
@@ -2359,13 +2399,35 @@ function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cw
2359
2399
  const where = flag(env, "CI") || flag(env, "GITHUB_ACTIONS") ? "ci" : "local";
2360
2400
  return `${path6.basename(cwd)} \xB7 ${stamp} \xB7 ${where}`;
2361
2401
  }
2362
- var path6, UNSUPPORTED_VARS;
2402
+ function ciCommit(reported, env = process.env) {
2403
+ const said = typeof reported === "object" && reported !== null ? reported : {};
2404
+ const text2 = (raw) => typeof raw === "string" && raw.trim() !== "" ? raw.trim() : void 0;
2405
+ const provider = PROVIDERS.find((p) => value(env, p.is) !== void 0);
2406
+ const job = {
2407
+ sha: provider === void 0 ? void 0 : value(env, provider.sha),
2408
+ branch: provider?.branch.map((name) => value(env, name)).find((v) => v !== void 0)
2409
+ };
2410
+ const sha = text2(said["commitHash"]);
2411
+ const sameCommit = sha === void 0 || sha === job.sha;
2412
+ const commit = sha ?? job.sha;
2413
+ const branch = text2(said["branch"]) ?? (sameCommit ? job.branch : void 0);
2414
+ return {
2415
+ ...commit !== void 0 ? { sha: commit } : {},
2416
+ ...branch !== void 0 ? { branch } : {}
2417
+ };
2418
+ }
2419
+ var path6, UNSUPPORTED_VARS, PROVIDERS;
2363
2420
  var init_env2 = __esm({
2364
2421
  "src/reporter-core/env.ts"() {
2365
2422
  "use strict";
2366
2423
  init_cjs_shims();
2367
2424
  path6 = __toESM(require("path"), 1);
2368
2425
  UNSUPPORTED_VARS = ["PLUNE_GROUP"];
2426
+ PROVIDERS = [
2427
+ { is: "GITHUB_ACTIONS", sha: "GITHUB_SHA", branch: ["GITHUB_HEAD_REF", "GITHUB_REF_NAME"] },
2428
+ { is: "GITLAB_CI", sha: "CI_COMMIT_SHA", branch: ["CI_COMMIT_REF_NAME"] },
2429
+ { is: "JENKINS_URL", sha: "GIT_COMMIT", branch: ["GIT_BRANCH"] }
2430
+ ];
2369
2431
  }
2370
2432
  });
2371
2433
 
@@ -2376,6 +2438,8 @@ function appendBatch(file, batch) {
2376
2438
  ts: (/* @__PURE__ */ new Date()).toISOString(),
2377
2439
  runId: batch.runId,
2378
2440
  externalKey: batch.externalKey,
2441
+ ...batch.session !== void 0 ? { session: batch.session } : {},
2442
+ ...batch.meta !== void 0 ? { meta: batch.meta } : {},
2379
2443
  results: batch.results
2380
2444
  };
2381
2445
  fs6.appendFileSync(file, `${JSON.stringify(line)}
@@ -2393,6 +2457,19 @@ var init_fallback = __esm({
2393
2457
  });
2394
2458
 
2395
2459
  // src/reporter-core/session.ts
2460
+ function nameOf(file) {
2461
+ const own = file.name.split(/[\\/]/).pop()?.trim() || (0, import_node_path2.basename)(file.path);
2462
+ const fileExt = (0, import_node_path2.extname)(file.path);
2463
+ const ownExt = (0, import_node_path2.extname)(own);
2464
+ const typed = ownExt !== "" && (ownExt.toLowerCase() === fileExt.toLowerCase() || TYPE_BY_EXTENSION[ownExt.toLowerCase()] !== void 0);
2465
+ const ext = typed ? ownExt : fileExt;
2466
+ let stem = typed ? own.slice(0, -ownExt.length) : own;
2467
+ if (stem.length + ext.length > NAME_MAX) {
2468
+ stem = stem.slice(0, Math.max(0, NAME_MAX - ext.length));
2469
+ if (/[\uD800-\uDBFF]$/.test(stem)) stem = stem.slice(0, -1);
2470
+ }
2471
+ return `${stem}${ext}`;
2472
+ }
2396
2473
  function boundKey(key) {
2397
2474
  if (key.value.length <= KEY_MAX) return key;
2398
2475
  const digest = (0, import_node_crypto2.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
@@ -2400,6 +2477,12 @@ function boundKey(key) {
2400
2477
  if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
2401
2478
  return { ...key, value: `${head}#${digest}` };
2402
2479
  }
2480
+ function whyUnoffered(out) {
2481
+ if (out.ok || out.status !== 429) return "other";
2482
+ if (/review queue quota reached/i.test(out.detail)) return "queue";
2483
+ if (/test case quota reached/i.test(out.detail)) return "cases";
2484
+ return "other";
2485
+ }
2403
2486
  function boundTitle(title) {
2404
2487
  return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
2405
2488
  }
@@ -2441,6 +2524,7 @@ async function startRun(passed, declared) {
2441
2524
  `));
2442
2525
  const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
2443
2526
  const externalKey = cfg.externalKey ?? null;
2527
+ const sessionMarker = (0, import_node_crypto2.randomUUID)();
2444
2528
  const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
2445
2529
  const offerDiscovered = cfg.offerDiscovered ?? false;
2446
2530
  const token = cfg.token ?? loadToken() ?? "";
@@ -2458,7 +2542,8 @@ async function startRun(passed, declared) {
2458
2542
  offered: 0,
2459
2543
  created: 0,
2460
2544
  unoffered: 0,
2461
- deferred: 0
2545
+ deferred: 0,
2546
+ screenshots: { uploaded: 0, skipped: 0, failed: 0 }
2462
2547
  };
2463
2548
  const resolved = /* @__PURE__ */ new Map();
2464
2549
  let buffer = [];
@@ -2468,6 +2553,7 @@ async function startRun(passed, declared) {
2468
2553
  let offline = token === "";
2469
2554
  let done = false;
2470
2555
  const shortened = /* @__PURE__ */ new Set();
2556
+ const skipsSaid = /* @__PURE__ */ new Set();
2471
2557
  function bound(keys) {
2472
2558
  return keys.map((key) => {
2473
2559
  const fit = boundKey(key);
@@ -2490,7 +2576,13 @@ async function startRun(passed, declared) {
2490
2576
  log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
2491
2577
  }
2492
2578
  function defer(results) {
2493
- appendBatch(fallbackPath, { runId, externalKey, results });
2579
+ appendBatch(fallbackPath, {
2580
+ runId,
2581
+ externalKey,
2582
+ session: sessionMarker,
2583
+ ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
2584
+ results
2585
+ });
2494
2586
  stats.deferred += results.length;
2495
2587
  }
2496
2588
  async function resolveKeys(values) {
@@ -2587,6 +2679,7 @@ async function startRun(passed, declared) {
2587
2679
  });
2588
2680
  if (!out.ok || !Array.isArray(out.body?.results)) {
2589
2681
  stats.unoffered = batches.slice(index).reduce((n, rest) => n + rest.length, 0);
2682
+ stats.unofferedWhy = whyUnoffered(out);
2590
2683
  log(
2591
2684
  `plune: could not offer ${stats.unoffered} unknown test(s) for review (${out.ok ? "the answer had no results" : out.detail || out.kind}).`
2592
2685
  );
@@ -2596,6 +2689,60 @@ async function startRun(passed, declared) {
2596
2689
  stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
2597
2690
  }
2598
2691
  }
2692
+ function skip(name, why) {
2693
+ stats.screenshots.skipped += 1;
2694
+ if (skipsSaid.has(why)) return;
2695
+ skipsSaid.add(why);
2696
+ log(`plune: screenshot "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
2697
+ }
2698
+ async function uploadOne(shot) {
2699
+ let bytes;
2700
+ try {
2701
+ if ((await import_node_fs2.promises.stat(shot.path)).size > SCREENSHOT_BYTES) return skip(shot.name, "size");
2702
+ bytes = await import_node_fs2.promises.readFile(shot.path);
2703
+ } catch {
2704
+ return skip(shot.name, "missing");
2705
+ }
2706
+ const out = await client.upload(
2707
+ `/v1/results/${encodeURIComponent(shot.id)}/files?name=${encodeURIComponent(shot.name)}`,
2708
+ bytes,
2709
+ shot.type
2710
+ );
2711
+ if (out.ok) {
2712
+ stats.screenshots.uploaded += 1;
2713
+ return;
2714
+ }
2715
+ stats.screenshots.failed += 1;
2716
+ if (stats.screenshots.failed === 1) {
2717
+ log(`plune: could not upload a screenshot (${out.detail || out.kind}) \u2014 its result is reported without it.`);
2718
+ }
2719
+ }
2720
+ async function upload(items, filesByKey) {
2721
+ if (filesByKey.size === 0 || !Array.isArray(items)) return;
2722
+ const shots = [];
2723
+ for (const item of items) {
2724
+ if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
2725
+ let room = SCREENSHOTS_PER_RESULT;
2726
+ for (const file of filesByKey.get(item.resultKey) ?? []) {
2727
+ const type = file.contentType?.split(";")[0]?.trim().toLowerCase() || TYPE_BY_EXTENSION[(0, import_node_path2.extname)(file.path).toLowerCase()];
2728
+ if (!type?.startsWith("image/")) continue;
2729
+ const name = nameOf(file);
2730
+ if (file.name.startsWith("_")) skip(name, "hidden");
2731
+ else if (!SCREENSHOT_TYPES.has(type)) skip(name, "type");
2732
+ else if (room === 0) skip(name, "limit");
2733
+ else {
2734
+ room -= 1;
2735
+ shots.push({ id: item.id, name, type, path: file.path });
2736
+ }
2737
+ }
2738
+ }
2739
+ let next = 0;
2740
+ await Promise.all(
2741
+ Array.from({ length: Math.min(UPLOADS_AT_ONCE, shots.length) }, async () => {
2742
+ while (next < shots.length) await uploadOne(shots[next++]);
2743
+ })
2744
+ );
2745
+ }
2599
2746
  async function flush() {
2600
2747
  if (buffer.length === 0) return;
2601
2748
  const pending = buffer;
@@ -2607,11 +2754,13 @@ async function startRun(passed, declared) {
2607
2754
  await resolveKeys(pending.flatMap((p) => p.keys));
2608
2755
  const submissions = [];
2609
2756
  const unanswered = [];
2757
+ const filesByKey = /* @__PURE__ */ new Map();
2610
2758
  for (const result of pending) {
2611
2759
  const match = matchFor(result);
2612
2760
  if (match.kind === "found") {
2613
- const { keys: _keys, ...rest } = result;
2761
+ const { keys: _keys, files, ...rest } = result;
2614
2762
  submissions.push({ ...rest, testCaseId: match.testCaseId });
2763
+ if (files !== void 0) filesByKey.set(result.resultKey, files);
2615
2764
  } else if (match.kind === "unmatched") {
2616
2765
  stats.unresolved += 1;
2617
2766
  remember(result);
@@ -2629,14 +2778,16 @@ async function startRun(passed, declared) {
2629
2778
  defer(part);
2630
2779
  continue;
2631
2780
  }
2632
- const out = await client.post(`/v1/runs/${runId}/results`, {
2633
- results: part
2634
- });
2781
+ const out = await client.post(
2782
+ `/v1/runs/${runId}/results`,
2783
+ { results: part }
2784
+ );
2635
2785
  const counts = out.ok ? out.body?.counts : void 0;
2636
2786
  const told = COUNTED.filter((k) => typeof counts?.[k] === "number");
2637
2787
  if (told.length > 0) {
2638
2788
  for (const k of told) stats[k] += counts[k];
2639
2789
  progress();
2790
+ await upload(out.ok ? out.body?.items : void 0, filesByKey);
2640
2791
  continue;
2641
2792
  }
2642
2793
  if (out.ok) {
@@ -2666,6 +2817,12 @@ async function startRun(passed, declared) {
2666
2817
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
2667
2818
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
2668
2819
  if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
2820
+ const shots = stats.screenshots;
2821
+ if (shots.uploaded + shots.skipped + shots.failed > 0) {
2822
+ parts.push(
2823
+ `${shots.uploaded} screenshot(s) uploaded` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} failed to upload` : "")
2824
+ );
2825
+ }
2669
2826
  if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
2670
2827
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
2671
2828
  log(`plune: ${parts.join(" \xB7 ")}`);
@@ -2717,12 +2874,14 @@ async function startRun(passed, declared) {
2717
2874
  }
2718
2875
  };
2719
2876
  }
2720
- var import_node_crypto2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, BATCH_BYTES, ENVELOPE_BYTES;
2877
+ var import_node_crypto2, import_node_fs2, import_node_path2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TYPE_BY_EXTENSION, UPLOADS_AT_ONCE, NAME_MAX, SKIPPED, BATCH_BYTES, ENVELOPE_BYTES;
2721
2878
  var init_session = __esm({
2722
2879
  "src/reporter-core/session.ts"() {
2723
2880
  "use strict";
2724
2881
  init_cjs_shims();
2725
2882
  import_node_crypto2 = require("crypto");
2883
+ import_node_fs2 = require("fs");
2884
+ import_node_path2 = require("path");
2726
2885
  init_api_url();
2727
2886
  init_credentials();
2728
2887
  init_client();
@@ -2736,6 +2895,24 @@ var init_session = __esm({
2736
2895
  KEY_MAX = 1024;
2737
2896
  TITLE_MAX = 300;
2738
2897
  COUNTED = ["accepted", "duplicate", "conflict", "rejected"];
2898
+ SCREENSHOT_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
2899
+ SCREENSHOT_BYTES = 2 * 1024 * 1024;
2900
+ SCREENSHOTS_PER_RESULT = 20;
2901
+ TYPE_BY_EXTENSION = {
2902
+ ".png": "image/png",
2903
+ ".jpg": "image/jpeg",
2904
+ ".jpeg": "image/jpeg",
2905
+ ".webp": "image/webp"
2906
+ };
2907
+ UPLOADS_AT_ONCE = 4;
2908
+ NAME_MAX = 200;
2909
+ SKIPPED = {
2910
+ hidden: "its name starts with an underscore, which hides an attachment in Playwright's own reports too",
2911
+ type: "Plune takes PNG, JPEG and WebP",
2912
+ limit: `a result holds at most ${SCREENSHOTS_PER_RESULT}`,
2913
+ missing: "the file cannot be read on this machine",
2914
+ size: "Plune takes a file of at most 2 MB"
2915
+ };
2739
2916
  BATCH_BYTES = 8 * 1024 * 1024;
2740
2917
  ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ results: [] }));
2741
2918
  }
@@ -2859,23 +3036,42 @@ async function handleRunReport(options = {}) {
2859
3036
  if (lines.length === 0) throw new RunCommandError(`Nothing to send \u2014 ${file} is empty.`);
2860
3037
  const { client } = connect(options);
2861
3038
  const result = { batches: lines.length, sent: 0, failed: 0 };
3039
+ const groups = [];
3040
+ const bySession = /* @__PURE__ */ new Map();
2862
3041
  for (const line of lines) {
2863
- const pending = line.results.filter(isPending);
2864
- const ready = line.results.filter((r) => !isPending(r));
2865
- if (line.runId !== null && ready.length > 0) {
2866
- const out = await client.post(`/v1/runs/${line.runId}/results`, { results: ready });
2867
- if (out.ok) result.sent += ready.length;
2868
- else {
2869
- result.failed += ready.length;
2870
- write(`Could not send ${ready.length} result(s) to run ${line.runId} \u2014 ${out.detail || out.kind}.`);
3042
+ const group = line.session === void 0 ? void 0 : bySession.get(line.session);
3043
+ if (group !== void 0) {
3044
+ group.push(line);
3045
+ continue;
3046
+ }
3047
+ const alone = [line];
3048
+ groups.push(alone);
3049
+ if (line.session !== void 0) bySession.set(line.session, alone);
3050
+ }
3051
+ for (const group of groups) {
3052
+ for (const line of group) {
3053
+ const ready = line.results.filter((r) => !isPending(r));
3054
+ if (line.runId !== null && ready.length > 0) {
3055
+ const out = await client.post(`/v1/runs/${line.runId}/results`, { results: ready });
3056
+ if (out.ok) result.sent += ready.length;
3057
+ else {
3058
+ result.failed += ready.length;
3059
+ write(
3060
+ `Could not send ${ready.length} result(s) to run ${line.runId} \u2014 ${out.detail || out.kind}.`
3061
+ );
3062
+ }
2871
3063
  }
2872
3064
  }
3065
+ const pending = group.flatMap((line) => line.results.filter(isPending));
2873
3066
  if (pending.length > 0) {
3067
+ const first = group[0];
2874
3068
  const session = await startRun({
2875
3069
  ...options.apiUrl !== void 0 ? { apiUrl: options.apiUrl } : {},
2876
3070
  ...options.token !== void 0 ? { token: options.token } : {},
2877
3071
  ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
2878
- ...line.externalKey !== null ? { externalKey: line.externalKey } : {},
3072
+ ...first.externalKey !== null ? { externalKey: first.externalKey } : {},
3073
+ // Where the run came from, as the reporter recorded it (plune#927) — never this machine's own.
3074
+ ...first.meta !== void 0 ? { meta: first.meta } : {},
2879
3075
  // The replay does not know whether the run is complete, so it must not say that it is.
2880
3076
  log: write
2881
3077
  });
@@ -2886,15 +3082,34 @@ async function handleRunReport(options = {}) {
2886
3082
  }
2887
3083
  }
2888
3084
  write(`Replayed ${result.batches} batch(es) from ${file}: ${result.sent} sent, ${result.failed} not.`);
3085
+ if (result.failed === 0) {
3086
+ const sentAs = sentName(file, (options.now ?? Date.now)());
3087
+ try {
3088
+ fs7.renameSync(file, sentAs);
3089
+ write(
3090
+ `Every result was sent \u2014 ${file} is now ${sentAs}, so a later replay does not send them twice.`
3091
+ );
3092
+ } catch (err) {
3093
+ const why = err instanceof Error ? err.message : String(err);
3094
+ write(
3095
+ `Every result was sent, but ${file} could not be renamed (${why}) \u2014 take it away by hand, or a later replay sends them again.`
3096
+ );
3097
+ }
3098
+ }
2889
3099
  return result;
2890
3100
  }
2891
- var import_node_child_process, fs7, NoTokenError, RunCommandError, isPending;
3101
+ function sentName(file, at) {
3102
+ const { dir, name, ext } = path8.parse(file);
3103
+ return path8.join(dir, `${name}.${new Date(at).toISOString().replace(/[:.]/g, "-")}.sent${ext}`);
3104
+ }
3105
+ var import_node_child_process, fs7, path8, NoTokenError, RunCommandError, isPending;
2892
3106
  var init_run_lifecycle = __esm({
2893
3107
  "src/cli/commands/run-lifecycle.ts"() {
2894
3108
  "use strict";
2895
3109
  init_cjs_shims();
2896
3110
  import_node_child_process = require("child_process");
2897
3111
  fs7 = __toESM(require("fs"), 1);
3112
+ path8 = __toESM(require("path"), 1);
2898
3113
  init_api_url();
2899
3114
  init_credentials();
2900
3115
  init_client();
@@ -3102,7 +3317,12 @@ function errorTextOf(detail) {
3102
3317
  const head = [detail.attrs["message"], detail.attrs["type"]].filter((s) => s !== void 0 && s !== "");
3103
3318
  return [...head, detail.text.trim()].filter((s) => s !== "").join("\n\n");
3104
3319
  }
3105
- function keyOf(classname, name) {
3320
+ function playwrightOf(classname, name) {
3321
+ if (!SPEC_FILE.test(classname)) return void 0;
3322
+ return { file: classname.replaceAll("\\", "/"), titles: name.split(" \u203A ") };
3323
+ }
3324
+ function keyOf(classname, name, playwright) {
3325
+ if (playwright !== void 0) return { kind: "path-title", value: [playwright.file, ...playwright.titles].join("#") };
3106
3326
  return { kind: "path-title", value: classname === "" ? name : `${classname}#${name}` };
3107
3327
  }
3108
3328
  function titleOf(classname, name) {
@@ -3142,12 +3362,15 @@ function readJUnit(source, file) {
3142
3362
  const name = test.attrs["name"] ?? "";
3143
3363
  const classname = test.attrs["classname"] ?? test.attrs["class"] ?? "";
3144
3364
  const { status, detail } = outcomeOf(test);
3145
- const key = keyOf(classname, name);
3365
+ const playwright = playwrightOf(classname, name);
3366
+ const key = keyOf(classname, name, playwright);
3367
+ const stated = TOKEN.exec(playwright?.titles.at(-1) ?? name)?.[1];
3146
3368
  const specRef = specRefOf(test, suite, classname);
3147
3369
  const errorContext = errorTextOf(detail);
3148
3370
  const execution = executionOf(test, suite);
3149
3371
  return {
3150
3372
  resultKey: resultKey(key.value, 0),
3373
+ ...stated !== void 0 ? { testCaseId: stated } : {},
3151
3374
  keys: [key],
3152
3375
  source: SOURCE,
3153
3376
  rawStatus: status,
@@ -3161,7 +3384,7 @@ function readJUnit(source, file) {
3161
3384
  function looksLikeJUnit(source) {
3162
3385
  return /<\s*testsuites?\b/.test(source.slice(0, 4096));
3163
3386
  }
3164
- var SOURCE;
3387
+ var SOURCE, TOKEN, SPEC_FILE;
3165
3388
  var init_junit = __esm({
3166
3389
  "src/importers/junit.ts"() {
3167
3390
  "use strict";
@@ -3169,6 +3392,8 @@ var init_junit = __esm({
3169
3392
  init_result_key();
3170
3393
  init_xml();
3171
3394
  SOURCE = "junit";
3395
+ TOKEN = /@P([A-Za-z0-9_-]+)/;
3396
+ SPEC_FILE = /\.(?:spec|test)\.[cm]?[jt]sx?$/;
3172
3397
  }
3173
3398
  });
3174
3399
 
@@ -3191,15 +3416,18 @@ function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
3191
3416
  return Object.keys(failure).length === 0 ? void 0 : failure;
3192
3417
  }
3193
3418
  function repoRootOf(dir, stopAt) {
3194
- for (let here = (0, import_node_path2.resolve)(dir); ; here = (0, import_node_path2.dirname)(here)) {
3195
- if ((0, import_node_fs2.existsSync)((0, import_node_path2.join)(here, ".git"))) return here;
3196
- if (here === stopAt || (0, import_node_path2.dirname)(here) === here) return void 0;
3419
+ for (let here = (0, import_node_path3.resolve)(dir); ; here = (0, import_node_path3.dirname)(here)) {
3420
+ if ((0, import_node_fs3.existsSync)((0, import_node_path3.join)(here, ".git"))) return here;
3421
+ if (here === stopAt || (0, import_node_path3.dirname)(here) === here) return void 0;
3197
3422
  }
3198
3423
  }
3199
3424
  function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
3200
3425
  const text2 = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
3201
3426
  return cut(machineless(text2, attempt.repoRoot, home));
3202
3427
  }
3428
+ function filesOf(attempt) {
3429
+ return attempt.attachments.filter((a) => a.path !== void 0);
3430
+ }
3203
3431
  function machineless(raw, repoRoot, home) {
3204
3432
  let text2 = (0, import_node_util.stripVTControlCharacters)(raw).replaceAll("\x1B", "");
3205
3433
  const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
@@ -3209,12 +3437,12 @@ function machineless(raw, repoRoot, home) {
3209
3437
  if (repoRoot === void 0) text2 = text2.replace(ANY_HOME, "~");
3210
3438
  return text2;
3211
3439
  }
3212
- function pathIn(path14) {
3213
- const parts = path14.split(/[\\/]+/);
3440
+ function pathIn(path15) {
3441
+ const parts = path15.split(/[\\/]+/);
3214
3442
  while (parts.length > 1 && parts.at(-1) === "") parts.pop();
3215
3443
  if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
3216
3444
  const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
3217
- const escaped = /^\/(?!\/)/.test(path14) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
3445
+ const escaped = /^\/(?!\/)/.test(path15) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
3218
3446
  const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
3219
3447
  return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
3220
3448
  }
@@ -3259,12 +3487,12 @@ function placeOf(error, testFile) {
3259
3487
  }
3260
3488
  function fileOf(raw) {
3261
3489
  if (!raw.startsWith("file://")) return raw;
3262
- let path14 = raw.slice("file://".length);
3490
+ let path15 = raw.slice("file://".length);
3263
3491
  try {
3264
- path14 = decodeURIComponent(path14);
3492
+ path15 = decodeURIComponent(path15);
3265
3493
  } catch {
3266
3494
  }
3267
- return /^\/[A-Za-z]:\//.test(path14) ? path14.slice(1) : path14;
3495
+ return /^\/[A-Za-z]:\//.test(path15) ? path15.slice(1) : path15;
3268
3496
  }
3269
3497
  function fromRoot(place, root) {
3270
3498
  if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
@@ -3279,14 +3507,14 @@ function fromRoot(place, root) {
3279
3507
  function webLink(href) {
3280
3508
  return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
3281
3509
  }
3282
- var import_node_fs2, import_node_os2, import_node_path2, import_node_util, PATH_START, TEXT_LIMIT, HEADLINE_LIMIT, jsonBytes, ANY_HOME, fileNameOf, omitted, METADATA_TYPE, REPO_RELATIVE, FRAME, FRAME_MAX, firstLine, normal;
3510
+ var import_node_fs3, import_node_os2, import_node_path3, import_node_util, PATH_START, TEXT_LIMIT, HEADLINE_LIMIT, jsonBytes, ANY_HOME, fileNameOf, omitted, METADATA_TYPE, REPO_RELATIVE, FRAME, FRAME_MAX, firstLine, normal;
3283
3511
  var init_failure_detail = __esm({
3284
3512
  "src/reporter-core/failure-detail.ts"() {
3285
3513
  "use strict";
3286
3514
  init_cjs_shims();
3287
- import_node_fs2 = require("fs");
3515
+ import_node_fs3 = require("fs");
3288
3516
  import_node_os2 = require("os");
3289
- import_node_path2 = require("path");
3517
+ import_node_path3 = require("path");
3290
3518
  import_node_util = require("util");
3291
3519
  PATH_START = "(?<![\\w.~%/\\\\-])";
3292
3520
  TEXT_LIMIT = 524288;
@@ -3300,7 +3528,7 @@ var init_failure_detail = __esm({
3300
3528
  FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
3301
3529
  FRAME_MAX = 8192;
3302
3530
  firstLine = (text2) => (0, import_node_util.stripVTControlCharacters)(text2).split("\n").map((line) => line.trim()).find((line) => line !== "");
3303
- normal = (path14) => import_node_path2.posix.normalize(path14.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
3531
+ normal = (path15) => import_node_path3.posix.normalize(path15.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
3304
3532
  }
3305
3533
  });
3306
3534
 
@@ -3308,7 +3536,7 @@ var init_failure_detail = __esm({
3308
3536
  function statedIdOf(test, title) {
3309
3537
  const annotation = test.annotations?.find((a) => a.type === "PluneId")?.description;
3310
3538
  if (annotation !== void 0 && annotation !== "") return annotation;
3311
- return TOKEN.exec(title)?.[1];
3539
+ return TOKEN2.exec(title)?.[1];
3312
3540
  }
3313
3541
  function attemptOf(result, file, ctx) {
3314
3542
  const steps = (list2) => (list2 ?? []).map((s) => ({ title: s.title ?? "", failed: s.error !== void 0, steps: steps(s.steps) }));
@@ -3319,10 +3547,10 @@ function attemptOf(result, file, ctx) {
3319
3547
  ...typeof at?.file === "string" && typeof at.line === "number" ? { location: { file: at.file, line: at.line, ...typeof at.column === "number" ? { column: at.column } : {} } } : {}
3320
3548
  })),
3321
3549
  steps: steps(result.steps),
3322
- attachments: (result.attachments ?? []).map(({ name, contentType, path: path14 }) => ({
3550
+ attachments: (result.attachments ?? []).map(({ name, contentType, path: path15 }) => ({
3323
3551
  name: name ?? "",
3324
3552
  ...contentType !== void 0 ? { contentType } : {},
3325
- ...path14 !== void 0 ? { path: path14 } : {}
3553
+ ...path15 !== void 0 ? { path: path15 } : {}
3326
3554
  })),
3327
3555
  ...ctx.buildHref !== void 0 ? { buildHref: ctx.buildHref } : {},
3328
3556
  // Spec files are posix and relative to the root dir; the core reads either slash.
@@ -3338,6 +3566,7 @@ function pendingFrom(spec, test, result, file, titles, ctx) {
3338
3566
  const attempt = attemptOf(result, file, ctx);
3339
3567
  const errorContext = errorContextOf(attempt);
3340
3568
  const failure = NOT_FAILED.has(attempt.status) ? void 0 : failureOf(attempt);
3569
+ const files = filesOf(attempt);
3341
3570
  const keys = [
3342
3571
  ...test.id !== void 0 ? [{ kind: "playwright-id", value: test.id }] : [],
3343
3572
  { kind: "path-title", value: pathTitle }
@@ -3366,7 +3595,8 @@ function pendingFrom(spec, test, result, file, titles, ctx) {
3366
3595
  }
3367
3596
  } : {},
3368
3597
  ...errorContext !== "" ? { errorContext } : {},
3369
- ...failure !== void 0 ? { failure } : {}
3598
+ ...failure !== void 0 ? { failure } : {},
3599
+ ...files.length > 0 ? { files } : {}
3370
3600
  };
3371
3601
  }
3372
3602
  function walk(suite, describes, depth, ctx, out) {
@@ -3393,7 +3623,7 @@ function readPlaywrightJson(source, file) {
3393
3623
  const { config, suites } = parsed;
3394
3624
  const rootDir = typeof config?.rootDir === "string" ? config.rootDir : void 0;
3395
3625
  const buildHref = config?.metadata?.ci?.buildHref;
3396
- const repoRoot = rootDir !== void 0 && (0, import_node_fs3.existsSync)(rootDir) ? repoRootOf(rootDir) : void 0;
3626
+ const repoRoot = rootDir !== void 0 && (0, import_node_fs4.existsSync)(rootDir) ? repoRootOf(rootDir) : void 0;
3397
3627
  const ctx = {
3398
3628
  ...rootDir !== void 0 ? { rootDir } : {},
3399
3629
  ...repoRoot !== void 0 ? { repoRoot } : {},
@@ -3402,21 +3632,26 @@ function readPlaywrightJson(source, file) {
3402
3632
  const out = [];
3403
3633
  for (const suite of suites) walk(suite, [], 0, ctx, out);
3404
3634
  const ciUrl = webLink(ctx.buildHref);
3405
- return ciUrl === void 0 ? { results: out } : { results: out, ciUrl };
3635
+ const ci = config?.metadata?.ci;
3636
+ return {
3637
+ results: out,
3638
+ ...ciUrl !== void 0 ? { ciUrl } : {},
3639
+ ...ci !== void 0 ? { ci } : {}
3640
+ };
3406
3641
  }
3407
3642
  function looksLikePlaywrightJson(source) {
3408
3643
  return /^\s*\{/.test(source) && source.includes('"suites"');
3409
3644
  }
3410
- var import_node_fs3, SOURCE2, TOKEN, NOT_FAILED, JsonReportError;
3645
+ var import_node_fs4, SOURCE2, TOKEN2, NOT_FAILED, JsonReportError;
3411
3646
  var init_playwright_json = __esm({
3412
3647
  "src/importers/playwright-json.ts"() {
3413
3648
  "use strict";
3414
3649
  init_cjs_shims();
3415
- import_node_fs3 = require("fs");
3650
+ import_node_fs4 = require("fs");
3416
3651
  init_failure_detail();
3417
3652
  init_result_key();
3418
3653
  SOURCE2 = "playwright";
3419
- TOKEN = /@P([A-Za-z0-9_-]+)/;
3654
+ TOKEN2 = /@P([A-Za-z0-9_-]+)/;
3420
3655
  NOT_FAILED = /* @__PURE__ */ new Set(["passed", "skipped"]);
3421
3656
  JsonReportError = class extends Error {
3422
3657
  constructor(file, detail) {
@@ -3485,7 +3720,7 @@ async function handleRunImport(options) {
3485
3720
  } catch {
3486
3721
  throw new RunCommandError(`Cannot read ${options.file}.`);
3487
3722
  }
3488
- const { format, results, ciUrl } = readReport(source, options.file, options.format);
3723
+ const { format, results, ciUrl, ci } = readReport(source, options.file, options.format);
3489
3724
  if (results.length === 0) {
3490
3725
  throw new RunCommandError(`No test cases in ${options.file} \u2014 it parsed, but there is nothing in it.`);
3491
3726
  }
@@ -3502,7 +3737,10 @@ async function handleRunImport(options) {
3502
3737
  ...options.create === true ? { offerDiscovered: true } : {},
3503
3738
  kind: "automated",
3504
3739
  // The CI run the report names, never this machine's: whoever imports may be another job (#790).
3505
- meta: { runner: format, ...ciUrl !== void 0 ? { ciUrl } : {} },
3740
+ // The commit and branch likewise, where the report names a commit; a report that names none —
3741
+ // JUnit's never does — takes this job's own, which is where the tests ran when import follows
3742
+ // them in one job (plune-ai/plune#927).
3743
+ meta: { runner: format, ...ciUrl !== void 0 ? { ciUrl } : {}, ...ciCommit(ci) },
3506
3744
  log: write
3507
3745
  },
3508
3746
  // The file is the complete list of what ran, so the platform can resolve every test in one
@@ -3526,7 +3764,9 @@ async function handleRunImport(options) {
3526
3764
  offered: stats.offered,
3527
3765
  created: stats.created,
3528
3766
  unoffered: stats.unoffered,
3767
+ ...stats.unofferedWhy !== void 0 ? { unofferedWhy: stats.unofferedWhy } : {},
3529
3768
  deferred: stats.deferred,
3769
+ screenshots: { ...stats.screenshots },
3530
3770
  unlocatable: results.filter((r) => r.specRef === void 0).length,
3531
3771
  offering: options.create === true
3532
3772
  };
@@ -3550,6 +3790,12 @@ function describe(result, apiUrl) {
3550
3790
  if (result.conflict > 0 || result.rejected > 0) {
3551
3791
  lines.push(`${result.conflict} conflicted and ${result.rejected} were refused.`);
3552
3792
  }
3793
+ const shots = result.screenshots;
3794
+ if (shots.uploaded + shots.skipped + shots.failed > 0) {
3795
+ lines.push(
3796
+ `${shots.uploaded} screenshot(s) uploaded to their results` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} could not be uploaded` : "") + "."
3797
+ );
3798
+ }
3553
3799
  if (result.deferred > 0) {
3554
3800
  lines.push(
3555
3801
  `${result.deferred} could not be sent and are in the fallback file \u2014 "plune run report" retries them.`
@@ -3569,7 +3815,7 @@ function describe(result, apiUrl) {
3569
3815
  }
3570
3816
  if (result.unoffered > 0) {
3571
3817
  lines.push(
3572
- `${result.unoffered} more could not be offered \u2014 the review queue is full. Approve or reject what is waiting, then import this report again to offer the rest.`
3818
+ `${result.unoffered} more could not be offered \u2014 ${NEXT[result.unofferedWhy ?? "other"]}`
3573
3819
  );
3574
3820
  }
3575
3821
  if (result.unlocatable > 0) {
@@ -3580,7 +3826,7 @@ function describe(result, apiUrl) {
3580
3826
  if (result.runId !== null) lines.push(runUrl2(apiUrl, result.runId));
3581
3827
  return lines;
3582
3828
  }
3583
- var fs8;
3829
+ var fs8, NEXT;
3584
3830
  var init_run_import = __esm({
3585
3831
  "src/cli/commands/run-import.ts"() {
3586
3832
  "use strict";
@@ -3592,6 +3838,11 @@ var init_run_import = __esm({
3592
3838
  init_api_url();
3593
3839
  init_credentials();
3594
3840
  init_env2();
3841
+ NEXT = {
3842
+ queue: "the review queue is full. Approve or reject what is waiting, then import this report again to offer the rest.",
3843
+ cases: "the project is at its test case limit. Delete cases you no longer need, or ask an operator to raise the limit, then import this report again to offer the rest.",
3844
+ other: 'the reason is in the "could not offer" line above.'
3845
+ };
3595
3846
  }
3596
3847
  });
3597
3848
 
@@ -3608,7 +3859,7 @@ function isRunResult(v) {
3608
3859
  }
3609
3860
  function handleReport(opts = {}) {
3610
3861
  const cwd = opts.cwd ?? process.cwd();
3611
- const file = path8.join(cwd, ".plune", "last-run.json");
3862
+ const file = path9.join(cwd, ".plune", "last-run.json");
3612
3863
  let raw;
3613
3864
  try {
3614
3865
  raw = fs9.readFileSync(file, "utf8");
@@ -3626,13 +3877,13 @@ function handleReport(opts = {}) {
3626
3877
  }
3627
3878
  return parsed;
3628
3879
  }
3629
- var fs9, path8, ReportNotFoundError;
3880
+ var fs9, path9, ReportNotFoundError;
3630
3881
  var init_report = __esm({
3631
3882
  "src/cli/commands/report.ts"() {
3632
3883
  "use strict";
3633
3884
  init_cjs_shims();
3634
3885
  fs9 = __toESM(require("fs"), 1);
3635
- path8 = __toESM(require("path"), 1);
3886
+ path9 = __toESM(require("path"), 1);
3636
3887
  ReportNotFoundError = class extends Error {
3637
3888
  code = "NO_SAVED_RUN";
3638
3889
  constructor(message) {
@@ -3836,7 +4087,7 @@ async function runInitWizard(cwd) {
3836
4087
  process.exit(1);
3837
4088
  return;
3838
4089
  }
3839
- const defaultModel = provider === "anthropic" ? "claude-3-opus" : "gpt-4o";
4090
+ const defaultModel = provider === "anthropic" ? "claude-sonnet-5-5" : "gpt-4o-mini";
3840
4091
  const model = await clack.text({
3841
4092
  message: "Model name:",
3842
4093
  defaultValue: defaultModel,
@@ -3856,7 +4107,7 @@ async function runInitWizard(cwd) {
3856
4107
  process.exit(1);
3857
4108
  return;
3858
4109
  }
3859
- const outputPath = path9.join(cwd, "plune.yaml");
4110
+ const outputPath = path10.join(cwd, "plune.yaml");
3860
4111
  if (fs11.existsSync(outputPath)) {
3861
4112
  const overwrite = await clack.confirm({
3862
4113
  message: "plune.yaml already exists. Overwrite?"
@@ -3900,14 +4151,14 @@ async function runInitWizard(cwd) {
3900
4151
  spin.stop("plune.yaml created!");
3901
4152
  clack.outro("plune.yaml is ready.");
3902
4153
  }
3903
- var clack, fs11, path9, import_yaml2;
4154
+ var clack, fs11, path10, import_yaml2;
3904
4155
  var init_wizard = __esm({
3905
4156
  "src/config/init/wizard.ts"() {
3906
4157
  "use strict";
3907
4158
  init_cjs_shims();
3908
4159
  clack = __toESM(require("@clack/prompts"), 1);
3909
4160
  fs11 = __toESM(require("fs"), 1);
3910
- path9 = __toESM(require("path"), 1);
4161
+ path10 = __toESM(require("path"), 1);
3911
4162
  import_yaml2 = require("yaml");
3912
4163
  init_errors();
3913
4164
  }
@@ -3915,21 +4166,21 @@ var init_wizard = __esm({
3915
4166
 
3916
4167
  // src/cli/templates/index.ts
3917
4168
  function writeTemplateFile(cwd, rel, content, force) {
3918
- const target = path10.join(cwd, rel);
4169
+ const target = path11.join(cwd, rel);
3919
4170
  if (!force && fs12.existsSync(target)) {
3920
4171
  return "skipped";
3921
4172
  }
3922
- fs12.mkdirSync(path10.dirname(target), { recursive: true });
4173
+ fs12.mkdirSync(path11.dirname(target), { recursive: true });
3923
4174
  fs12.writeFileSync(target, content, "utf8");
3924
4175
  return "written";
3925
4176
  }
3926
- var fs12, path10, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
4177
+ var fs12, path11, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
3927
4178
  var init_templates = __esm({
3928
4179
  "src/cli/templates/index.ts"() {
3929
4180
  "use strict";
3930
4181
  init_cjs_shims();
3931
4182
  fs12 = __toESM(require("fs"), 1);
3932
- path10 = __toESM(require("path"), 1);
4183
+ path11 = __toESM(require("path"), 1);
3933
4184
  ENV_EXAMPLE_TPL = `# Copy this file to .env and replace the placeholders with your real keys.
3934
4185
  # .env is gitignored; .env.example is safe to commit.
3935
4186
  # Plune reads whichever key matches your configured provider.
@@ -3950,7 +4201,7 @@ OPENROUTER_API_KEY=YOUR_OPENROUTER_API_KEY_HERE
3950
4201
  version: 1
3951
4202
  provider:
3952
4203
  type: anthropic
3953
- model: claude-3-5-sonnet-latest
4204
+ model: claude-sonnet-5-5
3954
4205
  evals:
3955
4206
  - id: example
3956
4207
  prompt: "Answer concisely. {{question}}"
@@ -4185,7 +4436,7 @@ __export(sync_exports, {
4185
4436
  reportSyncFailure: () => reportSyncFailure
4186
4437
  });
4187
4438
  function readRun(cwd, file) {
4188
- const target = file !== void 0 ? path11.resolve(cwd, file) : path11.join(cwd, ".plune", "last-run.json");
4439
+ const target = file !== void 0 ? path12.resolve(cwd, file) : path12.join(cwd, ".plune", "last-run.json");
4189
4440
  let raw;
4190
4441
  try {
4191
4442
  raw = fs13.readFileSync(target, "utf8");
@@ -4252,13 +4503,13 @@ function reportSyncFailure(err, write) {
4252
4503
  }
4253
4504
  return null;
4254
4505
  }
4255
- var fs13, path11, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
4506
+ var fs13, path12, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
4256
4507
  var init_sync = __esm({
4257
4508
  "src/cli/commands/sync.ts"() {
4258
4509
  "use strict";
4259
4510
  init_cjs_shims();
4260
4511
  fs13 = __toESM(require("fs"), 1);
4261
- path11 = __toESM(require("path"), 1);
4512
+ path12 = __toESM(require("path"), 1);
4262
4513
  init_run_result_schema();
4263
4514
  init_api_url();
4264
4515
  init_credentials();
@@ -4308,9 +4559,9 @@ async function findLatestCairnRun(runsDir = "runs") {
4308
4559
  let newest;
4309
4560
  for (const entry of entries) {
4310
4561
  if (!entry.isDirectory()) continue;
4311
- const dir = path12.join(runsDir, entry.name);
4562
+ const dir = path13.join(runsDir, entry.name);
4312
4563
  try {
4313
- const report = await fs14.lstat(path12.join(dir, "report.json"));
4564
+ const report = await fs14.lstat(path13.join(dir, "report.json"));
4314
4565
  if (!report.isFile()) continue;
4315
4566
  if (newest === void 0 || report.mtimeMs > newest.at) {
4316
4567
  newest = { dir, at: report.mtimeMs };
@@ -4329,7 +4580,7 @@ async function readJson(file) {
4329
4580
  }
4330
4581
  }
4331
4582
  async function defaultReadEvidence(dir) {
4332
- const raw = await readJson(path12.join(dir, "api-evidence.json"));
4583
+ const raw = await readJson(path13.join(dir, "api-evidence.json"));
4333
4584
  if (!Array.isArray(raw)) return void 0;
4334
4585
  return raw.map((r) => {
4335
4586
  const rec = asRecord(r);
@@ -4371,7 +4622,7 @@ function casesFromApi(report, verdicts) {
4371
4622
  });
4372
4623
  }
4373
4624
  async function readCairnRun(dir, deps = {}) {
4374
- const raw = await readJson(path12.join(dir, "report.json"));
4625
+ const raw = await readJson(path13.join(dir, "report.json"));
4375
4626
  if (raw === void 0) throw new CairnRunNotFoundError(dir);
4376
4627
  const report = asRecord(raw);
4377
4628
  if (report["schemaVersion"] !== SUPPORTED_ARTIFACT_VERSION) {
@@ -4401,20 +4652,20 @@ async function readCairnRun(dir, deps = {}) {
4401
4652
  return {
4402
4653
  schemaVersion: INGEST_SCHEMA_VERSION,
4403
4654
  run: {
4404
- runId: str(report["runId"], path12.basename(dir)),
4655
+ runId: str(report["runId"], path13.basename(dir)),
4405
4656
  mode,
4406
4657
  target: str(report["url"], "unknown")
4407
4658
  },
4408
4659
  cases
4409
4660
  };
4410
4661
  }
4411
- var fs14, path12, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
4662
+ var fs14, path13, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
4412
4663
  var init_cairn_artifact = __esm({
4413
4664
  "src/cli/commands/cairn-artifact.ts"() {
4414
4665
  "use strict";
4415
4666
  init_cjs_shims();
4416
4667
  fs14 = __toESM(require("fs/promises"), 1);
4417
- path12 = __toESM(require("path"), 1);
4668
+ path13 = __toESM(require("path"), 1);
4418
4669
  SUPPORTED_ARTIFACT_VERSION = 1;
4419
4670
  INGEST_SCHEMA_VERSION = 1;
4420
4671
  NoCairnRunsError = class extends Error {
@@ -4581,7 +4832,7 @@ __export(cases_exports, {
4581
4832
  function gitDirty(file) {
4582
4833
  try {
4583
4834
  const out = (0, import_node_child_process2.execFileSync)("git", ["status", "--porcelain", "--", file], {
4584
- cwd: path13.dirname(file),
4835
+ cwd: path14.dirname(file),
4585
4836
  stdio: ["ignore", "pipe", "ignore"],
4586
4837
  encoding: "utf8"
4587
4838
  });
@@ -4607,9 +4858,9 @@ async function handlePull(deps = {}) {
4607
4858
  const cwd = deps.cwd ?? process.cwd();
4608
4859
  const apiUrl = resolveApiUrl(deps.apiUrl);
4609
4860
  const token = tokenOf(deps);
4610
- const file = path13.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
4861
+ const file = path14.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
4611
4862
  if (fs15.existsSync(file) && !deps.force && (deps.isDirty ?? gitDirty)(file))
4612
- throw new DirtyFileError(path13.relative(cwd, file) || file);
4863
+ throw new DirtyFileError(path14.relative(cwd, file) || file);
4613
4864
  const doFetch = deps.fetchImpl ?? fetch;
4614
4865
  const endpoint = deps.suiteId !== void 0 ? `${apiUrl}/v1/suites/${encodeURIComponent(deps.suiteId)}/markdown` : `${apiUrl}/v1/test-cases/markdown`;
4615
4866
  let res;
@@ -4624,7 +4875,7 @@ async function handlePull(deps = {}) {
4624
4875
  throw new SyncHttpError(res.status, detail);
4625
4876
  }
4626
4877
  const text2 = await res.text();
4627
- fs15.mkdirSync(path13.dirname(file), { recursive: true });
4878
+ fs15.mkdirSync(path14.dirname(file), { recursive: true });
4628
4879
  fs15.writeFileSync(file, text2, "utf8");
4629
4880
  return { file, cases: Number(res.headers.get("x-plune-cases") ?? "0") };
4630
4881
  }
@@ -4632,7 +4883,7 @@ async function handlePush(deps = {}) {
4632
4883
  const cwd = deps.cwd ?? process.cwd();
4633
4884
  const apiUrl = resolveApiUrl(deps.apiUrl);
4634
4885
  const token = tokenOf(deps);
4635
- const file = path13.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
4886
+ const file = path14.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
4636
4887
  let text2;
4637
4888
  try {
4638
4889
  text2 = fs15.readFileSync(file, "utf8");
@@ -4686,18 +4937,18 @@ function reportCasesFailure(err, write) {
4686
4937
  }
4687
4938
  return null;
4688
4939
  }
4689
- var import_node_child_process2, fs15, path13, DEFAULT_CASES_FILE, DirtyFileError;
4940
+ var import_node_child_process2, fs15, path14, DEFAULT_CASES_FILE, DirtyFileError;
4690
4941
  var init_cases = __esm({
4691
4942
  "src/cli/commands/cases.ts"() {
4692
4943
  "use strict";
4693
4944
  init_cjs_shims();
4694
4945
  import_node_child_process2 = require("child_process");
4695
4946
  fs15 = __toESM(require("fs"), 1);
4696
- path13 = __toESM(require("path"), 1);
4947
+ path14 = __toESM(require("path"), 1);
4697
4948
  init_api_url();
4698
4949
  init_credentials();
4699
4950
  init_sync();
4700
- DEFAULT_CASES_FILE = path13.join("plune", "cases.md");
4951
+ DEFAULT_CASES_FILE = path14.join("plune", "cases.md");
4701
4952
  DirtyFileError = class extends Error {
4702
4953
  constructor(file) {
4703
4954
  super(`${file} has uncommitted changes; commit them or pass --force to overwrite.`);
@@ -4778,11 +5029,11 @@ module.exports = __toCommonJS(cli_exports);
4778
5029
  init_cjs_shims();
4779
5030
  init_api_url();
4780
5031
  var import_commander = require("commander");
4781
- var import_node_fs4 = require("fs");
4782
- var import_node_path3 = require("path");
5032
+ var import_node_fs5 = require("fs");
5033
+ var import_node_path4 = require("path");
4783
5034
  var import_node_url = require("url");
4784
- var __dirname = (0, import_node_path3.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
4785
- var pkg = JSON.parse((0, import_node_fs4.readFileSync)((0, import_node_path3.join)(__dirname, "..", "package.json"), "utf-8"));
5035
+ var __dirname = (0, import_node_path4.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
5036
+ var pkg = JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path4.join)(__dirname, "..", "package.json"), "utf-8"));
4786
5037
  function toFormat(s) {
4787
5038
  return s === "json" || s === "markdown" ? s : "console";
4788
5039
  }
@@ -4791,7 +5042,7 @@ function shouldColor(outputPath) {
4791
5042
  }
4792
5043
  function writeReport(text2, outputPath) {
4793
5044
  if (outputPath !== void 0) {
4794
- (0, import_node_fs4.writeFileSync)(outputPath, text2);
5045
+ (0, import_node_fs5.writeFileSync)(outputPath, text2);
4795
5046
  } else {
4796
5047
  process.stdout.write(text2 + "\n");
4797
5048
  }
@@ -4833,6 +5084,12 @@ function createProgram() {
4833
5084
  const program = new import_commander.Command();
4834
5085
  program.name("plune").description("Plune \u2014 AI-powered assertion testing CLI").version(pkg.version);
4835
5086
  program.option("-c, --config <path>", "Path to plune.yaml config file (applies to all commands)").option("-v, --verbose", "Verbose output, including stack traces on unexpected errors", false).option("--no-color", "Disable colored output regardless of TTY");
5087
+ program.hook("preAction", async (_program, action) => {
5088
+ const { loadEnv: loadEnv2 } = await Promise.resolve().then(() => (init_env(), env_exports));
5089
+ const { config } = action.optsWithGlobals();
5090
+ if (config !== void 0) loadEnv2((0, import_node_path4.dirname)((0, import_node_path4.resolve)(config)));
5091
+ loadEnv2(process.cwd());
5092
+ });
4836
5093
  const runCommand = program.command("run").description("Run assertions against a dataset").option("--dry-run", "Estimate cost/tokens; do not call the model", false).option("--config <path>", "Path to plune.yaml config file").option(
4837
5094
  "--only <selector>",
4838
5095
  "Run a subset by eval id or tag (repeatable)",
@@ -4843,21 +5100,18 @@ function createProgram() {
4843
5100
  const globals = command.optsWithGlobals();
4844
5101
  const configPath = globals.config;
4845
5102
  const [
4846
- { loadEnv: loadEnv2 },
4847
5103
  { handleRun: handleRun2 },
4848
5104
  { exitCodeFor: exitCodeFor2, RunConfigError: RunConfigError2 },
4849
5105
  { renderReport: renderReport2 },
4850
5106
  { ConfigNotFoundError: ConfigNotFoundError2, ConfigValidationError: ConfigValidationError2, YamlParseError: YamlParseError2 },
4851
5107
  { AuthError: AuthError2 }
4852
5108
  ] = await Promise.all([
4853
- Promise.resolve().then(() => (init_env(), env_exports)),
4854
5109
  Promise.resolve().then(() => (init_run2(), run_exports)),
4855
5110
  Promise.resolve().then(() => (init_orchestrator(), orchestrator_exports)),
4856
5111
  Promise.resolve().then(() => (init_reporters(), reporters_exports)),
4857
5112
  Promise.resolve().then(() => (init_errors(), errors_exports)),
4858
5113
  Promise.resolve().then(() => (init_errors2(), errors_exports2))
4859
5114
  ]);
4860
- loadEnv2(configPath !== void 0 ? (0, import_node_path3.dirname)((0, import_node_path3.resolve)(configPath)) : process.cwd());
4861
5115
  try {
4862
5116
  const result = await handleRun2({
4863
5117
  dryRun: options.dryRun,
@@ -5017,13 +5271,11 @@ function createProgram() {
5017
5271
  );
5018
5272
  program.command("report").description("Render the most recent run in a chosen format").option("--format <fmt>", "console | json | markdown", "console").option("-o, --output <path>", "Write the report to a file instead of stdout").action(async (options, command) => {
5019
5273
  const globals = command.optsWithGlobals();
5020
- const [{ loadEnv: loadEnv2 }, { handleReport: handleReport2, ReportNotFoundError: ReportNotFoundError2 }, { renderReport: renderReport2 }] = await Promise.all([
5021
- Promise.resolve().then(() => (init_env(), env_exports)),
5274
+ const [{ handleReport: handleReport2, ReportNotFoundError: ReportNotFoundError2 }, { renderReport: renderReport2 }] = await Promise.all([
5022
5275
  Promise.resolve().then(() => (init_report(), report_exports)),
5023
5276
  Promise.resolve().then(() => (init_reporters(), reporters_exports))
5024
5277
  ]);
5025
- const cwd = globals.config !== void 0 ? (0, import_node_path3.dirname)((0, import_node_path3.resolve)(globals.config)) : process.cwd();
5026
- loadEnv2(cwd);
5278
+ const cwd = globals.config !== void 0 ? (0, import_node_path4.dirname)((0, import_node_path4.resolve)(globals.config)) : process.cwd();
5027
5279
  try {
5028
5280
  const result = handleReport2({ cwd });
5029
5281
  const text2 = renderReport2(result, toFormat(options.format), {
@@ -5087,10 +5339,10 @@ function createProgram() {
5087
5339
  const { handleLogin: handleLogin2, reportLoginFailure: reportLoginFailure2 } = await Promise.resolve().then(() => (init_login(), login_exports));
5088
5340
  try {
5089
5341
  const token = options.token ?? await readTokenFromStdin();
5090
- const { path: path14, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
5342
+ const { path: path15, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
5091
5343
  process.stdout.write(
5092
- verified ? `Logged in. Token checked against the API and saved to ${path14}
5093
- ` : `Token saved to ${path14} WITHOUT being checked (--skip-verify).
5344
+ verified ? `Logged in. Token checked against the API and saved to ${path15}
5345
+ ` : `Token saved to ${path15} WITHOUT being checked (--skip-verify).
5094
5346
  `
5095
5347
  );
5096
5348
  } catch (err) {
@@ -5107,9 +5359,9 @@ function createProgram() {
5107
5359
  const verbose = command.optsWithGlobals().verbose === true;
5108
5360
  const { handleLogout: handleLogout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
5109
5361
  try {
5110
- const { removed, path: path14 } = handleLogout2();
5362
+ const { removed, path: path15 } = handleLogout2();
5111
5363
  process.stdout.write(
5112
- removed ? `Logged out. Removed ${path14}
5364
+ removed ? `Logged out. Removed ${path15}
5113
5365
  ` : "Not logged in \u2014 nothing to remove.\n"
5114
5366
  );
5115
5367
  } catch (err) {
@@ -5120,7 +5372,7 @@ function createProgram() {
5120
5372
  const globals = command.optsWithGlobals();
5121
5373
  const verbose = globals.verbose === true;
5122
5374
  const { handleSync: handleSync2, reportSyncFailure: reportSyncFailure2 } = await Promise.resolve().then(() => (init_sync(), sync_exports));
5123
- const cwd = globals.config !== void 0 ? (0, import_node_path3.dirname)((0, import_node_path3.resolve)(globals.config)) : process.cwd();
5375
+ const cwd = globals.config !== void 0 ? (0, import_node_path4.dirname)((0, import_node_path4.resolve)(globals.config)) : process.cwd();
5124
5376
  try {
5125
5377
  const { id, url } = await handleSync2({
5126
5378
  cwd,
@@ -5238,7 +5490,7 @@ function isMainModule() {
5238
5490
  const entry = process.argv[1];
5239
5491
  if (entry === void 0) return false;
5240
5492
  try {
5241
- return (0, import_node_fs4.realpathSync)(entry) === (0, import_node_fs4.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
5493
+ return (0, import_node_fs5.realpathSync)(entry) === (0, import_node_fs5.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
5242
5494
  } catch {
5243
5495
  return false;
5244
5496
  }