@plune-ai/cli 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -165,9 +165,20 @@ plune requirements import prd.md --sections Requirements --unit bullets --key-pr
165
165
 
166
166
  `--sections` takes the heading or its number; `--unit bullets|subheadings` (default: sub-headings when the section has any); `--key-prefix` is text a bullet starts with that carries its key (`REQ-12: The system shall ...`; no pattern is accepted); `--priority-from subheading` makes the bullets under a sub-heading take its text as their priority.
167
167
 
168
- **3. Read the plan, then write it.** Without `--yes` nothing is written. The report is the summary line (`create 3 · update 0 · unchanged 12 · skip 1`), the rows grouped by action with their line in the file, key, title and reason (a skipped row says why), the priority and category values seen and what they map to, and what was **not imported** (other tables, other sections, frontmatter keys). It ends with `dry run — nothing was written; add --yes to apply`. Add `--yes` to apply the same plan in one transaction. A row with a key matches the requirement of that key; one without matches by exact title; running the same file again changes nothing (every row is `unchanged`). A skipped row does not fail the command.
168
+ **2c. Several tables in one run.** A PRD keeps its functional and non-functional requirements in different tables. Repeat `--table` and put each table's columns inside it, after the number and a colon:
169
169
 
170
- `--json` prints the platform's answer instead. The project is the only one of the account, or `--project <id>` / `PLUNE_PROJECT` (a token only works in the project it was minted in). Exit `0` on success, `2` for a flag combination that cannot work (`--table` with `--sections`, `--title-column` with `--text-column`), an unreadable or over-limit file, or a rejected token, `1` when the platform refuses (400, 403, 409 a key taken meanwhile, 413) or does not have the routes yet.
170
+ ```bash
171
+ plune requirements import prd.md \
172
+ --table "1:key=ID,text=Requirement,priority=Priority,fixed-category=functional" \
173
+ --table "2:key=ID,text=Requirement,fixed-category=non-functional" \
174
+ --map-priority "Must=Must-have"
175
+ ```
176
+
177
+ Inside a `--table` the names are `key`, `title` or `text` (one of the two), `description`, `priority`, `category`, `type`, `status`, `criteria`, plus `fixed-category=functional|non-functional|other` and `fixed-type=user_story|feature|epic` — one meaning for the whole table, used where a row has none of its own. Names are separated by commas, so a header that contains a comma cannot be named in this form. `--map-priority`, `--type` and `--map-category` are shared by all tables. A table number may be given once. The single-table form (`--table 0 --key-column ...`) keeps working, but the two forms are not mixed: `--key-column` and the other column flags are refused together with `--table "<n>:..."`. The plan shows `table #N "heading"` in front of each row.
178
+
179
+ **3. Read the plan, then write it.** Without `--yes` nothing is written. The report is the summary line (`create 3 · update 0 · unchanged 12 · skip 1`), the rows grouped by action with their line in the file, key, title and reason (a skipped row says why), the priority and category values seen and what they map to, and what was **not imported** (other tables, other sections, frontmatter keys). It ends with `dry run — nothing was written; add --yes to apply`. Add `--yes` to apply the same plan in one transaction. A row with a key matches the requirement of that key; one without matches by exact title; running the same file again changes nothing (every row is `unchanged`). A skipped row does not fail the command. Rows that are only placeholders (`[Behavior]`) are skipped with the reason `template placeholder`; a key that appears twice in the file is skipped with `first on line N, again on line M`. When every row is a placeholder the plan says `this file is an empty template` (`--json` has `"emptyTemplate": true`) and the exit code is `0`, like any plan whose rows are skipped: nothing is written, and nothing is an error.
180
+
181
+ `--json` prints the platform's answer instead. The project is the only one of the account, or `--project <id>` / `PLUNE_PROJECT` (a token only works in the project it was minted in). Exit `0` on success, `2` for a flag combination that cannot work (`--table` with `--sections`, `--title-column` with `--text-column`, a `--table "<n>:..."` spec with the single-table column flags), an unreadable or over-limit file, or a rejected token, `1` when the platform refuses (400, 403, 409 a key taken meanwhile, 413) or does not have the routes yet.
171
182
 
172
183
  ### A release gate: `--fail-on-failures`
173
184
 
package/dist/cli.cjs CHANGED
@@ -2315,7 +2315,8 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
2315
2315
  return new Promise((resolve10, reject) => {
2316
2316
  const target = new URL(url);
2317
2317
  const send = target.protocol === "https:" ? import_node_https.request : import_node_http.request;
2318
- const req = send(target, { method: init.method, headers: init.headers }, (res) => {
2318
+ const agent = POOLS[target.protocol === "https:" ? "https:" : "http:"];
2319
+ const req = send(target, { method: init.method, headers: init.headers, agent }, (res) => {
2319
2320
  const chunks = [];
2320
2321
  res.on("data", (chunk2) => chunks.push(chunk2));
2321
2322
  res.on("error", reject);
@@ -2506,7 +2507,7 @@ function createClient(opts) {
2506
2507
  }
2507
2508
  };
2508
2509
  }
2509
- var import_node_fs2, import_node_stream, import_node_http, import_node_https, BACKOFF_BASE_MS, BACKOFF_CAP_MS, DEFAULT_RETRIES, RETRY_AFTER_CAP_MS, IDLE_MS, withoutLength, codeOfError, sleep;
2510
+ var import_node_fs2, import_node_stream, import_node_http, import_node_https, BACKOFF_BASE_MS, BACKOFF_CAP_MS, DEFAULT_RETRIES, RETRY_AFTER_CAP_MS, POOLS, IDLE_MS, withoutLength, codeOfError, sleep;
2510
2511
  var init_client = __esm({
2511
2512
  "src/reporter-core/client.ts"() {
2512
2513
  "use strict";
@@ -2519,6 +2520,7 @@ var init_client = __esm({
2519
2520
  BACKOFF_CAP_MS = 1e4;
2520
2521
  DEFAULT_RETRIES = 2;
2521
2522
  RETRY_AFTER_CAP_MS = 6e4;
2523
+ POOLS = { "http:": new import_node_http.Agent({ keepAlive: true }), "https:": new import_node_https.Agent({ keepAlive: true }) };
2522
2524
  IDLE_MS = 3e5;
2523
2525
  withoutLength = (headers) => Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "content-length"));
2524
2526
  codeOfError = (err) => {
@@ -3558,7 +3560,29 @@ async function startRun(passed, declared2) {
3558
3560
  else await uploadBatched(jobs);
3559
3561
  uploadMs += Date.now() - started;
3560
3562
  }
3563
+ const sending = /* @__PURE__ */ new Set();
3564
+ let sendError;
3565
+ let answered = false;
3566
+ async function dispatch(work) {
3567
+ while (sending.size >= (answered ? SEND_CONCURRENCY : 1)) await Promise.race(sending);
3568
+ const task = work().catch((err) => {
3569
+ sendError ??= err;
3570
+ }).finally(() => sending.delete(task));
3571
+ sending.add(task);
3572
+ }
3573
+ async function settle() {
3574
+ while (sending.size > 0) await Promise.all([...sending]);
3575
+ if (sendError !== void 0) {
3576
+ const err = sendError;
3577
+ sendError = void 0;
3578
+ throw err;
3579
+ }
3580
+ }
3561
3581
  async function flush() {
3582
+ await flushBuffer();
3583
+ await settle();
3584
+ }
3585
+ async function flushBuffer() {
3562
3586
  if (buffer.length === 0) return;
3563
3587
  const pending = buffer;
3564
3588
  buffer = [];
@@ -3566,6 +3590,9 @@ async function startRun(passed, declared2) {
3566
3590
  defer(pending);
3567
3591
  return;
3568
3592
  }
3593
+ await dispatch(() => deliver(pending));
3594
+ }
3595
+ async function deliver(pending) {
3569
3596
  await resolveKeys(pending.flatMap((p) => p.keys));
3570
3597
  const submissions = [];
3571
3598
  const unanswered = [];
@@ -3606,6 +3633,7 @@ async function startRun(passed, declared2) {
3606
3633
  for (const k of told) stats[k] += counts[k];
3607
3634
  progress();
3608
3635
  await upload(out.ok ? out.body?.items : void 0, filesByKey);
3636
+ answered = true;
3609
3637
  continue;
3610
3638
  }
3611
3639
  if (out.ok) {
@@ -3680,7 +3708,7 @@ async function startRun(passed, declared2) {
3680
3708
  },
3681
3709
  async add(result) {
3682
3710
  buffer.push({ ...result, keys: bound(result.keys) });
3683
- if (buffer.length >= batchSize) await flush();
3711
+ if (buffer.length >= batchSize) await flushBuffer();
3684
3712
  },
3685
3713
  flush,
3686
3714
  async finish(reason) {
@@ -3745,7 +3773,7 @@ async function startRun(passed, declared2) {
3745
3773
  }
3746
3774
  };
3747
3775
  }
3748
- var import_node_crypto3, import_node_fs5, import_node_path3, import_node_util2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TEXT_BYTES, TEXTS_PER_RESULT, UTF8, TYPE_BY_EXTENSION, METADATA_TYPE2, NAME_MAX, SKIPPED, BUCKET_FOUND, KINDS, codeOf2, BATCH_BYTES, ENVELOPE_BYTES;
3776
+ var import_node_crypto3, import_node_fs5, import_node_path3, import_node_util2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, SEND_CONCURRENCY, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TEXT_BYTES, TEXTS_PER_RESULT, UTF8, TYPE_BY_EXTENSION, METADATA_TYPE2, NAME_MAX, SKIPPED, BUCKET_FOUND, KINDS, codeOf2, BATCH_BYTES, ENVELOPE_BYTES;
3749
3777
  var init_session = __esm({
3750
3778
  "src/reporter-core/session.ts"() {
3751
3779
  "use strict";
@@ -3765,6 +3793,7 @@ var init_session = __esm({
3765
3793
  BATCH_MAX = 500;
3766
3794
  DISCOVER_MAX = 500;
3767
3795
  BATCH_DEFAULT = 500;
3796
+ SEND_CONCURRENCY = 4;
3768
3797
  EXPECTED_MAX = 1e4;
3769
3798
  KEY_MAX = 1024;
3770
3799
  TITLE_MAX = 300;
@@ -6291,6 +6320,7 @@ __export(requirements_import_exports, {
6291
6320
  formatReport: () => formatReport2,
6292
6321
  handleRequirementsImport: () => handleRequirementsImport,
6293
6322
  parseMap: () => parseMap,
6323
+ parseTableSpec: () => parseTableSpec,
6294
6324
  reportRequirementsImportFailure: () => reportRequirementsImportFailure
6295
6325
  });
6296
6326
  function parseMap(flag2, raw) {
@@ -6306,25 +6336,63 @@ function parseMap(flag2, raw) {
6306
6336
  if (Object.keys(out).length === 0) usage(`${flag2} is empty. Write it like ${flag2} "Critical=Must-have,High=Should-have".`);
6307
6337
  return out;
6308
6338
  }
6339
+ function parseTableSpec(spec) {
6340
+ const at = spec.indexOf(":");
6341
+ const index = (at < 0 ? spec : spec.slice(0, at)).trim();
6342
+ if (!/^\d+$/.test(index)) usage(`--table "${spec}": "${index}" is not a table number. ${SPEC_HELP}`);
6343
+ const t = { table: Number(index) };
6344
+ for (const pair of (at < 0 ? "" : spec.slice(at + 1)).split(",")) {
6345
+ if (pair.trim() === "") continue;
6346
+ const eq = pair.indexOf("=");
6347
+ const name = (eq < 0 ? pair : pair.slice(0, eq)).trim().toLowerCase();
6348
+ const value2 = eq < 0 ? "" : pair.slice(eq + 1).trim();
6349
+ const field = TABLE_SPEC_KEYS[name];
6350
+ if (field === void 0) return usage(`--table "${spec}": "${name}" is not a column name. Use ${Object.keys(TABLE_SPEC_KEYS).join(", ")}.`);
6351
+ if (value2 === "") usage(`--table "${spec}": "${name}" has no value. ${SPEC_HELP}`);
6352
+ if (field in t) usage(`--table "${spec}": "${name}" is given twice.`);
6353
+ if (field === "fixedCategory") oneOf("fixed-category", value2, ["functional", "non-functional", "other"]);
6354
+ if (field === "fixedType") oneOf("fixed-type", value2, ["user_story", "feature", "epic"]);
6355
+ t[field] = value2;
6356
+ }
6357
+ if (t["titleColumn"] !== void 0 && t["textColumn"] !== void 0) usage(`--table "${spec}": give title= or text=, not both.`);
6358
+ if (t["titleColumn"] === void 0 && t["textColumn"] === void 0) usage(`--table "${spec}": each table needs title=<header> or text=<header>.`);
6359
+ return t;
6360
+ }
6309
6361
  function buildImportBody(f) {
6362
+ const tables = f.table === void 0 ? [] : Array.isArray(f.table) ? f.table : [f.table];
6310
6363
  const columnFlags = COLUMN_FLAGS.filter(([k]) => f[k] !== void 0);
6311
6364
  const sectionFlags = SECTION_FLAGS.filter(([k]) => f[k] !== void 0);
6312
- const anyMapping = f.table !== void 0 || f.sections !== void 0 || columnFlags.length > 0 || sectionFlags.length > 0 || f.mapPriority !== void 0 || f.mapCategory !== void 0 || f.type !== void 0;
6365
+ const anyMapping = tables.length > 0 || f.sections !== void 0 || columnFlags.length > 0 || sectionFlags.length > 0 || f.mapPriority !== void 0 || f.mapCategory !== void 0 || f.type !== void 0;
6313
6366
  if (!anyMapping) return null;
6314
- if (f.table !== void 0 && f.sections !== void 0) usage("Give --table or --sections, not both: one import reads one table or one section.");
6315
- if (f.table === void 0 && f.sections === void 0) {
6367
+ if (tables.length > 0 && f.sections !== void 0) usage("Give --table or --sections, not both: one import reads one table or one section.");
6368
+ if (tables.length === 0 && f.sections === void 0) {
6316
6369
  usage("The mapping flags need --table <n> (a table) or --sections <heading|n> (a section). Run without flags to see which the file has.");
6317
6370
  }
6318
6371
  const body = {};
6319
6372
  if (f.mapPriority !== void 0) body["priorityMap"] = parseMap("--map-priority", f.mapPriority);
6320
6373
  if (f.type !== void 0) body["defaultType"] = oneOf("--type", f.type, ["user_story", "feature", "epic"]);
6321
- if (f.table !== void 0) {
6322
- if (!/^\d+$/.test(f.table)) usage(`--table is the number of the table in the list (0, 1, ...), not "${f.table}".`);
6374
+ if (tables.length > 1 || tables.some((t) => t.includes(":"))) {
6375
+ if (sectionFlags.length > 0) usage(`${sectionFlags.map(([, n]) => n).join(", ")} ${sectionFlags.length > 1 ? "are" : "is"} for --sections, not --table.`);
6376
+ if (columnFlags.length > 0) {
6377
+ usage(`${columnFlags.map(([, n]) => n).join(", ")} cannot be used with --table "<n>:...": put the column inside each --table, e.g. --table "3:key=ID,text=Requirement".`);
6378
+ }
6379
+ const specs = tables.map(parseTableSpec);
6380
+ const seen = /* @__PURE__ */ new Set();
6381
+ for (const t of specs) {
6382
+ if (seen.has(t["table"])) usage(`Table ${String(t["table"])} is given twice in --table.`);
6383
+ seen.add(t["table"]);
6384
+ }
6385
+ body["mode"] = "table";
6386
+ const categoryMap = f.mapCategory !== void 0 ? parseMap("--map-category", f.mapCategory) : void 0;
6387
+ body["tables"] = specs.map((t) => categoryMap === void 0 ? t : { ...t, categoryMap });
6388
+ } else if (tables.length === 1) {
6389
+ const only = tables[0];
6390
+ if (!/^\d+$/.test(only)) usage(`--table is the number of the table in the list (0, 1, ...), not "${only}".`);
6323
6391
  if (sectionFlags.length > 0) usage(`${sectionFlags.map(([, n]) => n).join(", ")} ${sectionFlags.length > 1 ? "are" : "is"} for --sections, not --table.`);
6324
6392
  if (f.titleColumn !== void 0 && f.textColumn !== void 0) usage("Give --title-column or --text-column, not both.");
6325
6393
  if (f.titleColumn === void 0 && f.textColumn === void 0) usage("--table needs --title-column <header> or --text-column <header>.");
6326
6394
  body["mode"] = "table";
6327
- body["table"] = Number(f.table);
6395
+ body["table"] = Number(only);
6328
6396
  for (const [k, , key] of columnFlags) body[key] = f[k];
6329
6397
  if (f.mapCategory !== void 0) body["categoryMap"] = parseMap("--map-category", f.mapCategory);
6330
6398
  } else {
@@ -6434,13 +6502,19 @@ function formatDiscover(file, r) {
6434
6502
  "",
6435
6503
  "Pick one and map it (a dry run, nothing is written):",
6436
6504
  ` plune requirements import ${file} --table <n> --key-column <header> --title-column <header>`,
6437
- ` plune requirements import ${file} --sections <heading|n> --unit bullets|subheadings`
6505
+ ` plune requirements import ${file} --sections <heading|n> --unit bullets|subheadings`,
6506
+ "Several tables in one run, each with its own columns:",
6507
+ ` plune requirements import ${file} --table "<n>:key=<header>,text=<header>,fixed-category=functional" --table "<m>:key=<header>,text=<header>"`
6438
6508
  );
6439
6509
  return out.join("\n") + "\n";
6440
6510
  }
6441
6511
  function formatReport2(report) {
6442
6512
  const s = report.summary;
6443
6513
  const out = [`create ${s.create} \xB7 update ${s.update} \xB7 unchanged ${s.unchanged} \xB7 skip ${s.skip}`];
6514
+ if (report.emptyTemplate === true) {
6515
+ const said = report.message !== void 0 && !/empty template/i.test(report.message) ? ` ${flat(report.message)}` : "";
6516
+ out.push("", `this file is an empty template: every row is a placeholder, nothing to import.${said}`);
6517
+ }
6444
6518
  for (const action of ["create", "update", "skip", "unchanged"]) {
6445
6519
  const rows = report.items.filter((i) => i.action === action);
6446
6520
  if (rows.length === 0) continue;
@@ -6448,7 +6522,8 @@ function formatReport2(report) {
6448
6522
  for (const i of rows.slice(0, SHOWN)) {
6449
6523
  const changes = i.changes !== void 0 && i.changes.length > 0 ? `; changes: ${i.changes.join(", ")}` : "";
6450
6524
  const notes = i.notes !== void 0 && i.notes.length > 0 ? `; note: ${i.notes.join("; ")}` : "";
6451
- out.push(` line ${i.line} ${i.key ?? "-"} ${cut2(flat(i.title), 80)} - ${flat(i.reason)}${changes}${notes}`);
6525
+ const from = i.table === void 0 ? "" : `table #${i.table}${i.tableHeading ? ` "${cut2(flat(i.tableHeading), 40)}"` : ""} `;
6526
+ out.push(` ${from}line ${i.line} ${i.key ?? "-"} ${cut2(flat(i.title), 80)} - ${flat(i.reason)}${changes}${notes}`);
6452
6527
  }
6453
6528
  if (rows.length > SHOWN) out.push(` ... and ${rows.length - SHOWN} more (--json lists every row)`);
6454
6529
  }
@@ -6472,7 +6547,7 @@ function reportRequirementsImportFailure(err, write) {
6472
6547
  write(err.message + "\n");
6473
6548
  return err.exitCode;
6474
6549
  }
6475
- var import_node_fs7, import_node_path4, MAX_MARKDOWN_BYTES, RequirementsImportError, usage, COLUMN_FLAGS, SECTION_FLAGS, oneOf, SUPPORT, SHOWN, flat, cut2, plural;
6550
+ var import_node_fs7, import_node_path4, MAX_MARKDOWN_BYTES, RequirementsImportError, usage, COLUMN_FLAGS, SECTION_FLAGS, oneOf, TABLE_SPEC_KEYS, SPEC_HELP, SUPPORT, SHOWN, flat, cut2, plural;
6476
6551
  var init_requirements_import = __esm({
6477
6552
  "src/cli/commands/requirements-import.ts"() {
6478
6553
  "use strict";
@@ -6511,6 +6586,20 @@ var init_requirements_import = __esm({
6511
6586
  ["priorityFrom", "--priority-from"]
6512
6587
  ];
6513
6588
  oneOf = (flag2, value2, allowed) => allowed.includes(value2) ? value2 : usage(`${flag2} must be ${allowed.join(" or ")}, not "${value2}".`);
6589
+ TABLE_SPEC_KEYS = {
6590
+ key: "keyColumn",
6591
+ title: "titleColumn",
6592
+ text: "textColumn",
6593
+ description: "descriptionColumn",
6594
+ priority: "priorityColumn",
6595
+ category: "categoryColumn",
6596
+ type: "typeColumn",
6597
+ status: "statusColumn",
6598
+ criteria: "acceptanceCriteriaColumn",
6599
+ "fixed-category": "fixedCategory",
6600
+ "fixed-type": "fixedType"
6601
+ };
6602
+ SPEC_HELP = 'Write it like --table "3:key=ID,text=Requirement,fixed-category=functional".';
6514
6603
  SUPPORT = "This platform does not support requirements import yet. Nothing was sent to a project; try a newer Plune platform.";
6515
6604
  SHOWN = 50;
6516
6605
  flat = (s) => s.replace(/\s+/g, " ").trim();
@@ -7008,7 +7097,11 @@ function createProgram() {
7008
7097
  const requirementsCommand = program.command("requirements").description("Requirements on the platform \u2014 import them from a Markdown file");
7009
7098
  requirementsCommand.command("import").argument("<file>", "A Markdown file (UTF-8, at most 1 MiB)").description(
7010
7099
  "Import requirements from a Markdown file. With no mapping flags it lists the tables and sections found; with them it is a dry run until --yes"
7011
- ).option("--table <n>", "Read the table with this number (from the list)").option("--key-column <header>", "Table: the column holding the requirement key").option("--title-column <header>", "Table: the column holding the title").option("--text-column <header>", "Table: one text column; the title is its first line (not with --title-column)").option("--description-column <header>", "Table: the description column").option("--priority-column <header>", "Table: the priority column").option("--category-column <header>", "Table: the category column").option("--type-column <header>", "Table: the type column").option("--status-column <header>", "Table: the status column (draft, active or done)").option("--criteria-column <header>", "Table: the acceptance criteria column").option("--map-priority <pairs>", 'Rename priority values, e.g. "Critical=Must-have,High=Should-have"').option("--map-category <pairs>", 'Table: rename category values, e.g. "Quality=non-functional"').option("--sections <heading|n>", "Read the section with this heading or number (from the list)").option("--unit <unit>", "Sections: bullets or subheadings (default: sub-headings if it has any)").option("--key-prefix <text>", "Sections: text a bullet or sub-heading starts with that carries its key").option("--priority-from <source>", "Sections: subheading \u2014 bullets take their sub-heading as priority").option("--type <type>", "What a row without a type becomes: user_story, feature or epic").option("--project <id>", "The project (default: PLUNE_PROJECT, else the only project of the account)").option("--yes", "Write it. Without it the import is a dry run", false).option("--json", "Print the platform\u2019s answer as JSON", false).action(async (file, options, command) => {
7100
+ ).option(
7101
+ "--table <n|spec>",
7102
+ 'Read the table with this number (from the list); repeat as --table "<n>:key=ID,text=Requirement,fixed-category=functional" for several tables',
7103
+ (value2, previous = []) => [...previous, value2]
7104
+ ).option("--key-column <header>", "Table: the column holding the requirement key").option("--title-column <header>", "Table: the column holding the title").option("--text-column <header>", "Table: one text column; the title is its first line (not with --title-column)").option("--description-column <header>", "Table: the description column").option("--priority-column <header>", "Table: the priority column").option("--category-column <header>", "Table: the category column").option("--type-column <header>", "Table: the type column").option("--status-column <header>", "Table: the status column (draft, active or done)").option("--criteria-column <header>", "Table: the acceptance criteria column").option("--map-priority <pairs>", 'Rename priority values, e.g. "Critical=Must-have,High=Should-have"').option("--map-category <pairs>", 'Table: rename category values, e.g. "Quality=non-functional"').option("--sections <heading|n>", "Read the section with this heading or number (from the list)").option("--unit <unit>", "Sections: bullets or subheadings (default: sub-headings if it has any)").option("--key-prefix <text>", "Sections: text a bullet or sub-heading starts with that carries its key").option("--priority-from <source>", "Sections: subheading \u2014 bullets take their sub-heading as priority").option("--type <type>", "What a row without a type becomes: user_story, feature or epic").option("--project <id>", "The project (default: PLUNE_PROJECT, else the only project of the account)").option("--yes", "Write it. Without it the import is a dry run", false).option("--json", "Print the platform\u2019s answer as JSON", false).action(async (file, options, command) => {
7012
7105
  const verbose = command.optsWithGlobals().verbose === true;
7013
7106
  const { handleRequirementsImport: handleRequirementsImport2, formatDiscover: formatDiscover2, formatReport: formatReport3, reportRequirementsImportFailure: reportRequirementsImportFailure2 } = await Promise.resolve().then(() => (init_requirements_import(), requirements_import_exports));
7014
7107
  try {
@@ -95,12 +95,14 @@ function parseRetryAfter(raw, now = Date.now()) {
95
95
  if (!Number.isFinite(seconds)) return null;
96
96
  return Math.min(Math.max(seconds, 0) * 1e3, RETRY_AFTER_CAP_MS);
97
97
  }
98
+ var POOLS = { "http:": new import_node_http.Agent({ keepAlive: true }), "https:": new import_node_https.Agent({ keepAlive: true }) };
98
99
  var IDLE_MS = 3e5;
99
100
  function nodeTransport(url, init, idleMs = IDLE_MS) {
100
101
  return new Promise((resolve2, reject) => {
101
102
  const target = new URL(url);
102
103
  const send = target.protocol === "https:" ? import_node_https.request : import_node_http.request;
103
- const req = send(target, { method: init.method, headers: init.headers }, (res) => {
104
+ const agent = POOLS[target.protocol === "https:" ? "https:" : "http:"];
105
+ const req = send(target, { method: init.method, headers: init.headers, agent }, (res) => {
104
106
  const chunks = [];
105
107
  res.on("data", (chunk2) => chunks.push(chunk2));
106
108
  res.on("error", reject);
@@ -591,6 +593,7 @@ var RESOLVE_CHUNK = 500;
591
593
  var BATCH_MAX = 500;
592
594
  var DISCOVER_MAX = 500;
593
595
  var BATCH_DEFAULT = 500;
596
+ var SEND_CONCURRENCY = 4;
594
597
  var EXPECTED_MAX = 1e4;
595
598
  var KEY_MAX = 1024;
596
599
  var TITLE_MAX = 300;
@@ -1344,7 +1347,29 @@ async function startRun(passed, declared) {
1344
1347
  else await uploadBatched(jobs);
1345
1348
  uploadMs += Date.now() - started;
1346
1349
  }
1350
+ const sending = /* @__PURE__ */ new Set();
1351
+ let sendError;
1352
+ let answered = false;
1353
+ async function dispatch(work) {
1354
+ while (sending.size >= (answered ? SEND_CONCURRENCY : 1)) await Promise.race(sending);
1355
+ const task = work().catch((err) => {
1356
+ sendError ??= err;
1357
+ }).finally(() => sending.delete(task));
1358
+ sending.add(task);
1359
+ }
1360
+ async function settle() {
1361
+ while (sending.size > 0) await Promise.all([...sending]);
1362
+ if (sendError !== void 0) {
1363
+ const err = sendError;
1364
+ sendError = void 0;
1365
+ throw err;
1366
+ }
1367
+ }
1347
1368
  async function flush() {
1369
+ await flushBuffer();
1370
+ await settle();
1371
+ }
1372
+ async function flushBuffer() {
1348
1373
  if (buffer.length === 0) return;
1349
1374
  const pending = buffer;
1350
1375
  buffer = [];
@@ -1352,6 +1377,9 @@ async function startRun(passed, declared) {
1352
1377
  defer(pending);
1353
1378
  return;
1354
1379
  }
1380
+ await dispatch(() => deliver(pending));
1381
+ }
1382
+ async function deliver(pending) {
1355
1383
  await resolveKeys(pending.flatMap((p) => p.keys));
1356
1384
  const submissions = [];
1357
1385
  const unanswered = [];
@@ -1392,6 +1420,7 @@ async function startRun(passed, declared) {
1392
1420
  for (const k of told) stats[k] += counts[k];
1393
1421
  progress();
1394
1422
  await upload(out.ok ? out.body?.items : void 0, filesByKey);
1423
+ answered = true;
1395
1424
  continue;
1396
1425
  }
1397
1426
  if (out.ok) {
@@ -1466,7 +1495,7 @@ async function startRun(passed, declared) {
1466
1495
  },
1467
1496
  async add(result) {
1468
1497
  buffer.push({ ...result, keys: bound(result.keys) });
1469
- if (buffer.length >= batchSize) await flush();
1498
+ if (buffer.length >= batchSize) await flushBuffer();
1470
1499
  },
1471
1500
  flush,
1472
1501
  async finish(reason) {
@@ -33,8 +33,8 @@ function loadToken() {
33
33
  // src/reporter-core/client.ts
34
34
  import { createReadStream, openAsBlob } from "fs";
35
35
  import { pipeline } from "stream";
36
- import { request as httpRequest } from "http";
37
- import { request as httpsRequest } from "https";
36
+ import { Agent as HttpAgent, request as httpRequest } from "http";
37
+ import { Agent as HttpsAgent, request as httpsRequest } from "https";
38
38
  var BACKOFF_BASE_MS = 300;
39
39
  var BACKOFF_CAP_MS = 1e4;
40
40
  var DEFAULT_RETRIES = 2;
@@ -46,12 +46,14 @@ function parseRetryAfter(raw, now = Date.now()) {
46
46
  if (!Number.isFinite(seconds)) return null;
47
47
  return Math.min(Math.max(seconds, 0) * 1e3, RETRY_AFTER_CAP_MS);
48
48
  }
49
+ var POOLS = { "http:": new HttpAgent({ keepAlive: true }), "https:": new HttpsAgent({ keepAlive: true }) };
49
50
  var IDLE_MS = 3e5;
50
51
  function nodeTransport(url, init, idleMs = IDLE_MS) {
51
52
  return new Promise((resolve2, reject) => {
52
53
  const target = new URL(url);
53
54
  const send = target.protocol === "https:" ? httpsRequest : httpRequest;
54
- const req = send(target, { method: init.method, headers: init.headers }, (res) => {
55
+ const agent = POOLS[target.protocol === "https:" ? "https:" : "http:"];
56
+ const req = send(target, { method: init.method, headers: init.headers, agent }, (res) => {
55
57
  const chunks = [];
56
58
  res.on("data", (chunk2) => chunks.push(chunk2));
57
59
  res.on("error", reject);
@@ -542,6 +544,7 @@ var RESOLVE_CHUNK = 500;
542
544
  var BATCH_MAX = 500;
543
545
  var DISCOVER_MAX = 500;
544
546
  var BATCH_DEFAULT = 500;
547
+ var SEND_CONCURRENCY = 4;
545
548
  var EXPECTED_MAX = 1e4;
546
549
  var KEY_MAX = 1024;
547
550
  var TITLE_MAX = 300;
@@ -1295,7 +1298,29 @@ async function startRun(passed, declared) {
1295
1298
  else await uploadBatched(jobs);
1296
1299
  uploadMs += Date.now() - started;
1297
1300
  }
1301
+ const sending = /* @__PURE__ */ new Set();
1302
+ let sendError;
1303
+ let answered = false;
1304
+ async function dispatch(work) {
1305
+ while (sending.size >= (answered ? SEND_CONCURRENCY : 1)) await Promise.race(sending);
1306
+ const task = work().catch((err) => {
1307
+ sendError ??= err;
1308
+ }).finally(() => sending.delete(task));
1309
+ sending.add(task);
1310
+ }
1311
+ async function settle() {
1312
+ while (sending.size > 0) await Promise.all([...sending]);
1313
+ if (sendError !== void 0) {
1314
+ const err = sendError;
1315
+ sendError = void 0;
1316
+ throw err;
1317
+ }
1318
+ }
1298
1319
  async function flush() {
1320
+ await flushBuffer();
1321
+ await settle();
1322
+ }
1323
+ async function flushBuffer() {
1299
1324
  if (buffer.length === 0) return;
1300
1325
  const pending = buffer;
1301
1326
  buffer = [];
@@ -1303,6 +1328,9 @@ async function startRun(passed, declared) {
1303
1328
  defer(pending);
1304
1329
  return;
1305
1330
  }
1331
+ await dispatch(() => deliver(pending));
1332
+ }
1333
+ async function deliver(pending) {
1306
1334
  await resolveKeys(pending.flatMap((p) => p.keys));
1307
1335
  const submissions = [];
1308
1336
  const unanswered = [];
@@ -1343,6 +1371,7 @@ async function startRun(passed, declared) {
1343
1371
  for (const k of told) stats[k] += counts[k];
1344
1372
  progress();
1345
1373
  await upload(out.ok ? out.body?.items : void 0, filesByKey);
1374
+ answered = true;
1346
1375
  continue;
1347
1376
  }
1348
1377
  if (out.ok) {
@@ -1417,7 +1446,7 @@ async function startRun(passed, declared) {
1417
1446
  },
1418
1447
  async add(result) {
1419
1448
  buffer.push({ ...result, keys: bound(result.keys) });
1420
- if (buffer.length >= batchSize) await flush();
1449
+ if (buffer.length >= batchSize) await flushBuffer();
1421
1450
  },
1422
1451
  flush,
1423
1452
  async finish(reason) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.25.0",
3
+ "version": "0.26.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",