@plune-ai/cli 0.7.0 → 0.9.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.
@@ -128,12 +128,7 @@ function createClient(opts) {
128
128
  }
129
129
 
130
130
  // src/reporter-core/env.ts
131
- var UNSUPPORTED_VARS = [
132
- "PLUNE_RUN_TITLE",
133
- "PLUNE_ENV",
134
- "PLUNE_LABELS",
135
- "PLUNE_GROUP"
136
- ];
131
+ var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
137
132
  function value(env, name) {
138
133
  const raw = env[name]?.trim();
139
134
  return raw === void 0 || raw === "" ? void 0 : raw;
@@ -148,19 +143,35 @@ function count(env, name) {
148
143
  const n = Number(raw);
149
144
  return Number.isInteger(n) && n > 0 ? n : void 0;
150
145
  }
146
+ function list(env, name) {
147
+ const raw = value(env, name);
148
+ if (raw === void 0) return void 0;
149
+ const items = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
150
+ return items.length === 0 ? void 0 : items;
151
+ }
151
152
  function readEnv(env = process.env) {
152
153
  const externalKey = value(env, "PLUNE_RUN");
153
154
  const token = value(env, "PLUNE_TOKEN");
154
155
  const apiUrl = value(env, "PLUNE_API_URL");
155
156
  const fallbackPath = value(env, "PLUNE_FALLBACK");
156
157
  const batchSize = count(env, "PLUNE_BATCH_SIZE");
158
+ const title = value(env, "PLUNE_RUN_TITLE");
159
+ const environment = value(env, "PLUNE_ENV");
160
+ const labels = list(env, "PLUNE_LABELS");
161
+ const offerDiscovered = flag(env, "PLUNE_CREATE");
157
162
  return {
158
163
  config: {
159
164
  ...externalKey !== void 0 ? { externalKey } : {},
160
165
  ...token !== void 0 ? { token } : {},
161
166
  ...apiUrl !== void 0 ? { apiUrl } : {},
162
167
  ...fallbackPath !== void 0 ? { fallbackPath } : {},
163
- ...batchSize !== void 0 ? { batchSize } : {}
168
+ ...batchSize !== void 0 ? { batchSize } : {},
169
+ ...title !== void 0 ? { title } : {},
170
+ ...environment !== void 0 ? { environment } : {},
171
+ ...labels !== void 0 ? { labels } : {},
172
+ // Only when set: `false` here would outrank a config file that asked for it, and an unset
173
+ // variable is not an instruction.
174
+ ...offerDiscovered ? { offerDiscovered } : {}
164
175
  },
165
176
  // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
166
177
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
@@ -189,6 +200,7 @@ function appendBatch(file, batch) {
189
200
  // src/reporter-core/session.ts
190
201
  var RESOLVE_CHUNK = 500;
191
202
  var BATCH_MAX = 500;
203
+ var DISCOVER_MAX = 500;
192
204
  var BATCH_DEFAULT = 100;
193
205
  var EXPECTED_MAX = 1e4;
194
206
  function chunk(items, size) {
@@ -196,6 +208,14 @@ function chunk(items, size) {
196
208
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
197
209
  return out;
198
210
  }
211
+ function warnIfDropped(sent, run, log) {
212
+ const names = Object.keys(sent);
213
+ if (names.length === 0) return;
214
+ if (names.some((name) => run[name] != null)) return;
215
+ log(
216
+ `plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
217
+ );
218
+ }
199
219
  async function startRun(passed, expected) {
200
220
  const env = readEnv();
201
221
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
@@ -205,6 +225,7 @@ async function startRun(passed, expected) {
205
225
  const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
206
226
  const externalKey = cfg.externalKey ?? null;
207
227
  const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
228
+ const offerDiscovered = cfg.offerDiscovered ?? false;
208
229
  const token = cfg.token ?? loadToken() ?? "";
209
230
  const client = createClient({
210
231
  apiUrl: resolveApiUrl(cfg.apiUrl),
@@ -217,10 +238,12 @@ async function startRun(passed, expected) {
217
238
  conflict: 0,
218
239
  rejected: 0,
219
240
  unresolved: 0,
241
+ offered: 0,
220
242
  deferred: 0
221
243
  };
222
244
  const resolved = /* @__PURE__ */ new Map();
223
245
  let buffer = [];
246
+ const discoveries = /* @__PURE__ */ new Map();
224
247
  let runId = null;
225
248
  let joined = false;
226
249
  let offline = token === "";
@@ -263,16 +286,23 @@ async function startRun(passed, expected) {
263
286
  if (expected !== void 0 && expected.length > EXPECTED_MAX) {
264
287
  log(`plune: ${expected.length} tests is more than the run configuration holds \u2014 reporting the first ${EXPECTED_MAX}.`);
265
288
  }
289
+ const described = {
290
+ ...cfg.title !== void 0 ? { title: cfg.title } : {},
291
+ ...cfg.environment !== void 0 ? { environment: cfg.environment } : {},
292
+ ...cfg.labels !== void 0 ? { labels: cfg.labels } : {}
293
+ };
266
294
  const start = await client.post("/v1/runs", {
267
295
  schemaVersion: 2,
268
296
  kind: cfg.kind ?? "automated",
269
297
  ...externalKey !== null ? { externalKey } : {},
270
298
  ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
271
- ...configuration !== void 0 ? { configuration } : {}
299
+ ...configuration !== void 0 ? { configuration } : {},
300
+ ...described
272
301
  });
273
302
  if (start.ok) {
274
303
  runId = start.body.run.id;
275
304
  joined = start.status === 200;
305
+ warnIfDropped(described, start.body.run, log);
276
306
  } else {
277
307
  log(`plune: could not start the run (${start.detail || start.kind}). Results will be written to ${fallbackPath}.`);
278
308
  }
@@ -287,6 +317,32 @@ async function startRun(passed, expected) {
287
317
  }
288
318
  return unanswered ? { kind: "unanswered" } : { kind: "unmatched" };
289
319
  }
320
+ function remember(result) {
321
+ if (!offerDiscovered) return;
322
+ if (result.title === void 0 || result.specRef === void 0) return;
323
+ if (result.keys.length === 0) return;
324
+ discoveries.set(result.keys[0].value, {
325
+ keys: result.keys,
326
+ title: result.title,
327
+ source: result.source,
328
+ specRef: result.specRef,
329
+ rawStatus: result.rawStatus
330
+ });
331
+ }
332
+ async function offer() {
333
+ if (discoveries.size === 0 || offline || runId === null) return;
334
+ for (const batch of chunk([...discoveries.values()], DISCOVER_MAX)) {
335
+ const out = await client.post("/v1/review-items/discovered", {
336
+ discovered: batch,
337
+ runId
338
+ });
339
+ if (!out.ok) {
340
+ log(`plune: could not offer ${batch.length} unknown tests for review (${out.detail || out.kind}).`);
341
+ return;
342
+ }
343
+ stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
344
+ }
345
+ }
290
346
  async function flush() {
291
347
  if (buffer.length === 0) return;
292
348
  const pending = buffer;
@@ -305,6 +361,7 @@ async function startRun(passed, expected) {
305
361
  submissions.push({ ...rest, testCaseId: match.testCaseId });
306
362
  } else if (match.kind === "unmatched") {
307
363
  stats.unresolved += 1;
364
+ remember(result);
308
365
  } else {
309
366
  unanswered.push(result);
310
367
  }
@@ -338,6 +395,7 @@ async function startRun(passed, expected) {
338
395
  if (stats.conflict > 0) parts.push(`${stats.conflict} conflicting`);
339
396
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
340
397
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
398
+ if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
341
399
  if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
342
400
  log(`plune: ${parts.join(" \xB7 ")}`);
343
401
  }
@@ -360,6 +418,7 @@ async function startRun(passed, expected) {
360
418
  if (done) return;
361
419
  done = true;
362
420
  await flush();
421
+ await offer();
363
422
  if (runId !== null && !offline) {
364
423
  const out = await client.post(`/v1/runs/${runId}/events`, {
365
424
  event: "finish",
@@ -373,6 +432,7 @@ async function startRun(passed, expected) {
373
432
  if (done) return;
374
433
  done = true;
375
434
  await flush();
435
+ await offer();
376
436
  if (runId !== null) {
377
437
  log(`plune: run ${runId} is still open \u2014 close it with "plune run finish ${runId}".`);
378
438
  }
@@ -88,6 +88,19 @@ interface PendingResult {
88
88
  rawStatus: string;
89
89
  /** What the author expected — `test.fail()` and friends. Absent means a pass was expected. */
90
90
  expectedStatus?: ResultStatus;
91
+ /**
92
+ * What to call this test, and where it lives — read ONLY when nothing resolved it (D14).
93
+ *
94
+ * A result that found its case needs neither: the case already has a title and a spec. These are
95
+ * for the other path, where the reporter offers the test to the review queue and the queue needs a
96
+ * name a person can judge and a spec a case can point at.
97
+ *
98
+ * Optional because an adapter that cannot say both simply does not participate. Deriving them from
99
+ * a key's internal spelling was the alternative and it is worse: it makes the core parse a string
100
+ * the adapter formatted, which is a contract nothing checks.
101
+ */
102
+ title?: string;
103
+ specRef?: string;
91
104
  execution?: Execution;
92
105
  errorContext?: string;
93
106
  params?: Record<string, unknown>;
@@ -121,8 +134,11 @@ interface SubmitCounts {
121
134
  /** What the session accumulated, for the one summary line a reporter prints. */
122
135
  interface RunStats extends SubmitCounts {
123
136
  /** Results whose keys matched no case — never sent, because inventing an id would write into
124
- * somebody else's history. What to do about them is C2's ladder. */
137
+ * somebody else's history. Rung 6 of C2's ladder is what to do about them: see `offered`. */
125
138
  unresolved: number;
139
+ /** Unresolved tests offered to the review queue under `PLUNE_CREATE=1` (D14). Counted separately
140
+ * from `unresolved` because they are the same tests seen twice, not a second population. */
141
+ offered: number;
126
142
  /** Results written to the fallback file instead of the platform. */
127
143
  deferred: number;
128
144
  }
@@ -136,10 +152,24 @@ interface ReporterConfig {
136
152
  externalKey?: string;
137
153
  kind?: RunKind;
138
154
  meta?: RunMeta;
155
+ /** What to call this run in a list. The first shard to declare one wins. */
156
+ title?: string;
157
+ /** Where it ran — staging, prod, a preview. What keeps two CI matrices from reading as one. */
158
+ environment?: string;
159
+ /** How the run is marked — smoke, nightly, a team. */
160
+ labels?: string[];
139
161
  /** Results per request. The platform's ceiling is 500. */
140
162
  batchSize?: number;
141
163
  /** Where unsent batches go. Defaults beside the run file the CLI already writes. */
142
164
  fallbackPath?: string;
165
+ /**
166
+ * Offer tests that resolved to nothing to the platform's review queue (`PLUNE_CREATE=1`, D14).
167
+ *
168
+ * Off by default, and that is the product decision rather than caution: a reporter that filled a
169
+ * stranger's review queue on first run would teach the team to ignore the queue. Turning it on is
170
+ * how somebody says "yes, tell me what we are running that you do not know about".
171
+ */
172
+ offerDiscovered?: boolean;
143
173
  /** Transport seam — the same one `sync.ts` uses so tests need no server. */
144
174
  fetchImpl?: typeof fetch;
145
175
  /** Where a line for the operator goes. Defaults to stderr. */
@@ -167,15 +197,6 @@ interface RunSession {
167
197
  /** Send what is left and deliberately leave the run open — the shard case (AC-03). */
168
198
  leaveOpen(): Promise<void>;
169
199
  }
170
- /**
171
- * Start a run, or join the one that already carries this key.
172
- *
173
- * `expected` — one list of candidate identifiers per test the runner knows about — is used twice
174
- * on purpose. It becomes `configuration.expected`, from which the platform derives what never ran
175
- * (AC-09); and it is the input to the single up-front resolve, so the per-test path afterwards is
176
- * a map lookup rather than a request (AC-10). A runner that cannot enumerate its tests passes
177
- * nothing and resolves lazily on the first flush — same function, later moment.
178
- */
179
200
  declare function startRun(passed: ReporterConfig, expected?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
180
201
 
181
202
  /**
@@ -209,14 +230,17 @@ declare function resultKey(testId: string, retry: number): string;
209
230
  * laptop the config was written on and the runner it executes on.
210
231
  */
211
232
  /**
212
- * Names the reporter promises but the platform cannot yet store (feature D13).
233
+ * Names the reporter promises but the platform cannot yet store.
213
234
  *
214
235
  * They are listed rather than ignored because of how they would fail otherwise: a run's `meta`
215
- * strips keys it does not know, so sending `PLUNE_ENV` there would be accepted, dropped, and look
216
- * exactly like success. A client believing it saved something the server discarded is the most
217
- * expensive kind of quiet — cheaper to say "not yet" out loud.
236
+ * strips keys it does not know, so sending one there would be accepted, dropped, and look exactly
237
+ * like success. A client believing it saved something the server discarded is the most expensive
238
+ * kind of quiet — cheaper to say "not yet" out loud.
239
+ *
240
+ * `PLUNE_RUN_TITLE`, `PLUNE_ENV` and `PLUNE_LABELS` left this list when D13 gave a run somewhere to
241
+ * put them. `PLUNE_GROUP` stays: a run group is D6's, and a group of one run means nothing.
218
242
  */
219
- declare const UNSUPPORTED_VARS: readonly ["PLUNE_RUN_TITLE", "PLUNE_ENV", "PLUNE_LABELS", "PLUNE_GROUP"];
243
+ declare const UNSUPPORTED_VARS: readonly ["PLUNE_GROUP"];
220
244
  interface EnvSettings {
221
245
  /** Config fields the environment supplied. Anything passed explicitly outranks these. */
222
246
  config: Partial<ReporterConfig>;
@@ -88,6 +88,19 @@ interface PendingResult {
88
88
  rawStatus: string;
89
89
  /** What the author expected — `test.fail()` and friends. Absent means a pass was expected. */
90
90
  expectedStatus?: ResultStatus;
91
+ /**
92
+ * What to call this test, and where it lives — read ONLY when nothing resolved it (D14).
93
+ *
94
+ * A result that found its case needs neither: the case already has a title and a spec. These are
95
+ * for the other path, where the reporter offers the test to the review queue and the queue needs a
96
+ * name a person can judge and a spec a case can point at.
97
+ *
98
+ * Optional because an adapter that cannot say both simply does not participate. Deriving them from
99
+ * a key's internal spelling was the alternative and it is worse: it makes the core parse a string
100
+ * the adapter formatted, which is a contract nothing checks.
101
+ */
102
+ title?: string;
103
+ specRef?: string;
91
104
  execution?: Execution;
92
105
  errorContext?: string;
93
106
  params?: Record<string, unknown>;
@@ -121,8 +134,11 @@ interface SubmitCounts {
121
134
  /** What the session accumulated, for the one summary line a reporter prints. */
122
135
  interface RunStats extends SubmitCounts {
123
136
  /** Results whose keys matched no case — never sent, because inventing an id would write into
124
- * somebody else's history. What to do about them is C2's ladder. */
137
+ * somebody else's history. Rung 6 of C2's ladder is what to do about them: see `offered`. */
125
138
  unresolved: number;
139
+ /** Unresolved tests offered to the review queue under `PLUNE_CREATE=1` (D14). Counted separately
140
+ * from `unresolved` because they are the same tests seen twice, not a second population. */
141
+ offered: number;
126
142
  /** Results written to the fallback file instead of the platform. */
127
143
  deferred: number;
128
144
  }
@@ -136,10 +152,24 @@ interface ReporterConfig {
136
152
  externalKey?: string;
137
153
  kind?: RunKind;
138
154
  meta?: RunMeta;
155
+ /** What to call this run in a list. The first shard to declare one wins. */
156
+ title?: string;
157
+ /** Where it ran — staging, prod, a preview. What keeps two CI matrices from reading as one. */
158
+ environment?: string;
159
+ /** How the run is marked — smoke, nightly, a team. */
160
+ labels?: string[];
139
161
  /** Results per request. The platform's ceiling is 500. */
140
162
  batchSize?: number;
141
163
  /** Where unsent batches go. Defaults beside the run file the CLI already writes. */
142
164
  fallbackPath?: string;
165
+ /**
166
+ * Offer tests that resolved to nothing to the platform's review queue (`PLUNE_CREATE=1`, D14).
167
+ *
168
+ * Off by default, and that is the product decision rather than caution: a reporter that filled a
169
+ * stranger's review queue on first run would teach the team to ignore the queue. Turning it on is
170
+ * how somebody says "yes, tell me what we are running that you do not know about".
171
+ */
172
+ offerDiscovered?: boolean;
143
173
  /** Transport seam — the same one `sync.ts` uses so tests need no server. */
144
174
  fetchImpl?: typeof fetch;
145
175
  /** Where a line for the operator goes. Defaults to stderr. */
@@ -167,15 +197,6 @@ interface RunSession {
167
197
  /** Send what is left and deliberately leave the run open — the shard case (AC-03). */
168
198
  leaveOpen(): Promise<void>;
169
199
  }
170
- /**
171
- * Start a run, or join the one that already carries this key.
172
- *
173
- * `expected` — one list of candidate identifiers per test the runner knows about — is used twice
174
- * on purpose. It becomes `configuration.expected`, from which the platform derives what never ran
175
- * (AC-09); and it is the input to the single up-front resolve, so the per-test path afterwards is
176
- * a map lookup rather than a request (AC-10). A runner that cannot enumerate its tests passes
177
- * nothing and resolves lazily on the first flush — same function, later moment.
178
- */
179
200
  declare function startRun(passed: ReporterConfig, expected?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
180
201
 
181
202
  /**
@@ -209,14 +230,17 @@ declare function resultKey(testId: string, retry: number): string;
209
230
  * laptop the config was written on and the runner it executes on.
210
231
  */
211
232
  /**
212
- * Names the reporter promises but the platform cannot yet store (feature D13).
233
+ * Names the reporter promises but the platform cannot yet store.
213
234
  *
214
235
  * They are listed rather than ignored because of how they would fail otherwise: a run's `meta`
215
- * strips keys it does not know, so sending `PLUNE_ENV` there would be accepted, dropped, and look
216
- * exactly like success. A client believing it saved something the server discarded is the most
217
- * expensive kind of quiet — cheaper to say "not yet" out loud.
236
+ * strips keys it does not know, so sending one there would be accepted, dropped, and look exactly
237
+ * like success. A client believing it saved something the server discarded is the most expensive
238
+ * kind of quiet — cheaper to say "not yet" out loud.
239
+ *
240
+ * `PLUNE_RUN_TITLE`, `PLUNE_ENV` and `PLUNE_LABELS` left this list when D13 gave a run somewhere to
241
+ * put them. `PLUNE_GROUP` stays: a run group is D6's, and a group of one run means nothing.
218
242
  */
219
- declare const UNSUPPORTED_VARS: readonly ["PLUNE_RUN_TITLE", "PLUNE_ENV", "PLUNE_LABELS", "PLUNE_GROUP"];
243
+ declare const UNSUPPORTED_VARS: readonly ["PLUNE_GROUP"];
220
244
  interface EnvSettings {
221
245
  /** Config fields the environment supplied. Anything passed explicitly outranks these. */
222
246
  config: Partial<ReporterConfig>;
@@ -85,12 +85,7 @@ function createClient(opts) {
85
85
  }
86
86
 
87
87
  // src/reporter-core/env.ts
88
- var UNSUPPORTED_VARS = [
89
- "PLUNE_RUN_TITLE",
90
- "PLUNE_ENV",
91
- "PLUNE_LABELS",
92
- "PLUNE_GROUP"
93
- ];
88
+ var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
94
89
  function value(env, name) {
95
90
  const raw = env[name]?.trim();
96
91
  return raw === void 0 || raw === "" ? void 0 : raw;
@@ -105,19 +100,35 @@ function count(env, name) {
105
100
  const n = Number(raw);
106
101
  return Number.isInteger(n) && n > 0 ? n : void 0;
107
102
  }
103
+ function list(env, name) {
104
+ const raw = value(env, name);
105
+ if (raw === void 0) return void 0;
106
+ const items = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
107
+ return items.length === 0 ? void 0 : items;
108
+ }
108
109
  function readEnv(env = process.env) {
109
110
  const externalKey = value(env, "PLUNE_RUN");
110
111
  const token = value(env, "PLUNE_TOKEN");
111
112
  const apiUrl = value(env, "PLUNE_API_URL");
112
113
  const fallbackPath = value(env, "PLUNE_FALLBACK");
113
114
  const batchSize = count(env, "PLUNE_BATCH_SIZE");
115
+ const title = value(env, "PLUNE_RUN_TITLE");
116
+ const environment = value(env, "PLUNE_ENV");
117
+ const labels = list(env, "PLUNE_LABELS");
118
+ const offerDiscovered = flag(env, "PLUNE_CREATE");
114
119
  return {
115
120
  config: {
116
121
  ...externalKey !== void 0 ? { externalKey } : {},
117
122
  ...token !== void 0 ? { token } : {},
118
123
  ...apiUrl !== void 0 ? { apiUrl } : {},
119
124
  ...fallbackPath !== void 0 ? { fallbackPath } : {},
120
- ...batchSize !== void 0 ? { batchSize } : {}
125
+ ...batchSize !== void 0 ? { batchSize } : {},
126
+ ...title !== void 0 ? { title } : {},
127
+ ...environment !== void 0 ? { environment } : {},
128
+ ...labels !== void 0 ? { labels } : {},
129
+ // Only when set: `false` here would outrank a config file that asked for it, and an unset
130
+ // variable is not an instruction.
131
+ ...offerDiscovered ? { offerDiscovered } : {}
121
132
  },
122
133
  // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
123
134
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
@@ -146,6 +157,7 @@ function appendBatch(file, batch) {
146
157
  // src/reporter-core/session.ts
147
158
  var RESOLVE_CHUNK = 500;
148
159
  var BATCH_MAX = 500;
160
+ var DISCOVER_MAX = 500;
149
161
  var BATCH_DEFAULT = 100;
150
162
  var EXPECTED_MAX = 1e4;
151
163
  function chunk(items, size) {
@@ -153,6 +165,14 @@ function chunk(items, size) {
153
165
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
154
166
  return out;
155
167
  }
168
+ function warnIfDropped(sent, run, log) {
169
+ const names = Object.keys(sent);
170
+ if (names.length === 0) return;
171
+ if (names.some((name) => run[name] != null)) return;
172
+ log(
173
+ `plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
174
+ );
175
+ }
156
176
  async function startRun(passed, expected) {
157
177
  const env = readEnv();
158
178
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
@@ -162,6 +182,7 @@ async function startRun(passed, expected) {
162
182
  const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
163
183
  const externalKey = cfg.externalKey ?? null;
164
184
  const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
185
+ const offerDiscovered = cfg.offerDiscovered ?? false;
165
186
  const token = cfg.token ?? loadToken() ?? "";
166
187
  const client = createClient({
167
188
  apiUrl: resolveApiUrl(cfg.apiUrl),
@@ -174,10 +195,12 @@ async function startRun(passed, expected) {
174
195
  conflict: 0,
175
196
  rejected: 0,
176
197
  unresolved: 0,
198
+ offered: 0,
177
199
  deferred: 0
178
200
  };
179
201
  const resolved = /* @__PURE__ */ new Map();
180
202
  let buffer = [];
203
+ const discoveries = /* @__PURE__ */ new Map();
181
204
  let runId = null;
182
205
  let joined = false;
183
206
  let offline = token === "";
@@ -220,16 +243,23 @@ async function startRun(passed, expected) {
220
243
  if (expected !== void 0 && expected.length > EXPECTED_MAX) {
221
244
  log(`plune: ${expected.length} tests is more than the run configuration holds \u2014 reporting the first ${EXPECTED_MAX}.`);
222
245
  }
246
+ const described = {
247
+ ...cfg.title !== void 0 ? { title: cfg.title } : {},
248
+ ...cfg.environment !== void 0 ? { environment: cfg.environment } : {},
249
+ ...cfg.labels !== void 0 ? { labels: cfg.labels } : {}
250
+ };
223
251
  const start = await client.post("/v1/runs", {
224
252
  schemaVersion: 2,
225
253
  kind: cfg.kind ?? "automated",
226
254
  ...externalKey !== null ? { externalKey } : {},
227
255
  ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
228
- ...configuration !== void 0 ? { configuration } : {}
256
+ ...configuration !== void 0 ? { configuration } : {},
257
+ ...described
229
258
  });
230
259
  if (start.ok) {
231
260
  runId = start.body.run.id;
232
261
  joined = start.status === 200;
262
+ warnIfDropped(described, start.body.run, log);
233
263
  } else {
234
264
  log(`plune: could not start the run (${start.detail || start.kind}). Results will be written to ${fallbackPath}.`);
235
265
  }
@@ -244,6 +274,32 @@ async function startRun(passed, expected) {
244
274
  }
245
275
  return unanswered ? { kind: "unanswered" } : { kind: "unmatched" };
246
276
  }
277
+ function remember(result) {
278
+ if (!offerDiscovered) return;
279
+ if (result.title === void 0 || result.specRef === void 0) return;
280
+ if (result.keys.length === 0) return;
281
+ discoveries.set(result.keys[0].value, {
282
+ keys: result.keys,
283
+ title: result.title,
284
+ source: result.source,
285
+ specRef: result.specRef,
286
+ rawStatus: result.rawStatus
287
+ });
288
+ }
289
+ async function offer() {
290
+ if (discoveries.size === 0 || offline || runId === null) return;
291
+ for (const batch of chunk([...discoveries.values()], DISCOVER_MAX)) {
292
+ const out = await client.post("/v1/review-items/discovered", {
293
+ discovered: batch,
294
+ runId
295
+ });
296
+ if (!out.ok) {
297
+ log(`plune: could not offer ${batch.length} unknown tests for review (${out.detail || out.kind}).`);
298
+ return;
299
+ }
300
+ stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
301
+ }
302
+ }
247
303
  async function flush() {
248
304
  if (buffer.length === 0) return;
249
305
  const pending = buffer;
@@ -262,6 +318,7 @@ async function startRun(passed, expected) {
262
318
  submissions.push({ ...rest, testCaseId: match.testCaseId });
263
319
  } else if (match.kind === "unmatched") {
264
320
  stats.unresolved += 1;
321
+ remember(result);
265
322
  } else {
266
323
  unanswered.push(result);
267
324
  }
@@ -295,6 +352,7 @@ async function startRun(passed, expected) {
295
352
  if (stats.conflict > 0) parts.push(`${stats.conflict} conflicting`);
296
353
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
297
354
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
355
+ if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
298
356
  if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
299
357
  log(`plune: ${parts.join(" \xB7 ")}`);
300
358
  }
@@ -317,6 +375,7 @@ async function startRun(passed, expected) {
317
375
  if (done) return;
318
376
  done = true;
319
377
  await flush();
378
+ await offer();
320
379
  if (runId !== null && !offline) {
321
380
  const out = await client.post(`/v1/runs/${runId}/events`, {
322
381
  event: "finish",
@@ -330,6 +389,7 @@ async function startRun(passed, expected) {
330
389
  if (done) return;
331
390
  done = true;
332
391
  await flush();
392
+ await offer();
333
393
  if (runId !== null) {
334
394
  log(`plune: run ${runId} is still open \u2014 close it with "plune run finish ${runId}".`);
335
395
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",