@plune-ai/cli 0.18.0 → 0.20.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
@@ -119,11 +119,11 @@ the last resort, used only when `PLUNE_TOKEN` is set nowhere else. Nothing write
119
119
  | `plune init` | Scaffold `plune.yaml`, a sample dataset, and `.env.example`. Flags: `--yes` (non-interactive), `--force`. |
120
120
  | `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. |
121
121
  | `plune logout` | Remove the saved token. |
122
- | `plune sync` | Upload the latest local run to the platform. Flags: `--file <path>` to send a specific run JSON. |
123
- | `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), `--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). |
122
+ | `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. |
123
+ | `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), `--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), `--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. |
124
124
  | `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. |
125
- | `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>`. |
126
- | `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>`. |
125
+ | `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. |
126
+ | `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>`, `--fail-on-failures` to exit with [the gate](#a-release-gate---fail-on-failures)'s code instead — or with the command's own when no result reached the run. |
127
127
  | `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 30 days** (an owner of the project can put it back from Settings → Bin), then gone for good. No prompt: it is your data, and this command belongs in scripts. |
128
128
  | `plune run report` | Replay `.plune/pending-results.jsonl` — send what the reporter could not. The lines of one reporter run go back as one run, with the commit and branch the reporter recorded. Once every result has gone the file is renamed to `pending-results.<time>.sent.jsonl`; if anything did not go, it stays as it was. Flags: `--file <path>`. |
129
129
  | `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. |
@@ -135,6 +135,24 @@ Global flags: `-c, --config <path>` · `-v, --verbose` · `--no-color`.
135
135
 
136
136
  **Exit codes:** `0` everything passed · `1` an assertion failed · `2` configuration or execution error · `3` `pull` refused to overwrite uncommitted changes · `4` `push` had refusals.
137
137
 
138
+ ### A release gate: `--fail-on-failures`
139
+
140
+ `plune run finish`, `plune run exec`, `plune run import` and `plune sync` take `--fail-on-failures`. The exit
141
+ code is then the platform's verdict on the run, not the runner's: the summary the platform froze at `finish`
142
+ (for `sync`, one `GET /v1/runs/:id/summary` after the upload). The platform counts a muted case's failure under
143
+ `muted`, so a mute set in Plune is honoured without the CLI knowing about it.
144
+
145
+ | The run | Exit |
146
+ | ------- | ---- |
147
+ | a `failed` or `broken` result that is not muted | `1` |
148
+ | an expected case that never reported (`notRun`): a dead shard is not green | `1` |
149
+ | only `passed`, `blocked`, `skipped` and muted failures | `0` |
150
+ | no verdict: the platform could not be reached, the finish was refused, the run was left open or terminated | `1` |
151
+ | `run exec` where no result reached the run and nothing was expected of it | the command's own code |
152
+
153
+ Without the flag every command exits as it always did. `plune run` (local evals) is not affected: it stays the
154
+ raw local signal and works offline.
155
+
138
156
  ## Already running tests? Bring the results in
139
157
 
140
158
  Everything above generates checks, which is why it needs a provider key. If you already have a
package/dist/cli.cjs CHANGED
@@ -3306,21 +3306,26 @@ async function startRun(passed, declared2) {
3306
3306
  },
3307
3307
  flush,
3308
3308
  async finish(reason) {
3309
- if (done) return;
3309
+ if (done) return void 0;
3310
3310
  done = true;
3311
3311
  await flush();
3312
3312
  await offer();
3313
+ let frozen;
3313
3314
  if (runId !== null && !offline) {
3314
3315
  const out = await client.post(`/v1/runs/${runId}/events`, {
3315
3316
  event: "finish",
3316
3317
  ...reason !== void 0 ? { reason } : {}
3317
3318
  });
3318
3319
  if (!out.ok) log(`plune: could not close run ${runId} (${out.detail || out.kind}).`);
3319
- else if (typeof out.body?.detached === "number" && out.body.detached > 0) {
3320
- log(`plune: ${out.body.detached} test case(s) marked detached \u2014 this full run no longer reports them.`);
3320
+ else {
3321
+ frozen = out.body?.summary ?? void 0;
3322
+ if (typeof out.body?.detached === "number" && out.body.detached > 0) {
3323
+ log(`plune: ${out.body.detached} test case(s) marked detached \u2014 this full run no longer reports them.`);
3324
+ }
3321
3325
  }
3322
3326
  }
3323
3327
  summarise();
3328
+ return frozen;
3324
3329
  },
3325
3330
  async leaveOpen() {
3326
3331
  if (done) return;
@@ -3398,6 +3403,28 @@ var init_session = __esm({
3398
3403
  }
3399
3404
  });
3400
3405
 
3406
+ // src/cli/gate.ts
3407
+ function gateExit(verdict, runnerCode) {
3408
+ if (verdict?.statusEvent === "terminated") {
3409
+ return { code: 1, line: "plune: gate failed \u2014 the run was terminated, so it has no verdict to pass on." };
3410
+ }
3411
+ const counts = verdict?.counts;
3412
+ if (counts == null) return { code: 1, line: "plune: gate failed \u2014 no verdict from the platform, and no verdict is no pass." };
3413
+ const n = (status) => counts[status] ?? 0;
3414
+ const notRun = verdict?.notRun?.length ?? 0;
3415
+ if (runnerCode !== void 0 && notRun === 0 && Object.values(counts).every((k) => !k)) {
3416
+ return { code: runnerCode, line: `plune: gate \u2014 no result reached the run, so the exit code is the command's own (${runnerCode}).` };
3417
+ }
3418
+ const said = `${n("failed")} failed \xB7 ${n("broken")} broken \xB7 ${notRun} not run \xB7 ${n("muted")} muted`;
3419
+ return n("failed") + n("broken") > 0 || notRun > 0 ? { code: 1, line: `plune: gate failed \u2014 ${said}` } : { code: 0, line: `plune: gate passed \u2014 ${said}` };
3420
+ }
3421
+ var init_gate = __esm({
3422
+ "src/cli/gate.ts"() {
3423
+ "use strict";
3424
+ init_cjs_shims();
3425
+ }
3426
+ });
3427
+
3401
3428
  // src/cli/commands/run-lifecycle.ts
3402
3429
  var run_lifecycle_exports = {};
3403
3430
  __export(run_lifecycle_exports, {
@@ -3455,7 +3482,7 @@ async function handleRunStart(options = {}) {
3455
3482
  }
3456
3483
  return result;
3457
3484
  }
3458
- async function handleRunFinish(id, options = {}) {
3485
+ async function handleRunFinish(id, options = {}, runnerCode) {
3459
3486
  const { client, write } = connect(options);
3460
3487
  const event = options.terminate === true ? "terminate" : "finish";
3461
3488
  const out = await client.post(`/v1/runs/${id}/events`, {
@@ -3468,6 +3495,11 @@ async function handleRunFinish(id, options = {}) {
3468
3495
  );
3469
3496
  }
3470
3497
  write(`Run ${id} ${event === "finish" ? "finished" : "terminated"}.`);
3498
+ if (options.failOnFailures !== true) return runnerCode ?? 0;
3499
+ const summary = out.body?.summary;
3500
+ const gate = gateExit({ statusEvent: out.body?.run?.statusEvent, counts: summary?.counts, notRun: summary?.notRun }, runnerCode);
3501
+ write(gate.line);
3502
+ return gate.code;
3471
3503
  }
3472
3504
  async function handleRunDelete(id, options = {}) {
3473
3505
  const { client, write } = connect(options);
@@ -3499,8 +3531,7 @@ async function handleRunExec(options) {
3499
3531
  child.on("close", (exitCode) => resolve9(exitCode ?? 1));
3500
3532
  child.on("error", () => resolve9(1));
3501
3533
  });
3502
- await handleRunFinish(run.id, options);
3503
- return code;
3534
+ return handleRunFinish(run.id, options, code);
3504
3535
  }
3505
3536
  async function handleRunReport(options = {}) {
3506
3537
  const file = options.file ?? DEFAULT_FALLBACK_PATH;
@@ -3596,6 +3627,7 @@ var init_run_lifecycle = __esm({
3596
3627
  init_env2();
3597
3628
  init_fallback();
3598
3629
  init_session();
3630
+ init_gate();
3599
3631
  NoTokenError = class extends Error {
3600
3632
  constructor() {
3601
3633
  super('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.');
@@ -4469,8 +4501,9 @@ async function handleRunImport(options) {
4469
4501
  results.map((r) => r.keys)
4470
4502
  );
4471
4503
  for (const result2 of results) await session.add(result2);
4504
+ let frozen;
4472
4505
  if (env.keepOpen) await session.leaveOpen();
4473
- else await session.finish();
4506
+ else frozen = await session.finish();
4474
4507
  const stats = session.stats;
4475
4508
  const result = {
4476
4509
  runId: session.runId,
@@ -4499,6 +4532,14 @@ async function handleRunImport(options) {
4499
4532
  offering: options.create === true
4500
4533
  };
4501
4534
  for (const line of describe(result, apiUrl)) write(line);
4535
+ if (options.failOnFailures === true) {
4536
+ if (env.keepOpen) {
4537
+ write("plune: the run is left open for the jobs still reporting, so there is no verdict yet \u2014 gate on the step that finishes it.");
4538
+ }
4539
+ const gate = gateExit(frozen);
4540
+ write(gate.line);
4541
+ result.gate = gate.code;
4542
+ }
4502
4543
  return result;
4503
4544
  }
4504
4545
  function runUrl2(apiUrl, id) {
@@ -4565,6 +4606,7 @@ var init_run_import = __esm({
4565
4606
  init_run_lifecycle();
4566
4607
  init_api_url();
4567
4608
  init_credentials();
4609
+ init_gate();
4568
4610
  init_env2();
4569
4611
  NEXT = {
4570
4612
  queue: "the review queue is full. Approve or reject what is waiting, then import this report again to offer the rest.",
@@ -5220,7 +5262,18 @@ async function handleSync(deps = {}) {
5220
5262
  }
5221
5263
  const body = await res.json().catch(() => ({}));
5222
5264
  const id = typeof body.id === "string" ? body.id : "";
5223
- return { id, url: `${endpoint}/${id}` };
5265
+ const result = { id, url: `${endpoint}/${id}` };
5266
+ if (deps.failOnFailures === true) result.gate = gateExit(await readVerdict(doFetch, `${endpoint}/${id}/summary`, token));
5267
+ return result;
5268
+ }
5269
+ async function readVerdict(doFetch, url, token) {
5270
+ try {
5271
+ const res = await doFetch(url, { method: "GET", headers: { authorization: `Bearer ${token}` } });
5272
+ const body = await res.json().catch(() => void 0);
5273
+ return res.ok ? { statusEvent: body?.run?.statusEvent, counts: body?.counts, notRun: body?.notRun } : void 0;
5274
+ } catch {
5275
+ return void 0;
5276
+ }
5224
5277
  }
5225
5278
  function reportSyncFailure(err, write) {
5226
5279
  if (err instanceof NotLoggedInError || err instanceof TokenRejectedError2 || err instanceof SyncFileError) {
@@ -5243,6 +5296,7 @@ var init_sync = __esm({
5243
5296
  init_run_result_schema();
5244
5297
  init_api_url();
5245
5298
  init_credentials();
5299
+ init_gate();
5246
5300
  NotLoggedInError = class extends Error {
5247
5301
  constructor() {
5248
5302
  super('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.');
@@ -5314,7 +5368,8 @@ async function defaultReadEvidence(dir) {
5314
5368
  if (!Array.isArray(raw)) return void 0;
5315
5369
  return raw.map((r) => {
5316
5370
  const rec = asRecord(r);
5317
- return { name: str(rec["name"]), passed: rec["passed"] === true };
5371
+ const error = str(rec["error"]);
5372
+ return { name: str(rec["name"]), passed: rec["passed"] === true, ...error !== "" ? { error } : {} };
5318
5373
  });
5319
5374
  }
5320
5375
  function casesFromDesign(report) {
@@ -5373,7 +5428,7 @@ async function readCairnRun(dir, deps = {}) {
5373
5428
  const verdicts = /* @__PURE__ */ new Map();
5374
5429
  for (const e of evidence ?? []) {
5375
5430
  if (verdicts.has(e.name)) throw new CairnEvidenceAmbiguousError(e.name);
5376
- verdicts.set(e.name, e.passed ? "passed" : "failed");
5431
+ verdicts.set(e.name, e.error !== void 0 ? "blocked" : e.passed ? "passed" : "failed");
5377
5432
  }
5378
5433
  cases = casesFromApi(report, verdicts);
5379
5434
  } else {
@@ -5907,6 +5962,7 @@ function createProgram() {
5907
5962
  process.exit(1);
5908
5963
  };
5909
5964
  const verboseOf = (command) => command.optsWithGlobals().verbose === true;
5965
+ const FAIL_ON_FAILURES = "Exit with the platform's verdict on the run: 1 on a failed or broken result (muted ones do not count), on an expected case that never ran, or when there is no verdict at all";
5910
5966
  runCommand.command("start").description("Open a platform run (or join one already carrying the key) and print its id").option("--key <externalKey>", "The key several processes share; generated when absent").option("--json", "Print one machine-readable line instead of prose", false).action(async (options, command) => {
5911
5967
  try {
5912
5968
  const { handleRunStart: handleRunStart2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
@@ -5918,14 +5974,16 @@ function createProgram() {
5918
5974
  await failRunCommand(err, verboseOf(command));
5919
5975
  }
5920
5976
  });
5921
- runCommand.command("finish").argument("<id>", "The run to close").description("Close a platform run \u2014 what the reporter tells you to do for a run left open").option("--terminate", "Record the run as cut short rather than finished", false).option("--reason <text>", "Why, for a terminated run").action(
5977
+ runCommand.command("finish").argument("<id>", "The run to close").description("Close a platform run \u2014 what the reporter tells you to do for a run left open").option("--terminate", "Record the run as cut short rather than finished", false).option("--reason <text>", "Why, for a terminated run").option("--fail-on-failures", FAIL_ON_FAILURES, false).action(
5922
5978
  async (id, options, command) => {
5923
5979
  try {
5924
5980
  const { handleRunFinish: handleRunFinish2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
5925
- await handleRunFinish2(id, {
5981
+ const code = await handleRunFinish2(id, {
5926
5982
  terminate: options.terminate,
5927
- ...options.reason !== void 0 ? { reason: options.reason } : {}
5983
+ ...options.reason !== void 0 ? { reason: options.reason } : {},
5984
+ ...options.failOnFailures ? { failOnFailures: true } : {}
5928
5985
  });
5986
+ if (options.failOnFailures) process.exitCode = code;
5929
5987
  } catch (err) {
5930
5988
  await failRunCommand(err, verboseOf(command));
5931
5989
  }
@@ -5933,12 +5991,13 @@ function createProgram() {
5933
5991
  );
5934
5992
  runCommand.command("exec").argument("<command...>", "The command to run inside the run, after `--`").description(
5935
5993
  "Open a run, run a command inside it, close the run \u2014 whatever the command returns"
5936
- ).option("--key <externalKey>", "The key several processes share; generated when absent").action(async (argv, options, command) => {
5994
+ ).option("--key <externalKey>", "The key several processes share; generated when absent").option("--fail-on-failures", `${FAIL_ON_FAILURES}; the command's own exit code when no result reached the run`, false).action(async (argv, options, command) => {
5937
5995
  try {
5938
5996
  const { handleRunExec: handleRunExec2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
5939
5997
  process.exitCode = await handleRunExec2({
5940
5998
  argv,
5941
- ...options.key !== void 0 ? { key: options.key } : {}
5999
+ ...options.key !== void 0 ? { key: options.key } : {},
6000
+ ...options.failOnFailures ? { failOnFailures: true } : {}
5942
6001
  });
5943
6002
  } catch (err) {
5944
6003
  await failRunCommand(err, verboseOf(command));
@@ -5967,7 +6026,7 @@ function createProgram() {
5967
6026
  "--fail-on-missing-attachments",
5968
6027
  "Exit 1 when a file the report names ([[ATTACHMENT|path]] in JUnit, a source in allure-results) is not there; the results are sent either way",
5969
6028
  false
5970
- ).action(
6029
+ ).option("--fail-on-failures", FAIL_ON_FAILURES, false).action(
5971
6030
  async (file, options, command) => {
5972
6031
  const [
5973
6032
  { handleRunImport: handleRunImport2 },
@@ -5991,9 +6050,11 @@ function createProgram() {
5991
6050
  file,
5992
6051
  ...format !== void 0 ? { format } : {},
5993
6052
  ...options.key !== void 0 ? { key: options.key } : {},
5994
- ...options.create ? { create: true } : {}
6053
+ ...options.create ? { create: true } : {},
6054
+ ...options.failOnFailures ? { failOnFailures: true } : {}
5995
6055
  });
5996
6056
  if (options.failOnMissingAttachments && imported.missingAttachments.length > 0) process.exitCode = 1;
6057
+ if (imported.gate === 1) process.exitCode = 1;
5997
6058
  } catch (err) {
5998
6059
  if (err instanceof UnknownFormatError2 || err instanceof XmlParseError2 || err instanceof JsonReportError2 || err instanceof AllureReportError2) {
5999
6060
  process.stderr.write(err.message + "\n");
@@ -6103,18 +6164,24 @@ function createProgram() {
6103
6164
  failUnexpected(err, verbose);
6104
6165
  }
6105
6166
  });
6106
- program.command("sync").description("Upload your latest local run to the Plune platform (POST /v1/runs)").option("--file <path>", "Sync a specific run JSON instead of .plune/last-run.json").action(async (options, command) => {
6167
+ program.command("sync").description("Upload your latest local run to the Plune platform (POST /v1/runs)").option("--file <path>", "Sync a specific run JSON instead of .plune/last-run.json").option("--fail-on-failures", `${FAIL_ON_FAILURES}; read with one GET of the run's summary after the upload`, false).action(async (options, command) => {
6107
6168
  const globals = command.optsWithGlobals();
6108
6169
  const verbose = globals.verbose === true;
6109
6170
  const { handleSync: handleSync2, reportSyncFailure: reportSyncFailure2 } = await Promise.resolve().then(() => (init_sync(), sync_exports));
6110
6171
  const cwd = globals.config !== void 0 ? (0, import_node_path4.dirname)((0, import_node_path4.resolve)(globals.config)) : process.cwd();
6111
6172
  try {
6112
- const { id, url } = await handleSync2({
6173
+ const { id, url, gate } = await handleSync2({
6113
6174
  cwd,
6114
- ...options.file !== void 0 ? { file: options.file } : {}
6175
+ ...options.file !== void 0 ? { file: options.file } : {},
6176
+ ...options.failOnFailures ? { failOnFailures: true } : {}
6115
6177
  });
6116
6178
  process.stdout.write(`Synced run ${id} \u2192 ${url}
6117
6179
  `);
6180
+ if (gate !== void 0) {
6181
+ process.stdout.write(`${gate.line}
6182
+ `);
6183
+ process.exitCode = gate.code;
6184
+ }
6118
6185
  } catch (err) {
6119
6186
  const code = reportSyncFailure2(err, (s) => process.stderr.write(s));
6120
6187
  if (code !== null) {
package/dist/index.cjs CHANGED
@@ -1703,6 +1703,22 @@ function loadToken() {
1703
1703
  }
1704
1704
  }
1705
1705
 
1706
+ // src/cli/gate.ts
1707
+ function gateExit(verdict, runnerCode) {
1708
+ if (verdict?.statusEvent === "terminated") {
1709
+ return { code: 1, line: "plune: gate failed \u2014 the run was terminated, so it has no verdict to pass on." };
1710
+ }
1711
+ const counts = verdict?.counts;
1712
+ if (counts == null) return { code: 1, line: "plune: gate failed \u2014 no verdict from the platform, and no verdict is no pass." };
1713
+ const n = (status) => counts[status] ?? 0;
1714
+ const notRun = verdict?.notRun?.length ?? 0;
1715
+ if (runnerCode !== void 0 && notRun === 0 && Object.values(counts).every((k) => !k)) {
1716
+ return { code: runnerCode, line: `plune: gate \u2014 no result reached the run, so the exit code is the command's own (${runnerCode}).` };
1717
+ }
1718
+ const said = `${n("failed")} failed \xB7 ${n("broken")} broken \xB7 ${notRun} not run \xB7 ${n("muted")} muted`;
1719
+ return n("failed") + n("broken") > 0 || notRun > 0 ? { code: 1, line: `plune: gate failed \u2014 ${said}` } : { code: 0, line: `plune: gate passed \u2014 ${said}` };
1720
+ }
1721
+
1706
1722
  // src/cli/commands/sync.ts
1707
1723
  var NotLoggedInError = class extends Error {
1708
1724
  constructor() {
@@ -1795,7 +1811,18 @@ async function handleSync(deps = {}) {
1795
1811
  }
1796
1812
  const body = await res.json().catch(() => ({}));
1797
1813
  const id = typeof body.id === "string" ? body.id : "";
1798
- return { id, url: `${endpoint}/${id}` };
1814
+ const result = { id, url: `${endpoint}/${id}` };
1815
+ if (deps.failOnFailures === true) result.gate = gateExit(await readVerdict(doFetch, `${endpoint}/${id}/summary`, token));
1816
+ return result;
1817
+ }
1818
+ async function readVerdict(doFetch, url, token) {
1819
+ try {
1820
+ const res = await doFetch(url, { method: "GET", headers: { authorization: `Bearer ${token}` } });
1821
+ const body = await res.json().catch(() => void 0);
1822
+ return res.ok ? { statusEvent: body?.run?.statusEvent, counts: body?.counts, notRun: body?.notRun } : void 0;
1823
+ } catch {
1824
+ return void 0;
1825
+ }
1799
1826
  }
1800
1827
  function reportSyncFailure(err, write) {
1801
1828
  if (err instanceof NotLoggedInError || err instanceof TokenRejectedError || err instanceof SyncFileError) {
@@ -1883,7 +1910,8 @@ async function defaultReadEvidence(dir) {
1883
1910
  if (!Array.isArray(raw)) return void 0;
1884
1911
  return raw.map((r) => {
1885
1912
  const rec = asRecord(r);
1886
- return { name: str(rec["name"]), passed: rec["passed"] === true };
1913
+ const error = str(rec["error"]);
1914
+ return { name: str(rec["name"]), passed: rec["passed"] === true, ...error !== "" ? { error } : {} };
1887
1915
  });
1888
1916
  }
1889
1917
  function casesFromDesign(report) {
@@ -1942,7 +1970,7 @@ async function readCairnRun(dir, deps = {}) {
1942
1970
  const verdicts = /* @__PURE__ */ new Map();
1943
1971
  for (const e of evidence ?? []) {
1944
1972
  if (verdicts.has(e.name)) throw new CairnEvidenceAmbiguousError(e.name);
1945
- verdicts.set(e.name, e.passed ? "passed" : "failed");
1973
+ verdicts.set(e.name, e.error !== void 0 ? "blocked" : e.passed ? "passed" : "failed");
1946
1974
  }
1947
1975
  cases = casesFromApi(report, verdicts);
1948
1976
  } else {
package/dist/index.d.cts CHANGED
@@ -315,12 +315,19 @@ interface SyncDeps {
315
315
  loadToken?: () => string | null;
316
316
  /** HTTP transport (injected in tests — e.g. a real Hono app's `request`). Defaults to global `fetch`. */
317
317
  fetchImpl?: typeof fetch;
318
+ /** `--fail-on-failures`: read the run's summary back once after the upload and judge it (plune#1247). */
319
+ failOnFailures?: boolean;
318
320
  }
319
321
  interface SyncResult {
320
322
  /** Server-assigned run id. */
321
323
  id: string;
322
324
  /** The `GET /v1/runs/:id` URL where the uploaded run can be read back. */
323
325
  url: string;
326
+ /** The exit code `--fail-on-failures` asks for and the line that says why; absent without the flag. */
327
+ gate?: {
328
+ code: number;
329
+ line: string;
330
+ };
324
331
  }
325
332
  /**
326
333
  * Upload the latest (or `--file`) local run to `POST {apiUrl}/v1/runs` with the stored Bearer token.
@@ -645,7 +652,12 @@ declare function formatIngestResult(r: IngestResult): string;
645
652
  declare const INGEST_SCHEMA_VERSION = 1;
646
653
  type CairnRunMode = 'explore' | 'design' | 'api';
647
654
  type CairnExecution = 'auto' | 'manual';
648
- type CairnVerdict = 'passed' | 'failed';
655
+ /**
656
+ * `blocked` (plune-ai/plune#1327): the case's request got no answer — Cairn's evidence records a transport error
657
+ * after the retries — so the product never said anything either way. Sent as `failed`, it blamed the product for an
658
+ * unreachable target. Needs a platform that accepts it (plune-ai/plune#1330); an older one refuses the payload.
659
+ */
660
+ type CairnVerdict = 'passed' | 'failed' | 'blocked';
649
661
  interface CairnIngestCase {
650
662
  stableId: string;
651
663
  title: string;
@@ -669,6 +681,8 @@ interface CairnIngestPayload {
669
681
  interface EvidenceRecord {
670
682
  name: string;
671
683
  passed: boolean;
684
+ /** Cairn's transport error, when the request got no response after the retries. */
685
+ error?: string;
672
686
  }
673
687
  interface ReadCairnRunDeps {
674
688
  /** Injected for tests; returns `undefined` when the run executed nothing and wrote no evidence. */
package/dist/index.d.ts CHANGED
@@ -315,12 +315,19 @@ interface SyncDeps {
315
315
  loadToken?: () => string | null;
316
316
  /** HTTP transport (injected in tests — e.g. a real Hono app's `request`). Defaults to global `fetch`. */
317
317
  fetchImpl?: typeof fetch;
318
+ /** `--fail-on-failures`: read the run's summary back once after the upload and judge it (plune#1247). */
319
+ failOnFailures?: boolean;
318
320
  }
319
321
  interface SyncResult {
320
322
  /** Server-assigned run id. */
321
323
  id: string;
322
324
  /** The `GET /v1/runs/:id` URL where the uploaded run can be read back. */
323
325
  url: string;
326
+ /** The exit code `--fail-on-failures` asks for and the line that says why; absent without the flag. */
327
+ gate?: {
328
+ code: number;
329
+ line: string;
330
+ };
324
331
  }
325
332
  /**
326
333
  * Upload the latest (or `--file`) local run to `POST {apiUrl}/v1/runs` with the stored Bearer token.
@@ -645,7 +652,12 @@ declare function formatIngestResult(r: IngestResult): string;
645
652
  declare const INGEST_SCHEMA_VERSION = 1;
646
653
  type CairnRunMode = 'explore' | 'design' | 'api';
647
654
  type CairnExecution = 'auto' | 'manual';
648
- type CairnVerdict = 'passed' | 'failed';
655
+ /**
656
+ * `blocked` (plune-ai/plune#1327): the case's request got no answer — Cairn's evidence records a transport error
657
+ * after the retries — so the product never said anything either way. Sent as `failed`, it blamed the product for an
658
+ * unreachable target. Needs a platform that accepts it (plune-ai/plune#1330); an older one refuses the payload.
659
+ */
660
+ type CairnVerdict = 'passed' | 'failed' | 'blocked';
649
661
  interface CairnIngestCase {
650
662
  stableId: string;
651
663
  title: string;
@@ -669,6 +681,8 @@ interface CairnIngestPayload {
669
681
  interface EvidenceRecord {
670
682
  name: string;
671
683
  passed: boolean;
684
+ /** Cairn's transport error, when the request got no response after the retries. */
685
+ error?: string;
672
686
  }
673
687
  interface ReadCairnRunDeps {
674
688
  /** Injected for tests; returns `undefined` when the run executed nothing and wrote no evidence. */
package/dist/index.js CHANGED
@@ -1647,6 +1647,22 @@ function loadToken() {
1647
1647
  }
1648
1648
  }
1649
1649
 
1650
+ // src/cli/gate.ts
1651
+ function gateExit(verdict, runnerCode) {
1652
+ if (verdict?.statusEvent === "terminated") {
1653
+ return { code: 1, line: "plune: gate failed \u2014 the run was terminated, so it has no verdict to pass on." };
1654
+ }
1655
+ const counts = verdict?.counts;
1656
+ if (counts == null) return { code: 1, line: "plune: gate failed \u2014 no verdict from the platform, and no verdict is no pass." };
1657
+ const n = (status) => counts[status] ?? 0;
1658
+ const notRun = verdict?.notRun?.length ?? 0;
1659
+ if (runnerCode !== void 0 && notRun === 0 && Object.values(counts).every((k) => !k)) {
1660
+ return { code: runnerCode, line: `plune: gate \u2014 no result reached the run, so the exit code is the command's own (${runnerCode}).` };
1661
+ }
1662
+ const said = `${n("failed")} failed \xB7 ${n("broken")} broken \xB7 ${notRun} not run \xB7 ${n("muted")} muted`;
1663
+ return n("failed") + n("broken") > 0 || notRun > 0 ? { code: 1, line: `plune: gate failed \u2014 ${said}` } : { code: 0, line: `plune: gate passed \u2014 ${said}` };
1664
+ }
1665
+
1650
1666
  // src/cli/commands/sync.ts
1651
1667
  var NotLoggedInError = class extends Error {
1652
1668
  constructor() {
@@ -1739,7 +1755,18 @@ async function handleSync(deps = {}) {
1739
1755
  }
1740
1756
  const body = await res.json().catch(() => ({}));
1741
1757
  const id = typeof body.id === "string" ? body.id : "";
1742
- return { id, url: `${endpoint}/${id}` };
1758
+ const result = { id, url: `${endpoint}/${id}` };
1759
+ if (deps.failOnFailures === true) result.gate = gateExit(await readVerdict(doFetch, `${endpoint}/${id}/summary`, token));
1760
+ return result;
1761
+ }
1762
+ async function readVerdict(doFetch, url, token) {
1763
+ try {
1764
+ const res = await doFetch(url, { method: "GET", headers: { authorization: `Bearer ${token}` } });
1765
+ const body = await res.json().catch(() => void 0);
1766
+ return res.ok ? { statusEvent: body?.run?.statusEvent, counts: body?.counts, notRun: body?.notRun } : void 0;
1767
+ } catch {
1768
+ return void 0;
1769
+ }
1743
1770
  }
1744
1771
  function reportSyncFailure(err, write) {
1745
1772
  if (err instanceof NotLoggedInError || err instanceof TokenRejectedError || err instanceof SyncFileError) {
@@ -1827,7 +1854,8 @@ async function defaultReadEvidence(dir) {
1827
1854
  if (!Array.isArray(raw)) return void 0;
1828
1855
  return raw.map((r) => {
1829
1856
  const rec = asRecord(r);
1830
- return { name: str(rec["name"]), passed: rec["passed"] === true };
1857
+ const error = str(rec["error"]);
1858
+ return { name: str(rec["name"]), passed: rec["passed"] === true, ...error !== "" ? { error } : {} };
1831
1859
  });
1832
1860
  }
1833
1861
  function casesFromDesign(report) {
@@ -1886,7 +1914,7 @@ async function readCairnRun(dir, deps = {}) {
1886
1914
  const verdicts = /* @__PURE__ */ new Map();
1887
1915
  for (const e of evidence ?? []) {
1888
1916
  if (verdicts.has(e.name)) throw new CairnEvidenceAmbiguousError(e.name);
1889
- verdicts.set(e.name, e.passed ? "passed" : "failed");
1917
+ verdicts.set(e.name, e.error !== void 0 ? "blocked" : e.passed ? "passed" : "failed");
1890
1918
  }
1891
1919
  cases = casesFromApi(report, verdicts);
1892
1920
  } else {
@@ -1100,21 +1100,26 @@ async function startRun(passed, declared) {
1100
1100
  },
1101
1101
  flush,
1102
1102
  async finish(reason) {
1103
- if (done) return;
1103
+ if (done) return void 0;
1104
1104
  done = true;
1105
1105
  await flush();
1106
1106
  await offer();
1107
+ let frozen;
1107
1108
  if (runId !== null && !offline) {
1108
1109
  const out = await client.post(`/v1/runs/${runId}/events`, {
1109
1110
  event: "finish",
1110
1111
  ...reason !== void 0 ? { reason } : {}
1111
1112
  });
1112
1113
  if (!out.ok) log(`plune: could not close run ${runId} (${out.detail || out.kind}).`);
1113
- else if (typeof out.body?.detached === "number" && out.body.detached > 0) {
1114
- log(`plune: ${out.body.detached} test case(s) marked detached \u2014 this full run no longer reports them.`);
1114
+ else {
1115
+ frozen = out.body?.summary ?? void 0;
1116
+ if (typeof out.body?.detached === "number" && out.body.detached > 0) {
1117
+ log(`plune: ${out.body.detached} test case(s) marked detached \u2014 this full run no longer reports them.`);
1118
+ }
1115
1119
  }
1116
1120
  }
1117
1121
  summarise();
1122
+ return frozen;
1118
1123
  },
1119
1124
  async leaveOpen() {
1120
1125
  if (done) return;
@@ -221,6 +221,17 @@ interface RunRecord {
221
221
  projectId?: string;
222
222
  schemaVersion?: number;
223
223
  }
224
+ /**
225
+ * What the platform froze into a run at `finish` and answers the event with (plune#1247): the verdict a
226
+ * release gate is made on, never recomputed. `counts` is by the status each result is counted under — a
227
+ * muted case's `failed` or `broken` is under `muted`, outside both — and an absent key is zero.
228
+ */
229
+ interface FrozenSummary {
230
+ counts: Record<string, number>;
231
+ /** The expected cases that never reported. */
232
+ notRun: string[];
233
+ total: number;
234
+ }
224
235
  /** Per-element verdicts from a batch. `conflict` means two runners disagreed about one result —
225
236
  * the platform refuses to resolve that by overwriting, and neither do we. */
226
237
  interface SubmitCounts {
@@ -357,8 +368,11 @@ interface RunSession {
357
368
  add(result: PendingResult): Promise<void>;
358
369
  /** Send whatever is buffered. Safe to call when nothing is. */
359
370
  flush(): Promise<void>;
360
- /** Send what is left and close the run. */
361
- finish(reason?: string): Promise<void>;
371
+ /**
372
+ * Send what is left and close the run. Resolves to the summary the platform froze at `finish` (plune#1247), or
373
+ * `undefined` when it answered with none: no run, a refused finish, a deployment older than the summary.
374
+ */
375
+ finish(reason?: string): Promise<FrozenSummary | undefined>;
362
376
  /** Send what is left and deliberately leave the run open — the shard case (AC-03). */
363
377
  leaveOpen(): Promise<void>;
364
378
  }
@@ -592,4 +606,4 @@ interface PlatformClient {
592
606
  }
593
607
  declare function createClient(opts: ClientOptions): PlatformClient;
594
608
 
595
- 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 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 };
609
+ 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 };
@@ -221,6 +221,17 @@ interface RunRecord {
221
221
  projectId?: string;
222
222
  schemaVersion?: number;
223
223
  }
224
+ /**
225
+ * What the platform froze into a run at `finish` and answers the event with (plune#1247): the verdict a
226
+ * release gate is made on, never recomputed. `counts` is by the status each result is counted under — a
227
+ * muted case's `failed` or `broken` is under `muted`, outside both — and an absent key is zero.
228
+ */
229
+ interface FrozenSummary {
230
+ counts: Record<string, number>;
231
+ /** The expected cases that never reported. */
232
+ notRun: string[];
233
+ total: number;
234
+ }
224
235
  /** Per-element verdicts from a batch. `conflict` means two runners disagreed about one result —
225
236
  * the platform refuses to resolve that by overwriting, and neither do we. */
226
237
  interface SubmitCounts {
@@ -357,8 +368,11 @@ interface RunSession {
357
368
  add(result: PendingResult): Promise<void>;
358
369
  /** Send whatever is buffered. Safe to call when nothing is. */
359
370
  flush(): Promise<void>;
360
- /** Send what is left and close the run. */
361
- finish(reason?: string): Promise<void>;
371
+ /**
372
+ * Send what is left and close the run. Resolves to the summary the platform froze at `finish` (plune#1247), or
373
+ * `undefined` when it answered with none: no run, a refused finish, a deployment older than the summary.
374
+ */
375
+ finish(reason?: string): Promise<FrozenSummary | undefined>;
362
376
  /** Send what is left and deliberately leave the run open — the shard case (AC-03). */
363
377
  leaveOpen(): Promise<void>;
364
378
  }
@@ -592,4 +606,4 @@ interface PlatformClient {
592
606
  }
593
607
  declare function createClient(opts: ClientOptions): PlatformClient;
594
608
 
595
- 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 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 };
609
+ 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 };
@@ -1051,21 +1051,26 @@ async function startRun(passed, declared) {
1051
1051
  },
1052
1052
  flush,
1053
1053
  async finish(reason) {
1054
- if (done) return;
1054
+ if (done) return void 0;
1055
1055
  done = true;
1056
1056
  await flush();
1057
1057
  await offer();
1058
+ let frozen;
1058
1059
  if (runId !== null && !offline) {
1059
1060
  const out = await client.post(`/v1/runs/${runId}/events`, {
1060
1061
  event: "finish",
1061
1062
  ...reason !== void 0 ? { reason } : {}
1062
1063
  });
1063
1064
  if (!out.ok) log(`plune: could not close run ${runId} (${out.detail || out.kind}).`);
1064
- else if (typeof out.body?.detached === "number" && out.body.detached > 0) {
1065
- log(`plune: ${out.body.detached} test case(s) marked detached \u2014 this full run no longer reports them.`);
1065
+ else {
1066
+ frozen = out.body?.summary ?? void 0;
1067
+ if (typeof out.body?.detached === "number" && out.body.detached > 0) {
1068
+ log(`plune: ${out.body.detached} test case(s) marked detached \u2014 this full run no longer reports them.`);
1069
+ }
1066
1070
  }
1067
1071
  }
1068
1072
  summarise();
1073
+ return frozen;
1069
1074
  },
1070
1075
  async leaveOpen() {
1071
1076
  if (done) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.18.0",
3
+ "version": "0.20.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",