@plune-ai/cli 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -1672,18 +1672,18 @@ var init_judge = __esm({
1672
1672
  });
1673
1673
 
1674
1674
  // src/orchestrator/concurrency.ts
1675
- async function mapLimit(items, limit, fn) {
1676
- const results = new Array(items.length);
1675
+ async function mapLimit(items2, limit, fn) {
1676
+ const results = new Array(items2.length);
1677
1677
  let next = 0;
1678
1678
  const worker = async () => {
1679
- while (next < items.length) {
1679
+ while (next < items2.length) {
1680
1680
  const i = next;
1681
1681
  next += 1;
1682
- results[i] = await fn(items[i], i);
1682
+ results[i] = await fn(items2[i], i);
1683
1683
  }
1684
1684
  };
1685
1685
  const safeLimit = Number.isFinite(limit) && limit >= 1 ? Math.floor(limit) : 1;
1686
- const workerCount = Math.min(safeLimit, items.length);
1686
+ const workerCount = Math.min(safeLimit, items2.length);
1687
1687
  await Promise.all(Array.from({ length: workerCount }, () => worker()));
1688
1688
  return results;
1689
1689
  }
@@ -1991,7 +1991,7 @@ async function runOrchestration(config, options, deps) {
1991
1991
  throw new RunConfigError("--no-cache and --cache-max-age cannot be used together");
1992
1992
  }
1993
1993
  const started = deps.now();
1994
- const dryRun = options.dryRun ?? false;
1994
+ const dryRun2 = options.dryRun ?? false;
1995
1995
  const evalResults = [];
1996
1996
  const prepared = selectEvals(config.evals, options.only).map((ev) => {
1997
1997
  const template = resolveTemplate(ev, deps.baseDir);
@@ -2017,7 +2017,7 @@ async function runOrchestration(config, options, deps) {
2017
2017
  embedder: deps.embedder,
2018
2018
  cache: deps.cache,
2019
2019
  now: deps.now,
2020
- dryRun,
2020
+ dryRun: dryRun2,
2021
2021
  noCache,
2022
2022
  cacheStrict,
2023
2023
  ...options.cacheMaxAgeMs !== void 0 ? { cacheMaxAgeMs: options.cacheMaxAgeMs } : {}
@@ -2143,18 +2143,18 @@ __export(run_exports, {
2143
2143
  function isMockMode(env) {
2144
2144
  return env["PLUNE_MOCK_PROVIDER"] === "1";
2145
2145
  }
2146
- function resolveProviderFor(config, dryRun) {
2146
+ function resolveProviderFor(config, dryRun2) {
2147
2147
  if (isMockMode(process.env)) return () => makeMockProvider();
2148
- if (dryRun) return (cfg) => makeEstimateProvider(cfg, config.pricing);
2148
+ if (dryRun2) return (cfg) => makeEstimateProvider(cfg, config.pricing);
2149
2149
  return (cfg) => getProvider(cfg, process.env, config.pricing);
2150
2150
  }
2151
- function buildRealDeps(config, baseDir, dryRun) {
2151
+ function buildRealDeps(config, baseDir, dryRun2) {
2152
2152
  const dir = path5.join(baseDir, ".plune");
2153
2153
  fs5.mkdirSync(dir, { recursive: true });
2154
2154
  return {
2155
- resolveProvider: resolveProviderFor(config, dryRun),
2155
+ resolveProvider: resolveProviderFor(config, dryRun2),
2156
2156
  embedder: getDefaultEmbedder(),
2157
- cache: dryRun ? NOOP_CACHE : openCache(path5.join(dir, "cache.db")),
2157
+ cache: dryRun2 ? NOOP_CACHE : openCache(path5.join(dir, "cache.db")),
2158
2158
  now: Date.now,
2159
2159
  loadDataset,
2160
2160
  baseDir
@@ -2225,8 +2225,8 @@ function wrap(code, s) {
2225
2225
  }
2226
2226
  function createStyler(color) {
2227
2227
  if (!color) {
2228
- const id = (s) => s;
2229
- return { green: id, red: id, yellow: id, dim: id, bold: id };
2228
+ const id2 = (s) => s;
2229
+ return { green: id2, red: id2, yellow: id2, dim: id2, bold: id2 };
2230
2230
  }
2231
2231
  return {
2232
2232
  green: (s) => wrap(CODE.green, s),
@@ -2596,7 +2596,7 @@ function createClient(opts) {
2596
2596
  }
2597
2597
  const base = opts.apiUrl.replace(/\/+$/, "");
2598
2598
  const scrub = (text2) => opts.token === "" ? text2 : text2.split(opts.token).join("***");
2599
- async function send(method, path21, payload, retries = DEFAULT_RETRIES) {
2599
+ async function send(method, path22, payload, retries = DEFAULT_RETRIES) {
2600
2600
  let last = {
2601
2601
  kind: "unavailable",
2602
2602
  status: null,
@@ -2607,7 +2607,7 @@ function createClient(opts) {
2607
2607
  await respectPause(mine);
2608
2608
  let res;
2609
2609
  try {
2610
- res = await doFetch(`${base}${path21}`, {
2610
+ res = await doFetch(`${base}${path22}`, {
2611
2611
  method,
2612
2612
  headers: { "content-type": payload?.type ?? "application/json", authorization: `Bearer ${opts.token}` },
2613
2613
  // A DELETE carries none. Sending `"undefined"` as a body is what a naive
@@ -2646,10 +2646,10 @@ function createClient(opts) {
2646
2646
  return { ok: false, ...last };
2647
2647
  }
2648
2648
  return {
2649
- post: (path21, body, retries) => send("POST", path21, { body: JSON.stringify(body), type: "application/json" }, retries),
2650
- upload: (path21, bytes, contentType) => send("POST", path21, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
2651
- del: (path21) => send("DELETE", path21),
2652
- get: (path21) => send("GET", path21),
2649
+ post: (path22, body, retries) => send("POST", path22, { body: JSON.stringify(body), type: "application/json" }, retries),
2650
+ upload: (path22, bytes, contentType) => send("POST", path22, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
2651
+ del: (path22) => send("DELETE", path22),
2652
+ get: (path22) => send("GET", path22),
2653
2653
  async put(url, headers, source) {
2654
2654
  const putRetries = uploadRetries ?? 3;
2655
2655
  let last = { ok: false, status: null, code: "network" };
@@ -2763,8 +2763,8 @@ function bounded(env, name, range, warnings) {
2763
2763
  function list(env, name) {
2764
2764
  const raw = value(env, name);
2765
2765
  if (raw === void 0) return void 0;
2766
- const items = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
2767
- return items.length === 0 ? void 0 : items;
2766
+ const items2 = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
2767
+ return items2.length === 0 ? void 0 : items2;
2768
2768
  }
2769
2769
  function readEnv(env = process.env) {
2770
2770
  const externalKey = value(env, "PLUNE_RUN");
@@ -2930,12 +2930,12 @@ function machineless(raw, repoRoot, home) {
2930
2930
  if (repoRoot === void 0) text2 = text2.replace(ANY_HOME, "~");
2931
2931
  return text2;
2932
2932
  }
2933
- function pathIn(path21) {
2934
- const parts = path21.split(/[\\/]+/);
2933
+ function pathIn(path22) {
2934
+ const parts = path22.split(/[\\/]+/);
2935
2935
  while (parts.length > 1 && parts.at(-1) === "") parts.pop();
2936
2936
  if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
2937
2937
  const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
2938
- const escaped = /^\/(?!\/)/.test(path21) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
2938
+ const escaped = /^\/(?!\/)/.test(path22) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
2939
2939
  const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
2940
2940
  return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
2941
2941
  }
@@ -2981,12 +2981,12 @@ function placeOf(error, testFile) {
2981
2981
  }
2982
2982
  function fileOf(raw) {
2983
2983
  if (!raw.startsWith("file://")) return raw;
2984
- let path21 = raw.slice("file://".length);
2984
+ let path22 = raw.slice("file://".length);
2985
2985
  try {
2986
- path21 = decodeURIComponent(path21);
2986
+ path22 = decodeURIComponent(path22);
2987
2987
  } catch {
2988
2988
  }
2989
- return /^\/[A-Za-z]:\//.test(path21) ? path21.slice(1) : path21;
2989
+ return /^\/[A-Za-z]:\//.test(path22) ? path22.slice(1) : path22;
2990
2990
  }
2991
2991
  function fromRoot(place, root) {
2992
2992
  if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
@@ -3026,14 +3026,14 @@ var init_failure_detail = __esm({
3026
3026
  FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
3027
3027
  FRAME_MAX = 8192;
3028
3028
  firstLine = (text2) => (0, import_node_util.stripVTControlCharacters)(text2).split("\n").map((line) => line.trim()).find((line) => line !== "");
3029
- normal = (path21) => import_node_path2.posix.normalize(path21.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
3029
+ normal = (path22) => import_node_path2.posix.normalize(path22.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
3030
3030
  }
3031
3031
  });
3032
3032
 
3033
3033
  // src/reporter-core/files-batch.ts
3034
- async function sha256File(path21) {
3034
+ async function sha256File(path22) {
3035
3035
  const hash = (0, import_node_crypto2.createHash)("sha256");
3036
- for await (const part of (0, import_node_fs4.createReadStream)(path21)) hash.update(part);
3036
+ for await (const part of (0, import_node_fs4.createReadStream)(path22)) hash.update(part);
3037
3037
  return hash.digest("hex");
3038
3038
  }
3039
3039
  var import_node_crypto2, import_node_fs4, FILES_BATCH_MAX, DIRECT_BATCH_BYTES, BATCH_PIPELINES, sha256Bytes, BUCKET_CODES;
@@ -3140,9 +3140,9 @@ function packed(results) {
3140
3140
  if (part.length > 0) out.push(part);
3141
3141
  return out;
3142
3142
  }
3143
- function chunk(items, size) {
3143
+ function chunk(items2, size) {
3144
3144
  const out = [];
3145
- for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
3145
+ for (let i = 0; i < items2.length; i += size) out.push(items2.slice(i, i + size));
3146
3146
  return out;
3147
3147
  }
3148
3148
  function warnIfDropped(sent, run, log) {
@@ -3645,15 +3645,15 @@ async function startRun(passed, declared2) {
3645
3645
  await complete(toComplete);
3646
3646
  await sendDirect(directs);
3647
3647
  }
3648
- async function complete(items) {
3649
- for (let round = 0; items.length > 0; round += 1) {
3648
+ async function complete(items2) {
3649
+ for (let round = 0; items2.length > 0; round += 1) {
3650
3650
  const out = await gated(() => {
3651
3651
  requests += 1;
3652
- return client.post("/v1/files/complete-batch", { ids: items.map((i) => i.id) }, uploadRetries);
3652
+ return client.post("/v1/files/complete-batch", { ids: items2.map((i) => i.id) }, uploadRetries);
3653
3653
  });
3654
3654
  if (routeMissing(out)) {
3655
3655
  await Promise.all(
3656
- items.map(
3656
+ items2.map(
3657
3657
  (i) => gated(async () => {
3658
3658
  requests += 1;
3659
3659
  const one = await client.post(`/v1/files/${encodeURIComponent(i.id)}/complete`, {}, uploadRetries);
@@ -3665,28 +3665,28 @@ async function startRun(passed, declared2) {
3665
3665
  return;
3666
3666
  }
3667
3667
  const verdicts = out.ok ? out.body?.items : void 0;
3668
- if (!out.ok || !Array.isArray(verdicts) || verdicts.length !== items.length) {
3669
- for (const i of items) failedToBucket(i.job, out.ok ? "bad_answer" : codeOf2(out));
3668
+ if (!out.ok || !Array.isArray(verdicts) || verdicts.length !== items2.length) {
3669
+ for (const i of items2) failedToBucket(i.job, out.ok ? "bad_answer" : codeOf2(out));
3670
3670
  return;
3671
3671
  }
3672
3672
  const again = [];
3673
- items.forEach((i, at) => {
3673
+ items2.forEach((i, at) => {
3674
3674
  const v = verdicts[at] ?? {};
3675
3675
  if (v.status === "ready") countsOf(i.job.kind).uploaded += 1;
3676
3676
  else if (v.retryable === true && round < Math.min(uploadRetries, 3)) again.push(i);
3677
3677
  else refusedItem(i.job, 0, v.status === "refused" ? v : { code: "bad_answer" });
3678
3678
  });
3679
- items = again;
3680
- if (items.length > 0) {
3681
- retried += items.length;
3679
+ items2 = again;
3680
+ if (items2.length > 0) {
3681
+ retried += items2.length;
3682
3682
  await itemPause(round);
3683
3683
  }
3684
3684
  }
3685
3685
  }
3686
- async function sendDirect(items) {
3686
+ async function sendDirect(items2) {
3687
3687
  const groups = [];
3688
3688
  let bytes = 0;
3689
- for (const item of items) {
3689
+ for (const item of items2) {
3690
3690
  const group = groups[groups.length - 1];
3691
3691
  if (group === void 0 || group.length >= FILES_BATCH_MAX || bytes + item.size > DIRECT_BATCH_BYTES) {
3692
3692
  groups.push([item]);
@@ -3752,10 +3752,10 @@ async function startRun(passed, declared2) {
3752
3752
  })
3753
3753
  );
3754
3754
  }
3755
- async function upload(items, filesByKey) {
3756
- if (filesByKey.size === 0 || !Array.isArray(items)) return;
3755
+ async function upload(items2, filesByKey) {
3756
+ if (filesByKey.size === 0 || !Array.isArray(items2)) return;
3757
3757
  const jobs = [];
3758
- for (const item of items) {
3758
+ for (const item of items2) {
3759
3759
  if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
3760
3760
  for (const file of filesByKey.get(item.resultKey) ?? []) {
3761
3761
  const type = mediaTypeOf(file.contentType) ?? (file.path === void 0 ? void 0 : TYPE_BY_EXTENSION[(0, import_node_path3.extname)(file.path).toLowerCase()]);
@@ -4110,9 +4110,9 @@ function connect(deps) {
4110
4110
  `))
4111
4111
  };
4112
4112
  }
4113
- function runUrl(apiUrl, id) {
4113
+ function runUrl(apiUrl, id2) {
4114
4114
  const web = dashboardUrl(apiUrl);
4115
- return web === void 0 ? `${apiUrl}/v1/runs/${id}` : `${web}/runs/${id}`;
4115
+ return web === void 0 ? `${apiUrl}/v1/runs/${id2}` : `${web}/runs/${id2}`;
4116
4116
  }
4117
4117
  async function handleRunStart(options = {}) {
4118
4118
  const { client, apiUrl, write } = connect(options);
@@ -4140,39 +4140,39 @@ async function handleRunStart(options = {}) {
4140
4140
  }
4141
4141
  return result;
4142
4142
  }
4143
- async function handleRunFinish(id, options = {}, runnerCode) {
4143
+ async function handleRunFinish(id2, options = {}, runnerCode) {
4144
4144
  const { client, write } = connect(options);
4145
4145
  const event = options.terminate === true ? "terminate" : "finish";
4146
- const out = await client.post(`/v1/runs/${id}/events`, {
4146
+ const out = await client.post(`/v1/runs/${id2}/events`, {
4147
4147
  event,
4148
4148
  ...options.reason !== void 0 ? { reason: options.reason } : {}
4149
4149
  });
4150
4150
  if (!out.ok) {
4151
4151
  throw new RunCommandError(
4152
- out.status === 404 ? `No run '${id}' \u2014 check the id, or whether the token belongs to the same project.` : `Could not ${event} run ${id} \u2014 ${out.detail || out.kind}.`
4152
+ out.status === 404 ? `No run '${id2}' \u2014 check the id, or whether the token belongs to the same project.` : `Could not ${event} run ${id2} \u2014 ${out.detail || out.kind}.`
4153
4153
  );
4154
4154
  }
4155
- write(`Run ${id} ${event === "finish" ? "finished" : "terminated"}.`);
4155
+ write(`Run ${id2} ${event === "finish" ? "finished" : "terminated"}.`);
4156
4156
  if (options.failOnFailures !== true) return runnerCode ?? 0;
4157
4157
  const summary = out.body?.summary;
4158
4158
  const gate = gateExit({ statusEvent: out.body?.run?.statusEvent, counts: summary?.counts, notRun: summary?.notRun }, runnerCode);
4159
4159
  write(gate.line);
4160
4160
  return gate.code;
4161
4161
  }
4162
- async function handleRunDelete(id, options = {}) {
4162
+ async function handleRunDelete(id2, options = {}) {
4163
4163
  const { client, write } = connect(options);
4164
- const out = await client.del(`/v1/runs/${encodeURIComponent(id)}`);
4164
+ const out = await client.del(`/v1/runs/${encodeURIComponent(id2)}`);
4165
4165
  if (!out.ok) {
4166
4166
  throw new RunCommandError(
4167
4167
  out.status === 404 ? (
4168
4168
  // Three causes, one answer, and the message names all three because the platform will not:
4169
4169
  // a 404 that distinguished "not yours" from "not there" would tell a stranger which ids
4170
4170
  // exist. The person holding the terminal is better served by the list than by a guess.
4171
- `No run '${id}' \u2014 check the id, whether the token belongs to the same project, or whether it is already deleted.`
4172
- ) : `Could not delete run ${id} \u2014 ${out.detail || out.kind}.`
4171
+ `No run '${id2}' \u2014 check the id, whether the token belongs to the same project, or whether it is already deleted.`
4172
+ ) : `Could not delete run ${id2} \u2014 ${out.detail || out.kind}.`
4173
4173
  );
4174
4174
  }
4175
- write(`Run ${id} deleted, with its results and the review-queue entries it produced.`);
4175
+ write(`Run ${id2} deleted, with its results and the review-queue entries it produced.`);
4176
4176
  write("Recoverable for 30 days \u2014 an owner of the project can put it back from Settings \u2192 Bin.");
4177
4177
  }
4178
4178
  async function handleRunExec(options) {
@@ -4456,11 +4456,11 @@ var init_job_reporter = __esm({
4456
4456
  }
4457
4457
  async finish(outcome) {
4458
4458
  if (this.id === void 0) return;
4459
- const id = this.id;
4459
+ const id2 = this.id;
4460
4460
  this.id = void 0;
4461
4461
  await this.send(
4462
4462
  "PATCH",
4463
- `/${encodeURIComponent(id)}/state`,
4463
+ `/${encodeURIComponent(id2)}/state`,
4464
4464
  outcome.ok ? { state: "completed" } : { state: "failed", reason: outcome.reason.slice(0, JOB_REASON_MAX) }
4465
4465
  );
4466
4466
  }
@@ -4552,11 +4552,11 @@ async function handleSync(deps = {}) {
4552
4552
  throw new SyncHttpError(res.status, detail);
4553
4553
  }
4554
4554
  const body = await res.json().catch(() => ({}));
4555
- const id = typeof body.id === "string" ? body.id : "";
4556
- const result = { id, url: `${endpoint}/${id}` };
4557
- await job.uploaded({ cursor: id || void 0 });
4555
+ const id2 = typeof body.id === "string" ? body.id : "";
4556
+ const result = { id: id2, url: `${endpoint}/${id2}` };
4557
+ await job.uploaded({ cursor: id2 || void 0 });
4558
4558
  if (deps.failOnFailures === true) {
4559
- result.gate = gateExit(await readVerdict(doFetch, `${endpoint}/${id}/summary`, bearer));
4559
+ result.gate = gateExit(await readVerdict(doFetch, `${endpoint}/${id2}/summary`, bearer));
4560
4560
  }
4561
4561
  return result;
4562
4562
  }
@@ -4666,10 +4666,10 @@ function platformGet(deps) {
4666
4666
  const token = (deps.loadToken ?? loadToken)();
4667
4667
  if (token === null) throw new NotLoggedInError();
4668
4668
  const doFetch = deps.fetchImpl ?? fetch;
4669
- return async (path21) => {
4669
+ return async (path22) => {
4670
4670
  let res;
4671
4671
  try {
4672
- res = await doFetch(`${apiUrl}${path21}`, { headers: { authorization: `Bearer ${token}` } });
4672
+ res = await doFetch(`${apiUrl}${path22}`, { headers: { authorization: `Bearer ${token}` } });
4673
4673
  } catch {
4674
4674
  throw new SyncNetworkError(apiUrl);
4675
4675
  }
@@ -5024,9 +5024,9 @@ var init_read_file = __esm({
5024
5024
  });
5025
5025
 
5026
5026
  // src/importers/allure.ts
5027
- function isFolder(path21) {
5027
+ function isFolder(path22) {
5028
5028
  try {
5029
- return fs12.statSync(path21).isDirectory();
5029
+ return fs12.statSync(path22).isDirectory();
5030
5030
  } catch {
5031
5031
  return false;
5032
5032
  }
@@ -5127,8 +5127,8 @@ function readAllure(dir, missing = [], notes = [], maxBytes = MAX_JSON_BYTES) {
5127
5127
  const ctx = { folder, lexical, missing, notes, around, direct };
5128
5128
  const groups = /* @__PURE__ */ new Map();
5129
5129
  tests.forEach((r, i) => {
5130
- const id = textOf(r.historyId);
5131
- const g = id === "" ? `\0${i}` : id;
5130
+ const id2 = textOf(r.historyId);
5131
+ const g = id2 === "" ? `\0${i}` : id2;
5132
5132
  const list2 = groups.get(g);
5133
5133
  if (list2 === void 0) groups.set(g, [r]);
5134
5134
  else list2.push(r);
@@ -5540,10 +5540,10 @@ function attemptOf(result, file, ctx) {
5540
5540
  ...typeof at?.file === "string" && typeof at.line === "number" ? { location: { file: at.file, line: at.line, ...typeof at.column === "number" ? { column: at.column } : {} } } : {}
5541
5541
  })),
5542
5542
  steps: steps(result.steps),
5543
- attachments: (result.attachments ?? []).map(({ name, contentType, path: path21, body }) => ({
5543
+ attachments: (result.attachments ?? []).map(({ name, contentType, path: path22, body }) => ({
5544
5544
  name: name ?? "",
5545
5545
  ...contentType !== void 0 ? { contentType } : {},
5546
- ...path21 !== void 0 ? { path: path21 } : {},
5546
+ ...path22 !== void 0 ? { path: path22 } : {},
5547
5547
  // Decoded only where the platform would take it (plune-ai/plune#928): a report of screenshots kept as
5548
5548
  // bodies is megabytes of base64 that nothing here will read.
5549
5549
  ...typeof body === "string" && isTextType(contentType) ? { body: Buffer.from(body, "base64") } : {}
@@ -5783,25 +5783,25 @@ function readLighthouse(source, file, minScores = {}) {
5783
5783
  const specRef = path14.basename(file);
5784
5784
  const known = Object.entries(categories).filter(([, raw]) => isObject2(raw)).map(([key, raw]) => idOf(key, raw));
5785
5785
  if (known.length > 0) {
5786
- for (const [id, score] of Object.entries(minScores)) {
5787
- if (!known.includes(id)) {
5786
+ for (const [id2, score] of Object.entries(minScores)) {
5787
+ if (!known.includes(id2)) {
5788
5788
  throw new JsonReportError(
5789
5789
  file,
5790
- `--min-score ${printable(id)}=${score} names a category the report does not have; it has ${known.map(printable).join(", ")}.`
5790
+ `--min-score ${printable(id2)}=${score} names a category the report does not have; it has ${known.map(printable).join(", ")}.`
5791
5791
  );
5792
5792
  }
5793
5793
  }
5794
5794
  }
5795
5795
  const out = [];
5796
- const result = (id, passed, message) => {
5797
- const value2 = `${SOURCE5}#${url ?? ""}#${formFactor !== void 0 ? `${formFactor}#` : ""}${id}`;
5796
+ const result = (id2, passed, message) => {
5797
+ const value2 = `${SOURCE5}#${url ?? ""}#${formFactor !== void 0 ? `${formFactor}#` : ""}${id2}`;
5798
5798
  const where = [url, formFactor].filter((part) => part !== void 0);
5799
5799
  out.push({
5800
5800
  resultKey: resultKey(value2, 0),
5801
5801
  keys: [{ kind: "path-title", value: value2 }],
5802
5802
  source: SOURCE5,
5803
5803
  rawStatus: passed ? "passed" : "failed",
5804
- title: where.length > 0 ? `${id} (${where.join(", ")})` : id,
5804
+ title: where.length > 0 ? `${id2} (${where.join(", ")})` : id2,
5805
5805
  specRef,
5806
5806
  ...execution !== void 0 ? { execution } : {},
5807
5807
  errorContext: message
@@ -5810,9 +5810,9 @@ function readLighthouse(source, file, minScores = {}) {
5810
5810
  for (const [key, raw] of Object.entries(categories)) {
5811
5811
  if (!isObject2(raw)) continue;
5812
5812
  const category = raw;
5813
- const id = idOf(key, category);
5814
- const name = typeof category.title === "string" && category.title !== "" ? category.title : id;
5815
- const bar = (Object.hasOwn(minScores, id) ? minScores[id] : void 0) ?? DEFAULT_MIN_SCORE;
5813
+ const id2 = idOf(key, category);
5814
+ const name = typeof category.title === "string" && category.title !== "" ? category.title : id2;
5815
+ const bar = (Object.hasOwn(minScores, id2) ? minScores[id2] : void 0) ?? DEFAULT_MIN_SCORE;
5816
5816
  let passed = false;
5817
5817
  let message;
5818
5818
  if (runtimeError !== void 0) {
@@ -5824,7 +5824,7 @@ function readLighthouse(source, file, minScores = {}) {
5824
5824
  passed = shown >= bar;
5825
5825
  message = `Lighthouse ${name} score: ${shown}, ${passed ? "at least" : "below"} the ${bar} needed to pass.`;
5826
5826
  }
5827
- result(id, passed, message);
5827
+ result(id2, passed, message);
5828
5828
  }
5829
5829
  if (out.length === 0 && runtimeError !== void 0) result("run", false, runtimeError);
5830
5830
  if (out.length === 0) {
@@ -6103,9 +6103,9 @@ async function handleRunImport(options) {
6103
6103
  for (const line of session.shardLines) write(line);
6104
6104
  return result;
6105
6105
  }
6106
- function runUrl2(apiUrl, id) {
6106
+ function runUrl2(apiUrl, id2) {
6107
6107
  const web = dashboardUrl(apiUrl);
6108
- return web === void 0 ? `${apiUrl}/v1/runs/${id}` : `${web}/runs/${id}`;
6108
+ return web === void 0 ? `${apiUrl}/v1/runs/${id2}` : `${web}/runs/${id2}`;
6109
6109
  }
6110
6110
  function describe(result, apiUrl) {
6111
6111
  const lines = [
@@ -6247,10 +6247,10 @@ function diffRuns(baseline, current) {
6247
6247
  const baseMap = new Map(baseline.evals.map((e) => [e.id, evalStatus(e)]));
6248
6248
  const curMap = new Map(current.evals.map((e) => [e.id, evalStatus(e)]));
6249
6249
  const ids = [.../* @__PURE__ */ new Set([...baseMap.keys(), ...curMap.keys()])];
6250
- const evals = ids.map((id) => {
6251
- const baselineP = baseMap.get(id) ?? "absent";
6252
- const currentP = curMap.get(id) ?? "absent";
6253
- return { id, status: classify2(baselineP, currentP), baseline: baselineP, current: currentP };
6250
+ const evals = ids.map((id2) => {
6251
+ const baselineP = baseMap.get(id2) ?? "absent";
6252
+ const currentP = curMap.get(id2) ?? "absent";
6253
+ return { id: id2, status: classify2(baselineP, currentP), baseline: baselineP, current: currentP };
6254
6254
  });
6255
6255
  const count2 = (s) => evals.filter((e) => e.status === s).length;
6256
6256
  const summary = {
@@ -6689,6 +6689,1033 @@ var init_logout = __esm({
6689
6689
  }
6690
6690
  });
6691
6691
 
6692
+ // src/mcp-tools/schemas.ts
6693
+ var import_zod4, BATCH_MAX2, isHttp, httpLink, id, dryRun, MISSION_STATES, MISSION_DOCUMENTATION, MISSION_LINK_KINDS, missionName, missionSummary, missionUrl, missionFields, sourceTitle, sourceVersion, linkSource, fileSource, ACCESS_KINDS, questionText, questionInput, STRATEGY_SOURCE_KINDS, TEST_LEVELS, QUALITY_CHARACTERISTICS, TEST_TECHNIQUES, RISK_SCALE, itemFields, strategyItem, items, unique, linkIds, share, strategyContent, SOURCE_REF_KINDS, proposedCase, API_METHODS, proposedEndpoint, REVIEW_STATES, REVIEW_KINDS, REJECTION_CATEGORIES, entity, plannedRequest, writeOutput, batchOutput, bulkPreview;
6694
+ var init_schemas = __esm({
6695
+ "src/mcp-tools/schemas.ts"() {
6696
+ "use strict";
6697
+ init_cjs_shims();
6698
+ import_zod4 = require("zod");
6699
+ BATCH_MAX2 = 100;
6700
+ isHttp = (s) => {
6701
+ try {
6702
+ const u = new URL(s);
6703
+ return u.protocol === "http:" || u.protocol === "https:";
6704
+ } catch {
6705
+ return false;
6706
+ }
6707
+ };
6708
+ httpLink = (what, max) => import_zod4.z.string().trim().min(1, `${what} is empty`).max(max).refine(isHttp, `${what} must be an http or https address`);
6709
+ id = (what) => import_zod4.z.string().min(1).describe(what);
6710
+ dryRun = import_zod4.z.boolean().optional().describe("true: answer what would be sent, and send nothing");
6711
+ MISSION_STATES = ["draft", "active", "done"];
6712
+ MISSION_DOCUMENTATION = ["unknown", "provided", "none"];
6713
+ MISSION_LINK_KINDS = ["url", "openapi", "repo", "ticket"];
6714
+ missionName = import_zod4.z.string().trim().min(1, "a mission needs a name").max(200);
6715
+ missionSummary = import_zod4.z.string().trim().max(2e3).describe("What the product is, in 3-5 sentences");
6716
+ missionUrl = httpLink("url", 2048).nullable().describe("Where the product runs; null takes it off");
6717
+ missionFields = {
6718
+ name: missionName,
6719
+ summary: missionSummary.optional(),
6720
+ url: missionUrl.optional(),
6721
+ state: import_zod4.z.enum(MISSION_STATES).optional(),
6722
+ documentation: import_zod4.z.enum(MISSION_DOCUMENTATION).optional().describe("Is there documentation: provided (it is among the sources), none, or unknown")
6723
+ };
6724
+ sourceTitle = import_zod4.z.string().trim().min(1).max(200);
6725
+ sourceVersion = import_zod4.z.string().trim().min(1).max(200).describe("A commit, a tag, a revision, an ETag");
6726
+ linkSource = import_zod4.z.strictObject({
6727
+ missionId: id("The mission"),
6728
+ kind: import_zod4.z.enum(MISSION_LINK_KINDS),
6729
+ ref: httpLink("ref", 2048).describe("The link"),
6730
+ title: sourceTitle.optional(),
6731
+ version: sourceVersion.optional(),
6732
+ dry_run: dryRun
6733
+ });
6734
+ fileSource = import_zod4.z.strictObject({
6735
+ missionId: id("The mission"),
6736
+ kind: import_zod4.z.literal("file"),
6737
+ name: import_zod4.z.string().trim().min(1).max(200).regex(/\.(txt|md|markdown|json)$/i, "a source file is .txt, .md, .markdown or .json").describe("The file name; its extension sets the type"),
6738
+ text: import_zod4.z.string().max(1048576).describe("The whole text of the file, UTF-8, at most 1 MiB"),
6739
+ title: sourceTitle.optional(),
6740
+ version: sourceVersion.optional(),
6741
+ dry_run: dryRun
6742
+ });
6743
+ ACCESS_KINDS = ["openapi", "repo", "staging", "har"];
6744
+ questionText = import_zod4.z.string().trim().min(1).max(2e3);
6745
+ questionInput = import_zod4.z.strictObject({
6746
+ template: import_zod4.z.enum(["plain", "access"]).describe("plain: a question with up to three options; access: ask for an API description, the code, a staging host or a HAR file"),
6747
+ text: questionText,
6748
+ missionId: import_zod4.z.string().min(1).optional(),
6749
+ options: import_zod4.z.array(import_zod4.z.string().trim().min(1).max(200)).min(1).max(3).refine((o) => new Set(o).size === o.length, { message: "no two options are the same" }).optional().describe("plain only: what the person may pick from"),
6750
+ access: import_zod4.z.array(import_zod4.z.enum(ACCESS_KINDS)).min(1).max(ACCESS_KINDS.length).refine((a) => new Set(a).size === a.length, { message: "an access kind is named once" }).optional().describe("access only: what you ask to be given"),
6751
+ dry_run: dryRun
6752
+ });
6753
+ STRATEGY_SOURCE_KINDS = ["mission-source", "doc-section", "screen", "endpoint", "requirement", "url"];
6754
+ TEST_LEVELS = ["component", "component-integration", "system", "system-integration", "acceptance"];
6755
+ QUALITY_CHARACTERISTICS = [
6756
+ "functional-suitability",
6757
+ "performance-efficiency",
6758
+ "compatibility",
6759
+ "interaction-capability",
6760
+ "reliability",
6761
+ "security",
6762
+ "maintainability",
6763
+ "flexibility",
6764
+ "safety"
6765
+ ];
6766
+ TEST_TECHNIQUES = [
6767
+ "equivalence-partitioning",
6768
+ "boundary-value",
6769
+ "decision-table",
6770
+ "state-transition",
6771
+ "statement",
6772
+ "branch",
6773
+ "error-guessing",
6774
+ "exploratory",
6775
+ "checklist"
6776
+ ];
6777
+ RISK_SCALE = ["low", "medium", "high"];
6778
+ itemFields = {
6779
+ text: import_zod4.z.string().trim().min(1).max(1e3).describe("Markdown"),
6780
+ source: import_zod4.z.strictObject({ kind: import_zod4.z.enum(STRATEGY_SOURCE_KINDS), ref: import_zod4.z.string().trim().min(1).max(300) }).optional().describe("Where it was read"),
6781
+ assumption: import_zod4.z.literal(true).optional().describe("You assumed it: a person confirms"),
6782
+ waitingOn: import_zod4.z.string().min(1).optional().describe("The id of an open question of this mission it waits for")
6783
+ };
6784
+ strategyItem = import_zod4.z.strictObject(itemFields);
6785
+ items = import_zod4.z.array(strategyItem).min(1).max(25);
6786
+ unique = (values) => import_zod4.z.array(import_zod4.z.enum(values)).min(1).refine((a) => new Set(a).size === a.length, { message: "named once" });
6787
+ linkIds = import_zod4.z.array(import_zod4.z.string().min(1)).max(50).refine((a) => new Set(a).size === a.length, { message: "an id is named once" });
6788
+ share = import_zod4.z.number().int().min(0).max(100);
6789
+ strategyContent = import_zod4.z.strictObject({
6790
+ objectives: items,
6791
+ scope: import_zod4.z.strictObject({ in: items, out: items.describe("What is deliberately not tested, and why") }),
6792
+ approach: items,
6793
+ profile: import_zod4.z.strictObject({
6794
+ levels: unique(TEST_LEVELS),
6795
+ characteristics: unique(QUALITY_CHARACTERISTICS),
6796
+ techniques: unique(TEST_TECHNIQUES),
6797
+ split: import_zod4.z.strictObject({ api: share, ui: share, manual: share }).refine((s) => s.api + s.ui + s.manual === 100, { message: "the split of api, ui and manual adds up to 100" }),
6798
+ notes: items
6799
+ }),
6800
+ risks: import_zod4.z.array(
6801
+ import_zod4.z.strictObject({
6802
+ ...itemFields,
6803
+ likelihood: import_zod4.z.enum(RISK_SCALE),
6804
+ impact: import_zod4.z.enum(RISK_SCALE),
6805
+ caseIds: linkIds.optional(),
6806
+ requirementIds: linkIds.optional()
6807
+ })
6808
+ ).min(1).max(50),
6809
+ entryCriteria: items,
6810
+ exitCriteria: items
6811
+ });
6812
+ SOURCE_REF_KINDS = ["url", "openapi-operation", "requirement", "pasted-text"];
6813
+ proposedCase = import_zod4.z.strictObject({
6814
+ title: import_zod4.z.string().trim().min(1).max(300),
6815
+ steps: import_zod4.z.array(import_zod4.z.string().trim().min(1).max(400)).min(1).max(20),
6816
+ expected: import_zod4.z.string().trim().min(1).max(2e3),
6817
+ execution: import_zod4.z.enum(["auto", "manual"]).optional().describe("auto: can be automated; manual (default): stays with a person"),
6818
+ technique: import_zod4.z.string().min(1).max(32).optional(),
6819
+ priority: import_zod4.z.string().min(1).optional(),
6820
+ source: import_zod4.z.strictObject({ kind: import_zod4.z.enum(SOURCE_REF_KINDS), value: import_zod4.z.string().min(1).max(100) }).optional().describe("What you read to design it"),
6821
+ rationale: import_zod4.z.string().min(1).max(400).optional().describe("Why the case is worth having")
6822
+ });
6823
+ API_METHODS = ["GET", "PUT", "POST", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"];
6824
+ proposedEndpoint = import_zod4.z.strictObject({
6825
+ method: import_zod4.z.enum(API_METHODS),
6826
+ path: import_zod4.z.string().min(1).max(512).regex(/^\/[^?#\s]*$/, "a path template starts with / and has no query").describe("A template: /users/{id}, never /users/42"),
6827
+ source: import_zod4.z.enum(["spec", "code", "traffic"]),
6828
+ trust: import_zod4.z.enum(["confirmed", "derived"]).optional(),
6829
+ summary: import_zod4.z.string().trim().min(1).max(500).optional(),
6830
+ auth: import_zod4.z.array(import_zod4.z.string().trim().min(1).max(100)).max(10).optional(),
6831
+ environment: import_zod4.z.string().trim().min(1).max(100).optional(),
6832
+ seenAt: import_zod4.z.string().optional().describe("ISO 8601 date and time with an offset")
6833
+ });
6834
+ REVIEW_STATES = ["pending", "changes_requested", "approved", "rejected"];
6835
+ REVIEW_KINDS = ["result", "proposal", "discovered", "endpoint", "strategy", "report", "memory_rule"];
6836
+ REJECTION_CATEGORIES = ["duplicate", "incorrect", "out-of-scope", "low-value", "not-testable", "other"];
6837
+ entity = import_zod4.z.looseObject({ id: import_zod4.z.string().optional() });
6838
+ plannedRequest = import_zod4.z.object({
6839
+ method: import_zod4.z.string(),
6840
+ path: import_zod4.z.string(),
6841
+ query: import_zod4.z.record(import_zod4.z.string(), import_zod4.z.string()).optional(),
6842
+ body: import_zod4.z.unknown().optional()
6843
+ });
6844
+ writeOutput = import_zod4.z.object({
6845
+ dryRun: import_zod4.z.boolean(),
6846
+ request: plannedRequest.optional(),
6847
+ result: entity.optional()
6848
+ });
6849
+ batchOutput = import_zod4.z.object({
6850
+ dryRun: import_zod4.z.boolean(),
6851
+ requested: import_zod4.z.number().int(),
6852
+ applied: import_zod4.z.number().int(),
6853
+ requests: import_zod4.z.array(plannedRequest).optional(),
6854
+ results: import_zod4.z.array(
6855
+ import_zod4.z.object({
6856
+ ok: import_zod4.z.boolean(),
6857
+ status: import_zod4.z.number().int().optional(),
6858
+ id: import_zod4.z.string().optional(),
6859
+ error: import_zod4.z.string().optional(),
6860
+ code: import_zod4.z.string().optional()
6861
+ })
6862
+ ).optional(),
6863
+ report: import_zod4.z.looseObject({}).optional()
6864
+ });
6865
+ bulkPreview = import_zod4.z.looseObject({
6866
+ total: import_zod4.z.number().int(),
6867
+ confirm: import_zod4.z.string(),
6868
+ expiresInSeconds: import_zod4.z.number().int()
6869
+ });
6870
+ }
6871
+ });
6872
+
6873
+ // src/mcp-tools/contract.ts
6874
+ function pluneTool(name) {
6875
+ const tool = BY_NAME.get(name);
6876
+ if (tool === void 0) throw new Error(`unknown tool: ${name}`);
6877
+ return tool;
6878
+ }
6879
+ function isPluneToolName(name) {
6880
+ return BY_NAME.has(name);
6881
+ }
6882
+ function defaultVisibleTools(scope) {
6883
+ return PLUNE_TOOLS.filter((t) => t.visibility === "core" && !(scope === "read" && t.write)).map((t) => t.name);
6884
+ }
6885
+ var import_zod5, MAX_VISIBLE_TOOLS, define, listPage, missionId, PLUNE_TOOLS, PLUNE_TOOL_NAMES, BY_NAME;
6886
+ var init_contract = __esm({
6887
+ "src/mcp-tools/contract.ts"() {
6888
+ "use strict";
6889
+ init_cjs_shims();
6890
+ import_zod5 = require("zod");
6891
+ init_schemas();
6892
+ MAX_VISIBLE_TOOLS = 20;
6893
+ define = (tool) => tool;
6894
+ listPage = {
6895
+ limit: import_zod5.z.number().int().min(1).max(BATCH_MAX2).optional(),
6896
+ cursor: import_zod5.z.string().min(1).optional().describe("nextCursor of the previous page")
6897
+ };
6898
+ missionId = id("The mission id");
6899
+ PLUNE_TOOLS = [
6900
+ define({
6901
+ name: "plune_project_context",
6902
+ title: "Project context",
6903
+ description: "Start here: learn the project you are working in with one call \u2014 its projects, the suite tree, how many test cases it has, its testing missions, and how much waits in the review queue.",
6904
+ input: import_zod5.z.strictObject({}),
6905
+ output: import_zod5.z.object({
6906
+ projectId: import_zod5.z.string().nullable(),
6907
+ projects: import_zod5.z.array(entity),
6908
+ suites: import_zod5.z.array(entity),
6909
+ cases: import_zod5.z.object({ total: import_zod5.z.number().int() }),
6910
+ missions: import_zod5.z.array(entity),
6911
+ reviewQueue: import_zod5.z.object({ pending: import_zod5.z.number().int(), more: import_zod5.z.boolean() })
6912
+ }),
6913
+ write: false,
6914
+ visibility: "core",
6915
+ endpoints: ["GET /v1/projects", "GET /v1/suites", "GET /v1/test-cases", "GET /v1/missions", "GET /v1/review-items"]
6916
+ }),
6917
+ define({
6918
+ name: "plune_get",
6919
+ title: "Read one object",
6920
+ description: "Read one test case, run or review queue entry in full when you already hold its id \u2014 steps, state, history fields and everything the platform keeps on it.",
6921
+ input: import_zod5.z.strictObject({ kind: import_zod5.z.enum(["test-case", "run", "review-item"]), id: id("Its id") }),
6922
+ output: import_zod5.z.object({ kind: import_zod5.z.string(), item: entity }),
6923
+ write: false,
6924
+ visibility: "core",
6925
+ endpoints: ["GET /v1/test-cases/:id", "GET /v1/runs/:id", "GET /v1/review-items/:id"]
6926
+ }),
6927
+ define({
6928
+ name: "plune_cases_search",
6929
+ title: "Find test cases",
6930
+ description: "Find test cases of the project by filter \u2014 suite, state, tag, priority, text and the other filters of the case list \u2014 one page at a time, with the total and how many match.",
6931
+ input: import_zod5.z.strictObject({
6932
+ filters: import_zod5.z.record(import_zod5.z.string(), import_zod5.z.union([import_zod5.z.string(), import_zod5.z.array(import_zod5.z.string())])).optional().describe('Query filters of GET /v1/test-cases: a list is OR inside one key, keys are AND-ed (e.g. {"suiteId": "s1", "tag": ["smoke"]})'),
6933
+ ...listPage
6934
+ }),
6935
+ output: import_zod5.z.object({
6936
+ testCases: import_zod5.z.array(entity),
6937
+ total: import_zod5.z.number().int().optional(),
6938
+ matching: import_zod5.z.number().int().optional(),
6939
+ nextCursor: import_zod5.z.string().nullable()
6940
+ }),
6941
+ write: false,
6942
+ visibility: "core",
6943
+ endpoints: ["GET /v1/test-cases?filters"]
6944
+ }),
6945
+ define({
6946
+ name: "plune_review_list",
6947
+ title: "Read the review queue",
6948
+ description: "See what waits for a decision in the review queue \u2014 proposed cases, discovered tests, endpoints, strategies, reports \u2014 narrowed by state and kind, newest first, one page at a time.",
6949
+ input: import_zod5.z.strictObject({
6950
+ state: import_zod5.z.enum(REVIEW_STATES).optional(),
6951
+ kinds: import_zod5.z.array(import_zod5.z.enum(REVIEW_KINDS)).min(1).optional().describe("Questions are read with the question tool"),
6952
+ ...listPage
6953
+ }),
6954
+ output: import_zod5.z.object({ items: import_zod5.z.array(entity), nextCursor: import_zod5.z.string().nullable() }),
6955
+ write: false,
6956
+ visibility: "core",
6957
+ endpoints: ["GET /v1/review-items"]
6958
+ }),
6959
+ define({
6960
+ name: "plune_review_decide",
6961
+ title: "Decide queue entries",
6962
+ description: "Approve or reject up to 100 review queue entries in one decision. The first call changes nothing and returns a preview with a confirm value; repeat the same call with confirm to apply it.",
6963
+ input: import_zod5.z.strictObject({
6964
+ itemIds: import_zod5.z.array(import_zod5.z.string().min(1)).min(1).max(BATCH_MAX2).refine((a) => new Set(a).size === a.length, { message: "an id is named once" }),
6965
+ verdict: import_zod5.z.enum(["approved", "rejected"]),
6966
+ rejectionCategory: import_zod5.z.enum(REJECTION_CATEGORIES).optional().describe("Required to reject"),
6967
+ note: import_zod5.z.string().trim().min(1).max(2e3).optional().describe("Why \u2014 required to reject"),
6968
+ confirm: import_zod5.z.string().min(1).optional().describe("The confirm value of the preview")
6969
+ }),
6970
+ output: import_zod5.z.object({
6971
+ requested: import_zod5.z.number().int(),
6972
+ applied: import_zod5.z.number().int(),
6973
+ preview: bulkPreview.optional(),
6974
+ report: import_zod5.z.looseObject({}).optional()
6975
+ }),
6976
+ write: true,
6977
+ visibility: "core",
6978
+ endpoints: ["POST /v1/review-items/decisions"]
6979
+ }),
6980
+ define({
6981
+ name: "plune_case_propose",
6982
+ title: "Propose test cases",
6983
+ description: "Propose up to 100 new test cases for a person to review: they land in the review queue as proposals and become cases only when someone approves them. Nothing is created silently.",
6984
+ input: import_zod5.z.strictObject({
6985
+ target: import_zod5.z.string().trim().min(1).max(2048).describe("What the cases test: the product address, or the mission name"),
6986
+ cases: import_zod5.z.array(proposedCase).min(1).max(BATCH_MAX2),
6987
+ dry_run: dryRun
6988
+ }),
6989
+ output: batchOutput,
6990
+ write: true,
6991
+ visibility: "core",
6992
+ endpoints: ["POST /v1/ingest/cairn"]
6993
+ }),
6994
+ define({
6995
+ name: "plune_mission_create",
6996
+ title: "Create a mission",
6997
+ description: "Create a testing mission for a product: its name, a short description of what the product is, where it runs, and whether documentation exists. It starts as a draft you fill in.",
6998
+ input: import_zod5.z.strictObject({ ...missionFields, dry_run: dryRun }),
6999
+ output: writeOutput,
7000
+ write: true,
7001
+ visibility: "core",
7002
+ endpoints: ["POST /v1/missions"]
7003
+ }),
7004
+ define({
7005
+ name: "plune_mission_get",
7006
+ title: "Read a mission",
7007
+ description: "Read a testing mission with everything it holds \u2014 its brief, state, documentation answer and every source attached to it \u2014 before planning or continuing the work on it.",
7008
+ input: import_zod5.z.strictObject({ missionId }),
7009
+ output: import_zod5.z.object({ mission: entity }),
7010
+ write: false,
7011
+ visibility: "core",
7012
+ endpoints: ["GET /v1/missions/:id"]
7013
+ }),
7014
+ define({
7015
+ name: "plune_mission_update",
7016
+ title: "Update a mission",
7017
+ description: "Change a testing mission: rename it, rewrite the brief, set or remove the address, answer whether documentation exists, or move it between draft, active and done.",
7018
+ input: import_zod5.z.strictObject({
7019
+ missionId,
7020
+ name: missionFields.name.optional(),
7021
+ summary: missionFields.summary,
7022
+ url: missionFields.url,
7023
+ state: missionFields.state,
7024
+ documentation: missionFields.documentation,
7025
+ dry_run: dryRun
7026
+ }).refine((b) => ["name", "summary", "url", "state", "documentation"].some((k) => b[k] !== void 0), {
7027
+ message: "nothing to change"
7028
+ }),
7029
+ output: writeOutput,
7030
+ write: true,
7031
+ visibility: "core",
7032
+ endpoints: ["PATCH /v1/missions/:id"]
7033
+ }),
7034
+ define({
7035
+ name: "plune_source_add",
7036
+ title: "Add a source to a mission",
7037
+ description: "Attach something to read to a mission: a link (page, OpenAPI document, repository, ticket) or the text of a file of the project \u2014 Markdown, plain text or JSON, up to 1 MiB.",
7038
+ input: import_zod5.z.strictObject({
7039
+ missionId,
7040
+ kind: import_zod5.z.enum([...MISSION_LINK_KINDS, "file"]),
7041
+ ref: import_zod5.z.string().optional().describe("The link, for url, openapi, repo and ticket"),
7042
+ name: import_zod5.z.string().optional().describe("The file name, for file (.md, .markdown, .txt, .json)"),
7043
+ text: import_zod5.z.string().optional().describe("The file text, for file"),
7044
+ title: import_zod5.z.string().optional(),
7045
+ version: import_zod5.z.string().optional(),
7046
+ dry_run: dryRun
7047
+ }).superRefine((b, ctx) => {
7048
+ if (b.kind === "file" && (b.name === void 0 || b.text === void 0))
7049
+ ctx.addIssue({ code: "custom", message: "a file source carries name and text" });
7050
+ if (b.kind !== "file" && b.ref === void 0) ctx.addIssue({ code: "custom", message: "a link source carries ref" });
7051
+ }),
7052
+ output: writeOutput,
7053
+ write: true,
7054
+ visibility: "core",
7055
+ endpoints: ["POST /v1/missions/:id/sources", "POST /v1/missions/:id/files"]
7056
+ }),
7057
+ define({
7058
+ name: "plune_question_ask",
7059
+ title: "Ask a person",
7060
+ description: "Ask a person what you do not know instead of guessing: a plain question with up to three options, or a request for access to an API description, the code, staging or a HAR file.",
7061
+ input: questionInput.superRefine((b, ctx) => {
7062
+ if (b.template === "plain" && b.access !== void 0) ctx.addIssue({ code: "custom", message: "access belongs to an access question" });
7063
+ if (b.template === "access" && b.options !== void 0) ctx.addIssue({ code: "custom", message: "options belong to a plain question" });
7064
+ if (b.template === "access" && b.access === void 0) ctx.addIssue({ code: "custom", message: "an access question names the access" });
7065
+ }),
7066
+ output: writeOutput,
7067
+ write: true,
7068
+ visibility: "core",
7069
+ endpoints: ["POST /v1/review-items/questions"]
7070
+ }),
7071
+ define({
7072
+ name: "plune_question_list",
7073
+ title: "Read questions and answers",
7074
+ description: "Check the questions asked to people and the answers they gave \u2014 all of them or one mission's, waiting or answered \u2014 so the work continues from what a person actually said.",
7075
+ input: import_zod5.z.strictObject({ missionId: import_zod5.z.string().min(1).optional(), state: import_zod5.z.enum(REVIEW_STATES).optional(), ...listPage }),
7076
+ output: import_zod5.z.object({ questions: import_zod5.z.array(entity), nextCursor: import_zod5.z.string().nullable() }),
7077
+ write: false,
7078
+ visibility: "core",
7079
+ endpoints: ["GET /v1/review-items?kind=question"]
7080
+ }),
7081
+ define({
7082
+ name: "plune_strategy_propose",
7083
+ title: "Propose a test strategy",
7084
+ description: "Propose a test strategy for a mission in ISTQB sections \u2014 objectives, scope in and out, approach, levels and techniques, risks, entry and exit criteria \u2014 for a person to accept.",
7085
+ input: import_zod5.z.strictObject({ missionId, content: strategyContent, dry_run: dryRun }),
7086
+ output: writeOutput,
7087
+ write: true,
7088
+ visibility: "core",
7089
+ endpoints: ["POST /v1/missions/:id/strategy"]
7090
+ }),
7091
+ define({
7092
+ name: "plune_report_request",
7093
+ title: "Request the mission report",
7094
+ description: "Ask the platform to build the completion report of a mission from its own data and queue it for a person to approve; it needs an accepted strategy first.",
7095
+ input: import_zod5.z.strictObject({ missionId, dry_run: dryRun }),
7096
+ output: writeOutput,
7097
+ write: true,
7098
+ visibility: "core",
7099
+ endpoints: ["POST /v1/missions/:id/report"]
7100
+ }),
7101
+ define({
7102
+ name: "plune_discover_tools",
7103
+ title: "Find more tools",
7104
+ description: "Look for a capability that is not in your tool list \u2014 editing cases, writing results, starting runs, cataloguing API endpoints, a raw API call \u2014 and switch the ones you need on for this session.",
7105
+ input: import_zod5.z.strictObject({
7106
+ query: import_zod5.z.string().trim().min(1).max(200).optional().describe('Words of the task, e.g. "write results"'),
7107
+ names: import_zod5.z.array(import_zod5.z.string().min(1)).min(1).max(MAX_VISIBLE_TOOLS).optional().describe("Tools to switch on")
7108
+ }),
7109
+ output: import_zod5.z.object({
7110
+ revealed: import_zod5.z.array(import_zod5.z.string()),
7111
+ available: import_zod5.z.array(import_zod5.z.object({ name: import_zod5.z.string(), title: import_zod5.z.string(), description: import_zod5.z.string(), write: import_zod5.z.boolean() })),
7112
+ visible: import_zod5.z.array(import_zod5.z.string())
7113
+ }),
7114
+ write: false,
7115
+ visibility: "core",
7116
+ endpoints: ["LOCAL /discover"]
7117
+ }),
7118
+ define({
7119
+ name: "plune_cases_update",
7120
+ title: "Edit test cases",
7121
+ description: "Edit up to 100 existing test cases in one call \u2014 title, steps, expected result, priority, tags, suite \u2014 each applied on its own, with a count of how many were applied.",
7122
+ input: import_zod5.z.strictObject({
7123
+ cases: import_zod5.z.array(import_zod5.z.strictObject({ id: id("The case id"), changes: import_zod5.z.record(import_zod5.z.string(), import_zod5.z.unknown()).describe("Fields of PATCH /v1/test-cases/:id") })).min(1).max(BATCH_MAX2),
7124
+ dry_run: dryRun
7125
+ }),
7126
+ output: batchOutput,
7127
+ write: true,
7128
+ visibility: "discoverable",
7129
+ endpoints: ["PATCH /v1/test-cases/:id"]
7130
+ }),
7131
+ define({
7132
+ name: "plune_run_create",
7133
+ title: "Start a run",
7134
+ description: "Start a test run on the platform to record results into \u2014 named, with its environment and the cases it covers \u2014 using the body of POST /v1/runs (schemaVersion 2).",
7135
+ input: import_zod5.z.strictObject({ run: import_zod5.z.record(import_zod5.z.string(), import_zod5.z.unknown()).describe("The body of POST /v1/runs"), dry_run: dryRun }),
7136
+ output: writeOutput,
7137
+ write: true,
7138
+ visibility: "discoverable",
7139
+ endpoints: ["POST /v1/runs"]
7140
+ }),
7141
+ define({
7142
+ name: "plune_results_write",
7143
+ title: "Write results into a run",
7144
+ description: "Record up to 100 test results into an open run in one request \u2014 passed, failed, skipped, with their keys and evidence \u2014 and learn how many the platform accepted.",
7145
+ input: import_zod5.z.strictObject({
7146
+ runId: id("The run id"),
7147
+ results: import_zod5.z.array(import_zod5.z.record(import_zod5.z.string(), import_zod5.z.unknown())).min(1).max(BATCH_MAX2).describe("Results as POST /v1/runs/:id/results takes them"),
7148
+ combination: import_zod5.z.number().int().min(0).optional(),
7149
+ configuration: import_zod5.z.record(import_zod5.z.string(), import_zod5.z.string()).optional(),
7150
+ dry_run: dryRun
7151
+ }),
7152
+ output: batchOutput,
7153
+ write: true,
7154
+ visibility: "discoverable",
7155
+ endpoints: ["POST /v1/runs/:id/results"]
7156
+ }),
7157
+ define({
7158
+ name: "plune_endpoint_propose",
7159
+ title: "Propose API endpoints",
7160
+ description: "Propose up to 100 API endpoints you found in a spec, the code or traffic for the project's API catalog; each waits in the review queue until a person approves it.",
7161
+ input: import_zod5.z.strictObject({ endpoints: import_zod5.z.array(proposedEndpoint).min(1).max(BATCH_MAX2), dry_run: dryRun }),
7162
+ output: batchOutput,
7163
+ write: true,
7164
+ visibility: "discoverable",
7165
+ endpoints: ["POST /v1/review-items/endpoints"]
7166
+ }),
7167
+ define({
7168
+ name: "plune_api_request",
7169
+ title: "Call the REST API directly",
7170
+ description: "Reach any Plune REST endpoint under /v1 that no other tool covers. Every guard still holds: a deletion is previewed first and needs confirm, and the platform refuses an agent what it refuses over HTTP.",
7171
+ input: import_zod5.z.strictObject({
7172
+ method: import_zod5.z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
7173
+ path: import_zod5.z.string().min(1).max(2048).describe("A path under /v1, e.g. /v1/suites"),
7174
+ query: import_zod5.z.record(import_zod5.z.string(), import_zod5.z.string()).optional(),
7175
+ body: import_zod5.z.unknown().optional(),
7176
+ confirm: import_zod5.z.string().min(1).optional().describe("The confirm value of a preview"),
7177
+ dry_run: dryRun
7178
+ }),
7179
+ output: import_zod5.z.object({
7180
+ status: import_zod5.z.number().int().optional(),
7181
+ body: import_zod5.z.unknown().optional(),
7182
+ dryRun: import_zod5.z.boolean().optional(),
7183
+ request: plannedRequest.optional(),
7184
+ preview: import_zod5.z.looseObject({ method: import_zod5.z.string(), path: import_zod5.z.string(), confirm: import_zod5.z.string(), expiresInSeconds: import_zod5.z.number().int() }).optional()
7185
+ }),
7186
+ write: true,
7187
+ visibility: "discoverable",
7188
+ endpoints: ["ANY /v1/*"]
7189
+ })
7190
+ ];
7191
+ PLUNE_TOOL_NAMES = PLUNE_TOOLS.map((t) => t.name);
7192
+ BY_NAME = new Map(PLUNE_TOOLS.map((t) => [t.name, t]));
7193
+ }
7194
+ });
7195
+
7196
+ // src/mcp-tools/client.ts
7197
+ function bodyField(body, key) {
7198
+ if (body === null || typeof body !== "object") return void 0;
7199
+ const value2 = body[key];
7200
+ return typeof value2 === "string" ? value2 : void 0;
7201
+ }
7202
+ function apiBase(apiUrl) {
7203
+ return apiUrl.replace(/\/+$/, "");
7204
+ }
7205
+ function buildUrl(apiUrl, req) {
7206
+ const url = new URL(apiBase(apiUrl) + req.path);
7207
+ for (const [key, value2] of Object.entries(req.query ?? {})) {
7208
+ for (const v of typeof value2 === "string" ? [value2] : value2) url.searchParams.append(key, v);
7209
+ }
7210
+ return url;
7211
+ }
7212
+ function createPluneApi(opts) {
7213
+ const doFetch = opts.fetchImpl ?? fetch;
7214
+ const headers = {
7215
+ authorization: `Bearer ${opts.token}`,
7216
+ accept: "application/json",
7217
+ "x-plune-session": opts.sessionId,
7218
+ ...opts.projectId === void 0 ? {} : { "x-plune-project": opts.projectId },
7219
+ ...opts.userAgent === void 0 ? {} : { "user-agent": opts.userAgent }
7220
+ };
7221
+ return async function request(req) {
7222
+ const url = buildUrl(opts.apiUrl, req);
7223
+ let res;
7224
+ try {
7225
+ res = await doFetch(url.toString(), {
7226
+ method: req.method,
7227
+ headers: req.body === void 0 ? headers : { ...headers, "content-type": "application/json" },
7228
+ ...req.body === void 0 ? {} : { body: JSON.stringify(req.body) },
7229
+ // Host pinning (#1283): a redirect elsewhere would carry the token there.
7230
+ redirect: "manual"
7231
+ });
7232
+ } catch {
7233
+ throw new PluneNetworkError(apiBase(opts.apiUrl));
7234
+ }
7235
+ const text2 = await res.text();
7236
+ let body = text2;
7237
+ if (text2 !== "") {
7238
+ try {
7239
+ body = JSON.parse(text2);
7240
+ } catch {
7241
+ }
7242
+ } else {
7243
+ body = void 0;
7244
+ }
7245
+ if (res.status < 200 || res.status >= 300) throw new PluneApiError(res.status, body);
7246
+ return { status: res.status, body };
7247
+ };
7248
+ }
7249
+ var PluneApiError, PluneNetworkError;
7250
+ var init_client2 = __esm({
7251
+ "src/mcp-tools/client.ts"() {
7252
+ "use strict";
7253
+ init_cjs_shims();
7254
+ PluneApiError = class extends Error {
7255
+ constructor(status, body) {
7256
+ const error = bodyField(body, "error");
7257
+ super(error ?? `HTTP ${status}`);
7258
+ this.status = status;
7259
+ this.body = body;
7260
+ this.name = "PluneApiError";
7261
+ }
7262
+ status;
7263
+ body;
7264
+ get code() {
7265
+ return bodyField(this.body, "code");
7266
+ }
7267
+ };
7268
+ PluneNetworkError = class extends Error {
7269
+ constructor(apiUrl) {
7270
+ super(`could not reach Plune at ${apiUrl}`);
7271
+ this.apiUrl = apiUrl;
7272
+ this.name = "PluneNetworkError";
7273
+ }
7274
+ apiUrl;
7275
+ };
7276
+ }
7277
+ });
7278
+
7279
+ // src/mcp-tools/execute.ts
7280
+ function errorOf(err) {
7281
+ if (err instanceof PluneApiError) {
7282
+ if (err.status === 401)
7283
+ return { status: 401, code: "token_rejected", message: `Plune rejected the token (401): ${err.message}. Run \`plune login\` or set PLUNE_TOKEN to a live token.` };
7284
+ if (err.status === 403 && err.message === READ_ONLY_REFUSAL)
7285
+ return { status: 403, code: "read_only_token", message: `Plune refused (403): ${readScopeMessage("it cannot write")}` };
7286
+ const code = err.code ?? `http_${err.status}`;
7287
+ return { status: err.status, code, message: `Plune refused (${err.status} ${code}): ${err.message}`, details: err.body };
7288
+ }
7289
+ if (err instanceof PluneNetworkError) return { code: "network", message: `${err.message}. Check PLUNE_API_URL and the network.` };
7290
+ throw err;
7291
+ }
7292
+ function isDeletion(req) {
7293
+ if (req.method === "DELETE") return true;
7294
+ if (asRecord(asRecord(req.body)["action"])["kind"] === "delete") return true;
7295
+ return /\/(bulk-delete|purge|empty)$/.test(req.path);
7296
+ }
7297
+ function safePath(apiUrl, raw) {
7298
+ if (!/^\/v1\/[^\\\s]*$/.test(raw) || raw.startsWith("//") || raw.includes("?") || raw.includes("#")) return void 0;
7299
+ if (raw.split("/").some((s) => s === "." || s === ".." || /^%2e/i.test(s))) return void 0;
7300
+ const base = new URL(apiUrl);
7301
+ const url = new URL(raw, base);
7302
+ return url.origin === base.origin && url.pathname.startsWith("/v1/") ? raw : void 0;
7303
+ }
7304
+ function createPluneToolExecutor(opts) {
7305
+ const sessionId = opts.sessionId ?? (0, import_node_crypto5.randomUUID)();
7306
+ const now = opts.now ?? Date.now;
7307
+ const request = createPluneApi({ ...opts, sessionId });
7308
+ let scope = opts.scope;
7309
+ const revealed = /* @__PURE__ */ new Set();
7310
+ const confirms = /* @__PURE__ */ new Map();
7311
+ let proposals = 0;
7312
+ const visibleTools = () => {
7313
+ const core = defaultVisibleTools(scope);
7314
+ const extra = [...revealed].filter((n) => !(scope === "read" && pluneTool(n).write) && !core.includes(n));
7315
+ return [...core, ...extra];
7316
+ };
7317
+ async function call(req) {
7318
+ try {
7319
+ return await request(req);
7320
+ } catch (err) {
7321
+ if (err instanceof PluneApiError && err.status === 403 && err.message === READ_ONLY_REFUSAL) scope = "read";
7322
+ throw err;
7323
+ }
7324
+ }
7325
+ const planned = (req) => withoutUndefined({ method: req.method, path: req.path, query: req.query, body: req.body });
7326
+ async function write(req, dry) {
7327
+ if (dry === true) return { dryRun: true, request: planned(req) };
7328
+ const res = await call(req);
7329
+ return { dryRun: false, result: asRecord(res.body) };
7330
+ }
7331
+ async function batch(reqs, dry, idOf2) {
7332
+ if (dry === true) return { dryRun: true, requested: reqs.length, applied: 0, requests: reqs.map(planned) };
7333
+ const results = [];
7334
+ for (const [i, req] of reqs.entries()) {
7335
+ try {
7336
+ const res = await call(req);
7337
+ results.push(withoutUndefined({ ok: true, status: res.status, id: idOf2(res.body, i) }));
7338
+ } catch (err) {
7339
+ const e = errorOf(err);
7340
+ if (e.code === "token_rejected" || e.code === "read_only_token" || e.code === "network") throw err;
7341
+ results.push(withoutUndefined({ ok: false, status: e.status, error: e.message, code: e.code }));
7342
+ }
7343
+ }
7344
+ return { dryRun: false, requested: reqs.length, applied: results.filter((r) => r["ok"] === true).length, results };
7345
+ }
7346
+ const fingerprint = (req) => JSON.stringify([req.method, req.path, req.query ?? {}, req.body ?? null]);
7347
+ const str2 = (v) => v;
7348
+ const handlers = {
7349
+ async plune_project_context() {
7350
+ const [projects, suites, cases, missions, queue] = await Promise.all([
7351
+ call({ method: "GET", path: "/v1/projects" }),
7352
+ call({ method: "GET", path: "/v1/suites" }),
7353
+ call({ method: "GET", path: "/v1/test-cases", query: { limit: "1", stability: "0" } }),
7354
+ call({ method: "GET", path: "/v1/missions" }),
7355
+ call({ method: "GET", path: "/v1/review-items", query: { state: "pending", limit: "100" } })
7356
+ ]);
7357
+ const projectList = asArray(asRecord(projects.body)["projects"]);
7358
+ const only = projectList.length === 1 ? asRecord(projectList[0])["id"] : void 0;
7359
+ const projectId = opts.projectId ?? (typeof only === "string" ? only : null);
7360
+ const total = asRecord(cases.body)["total"];
7361
+ return {
7362
+ projectId,
7363
+ projects: projectList,
7364
+ suites: asArray(asRecord(suites.body)["suites"]),
7365
+ cases: { total: typeof total === "number" ? total : 0 },
7366
+ missions: asArray(asRecord(missions.body)["missions"]),
7367
+ reviewQueue: { pending: asArray(asRecord(queue.body)["items"]).length, more: cursorOf(asRecord(queue.body)["nextCursor"]) !== null }
7368
+ };
7369
+ },
7370
+ async plune_get(i) {
7371
+ const route = { "test-case": "/v1/test-cases/:id", run: "/v1/runs/:id", "review-item": "/v1/review-items/:id" }[str2(i["kind"])];
7372
+ const res = await call({ method: "GET", path: path19(route, str2(i["id"])) });
7373
+ return { kind: str2(i["kind"]), item: asRecord(res.body) };
7374
+ },
7375
+ async plune_cases_search(i) {
7376
+ const query = withoutUndefined({
7377
+ ...i["filters"],
7378
+ limit: i["limit"] === void 0 ? void 0 : String(i["limit"]),
7379
+ cursor: i["cursor"],
7380
+ stability: "0"
7381
+ });
7382
+ const body = asRecord((await call({ method: "GET", path: "/v1/test-cases", query })).body);
7383
+ return withoutUndefined({
7384
+ testCases: asArray(body["testCases"]),
7385
+ total: typeof body["total"] === "number" ? body["total"] : void 0,
7386
+ matching: typeof body["matching"] === "number" ? body["matching"] : void 0,
7387
+ nextCursor: cursorOf(body["nextCursor"])
7388
+ });
7389
+ },
7390
+ async plune_review_list(i) {
7391
+ const kinds = i["kinds"];
7392
+ const query = withoutUndefined({
7393
+ state: i["state"],
7394
+ kind: kinds?.join(","),
7395
+ limit: i["limit"] === void 0 ? void 0 : String(i["limit"]),
7396
+ cursor: i["cursor"]
7397
+ });
7398
+ const body = asRecord((await call({ method: "GET", path: "/v1/review-items", query })).body);
7399
+ return { items: asArray(body["items"]), nextCursor: cursorOf(body["nextCursor"]) };
7400
+ },
7401
+ async plune_review_decide(i) {
7402
+ const itemIds = i["itemIds"];
7403
+ const res = await call({
7404
+ method: "POST",
7405
+ path: "/v1/review-items/decisions",
7406
+ ...i["confirm"] === void 0 ? {} : { query: { confirm: str2(i["confirm"]) } },
7407
+ body: withoutUndefined({ itemIds, state: i["verdict"], rejectionCategory: i["rejectionCategory"], note: i["note"] })
7408
+ });
7409
+ const body = asRecord(res.body);
7410
+ if (body["preview"] !== void 0) return { requested: itemIds.length, applied: 0, preview: asRecord(body["preview"]) };
7411
+ const applied = body["applied"];
7412
+ return { requested: itemIds.length, applied: typeof applied === "number" ? applied : 0, report: body };
7413
+ },
7414
+ async plune_case_propose(i) {
7415
+ const cases = i["cases"].map((c) => {
7416
+ const steps = c["steps"];
7417
+ const stableId = (0, import_node_crypto5.createHash)("sha256").update([c["title"], ...steps, c["expected"]].join("\n")).digest("hex").slice(0, 32);
7418
+ return withoutUndefined({ stableId, execution: "manual", ...c });
7419
+ });
7420
+ proposals += 1;
7421
+ const req = {
7422
+ method: "POST",
7423
+ path: "/v1/ingest/cairn",
7424
+ body: { schemaVersion: 1, run: { runId: `mcp-${sessionId}-${proposals}`, mode: "design", target: str2(i["target"]) }, cases }
7425
+ };
7426
+ if (i["dry_run"] === true) return { dryRun: true, requested: cases.length, applied: 0, requests: [planned(req)] };
7427
+ const summary = asRecord((await call(req)).body);
7428
+ const proposed = summary["proposed"];
7429
+ return { dryRun: false, requested: cases.length, applied: typeof proposed === "number" ? proposed : 0, report: summary };
7430
+ },
7431
+ async plune_mission_create(i) {
7432
+ const { dry_run, ...body } = i;
7433
+ return write({ method: "POST", path: "/v1/missions", body: withoutUndefined(body) }, dry_run);
7434
+ },
7435
+ async plune_mission_get(i) {
7436
+ const res = await call({ method: "GET", path: path19("/v1/missions/:id", str2(i["missionId"])) });
7437
+ return { mission: asRecord(res.body) };
7438
+ },
7439
+ async plune_mission_update(i) {
7440
+ const { dry_run, missionId: missionId2, ...body } = i;
7441
+ return write({ method: "PATCH", path: path19("/v1/missions/:id", str2(missionId2)), body: withoutUndefined(body) }, dry_run);
7442
+ },
7443
+ async plune_source_add(i) {
7444
+ const parsed = (i["kind"] === "file" ? fileSource : linkSource).safeParse(withoutUndefined(i));
7445
+ if (!parsed.success) return fail("invalid_input", `The input does not fit: ${zodMessage(parsed.error)}`);
7446
+ const { dry_run, missionId: missionId2, kind, ...rest } = parsed.data;
7447
+ const req = kind === "file" ? { method: "POST", path: path19("/v1/missions/:id/files", str2(missionId2)), body: withoutUndefined(rest) } : { method: "POST", path: path19("/v1/missions/:id/sources", str2(missionId2)), body: withoutUndefined({ kind, ...rest }) };
7448
+ return write(req, dry_run);
7449
+ },
7450
+ async plune_question_ask(i) {
7451
+ const { dry_run, ...body } = i;
7452
+ return write({ method: "POST", path: "/v1/review-items/questions", body: withoutUndefined(body) }, dry_run);
7453
+ },
7454
+ async plune_question_list(i) {
7455
+ const query = withoutUndefined({
7456
+ kind: "question",
7457
+ state: i["state"],
7458
+ limit: i["limit"] === void 0 ? void 0 : String(i["limit"]),
7459
+ cursor: i["cursor"]
7460
+ });
7461
+ const body = asRecord((await call({ method: "GET", path: "/v1/review-items", query })).body);
7462
+ const missionId2 = i["missionId"];
7463
+ const questions = asArray(body["items"]).filter(
7464
+ (item) => missionId2 === void 0 || asRecord(asRecord(item)["question"])["missionId"] === missionId2
7465
+ );
7466
+ return { questions, nextCursor: cursorOf(body["nextCursor"]) };
7467
+ },
7468
+ async plune_strategy_propose(i) {
7469
+ return write({ method: "POST", path: path19("/v1/missions/:id/strategy", str2(i["missionId"])), body: i["content"] }, i["dry_run"]);
7470
+ },
7471
+ async plune_report_request(i) {
7472
+ return write({ method: "POST", path: path19("/v1/missions/:id/report", str2(i["missionId"])), body: {} }, i["dry_run"]);
7473
+ },
7474
+ async plune_discover_tools(i) {
7475
+ const names = i["names"] ?? [];
7476
+ const unknown = names.filter((n) => !isPluneToolName(n));
7477
+ if (unknown.length > 0) return fail("invalid_input", `No such tool: ${unknown.join(", ")}. Call this tool without names to see what there is.`);
7478
+ const refused = names.filter((n) => scope === "read" && pluneTool(n).write);
7479
+ if (refused.length > 0) return fail("read_only_token", readScopeMessage(`${refused.join(", ")} cannot be switched on`));
7480
+ for (const n of names) revealed.add(n);
7481
+ const words = (i["query"] ?? "").toLowerCase().split(/\s+/).filter(Boolean);
7482
+ const visible = visibleTools();
7483
+ const available = PLUNE_TOOLS.filter((t) => !visible.includes(t.name) && !(scope === "read" && t.write)).filter((t) => words.every((w) => `${t.name} ${t.title} ${t.description}`.toLowerCase().includes(w))).map((t) => ({ name: t.name, title: t.title, description: t.description, write: t.write }));
7484
+ return { revealed: names, available, visible };
7485
+ },
7486
+ async plune_cases_update(i) {
7487
+ const cases = i["cases"];
7488
+ return batch(
7489
+ cases.map((c) => ({ method: "PATCH", path: path19("/v1/test-cases/:id", c.id), body: c.changes })),
7490
+ i["dry_run"],
7491
+ (_b, n) => cases[n]?.id
7492
+ );
7493
+ },
7494
+ async plune_run_create(i) {
7495
+ return write({ method: "POST", path: "/v1/runs", body: i["run"] }, i["dry_run"]);
7496
+ },
7497
+ async plune_results_write(i) {
7498
+ const results = i["results"];
7499
+ const req = {
7500
+ method: "POST",
7501
+ path: path19("/v1/runs/:id/results", str2(i["runId"])),
7502
+ body: withoutUndefined({ results, combination: i["combination"], configuration: i["configuration"] })
7503
+ };
7504
+ if (i["dry_run"] === true) return { dryRun: true, requested: results.length, applied: 0, requests: [planned(req)] };
7505
+ const report = asRecord((await call(req)).body);
7506
+ const accepted = asRecord(report["counts"])["accepted"];
7507
+ return { dryRun: false, requested: results.length, applied: typeof accepted === "number" ? accepted : 0, report };
7508
+ },
7509
+ async plune_endpoint_propose(i) {
7510
+ return batch(
7511
+ i["endpoints"].map((e) => ({ method: "POST", path: "/v1/review-items/endpoints", body: withoutUndefined(e) })),
7512
+ i["dry_run"],
7513
+ (b) => asRecord(b)["id"]
7514
+ );
7515
+ },
7516
+ async plune_api_request(i) {
7517
+ const method = str2(i["method"]);
7518
+ const p = safePath(opts.apiUrl, str2(i["path"]));
7519
+ if (p === void 0) return fail("invalid_input", `path must be a path under /v1 on ${new URL(opts.apiUrl).origin}, e.g. /v1/suites \u2014 no host, no dot segments, no query (use query)`);
7520
+ const query = i["query"];
7521
+ const req = withoutUndefined({ method, path: p, query, body: i["body"] });
7522
+ if (i["dry_run"] === true) return { dryRun: true, request: planned(req) };
7523
+ const confirm2 = i["confirm"];
7524
+ if (isDeletion(req)) {
7525
+ const t = now();
7526
+ for (const [k, v] of confirms) if (v.expires <= t) confirms.delete(k);
7527
+ if (confirm2 === void 0) {
7528
+ const value2 = (0, import_node_crypto5.randomBytes)(24).toString("base64url");
7529
+ confirms.set(value2, { fingerprint: fingerprint(req), expires: t + CONFIRM_TTL_MS });
7530
+ return {
7531
+ preview: {
7532
+ action: "delete",
7533
+ method,
7534
+ path: p,
7535
+ confirm: value2,
7536
+ expiresInSeconds: CONFIRM_TTL_MS / 1e3,
7537
+ note: "Nothing was sent. Repeat the same call with confirm to send it. The platform refuses an agent any deletion (agent_cannot_delete, bulk_delete_needs_person): a person deletes from the dashboard."
7538
+ }
7539
+ };
7540
+ }
7541
+ const held = confirms.get(confirm2);
7542
+ confirms.delete(confirm2);
7543
+ if (held === void 0 || held.fingerprint !== fingerprint(req))
7544
+ return fail("confirm_invalid", "confirm is unknown, expired, used, or for another request \u2014 repeat the call without it to get a new preview");
7545
+ } else if (confirm2 !== void 0) {
7546
+ req.query = { ...query, confirm: confirm2 };
7547
+ }
7548
+ const res = await call(req);
7549
+ return { status: res.status, body: res.body };
7550
+ }
7551
+ };
7552
+ return {
7553
+ sessionId,
7554
+ scope: () => scope,
7555
+ visibleTools,
7556
+ async execute(name, input) {
7557
+ const handler = isPluneToolName(name) ? handlers[name] : void 0;
7558
+ if (handler === void 0) return fail("unknown_tool", `No such tool: ${name}.`);
7559
+ const tool = pluneTool(name);
7560
+ const parsed = tool.input.safeParse(input ?? {});
7561
+ if (!parsed.success) return fail("invalid_input", `The input does not fit: ${zodMessage(parsed.error)}`);
7562
+ const args = parsed.data;
7563
+ const writes = name === "plune_api_request" ? rawWrites(str2(args["method"]), str2(args["path"])) : tool.write;
7564
+ if (writes && scope === "read" && args["dry_run"] !== true) return fail("read_only_token", readScopeMessage(`${name} is refused before any request`));
7565
+ try {
7566
+ const out = await handler(args);
7567
+ return "ok" in out && typeof out["ok"] === "boolean" && ("error" in out || "data" in out) ? out : { ok: true, data: out };
7568
+ } catch (err) {
7569
+ return { ok: false, error: errorOf(err) };
7570
+ }
7571
+ }
7572
+ };
7573
+ }
7574
+ var import_node_crypto5, CONFIRM_TTL_MS, READ_ONLY_REFUSAL, readScopeMessage, fail, zodMessage, path19, withoutUndefined, asRecord, asArray, cursorOf, rawWrites;
7575
+ var init_execute = __esm({
7576
+ "src/mcp-tools/execute.ts"() {
7577
+ "use strict";
7578
+ init_cjs_shims();
7579
+ import_node_crypto5 = require("crypto");
7580
+ init_client2();
7581
+ init_contract();
7582
+ init_schemas();
7583
+ CONFIRM_TTL_MS = 5 * 60 * 1e3;
7584
+ READ_ONLY_REFUSAL = "read-only token";
7585
+ readScopeMessage = (what) => `This token has the read scope, so ${what}. Use a token with the write scope (Plune \u2192 Account \u2192 API tokens) and restart the server.`;
7586
+ fail = (code, message, extra = {}) => ({
7587
+ ok: false,
7588
+ error: { code, message, ...extra }
7589
+ });
7590
+ zodMessage = (error) => error.issues.map((i) => `${i.path.join(".") || "(input)"}: ${i.message}`).join("; ");
7591
+ path19 = (template, ...ids) => {
7592
+ let i = 0;
7593
+ return template.replace(/:[a-z]+/gi, () => encodeURIComponent(ids[i++] ?? ""));
7594
+ };
7595
+ withoutUndefined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
7596
+ asRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
7597
+ asArray = (v) => Array.isArray(v) ? v : [];
7598
+ cursorOf = (v) => typeof v === "string" ? v : null;
7599
+ rawWrites = (method, p) => method !== "GET" && !(method === "POST" && p === "/v1/test-cases/resolve");
7600
+ }
7601
+ });
7602
+
7603
+ // src/mcp-tools/index.ts
7604
+ var init_mcp_tools = __esm({
7605
+ "src/mcp-tools/index.ts"() {
7606
+ "use strict";
7607
+ init_cjs_shims();
7608
+ init_contract();
7609
+ init_execute();
7610
+ init_client2();
7611
+ init_schemas();
7612
+ }
7613
+ });
7614
+
7615
+ // src/mcp/server.ts
7616
+ var server_exports = {};
7617
+ __export(server_exports, {
7618
+ createPluneMcpServer: () => createPluneMcpServer,
7619
+ serveStdio: () => serveStdio
7620
+ });
7621
+ function toResult(outcome) {
7622
+ if (outcome.ok) {
7623
+ return { content: [{ type: "text", text: JSON.stringify(outcome.data, null, 2) }], structuredContent: outcome.data };
7624
+ }
7625
+ return {
7626
+ isError: true,
7627
+ content: [{ type: "text", text: outcome.error.message }],
7628
+ structuredContent: { error: outcome.error }
7629
+ };
7630
+ }
7631
+ function createPluneMcpServer(opts) {
7632
+ const { version, ...executorOptions } = opts;
7633
+ const executor = createPluneToolExecutor({ ...executorOptions, userAgent: executorOptions.userAgent ?? `plune-cli/${version} mcp` });
7634
+ const server = new import_mcp.McpServer({ name: "plune", version }, { capabilities: { tools: { listChanged: true } } });
7635
+ const registered = /* @__PURE__ */ new Map();
7636
+ const sync = () => {
7637
+ const visible = new Set(executor.visibleTools());
7638
+ for (const [name, tool] of registered) {
7639
+ if (visible.has(name) && !tool.enabled) tool.enable();
7640
+ else if (!visible.has(name) && tool.enabled) tool.disable();
7641
+ }
7642
+ };
7643
+ for (const tool of PLUNE_TOOLS) {
7644
+ if (opts.scope === "read" && tool.write) continue;
7645
+ const handle = server.registerTool(
7646
+ tool.name,
7647
+ {
7648
+ title: tool.title,
7649
+ description: tool.description,
7650
+ inputSchema: tool.input,
7651
+ outputSchema: tool.output,
7652
+ annotations: {
7653
+ title: tool.title,
7654
+ readOnlyHint: !tool.write,
7655
+ destructiveHint: tool.name === "plune_api_request",
7656
+ openWorldHint: false
7657
+ }
7658
+ },
7659
+ async (args) => {
7660
+ const outcome = await executor.execute(tool.name, args);
7661
+ sync();
7662
+ return toResult(outcome);
7663
+ }
7664
+ );
7665
+ registered.set(tool.name, handle);
7666
+ }
7667
+ sync();
7668
+ return server;
7669
+ }
7670
+ async function serveStdio(opts) {
7671
+ const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
7672
+ const server = createPluneMcpServer(opts);
7673
+ const transport = new StdioServerTransport();
7674
+ const closed = new Promise((resolve11) => {
7675
+ transport.onclose = () => resolve11();
7676
+ });
7677
+ await server.connect(transport);
7678
+ await closed;
7679
+ }
7680
+ var import_mcp;
7681
+ var init_server = __esm({
7682
+ "src/mcp/server.ts"() {
7683
+ "use strict";
7684
+ init_cjs_shims();
7685
+ import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
7686
+ init_mcp_tools();
7687
+ }
7688
+ });
7689
+
7690
+ // src/cli/commands/mcp.ts
7691
+ var mcp_exports = {};
7692
+ __export(mcp_exports, {
7693
+ handleMcpServe: () => handleMcpServe
7694
+ });
7695
+ async function handleMcpServe(flags, deps) {
7696
+ const token = (deps.loadToken ?? loadToken)();
7697
+ if (token === null) throw new NotLoggedInError();
7698
+ const apiUrl = resolveApiUrl(deps.apiUrl);
7699
+ const projectId = flags.project?.trim() || process.env["PLUNE_PROJECT"]?.trim() || void 0;
7700
+ const serve = deps.serve ?? (await Promise.resolve().then(() => (init_server(), server_exports))).serveStdio;
7701
+ await serve({
7702
+ token,
7703
+ apiUrl,
7704
+ version: deps.version,
7705
+ ...projectId !== void 0 ? { projectId } : {},
7706
+ ...flags.readOnly === true ? { scope: "read" } : {}
7707
+ });
7708
+ }
7709
+ var init_mcp = __esm({
7710
+ "src/cli/commands/mcp.ts"() {
7711
+ "use strict";
7712
+ init_cjs_shims();
7713
+ init_credentials();
7714
+ init_api_url();
7715
+ init_sync();
7716
+ }
7717
+ });
7718
+
6692
7719
  // src/cli/commands/cairn-artifact.ts
6693
7720
  async function findLatestCairnRun(runsDir = "runs") {
6694
7721
  const entries = await fs17.readdir(runsDir, { withFileTypes: true }).catch(() => null);
@@ -6696,9 +7723,9 @@ async function findLatestCairnRun(runsDir = "runs") {
6696
7723
  let newest;
6697
7724
  for (const entry of entries) {
6698
7725
  if (!entry.isDirectory()) continue;
6699
- const dir = path19.join(runsDir, entry.name);
7726
+ const dir = path20.join(runsDir, entry.name);
6700
7727
  try {
6701
- const report = await fs17.lstat(path19.join(dir, "report.json"));
7728
+ const report = await fs17.lstat(path20.join(dir, "report.json"));
6702
7729
  if (!report.isFile()) continue;
6703
7730
  if (newest === void 0 || report.mtimeMs > newest.at) {
6704
7731
  newest = { dir, at: report.mtimeMs };
@@ -6717,10 +7744,10 @@ async function readJson(file) {
6717
7744
  }
6718
7745
  }
6719
7746
  async function defaultReadEvidence(dir) {
6720
- const raw = await readJson(path19.join(dir, "api-evidence.json"));
7747
+ const raw = await readJson(path20.join(dir, "api-evidence.json"));
6721
7748
  if (!Array.isArray(raw)) return void 0;
6722
7749
  return raw.map((r) => {
6723
- const rec = asRecord(r);
7750
+ const rec = asRecord2(r);
6724
7751
  const error = str(rec["error"]);
6725
7752
  return { name: str(rec["name"]), passed: rec["passed"] === true, ...error !== "" ? { error } : {} };
6726
7753
  });
@@ -6728,7 +7755,7 @@ async function defaultReadEvidence(dir) {
6728
7755
  function casesFromDesign(report) {
6729
7756
  const list2 = Array.isArray(report["testCases"]) ? report["testCases"] : [];
6730
7757
  return list2.map((raw) => {
6731
- const c = asRecord(raw);
7758
+ const c = asRecord2(raw);
6732
7759
  const steps = (Array.isArray(c["steps"]) ? c["steps"] : []).map((s) => str(s)).filter((s) => s !== "");
6733
7760
  return {
6734
7761
  stableId: str(c["stableId"]),
@@ -6744,7 +7771,7 @@ function casesFromDesign(report) {
6744
7771
  function casesFromApi(report, verdicts) {
6745
7772
  const list2 = Array.isArray(report["cases"]) ? report["cases"] : [];
6746
7773
  return list2.map((raw) => {
6747
- const c = asRecord(raw);
7774
+ const c = asRecord2(raw);
6748
7775
  const name = str(c["name"], "unnamed operation");
6749
7776
  const request = `${str(c["method"], "GET")} ${str(c["path"], "/")}`;
6750
7777
  const verdict = verdicts.get(name);
@@ -6760,9 +7787,9 @@ function casesFromApi(report, verdicts) {
6760
7787
  });
6761
7788
  }
6762
7789
  async function readCairnRun(dir, deps = {}) {
6763
- const raw = await readJson(path19.join(dir, "report.json"));
7790
+ const raw = await readJson(path20.join(dir, "report.json"));
6764
7791
  if (raw === void 0) throw new CairnRunNotFoundError(dir);
6765
- const report = asRecord(raw);
7792
+ const report = asRecord2(raw);
6766
7793
  if (report["schemaVersion"] !== SUPPORTED_ARTIFACT_VERSION) {
6767
7794
  throw new CairnRunVersionError(report["schemaVersion"]);
6768
7795
  }
@@ -6790,20 +7817,20 @@ async function readCairnRun(dir, deps = {}) {
6790
7817
  return {
6791
7818
  schemaVersion: INGEST_SCHEMA_VERSION,
6792
7819
  run: {
6793
- runId: str(report["runId"], path19.basename(dir)),
7820
+ runId: str(report["runId"], path20.basename(dir)),
6794
7821
  mode,
6795
7822
  target: str(report["url"], "unknown")
6796
7823
  },
6797
7824
  cases
6798
7825
  };
6799
7826
  }
6800
- var fs17, path19, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
7827
+ var fs17, path20, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord2, str;
6801
7828
  var init_cairn_artifact = __esm({
6802
7829
  "src/cli/commands/cairn-artifact.ts"() {
6803
7830
  "use strict";
6804
7831
  init_cjs_shims();
6805
7832
  fs17 = __toESM(require("fs/promises"), 1);
6806
- path19 = __toESM(require("path"), 1);
7833
+ path20 = __toESM(require("path"), 1);
6807
7834
  SUPPORTED_ARTIFACT_VERSION = 1;
6808
7835
  INGEST_SCHEMA_VERSION = 1;
6809
7836
  NoCairnRunsError = class extends Error {
@@ -6842,7 +7869,7 @@ var init_cairn_artifact = __esm({
6842
7869
  this.name = "CairnEvidenceAmbiguousError";
6843
7870
  }
6844
7871
  };
6845
- asRecord = (v) => typeof v === "object" && v !== null ? v : {};
7872
+ asRecord2 = (v) => typeof v === "object" && v !== null ? v : {};
6846
7873
  str = (v, fallback = "") => typeof v === "string" && v !== "" ? v : fallback;
6847
7874
  }
6848
7875
  });
@@ -6875,7 +7902,7 @@ async function handleIngest(deps) {
6875
7902
  const doFetch = deps.fetchImpl ?? fetch;
6876
7903
  const endpoint = `${apiUrl}/v1/ingest/cairn`;
6877
7904
  const job = new JobReporter(doFetch, apiUrl, token, "ingest");
6878
- await job.open(path20.basename(path20.resolve(dir)), payload.cases.length);
7905
+ await job.open(path21.basename(path21.resolve(dir)), payload.cases.length);
6879
7906
  try {
6880
7907
  const result = await upload();
6881
7908
  await job.uploaded({ subjectId: result.runId || void 0 });
@@ -6934,12 +7961,12 @@ function formatIngestResult(r) {
6934
7961
  return `${lines.join("\n")}
6935
7962
  `;
6936
7963
  }
6937
- var path20, IngestNotLoggedInError, IngestTokenRejectedError, IngestNetworkError, IngestHttpError;
7964
+ var path21, IngestNotLoggedInError, IngestTokenRejectedError, IngestNetworkError, IngestHttpError;
6938
7965
  var init_ingest = __esm({
6939
7966
  "src/cli/commands/ingest.ts"() {
6940
7967
  "use strict";
6941
7968
  init_cjs_shims();
6942
- path20 = __toESM(require("path"), 1);
7969
+ path21 = __toESM(require("path"), 1);
6943
7970
  init_api_url();
6944
7971
  init_credentials();
6945
7972
  init_cairn_artifact();
@@ -7374,10 +8401,10 @@ function testsOfListing(listing) {
7374
8401
  const annotated = spec.tests?.flatMap((t) => t.annotations ?? []).find(
7375
8402
  (a) => a.type === "PluneId" && a.description !== void 0 && a.description !== ""
7376
8403
  )?.description;
7377
- const id = annotated ?? TOKEN4.exec(spec.title)?.[1];
8404
+ const id2 = annotated ?? TOKEN4.exec(spec.title)?.[1];
7378
8405
  const pathTitle = [...titles, spec.title].join("#");
7379
8406
  if (!found.has(pathTitle))
7380
- found.set(pathTitle, { pathTitle, ...id === void 0 ? {} : { id } });
8407
+ found.set(pathTitle, { pathTitle, ...id2 === void 0 ? {} : { id: id2 } });
7381
8408
  }
7382
8409
  for (const child of suite.suites ?? []) walk2(child, [...titles, child.title]);
7383
8410
  };
@@ -7814,10 +8841,10 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7814
8841
  }
7815
8842
  });
7816
8843
  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(
7817
- async (id, options, command) => {
8844
+ async (id2, options, command) => {
7818
8845
  try {
7819
8846
  const { handleRunFinish: handleRunFinish2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
7820
- const code = await handleRunFinish2(id, {
8847
+ const code = await handleRunFinish2(id2, {
7821
8848
  terminate: options.terminate,
7822
8849
  ...options.reason !== void 0 ? { reason: options.reason } : {},
7823
8850
  ...options.failOnFailures ? { failOnFailures: true } : {}
@@ -7846,10 +8873,10 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7846
8873
  runCommand.command("delete").argument(
7847
8874
  "<id>",
7848
8875
  "The run id \u2014 the one `plune run import` printed, or the one in the dashboard URL"
7849
- ).description("Delete a run and everything it produced \u2014 recoverable for 30 days").action(async (id, _options, command) => {
8876
+ ).description("Delete a run and everything it produced \u2014 recoverable for 30 days").action(async (id2, _options, command) => {
7850
8877
  try {
7851
8878
  const { handleRunDelete: handleRunDelete2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
7852
- await handleRunDelete2(id);
8879
+ await handleRunDelete2(id2);
7853
8880
  } catch (err) {
7854
8881
  await failRunCommand(err, verboseOf(command));
7855
8882
  }
@@ -8035,10 +9062,10 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
8035
9062
  const { handleLogin: handleLogin2, reportLoginFailure: reportLoginFailure2 } = await Promise.resolve().then(() => (init_login(), login_exports));
8036
9063
  try {
8037
9064
  const token = options.token ?? await readTokenFromStdin();
8038
- const { path: path21, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
9065
+ const { path: path22, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
8039
9066
  process.stdout.write(
8040
- verified ? `Logged in. Token checked against the API and saved to ${path21}
8041
- ` : `Token saved to ${path21} WITHOUT being checked (--skip-verify).
9067
+ verified ? `Logged in. Token checked against the API and saved to ${path22}
9068
+ ` : `Token saved to ${path22} WITHOUT being checked (--skip-verify).
8042
9069
  `
8043
9070
  );
8044
9071
  } catch (err) {
@@ -8055,27 +9082,46 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
8055
9082
  const verbose = command.optsWithGlobals().verbose === true;
8056
9083
  const { handleLogout: handleLogout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
8057
9084
  try {
8058
- const { removed, path: path21 } = handleLogout2();
9085
+ const { removed, path: path22 } = handleLogout2();
8059
9086
  process.stdout.write(
8060
- removed ? `Logged out. Removed ${path21}
9087
+ removed ? `Logged out. Removed ${path22}
8061
9088
  ` : "Not logged in \u2014 nothing to remove.\n"
8062
9089
  );
8063
9090
  } catch (err) {
8064
9091
  failUnexpected(err, verbose);
8065
9092
  }
8066
9093
  });
9094
+ program.command("mcp").description("Plune tools for AI agents over the Model Context Protocol").command("serve").description("Serve the Plune tools over MCP stdio, for Claude Code, Codex and other MCP clients").option("--project <id>", "Project the tools work in (default: PLUNE_PROJECT, else the token's own project)").option("--read-only", "Offer only the read tools, as for a read token", false).action(async (options, command) => {
9095
+ const verbose = command.optsWithGlobals().verbose === true;
9096
+ const { handleMcpServe: handleMcpServe2 } = await Promise.resolve().then(() => (init_mcp(), mcp_exports));
9097
+ const { NotLoggedInError: NotLoggedInError2 } = await Promise.resolve().then(() => (init_sync(), sync_exports));
9098
+ try {
9099
+ await handleMcpServe2(
9100
+ { project: options.project, readOnly: options.readOnly },
9101
+ { version: pkg.version }
9102
+ );
9103
+ } catch (err) {
9104
+ if (err instanceof NotLoggedInError2) {
9105
+ process.stderr.write(`plune: ${err.message}
9106
+ `);
9107
+ process.exitCode = 2;
9108
+ return;
9109
+ }
9110
+ failUnexpected(err, verbose);
9111
+ }
9112
+ });
8067
9113
  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) => {
8068
9114
  const globals = command.optsWithGlobals();
8069
9115
  const verbose = globals.verbose === true;
8070
9116
  const { handleSync: handleSync2, reportSyncFailure: reportSyncFailure2 } = await Promise.resolve().then(() => (init_sync(), sync_exports));
8071
9117
  const cwd = globals.config !== void 0 ? (0, import_node_path6.dirname)((0, import_node_path6.resolve)(globals.config)) : process.cwd();
8072
9118
  try {
8073
- const { id, url, gate, jobProblem } = await handleSync2({
9119
+ const { id: id2, url, gate, jobProblem } = await handleSync2({
8074
9120
  cwd,
8075
9121
  ...options.file !== void 0 ? { file: options.file } : {},
8076
9122
  ...options.failOnFailures ? { failOnFailures: true } : {}
8077
9123
  });
8078
- process.stdout.write(`Synced run ${id} \u2192 ${url}
9124
+ process.stdout.write(`Synced run ${id2} \u2192 ${url}
8079
9125
  `);
8080
9126
  if (jobProblem !== void 0) {
8081
9127
  process.stderr.write(`plune: could not report this sync to the job queue (${jobProblem}) \u2014 the run is uploaded.
@@ -8218,12 +9264,12 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
8218
9264
  const planCommand = program.command("plan").description("Test plans on the platform \u2014 what a plan collects, as something a runner can take");
8219
9265
  planCommand.command("grep").argument("<id>", "The plan").description(
8220
9266
  "Print the plan's cases as one --grep pattern for the runner (GET /v1/plans/:id/cases)"
8221
- ).action(async (id, _options, command) => {
9267
+ ).action(async (id2, _options, command) => {
8222
9268
  const verbose = command.optsWithGlobals().verbose === true;
8223
9269
  const { handlePlanGrep: handlePlanGrep2 } = await Promise.resolve().then(() => (init_plan(), plan_exports));
8224
9270
  const { reportCasesFailure: reportCasesFailure2 } = await Promise.resolve().then(() => (init_cases(), cases_exports));
8225
9271
  try {
8226
- const out = await handlePlanGrep2({ planId: id });
9272
+ const out = await handlePlanGrep2({ planId: id2 });
8227
9273
  process.stdout.write(out.pattern + "\n");
8228
9274
  process.stderr.write(
8229
9275
  out.cases === 0 ? "plune: the plan matches no case \u2014 the pattern matches nothing.\n" : `plune: ${out.cases} case(s) in the plan, ${out.keyed} with a test key; the rest by title.