@plune-ai/cli 0.24.0 → 0.25.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
@@ -512,7 +512,7 @@ var init_retry = __esm({
512
512
  "use strict";
513
513
  init_cjs_shims();
514
514
  init_errors2();
515
- defaultSleep = (ms) => new Promise((resolve9) => setTimeout(resolve9, ms));
515
+ defaultSleep = (ms) => new Promise((resolve10) => setTimeout(resolve10, ms));
516
516
  }
517
517
  });
518
518
 
@@ -555,22 +555,22 @@ var init_prices = __esm({
555
555
  });
556
556
 
557
557
  // src/providers/cost.ts
558
- function priceFrom(price, usage) {
559
- return usage.input_tokens / 1e3 * price.input_per_1k_usd + usage.output_tokens / 1e3 * price.output_per_1k_usd;
558
+ function priceFrom(price, usage2) {
559
+ return usage2.input_tokens / 1e3 * price.input_per_1k_usd + usage2.output_tokens / 1e3 * price.output_per_1k_usd;
560
560
  }
561
- function resolveCost(usage, model, reportedCostUsd, overrides, deps = {}) {
561
+ function resolveCost(usage2, model, reportedCostUsd, overrides, deps = {}) {
562
562
  const table = deps.table ?? PRICE_TABLE;
563
563
  const warn = deps.warn ?? defaultWarn;
564
564
  const override = overrides?.[model];
565
565
  if (override !== void 0) {
566
- return priceFrom(override, usage);
566
+ return priceFrom(override, usage2);
567
567
  }
568
568
  if (reportedCostUsd !== void 0) {
569
569
  return reportedCostUsd;
570
570
  }
571
571
  const tablePrice = table[model];
572
572
  if (tablePrice !== void 0) {
573
- return priceFrom(tablePrice, usage);
573
+ return priceFrom(tablePrice, usage2);
574
574
  }
575
575
  warn(
576
576
  `plune: no price for model "${model}" \u2014 reporting cost_usd=0. Set pricing["${model}"] in plune.yaml to track its cost.`
@@ -628,8 +628,8 @@ function makeAnthropicProvider(config, env, pricing) {
628
628
  throw normalizeProviderError(err, apiKey, ENV_VAR);
629
629
  }
630
630
  },
631
- estimateCost(usage, reportedCostUsd) {
632
- return { cost_usd: resolveCost(usage, config.model, reportedCostUsd, pricing) };
631
+ estimateCost(usage2, reportedCostUsd) {
632
+ return { cost_usd: resolveCost(usage2, config.model, reportedCostUsd, pricing) };
633
633
  }
634
634
  };
635
635
  }
@@ -692,8 +692,8 @@ function makeOpenAiCompatibleProvider(opts) {
692
692
  throw normalizeProviderError(err, apiKey, apiKeyEnv);
693
693
  }
694
694
  },
695
- estimateCost(usage, reportedCostUsd) {
696
- return { cost_usd: resolveCost(usage, config.model, reportedCostUsd, pricing) };
695
+ estimateCost(usage2, reportedCostUsd) {
696
+ return { cost_usd: resolveCost(usage2, config.model, reportedCostUsd, pricing) };
697
697
  }
698
698
  };
699
699
  }
@@ -796,8 +796,8 @@ function makeEstimateProvider(config, pricing) {
796
796
  complete: () => Promise.reject(
797
797
  new Error("The --dry-run provider only estimates cost; it never calls a model.")
798
798
  ),
799
- estimateCost: (usage, reportedCostUsd) => ({
800
- cost_usd: resolveCost(usage, config.model, reportedCostUsd, pricing)
799
+ estimateCost: (usage2, reportedCostUsd) => ({
800
+ cost_usd: resolveCost(usage2, config.model, reportedCostUsd, pricing)
801
801
  })
802
802
  };
803
803
  }
@@ -1716,8 +1716,8 @@ async function runRow(p) {
1716
1716
  const maxTokens = p.providerConfig.max_tokens ?? DEFAULT_MAX_TOKENS2;
1717
1717
  if (p.dryRun) {
1718
1718
  const est = estimateUsage(prompt5, maxTokens);
1719
- const usage2 = { ...est, cost_usd: p.provider.estimateCost(est).cost_usd };
1720
- return { vars: p.row.vars, output: null, cached: false, usage: usage2, assertions: [] };
1719
+ const usage3 = { ...est, cost_usd: p.provider.estimateCost(est).cost_usd };
1720
+ return { vars: p.row.vars, output: null, cached: false, usage: usage3, assertions: [] };
1721
1721
  }
1722
1722
  const key = cacheKey({
1723
1723
  provider: p.providerConfig.type,
@@ -1727,13 +1727,13 @@ async function runRow(p) {
1727
1727
  prompt_resolved: prompt5
1728
1728
  });
1729
1729
  let output;
1730
- let usage;
1730
+ let usage2;
1731
1731
  let cached = false;
1732
1732
  const hit = p.noCache ? void 0 : p.cache.get(key);
1733
1733
  if (hit !== void 0) {
1734
1734
  output = hit.output;
1735
1735
  cached = true;
1736
- usage = {
1736
+ usage2 = {
1737
1737
  input_tokens: hit.usage.input_tokens,
1738
1738
  output_tokens: hit.usage.output_tokens,
1739
1739
  cost_usd: 0
@@ -1748,7 +1748,7 @@ async function runRow(p) {
1748
1748
  prompt_resolved: prompt5
1749
1749
  });
1750
1750
  output = res.output;
1751
- usage = {
1751
+ usage2 = {
1752
1752
  input_tokens: res.usage.input_tokens,
1753
1753
  output_tokens: res.usage.output_tokens,
1754
1754
  // Prefer the provider-reported actual cost (res.cost_usd) when present; resolveCost still
@@ -1794,7 +1794,7 @@ async function runRow(p) {
1794
1794
  break;
1795
1795
  }
1796
1796
  }
1797
- const finalUsage = { ...usage, cost_usd: usage.cost_usd + judgeCost };
1797
+ const finalUsage = { ...usage2, cost_usd: usage2.cost_usd + judgeCost };
1798
1798
  return {
1799
1799
  vars: p.row.vars,
1800
1800
  output,
@@ -2312,7 +2312,7 @@ function parseRetryAfter(raw, now = Date.now()) {
2312
2312
  return Math.min(Math.max(seconds, 0) * 1e3, RETRY_AFTER_CAP_MS);
2313
2313
  }
2314
2314
  function nodeTransport(url, init, idleMs = IDLE_MS) {
2315
- return new Promise((resolve9, reject) => {
2315
+ return new Promise((resolve10, reject) => {
2316
2316
  const target = new URL(url);
2317
2317
  const send = target.protocol === "https:" ? import_node_https.request : import_node_http.request;
2318
2318
  const req = send(target, { method: init.method, headers: init.headers }, (res) => {
@@ -2322,7 +2322,7 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
2322
2322
  res.on("end", () => {
2323
2323
  const status = res.statusCode ?? 0;
2324
2324
  const text2 = Buffer.concat(chunks).toString("utf8");
2325
- resolve9({
2325
+ resolve10({
2326
2326
  ok: status >= 200 && status <= 299,
2327
2327
  status,
2328
2328
  headers: {
@@ -2341,12 +2341,12 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
2341
2341
  });
2342
2342
  }
2343
2343
  function nodePut(url, headers, source, idleMs) {
2344
- return new Promise((resolve9, reject) => {
2344
+ return new Promise((resolve10, reject) => {
2345
2345
  const target = new URL(url);
2346
2346
  const send = target.protocol === "https:" ? import_node_https.request : import_node_http.request;
2347
2347
  const length = "body" in source ? source.body.length : source.size;
2348
2348
  let answered;
2349
- const settle = (err) => answered !== void 0 ? resolve9(answered) : reject(err);
2349
+ const settle = (err) => answered !== void 0 ? resolve10(answered) : reject(err);
2350
2350
  const req = send(
2351
2351
  target,
2352
2352
  { method: "PUT", headers: { ...withoutLength(headers), "content-length": String(length) } },
@@ -2355,7 +2355,7 @@ function nodePut(url, headers, source, idleMs) {
2355
2355
  answered = { status: res.statusCode ?? 0, retryAfter: Array.isArray(retry) ? retry[0] ?? null : retry ?? null };
2356
2356
  res.on("error", settle);
2357
2357
  res.on("close", () => {
2358
- resolve9(answered);
2358
+ resolve10(answered);
2359
2359
  req.destroy();
2360
2360
  });
2361
2361
  res.resume();
@@ -2370,14 +2370,25 @@ function nodePut(url, headers, source, idleMs) {
2370
2370
  async function detailOf(res) {
2371
2371
  try {
2372
2372
  const body = await res.json();
2373
+ const q = body.quota;
2373
2374
  return {
2375
+ ...typeof body.shardsExpected === "number" && Number.isInteger(body.shardsExpected) ? { shardsExpected: body.shardsExpected } : {},
2374
2376
  detail: typeof body.error === "string" ? body.error : "",
2377
+ ...typeof q === "object" && q !== null && !Array.isArray(q) ? { quota: q } : {},
2375
2378
  ...typeof body.code === "string" && /^[a-z0-9_]{1,60}$/.test(body.code) ? { code: body.code } : {}
2376
2379
  };
2377
2380
  } catch {
2378
2381
  return { detail: "" };
2379
2382
  }
2380
2383
  }
2384
+ function describeQuota(q) {
2385
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
2386
+ const kind = typeof q["kind"] === "string" ? q["kind"].slice(0, 60) : "limit";
2387
+ const [used, limit] = [num(q["used"]), num(q["limit"])];
2388
+ const parts = [used !== void 0 && limit !== void 0 ? `${kind}: ${used} of ${limit} used` : kind];
2389
+ if (typeof q["resetsAt"] === "string" && q["resetsAt"] !== "") parts.push(`resets ${q["resetsAt"].slice(0, 40)}`);
2390
+ return parts.join(", ");
2391
+ }
2381
2392
  function classify(status) {
2382
2393
  if (status === 401 || status === 403) return "auth";
2383
2394
  if (status === 409) return "conflict";
@@ -2442,12 +2453,12 @@ function createClient(opts) {
2442
2453
  }
2443
2454
  const said = await detailOf(res);
2444
2455
  const kind = classify(res.status);
2445
- last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
2456
+ last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {}, ...said.shardsExpected !== void 0 ? { shardsExpected: said.shardsExpected } : {} };
2446
2457
  if (kind !== "unavailable") break;
2447
2458
  const said429 = parseRetryAfter(res.headers.get("retry-after"), now());
2448
- if (res.status === 429 && said429 === null) {
2459
+ if (res.status === 429 && (said.quota !== void 0 || said429 === null)) {
2449
2460
  last.kind = "invalid";
2450
- last.detail = "quota exceeded (no Retry-After)";
2461
+ last.detail = `quota exceeded (${said.quota === void 0 ? "no Retry-After" : scrub(describeQuota(said.quota))})`;
2451
2462
  if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
2452
2463
  break;
2453
2464
  }
@@ -2460,6 +2471,7 @@ function createClient(opts) {
2460
2471
  post: (path17, body, retries) => send("POST", path17, { body: JSON.stringify(body), type: "application/json" }, retries),
2461
2472
  upload: (path17, bytes, contentType) => send("POST", path17, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
2462
2473
  del: (path17) => send("DELETE", path17),
2474
+ get: (path17) => send("GET", path17),
2463
2475
  async put(url, headers, source) {
2464
2476
  const putRetries = uploadRetries ?? 3;
2465
2477
  let last = { ok: false, status: null, code: "network" };
@@ -2518,6 +2530,16 @@ var init_client = __esm({
2518
2530
  });
2519
2531
 
2520
2532
  // src/reporter-core/env.ts
2533
+ function shardOf(env) {
2534
+ const raw = value(env, "PLUNE_SHARD");
2535
+ if (raw === void 0) return {};
2536
+ const m = /^(\d{1,4})\/(\d{1,4})$/.exec(raw);
2537
+ const [k, n] = m ? [Number(m[1]), Number(m[2])] : [0, 0];
2538
+ if (k >= 1 && k <= n && n <= SHARDS_MAX) return { shard: { index: k, total: n } };
2539
+ return {
2540
+ error: `PLUNE_SHARD=${JSON.stringify(raw.slice(0, 40))} is not "k/N" with 1 <= k <= N <= ${SHARDS_MAX} (for example 2/4) - nothing was sent.`
2541
+ };
2542
+ }
2521
2543
  function value(env, name) {
2522
2544
  const raw = env[name]?.trim();
2523
2545
  return raw === void 0 || raw === "" ? void 0 : raw;
@@ -2560,6 +2582,7 @@ function readEnv(env = process.env) {
2560
2582
  const labels = list(env, "PLUNE_LABELS");
2561
2583
  const offerDiscovered = flag(env, "PLUNE_CREATE");
2562
2584
  const full = flag(env, "PLUNE_FULL_RUN");
2585
+ const { shard, error: shardError } = shardOf(env);
2563
2586
  return {
2564
2587
  config: {
2565
2588
  ...externalKey !== void 0 ? { externalKey } : {},
@@ -2580,7 +2603,11 @@ function readEnv(env = process.env) {
2580
2603
  // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
2581
2604
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
2582
2605
  // way this process must not be the one to call it finished.
2583
- keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED"),
2606
+ // `PLUNE_SHARD` implies it: the run is joined by the shared key and this process does not close it
2607
+ // by itself; `leaveOpen` reports the shard instead.
2608
+ keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED") || shard !== void 0 || shardError !== void 0,
2609
+ ...shard !== void 0 ? { shard } : {},
2610
+ ...shardError !== void 0 ? { shardError } : {},
2584
2611
  ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
2585
2612
  warnings
2586
2613
  };
@@ -2608,14 +2635,15 @@ function ciCommit(reported, env = process.env) {
2608
2635
  ...branch !== void 0 ? { branch } : {}
2609
2636
  };
2610
2637
  }
2611
- var path6, UNSUPPORTED_VARS, UPLOAD_CONCURRENCY, UPLOAD_RETRIES, PROVIDERS;
2638
+ var path6, UNSUPPORTED_VARS, SHARDS_MAX, UPLOAD_CONCURRENCY, UPLOAD_RETRIES, PROVIDERS;
2612
2639
  var init_env2 = __esm({
2613
2640
  "src/reporter-core/env.ts"() {
2614
2641
  "use strict";
2615
2642
  init_cjs_shims();
2616
2643
  path6 = __toESM(require("path"), 1);
2617
2644
  UNSUPPORTED_VARS = ["PLUNE_GROUP"];
2618
- UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 4 };
2645
+ SHARDS_MAX = 1e3;
2646
+ UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 16 };
2619
2647
  UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
2620
2648
  PROVIDERS = [
2621
2649
  { is: "GITHUB_ACTIONS", sha: "GITHUB_SHA", branch: ["GITHUB_HEAD_REF", "GITHUB_REF_NAME"] },
@@ -2914,6 +2942,7 @@ function warnIfDropped(sent, run, log) {
2914
2942
  }
2915
2943
  async function startRun(passed, declared2) {
2916
2944
  const env = readEnv();
2945
+ if (env.shardError !== void 0) throw new Error(env.shardError);
2917
2946
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
2918
2947
  const cfg = { ...env.config, ...stated };
2919
2948
  const log = cfg.log ?? ((line) => void process.stderr.write(`${line}
@@ -2955,6 +2984,9 @@ async function startRun(passed, declared2) {
2955
2984
  const resolved = /* @__PURE__ */ new Map();
2956
2985
  let buffer = [];
2957
2986
  const discoveries = /* @__PURE__ */ new Map();
2987
+ const held = /* @__PURE__ */ new Map();
2988
+ let createdWithResult = 0;
2989
+ let createdUndelivered = 0;
2958
2990
  let runId = null;
2959
2991
  let joined = false;
2960
2992
  let offline = token === "";
@@ -3083,6 +3115,7 @@ async function startRun(passed, declared2) {
3083
3115
  specRef: result.specRef,
3084
3116
  rawStatus: result.rawStatus
3085
3117
  });
3118
+ held.set(result.keys[0].value, result);
3086
3119
  }
3087
3120
  async function offer() {
3088
3121
  if (discoveries.size === 0 || offline || runId === null) return;
@@ -3101,7 +3134,24 @@ async function startRun(passed, declared2) {
3101
3134
  return;
3102
3135
  }
3103
3136
  stats.offered += out.body.results.filter((r) => r?.outcome === "queued").length;
3104
- stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
3137
+ const made = out.body.results.filter((r) => r?.outcome === "created");
3138
+ stats.created += made.length;
3139
+ const submissions = [];
3140
+ const filesByKey = /* @__PURE__ */ new Map();
3141
+ for (const r of made) {
3142
+ const pending = typeof r.id === "string" && r.id !== "" ? held.get(r.key?.value) : void 0;
3143
+ if (pending === void 0) continue;
3144
+ held.delete(r.key.value);
3145
+ const { keys: _keys, files, ...rest } = pending;
3146
+ submissions.push({ ...rest, testCaseId: r.id });
3147
+ if (files !== void 0) filesByKey.set(pending.resultKey, files);
3148
+ }
3149
+ if (submissions.length > 0) {
3150
+ const before = stats.deferred;
3151
+ await send(submissions, filesByKey);
3152
+ createdWithResult += submissions.length;
3153
+ createdUndelivered += stats.deferred - before;
3154
+ }
3105
3155
  }
3106
3156
  }
3107
3157
  function skip(kind, name, why) {
@@ -3154,8 +3204,8 @@ async function startRun(passed, declared2) {
3154
3204
  return skip(kind, job.name, "missing");
3155
3205
  }
3156
3206
  }
3157
- const refusal = kind === "text" ? textRefusal(bytes) : void 0;
3158
- if (refusal !== void 0) return skip(kind, job.name, refusal);
3207
+ const refusal2 = kind === "text" ? textRefusal(bytes) : void 0;
3208
+ if (refusal2 !== void 0) return skip(kind, job.name, refusal2);
3159
3209
  requests += 1;
3160
3210
  const out = await client.upload(
3161
3211
  `/v1/results/${encodeURIComponent(job.id)}/files?name=${encodeURIComponent(job.name)}`,
@@ -3187,7 +3237,7 @@ async function startRun(passed, declared2) {
3187
3237
  let free = uploadsAtOnce;
3188
3238
  const waiting = [];
3189
3239
  async function gated(work) {
3190
- if (free === 0) await new Promise((resolve9) => waiting.push(resolve9));
3240
+ if (free === 0) await new Promise((resolve10) => waiting.push(resolve10));
3191
3241
  else free -= 1;
3192
3242
  try {
3193
3243
  return await work();
@@ -3439,9 +3489,9 @@ async function startRun(passed, declared2) {
3439
3489
  skip(kind, job.name, "missing");
3440
3490
  continue;
3441
3491
  }
3442
- const refusal = kind === "text" ? textRefusal(data) : void 0;
3443
- if (refusal !== void 0) {
3444
- skip(kind, job.name, refusal);
3492
+ const refusal2 = kind === "text" ? textRefusal(data) : void 0;
3493
+ if (refusal2 !== void 0) {
3494
+ skip(kind, job.name, refusal2);
3445
3495
  continue;
3446
3496
  }
3447
3497
  body.push({ resultId: job.id, name: job.name, contentType: job.type, data: data.toString("base64") });
@@ -3538,6 +3588,9 @@ async function startRun(passed, declared2) {
3538
3588
  progress();
3539
3589
  return;
3540
3590
  }
3591
+ await send(submissions, filesByKey);
3592
+ }
3593
+ async function send(submissions, filesByKey) {
3541
3594
  for (const part of packed(submissions)) {
3542
3595
  if (offline) {
3543
3596
  defer(part);
@@ -3581,7 +3634,10 @@ async function startRun(passed, declared2) {
3581
3634
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
3582
3635
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
3583
3636
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
3584
- if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
3637
+ if (stats.created > 0) {
3638
+ const recorded = createdWithResult === stats.created && createdUndelivered === 0;
3639
+ parts.push(`${stats.created} added as cases (trusted source)${recorded ? " and their results recorded" : ""}`);
3640
+ }
3585
3641
  const kinds = [stats.screenshots, stats.textFiles, stats.otherFiles];
3586
3642
  const sum = (what) => kinds.reduce((n, counts) => n + counts[what], 0);
3587
3643
  if (bucketSeen) {
@@ -3611,6 +3667,7 @@ async function startRun(passed, declared2) {
3611
3667
  log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
3612
3668
  }
3613
3669
  }
3670
+ const shardLines = [];
3614
3671
  return {
3615
3672
  get runId() {
3616
3673
  return runId;
@@ -3648,15 +3705,43 @@ async function startRun(passed, declared2) {
3648
3705
  summarise();
3649
3706
  return frozen;
3650
3707
  },
3651
- async leaveOpen() {
3652
- if (done) return;
3708
+ shardLines,
3709
+ async leaveOpen(opts) {
3710
+ if (done) return void 0;
3653
3711
  done = true;
3654
3712
  await flush();
3655
3713
  await offer();
3656
- if (runId !== null) {
3714
+ let frozen;
3715
+ const after = [];
3716
+ const shard = env.shard;
3717
+ let counted = false;
3718
+ let mismatch;
3719
+ if (runId !== null && shard !== void 0 && !offline) {
3720
+ const out = await client.post(`/v1/runs/${runId}/events`, { event: "finish", shardIndex: shard.index, shardsExpected: shard.total });
3721
+ if (out.ok && typeof out.body?.shardsReported === "number") {
3722
+ counted = true;
3723
+ frozen = out.body.summary ?? void 0;
3724
+ const n = out.body.shardsExpected ?? shard.total;
3725
+ if (out.body.run?.statusEvent === "finished") after.push("plune: run closed");
3726
+ after.push(`plune: shard ${shard.index} of ${shard.total} done \u2014 ${out.body.shardsReported} of ${n} shards reported`);
3727
+ } else if (out.ok || out.kind === "invalid") {
3728
+ log('plune: the platform does not support shard auto-close yet \u2014 the run is left open for "plune run finish".');
3729
+ } else if (out.code === "shards_mismatch") {
3730
+ mismatch = new Error(
3731
+ `PLUNE_SHARD=${shard.index}/${shard.total} says ${shard.total} shards, but run ${runId} already expects ${out.shardsExpected ?? "a different number of"} \u2014 every shard of one run must use the same N. The results are uploaded; the run is left open.`
3732
+ );
3733
+ } else {
3734
+ log(`plune: could not report shard ${shard.index} of ${shard.total} to run ${runId} (${out.detail || out.kind}) \u2014 the results are uploaded; close the run with "plune run finish ${runId}".`);
3735
+ }
3736
+ }
3737
+ if (runId !== null && !counted) {
3657
3738
  log(`plune: run ${runId} is still open \u2014 close it with "plune run finish ${runId}".`);
3658
3739
  }
3659
3740
  summarise();
3741
+ shardLines.push(...after);
3742
+ if (opts?.holdShardLines !== true) for (const line of after) log(line);
3743
+ if (mismatch !== void 0) throw mismatch;
3744
+ return frozen;
3660
3745
  }
3661
3746
  };
3662
3747
  }
@@ -3843,14 +3928,14 @@ async function handleRunExec(options) {
3843
3928
  if (command === void 0) throw new RunCommandError("Nothing to run \u2014 pass a command after `--`.");
3844
3929
  const run = await handleRunStart(options);
3845
3930
  const spawnImpl = options.spawnImpl ?? import_node_child_process.spawn;
3846
- const code = await new Promise((resolve9) => {
3931
+ const code = await new Promise((resolve10) => {
3847
3932
  const child = spawnImpl(command, args, {
3848
3933
  stdio: "inherit",
3849
3934
  shell: process.platform === "win32",
3850
3935
  env: { ...process.env, PLUNE_RUN: run.externalKey, PLUNE_PROCEED: "1" }
3851
3936
  });
3852
- child.on("close", (exitCode) => resolve9(exitCode ?? 1));
3853
- child.on("error", () => resolve9(1));
3937
+ child.on("close", (exitCode) => resolve10(exitCode ?? 1));
3938
+ child.on("error", () => resolve10(1));
3854
3939
  });
3855
3940
  return handleRunFinish(run.id, options, code);
3856
3941
  }
@@ -5302,7 +5387,7 @@ async function handleRunImport(options) {
5302
5387
  );
5303
5388
  for (const result2 of results) await session.add(result2);
5304
5389
  let frozen;
5305
- if (env.keepOpen) await session.leaveOpen();
5390
+ if (env.keepOpen) frozen = await session.leaveOpen({ holdShardLines: true });
5306
5391
  else frozen = await session.finish();
5307
5392
  const stats = session.stats;
5308
5393
  const result = {
@@ -5333,13 +5418,14 @@ async function handleRunImport(options) {
5333
5418
  };
5334
5419
  for (const line of describe(result, apiUrl)) write(line);
5335
5420
  if (options.failOnFailures === true) {
5336
- if (env.keepOpen) {
5421
+ if (env.keepOpen && frozen === void 0) {
5337
5422
  write("plune: the run is left open for the jobs still reporting, so there is no verdict yet \u2014 gate on the step that finishes it.");
5338
5423
  }
5339
5424
  const gate = gateExit(frozen);
5340
5425
  write(gate.line);
5341
5426
  result.gate = gate.code;
5342
5427
  }
5428
+ for (const line of session.shardLines) write(line);
5343
5429
  return result;
5344
5430
  }
5345
5431
  function runUrl2(apiUrl, id) {
@@ -6195,6 +6281,244 @@ var init_ingest = __esm({
6195
6281
  }
6196
6282
  });
6197
6283
 
6284
+ // src/cli/commands/requirements-import.ts
6285
+ var requirements_import_exports = {};
6286
+ __export(requirements_import_exports, {
6287
+ MAX_MARKDOWN_BYTES: () => MAX_MARKDOWN_BYTES,
6288
+ RequirementsImportError: () => RequirementsImportError,
6289
+ buildImportBody: () => buildImportBody,
6290
+ formatDiscover: () => formatDiscover,
6291
+ formatReport: () => formatReport2,
6292
+ handleRequirementsImport: () => handleRequirementsImport,
6293
+ parseMap: () => parseMap,
6294
+ reportRequirementsImportFailure: () => reportRequirementsImportFailure
6295
+ });
6296
+ function parseMap(flag2, raw) {
6297
+ const out = {};
6298
+ for (const pair of raw.split(",")) {
6299
+ if (pair.trim() === "") continue;
6300
+ const at = pair.indexOf("=");
6301
+ const from = at < 0 ? "" : pair.slice(0, at).trim();
6302
+ const to = at < 0 ? "" : pair.slice(at + 1).trim();
6303
+ if (from === "" || to === "") usage(`${flag2}: "${pair.trim()}" is not Value=Value. Write it like ${flag2} "Critical=Must-have,High=Should-have".`);
6304
+ out[from] = to;
6305
+ }
6306
+ if (Object.keys(out).length === 0) usage(`${flag2} is empty. Write it like ${flag2} "Critical=Must-have,High=Should-have".`);
6307
+ return out;
6308
+ }
6309
+ function buildImportBody(f) {
6310
+ const columnFlags = COLUMN_FLAGS.filter(([k]) => f[k] !== void 0);
6311
+ const sectionFlags = SECTION_FLAGS.filter(([k]) => f[k] !== void 0);
6312
+ const anyMapping = f.table !== void 0 || f.sections !== void 0 || columnFlags.length > 0 || sectionFlags.length > 0 || f.mapPriority !== void 0 || f.mapCategory !== void 0 || f.type !== void 0;
6313
+ if (!anyMapping) return null;
6314
+ if (f.table !== void 0 && f.sections !== void 0) usage("Give --table or --sections, not both: one import reads one table or one section.");
6315
+ if (f.table === void 0 && f.sections === void 0) {
6316
+ usage("The mapping flags need --table <n> (a table) or --sections <heading|n> (a section). Run without flags to see which the file has.");
6317
+ }
6318
+ const body = {};
6319
+ if (f.mapPriority !== void 0) body["priorityMap"] = parseMap("--map-priority", f.mapPriority);
6320
+ if (f.type !== void 0) body["defaultType"] = oneOf("--type", f.type, ["user_story", "feature", "epic"]);
6321
+ if (f.table !== void 0) {
6322
+ if (!/^\d+$/.test(f.table)) usage(`--table is the number of the table in the list (0, 1, ...), not "${f.table}".`);
6323
+ if (sectionFlags.length > 0) usage(`${sectionFlags.map(([, n]) => n).join(", ")} ${sectionFlags.length > 1 ? "are" : "is"} for --sections, not --table.`);
6324
+ if (f.titleColumn !== void 0 && f.textColumn !== void 0) usage("Give --title-column or --text-column, not both.");
6325
+ if (f.titleColumn === void 0 && f.textColumn === void 0) usage("--table needs --title-column <header> or --text-column <header>.");
6326
+ body["mode"] = "table";
6327
+ body["table"] = Number(f.table);
6328
+ for (const [k, , key] of columnFlags) body[key] = f[k];
6329
+ if (f.mapCategory !== void 0) body["categoryMap"] = parseMap("--map-category", f.mapCategory);
6330
+ } else {
6331
+ if (columnFlags.length > 0) usage(`${columnFlags.map(([, n]) => n).join(", ")} ${columnFlags.length > 1 ? "are" : "is"} for --table, not --sections.`);
6332
+ if (f.mapCategory !== void 0) usage("--map-category is for --table, not --sections.");
6333
+ body["mode"] = "sections";
6334
+ const section = (f.sections ?? "").trim();
6335
+ if (section === "") usage("--sections needs a heading or its number in the list.");
6336
+ body["section"] = /^\d+$/.test(section) ? Number(section) : section;
6337
+ if (f.unit !== void 0) body["unit"] = oneOf("--unit", f.unit, ["bullets", "subheadings"]);
6338
+ if (f.keyPrefix !== void 0) body["keyPrefix"] = f.keyPrefix;
6339
+ if (f.priorityFrom !== void 0) body["priorityFrom"] = oneOf("--priority-from", f.priorityFrom, ["subheading"]);
6340
+ }
6341
+ return body;
6342
+ }
6343
+ function readMarkdown(cwd, file) {
6344
+ const target = (0, import_node_path4.resolve)(cwd, file);
6345
+ let size;
6346
+ try {
6347
+ const st = (0, import_node_fs7.statSync)(target);
6348
+ if (!st.isFile()) return usage(`${target} is not a file.`);
6349
+ size = st.size;
6350
+ } catch {
6351
+ return usage(`File not found: ${target}`);
6352
+ }
6353
+ if (size > MAX_MARKDOWN_BYTES) {
6354
+ return usage(`${target} is ${size} bytes; the platform reads at most ${MAX_MARKDOWN_BYTES} bytes (1 MiB). Nothing was sent. Split the file.`);
6355
+ }
6356
+ let text2;
6357
+ try {
6358
+ text2 = new TextDecoder("utf-8", { fatal: true }).decode((0, import_node_fs7.readFileSync)(target));
6359
+ } catch {
6360
+ return usage(`${target} is not valid UTF-8. Save it as UTF-8 and try again.`);
6361
+ }
6362
+ text2 = text2.replace(/^/, "");
6363
+ if (text2.trim() === "") usage(`${target} is empty.`);
6364
+ return text2;
6365
+ }
6366
+ function refusal(out, apiUrl, projectId) {
6367
+ const d = out.detail;
6368
+ const said = d === "" ? "" : ` (${d})`;
6369
+ switch (out.status) {
6370
+ case null:
6371
+ return new RequirementsImportError(`Could not reach the Plune API at ${apiUrl}. Check your connection (or PLUNE_API_URL) and try again.`, 1);
6372
+ case 401:
6373
+ return new RequirementsImportError(`Your API token was rejected (revoked or invalid). ${whereToGetAToken(apiUrl)} Then run "plune login".`, 2);
6374
+ case 403:
6375
+ return new RequirementsImportError(`The platform refused: ${d || "read-only member"}. Importing needs an owner or a member of the project.`, 1);
6376
+ case 404:
6377
+ return /^project '.*' not found$/.test(d) ? new RequirementsImportError(`Project ${projectId} was not found for this token. A token only works in the project it was minted in; pass --project <id> or use a token from that project.`, 1) : new RequirementsImportError(SUPPORT, 1);
6378
+ case 409:
6379
+ return new RequirementsImportError(`A requirement key was taken by another change while the import ran. Nothing was written; run it again${said}.`, 1);
6380
+ case 413:
6381
+ return new RequirementsImportError(`The platform refused the file as too large${said}. Nothing was written.`, 1);
6382
+ case 400:
6383
+ return new RequirementsImportError(`The platform refused the mapping${said}.`, 1);
6384
+ default:
6385
+ return new RequirementsImportError(`The platform answered HTTP ${out.status}${said}. Nothing was written.`, 1);
6386
+ }
6387
+ }
6388
+ async function resolveProject(client, explicit, apiUrl) {
6389
+ const given = (explicit ?? process.env["PLUNE_PROJECT"])?.trim();
6390
+ if (given !== void 0 && given !== "") return given;
6391
+ const out = await client.get("/v1/projects");
6392
+ if (!out.ok) throw refusal(out, apiUrl, "");
6393
+ const projects = out.body.projects ?? [];
6394
+ if (projects.length === 1 && projects[0] !== void 0) return projects[0].id;
6395
+ const list2 = projects.map((p) => `${p.id}${p.name ? ` (${p.name})` : ""}`).join(", ");
6396
+ return usage(
6397
+ projects.length === 0 ? "No project found for this token. Pass --project <id>." : `This account is in ${projects.length} projects (${list2}). A token only works in the project it was minted in: pass --project <id> (or set PLUNE_PROJECT).`
6398
+ );
6399
+ }
6400
+ async function handleRequirementsImport(deps) {
6401
+ const apiUrl = resolveApiUrl(deps.apiUrl);
6402
+ const mapping = buildImportBody(deps);
6403
+ const markdown = readMarkdown(deps.cwd ?? process.cwd(), deps.file);
6404
+ const token = (deps.loadToken ?? loadToken)();
6405
+ if (token === null) throw new RequirementsImportError('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.', 2);
6406
+ const client = createClient({ apiUrl, token, ...deps.fetchImpl !== void 0 ? { fetchImpl: deps.fetchImpl } : {} });
6407
+ const projectId = await resolveProject(client, deps.project, apiUrl);
6408
+ const base = `/v1/projects/${encodeURIComponent(projectId)}/requirements/import`;
6409
+ if (mapping === null) {
6410
+ const out2 = await client.post(`${base}/discover`, { markdown });
6411
+ if (!out2.ok) throw refusal(out2, apiUrl, projectId);
6412
+ return { step: "discover", result: out2.body };
6413
+ }
6414
+ const out = await client.post(base, { markdown, dryRun: deps.yes !== true, ...mapping });
6415
+ if (!out.ok) throw refusal(out, apiUrl, projectId);
6416
+ return { step: "import", report: out.body };
6417
+ }
6418
+ function formatDiscover(file, r) {
6419
+ const out = [`${file}: ${plural(r.tables.length, "table")}, ${plural(r.sections.length, "section")}`];
6420
+ if (r.frontmatter.keys.length > 0) out.push(`Frontmatter keys: ${r.frontmatter.keys.join(", ")}`);
6421
+ out.push("", "Tables");
6422
+ if (r.tables.length === 0) out.push(" (none)");
6423
+ for (const t of r.tables) {
6424
+ const mark = t.unreadable !== void 0 ? ` cannot be read: ${t.unreadable}` : t.looksLikeRequirements ? " <- looks like requirements" : "";
6425
+ out.push(` #${t.index} line ${t.line} ${t.heading === null ? "(no heading)" : `"${flat(t.heading)}"`} ${cut2(t.headers.map(flat).join(" | "), 100)} ${plural(t.rowCount, "row")}${mark}`);
6426
+ }
6427
+ out.push("", "Sections");
6428
+ if (r.sections.length === 0) out.push(" (none)");
6429
+ for (const s of r.sections) {
6430
+ out.push(` #${s.index} line ${s.line} ${"#".repeat(s.level)} ${flat(s.heading)} ${plural(s.bulletCount, "bullet")}, ${plural(s.subheadingCount, "sub-heading")}`);
6431
+ }
6432
+ if (r.truncated) out.push("", "The file has more headings or tables than are listed here.");
6433
+ out.push(
6434
+ "",
6435
+ "Pick one and map it (a dry run, nothing is written):",
6436
+ ` plune requirements import ${file} --table <n> --key-column <header> --title-column <header>`,
6437
+ ` plune requirements import ${file} --sections <heading|n> --unit bullets|subheadings`
6438
+ );
6439
+ return out.join("\n") + "\n";
6440
+ }
6441
+ function formatReport2(report) {
6442
+ const s = report.summary;
6443
+ const out = [`create ${s.create} \xB7 update ${s.update} \xB7 unchanged ${s.unchanged} \xB7 skip ${s.skip}`];
6444
+ for (const action of ["create", "update", "skip", "unchanged"]) {
6445
+ const rows = report.items.filter((i) => i.action === action);
6446
+ if (rows.length === 0) continue;
6447
+ out.push("", `${action} (${rows.length})`);
6448
+ for (const i of rows.slice(0, SHOWN)) {
6449
+ const changes = i.changes !== void 0 && i.changes.length > 0 ? `; changes: ${i.changes.join(", ")}` : "";
6450
+ const notes = i.notes !== void 0 && i.notes.length > 0 ? `; note: ${i.notes.join("; ")}` : "";
6451
+ out.push(` line ${i.line} ${i.key ?? "-"} ${cut2(flat(i.title), 80)} - ${flat(i.reason)}${changes}${notes}`);
6452
+ }
6453
+ if (rows.length > SHOWN) out.push(` ... and ${rows.length - SHOWN} more (--json lists every row)`);
6454
+ }
6455
+ for (const [label, values] of [["Priority values", report.values.priority], ["Category values", report.values.category]]) {
6456
+ if (values.length === 0) continue;
6457
+ out.push("", label);
6458
+ for (const v of values.slice(0, SHOWN)) out.push(` ${v.value} x${v.count} -> ${v.mappedTo ?? "(no mapping: kept as written)"}`);
6459
+ }
6460
+ const n = report.notImported;
6461
+ if (n.tables.length + n.sections.length + n.frontmatterKeys.length > 0) {
6462
+ out.push("", "Not imported");
6463
+ for (const t of n.tables) out.push(` table #${t.index} ${t.heading === null ? "" : `"${flat(t.heading)}" `}(${cut2(t.headers.map(flat).join(" | "), 80)}; ${plural(t.rowCount, "row")})`);
6464
+ for (const x of n.sections) out.push(` section #${x.index} "${flat(x.heading)}" (${plural(x.bulletCount, "bullet")})`);
6465
+ if (n.frontmatterKeys.length > 0) out.push(` frontmatter keys: ${n.frontmatterKeys.join(", ")}`);
6466
+ }
6467
+ out.push("", report.applied ? `applied \u2014 ${plural(s.create, "requirement")} created, ${s.update} updated.` : "dry run \u2014 nothing was written; add --yes to apply");
6468
+ return out.join("\n") + "\n";
6469
+ }
6470
+ function reportRequirementsImportFailure(err, write) {
6471
+ if (!(err instanceof RequirementsImportError)) return null;
6472
+ write(err.message + "\n");
6473
+ return err.exitCode;
6474
+ }
6475
+ var import_node_fs7, import_node_path4, MAX_MARKDOWN_BYTES, RequirementsImportError, usage, COLUMN_FLAGS, SECTION_FLAGS, oneOf, SUPPORT, SHOWN, flat, cut2, plural;
6476
+ var init_requirements_import = __esm({
6477
+ "src/cli/commands/requirements-import.ts"() {
6478
+ "use strict";
6479
+ init_cjs_shims();
6480
+ import_node_fs7 = require("fs");
6481
+ import_node_path4 = require("path");
6482
+ init_client();
6483
+ init_api_url();
6484
+ init_credentials();
6485
+ MAX_MARKDOWN_BYTES = 1048576;
6486
+ RequirementsImportError = class extends Error {
6487
+ constructor(message, exitCode) {
6488
+ super(message);
6489
+ this.exitCode = exitCode;
6490
+ this.name = "RequirementsImportError";
6491
+ }
6492
+ exitCode;
6493
+ };
6494
+ usage = (message) => {
6495
+ throw new RequirementsImportError(message, 2);
6496
+ };
6497
+ COLUMN_FLAGS = [
6498
+ ["keyColumn", "--key-column", "keyColumn"],
6499
+ ["titleColumn", "--title-column", "titleColumn"],
6500
+ ["textColumn", "--text-column", "textColumn"],
6501
+ ["descriptionColumn", "--description-column", "descriptionColumn"],
6502
+ ["priorityColumn", "--priority-column", "priorityColumn"],
6503
+ ["categoryColumn", "--category-column", "categoryColumn"],
6504
+ ["typeColumn", "--type-column", "typeColumn"],
6505
+ ["statusColumn", "--status-column", "statusColumn"],
6506
+ ["criteriaColumn", "--criteria-column", "acceptanceCriteriaColumn"]
6507
+ ];
6508
+ SECTION_FLAGS = [
6509
+ ["unit", "--unit"],
6510
+ ["keyPrefix", "--key-prefix"],
6511
+ ["priorityFrom", "--priority-from"]
6512
+ ];
6513
+ oneOf = (flag2, value2, allowed) => allowed.includes(value2) ? value2 : usage(`${flag2} must be ${allowed.join(" or ")}, not "${value2}".`);
6514
+ SUPPORT = "This platform does not support requirements import yet. Nothing was sent to a project; try a newer Plune platform.";
6515
+ SHOWN = 50;
6516
+ flat = (s) => s.replace(/\s+/g, " ").trim();
6517
+ cut2 = (s, n) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
6518
+ plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
6519
+ }
6520
+ });
6521
+
6198
6522
  // src/cli.ts
6199
6523
  var cli_exports = {};
6200
6524
  __export(cli_exports, {
@@ -6204,11 +6528,11 @@ module.exports = __toCommonJS(cli_exports);
6204
6528
  init_cjs_shims();
6205
6529
  init_api_url();
6206
6530
  var import_commander = require("commander");
6207
- var import_node_fs7 = require("fs");
6208
- var import_node_path4 = require("path");
6531
+ var import_node_fs8 = require("fs");
6532
+ var import_node_path5 = require("path");
6209
6533
  var import_node_url = require("url");
6210
- var __dirname = (0, import_node_path4.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
6211
- var pkg = JSON.parse((0, import_node_fs7.readFileSync)((0, import_node_path4.join)(__dirname, "..", "package.json"), "utf-8"));
6534
+ var __dirname = (0, import_node_path5.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
6535
+ var pkg = JSON.parse((0, import_node_fs8.readFileSync)((0, import_node_path5.join)(__dirname, "..", "package.json"), "utf-8"));
6212
6536
  function toFormat(s) {
6213
6537
  return s === "json" || s === "markdown" ? s : "console";
6214
6538
  }
@@ -6217,7 +6541,7 @@ function shouldColor(outputPath) {
6217
6541
  }
6218
6542
  function writeReport(text2, outputPath) {
6219
6543
  if (outputPath !== void 0) {
6220
- (0, import_node_fs7.writeFileSync)(outputPath, text2);
6544
+ (0, import_node_fs8.writeFileSync)(outputPath, text2);
6221
6545
  } else {
6222
6546
  process.stdout.write(text2 + "\n");
6223
6547
  }
@@ -6262,7 +6586,7 @@ function createProgram() {
6262
6586
  program.hook("preAction", async (_program, action) => {
6263
6587
  const { loadEnv: loadEnv2 } = await Promise.resolve().then(() => (init_env(), env_exports));
6264
6588
  const { config } = action.optsWithGlobals();
6265
- if (config !== void 0) loadEnv2((0, import_node_path4.dirname)((0, import_node_path4.resolve)(config)));
6589
+ if (config !== void 0) loadEnv2((0, import_node_path5.dirname)((0, import_node_path5.resolve)(config)));
6266
6590
  loadEnv2(process.cwd());
6267
6591
  });
6268
6592
  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(
@@ -6444,7 +6768,7 @@ function createProgram() {
6444
6768
  false
6445
6769
  ).option(
6446
6770
  "--upload-concurrency <n>",
6447
- "File uploads in flight at once, 1 to 32 (default 4; PLUNE_UPLOAD_CONCURRENCY sets it too). Files are announced and confirmed up to 100 to a request; a 429 is retried PLUNE_UPLOAD_RETRIES times (default 6)",
6771
+ "File uploads in flight at once, 1 to 32 (default 16; PLUNE_UPLOAD_CONCURRENCY sets it too). Files are announced and confirmed up to 100 to a request; a 429 is retried PLUNE_UPLOAD_RETRIES times (default 6)",
6448
6772
  (v) => {
6449
6773
  if (!/^\d+$/.test(v) || Number(v) < 1 || Number(v) > 32) throw new import_commander.InvalidArgumentError("a whole number from 1 to 32.");
6450
6774
  return Number(v);
@@ -6495,7 +6819,7 @@ function createProgram() {
6495
6819
  Promise.resolve().then(() => (init_report(), report_exports)),
6496
6820
  Promise.resolve().then(() => (init_reporters(), reporters_exports))
6497
6821
  ]);
6498
- const cwd = globals.config !== void 0 ? (0, import_node_path4.dirname)((0, import_node_path4.resolve)(globals.config)) : process.cwd();
6822
+ const cwd = globals.config !== void 0 ? (0, import_node_path5.dirname)((0, import_node_path5.resolve)(globals.config)) : process.cwd();
6499
6823
  try {
6500
6824
  const result = handleReport2({ cwd });
6501
6825
  const text2 = renderReport2(result, toFormat(options.format), {
@@ -6592,7 +6916,7 @@ function createProgram() {
6592
6916
  const globals = command.optsWithGlobals();
6593
6917
  const verbose = globals.verbose === true;
6594
6918
  const { handleSync: handleSync2, reportSyncFailure: reportSyncFailure2 } = await Promise.resolve().then(() => (init_sync(), sync_exports));
6595
- const cwd = globals.config !== void 0 ? (0, import_node_path4.dirname)((0, import_node_path4.resolve)(globals.config)) : process.cwd();
6919
+ const cwd = globals.config !== void 0 ? (0, import_node_path5.dirname)((0, import_node_path5.resolve)(globals.config)) : process.cwd();
6596
6920
  try {
6597
6921
  const { id, url, gate } = await handleSync2({
6598
6922
  cwd,
@@ -6663,13 +6987,13 @@ function createProgram() {
6663
6987
  "Send a Markdown document of test cases back to Plune (POST /v1/test-cases/markdown)"
6664
6988
  ).option("--dry-run", "Show what would change and write nothing", false).action(async (file, options, command) => {
6665
6989
  const verbose = command.optsWithGlobals().verbose === true;
6666
- const { handlePush: handlePush2, formatReport: formatReport2, reportCasesFailure: reportCasesFailure2 } = await Promise.resolve().then(() => (init_cases(), cases_exports));
6990
+ const { handlePush: handlePush2, formatReport: formatReport3, reportCasesFailure: reportCasesFailure2 } = await Promise.resolve().then(() => (init_cases(), cases_exports));
6667
6991
  try {
6668
6992
  const report = await handlePush2({
6669
6993
  ...file !== void 0 ? { file } : {},
6670
6994
  dryRun: options.dryRun
6671
6995
  });
6672
- process.stdout.write(formatReport2(report));
6996
+ process.stdout.write(formatReport3(report));
6673
6997
  if (report.refused.length > 0) process.exitCode = 4;
6674
6998
  } catch (err) {
6675
6999
  const code = reportCasesFailure2(err, (s) => process.stderr.write(s));
@@ -6681,6 +7005,29 @@ function createProgram() {
6681
7005
  failUnexpected(err, verbose);
6682
7006
  }
6683
7007
  });
7008
+ const requirementsCommand = program.command("requirements").description("Requirements on the platform \u2014 import them from a Markdown file");
7009
+ requirementsCommand.command("import").argument("<file>", "A Markdown file (UTF-8, at most 1 MiB)").description(
7010
+ "Import requirements from a Markdown file. With no mapping flags it lists the tables and sections found; with them it is a dry run until --yes"
7011
+ ).option("--table <n>", "Read the table with this number (from the list)").option("--key-column <header>", "Table: the column holding the requirement key").option("--title-column <header>", "Table: the column holding the title").option("--text-column <header>", "Table: one text column; the title is its first line (not with --title-column)").option("--description-column <header>", "Table: the description column").option("--priority-column <header>", "Table: the priority column").option("--category-column <header>", "Table: the category column").option("--type-column <header>", "Table: the type column").option("--status-column <header>", "Table: the status column (draft, active or done)").option("--criteria-column <header>", "Table: the acceptance criteria column").option("--map-priority <pairs>", 'Rename priority values, e.g. "Critical=Must-have,High=Should-have"').option("--map-category <pairs>", 'Table: rename category values, e.g. "Quality=non-functional"').option("--sections <heading|n>", "Read the section with this heading or number (from the list)").option("--unit <unit>", "Sections: bullets or subheadings (default: sub-headings if it has any)").option("--key-prefix <text>", "Sections: text a bullet or sub-heading starts with that carries its key").option("--priority-from <source>", "Sections: subheading \u2014 bullets take their sub-heading as priority").option("--type <type>", "What a row without a type becomes: user_story, feature or epic").option("--project <id>", "The project (default: PLUNE_PROJECT, else the only project of the account)").option("--yes", "Write it. Without it the import is a dry run", false).option("--json", "Print the platform\u2019s answer as JSON", false).action(async (file, options, command) => {
7012
+ const verbose = command.optsWithGlobals().verbose === true;
7013
+ const { handleRequirementsImport: handleRequirementsImport2, formatDiscover: formatDiscover2, formatReport: formatReport3, reportRequirementsImportFailure: reportRequirementsImportFailure2 } = await Promise.resolve().then(() => (init_requirements_import(), requirements_import_exports));
7014
+ try {
7015
+ const res = await handleRequirementsImport2({ file, ...options });
7016
+ if (options.json === true) {
7017
+ process.stdout.write(JSON.stringify(res.step === "discover" ? res.result : res.report, null, 2) + "\n");
7018
+ } else {
7019
+ process.stdout.write(res.step === "discover" ? formatDiscover2(file, res.result) : formatReport3(res.report));
7020
+ }
7021
+ } catch (err) {
7022
+ const code = reportRequirementsImportFailure2(err, (s) => process.stderr.write(s));
7023
+ if (code !== null) {
7024
+ maybeStack(err, verbose);
7025
+ process.exitCode = code;
7026
+ return;
7027
+ }
7028
+ failUnexpected(err, verbose);
7029
+ }
7030
+ });
6684
7031
  const planCommand = program.command("plan").description("Test plans on the platform \u2014 what a plan collects, as something a runner can take");
6685
7032
  planCommand.command("grep").argument("<id>", "The plan").description(
6686
7033
  "Print the plan's cases as one --grep pattern for the runner (GET /v1/plans/:id/cases)"
@@ -6716,7 +7063,7 @@ function isMainModule() {
6716
7063
  const entry = process.argv[1];
6717
7064
  if (entry === void 0) return false;
6718
7065
  try {
6719
- return (0, import_node_fs7.realpathSync)(entry) === (0, import_node_fs7.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
7066
+ return (0, import_node_fs8.realpathSync)(entry) === (0, import_node_fs8.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
6720
7067
  } catch {
6721
7068
  return false;
6722
7069
  }