@plune-ai/cli 0.28.0 → 0.30.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
@@ -121,7 +121,7 @@ the last resort, used only when `PLUNE_TOKEN` is set nowhere else. Nothing write
121
121
  | `plune init` | Scaffold `plune.yaml`, a sample dataset, and `.env.example`. Flags: `--yes` (non-interactive), `--force`. |
122
122
  | `plune login` | Save a [Plune platform](https://plune.ai/platform) API token so `sync` and `ingest` can reach it. The token is **checked against the API before it is saved**, so a wrong one fails here rather than two commands later. Get one at `https://beta.plune.ai` → Account → API tokens. Flags: `--token <token>` (omit to paste it or pipe it via stdin), `--skip-verify` (save without checking, for offline setup). In CI, skip this step: every platform command reads `PLUNE_TOKEN` from the environment first, and the saved login only when it is not set. |
123
123
  | `plune logout` | Remove the saved token. |
124
- | `plune sync` | Upload the latest local run to the platform. Flags: `--file <path>` to send a specific run JSON, `--fail-on-failures` to read the run's summary back once after the upload and exit with [the gate](#a-release-gate---fail-on-failures)'s code. Each upload carries an `Idempotency-Key` (SHA-256 of the body), so sending the same run again returns the same run rather than creating a second one. |
124
+ | `plune sync` | Upload the latest local run to the platform. Flags: `--file <path>` to send a specific run JSON, `--fail-on-failures` to read the run's summary back once after the upload and exit with [the gate](#a-release-gate---fail-on-failures)'s code. Each upload carries an `Idempotency-Key` (SHA-256 of the body), so sending the same run again returns the same run rather than creating a second one. Against a platform with `POST /v1/jobs` the upload is also reported as a job (kind `cli-sync`) that ends failed with the error text if the sync fails; an older platform without the route is not told, and a job report never fails the sync. |
125
125
  | `plune run import <file>` | Turn a **JUnit XML** or **Playwright JSON** report, or an **Allure `allure-results` folder**, into a run in Plune. Needs no provider key — nothing is generated. Flags: `--format junit\|playwright-json\|allure` (detected from the file when omitted; a folder is Allure), `--name <text>` to call the run something other than the [folder-based default](#what-the-run-is-called), `--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, `--fail-on-missing-attachments` to exit 1 when a file a JUnit report names with `[[ATTACHMENT\|path]]`, or an Allure result names as a `source`, is not there (the results are sent either way), `--upload-concurrency <n>` for files uploaded at once ([1 to 32, default 16](#many-screenshots-upload-concurrency-and-retries)), `--fail-on-failures` to exit with [the gate](#a-release-gate---fail-on-failures)'s code on the summary the platform froze when this import finished the run. |
126
126
  | `plune run start` | Open a platform run — or join the one already carrying `--key` — and print its id. Flags: `--key <externalKey>` (generated when absent), `--name <text>` to call the run something other than the [default](#what-the-run-is-called), `--json` for one machine-readable line. |
127
127
  | `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>`, `--fail-on-failures` to exit with [the gate](#a-release-gate---fail-on-failures)'s code on the summary the platform froze. |
@@ -132,7 +132,9 @@ the last resort, used only when `PLUNE_TOKEN` is set nowhere else. Nothing write
132
132
  | `plune pull [file]` | Write the project's test cases as **one Markdown document** — Testomat's classical format, plus Plune's own columns — to `plune/cases.md` (or `[file]`). Refuses to overwrite a file git sees as modified; `--force` overrides, `--suite <id>` takes one suite or folder and what is under it. |
133
133
  | `plune push [file] [--dry-run]` | Send the document back. Cases are matched by `id`; a block without one becomes a draft; an unknown id is refused by line; **nothing is deleted**. Prints the report — created, updated, unchanged, refused, warnings — and with `--dry-run` writes nothing. Exit `4` when anything was refused. |
134
134
  | `plune requirements import <file.md>` | Import requirements from a **Markdown file** (an SRS, a PRD, a page exported from a wiki). With no mapping flags it lists the tables and sections the file has and exits `0`; with `--table` or `--sections` it is a **dry run** that writes nothing until you add `--yes`. See [Requirements from a Markdown file](#requirements-from-a-markdown-file). Needs a Plune platform that serves `POST /v1/projects/:id/requirements/import` (plune-ai/plune#1621). |
135
+ | `plune requirements links <file.md>` | Import a **traceability matrix** (a Markdown table of requirement key to test) as links between requirements and cases. With no flags it lists the tables the file has and exits `0`; with `--table`, `--requirement-column` and `--test-column` it is a **dry run** that writes nothing until you add `--yes`. See [Links from a traceability matrix](#links-from-a-traceability-matrix). Needs a platform that serves `POST /v1/projects/:id/requirements/import/links` (plune-ai/plune#1608). |
135
136
  | `plune verify` | Compare the project's **automated cases** with the Playwright tests in the code, **read-only** (plune-ai/plune#1291). Prints both sides: cases in Plune that the code no longer has (already `detached`, or one a full run would detach) and tests in the code that Plune has no case for. A test is matched by the id it names (`PluneId` annotation or `@P<id>` in its title), else by its `path-title` key. Nothing is written to Plune and no id is added to your source. Playwright only. Flags: `--from <file>` (a saved `playwright test --list --reporter=json`, instead of running Playwright), `--playwright-config <path>`, `--json`, `--fail-on-drift` (exit `1` when either side has something the other lacks). |
137
+ | `plune ids [--write]` | Write the Plune **case id of each Playwright test that names none** into a mapfile, `plune/ids.json` (`{ "<path-title>": "<case id>" }`), instead of editing your test files (plune-ai/plune#1291). A **dry run** unless `--write`: it prints what it would add. Only tests whose case Plune already holds get an entry (matched by the `path-title` key); a test with no case yet is listed and skipped. Idempotent: entries are only added, never overwritten or removed (an entry for a test no longer in the code is kept and listed), the file is sorted so diffs stay small, and nothing is written when nothing changes. A mapfile that is not a JSON object of strings is refused (exit `2`) and left as it is. Playwright only. The Playwright reporter reads the mapfile: an entry names the case after the `PluneId` annotation, `@P<id>` and a metadata attachment, and before the `playwright-id` and `path-title` keys, so a renamed test keeps its case when the entry is updated with `plune ids --write` (override the path with `PLUNE_IDS_FILE` or the reporter option `idsFile`). Flags: `--write`, `--file <path>` (default `plune/ids.json`), `--from <file>`, `--playwright-config <path>`, `--json`. Exit codes as `plune verify`. |
136
138
  | `plune plan grep <id>` | Print a [test plan](https://docs.plune.ai/platform/plans/) as **one `--grep` pattern** — `npx playwright test --grep "$(plune plan grep <id>)"` or `npx vitest run -t "$(plune plan grep <id>)"` runs exactly what the plan collects. Each case contributes its title path from the `path-title` key the reporter wrote (the file left out, `[ >#]+` between the segments — a space for Playwright, jest, mocha and vitest ≤ 4, ` > ` for vitest 5), escaped, joined with `\|`; a case without one goes in by title. The pattern is all that reaches stdout; the summary — how many cases, how many keyed — goes to stderr. An empty plan prints `(?!)`, which matches nothing, because an empty `--grep` would run everything. |
137
139
  | `plune run grep <run> [--failed]` | Print a run's **automated** cases as one `--grep` pattern, built the way `plan grep` builds a plan's — so the commands Plune shows for a run work as they are: `npx playwright test --grep "$(plune run grep <run-id> --failed)"` re-runs an automated run's failures where your tests run (Plune starts no CI), and `PLUNE_RUN=<key> npx playwright test --grep "$(plune run grep <key>)"` runs the automated half of a mixed run launched without a plan. `<run>` is the run's id (a UUID) or its key (`rk_` and ten hex); anything else is refused with exit `2` before anything is sent, and a key no run carries exits `1`. The cases are the ones the run expected — cases added on its page among them, run or not — or holds a result of; manual cases are left out. `--failed` keeps the cases whose latest result the platform counts as failed or broken, so a muted case's failure stays out, as from **Re-run N failed**. Only the pattern reaches stdout; how many cases were taken and how many left out goes to stderr; nothing taken prints `(?!)`. Needs a Plune that serves `GET /v1/runs/:id/cases` (plune-ai/plune#1333). |
138
140
 
@@ -196,6 +198,20 @@ Inside a `--table` the names are `key`, `title` or `text` (one of the two), `des
196
198
 
197
199
  `--json` prints the platform's answer instead. The project is the only one of the account, or `--project <id>` / `PLUNE_PROJECT` (a token only works in the project it was minted in). Exit `0` on success, `2` for a flag combination that cannot work (`--table` with `--sections`, `--title-column` with `--text-column`, a `--table "<n>:..."` spec with the single-table column flags), an unreadable or over-limit file, or a rejected token, `1` when the platform refuses (400, 403, 409 a key taken meanwhile, 413) or does not have the routes yet.
198
200
 
201
+ ### Links from a traceability matrix
202
+
203
+ Requirements documents often end with a table of requirement id to test. `plune requirements links <file.md>` imports it as links between requirements and cases, in the same two steps as `requirements import`: it sends the file as it is, and you say which table and which two columns.
204
+
205
+ ```bash
206
+ plune requirements links matrix.md # lists the tables, exit 0
207
+ plune requirements links matrix.md --table 0 --requirement-column "Requirement" --test-column "Test" # dry run
208
+ plune requirements links matrix.md --table 0 --requirement-column "Requirement" --test-column "Test" --yes
209
+ ```
210
+
211
+ A requirement is matched by its key (import the requirements first, with `--key-column`). A test is matched by the exact title of a case, or for an automated case by its source path (`e2e/login.spec.ts` or `e2e/login.spec.ts#title`); put several tests in one cell on separate lines (`<br>`). A requirement cell with several keys is split by `--separator` (1 to 3 characters, default `,`; nothing else is guessed). The report lists what it would link, what is already linked, and every row that matched nothing, with the reason (unknown key, archived requirement, no such case, a title that names several cases). A second import changes nothing. Links never change a requirement's coverage, which still comes from each case's last result.
212
+
213
+ Exit `0` after a dry run or a write, even when rows are unmatched; `--fail-on-unmatched` makes any unmatched row exit `1`. A wrong flag or file is `2`; a read-only member, a rejected mapping or a matrix over 2000 rows (after splitting) is `1` with the platform's reason. `--json` prints the platform's answer; `--project <id>` / `PLUNE_PROJECT` picks the project.
214
+
199
215
  ### A release gate: `--fail-on-failures`
200
216
 
201
217
  `plune run finish`, `plune run exec`, `plune run import` and `plune sync` take `--fail-on-failures`. The exit
package/dist/cli.cjs CHANGED
@@ -555,22 +555,22 @@ var init_prices = __esm({
555
555
  });
556
556
 
557
557
  // src/providers/cost.ts
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;
558
+ function priceFrom(price, usage3) {
559
+ return usage3.input_tokens / 1e3 * price.input_per_1k_usd + usage3.output_tokens / 1e3 * price.output_per_1k_usd;
560
560
  }
561
- function resolveCost(usage2, model, reportedCostUsd, overrides, deps = {}) {
561
+ function resolveCost(usage3, 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, usage2);
566
+ return priceFrom(override, usage3);
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, usage2);
573
+ return priceFrom(tablePrice, usage3);
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.`
@@ -629,8 +629,8 @@ function makeAnthropicProvider(config, env, pricing) {
629
629
  throw normalizeProviderError(err, apiKey, ENV_VAR);
630
630
  }
631
631
  },
632
- estimateCost(usage2, reportedCostUsd) {
633
- return { cost_usd: resolveCost(usage2, config.model, reportedCostUsd, pricing) };
632
+ estimateCost(usage3, reportedCostUsd) {
633
+ return { cost_usd: resolveCost(usage3, config.model, reportedCostUsd, pricing) };
634
634
  }
635
635
  };
636
636
  }
@@ -696,8 +696,8 @@ function makeOpenAiCompatibleProvider(opts) {
696
696
  throw normalizeProviderError(err, apiKey, apiKeyEnv);
697
697
  }
698
698
  },
699
- estimateCost(usage2, reportedCostUsd) {
700
- return { cost_usd: resolveCost(usage2, config.model, reportedCostUsd, pricing) };
699
+ estimateCost(usage3, reportedCostUsd) {
700
+ return { cost_usd: resolveCost(usage3, config.model, reportedCostUsd, pricing) };
701
701
  }
702
702
  };
703
703
  }
@@ -800,8 +800,8 @@ function makeEstimateProvider(config, pricing) {
800
800
  complete: () => Promise.reject(
801
801
  new Error("The --dry-run provider only estimates cost; it never calls a model.")
802
802
  ),
803
- estimateCost: (usage2, reportedCostUsd) => ({
804
- cost_usd: resolveCost(usage2, config.model, reportedCostUsd, pricing)
803
+ estimateCost: (usage3, reportedCostUsd) => ({
804
+ cost_usd: resolveCost(usage3, config.model, reportedCostUsd, pricing)
805
805
  })
806
806
  };
807
807
  }
@@ -1781,8 +1781,8 @@ async function runRow(p) {
1781
1781
  const maxTokens = p.providerConfig.max_tokens ?? DEFAULT_MAX_TOKENS2;
1782
1782
  if (p.dryRun) {
1783
1783
  const est = estimateUsage(prompt5, maxTokens);
1784
- const usage3 = { ...est, cost_usd: p.provider.estimateCost(est).cost_usd };
1785
- return { vars: p.row.vars, output: null, cached: false, usage: usage3, assertions: [] };
1784
+ const usage4 = { ...est, cost_usd: p.provider.estimateCost(est).cost_usd };
1785
+ return { vars: p.row.vars, output: null, cached: false, usage: usage4, assertions: [] };
1786
1786
  }
1787
1787
  const key = cacheKey(
1788
1788
  {
@@ -1795,7 +1795,7 @@ async function runRow(p) {
1795
1795
  p.policyVersion
1796
1796
  );
1797
1797
  let output;
1798
- let usage2;
1798
+ let usage3;
1799
1799
  let cached = false;
1800
1800
  let cacheSaved;
1801
1801
  let hit = p.noCache ? void 0 : p.cache.get(key, p.cacheMaxAgeMs !== void 0 ? { maxAgeMs: p.cacheMaxAgeMs } : {});
@@ -1816,7 +1816,7 @@ async function runRow(p) {
1816
1816
  if (hit !== void 0) {
1817
1817
  output = hit.output;
1818
1818
  cached = true;
1819
- usage2 = {
1819
+ usage3 = {
1820
1820
  input_tokens: hit.usage.input_tokens,
1821
1821
  output_tokens: hit.usage.output_tokens,
1822
1822
  cost_usd: 0
@@ -1836,7 +1836,7 @@ async function runRow(p) {
1836
1836
  prompt_resolved: prompt5
1837
1837
  });
1838
1838
  output = res.output;
1839
- usage2 = {
1839
+ usage3 = {
1840
1840
  input_tokens: res.usage.input_tokens,
1841
1841
  output_tokens: res.usage.output_tokens,
1842
1842
  // Prefer the provider-reported actual cost (res.cost_usd) when present; resolveCost still
@@ -1884,7 +1884,7 @@ async function runRow(p) {
1884
1884
  break;
1885
1885
  }
1886
1886
  }
1887
- const finalUsage = { ...usage2, cost_usd: usage2.cost_usd + judgeCost };
1887
+ const finalUsage = { ...usage3, cost_usd: usage3.cost_usd + judgeCost };
1888
1888
  return {
1889
1889
  vars: p.row.vars,
1890
1890
  output,
@@ -2853,7 +2853,14 @@ function repoRootOf(dir, stopAt) {
2853
2853
  if (here === stopAt || (0, import_node_path2.dirname)(here) === here) return void 0;
2854
2854
  }
2855
2855
  }
2856
- function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
2856
+ function errorContextFields(attempt, home = (0, import_node_os2.homedir)()) {
2857
+ const { text: text2, omitted: omitted2 } = cutErrorContext(attempt, home);
2858
+ return {
2859
+ ...text2 !== "" ? { errorContext: text2 } : {},
2860
+ ...omitted2 > 0 ? { errorContextOmitted: omitted2 } : {}
2861
+ };
2862
+ }
2863
+ function cutErrorContext(attempt, home) {
2857
2864
  const text2 = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
2858
2865
  return cut(machineless(text2, attempt.repoRoot, home));
2859
2866
  }
@@ -2879,7 +2886,7 @@ function pathIn(path17) {
2879
2886
  return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
2880
2887
  }
2881
2888
  function cut(text2) {
2882
- if (jsonBytes(text2) <= TEXT_LIMIT) return text2;
2889
+ if (jsonBytes(text2) <= TEXT_LIMIT) return { text: text2, omitted: 0 };
2883
2890
  const lines = text2.split("\n");
2884
2891
  const size = lines.map((line) => jsonBytes(line) + 2);
2885
2892
  const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
@@ -2888,7 +2895,8 @@ function cut(text2) {
2888
2895
  let tail = lines.length;
2889
2896
  while (head < tail && used + size[head] <= budget / 2) used += size[head++];
2890
2897
  while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
2891
- return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
2898
+ const kept = [...lines.slice(0, head), ...lines.slice(tail)].join("\n");
2899
+ return { text: [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n"), omitted: text2.length - kept.length };
2892
2900
  }
2893
2901
  function chosen({ status, errors }) {
2894
2902
  if (status === "timedOut") {
@@ -4359,30 +4367,46 @@ async function handleSync(deps = {}) {
4359
4367
  const endpoint = `${apiUrl}/v1/runs`;
4360
4368
  const payload = JSON.stringify(run);
4361
4369
  const idempotencyKey = (0, import_node_crypto4.createHash)("sha256").update(payload).digest("hex");
4362
- let res;
4370
+ const bearer = token;
4371
+ const job = new JobReporter(doFetch, apiUrl, bearer);
4372
+ await job.open(path9.basename(deps.file !== void 0 ? path9.resolve(cwd, deps.file) : cwd));
4363
4373
  try {
4364
- res = await doFetch(endpoint, {
4365
- method: "POST",
4366
- headers: {
4367
- "content-type": "application/json",
4368
- authorization: `Bearer ${token}`,
4369
- "idempotency-key": idempotencyKey
4370
- },
4371
- body: payload
4372
- });
4373
- } catch {
4374
- throw new SyncNetworkError(apiUrl);
4374
+ const result = await upload();
4375
+ await job.finish({ ok: true });
4376
+ return result;
4377
+ } catch (err) {
4378
+ await job.finish({ ok: false, reason: err instanceof Error ? err.message : "Sync failed." });
4379
+ throw err;
4375
4380
  }
4376
- if (!res.ok) {
4377
- const detail = await safeErrorDetail(res);
4378
- if (res.status === 401) throw new TokenRejectedError();
4379
- throw new SyncHttpError(res.status, detail);
4381
+ async function upload() {
4382
+ let res;
4383
+ try {
4384
+ res = await doFetch(endpoint, {
4385
+ method: "POST",
4386
+ headers: {
4387
+ "content-type": "application/json",
4388
+ authorization: `Bearer ${bearer}`,
4389
+ "idempotency-key": idempotencyKey
4390
+ },
4391
+ body: payload
4392
+ });
4393
+ } catch {
4394
+ throw new SyncNetworkError(apiUrl);
4395
+ }
4396
+ if (!res.ok) {
4397
+ const detail = await safeErrorDetail(res);
4398
+ if (res.status === 401) throw new TokenRejectedError();
4399
+ throw new SyncHttpError(res.status, detail);
4400
+ }
4401
+ const body = await res.json().catch(() => ({}));
4402
+ const id = typeof body.id === "string" ? body.id : "";
4403
+ const result = { id, url: `${endpoint}/${id}` };
4404
+ await job.report(deps.failOnFailures === true ? 80 : 95, id || void 0);
4405
+ if (deps.failOnFailures === true) {
4406
+ result.gate = gateExit(await readVerdict(doFetch, `${endpoint}/${id}/summary`, bearer));
4407
+ }
4408
+ return result;
4380
4409
  }
4381
- const body = await res.json().catch(() => ({}));
4382
- const id = typeof body.id === "string" ? body.id : "";
4383
- const result = { id, url: `${endpoint}/${id}` };
4384
- if (deps.failOnFailures === true) result.gate = gateExit(await readVerdict(doFetch, `${endpoint}/${id}/summary`, token));
4385
- return result;
4386
4410
  }
4387
4411
  async function readVerdict(doFetch, url, token) {
4388
4412
  try {
@@ -4404,7 +4428,7 @@ function reportSyncFailure(err, write) {
4404
4428
  }
4405
4429
  return null;
4406
4430
  }
4407
- var import_node_crypto4, fs8, path9, NotLoggedInError, TokenRejectedError, SyncFileError, SyncNetworkError, SyncHttpError;
4431
+ var import_node_crypto4, fs8, path9, NotLoggedInError, TokenRejectedError, SyncFileError, SyncNetworkError, SyncHttpError, JOB_KIND, JOB_TIMEOUT_MS, JobReporter;
4408
4432
  var init_sync = __esm({
4409
4433
  "src/cli/commands/sync.ts"() {
4410
4434
  "use strict";
@@ -4452,6 +4476,58 @@ var init_sync = __esm({
4452
4476
  }
4453
4477
  status;
4454
4478
  };
4479
+ JOB_KIND = "cli-sync";
4480
+ JOB_TIMEOUT_MS = 5e3;
4481
+ JobReporter = class {
4482
+ constructor(doFetch, base, token) {
4483
+ this.doFetch = doFetch;
4484
+ this.base = base;
4485
+ this.token = token;
4486
+ }
4487
+ doFetch;
4488
+ base;
4489
+ token;
4490
+ id;
4491
+ async send(method, url, body) {
4492
+ try {
4493
+ const res = await this.doFetch(url, {
4494
+ method,
4495
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
4496
+ body: JSON.stringify(body),
4497
+ signal: AbortSignal.timeout(JOB_TIMEOUT_MS)
4498
+ });
4499
+ const json = await res.json().catch(() => void 0);
4500
+ return res.ok ? json ?? {} : void 0;
4501
+ } catch {
4502
+ return void 0;
4503
+ }
4504
+ }
4505
+ async open(subjectName) {
4506
+ const body = await this.send("POST", `${this.base}/v1/jobs`, {
4507
+ kind: JOB_KIND,
4508
+ subjectName,
4509
+ progress: 0
4510
+ });
4511
+ if (typeof body?.id === "string") this.id = body.id;
4512
+ }
4513
+ async report(progress, cursor) {
4514
+ if (this.id === void 0) return;
4515
+ await this.send("PATCH", `${this.base}/v1/jobs/${encodeURIComponent(this.id)}`, {
4516
+ progress,
4517
+ ...cursor !== void 0 ? { cursor } : {}
4518
+ });
4519
+ }
4520
+ async finish(outcome) {
4521
+ if (this.id === void 0) return;
4522
+ const id = this.id;
4523
+ this.id = void 0;
4524
+ await this.send(
4525
+ "PATCH",
4526
+ `${this.base}/v1/jobs/${encodeURIComponent(id)}`,
4527
+ outcome.ok ? { status: "succeeded", progress: 100 } : { status: "failed", reason: outcome.reason }
4528
+ );
4529
+ }
4530
+ };
4455
4531
  }
4456
4532
  });
4457
4533
 
@@ -5008,7 +5084,7 @@ function pendingOf(r, retry, ctx) {
5008
5084
  })),
5009
5085
  testFile: ""
5010
5086
  };
5011
- const errorContext = errorContextOf(attempt);
5087
+ const errorContext = errorContextFields(attempt);
5012
5088
  const failure = FAILED.has(status) ? failureOf(attempt) : void 0;
5013
5089
  const pkg2 = labelOf(r, "package");
5014
5090
  const where = (key.includes("#") ? key.split("#")[0] : pkg2)?.replace(/^[A-Za-z][A-Za-z0-9_-]+:(?![\\/])/, "");
@@ -5032,7 +5108,7 @@ function pendingOf(r, retry, ctx) {
5032
5108
  retry
5033
5109
  }
5034
5110
  } : {},
5035
- ...errorContext !== "" ? { errorContext } : {},
5111
+ ...errorContext,
5036
5112
  ...failure !== void 0 ? { failure } : {},
5037
5113
  ...files.length > 0 ? { files } : {}
5038
5114
  };
@@ -5362,7 +5438,7 @@ function pendingFrom(spec, test, result, file, titles, ctx) {
5362
5438
  const stated = statedIdOf(test, spec.title ?? "");
5363
5439
  const expected = test.expectedStatus;
5364
5440
  const attempt = attemptOf(result, file, ctx);
5365
- const errorContext = errorContextOf(attempt);
5441
+ const errorContext = errorContextFields(attempt);
5366
5442
  const failure = NOT_FAILED.has(attempt.status) ? void 0 : failureOf(attempt);
5367
5443
  const files = filesOf(attempt);
5368
5444
  const keys = [
@@ -5392,7 +5468,7 @@ function pendingFrom(spec, test, result, file, titles, ctx) {
5392
5468
  ...result.workerIndex !== void 0 ? { worker: String(result.workerIndex) } : {}
5393
5469
  }
5394
5470
  } : {},
5395
- ...errorContext !== "" ? { errorContext } : {},
5471
+ ...errorContext,
5396
5472
  ...failure !== void 0 ? { failure } : {},
5397
5473
  ...files.length > 0 ? { files } : {}
5398
5474
  };
@@ -6509,12 +6585,18 @@ __export(requirements_import_exports, {
6509
6585
  MAX_MARKDOWN_BYTES: () => MAX_MARKDOWN_BYTES,
6510
6586
  RequirementsImportError: () => RequirementsImportError,
6511
6587
  buildImportBody: () => buildImportBody,
6588
+ cut: () => cut2,
6589
+ flat: () => flat,
6512
6590
  formatDiscover: () => formatDiscover,
6513
6591
  formatReport: () => formatReport2,
6514
6592
  handleRequirementsImport: () => handleRequirementsImport,
6515
6593
  parseMap: () => parseMap,
6516
6594
  parseTableSpec: () => parseTableSpec,
6517
- reportRequirementsImportFailure: () => reportRequirementsImportFailure
6595
+ plural: () => plural,
6596
+ readMarkdown: () => readMarkdown,
6597
+ refusal: () => refusal,
6598
+ reportRequirementsImportFailure: () => reportRequirementsImportFailure,
6599
+ resolveProject: () => resolveProject
6518
6600
  });
6519
6601
  function parseMap(flag2, raw) {
6520
6602
  const out = {};
@@ -6801,12 +6883,94 @@ var init_requirements_import = __esm({
6801
6883
  }
6802
6884
  });
6803
6885
 
6886
+ // src/cli/commands/requirements-links.ts
6887
+ var requirements_links_exports = {};
6888
+ __export(requirements_links_exports, {
6889
+ buildLinksBody: () => buildLinksBody,
6890
+ formatLinks: () => formatLinks,
6891
+ formatLinksDiscover: () => formatLinksDiscover,
6892
+ handleRequirementsLinks: () => handleRequirementsLinks
6893
+ });
6894
+ function buildLinksBody(f) {
6895
+ if (f.table === void 0 && f.requirementColumn === void 0 && f.testColumn === void 0 && f.separator === void 0) return null;
6896
+ if (f.table === void 0) return usage2("--requirement-column, --test-column and --separator need --table <n>. Run without flags to see which tables the file has.");
6897
+ if (!/^\d+$/.test(f.table)) usage2(`--table is the number of the table in the list (0, 1, ...), not "${f.table}".`);
6898
+ if (f.requirementColumn === void 0 || f.requirementColumn.trim() === "") usage2("--table needs --requirement-column <header> (the column with the requirement keys).");
6899
+ if (f.testColumn === void 0 || f.testColumn.trim() === "") usage2("--table needs --test-column <header> (the column with the test titles or paths).");
6900
+ const body = { table: Number(f.table), requirementColumn: f.requirementColumn, testColumn: f.testColumn };
6901
+ if (f.separator !== void 0) {
6902
+ if (f.separator.length < 1 || f.separator.length > 3) usage2(`--separator is 1 to 3 characters, not "${f.separator}".`);
6903
+ body["requirementSeparator"] = f.separator;
6904
+ }
6905
+ return body;
6906
+ }
6907
+ async function handleRequirementsLinks(deps) {
6908
+ const apiUrl = resolveApiUrl(deps.apiUrl);
6909
+ const mapping = buildLinksBody(deps);
6910
+ const markdown = readMarkdown(deps.cwd ?? process.cwd(), deps.file);
6911
+ const token = (deps.loadToken ?? loadToken)();
6912
+ if (token === null) throw new RequirementsImportError('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.', 2);
6913
+ const client = createClient({ apiUrl, token, ...deps.fetchImpl !== void 0 ? { fetchImpl: deps.fetchImpl } : {} });
6914
+ const projectId = await resolveProject(client, deps.project, apiUrl);
6915
+ const base = `/v1/projects/${encodeURIComponent(projectId)}/requirements/import`;
6916
+ if (mapping === null) {
6917
+ const out2 = await client.post(`${base}/discover`, { markdown });
6918
+ if (!out2.ok) throw refusal(out2, apiUrl, projectId);
6919
+ return { step: "discover", result: out2.body };
6920
+ }
6921
+ const out = await client.post(`${base}/links`, { markdown, dryRun: deps.yes !== true, ...mapping });
6922
+ if (!out.ok) throw refusal(out, apiUrl, projectId);
6923
+ return { step: "links", report: out.body };
6924
+ }
6925
+ function formatLinksDiscover(file, r) {
6926
+ const out = [`${file}: ${plural(r.tables.length, "table")}`, ""];
6927
+ if (r.tables.length === 0) out.push(" (none)");
6928
+ for (const t of r.tables) {
6929
+ const mark = t.unreadable !== void 0 ? ` cannot be read: ${t.unreadable}` : "";
6930
+ 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}`);
6931
+ }
6932
+ if (r.truncated) out.push("", "The file has more tables than are listed here.");
6933
+ out.push("", "Pick the matrix and name its two columns (a dry run, nothing is written):", ` plune requirements links ${file} --table <n> --requirement-column <header> --test-column <header>`);
6934
+ return out.join("\n") + "\n";
6935
+ }
6936
+ function formatLinks(report) {
6937
+ const s = report.summary;
6938
+ const out = [`link ${s.link} \xB7 unchanged ${s.unchanged} \xB7 unmatched ${s.unmatched}`];
6939
+ for (const action of ["unmatched", "link", "unchanged"]) {
6940
+ const rows = report.items.filter((i) => i.action === action);
6941
+ if (rows.length === 0) continue;
6942
+ out.push("", `${action} (${rows.length})`);
6943
+ for (const i of rows.slice(0, SHOWN2)) {
6944
+ out.push(` line ${i.line} ${i.key === "" ? "-" : i.key} -> ${i.test === "" ? "-" : cut2(flat(i.test), 80)} - ${flat(i.reason)}`);
6945
+ }
6946
+ if (rows.length > SHOWN2) out.push(` ... and ${rows.length - SHOWN2} more (--json lists every row)`);
6947
+ }
6948
+ out.push("", report.applied ? `applied \u2014 ${plural(s.link, "link")} created.` : "dry run \u2014 nothing was written; add --yes to apply");
6949
+ return out.join("\n") + "\n";
6950
+ }
6951
+ var usage2, SHOWN2;
6952
+ var init_requirements_links = __esm({
6953
+ "src/cli/commands/requirements-links.ts"() {
6954
+ "use strict";
6955
+ init_cjs_shims();
6956
+ init_client();
6957
+ init_api_url();
6958
+ init_credentials();
6959
+ init_requirements_import();
6960
+ usage2 = (message) => {
6961
+ throw new RequirementsImportError(message, 2);
6962
+ };
6963
+ SHOWN2 = 50;
6964
+ }
6965
+ });
6966
+
6804
6967
  // src/cli/commands/verify.ts
6805
6968
  var verify_exports = {};
6806
6969
  __export(verify_exports, {
6807
6970
  compare: () => compare,
6808
6971
  formatVerify: () => formatVerify,
6809
6972
  handleVerify: () => handleVerify,
6973
+ loadSources: () => loadSources,
6810
6974
  testsOfListing: () => testsOfListing
6811
6975
  });
6812
6976
  function testsOfListing(listing) {
@@ -6859,7 +7023,7 @@ function runPlaywrightList(args, cwd) {
6859
7023
  throw new SyncFileError(`could not list the Playwright tests${stderr ? `: ${stderr}` : ""}`);
6860
7024
  }
6861
7025
  }
6862
- async function handleVerify(deps) {
7026
+ async function loadSources(deps) {
6863
7027
  const get = platformGet(deps);
6864
7028
  let raw;
6865
7029
  if (deps.from !== void 0) {
@@ -6891,7 +7055,11 @@ async function handleVerify(deps) {
6891
7055
  cases.push(...page.testCases);
6892
7056
  cursor = page.nextCursor;
6893
7057
  } while (cursor !== null);
6894
- return compare(testsOfListing(listing), cases);
7058
+ return { code: testsOfListing(listing), cases };
7059
+ }
7060
+ async function handleVerify(deps) {
7061
+ const { code, cases } = await loadSources(deps);
7062
+ return compare(code, cases);
6895
7063
  }
6896
7064
  function formatVerify(r) {
6897
7065
  const lines = [];
@@ -6925,6 +7093,111 @@ var init_verify = __esm({
6925
7093
  }
6926
7094
  });
6927
7095
 
7096
+ // src/cli/commands/ids.ts
7097
+ var ids_exports = {};
7098
+ __export(ids_exports, {
7099
+ DEFAULT_IDS_FILE: () => DEFAULT_IDS_FILE,
7100
+ formatIds: () => formatIds,
7101
+ handleIds: () => handleIds,
7102
+ planIds: () => planIds,
7103
+ readMap: () => readMap,
7104
+ serializeMap: () => serializeMap
7105
+ });
7106
+ function readMap(file) {
7107
+ if (!(0, import_node_fs9.existsSync)(file)) return {};
7108
+ let parsed;
7109
+ try {
7110
+ parsed = JSON.parse((0, import_node_fs9.readFileSync)(file, "utf8"));
7111
+ } catch {
7112
+ throw new SyncFileError(`${file} is not valid JSON; fix or remove it, nothing was written`);
7113
+ }
7114
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) || Object.values(parsed).some((v) => typeof v !== "string" || v === ""))
7115
+ throw new SyncFileError(
7116
+ `${file} must be an object of path-title to case id strings; nothing was written`
7117
+ );
7118
+ return parsed;
7119
+ }
7120
+ function planIds(code, cases, current) {
7121
+ const byPath = /* @__PURE__ */ new Map();
7122
+ for (const c of cases)
7123
+ if (c.type === "automated" && c.state !== "deprecated") {
7124
+ for (const k of c.externalKeys ?? []) if (k.kind === "path-title") byPath.set(k.value, c);
7125
+ }
7126
+ const added = {};
7127
+ const noCase = [];
7128
+ let covered = 0;
7129
+ for (const t of code) {
7130
+ if (t.id !== void 0 || current[t.pathTitle] !== void 0) covered++;
7131
+ else {
7132
+ const hit = byPath.get(t.pathTitle);
7133
+ if (hit === void 0) noCase.push(t.pathTitle);
7134
+ else added[t.pathTitle] = hit.id;
7135
+ }
7136
+ }
7137
+ const inCode = new Set(code.map((t) => t.pathTitle));
7138
+ return {
7139
+ added,
7140
+ covered,
7141
+ noCase: noCase.sort(compareKeys),
7142
+ orphaned: Object.keys(current).filter((k) => !inCode.has(k)).sort(compareKeys)
7143
+ };
7144
+ }
7145
+ function serializeMap(map) {
7146
+ const sorted = {};
7147
+ for (const k of Object.keys(map).sort(compareKeys)) sorted[k] = map[k];
7148
+ return JSON.stringify(sorted, null, 2) + "\n";
7149
+ }
7150
+ async function handleIds(deps) {
7151
+ const file = deps.file ?? DEFAULT_IDS_FILE;
7152
+ const current = readMap(file);
7153
+ const { code, cases } = await loadSources(deps);
7154
+ const plan = planIds(code, cases, current);
7155
+ let written = false;
7156
+ if (deps.write === true && Object.keys(plan.added).length > 0) {
7157
+ try {
7158
+ (0, import_node_fs9.mkdirSync)((0, import_node_path5.dirname)(file), { recursive: true });
7159
+ (0, import_node_fs9.writeFileSync)(file, serializeMap({ ...current, ...plan.added }));
7160
+ } catch {
7161
+ throw new SyncFileError(`cannot write ${file}`);
7162
+ }
7163
+ written = true;
7164
+ }
7165
+ return { file, ...plan, written };
7166
+ }
7167
+ function formatIds(r, write) {
7168
+ const n = Object.keys(r.added).length;
7169
+ const lines = [];
7170
+ if (n > 0) {
7171
+ lines.push(`${write ? "Added to" : "Would add to"} ${r.file} (${n}):`);
7172
+ for (const k of Object.keys(r.added).sort(compareKeys)) lines.push(` ${k} -> ${r.added[k]}`);
7173
+ }
7174
+ if (r.noCase.length > 0) {
7175
+ lines.push(`In the code, no case in Plune yet, nothing to write (${r.noCase.length}):`);
7176
+ for (const k of r.noCase) lines.push(` ${k}`);
7177
+ }
7178
+ if (r.orphaned.length > 0) {
7179
+ lines.push(`In ${r.file}, test no longer in the code, kept (${r.orphaned.length}):`);
7180
+ for (const k of r.orphaned) lines.push(` ${k}`);
7181
+ }
7182
+ lines.push(
7183
+ n === 0 ? `Nothing to write: ${r.covered} test(s) already have an id.` : write ? `${r.covered} already had an id. ${r.file} written.` : `${r.covered} already had an id. Dry run: nothing was written; add --write.`
7184
+ );
7185
+ return lines.join("\n") + "\n";
7186
+ }
7187
+ var import_node_fs9, import_node_path5, DEFAULT_IDS_FILE, compareKeys;
7188
+ var init_ids = __esm({
7189
+ "src/cli/commands/ids.ts"() {
7190
+ "use strict";
7191
+ init_cjs_shims();
7192
+ import_node_fs9 = require("fs");
7193
+ import_node_path5 = require("path");
7194
+ init_verify();
7195
+ init_sync();
7196
+ DEFAULT_IDS_FILE = "plune/ids.json";
7197
+ compareKeys = (a, b) => a < b ? -1 : a > b ? 1 : 0;
7198
+ }
7199
+ });
7200
+
6928
7201
  // src/cli.ts
6929
7202
  var cli_exports = {};
6930
7203
  __export(cli_exports, {
@@ -6934,8 +7207,8 @@ module.exports = __toCommonJS(cli_exports);
6934
7207
  init_cjs_shims();
6935
7208
  init_api_url();
6936
7209
  var import_commander = require("commander");
6937
- var import_node_fs9 = require("fs");
6938
- var import_node_path5 = require("path");
7210
+ var import_node_fs10 = require("fs");
7211
+ var import_node_path6 = require("path");
6939
7212
  var import_node_url = require("url");
6940
7213
 
6941
7214
  // src/util/duration.ts
@@ -6951,8 +7224,8 @@ function parseDuration(text2) {
6951
7224
  }
6952
7225
 
6953
7226
  // src/cli.ts
6954
- var __dirname = (0, import_node_path5.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
6955
- var pkg = JSON.parse((0, import_node_fs9.readFileSync)((0, import_node_path5.join)(__dirname, "..", "package.json"), "utf-8"));
7227
+ var __dirname = (0, import_node_path6.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
7228
+ var pkg = JSON.parse((0, import_node_fs10.readFileSync)((0, import_node_path6.join)(__dirname, "..", "package.json"), "utf-8"));
6956
7229
  function toFormat(s) {
6957
7230
  return s === "json" || s === "markdown" ? s : "console";
6958
7231
  }
@@ -6961,7 +7234,7 @@ function shouldColor(outputPath) {
6961
7234
  }
6962
7235
  function writeReport(text2, outputPath) {
6963
7236
  if (outputPath !== void 0) {
6964
- (0, import_node_fs9.writeFileSync)(outputPath, text2);
7237
+ (0, import_node_fs10.writeFileSync)(outputPath, text2);
6965
7238
  } else {
6966
7239
  process.stdout.write(text2 + "\n");
6967
7240
  }
@@ -7006,7 +7279,7 @@ function createProgram() {
7006
7279
  program.hook("preAction", async (_program, action) => {
7007
7280
  const { loadEnv: loadEnv2 } = await Promise.resolve().then(() => (init_env(), env_exports));
7008
7281
  const { config } = action.optsWithGlobals();
7009
- if (config !== void 0) loadEnv2((0, import_node_path5.dirname)((0, import_node_path5.resolve)(config)));
7282
+ if (config !== void 0) loadEnv2((0, import_node_path6.dirname)((0, import_node_path6.resolve)(config)));
7010
7283
  loadEnv2(process.cwd());
7011
7284
  });
7012
7285
  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(
@@ -7286,7 +7559,7 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7286
7559
  Promise.resolve().then(() => (init_report(), report_exports)),
7287
7560
  Promise.resolve().then(() => (init_reporters(), reporters_exports))
7288
7561
  ]);
7289
- const cwd = globals.config !== void 0 ? (0, import_node_path5.dirname)((0, import_node_path5.resolve)(globals.config)) : process.cwd();
7562
+ const cwd = globals.config !== void 0 ? (0, import_node_path6.dirname)((0, import_node_path6.resolve)(globals.config)) : process.cwd();
7290
7563
  try {
7291
7564
  const result = handleReport2({ cwd });
7292
7565
  const text2 = renderReport2(result, toFormat(options.format), {
@@ -7383,7 +7656,7 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7383
7656
  const globals = command.optsWithGlobals();
7384
7657
  const verbose = globals.verbose === true;
7385
7658
  const { handleSync: handleSync2, reportSyncFailure: reportSyncFailure2 } = await Promise.resolve().then(() => (init_sync(), sync_exports));
7386
- const cwd = globals.config !== void 0 ? (0, import_node_path5.dirname)((0, import_node_path5.resolve)(globals.config)) : process.cwd();
7659
+ const cwd = globals.config !== void 0 ? (0, import_node_path6.dirname)((0, import_node_path6.resolve)(globals.config)) : process.cwd();
7387
7660
  try {
7388
7661
  const { id, url, gate } = await handleSync2({
7389
7662
  cwd,
@@ -7472,7 +7745,7 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7472
7745
  failUnexpected(err, verbose);
7473
7746
  }
7474
7747
  });
7475
- const requirementsCommand = program.command("requirements").description("Requirements on the platform \u2014 import them from a Markdown file");
7748
+ const requirementsCommand = program.command("requirements").description("Requirements on the platform \u2014 import them, or their links to tests, from a Markdown file");
7476
7749
  requirementsCommand.command("import").argument("<file>", "A Markdown file (UTF-8, at most 1 MiB)").description(
7477
7750
  "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"
7478
7751
  ).option(
@@ -7499,6 +7772,30 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7499
7772
  failUnexpected(err, verbose);
7500
7773
  }
7501
7774
  });
7775
+ requirementsCommand.command("links").argument("<file>", "A Markdown file with a traceability matrix (UTF-8, at most 1 MiB)").description(
7776
+ "Import a traceability matrix (requirement key -> test) as links. With no flags it lists the tables found; with them it is a dry run until --yes"
7777
+ ).option("--table <n>", "The table with the matrix (its number from the list)").option("--requirement-column <header>", "The column holding the requirement keys").option("--test-column <header>", "The column holding the tests: an exact case title or a source path, one per line").option("--separator <text>", 'Splits a requirement cell into several keys, 1 to 3 characters (default ",")').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).option("--fail-on-unmatched", "Exit 1 when any requirement or test of the matrix matched nothing", false).action(async (file, options, command) => {
7778
+ const verbose = command.optsWithGlobals().verbose === true;
7779
+ const { handleRequirementsLinks: handleRequirementsLinks2, formatLinksDiscover: formatLinksDiscover2, formatLinks: formatLinks2 } = await Promise.resolve().then(() => (init_requirements_links(), requirements_links_exports));
7780
+ const { reportRequirementsImportFailure: reportRequirementsImportFailure2 } = await Promise.resolve().then(() => (init_requirements_import(), requirements_import_exports));
7781
+ try {
7782
+ const res = await handleRequirementsLinks2({ file, ...options });
7783
+ if (options.json === true) {
7784
+ process.stdout.write(JSON.stringify(res.step === "discover" ? res.result : res.report, null, 2) + "\n");
7785
+ } else {
7786
+ process.stdout.write(res.step === "discover" ? formatLinksDiscover2(file, res.result) : formatLinks2(res.report));
7787
+ }
7788
+ if (options.failOnUnmatched === true && res.step === "links" && res.report.summary.unmatched > 0) process.exitCode = 1;
7789
+ } catch (err) {
7790
+ const code = reportRequirementsImportFailure2(err, (s) => process.stderr.write(s));
7791
+ if (code !== null) {
7792
+ maybeStack(err, verbose);
7793
+ process.exitCode = code;
7794
+ return;
7795
+ }
7796
+ failUnexpected(err, verbose);
7797
+ }
7798
+ });
7502
7799
  const planCommand = program.command("plan").description("Test plans on the platform \u2014 what a plan collects, as something a runner can take");
7503
7800
  planCommand.command("grep").argument("<id>", "The plan").description(
7504
7801
  "Print the plan's cases as one --grep pattern for the runner (GET /v1/plans/:id/cases)"
@@ -7548,6 +7845,32 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7548
7845
  }
7549
7846
  }
7550
7847
  );
7848
+ program.command("ids").description(
7849
+ "Write the Plune case id of each Playwright test that names none into plune/ids.json (dry run unless --write); test sources are not touched"
7850
+ ).option("--write", "Write the file; without it only shows what would be written", false).option("--file <path>", "The mapfile", "plune/ids.json").option("--from <file>", "A saved `playwright test --list --reporter=json` document instead of running Playwright").option("--playwright-config <path>", "Passed to `playwright test --config`").option("--json", "Print the report as JSON", false).action(
7851
+ async (options, command) => {
7852
+ const verbose = verboseOf(command);
7853
+ const { handleIds: handleIds2, formatIds: formatIds2 } = await Promise.resolve().then(() => (init_ids(), ids_exports));
7854
+ const { reportCasesFailure: reportCasesFailure2 } = await Promise.resolve().then(() => (init_cases(), cases_exports));
7855
+ try {
7856
+ const report = await handleIds2({
7857
+ write: options.write,
7858
+ file: options.file,
7859
+ ...options.from === void 0 ? {} : { from: options.from },
7860
+ ...options.playwrightConfig === void 0 ? {} : { playwrightConfig: options.playwrightConfig }
7861
+ });
7862
+ process.stdout.write(options.json ? JSON.stringify(report, null, 2) + "\n" : formatIds2(report, options.write));
7863
+ } catch (err) {
7864
+ const code = reportCasesFailure2(err, (s) => process.stderr.write(s));
7865
+ if (code !== null) {
7866
+ maybeStack(err, verbose);
7867
+ process.exitCode = code;
7868
+ return;
7869
+ }
7870
+ failUnexpected(err, verbose);
7871
+ }
7872
+ }
7873
+ );
7551
7874
  program.on("command:*", (operands) => {
7552
7875
  process.stderr.write(`Unknown command: ${operands.join(" ")}
7553
7876
  `);
@@ -7559,7 +7882,7 @@ function isMainModule() {
7559
7882
  const entry = process.argv[1];
7560
7883
  if (entry === void 0) return false;
7561
7884
  try {
7562
- return (0, import_node_fs9.realpathSync)(entry) === (0, import_node_fs9.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
7885
+ return (0, import_node_fs10.realpathSync)(entry) === (0, import_node_fs10.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
7563
7886
  } catch {
7564
7887
  return false;
7565
7888
  }
package/dist/index.cjs CHANGED
@@ -1892,6 +1892,58 @@ var SyncHttpError = class extends Error {
1892
1892
  }
1893
1893
  status;
1894
1894
  };
1895
+ var JOB_KIND = "cli-sync";
1896
+ var JOB_TIMEOUT_MS = 5e3;
1897
+ var JobReporter = class {
1898
+ constructor(doFetch, base, token) {
1899
+ this.doFetch = doFetch;
1900
+ this.base = base;
1901
+ this.token = token;
1902
+ }
1903
+ doFetch;
1904
+ base;
1905
+ token;
1906
+ id;
1907
+ async send(method, url, body) {
1908
+ try {
1909
+ const res = await this.doFetch(url, {
1910
+ method,
1911
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
1912
+ body: JSON.stringify(body),
1913
+ signal: AbortSignal.timeout(JOB_TIMEOUT_MS)
1914
+ });
1915
+ const json = await res.json().catch(() => void 0);
1916
+ return res.ok ? json ?? {} : void 0;
1917
+ } catch {
1918
+ return void 0;
1919
+ }
1920
+ }
1921
+ async open(subjectName) {
1922
+ const body = await this.send("POST", `${this.base}/v1/jobs`, {
1923
+ kind: JOB_KIND,
1924
+ subjectName,
1925
+ progress: 0
1926
+ });
1927
+ if (typeof body?.id === "string") this.id = body.id;
1928
+ }
1929
+ async report(progress, cursor) {
1930
+ if (this.id === void 0) return;
1931
+ await this.send("PATCH", `${this.base}/v1/jobs/${encodeURIComponent(this.id)}`, {
1932
+ progress,
1933
+ ...cursor !== void 0 ? { cursor } : {}
1934
+ });
1935
+ }
1936
+ async finish(outcome) {
1937
+ if (this.id === void 0) return;
1938
+ const id = this.id;
1939
+ this.id = void 0;
1940
+ await this.send(
1941
+ "PATCH",
1942
+ `${this.base}/v1/jobs/${encodeURIComponent(id)}`,
1943
+ outcome.ok ? { status: "succeeded", progress: 100 } : { status: "failed", reason: outcome.reason }
1944
+ );
1945
+ }
1946
+ };
1895
1947
  function readRun(cwd, file) {
1896
1948
  const target = file !== void 0 ? path5.resolve(cwd, file) : path5.join(cwd, ".plune", "last-run.json");
1897
1949
  let raw;
@@ -1932,30 +1984,46 @@ async function handleSync(deps = {}) {
1932
1984
  const endpoint = `${apiUrl}/v1/runs`;
1933
1985
  const payload = JSON.stringify(run);
1934
1986
  const idempotencyKey = (0, import_node_crypto2.createHash)("sha256").update(payload).digest("hex");
1935
- let res;
1987
+ const bearer = token;
1988
+ const job = new JobReporter(doFetch, apiUrl, bearer);
1989
+ await job.open(path5.basename(deps.file !== void 0 ? path5.resolve(cwd, deps.file) : cwd));
1936
1990
  try {
1937
- res = await doFetch(endpoint, {
1938
- method: "POST",
1939
- headers: {
1940
- "content-type": "application/json",
1941
- authorization: `Bearer ${token}`,
1942
- "idempotency-key": idempotencyKey
1943
- },
1944
- body: payload
1945
- });
1946
- } catch {
1947
- throw new SyncNetworkError(apiUrl);
1991
+ const result = await upload();
1992
+ await job.finish({ ok: true });
1993
+ return result;
1994
+ } catch (err) {
1995
+ await job.finish({ ok: false, reason: err instanceof Error ? err.message : "Sync failed." });
1996
+ throw err;
1948
1997
  }
1949
- if (!res.ok) {
1950
- const detail = await safeErrorDetail(res);
1951
- if (res.status === 401) throw new TokenRejectedError();
1952
- throw new SyncHttpError(res.status, detail);
1998
+ async function upload() {
1999
+ let res;
2000
+ try {
2001
+ res = await doFetch(endpoint, {
2002
+ method: "POST",
2003
+ headers: {
2004
+ "content-type": "application/json",
2005
+ authorization: `Bearer ${bearer}`,
2006
+ "idempotency-key": idempotencyKey
2007
+ },
2008
+ body: payload
2009
+ });
2010
+ } catch {
2011
+ throw new SyncNetworkError(apiUrl);
2012
+ }
2013
+ if (!res.ok) {
2014
+ const detail = await safeErrorDetail(res);
2015
+ if (res.status === 401) throw new TokenRejectedError();
2016
+ throw new SyncHttpError(res.status, detail);
2017
+ }
2018
+ const body = await res.json().catch(() => ({}));
2019
+ const id = typeof body.id === "string" ? body.id : "";
2020
+ const result = { id, url: `${endpoint}/${id}` };
2021
+ await job.report(deps.failOnFailures === true ? 80 : 95, id || void 0);
2022
+ if (deps.failOnFailures === true) {
2023
+ result.gate = gateExit(await readVerdict(doFetch, `${endpoint}/${id}/summary`, bearer));
2024
+ }
2025
+ return result;
1953
2026
  }
1954
- const body = await res.json().catch(() => ({}));
1955
- const id = typeof body.id === "string" ? body.id : "";
1956
- const result = { id, url: `${endpoint}/${id}` };
1957
- if (deps.failOnFailures === true) result.gate = gateExit(await readVerdict(doFetch, `${endpoint}/${id}/summary`, token));
1958
- return result;
1959
2027
  }
1960
2028
  async function readVerdict(doFetch, url, token) {
1961
2029
  try {
package/dist/index.js CHANGED
@@ -1832,6 +1832,58 @@ var SyncHttpError = class extends Error {
1832
1832
  }
1833
1833
  status;
1834
1834
  };
1835
+ var JOB_KIND = "cli-sync";
1836
+ var JOB_TIMEOUT_MS = 5e3;
1837
+ var JobReporter = class {
1838
+ constructor(doFetch, base, token) {
1839
+ this.doFetch = doFetch;
1840
+ this.base = base;
1841
+ this.token = token;
1842
+ }
1843
+ doFetch;
1844
+ base;
1845
+ token;
1846
+ id;
1847
+ async send(method, url, body) {
1848
+ try {
1849
+ const res = await this.doFetch(url, {
1850
+ method,
1851
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
1852
+ body: JSON.stringify(body),
1853
+ signal: AbortSignal.timeout(JOB_TIMEOUT_MS)
1854
+ });
1855
+ const json = await res.json().catch(() => void 0);
1856
+ return res.ok ? json ?? {} : void 0;
1857
+ } catch {
1858
+ return void 0;
1859
+ }
1860
+ }
1861
+ async open(subjectName) {
1862
+ const body = await this.send("POST", `${this.base}/v1/jobs`, {
1863
+ kind: JOB_KIND,
1864
+ subjectName,
1865
+ progress: 0
1866
+ });
1867
+ if (typeof body?.id === "string") this.id = body.id;
1868
+ }
1869
+ async report(progress, cursor) {
1870
+ if (this.id === void 0) return;
1871
+ await this.send("PATCH", `${this.base}/v1/jobs/${encodeURIComponent(this.id)}`, {
1872
+ progress,
1873
+ ...cursor !== void 0 ? { cursor } : {}
1874
+ });
1875
+ }
1876
+ async finish(outcome) {
1877
+ if (this.id === void 0) return;
1878
+ const id = this.id;
1879
+ this.id = void 0;
1880
+ await this.send(
1881
+ "PATCH",
1882
+ `${this.base}/v1/jobs/${encodeURIComponent(id)}`,
1883
+ outcome.ok ? { status: "succeeded", progress: 100 } : { status: "failed", reason: outcome.reason }
1884
+ );
1885
+ }
1886
+ };
1835
1887
  function readRun(cwd, file) {
1836
1888
  const target = file !== void 0 ? path5.resolve(cwd, file) : path5.join(cwd, ".plune", "last-run.json");
1837
1889
  let raw;
@@ -1872,30 +1924,46 @@ async function handleSync(deps = {}) {
1872
1924
  const endpoint = `${apiUrl}/v1/runs`;
1873
1925
  const payload = JSON.stringify(run);
1874
1926
  const idempotencyKey = createHash2("sha256").update(payload).digest("hex");
1875
- let res;
1927
+ const bearer = token;
1928
+ const job = new JobReporter(doFetch, apiUrl, bearer);
1929
+ await job.open(path5.basename(deps.file !== void 0 ? path5.resolve(cwd, deps.file) : cwd));
1876
1930
  try {
1877
- res = await doFetch(endpoint, {
1878
- method: "POST",
1879
- headers: {
1880
- "content-type": "application/json",
1881
- authorization: `Bearer ${token}`,
1882
- "idempotency-key": idempotencyKey
1883
- },
1884
- body: payload
1885
- });
1886
- } catch {
1887
- throw new SyncNetworkError(apiUrl);
1931
+ const result = await upload();
1932
+ await job.finish({ ok: true });
1933
+ return result;
1934
+ } catch (err) {
1935
+ await job.finish({ ok: false, reason: err instanceof Error ? err.message : "Sync failed." });
1936
+ throw err;
1888
1937
  }
1889
- if (!res.ok) {
1890
- const detail = await safeErrorDetail(res);
1891
- if (res.status === 401) throw new TokenRejectedError();
1892
- throw new SyncHttpError(res.status, detail);
1938
+ async function upload() {
1939
+ let res;
1940
+ try {
1941
+ res = await doFetch(endpoint, {
1942
+ method: "POST",
1943
+ headers: {
1944
+ "content-type": "application/json",
1945
+ authorization: `Bearer ${bearer}`,
1946
+ "idempotency-key": idempotencyKey
1947
+ },
1948
+ body: payload
1949
+ });
1950
+ } catch {
1951
+ throw new SyncNetworkError(apiUrl);
1952
+ }
1953
+ if (!res.ok) {
1954
+ const detail = await safeErrorDetail(res);
1955
+ if (res.status === 401) throw new TokenRejectedError();
1956
+ throw new SyncHttpError(res.status, detail);
1957
+ }
1958
+ const body = await res.json().catch(() => ({}));
1959
+ const id = typeof body.id === "string" ? body.id : "";
1960
+ const result = { id, url: `${endpoint}/${id}` };
1961
+ await job.report(deps.failOnFailures === true ? 80 : 95, id || void 0);
1962
+ if (deps.failOnFailures === true) {
1963
+ result.gate = gateExit(await readVerdict(doFetch, `${endpoint}/${id}/summary`, bearer));
1964
+ }
1965
+ return result;
1893
1966
  }
1894
- const body = await res.json().catch(() => ({}));
1895
- const id = typeof body.id === "string" ? body.id : "";
1896
- const result = { id, url: `${endpoint}/${id}` };
1897
- if (deps.failOnFailures === true) result.gate = gateExit(await readVerdict(doFetch, `${endpoint}/${id}/summary`, token));
1898
- return result;
1899
1967
  }
1900
1968
  async function readVerdict(doFetch, url, token) {
1901
1969
  try {
@@ -36,6 +36,7 @@ __export(reporter_core_exports, {
36
36
  appendBatch: () => appendBatch,
37
37
  ciCommit: () => ciCommit,
38
38
  createClient: () => createClient,
39
+ errorContextFields: () => errorContextFields,
39
40
  errorContextOf: () => errorContextOf,
40
41
  failureOf: () => failureOf,
41
42
  filesOf: () => filesOf,
@@ -449,6 +450,16 @@ function repoRootOf(dir, stopAt) {
449
450
  }
450
451
  }
451
452
  function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
453
+ return cutErrorContext(attempt, home).text;
454
+ }
455
+ function errorContextFields(attempt, home = (0, import_node_os2.homedir)()) {
456
+ const { text, omitted: omitted2 } = cutErrorContext(attempt, home);
457
+ return {
458
+ ...text !== "" ? { errorContext: text } : {},
459
+ ...omitted2 > 0 ? { errorContextOmitted: omitted2 } : {}
460
+ };
461
+ }
462
+ function cutErrorContext(attempt, home) {
452
463
  const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
453
464
  return cut(machineless(text, attempt.repoRoot, home));
454
465
  }
@@ -484,7 +495,7 @@ function pathIn(path3) {
484
495
  }
485
496
  var fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
486
497
  function cut(text) {
487
- if (jsonBytes(text) <= TEXT_LIMIT) return text;
498
+ if (jsonBytes(text) <= TEXT_LIMIT) return { text, omitted: 0 };
488
499
  const lines = text.split("\n");
489
500
  const size = lines.map((line) => jsonBytes(line) + 2);
490
501
  const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
@@ -493,7 +504,8 @@ function cut(text) {
493
504
  let tail = lines.length;
494
505
  while (head < tail && used + size[head] <= budget / 2) used += size[head++];
495
506
  while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
496
- return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
507
+ const kept = [...lines.slice(0, head), ...lines.slice(tail)].join("\n");
508
+ return { text: [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n"), omitted: text.length - kept.length };
497
509
  }
498
510
  var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
499
511
  var METADATA_TYPE = "application/plune.metadata+json";
@@ -1585,6 +1597,7 @@ function resultKey(testId, retry) {
1585
1597
  appendBatch,
1586
1598
  ciCommit,
1587
1599
  createClient,
1600
+ errorContextFields,
1588
1601
  errorContextOf,
1589
1602
  failureOf,
1590
1603
  filesOf,
@@ -541,6 +541,16 @@ declare function repoRootOf(dir: string, stopAt?: string): string | undefined;
541
541
  * searched for credentials: the platform does that before its own cut (ADR-0005).
542
542
  */
543
543
  declare function errorContextOf(attempt: FailedAttempt, home?: string): string;
544
+ /**
545
+ * The `errorContext` and `errorContextOmitted` keys of a result (plune-ai/plune#1281 AC-03), ready to spread:
546
+ * none for an empty text, and `errorContextOmitted` only when the cut took something out, a whole number of at
547
+ * least 1 (the platform refuses 0). Counted in characters of the text as it would have been sent (`.length`,
548
+ * the unit the platform counts in), never bytes; the `…[omitted N lines]…` marker is not counted.
549
+ */
550
+ declare function errorContextFields(attempt: FailedAttempt, home?: string): {
551
+ errorContext?: string;
552
+ errorContextOmitted?: number;
553
+ };
544
554
  /**
545
555
  * The files the runner kept for one attempt, whatever its status: a screenshot of a test that passed
546
556
  * is how a person sees what it checked (plune-ai/plune#913). Which of them the platform takes, the
@@ -642,4 +652,4 @@ interface PlatformClient {
642
652
  }
643
653
  declare function createClient(opts: ClientOptions): PlatformClient;
644
654
 
645
- export { type AssertionRecord, type Attachment, type AttemptError, type ClientFailureKind, type ClientOptions, type ClientOutcome, DEFAULT_FALLBACK_PATH, type DeclaredStep, type DeferredBatch, type DeferredResult, type EnvSettings, type Execution, type ExpectedEntry, type ExternalKeyKind, type FailedAttempt, type FailureDetail, type FrozenSummary, type KeyRef, type PendingResult, type PlatformClient, RESULT_KEY_MAX, type ReporterConfig, type ResultFile, type ResultStatus, type ResultSubmission, type RunKind, type RunMeta, type RunRecord, type RunSession, type RunStats, type RunnerLocation, type SubmitCounts, UNSUPPORTED_VARS, type UnofferedWhy, appendBatch, ciCommit, createClient, errorContextOf, failureOf, filesOf, readEnv, repoRootOf, resultKey, startRun, webLink };
655
+ export { type AssertionRecord, type Attachment, type AttemptError, type ClientFailureKind, type ClientOptions, type ClientOutcome, DEFAULT_FALLBACK_PATH, type DeclaredStep, type DeferredBatch, type DeferredResult, type EnvSettings, type Execution, type ExpectedEntry, type ExternalKeyKind, type FailedAttempt, type FailureDetail, type FrozenSummary, type KeyRef, type PendingResult, type PlatformClient, RESULT_KEY_MAX, type ReporterConfig, type ResultFile, type ResultStatus, type ResultSubmission, type RunKind, type RunMeta, type RunRecord, type RunSession, type RunStats, type RunnerLocation, type SubmitCounts, UNSUPPORTED_VARS, type UnofferedWhy, appendBatch, ciCommit, createClient, errorContextFields, errorContextOf, failureOf, filesOf, readEnv, repoRootOf, resultKey, startRun, webLink };
@@ -541,6 +541,16 @@ declare function repoRootOf(dir: string, stopAt?: string): string | undefined;
541
541
  * searched for credentials: the platform does that before its own cut (ADR-0005).
542
542
  */
543
543
  declare function errorContextOf(attempt: FailedAttempt, home?: string): string;
544
+ /**
545
+ * The `errorContext` and `errorContextOmitted` keys of a result (plune-ai/plune#1281 AC-03), ready to spread:
546
+ * none for an empty text, and `errorContextOmitted` only when the cut took something out, a whole number of at
547
+ * least 1 (the platform refuses 0). Counted in characters of the text as it would have been sent (`.length`,
548
+ * the unit the platform counts in), never bytes; the `…[omitted N lines]…` marker is not counted.
549
+ */
550
+ declare function errorContextFields(attempt: FailedAttempt, home?: string): {
551
+ errorContext?: string;
552
+ errorContextOmitted?: number;
553
+ };
544
554
  /**
545
555
  * The files the runner kept for one attempt, whatever its status: a screenshot of a test that passed
546
556
  * is how a person sees what it checked (plune-ai/plune#913). Which of them the platform takes, the
@@ -642,4 +652,4 @@ interface PlatformClient {
642
652
  }
643
653
  declare function createClient(opts: ClientOptions): PlatformClient;
644
654
 
645
- export { type AssertionRecord, type Attachment, type AttemptError, type ClientFailureKind, type ClientOptions, type ClientOutcome, DEFAULT_FALLBACK_PATH, type DeclaredStep, type DeferredBatch, type DeferredResult, type EnvSettings, type Execution, type ExpectedEntry, type ExternalKeyKind, type FailedAttempt, type FailureDetail, type FrozenSummary, type KeyRef, type PendingResult, type PlatformClient, RESULT_KEY_MAX, type ReporterConfig, type ResultFile, type ResultStatus, type ResultSubmission, type RunKind, type RunMeta, type RunRecord, type RunSession, type RunStats, type RunnerLocation, type SubmitCounts, UNSUPPORTED_VARS, type UnofferedWhy, appendBatch, ciCommit, createClient, errorContextOf, failureOf, filesOf, readEnv, repoRootOf, resultKey, startRun, webLink };
655
+ export { type AssertionRecord, type Attachment, type AttemptError, type ClientFailureKind, type ClientOptions, type ClientOutcome, DEFAULT_FALLBACK_PATH, type DeclaredStep, type DeferredBatch, type DeferredResult, type EnvSettings, type Execution, type ExpectedEntry, type ExternalKeyKind, type FailedAttempt, type FailureDetail, type FrozenSummary, type KeyRef, type PendingResult, type PlatformClient, RESULT_KEY_MAX, type ReporterConfig, type ResultFile, type ResultStatus, type ResultSubmission, type RunKind, type RunMeta, type RunRecord, type RunSession, type RunStats, type RunnerLocation, type SubmitCounts, UNSUPPORTED_VARS, type UnofferedWhy, appendBatch, ciCommit, createClient, errorContextFields, errorContextOf, failureOf, filesOf, readEnv, repoRootOf, resultKey, startRun, webLink };
@@ -400,6 +400,16 @@ function repoRootOf(dir, stopAt) {
400
400
  }
401
401
  }
402
402
  function errorContextOf(attempt, home = homedir2()) {
403
+ return cutErrorContext(attempt, home).text;
404
+ }
405
+ function errorContextFields(attempt, home = homedir2()) {
406
+ const { text, omitted: omitted2 } = cutErrorContext(attempt, home);
407
+ return {
408
+ ...text !== "" ? { errorContext: text } : {},
409
+ ...omitted2 > 0 ? { errorContextOmitted: omitted2 } : {}
410
+ };
411
+ }
412
+ function cutErrorContext(attempt, home) {
403
413
  const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
404
414
  return cut(machineless(text, attempt.repoRoot, home));
405
415
  }
@@ -435,7 +445,7 @@ function pathIn(path3) {
435
445
  }
436
446
  var fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
437
447
  function cut(text) {
438
- if (jsonBytes(text) <= TEXT_LIMIT) return text;
448
+ if (jsonBytes(text) <= TEXT_LIMIT) return { text, omitted: 0 };
439
449
  const lines = text.split("\n");
440
450
  const size = lines.map((line) => jsonBytes(line) + 2);
441
451
  const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
@@ -444,7 +454,8 @@ function cut(text) {
444
454
  let tail = lines.length;
445
455
  while (head < tail && used + size[head] <= budget / 2) used += size[head++];
446
456
  while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
447
- return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
457
+ const kept = [...lines.slice(0, head), ...lines.slice(tail)].join("\n");
458
+ return { text: [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n"), omitted: text.length - kept.length };
448
459
  }
449
460
  var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
450
461
  var METADATA_TYPE = "application/plune.metadata+json";
@@ -1535,6 +1546,7 @@ export {
1535
1546
  appendBatch,
1536
1547
  ciCommit,
1537
1548
  createClient,
1549
+ errorContextFields,
1538
1550
  errorContextOf,
1539
1551
  failureOf,
1540
1552
  filesOf,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.28.0",
3
+ "version": "0.30.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",