@plune-ai/cli 0.9.1 → 0.11.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/README.md CHANGED
@@ -104,6 +104,11 @@ provider API key is read from the environment based on `provider.type`:
104
104
  | `plune logout` | Remove the saved token. |
105
105
  | `plune sync` | Upload the latest local run to the platform. Flags: `--file <path>` to send a specific run JSON. |
106
106
  | `plune run import <file>` | Turn a **JUnit XML** or **Playwright JSON** report into a run in Plune. Needs no provider key — nothing is generated. Flags: `--format junit\|playwright-json` (detected from the file when omitted), `--key <externalKey>` to land several reports in one run (with `PLUNE_SHARED_RUN=1` — see below), `--create` to offer unmatched tests to the review queue. |
107
+ | `plune run start` | Open a platform run — or join the one already carrying `--key` — and print its id. Flags: `--key <externalKey>` (generated when absent), `--json` for one machine-readable line. |
108
+ | `plune run finish <id>` | Close a platform run. This is what the reporter tells you to do for a run it had to leave open. Flags: `--terminate` to record it as cut short, `--reason <text>`. |
109
+ | `plune run exec -- <command>` | Open a run, run the command inside it, close the run — and exit with whatever the command returned. Sets `PLUNE_SHARED_RUN` for you, so anything reporting inside joins that run. Flags: `--key <externalKey>`. |
110
+ | `plune run delete <id>` | Delete a run and everything it produced — its results and the review-queue entries it raised. Approved test cases and the audit log stay. **Recoverable for six months** (ask Plune to put it back), then gone for good. No prompt: it is your data, and this command belongs in scripts. |
111
+ | `plune run report` | Replay `.plune/pending-results.jsonl` — send what the reporter could not. Flags: `--file <path>`. |
107
112
  | `plune ingest [dir]` | Record a [Cairn](https://github.com/plune-ai/cairn) run in Plune. Omit `[dir]` for the newest run under `./runs`, or name the directory holding `report.json`. Generated cases arrive as **review proposals** — nothing is created until a person approves it. |
108
113
 
109
114
  Global flags: `-c, --config <path>` · `-v, --verbose` · `--no-color`.
@@ -153,6 +158,14 @@ job closes the run instead of the explicit `finish`.
153
158
  `plune run exec` sets it for you — anything reporting inside it, this command included, joins
154
159
  without closing.
155
160
 
161
+ ### What the run is called
162
+
163
+ A run nobody named is called after the directory, the minute it started and where it ran —
164
+ `plune · 2026-09-15 15:26 · ci` — so a row in the list reads without being opened. The clock is the
165
+ reporting machine's own; `ci` comes from the `CI` variable every hosted runner sets. To call it
166
+ something else, set `PLUNE_RUN_TITLE`; `PLUNE_ENV` and `PLUNE_LABELS` mark where it ran and how, and
167
+ those stay marks rather than becoming part of the name.
168
+
156
169
  ## Optional: keep a history
157
170
 
158
171
  Everything above works with no account, no network, and no token — that does not change. `run`,
package/dist/cli.cjs CHANGED
@@ -2210,7 +2210,7 @@ function createClient(opts) {
2210
2210
  const wait = opts.wait ?? sleep;
2211
2211
  const base = opts.apiUrl.replace(/\/+$/, "");
2212
2212
  const scrub = (text2) => opts.token === "" ? text2 : text2.split(opts.token).join("***");
2213
- async function post(path12, body) {
2213
+ async function send(method, path13, body) {
2214
2214
  let last = {
2215
2215
  kind: "unavailable",
2216
2216
  status: null,
@@ -2219,17 +2219,23 @@ function createClient(opts) {
2219
2219
  for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
2220
2220
  let res;
2221
2221
  try {
2222
- res = await doFetch(`${base}${path12}`, {
2223
- method: "POST",
2222
+ res = await doFetch(`${base}${path13}`, {
2223
+ method,
2224
2224
  headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
2225
- body: JSON.stringify(body)
2225
+ // A DELETE carries none. Sending `"undefined"` as a body is what a naive
2226
+ // `JSON.stringify(body)` would do, and some proxies answer that with a 400 nobody can
2227
+ // explain.
2228
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
2226
2229
  });
2227
2230
  } catch (err) {
2228
2231
  last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
2229
2232
  if (attempt < RETRY_DELAYS_MS.length) await wait(RETRY_DELAYS_MS[attempt]);
2230
2233
  continue;
2231
2234
  }
2232
- if (res.ok) return { ok: true, status: res.status, body: await res.json() };
2235
+ if (res.ok) {
2236
+ if (res.status === 204) return { ok: true, status: res.status, body: void 0 };
2237
+ return { ok: true, status: res.status, body: await res.json() };
2238
+ }
2233
2239
  const detail = scrub(await detailOf(res));
2234
2240
  const kind = classify3(res.status);
2235
2241
  last = { kind, status: res.status, detail };
@@ -2240,7 +2246,10 @@ function createClient(opts) {
2240
2246
  }
2241
2247
  return { ok: false, ...last };
2242
2248
  }
2243
- return { post };
2249
+ return {
2250
+ post: (path13, body) => send("POST", path13, body),
2251
+ del: (path13) => send("DELETE", path13)
2252
+ };
2244
2253
  }
2245
2254
  var RETRY_DELAYS_MS, sleep;
2246
2255
  var init_client = __esm({
@@ -2304,18 +2313,25 @@ function readEnv(env = process.env) {
2304
2313
  ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0)
2305
2314
  };
2306
2315
  }
2307
- var UNSUPPORTED_VARS;
2316
+ function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cwd = process.cwd()) {
2317
+ const two = (n) => String(n).padStart(2, "0");
2318
+ const stamp = `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())} ${two(now.getHours())}:${two(now.getMinutes())}`;
2319
+ const where = flag(env, "CI") || flag(env, "GITHUB_ACTIONS") ? "ci" : "local";
2320
+ return `${path6.basename(cwd)} \xB7 ${stamp} \xB7 ${where}`;
2321
+ }
2322
+ var path6, UNSUPPORTED_VARS;
2308
2323
  var init_env2 = __esm({
2309
2324
  "src/reporter-core/env.ts"() {
2310
2325
  "use strict";
2311
2326
  init_cjs_shims();
2327
+ path6 = __toESM(require("path"), 1);
2312
2328
  UNSUPPORTED_VARS = ["PLUNE_GROUP"];
2313
2329
  }
2314
2330
  });
2315
2331
 
2316
2332
  // src/reporter-core/fallback.ts
2317
2333
  function appendBatch(file, batch) {
2318
- fs6.mkdirSync(path6.dirname(file), { recursive: true });
2334
+ fs6.mkdirSync(path7.dirname(file), { recursive: true });
2319
2335
  const line = {
2320
2336
  ts: (/* @__PURE__ */ new Date()).toISOString(),
2321
2337
  runId: batch.runId,
@@ -2325,18 +2341,28 @@ function appendBatch(file, batch) {
2325
2341
  fs6.appendFileSync(file, `${JSON.stringify(line)}
2326
2342
  `, "utf8");
2327
2343
  }
2328
- var fs6, path6, DEFAULT_FALLBACK_PATH;
2344
+ var fs6, path7, DEFAULT_FALLBACK_PATH;
2329
2345
  var init_fallback = __esm({
2330
2346
  "src/reporter-core/fallback.ts"() {
2331
2347
  "use strict";
2332
2348
  init_cjs_shims();
2333
2349
  fs6 = __toESM(require("fs"), 1);
2334
- path6 = __toESM(require("path"), 1);
2335
- DEFAULT_FALLBACK_PATH = path6.join(".plune", "pending-results.jsonl");
2350
+ path7 = __toESM(require("path"), 1);
2351
+ DEFAULT_FALLBACK_PATH = path7.join(".plune", "pending-results.jsonl");
2336
2352
  }
2337
2353
  });
2338
2354
 
2339
2355
  // src/reporter-core/session.ts
2356
+ function boundKey(key) {
2357
+ if (key.value.length <= KEY_MAX) return key;
2358
+ const digest = (0, import_node_crypto2.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
2359
+ let head = key.value.slice(0, KEY_MAX - digest.length - 1);
2360
+ if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
2361
+ return { ...key, value: `${head}#${digest}` };
2362
+ }
2363
+ function boundTitle(title) {
2364
+ return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
2365
+ }
2340
2366
  function chunk(items, size) {
2341
2367
  const out = [];
2342
2368
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
@@ -2350,7 +2376,7 @@ function warnIfDropped(sent, run, log) {
2350
2376
  `plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
2351
2377
  );
2352
2378
  }
2353
- async function startRun(passed, expected) {
2379
+ async function startRun(passed, declared) {
2354
2380
  const env = readEnv();
2355
2381
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
2356
2382
  const cfg = { ...env.config, ...stated };
@@ -2373,6 +2399,7 @@ async function startRun(passed, expected) {
2373
2399
  rejected: 0,
2374
2400
  unresolved: 0,
2375
2401
  offered: 0,
2402
+ created: 0,
2376
2403
  unoffered: 0,
2377
2404
  deferred: 0
2378
2405
  };
@@ -2383,6 +2410,22 @@ async function startRun(passed, expected) {
2383
2410
  let joined = false;
2384
2411
  let offline = token === "";
2385
2412
  let done = false;
2413
+ const shortened = /* @__PURE__ */ new Set();
2414
+ function bound(keys) {
2415
+ return keys.map((key) => {
2416
+ const fit = boundKey(key);
2417
+ if (fit !== key && !shortened.has(key.value)) {
2418
+ shortened.add(key.value);
2419
+ if (shortened.size === 1) {
2420
+ log(
2421
+ `plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 "${key.value.slice(0, 80)}\u2026". Give the test a shorter name; until then this is its identity.`
2422
+ );
2423
+ }
2424
+ }
2425
+ return fit;
2426
+ });
2427
+ }
2428
+ const expected = declared?.map(bound);
2386
2429
  if (offline) {
2387
2430
  log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
2388
2431
  }
@@ -2410,9 +2453,9 @@ async function startRun(passed, expected) {
2410
2453
  }
2411
2454
  if (!offline) {
2412
2455
  if (expected !== void 0) await resolveKeys(expected.flat());
2413
- const declared = expected?.slice(0, EXPECTED_MAX) ?? [];
2414
- const configuration = declared.length === 0 ? void 0 : {
2415
- expected: declared.flatMap((keys) => {
2456
+ const stated2 = expected?.slice(0, EXPECTED_MAX) ?? [];
2457
+ const configuration = stated2.length === 0 ? void 0 : {
2458
+ expected: stated2.flatMap((keys) => {
2416
2459
  const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
2417
2460
  if (typeof hit === "string") return [{ testCaseId: hit }];
2418
2461
  return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
@@ -2432,6 +2475,10 @@ async function startRun(passed, expected) {
2432
2475
  ...externalKey !== null ? { externalKey } : {},
2433
2476
  ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
2434
2477
  ...configuration !== void 0 ? { configuration } : {},
2478
+ // Named even when nobody named it — the list has to read without opening a row. Only what
2479
+ // the caller DESCRIBED goes to `warnIfDropped` below: a title this side invented is not one
2480
+ // a person will miss, and a deployment older than the field must not be nagged on every run.
2481
+ title: defaultRunTitle(),
2435
2482
  ...described
2436
2483
  });
2437
2484
  if (start.ok) {
@@ -2456,9 +2503,11 @@ async function startRun(passed, expected) {
2456
2503
  if (!offerDiscovered) return;
2457
2504
  if (result.title === void 0 || result.specRef === void 0) return;
2458
2505
  if (result.keys.length === 0) return;
2506
+ const title = boundTitle(result.title);
2507
+ if (title !== result.title) shortened.add(result.title);
2459
2508
  discoveries.set(result.keys[0].value, {
2460
2509
  keys: result.keys,
2461
- title: result.title,
2510
+ title,
2462
2511
  source: result.source,
2463
2512
  specRef: result.specRef,
2464
2513
  rawStatus: result.rawStatus
@@ -2480,6 +2529,7 @@ async function startRun(passed, expected) {
2480
2529
  return;
2481
2530
  }
2482
2531
  stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
2532
+ stats.created += out.body.results.filter((r) => r.outcome === "created").length;
2483
2533
  }
2484
2534
  }
2485
2535
  async function flush() {
@@ -2545,7 +2595,9 @@ async function startRun(passed, expected) {
2545
2595
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
2546
2596
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
2547
2597
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
2598
+ if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
2548
2599
  if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
2600
+ if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
2549
2601
  log(`plune: ${parts.join(" \xB7 ")}`);
2550
2602
  }
2551
2603
  return {
@@ -2559,7 +2611,7 @@ async function startRun(passed, expected) {
2559
2611
  return stats;
2560
2612
  },
2561
2613
  async add(result) {
2562
- buffer.push(result);
2614
+ buffer.push({ ...result, keys: bound(result.keys) });
2563
2615
  if (buffer.length >= batchSize) await flush();
2564
2616
  },
2565
2617
  flush,
@@ -2589,11 +2641,12 @@ async function startRun(passed, expected) {
2589
2641
  }
2590
2642
  };
2591
2643
  }
2592
- var RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX;
2644
+ var import_node_crypto2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX;
2593
2645
  var init_session = __esm({
2594
2646
  "src/reporter-core/session.ts"() {
2595
2647
  "use strict";
2596
2648
  init_cjs_shims();
2649
+ import_node_crypto2 = require("crypto");
2597
2650
  init_api_url();
2598
2651
  init_credentials();
2599
2652
  init_client();
@@ -2604,6 +2657,8 @@ var init_session = __esm({
2604
2657
  DISCOVER_MAX = 500;
2605
2658
  BATCH_DEFAULT = 100;
2606
2659
  EXPECTED_MAX = 1e4;
2660
+ KEY_MAX = 1024;
2661
+ TITLE_MAX = 300;
2607
2662
  }
2608
2663
  });
2609
2664
 
@@ -2612,6 +2667,7 @@ var run_lifecycle_exports = {};
2612
2667
  __export(run_lifecycle_exports, {
2613
2668
  NoTokenError: () => NoTokenError,
2614
2669
  RunCommandError: () => RunCommandError,
2670
+ handleRunDelete: () => handleRunDelete,
2615
2671
  handleRunExec: () => handleRunExec,
2616
2672
  handleRunFinish: () => handleRunFinish,
2617
2673
  handleRunReport: () => handleRunReport,
@@ -2644,7 +2700,9 @@ async function handleRunStart(options = {}) {
2644
2700
  const out = await client.post("/v1/runs", {
2645
2701
  schemaVersion: 2,
2646
2702
  kind: options.kind ?? "automated",
2647
- externalKey
2703
+ externalKey,
2704
+ // The same name the reporter would give it; `PLUNE_RUN_TITLE` replaces it, as everywhere.
2705
+ title: env.config.title ?? defaultRunTitle()
2648
2706
  });
2649
2707
  if (!out.ok) throw new RunCommandError(`Could not start the run \u2014 ${out.detail || out.kind}.`);
2650
2708
  const result = {
@@ -2675,6 +2733,22 @@ async function handleRunFinish(id, options = {}) {
2675
2733
  }
2676
2734
  write(`Run ${id} ${event === "finish" ? "finished" : "terminated"}.`);
2677
2735
  }
2736
+ async function handleRunDelete(id, options = {}) {
2737
+ const { client, write } = connect(options);
2738
+ const out = await client.del(`/v1/runs/${encodeURIComponent(id)}`);
2739
+ if (!out.ok) {
2740
+ throw new RunCommandError(
2741
+ out.status === 404 ? (
2742
+ // Three causes, one answer, and the message names all three because the platform will not:
2743
+ // a 404 that distinguished "not yours" from "not there" would tell a stranger which ids
2744
+ // exist. The person holding the terminal is better served by the list than by a guess.
2745
+ `No run '${id}' \u2014 check the id, whether the token belongs to the same project, or whether it is already deleted.`
2746
+ ) : `Could not delete run ${id} \u2014 ${out.detail || out.kind}.`
2747
+ );
2748
+ }
2749
+ write(`Run ${id} deleted, with its results and the review-queue entries it produced.`);
2750
+ write("Recoverable for six months \u2014 ask Plune to put it back.");
2751
+ }
2678
2752
  async function handleRunExec(options) {
2679
2753
  const [command, ...args] = options.argv;
2680
2754
  if (command === void 0) throw new RunCommandError("Nothing to run \u2014 pass a command after `--`.");
@@ -3208,6 +3282,7 @@ async function handleRunImport(options) {
3208
3282
  rejected: stats.rejected,
3209
3283
  unresolved: stats.unresolved,
3210
3284
  offered: stats.offered,
3285
+ created: stats.created,
3211
3286
  unoffered: stats.unoffered,
3212
3287
  deferred: stats.deferred,
3213
3288
  unlocatable: results.filter((r) => r.specRef === void 0).length,
@@ -3236,11 +3311,16 @@ function describe(result, apiUrl) {
3236
3311
  `${result.deferred} could not be sent and are in the fallback file \u2014 "plune run report" retries them.`
3237
3312
  );
3238
3313
  }
3314
+ if (result.created > 0) {
3315
+ lines.push(
3316
+ `${result.created} unknown test(s) became cases without review \u2014 this project trusts the source. Change that under Settings \u2192 Trusted sources.`
3317
+ );
3318
+ }
3239
3319
  if (result.offered > 0) {
3240
3320
  lines.push(`${result.offered} unknown test(s) offered to the review queue.`);
3241
- } else if (result.unresolved > 0 && !result.offering) {
3321
+ } else if (result.unresolved > 0 && result.created === 0 && !result.offering) {
3242
3322
  lines.push("Nothing was offered to the review queue \u2014 pass --create to propose the unmatched tests.");
3243
- } else if (result.unresolved > 0 && result.unoffered === 0) {
3323
+ } else if (result.unresolved > 0 && result.created === 0 && result.unoffered === 0) {
3244
3324
  lines.push("Nothing new to offer \u2014 the unmatched tests are already in the review queue.");
3245
3325
  }
3246
3326
  if (result.unoffered > 0) {
@@ -3284,7 +3364,7 @@ function isRunResult(v) {
3284
3364
  }
3285
3365
  function handleReport(opts = {}) {
3286
3366
  const cwd = opts.cwd ?? process.cwd();
3287
- const file = path7.join(cwd, ".plune", "last-run.json");
3367
+ const file = path8.join(cwd, ".plune", "last-run.json");
3288
3368
  let raw;
3289
3369
  try {
3290
3370
  raw = fs9.readFileSync(file, "utf8");
@@ -3302,13 +3382,13 @@ function handleReport(opts = {}) {
3302
3382
  }
3303
3383
  return parsed;
3304
3384
  }
3305
- var fs9, path7, ReportNotFoundError;
3385
+ var fs9, path8, ReportNotFoundError;
3306
3386
  var init_report = __esm({
3307
3387
  "src/cli/commands/report.ts"() {
3308
3388
  "use strict";
3309
3389
  init_cjs_shims();
3310
3390
  fs9 = __toESM(require("fs"), 1);
3311
- path7 = __toESM(require("path"), 1);
3391
+ path8 = __toESM(require("path"), 1);
3312
3392
  ReportNotFoundError = class extends Error {
3313
3393
  code = "NO_SAVED_RUN";
3314
3394
  constructor(message) {
@@ -3532,7 +3612,7 @@ async function runInitWizard(cwd) {
3532
3612
  process.exit(1);
3533
3613
  return;
3534
3614
  }
3535
- const outputPath = path8.join(cwd, "plune.yaml");
3615
+ const outputPath = path9.join(cwd, "plune.yaml");
3536
3616
  if (fs11.existsSync(outputPath)) {
3537
3617
  const overwrite = await clack.confirm({
3538
3618
  message: "plune.yaml already exists. Overwrite?"
@@ -3576,14 +3656,14 @@ async function runInitWizard(cwd) {
3576
3656
  spin.stop("plune.yaml created!");
3577
3657
  clack.outro("plune.yaml is ready.");
3578
3658
  }
3579
- var clack, fs11, path8, import_yaml2;
3659
+ var clack, fs11, path9, import_yaml2;
3580
3660
  var init_wizard = __esm({
3581
3661
  "src/config/init/wizard.ts"() {
3582
3662
  "use strict";
3583
3663
  init_cjs_shims();
3584
3664
  clack = __toESM(require("@clack/prompts"), 1);
3585
3665
  fs11 = __toESM(require("fs"), 1);
3586
- path8 = __toESM(require("path"), 1);
3666
+ path9 = __toESM(require("path"), 1);
3587
3667
  import_yaml2 = require("yaml");
3588
3668
  init_errors();
3589
3669
  }
@@ -3591,21 +3671,21 @@ var init_wizard = __esm({
3591
3671
 
3592
3672
  // src/cli/templates/index.ts
3593
3673
  function writeTemplateFile(cwd, rel, content, force) {
3594
- const target = path9.join(cwd, rel);
3674
+ const target = path10.join(cwd, rel);
3595
3675
  if (!force && fs12.existsSync(target)) {
3596
3676
  return "skipped";
3597
3677
  }
3598
- fs12.mkdirSync(path9.dirname(target), { recursive: true });
3678
+ fs12.mkdirSync(path10.dirname(target), { recursive: true });
3599
3679
  fs12.writeFileSync(target, content, "utf8");
3600
3680
  return "written";
3601
3681
  }
3602
- var fs12, path9, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
3682
+ var fs12, path10, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
3603
3683
  var init_templates = __esm({
3604
3684
  "src/cli/templates/index.ts"() {
3605
3685
  "use strict";
3606
3686
  init_cjs_shims();
3607
3687
  fs12 = __toESM(require("fs"), 1);
3608
- path9 = __toESM(require("path"), 1);
3688
+ path10 = __toESM(require("path"), 1);
3609
3689
  ENV_EXAMPLE_TPL = `# Copy this file to .env and replace the placeholders with your real keys.
3610
3690
  # .env is gitignored; .env.example is safe to commit.
3611
3691
  # Plune reads whichever key matches your configured provider.
@@ -3861,7 +3941,7 @@ __export(sync_exports, {
3861
3941
  reportSyncFailure: () => reportSyncFailure
3862
3942
  });
3863
3943
  function readRun(cwd, file) {
3864
- const target = file !== void 0 ? path10.resolve(cwd, file) : path10.join(cwd, ".plune", "last-run.json");
3944
+ const target = file !== void 0 ? path11.resolve(cwd, file) : path11.join(cwd, ".plune", "last-run.json");
3865
3945
  let raw;
3866
3946
  try {
3867
3947
  raw = fs13.readFileSync(target, "utf8");
@@ -3928,13 +4008,13 @@ function reportSyncFailure(err, write) {
3928
4008
  }
3929
4009
  return null;
3930
4010
  }
3931
- var fs13, path10, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
4011
+ var fs13, path11, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
3932
4012
  var init_sync = __esm({
3933
4013
  "src/cli/commands/sync.ts"() {
3934
4014
  "use strict";
3935
4015
  init_cjs_shims();
3936
4016
  fs13 = __toESM(require("fs"), 1);
3937
- path10 = __toESM(require("path"), 1);
4017
+ path11 = __toESM(require("path"), 1);
3938
4018
  init_run_result_schema();
3939
4019
  init_api_url();
3940
4020
  init_credentials();
@@ -3984,9 +4064,9 @@ async function findLatestCairnRun(runsDir = "runs") {
3984
4064
  let newest;
3985
4065
  for (const entry of entries) {
3986
4066
  if (!entry.isDirectory()) continue;
3987
- const dir = path11.join(runsDir, entry.name);
4067
+ const dir = path12.join(runsDir, entry.name);
3988
4068
  try {
3989
- const report = await fs14.lstat(path11.join(dir, "report.json"));
4069
+ const report = await fs14.lstat(path12.join(dir, "report.json"));
3990
4070
  if (!report.isFile()) continue;
3991
4071
  if (newest === void 0 || report.mtimeMs > newest.at) {
3992
4072
  newest = { dir, at: report.mtimeMs };
@@ -4005,7 +4085,7 @@ async function readJson(file) {
4005
4085
  }
4006
4086
  }
4007
4087
  async function defaultReadEvidence(dir) {
4008
- const raw = await readJson(path11.join(dir, "api-evidence.json"));
4088
+ const raw = await readJson(path12.join(dir, "api-evidence.json"));
4009
4089
  if (!Array.isArray(raw)) return void 0;
4010
4090
  return raw.map((r) => {
4011
4091
  const rec = asRecord(r);
@@ -4047,7 +4127,7 @@ function casesFromApi(report, verdicts) {
4047
4127
  });
4048
4128
  }
4049
4129
  async function readCairnRun(dir, deps = {}) {
4050
- const raw = await readJson(path11.join(dir, "report.json"));
4130
+ const raw = await readJson(path12.join(dir, "report.json"));
4051
4131
  if (raw === void 0) throw new CairnRunNotFoundError(dir);
4052
4132
  const report = asRecord(raw);
4053
4133
  if (report["schemaVersion"] !== SUPPORTED_ARTIFACT_VERSION) {
@@ -4077,20 +4157,20 @@ async function readCairnRun(dir, deps = {}) {
4077
4157
  return {
4078
4158
  schemaVersion: INGEST_SCHEMA_VERSION,
4079
4159
  run: {
4080
- runId: str(report["runId"], path11.basename(dir)),
4160
+ runId: str(report["runId"], path12.basename(dir)),
4081
4161
  mode,
4082
4162
  target: str(report["url"], "unknown")
4083
4163
  },
4084
4164
  cases
4085
4165
  };
4086
4166
  }
4087
- var fs14, path11, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
4167
+ var fs14, path12, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
4088
4168
  var init_cairn_artifact = __esm({
4089
4169
  "src/cli/commands/cairn-artifact.ts"() {
4090
4170
  "use strict";
4091
4171
  init_cjs_shims();
4092
4172
  fs14 = __toESM(require("fs/promises"), 1);
4093
- path11 = __toESM(require("path"), 1);
4173
+ path12 = __toESM(require("path"), 1);
4094
4174
  SUPPORTED_ARTIFACT_VERSION = 1;
4095
4175
  INGEST_SCHEMA_VERSION = 1;
4096
4176
  NoCairnRunsError = class extends Error {
@@ -4428,6 +4508,14 @@ function createProgram() {
4428
4508
  await failRunCommand(err, verboseOf(command));
4429
4509
  }
4430
4510
  });
4511
+ runCommand.command("delete").argument("<id>", "The run id \u2014 the one `plune run import` printed, or the one in the dashboard URL").description("Delete a run and everything it produced \u2014 recoverable for six months").action(async (id, _options, command) => {
4512
+ try {
4513
+ const { handleRunDelete: handleRunDelete2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
4514
+ await handleRunDelete2(id);
4515
+ } catch (err) {
4516
+ await failRunCommand(err, verboseOf(command));
4517
+ }
4518
+ });
4431
4519
  runCommand.command("report").description("Send what the reporter could not \u2014 replays .plune/pending-results.jsonl").option("--file <path>", "The fallback file to replay").action(async (options, command) => {
4432
4520
  try {
4433
4521
  const { handleRunReport: handleRunReport2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
@@ -4538,10 +4626,10 @@ function createProgram() {
4538
4626
  const { handleLogin: handleLogin2, reportLoginFailure: reportLoginFailure2 } = await Promise.resolve().then(() => (init_login(), login_exports));
4539
4627
  try {
4540
4628
  const token = options.token ?? await readTokenFromStdin();
4541
- const { path: path12, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
4629
+ const { path: path13, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
4542
4630
  process.stdout.write(
4543
- verified ? `Logged in. Token checked against the API and saved to ${path12}
4544
- ` : `Token saved to ${path12} WITHOUT being checked (--skip-verify).
4631
+ verified ? `Logged in. Token checked against the API and saved to ${path13}
4632
+ ` : `Token saved to ${path13} WITHOUT being checked (--skip-verify).
4545
4633
  `
4546
4634
  );
4547
4635
  } catch (err) {
@@ -4558,9 +4646,9 @@ function createProgram() {
4558
4646
  const verbose = command.optsWithGlobals().verbose === true;
4559
4647
  const { handleLogout: handleLogout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
4560
4648
  try {
4561
- const { removed, path: path12 } = handleLogout2();
4649
+ const { removed, path: path13 } = handleLogout2();
4562
4650
  process.stdout.write(
4563
- removed ? `Logged out. Removed ${path12}
4651
+ removed ? `Logged out. Removed ${path13}
4564
4652
  ` : "Not logged in \u2014 nothing to remove.\n"
4565
4653
  );
4566
4654
  } catch (err) {
@@ -41,6 +41,9 @@ __export(reporter_core_exports, {
41
41
  });
42
42
  module.exports = __toCommonJS(reporter_core_exports);
43
43
 
44
+ // src/reporter-core/session.ts
45
+ var import_node_crypto = require("crypto");
46
+
44
47
  // src/cli/api-url.ts
45
48
  var DEFAULT_API_URL = "https://beta-api.plune.ai";
46
49
  function resolveApiUrl(explicit) {
@@ -94,7 +97,7 @@ function createClient(opts) {
94
97
  const wait = opts.wait ?? sleep;
95
98
  const base = opts.apiUrl.replace(/\/+$/, "");
96
99
  const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
97
- async function post(path2, body) {
100
+ async function send(method, path3, body) {
98
101
  let last = {
99
102
  kind: "unavailable",
100
103
  status: null,
@@ -103,17 +106,23 @@ function createClient(opts) {
103
106
  for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
104
107
  let res;
105
108
  try {
106
- res = await doFetch(`${base}${path2}`, {
107
- method: "POST",
109
+ res = await doFetch(`${base}${path3}`, {
110
+ method,
108
111
  headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
109
- body: JSON.stringify(body)
112
+ // A DELETE carries none. Sending `"undefined"` as a body is what a naive
113
+ // `JSON.stringify(body)` would do, and some proxies answer that with a 400 nobody can
114
+ // explain.
115
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
110
116
  });
111
117
  } catch (err) {
112
118
  last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
113
119
  if (attempt < RETRY_DELAYS_MS.length) await wait(RETRY_DELAYS_MS[attempt]);
114
120
  continue;
115
121
  }
116
- if (res.ok) return { ok: true, status: res.status, body: await res.json() };
122
+ if (res.ok) {
123
+ if (res.status === 204) return { ok: true, status: res.status, body: void 0 };
124
+ return { ok: true, status: res.status, body: await res.json() };
125
+ }
117
126
  const detail = scrub(await detailOf(res));
118
127
  const kind = classify(res.status);
119
128
  last = { kind, status: res.status, detail };
@@ -124,10 +133,14 @@ function createClient(opts) {
124
133
  }
125
134
  return { ok: false, ...last };
126
135
  }
127
- return { post };
136
+ return {
137
+ post: (path3, body) => send("POST", path3, body),
138
+ del: (path3) => send("DELETE", path3)
139
+ };
128
140
  }
129
141
 
130
142
  // src/reporter-core/env.ts
143
+ var path = __toESM(require("path"), 1);
131
144
  var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
132
145
  function value(env, name) {
133
146
  const raw = env[name]?.trim();
@@ -180,13 +193,19 @@ function readEnv(env = process.env) {
180
193
  ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0)
181
194
  };
182
195
  }
196
+ function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cwd = process.cwd()) {
197
+ const two = (n) => String(n).padStart(2, "0");
198
+ const stamp = `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())} ${two(now.getHours())}:${two(now.getMinutes())}`;
199
+ const where = flag(env, "CI") || flag(env, "GITHUB_ACTIONS") ? "ci" : "local";
200
+ return `${path.basename(cwd)} \xB7 ${stamp} \xB7 ${where}`;
201
+ }
183
202
 
184
203
  // src/reporter-core/fallback.ts
185
204
  var fs = __toESM(require("fs"), 1);
186
- var path = __toESM(require("path"), 1);
187
- var DEFAULT_FALLBACK_PATH = path.join(".plune", "pending-results.jsonl");
205
+ var path2 = __toESM(require("path"), 1);
206
+ var DEFAULT_FALLBACK_PATH = path2.join(".plune", "pending-results.jsonl");
188
207
  function appendBatch(file, batch) {
189
- fs.mkdirSync(path.dirname(file), { recursive: true });
208
+ fs.mkdirSync(path2.dirname(file), { recursive: true });
190
209
  const line = {
191
210
  ts: (/* @__PURE__ */ new Date()).toISOString(),
192
211
  runId: batch.runId,
@@ -203,6 +222,18 @@ var BATCH_MAX = 500;
203
222
  var DISCOVER_MAX = 500;
204
223
  var BATCH_DEFAULT = 100;
205
224
  var EXPECTED_MAX = 1e4;
225
+ var KEY_MAX = 1024;
226
+ var TITLE_MAX = 300;
227
+ function boundKey(key) {
228
+ if (key.value.length <= KEY_MAX) return key;
229
+ const digest = (0, import_node_crypto.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
230
+ let head = key.value.slice(0, KEY_MAX - digest.length - 1);
231
+ if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
232
+ return { ...key, value: `${head}#${digest}` };
233
+ }
234
+ function boundTitle(title) {
235
+ return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
236
+ }
206
237
  function chunk(items, size) {
207
238
  const out = [];
208
239
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
@@ -216,7 +247,7 @@ function warnIfDropped(sent, run, log) {
216
247
  `plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
217
248
  );
218
249
  }
219
- async function startRun(passed, expected) {
250
+ async function startRun(passed, declared) {
220
251
  const env = readEnv();
221
252
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
222
253
  const cfg = { ...env.config, ...stated };
@@ -239,6 +270,7 @@ async function startRun(passed, expected) {
239
270
  rejected: 0,
240
271
  unresolved: 0,
241
272
  offered: 0,
273
+ created: 0,
242
274
  unoffered: 0,
243
275
  deferred: 0
244
276
  };
@@ -249,6 +281,22 @@ async function startRun(passed, expected) {
249
281
  let joined = false;
250
282
  let offline = token === "";
251
283
  let done = false;
284
+ const shortened = /* @__PURE__ */ new Set();
285
+ function bound(keys) {
286
+ return keys.map((key) => {
287
+ const fit = boundKey(key);
288
+ if (fit !== key && !shortened.has(key.value)) {
289
+ shortened.add(key.value);
290
+ if (shortened.size === 1) {
291
+ log(
292
+ `plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 "${key.value.slice(0, 80)}\u2026". Give the test a shorter name; until then this is its identity.`
293
+ );
294
+ }
295
+ }
296
+ return fit;
297
+ });
298
+ }
299
+ const expected = declared?.map(bound);
252
300
  if (offline) {
253
301
  log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
254
302
  }
@@ -276,9 +324,9 @@ async function startRun(passed, expected) {
276
324
  }
277
325
  if (!offline) {
278
326
  if (expected !== void 0) await resolveKeys(expected.flat());
279
- const declared = expected?.slice(0, EXPECTED_MAX) ?? [];
280
- const configuration = declared.length === 0 ? void 0 : {
281
- expected: declared.flatMap((keys) => {
327
+ const stated2 = expected?.slice(0, EXPECTED_MAX) ?? [];
328
+ const configuration = stated2.length === 0 ? void 0 : {
329
+ expected: stated2.flatMap((keys) => {
282
330
  const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
283
331
  if (typeof hit === "string") return [{ testCaseId: hit }];
284
332
  return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
@@ -298,6 +346,10 @@ async function startRun(passed, expected) {
298
346
  ...externalKey !== null ? { externalKey } : {},
299
347
  ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
300
348
  ...configuration !== void 0 ? { configuration } : {},
349
+ // Named even when nobody named it — the list has to read without opening a row. Only what
350
+ // the caller DESCRIBED goes to `warnIfDropped` below: a title this side invented is not one
351
+ // a person will miss, and a deployment older than the field must not be nagged on every run.
352
+ title: defaultRunTitle(),
301
353
  ...described
302
354
  });
303
355
  if (start.ok) {
@@ -322,9 +374,11 @@ async function startRun(passed, expected) {
322
374
  if (!offerDiscovered) return;
323
375
  if (result.title === void 0 || result.specRef === void 0) return;
324
376
  if (result.keys.length === 0) return;
377
+ const title = boundTitle(result.title);
378
+ if (title !== result.title) shortened.add(result.title);
325
379
  discoveries.set(result.keys[0].value, {
326
380
  keys: result.keys,
327
- title: result.title,
381
+ title,
328
382
  source: result.source,
329
383
  specRef: result.specRef,
330
384
  rawStatus: result.rawStatus
@@ -346,6 +400,7 @@ async function startRun(passed, expected) {
346
400
  return;
347
401
  }
348
402
  stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
403
+ stats.created += out.body.results.filter((r) => r.outcome === "created").length;
349
404
  }
350
405
  }
351
406
  async function flush() {
@@ -411,7 +466,9 @@ async function startRun(passed, expected) {
411
466
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
412
467
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
413
468
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
469
+ if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
414
470
  if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
471
+ if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
415
472
  log(`plune: ${parts.join(" \xB7 ")}`);
416
473
  }
417
474
  return {
@@ -425,7 +482,7 @@ async function startRun(passed, expected) {
425
482
  return stats;
426
483
  },
427
484
  async add(result) {
428
- buffer.push(result);
485
+ buffer.push({ ...result, keys: bound(result.keys) });
429
486
  if (buffer.length >= batchSize) await flush();
430
487
  },
431
488
  flush,
@@ -457,9 +514,9 @@ async function startRun(passed, expected) {
457
514
  }
458
515
 
459
516
  // src/util/hash.ts
460
- var import_node_crypto = require("crypto");
517
+ var import_node_crypto2 = require("crypto");
461
518
  function sha256(input) {
462
- return (0, import_node_crypto.createHash)("sha256").update(input, "utf8").digest("hex");
519
+ return (0, import_node_crypto2.createHash)("sha256").update(input, "utf8").digest("hex");
463
520
  }
464
521
 
465
522
  // src/reporter-core/result-key.ts
@@ -139,6 +139,16 @@ interface RunStats extends SubmitCounts {
139
139
  /** Unresolved tests offered to the review queue under `PLUNE_CREATE=1` (D14). Counted separately
140
140
  * from `unresolved` because they are the same tests seen twice, not a second population. */
141
141
  offered: number;
142
+ /**
143
+ * Offered tests the platform turned into cases outright, because the project trusts this source
144
+ * (platform ADR 0030).
145
+ *
146
+ * Its own number rather than part of `offered`: these are the opposite of work to do. `offered`
147
+ * means "somebody has to look at this"; `created` means "nobody has to, and here is what appeared
148
+ * in your project". Folding them together would hide the larger of the two facts under a word
149
+ * that asks for attention nobody owes.
150
+ */
151
+ created: number;
142
152
  /**
143
153
  * Unmatched tests that were NOT offered, because the queue would not take them (#627).
144
154
  *
@@ -205,7 +215,7 @@ interface RunSession {
205
215
  /** Send what is left and deliberately leave the run open — the shard case (AC-03). */
206
216
  leaveOpen(): Promise<void>;
207
217
  }
208
- declare function startRun(passed: ReporterConfig, expected?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
218
+ declare function startRun(passed: ReporterConfig, declared?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
209
219
 
210
220
  /**
211
221
  * The ceiling the platform's contract puts on `resultKey` (`resultSubmissionSchema`).
@@ -341,6 +351,13 @@ interface ClientOptions {
341
351
  }
342
352
  interface PlatformClient {
343
353
  post<T>(path: string, body: unknown): Promise<ClientOutcome<T>>;
354
+ /**
355
+ * A DELETE, through the same retries, the same classification and the same token scrubbing as a
356
+ * POST. It is here rather than as a bare `fetch` in the one command that needs it because all
357
+ * three of those matter at least as much for a delete: an unclassified 404 reads as an outage, and
358
+ * a proxy error page that echoes the request would put a token in a CI log.
359
+ */
360
+ del<T>(path: string): Promise<ClientOutcome<T>>;
344
361
  }
345
362
  declare function createClient(opts: ClientOptions): PlatformClient;
346
363
 
@@ -139,6 +139,16 @@ interface RunStats extends SubmitCounts {
139
139
  /** Unresolved tests offered to the review queue under `PLUNE_CREATE=1` (D14). Counted separately
140
140
  * from `unresolved` because they are the same tests seen twice, not a second population. */
141
141
  offered: number;
142
+ /**
143
+ * Offered tests the platform turned into cases outright, because the project trusts this source
144
+ * (platform ADR 0030).
145
+ *
146
+ * Its own number rather than part of `offered`: these are the opposite of work to do. `offered`
147
+ * means "somebody has to look at this"; `created` means "nobody has to, and here is what appeared
148
+ * in your project". Folding them together would hide the larger of the two facts under a word
149
+ * that asks for attention nobody owes.
150
+ */
151
+ created: number;
142
152
  /**
143
153
  * Unmatched tests that were NOT offered, because the queue would not take them (#627).
144
154
  *
@@ -205,7 +215,7 @@ interface RunSession {
205
215
  /** Send what is left and deliberately leave the run open — the shard case (AC-03). */
206
216
  leaveOpen(): Promise<void>;
207
217
  }
208
- declare function startRun(passed: ReporterConfig, expected?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
218
+ declare function startRun(passed: ReporterConfig, declared?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
209
219
 
210
220
  /**
211
221
  * The ceiling the platform's contract puts on `resultKey` (`resultSubmissionSchema`).
@@ -341,6 +351,13 @@ interface ClientOptions {
341
351
  }
342
352
  interface PlatformClient {
343
353
  post<T>(path: string, body: unknown): Promise<ClientOutcome<T>>;
354
+ /**
355
+ * A DELETE, through the same retries, the same classification and the same token scrubbing as a
356
+ * POST. It is here rather than as a bare `fetch` in the one command that needs it because all
357
+ * three of those matter at least as much for a delete: an unclassified 404 reads as an outage, and
358
+ * a proxy error page that echoes the request would put a token in a CI log.
359
+ */
360
+ del<T>(path: string): Promise<ClientOutcome<T>>;
344
361
  }
345
362
  declare function createClient(opts: ClientOptions): PlatformClient;
346
363
 
@@ -1,3 +1,6 @@
1
+ // src/reporter-core/session.ts
2
+ import { createHash } from "crypto";
3
+
1
4
  // src/cli/api-url.ts
2
5
  var DEFAULT_API_URL = "https://beta-api.plune.ai";
3
6
  function resolveApiUrl(explicit) {
@@ -51,7 +54,7 @@ function createClient(opts) {
51
54
  const wait = opts.wait ?? sleep;
52
55
  const base = opts.apiUrl.replace(/\/+$/, "");
53
56
  const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
54
- async function post(path2, body) {
57
+ async function send(method, path3, body) {
55
58
  let last = {
56
59
  kind: "unavailable",
57
60
  status: null,
@@ -60,17 +63,23 @@ function createClient(opts) {
60
63
  for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
61
64
  let res;
62
65
  try {
63
- res = await doFetch(`${base}${path2}`, {
64
- method: "POST",
66
+ res = await doFetch(`${base}${path3}`, {
67
+ method,
65
68
  headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
66
- body: JSON.stringify(body)
69
+ // A DELETE carries none. Sending `"undefined"` as a body is what a naive
70
+ // `JSON.stringify(body)` would do, and some proxies answer that with a 400 nobody can
71
+ // explain.
72
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
67
73
  });
68
74
  } catch (err) {
69
75
  last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
70
76
  if (attempt < RETRY_DELAYS_MS.length) await wait(RETRY_DELAYS_MS[attempt]);
71
77
  continue;
72
78
  }
73
- if (res.ok) return { ok: true, status: res.status, body: await res.json() };
79
+ if (res.ok) {
80
+ if (res.status === 204) return { ok: true, status: res.status, body: void 0 };
81
+ return { ok: true, status: res.status, body: await res.json() };
82
+ }
74
83
  const detail = scrub(await detailOf(res));
75
84
  const kind = classify(res.status);
76
85
  last = { kind, status: res.status, detail };
@@ -81,10 +90,14 @@ function createClient(opts) {
81
90
  }
82
91
  return { ok: false, ...last };
83
92
  }
84
- return { post };
93
+ return {
94
+ post: (path3, body) => send("POST", path3, body),
95
+ del: (path3) => send("DELETE", path3)
96
+ };
85
97
  }
86
98
 
87
99
  // src/reporter-core/env.ts
100
+ import * as path from "path";
88
101
  var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
89
102
  function value(env, name) {
90
103
  const raw = env[name]?.trim();
@@ -137,13 +150,19 @@ function readEnv(env = process.env) {
137
150
  ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0)
138
151
  };
139
152
  }
153
+ function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cwd = process.cwd()) {
154
+ const two = (n) => String(n).padStart(2, "0");
155
+ const stamp = `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())} ${two(now.getHours())}:${two(now.getMinutes())}`;
156
+ const where = flag(env, "CI") || flag(env, "GITHUB_ACTIONS") ? "ci" : "local";
157
+ return `${path.basename(cwd)} \xB7 ${stamp} \xB7 ${where}`;
158
+ }
140
159
 
141
160
  // src/reporter-core/fallback.ts
142
161
  import * as fs from "fs";
143
- import * as path from "path";
144
- var DEFAULT_FALLBACK_PATH = path.join(".plune", "pending-results.jsonl");
162
+ import * as path2 from "path";
163
+ var DEFAULT_FALLBACK_PATH = path2.join(".plune", "pending-results.jsonl");
145
164
  function appendBatch(file, batch) {
146
- fs.mkdirSync(path.dirname(file), { recursive: true });
165
+ fs.mkdirSync(path2.dirname(file), { recursive: true });
147
166
  const line = {
148
167
  ts: (/* @__PURE__ */ new Date()).toISOString(),
149
168
  runId: batch.runId,
@@ -160,6 +179,18 @@ var BATCH_MAX = 500;
160
179
  var DISCOVER_MAX = 500;
161
180
  var BATCH_DEFAULT = 100;
162
181
  var EXPECTED_MAX = 1e4;
182
+ var KEY_MAX = 1024;
183
+ var TITLE_MAX = 300;
184
+ function boundKey(key) {
185
+ if (key.value.length <= KEY_MAX) return key;
186
+ const digest = createHash("sha256").update(key.value).digest("hex").slice(0, 16);
187
+ let head = key.value.slice(0, KEY_MAX - digest.length - 1);
188
+ if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
189
+ return { ...key, value: `${head}#${digest}` };
190
+ }
191
+ function boundTitle(title) {
192
+ return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
193
+ }
163
194
  function chunk(items, size) {
164
195
  const out = [];
165
196
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
@@ -173,7 +204,7 @@ function warnIfDropped(sent, run, log) {
173
204
  `plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
174
205
  );
175
206
  }
176
- async function startRun(passed, expected) {
207
+ async function startRun(passed, declared) {
177
208
  const env = readEnv();
178
209
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
179
210
  const cfg = { ...env.config, ...stated };
@@ -196,6 +227,7 @@ async function startRun(passed, expected) {
196
227
  rejected: 0,
197
228
  unresolved: 0,
198
229
  offered: 0,
230
+ created: 0,
199
231
  unoffered: 0,
200
232
  deferred: 0
201
233
  };
@@ -206,6 +238,22 @@ async function startRun(passed, expected) {
206
238
  let joined = false;
207
239
  let offline = token === "";
208
240
  let done = false;
241
+ const shortened = /* @__PURE__ */ new Set();
242
+ function bound(keys) {
243
+ return keys.map((key) => {
244
+ const fit = boundKey(key);
245
+ if (fit !== key && !shortened.has(key.value)) {
246
+ shortened.add(key.value);
247
+ if (shortened.size === 1) {
248
+ log(
249
+ `plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 "${key.value.slice(0, 80)}\u2026". Give the test a shorter name; until then this is its identity.`
250
+ );
251
+ }
252
+ }
253
+ return fit;
254
+ });
255
+ }
256
+ const expected = declared?.map(bound);
209
257
  if (offline) {
210
258
  log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
211
259
  }
@@ -233,9 +281,9 @@ async function startRun(passed, expected) {
233
281
  }
234
282
  if (!offline) {
235
283
  if (expected !== void 0) await resolveKeys(expected.flat());
236
- const declared = expected?.slice(0, EXPECTED_MAX) ?? [];
237
- const configuration = declared.length === 0 ? void 0 : {
238
- expected: declared.flatMap((keys) => {
284
+ const stated2 = expected?.slice(0, EXPECTED_MAX) ?? [];
285
+ const configuration = stated2.length === 0 ? void 0 : {
286
+ expected: stated2.flatMap((keys) => {
239
287
  const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
240
288
  if (typeof hit === "string") return [{ testCaseId: hit }];
241
289
  return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
@@ -255,6 +303,10 @@ async function startRun(passed, expected) {
255
303
  ...externalKey !== null ? { externalKey } : {},
256
304
  ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
257
305
  ...configuration !== void 0 ? { configuration } : {},
306
+ // Named even when nobody named it — the list has to read without opening a row. Only what
307
+ // the caller DESCRIBED goes to `warnIfDropped` below: a title this side invented is not one
308
+ // a person will miss, and a deployment older than the field must not be nagged on every run.
309
+ title: defaultRunTitle(),
258
310
  ...described
259
311
  });
260
312
  if (start.ok) {
@@ -279,9 +331,11 @@ async function startRun(passed, expected) {
279
331
  if (!offerDiscovered) return;
280
332
  if (result.title === void 0 || result.specRef === void 0) return;
281
333
  if (result.keys.length === 0) return;
334
+ const title = boundTitle(result.title);
335
+ if (title !== result.title) shortened.add(result.title);
282
336
  discoveries.set(result.keys[0].value, {
283
337
  keys: result.keys,
284
- title: result.title,
338
+ title,
285
339
  source: result.source,
286
340
  specRef: result.specRef,
287
341
  rawStatus: result.rawStatus
@@ -303,6 +357,7 @@ async function startRun(passed, expected) {
303
357
  return;
304
358
  }
305
359
  stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
360
+ stats.created += out.body.results.filter((r) => r.outcome === "created").length;
306
361
  }
307
362
  }
308
363
  async function flush() {
@@ -368,7 +423,9 @@ async function startRun(passed, expected) {
368
423
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
369
424
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
370
425
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
426
+ if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
371
427
  if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
428
+ if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
372
429
  log(`plune: ${parts.join(" \xB7 ")}`);
373
430
  }
374
431
  return {
@@ -382,7 +439,7 @@ async function startRun(passed, expected) {
382
439
  return stats;
383
440
  },
384
441
  async add(result) {
385
- buffer.push(result);
442
+ buffer.push({ ...result, keys: bound(result.keys) });
386
443
  if (buffer.length >= batchSize) await flush();
387
444
  },
388
445
  flush,
@@ -414,9 +471,9 @@ async function startRun(passed, expected) {
414
471
  }
415
472
 
416
473
  // src/util/hash.ts
417
- import { createHash } from "crypto";
474
+ import { createHash as createHash2 } from "crypto";
418
475
  function sha256(input) {
419
- return createHash("sha256").update(input, "utf8").digest("hex");
476
+ return createHash2("sha256").update(input, "utf8").digest("hex");
420
477
  }
421
478
 
422
479
  // src/reporter-core/result-key.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "AI-powered assertion testing for LLM apps — write assertions, run them against any provider, get a pass/fail report (local, CI, or diff).",
5
5
  "keywords": [
6
6
  "ai",