kanbo-cli 0.5.0 → 0.6.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-main.cjs CHANGED
@@ -212,6 +212,26 @@ var BoardError = class extends Error {
212
212
  }
213
213
  };
214
214
  //#endregion
215
+ //#region src/domain/iso-week.ts
216
+ const DAY_SECONDS$2 = 86400;
217
+ /** The ISO week a moment falls in, read in UTC like every board date (ruling 5x-6). */
218
+ function isoWeekOf(seconds) {
219
+ const day = Math.floor(seconds / DAY_SECONDS$2);
220
+ const monday = day - (day + 3) % 7;
221
+ const thursday = monday + 3;
222
+ const year = (/* @__PURE__ */ new Date(thursday * DAY_SECONDS$2 * 1e3)).getUTCFullYear();
223
+ const januaryFirst = Date.UTC(year, 0, 1) / 1e3 / DAY_SECONDS$2;
224
+ const week = Math.floor((thursday - januaryFirst) / 7) + 1;
225
+ return {
226
+ week: `${year}-W${String(week).padStart(2, "0")}`,
227
+ start: monday * DAY_SECONDS$2
228
+ };
229
+ }
230
+ /** The UTC day a moment falls on, as `2026-01-05`. */
231
+ function utcDayLabel(seconds) {
232
+ return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
233
+ }
234
+ //#endregion
215
235
  //#region src/domain/status-name.ts
216
236
  /**
217
237
  * The columns a workspace gets the first time someone opens its board, in board
@@ -314,6 +334,7 @@ const BOARD_ERROR_TEXTS = {
314
334
  text: `A column accepts cards only from other columns of its own board, and ${list(details.allowedFrom)} is not one.`,
315
335
  next: "kanbo columns list"
316
336
  },
337
+ board_metrics_range_invalid: (details) => typeof details.from === "number" && typeof details.to === "number" ? { text: `The range starts after it ends: it starts on ${utcDayLabel(details.from)} and ends on ${utcDayLabel(details.to)}.` } : { text: "The range starts after it ends." },
317
338
  board_nothing_ready: () => ({
318
339
  text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
319
340
  next: "kanbo ready"
@@ -3577,7 +3598,7 @@ new Command();
3577
3598
  * `package.version`. A build carries it as a literal; running from source
3578
3599
  * (tests, `tsx`) reads the package's own `package.json` instead.
3579
3600
  */
3580
- const KANBO_PACKAGE_VERSION = "0.5.0";
3601
+ const KANBO_PACKAGE_VERSION = "0.6.0";
3581
3602
  //#endregion
3582
3603
  //#region src/cli/agent-shell.ts
3583
3604
  /**
@@ -9851,18 +9872,18 @@ function activityToken(token) {
9851
9872
  function currentUnixSeconds() {
9852
9873
  return Math.floor(Date.now() / 1e3);
9853
9874
  }
9854
- const DAY_SECONDS = 86400;
9875
+ const DAY_SECONDS$1 = 86400;
9855
9876
  /**
9856
9877
  * A board date is a calendar day (ruling 5x-6): a start is kept as the first
9857
9878
  * second of its UTC day, a due date as the last, so a sprint runs through its
9858
9879
  * due day wherever the person reading it is.
9859
9880
  */
9860
9881
  function utcDayStart(seconds) {
9861
- return Math.floor(seconds / DAY_SECONDS) * DAY_SECONDS;
9882
+ return Math.floor(seconds / DAY_SECONDS$1) * DAY_SECONDS$1;
9862
9883
  }
9863
9884
  /** The last second of the UTC day `seconds` falls on — see `utcDayStart`. */
9864
9885
  function utcDayEnd(seconds) {
9865
- return utcDayStart(seconds) + DAY_SECONDS - 1;
9886
+ return utcDayStart(seconds) + DAY_SECONDS$1 - 1;
9866
9887
  }
9867
9888
  /** The largest unix seconds a moment is read as: past it, the number is milliseconds (year 5138 onwards). */
9868
9889
  const MAX_UNIX_SECONDS = 1e11;
@@ -10278,6 +10299,536 @@ function previewComment(comment) {
10278
10299
  };
10279
10300
  }
10280
10301
  //#endregion
10302
+ //#region src/domain/card-display-title.ts
10303
+ /**
10304
+ * What a card is called wherever a person or an agent reads it by name.
10305
+ *
10306
+ * A card written as a description alone is titled with its own key (every
10307
+ * writer does that). Named by its key, such a card says nothing — so its name
10308
+ * is the first line of its description instead. A browser bundle that does not
10309
+ * load this package keeps the same rule on its own, tested with the same
10310
+ * examples.
10311
+ */
10312
+ /** How long a description line may run before it is cut with an ellipsis. */
10313
+ const DESCRIPTION_LINE_LIMIT = 120;
10314
+ function cardDisplayTitle(card) {
10315
+ if (card.title !== card.id) return card.title;
10316
+ return descriptionFirstLine(card.description) ?? card.title;
10317
+ }
10318
+ function descriptionFirstLine(description) {
10319
+ for (const rawLine of (description ?? "").split("\n")) {
10320
+ const line = stripMarkdown(rawLine);
10321
+ if (line) return line.length > DESCRIPTION_LINE_LIMIT ? `${line.slice(0, 119).trimEnd()}…` : line;
10322
+ }
10323
+ return null;
10324
+ }
10325
+ function stripMarkdown(line) {
10326
+ return line.replace(/^\s*(?:>\s*)+/, "").replace(/^\s*#{1,6}\s+/, "").replace(/^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ x]\]\s+)?/i, "").replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<[^>]+>/g, "").replace(/[*_~`]/g, "").replace(/\s+/g, " ").trim();
10327
+ }
10328
+ //#endregion
10329
+ //#region src/domain/flow-rules.ts
10330
+ /** The column the standard flow guards, and the columns it lets cards in from, by slug. */
10331
+ const STANDARD_FLOW_TARGET = "done";
10332
+ const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
10333
+ /** A column's flow rules, out of what the row stores. */
10334
+ function readFlowRules(column) {
10335
+ return {
10336
+ allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
10337
+ wipLimit: column.wipLimit
10338
+ };
10339
+ }
10340
+ /** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
10341
+ function serializeAllowedFrom(allowedFrom) {
10342
+ return allowedFrom === null ? null : JSON.stringify(allowedFrom);
10343
+ }
10344
+ /**
10345
+ * The flow rules a card entering `column` does not meet, where it may come from
10346
+ * first. Pure: what the board holds arrives in `facts`. A card being created
10347
+ * comes from no column, so a column that names where cards come from does not
10348
+ * take it.
10349
+ */
10350
+ function evaluateFlowRules(card, column, rules, facts) {
10351
+ const unmet = [];
10352
+ if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
10353
+ rule: "allowed_from",
10354
+ reason: "not_allowed_from",
10355
+ detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
10356
+ });
10357
+ if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
10358
+ rule: "wip_limit",
10359
+ reason: "column_full",
10360
+ detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
10361
+ });
10362
+ return unmet;
10363
+ }
10364
+ function describeAllowedFrom(name, allowedFrom, columnNames) {
10365
+ const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
10366
+ return names.length === 0 ? `${name} accepts cards from no column; only a person may put a card here` : `${name} accepts cards from ${joinWords(names)} only`;
10367
+ }
10368
+ /** `A`, `A and B`, `A, B and C`. */
10369
+ function joinWords(words) {
10370
+ return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
10371
+ }
10372
+ /**
10373
+ * The standard flow on these columns: Done, and the columns it takes cards from
10374
+ * — In Review, and QA when there is one — in board order. `null` when the board
10375
+ * has no Done, or neither of the two.
10376
+ */
10377
+ function standardFlowOf(columns) {
10378
+ const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
10379
+ const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
10380
+ return target && sources.length > 0 ? {
10381
+ target,
10382
+ sources
10383
+ } : null;
10384
+ }
10385
+ //#endregion
10386
+ //#region src/ops/card-timeline.ts
10387
+ /**
10388
+ * A card's columns over time, oldest first, from its creation on: the column
10389
+ * it was created in, then one stretch per move. A card created before 0.5.0
10390
+ * carries no entry for its creation; one created since carries `null → column`
10391
+ * at its creation, which leaves a stretch of no length in no column.
10392
+ *
10393
+ * The feed lists moves by time and then by change id, and a change id is
10394
+ * random, so two moves of one card in the same second can come back in either
10395
+ * order. Within one second the moves are put back in the order they chain —
10396
+ * each leaves the column the one before it entered — which is the order they
10397
+ * were made in (ruling R012-5). Moves that do not chain keep the feed's order.
10398
+ */
10399
+ function buildCardTimeline(card, entries) {
10400
+ const ordered = chainSameSecond(entries.toSorted((left, right) => left.at - right.at || (left.changeId < right.changeId ? -1 : left.changeId > right.changeId ? 1 : 0)));
10401
+ const stretches = [];
10402
+ const first = ordered[0];
10403
+ let current = {
10404
+ statusId: first ? first.fromStatusId : card.statusId,
10405
+ from: card.createdAt,
10406
+ to: null
10407
+ };
10408
+ for (const entry of ordered) {
10409
+ stretches.push({
10410
+ ...current,
10411
+ to: entry.at
10412
+ });
10413
+ current = {
10414
+ statusId: entry.toStatusId,
10415
+ from: entry.at,
10416
+ to: null
10417
+ };
10418
+ }
10419
+ stretches.push(current);
10420
+ return stretches;
10421
+ }
10422
+ /** The column the card was in at `at` — a move at `at` has already happened — or `undefined` before the card existed. */
10423
+ function statusAt(stretches, at) {
10424
+ return stretches.find((stretch) => stretch.from <= at && (stretch.to === null || at < stretch.to))?.statusId;
10425
+ }
10426
+ function chainSameSecond(entries) {
10427
+ const ordered = [];
10428
+ let start = 0;
10429
+ while (start < entries.length) {
10430
+ let end = start;
10431
+ while (end < entries.length && entries[end].at === entries[start].at) end++;
10432
+ const group = entries.slice(start, end);
10433
+ const previous = ordered.at(-1);
10434
+ let current = previous ? previous.toStatusId : headOf(group)?.fromStatusId;
10435
+ while (group.length > 0) {
10436
+ let next = group.findIndex((entry) => entry.fromStatusId === current);
10437
+ if (next < 0) next = Math.max(group.findIndex((entry) => entry === headOf(group)), 0);
10438
+ const [picked] = group.splice(next, 1);
10439
+ ordered.push(picked);
10440
+ current = picked.toStatusId;
10441
+ }
10442
+ start = end;
10443
+ }
10444
+ return ordered;
10445
+ }
10446
+ /** The move nothing else in the group feeds into. */
10447
+ function headOf(group) {
10448
+ return group.find((entry) => !group.some((other) => other !== entry && other.toStatusId === entry.fromStatusId));
10449
+ }
10450
+ //#endregion
10451
+ //#region src/ops/board-metrics.ts
10452
+ const DAY_SECONDS = 86400;
10453
+ const WEEK_SECONDS$1 = 7 * DAY_SECONDS;
10454
+ /**
10455
+ * The value at `percent` of `values` by nearest rank (ruling R012-2): the
10456
+ * ⌈percent·n/100⌉-th in ascending order. Always a value that occurred; `null`
10457
+ * for none.
10458
+ */
10459
+ function nearestRank(values, percent) {
10460
+ if (values.length === 0) return null;
10461
+ const sorted = values.toSorted((left, right) => left - right);
10462
+ return sorted[Math.max(Math.ceil(percent * sorted.length / 100), 1) - 1];
10463
+ }
10464
+ /** Who a run counts under in the agent rows: the host's agent id when the run names one, else the name it was started under. */
10465
+ function boardMetricsAgentKey(run) {
10466
+ return run.agentId ?? run.agentName;
10467
+ }
10468
+ function scopeOf(history, input) {
10469
+ const workspaceIds = new Set(input.workspaceIds);
10470
+ const milestoneId = input.milestoneId ?? null;
10471
+ const statusById = new Map(history.statuses.filter((status) => workspaceIds.has(status.workspaceId)).map((status) => [status.id, status]));
10472
+ const cards = history.cards.filter((card) => workspaceIds.has(card.workspaceId)).filter((card) => milestoneId === null || card.milestoneId === milestoneId);
10473
+ const isCanceled = (card) => card.statusId !== null && statusById.get(card.statusId)?.category === "canceled";
10474
+ return {
10475
+ statusById,
10476
+ counted: cards.filter((card) => !isCanceled(card)),
10477
+ canceled: cards.filter(isCanceled)
10478
+ };
10479
+ }
10480
+ /** Who made a return, as `returns.byActorKind` counts it: a person, an agent (a provider target stands in for one), or a writer from outside. */
10481
+ function returnerOf(entry) {
10482
+ if (entry.actorKind === "user") return "user";
10483
+ return entry.actorKind === "system" ? "external" : "agent";
10484
+ }
10485
+ /**
10486
+ * The metrics of `input` over rows already read. Pure: the same rows give the
10487
+ * same answer in any order, so a host may put several boards' rows together
10488
+ * and compute once — percentiles of parts do not add up to the whole's.
10489
+ */
10490
+ function computeBoardMetrics(history, input, now) {
10491
+ if (input.from > input.to) throw new BoardError("board_metrics_range_invalid", {
10492
+ from: input.from,
10493
+ to: input.to
10494
+ });
10495
+ const milestoneId = input.milestoneId ?? null;
10496
+ if (milestoneId !== null && !history.milestones.some((milestone) => milestone.id === milestoneId && input.workspaceIds.includes(milestone.workspaceId))) throw new BoardError("issue_milestone_not_found", { milestoneId });
10497
+ const from = input.from;
10498
+ const to = Math.min(input.to, now);
10499
+ const inRange = (at) => from <= at && at <= to;
10500
+ const { statusById, counted, canceled } = scopeOf(history, input);
10501
+ const columns = groupColumns(input.workspaceIds, [...statusById.values()]);
10502
+ const slugOf = (statusId) => {
10503
+ const status = statusId === null ? void 0 : statusById.get(statusId);
10504
+ return status ? normalizeStatusName(status.name) : null;
10505
+ };
10506
+ const categoryOf = (statusId) => statusId === null ? null : statusById.get(statusId)?.category ?? null;
10507
+ const isReturn = (entry) => {
10508
+ const source = entry.fromStatusId === null ? void 0 : statusById.get(entry.fromStatusId);
10509
+ const target = statusById.get(entry.toStatusId);
10510
+ return source !== void 0 && target !== void 0 && source.workspaceId === target.workspaceId && target.order < source.order && (target.category === "unstarted" || target.category === "started");
10511
+ };
10512
+ const entriesByCard = groupBy(history.entries, (entry) => entry.issueId);
10513
+ const timelineOf = (card) => buildCardTimeline(card, entriesByCard.get(card.id) ?? []);
10514
+ const countedIds = new Set(counted.map((card) => card.id));
10515
+ const facts = counted.map((card) => {
10516
+ const stretches = timelineOf(card);
10517
+ return {
10518
+ card,
10519
+ stretches,
10520
+ startedAt: stretches.find((stretch) => categoryOf(stretch.statusId) === "started")?.from ?? null
10521
+ };
10522
+ });
10523
+ const finished = facts.filter((fact) => categoryOf(fact.card.statusId) === "completed").map((fact) => ({
10524
+ ...fact,
10525
+ finishedAt: fact.stretches.at(-1).from
10526
+ })).filter((fact) => inRange(fact.finishedAt)).toSorted((left, right) => left.finishedAt - right.finishedAt || left.card.id.localeCompare(right.card.id));
10527
+ const runsByCard = groupBy(history.runs.filter((run) => countedIds.has(run.issueId)), (run) => run.issueId);
10528
+ const returnsByCard = groupBy(history.entries.filter((entry) => countedIds.has(entry.issueId) && isReturn(entry)), (entry) => entry.issueId);
10529
+ const finishedCards = finished.map((fact) => ({
10530
+ ...cardOf(fact.card),
10531
+ startedAt: fact.startedAt,
10532
+ finishedAt: fact.finishedAt,
10533
+ cycleSeconds: fact.startedAt === null ? null : fact.finishedAt - fact.startedAt,
10534
+ leadSeconds: fact.finishedAt - fact.card.createdAt,
10535
+ returns: returnsByCard.get(fact.card.id)?.length ?? 0,
10536
+ runs: runsByCard.get(fact.card.id)?.length ?? 0
10537
+ }));
10538
+ const cycleTime = durations(finishedCards.flatMap((card) => card.cycleSeconds === null ? [] : [card.cycleSeconds]));
10539
+ const leadTime = durations(finishedCards.map((card) => card.leadSeconds));
10540
+ const throughput = [];
10541
+ for (let start = isoWeekOf(from).start; from <= to && start <= to; start += WEEK_SECONDS$1) throughput.push({
10542
+ week: isoWeekOf(start).week,
10543
+ start,
10544
+ finished: finishedCards.filter((card) => card.finishedAt >= start && card.finishedAt < start + WEEK_SECONDS$1).length
10545
+ });
10546
+ const inProgress = facts.filter((fact) => categoryOf(fact.card.statusId) === "started");
10547
+ const wip = columns.filter((column) => column.category === "started").map((column) => {
10548
+ const limits = [...statusById.values()].filter((status) => normalizeStatusName(status.name) === column.slug).map((status) => readFlowRules(status).wipLimit);
10549
+ return {
10550
+ slug: column.slug,
10551
+ cards: inProgress.filter((fact) => slugOf(fact.card.statusId) === column.slug).length,
10552
+ limit: limits.every((limit) => limit !== null) ? limits.reduce((sum, limit) => sum + limit, 0) : null
10553
+ };
10554
+ });
10555
+ const aging = inProgress.map((fact) => {
10556
+ const current = fact.stretches.at(-1);
10557
+ const startedAt = fact.startedAt ?? current.from;
10558
+ return {
10559
+ ...cardOf(fact.card),
10560
+ slug: slugOf(fact.card.statusId),
10561
+ startedAt,
10562
+ ageSeconds: now - startedAt,
10563
+ inColumnSeconds: now - current.from,
10564
+ overCycleP85: cycleTime.p85 !== null && now - startedAt > cycleTime.p85
10565
+ };
10566
+ }).toSorted((left, right) => right.ageSeconds - left.ageSeconds || left.issueId.localeCompare(right.issueId));
10567
+ const columnSeconds = new Map(columns.map((column) => [column.slug, 0]));
10568
+ for (const fact of finished) for (const stretch of fact.stretches) {
10569
+ const slug = slugOf(stretch.statusId);
10570
+ const end = Math.min(stretch.to ?? fact.finishedAt, fact.finishedAt);
10571
+ if (slug !== null && end > stretch.from) columnSeconds.set(slug, (columnSeconds.get(slug) ?? 0) + end - stretch.from);
10572
+ }
10573
+ const cumulativeFlow = [];
10574
+ for (let dayStart = utcDayStart(from); from <= to && dayStart <= to; dayStart += DAY_SECONDS) {
10575
+ const at = Math.min(dayStart + DAY_SECONDS - 1, to);
10576
+ const cards = Object.fromEntries(columns.map((column) => [column.slug, 0]));
10577
+ for (const fact of facts) {
10578
+ const statusId = statusAt(fact.stretches, at);
10579
+ const slug = statusId === void 0 ? null : slugOf(statusId);
10580
+ if (slug !== null) cards[slug] = (cards[slug] ?? 0) + 1;
10581
+ }
10582
+ cumulativeFlow.push({
10583
+ day: utcDayLabel(dayStart),
10584
+ at,
10585
+ cards
10586
+ });
10587
+ }
10588
+ const finishedReturns = finished.flatMap((fact) => returnsByCard.get(fact.card.id) ?? []);
10589
+ const byActorKind = {
10590
+ user: 0,
10591
+ agent: 0,
10592
+ external: 0
10593
+ };
10594
+ for (const entry of finishedReturns) byActorKind[returnerOf(entry)]++;
10595
+ const runTotal = finishedCards.reduce((sum, card) => sum + card.runs, 0);
10596
+ const share = (part, whole) => whole === 0 ? null : part / whole;
10597
+ return {
10598
+ range: {
10599
+ from,
10600
+ to
10601
+ },
10602
+ now,
10603
+ milestoneId,
10604
+ columns,
10605
+ cycleTime,
10606
+ leadTime,
10607
+ throughput,
10608
+ wip,
10609
+ aging,
10610
+ timeInColumn: columns.map((column) => ({
10611
+ slug: column.slug,
10612
+ seconds: columnSeconds.get(column.slug) ?? 0
10613
+ })),
10614
+ cumulativeFlow,
10615
+ finished: finishedCards,
10616
+ returns: {
10617
+ total: finishedReturns.length,
10618
+ perFinishedCard: share(finishedReturns.length, finishedCards.length),
10619
+ firstPassRate: share(finishedCards.filter((card) => card.returns === 0).length, finishedCards.length),
10620
+ byActorKind
10621
+ },
10622
+ attempts: {
10623
+ runs: runTotal,
10624
+ perFinishedCard: share(runTotal, finishedCards.length)
10625
+ },
10626
+ canceled: canceled.filter((card) => inRange(timelineOf(card).at(-1).from)).length,
10627
+ agents: agentRows(history.runs.filter((run) => countedIds.has(run.issueId)), finishedCards, inRange),
10628
+ people: personRows(counted, history, countedIds, inRange, isReturn)
10629
+ };
10630
+ }
10631
+ function agentRows(runs, finished, inRange) {
10632
+ const finishedIds = new Set(finished.map((card) => card.issueId));
10633
+ const returned = new Set(finished.filter((card) => card.returns > 0).map((card) => card.issueId));
10634
+ const rows = [];
10635
+ for (const [key, own] of groupBy(runs, boardMetricsAgentKey)) {
10636
+ const ranged = own.filter((run) => inRange(run.startedAt));
10637
+ const worked = new Set(own.map((run) => run.issueId).filter((issueId) => finishedIds.has(issueId)));
10638
+ if (ranged.length === 0 && worked.size === 0) continue;
10639
+ const newest = own.toSorted((left, right) => right.startedAt - left.startedAt || right.id.localeCompare(left.id))[0];
10640
+ const ended = ranged.flatMap((run) => run.endedAt === null ? [] : [run.endedAt - run.startedAt]);
10641
+ rows.push({
10642
+ key,
10643
+ agentId: newest.agentId,
10644
+ agentName: newest.agentName,
10645
+ runs: {
10646
+ total: ranged.length,
10647
+ finished: ranged.filter((run) => run.state === "finished").length,
10648
+ failed: ranged.filter((run) => run.state === "failed").length,
10649
+ stopped: ranged.filter((run) => run.state === "stopped").length,
10650
+ running: ranged.filter((run) => run.state === "running").length
10651
+ },
10652
+ medianRunSeconds: nearestRank(ended, 50),
10653
+ cardsFinished: worked.size,
10654
+ returnRate: worked.size === 0 ? null : [...worked].filter((issueId) => returned.has(issueId)).length / worked.size
10655
+ });
10656
+ }
10657
+ return rows.toSorted((left, right) => right.runs.total - left.runs.total || left.key.localeCompare(right.key));
10658
+ }
10659
+ function personRows(counted, history, countedIds, inRange, isReturn) {
10660
+ const rows = /* @__PURE__ */ new Map();
10661
+ const row = (id) => {
10662
+ let found = rows.get(id);
10663
+ if (!found) {
10664
+ found = {
10665
+ id,
10666
+ created: 0,
10667
+ approvals: 0,
10668
+ returns: 0,
10669
+ moves: 0
10670
+ };
10671
+ rows.set(id, found);
10672
+ }
10673
+ return found;
10674
+ };
10675
+ for (const card of counted) if (card.createdByKind === "user" && inRange(card.createdAt)) row(card.createdById).created++;
10676
+ for (const comment of history.approvals) if (comment.authorKind === "system.approved" && countedIds.has(comment.issueId) && inRange(comment.createdAt)) row(comment.authorId).approvals++;
10677
+ for (const entry of history.entries) if (entry.actorKind === "user" && entry.fromStatusId !== null && countedIds.has(entry.issueId) && inRange(entry.at)) {
10678
+ const person = row(entry.actorId);
10679
+ person.moves++;
10680
+ if (isReturn(entry)) person.returns++;
10681
+ }
10682
+ const activity = (person) => person.created + person.approvals + person.returns + person.moves;
10683
+ return [...rows.values()].toSorted((left, right) => activity(right) - activity(left) || (left.id ?? "￿").localeCompare(right.id ?? "￿"));
10684
+ }
10685
+ /**
10686
+ * The workspaces' columns as one board shows them, one per slug (ruling
10687
+ * R012-8): the first workspace's columns set the order, and a column only a
10688
+ * later one has goes right after the column before it there. Name and category
10689
+ * are the first workspace's that has it.
10690
+ */
10691
+ function groupColumns(workspaceIds, statuses) {
10692
+ const columns = [];
10693
+ for (const workspaceId of new Set(workspaceIds)) {
10694
+ let previous = -1;
10695
+ const own = statuses.filter((status) => status.workspaceId === workspaceId).toSorted((left, right) => left.order - right.order || left.id.localeCompare(right.id));
10696
+ for (const status of own) {
10697
+ const slug = normalizeStatusName(status.name);
10698
+ let index = columns.findIndex((column) => column.slug === slug);
10699
+ if (index === -1) {
10700
+ index = previous + 1;
10701
+ columns.splice(index, 0, {
10702
+ slug,
10703
+ name: status.name,
10704
+ category: status.category
10705
+ });
10706
+ }
10707
+ previous = index;
10708
+ }
10709
+ }
10710
+ return columns;
10711
+ }
10712
+ function durations(values) {
10713
+ return {
10714
+ cards: values.length,
10715
+ p50: nearestRank(values, 50),
10716
+ p85: nearestRank(values, 85)
10717
+ };
10718
+ }
10719
+ function cardOf(card) {
10720
+ return {
10721
+ issueId: card.id,
10722
+ workspaceId: card.workspaceId,
10723
+ title: cardDisplayTitle(card)
10724
+ };
10725
+ }
10726
+ function groupBy(items, keyOf) {
10727
+ const groups = /* @__PURE__ */ new Map();
10728
+ for (const item of items) {
10729
+ const key = keyOf(item);
10730
+ const group = groups.get(key);
10731
+ if (group) group.push(item);
10732
+ else groups.set(key, [item]);
10733
+ }
10734
+ return groups;
10735
+ }
10736
+ //#endregion
10737
+ //#region src/ops/column-entries.ts
10738
+ /**
10739
+ * Every entry into a column on the named boards at or after `since`, in order
10740
+ * of `(at, changeId)` — the one signal an app dispatching work on column
10741
+ * changes needs. It sees every writer, an outside `kanbo` on the same database
10742
+ * included, because it reads the cards' own history.
10743
+ *
10744
+ * A page never ends inside a second: when `limit` cuts into one, the rest of
10745
+ * that second comes too, so a page may be longer than `limit`. A reader keeps
10746
+ * its cursor at the last `at` it read after a short page — a write in that
10747
+ * second may still commit — and drops the `changeId`s it has seen; after a page
10748
+ * of `limit` or more it moves on to the last `at` + 1, which a second holding
10749
+ * more entries than `limit` would otherwise never let it pass.
10750
+ */
10751
+ async function listColumnEntries(store, input) {
10752
+ const page = await store.fieldChanges.listByFieldSince("statusId", input);
10753
+ return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
10754
+ }
10755
+ /** The page with every change of its last second, not only those `limit` let in. */
10756
+ async function withWholeLastSecond(store, input, page) {
10757
+ const last = page.at(-1).createdAt;
10758
+ const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
10759
+ workspaceIds: input.workspaceIds,
10760
+ since: last,
10761
+ until: last
10762
+ });
10763
+ return [...page.filter((change) => change.createdAt < last), ...lastSecond];
10764
+ }
10765
+ /** A status change as an entry; a change that took the card out of every column enters none. */
10766
+ function toColumnEntry(change) {
10767
+ return change.toValue === null ? [] : [{
10768
+ changeId: change.id,
10769
+ issueId: change.issueId,
10770
+ workspaceId: change.workspaceId,
10771
+ fromStatusId: change.fromValue,
10772
+ toStatusId: change.toValue,
10773
+ actorKind: change.actorKind,
10774
+ actorId: change.actorId,
10775
+ at: change.createdAt
10776
+ }];
10777
+ }
10778
+ //#endregion
10779
+ //#region src/ops/board-metrics-read.ts
10780
+ /** The comment kind the metrics read: a person's approvals. Returns are moves (R012-7). */
10781
+ const APPROVAL_KINDS = ["system.approved"];
10782
+ /** `listColumnEntries` from the board's first column change on (drift check, item 3). */
10783
+ const FROM_THE_FIRST_CHANGE = 0;
10784
+ /**
10785
+ * Every row the metrics of these workspaces are computed from: three
10786
+ * statements per workspace (columns, milestones, cards) plus three across all
10787
+ * of them (column changes through the `(field, created_at)` index, runs,
10788
+ * approvals); none per card.
10789
+ *
10790
+ * The rows are read and the metrics computed in TypeScript rather than in SQL
10791
+ * (ruling R012-1): a board is thousands of rows, not millions; percentiles, ISO
10792
+ * weeks and a day-by-day replay would otherwise be written once per dialect;
10793
+ * and a host whose workspaces live on several boards can put their rows
10794
+ * together and compute once.
10795
+ */
10796
+ async function readBoardMetricsHistory(store, workspaceIds) {
10797
+ const ids = [...new Set(workspaceIds)];
10798
+ if (ids.length === 0) return {
10799
+ statuses: [],
10800
+ milestones: [],
10801
+ cards: [],
10802
+ entries: [],
10803
+ runs: [],
10804
+ approvals: []
10805
+ };
10806
+ const perWorkspace = async (read) => (await Promise.all(ids.map(read))).flat();
10807
+ const [statuses, milestones, cards, entries, runs, approvals] = await Promise.all([
10808
+ perWorkspace(async (id) => await store.statuses.listByWorkspace(id)),
10809
+ perWorkspace(async (id) => await store.milestones.listByWorkspace(id)),
10810
+ perWorkspace(async (id) => await store.issues.listByWorkspace(id)),
10811
+ listColumnEntries(store, {
10812
+ workspaceIds: ids,
10813
+ since: FROM_THE_FIRST_CHANGE
10814
+ }),
10815
+ store.runs.listByWorkspaces(ids),
10816
+ store.comments.listByWorkspaces(ids, APPROVAL_KINDS)
10817
+ ]);
10818
+ return {
10819
+ statuses,
10820
+ milestones,
10821
+ cards,
10822
+ entries,
10823
+ runs,
10824
+ approvals
10825
+ };
10826
+ }
10827
+ /** How work flows on the board over `input`'s range (`computeBoardMetrics`), read from the store; without cost. */
10828
+ async function boardMetrics(store, input, now = currentUnixSeconds()) {
10829
+ return computeBoardMetrics(await readBoardMetricsHistory(store, input.workspaceIds), input, now);
10830
+ }
10831
+ //#endregion
10281
10832
  //#region src/domain/field-changes.ts
10282
10833
  /**
10283
10834
  * The issue columns whose edits are written to the audit trail.
@@ -10704,63 +11255,6 @@ function describeColumnRefusal(details) {
10704
11255
  return [`board_column_rules_unmet: ${details.issueId ? `${details.issueId} cannot enter` : "A new card cannot be created in"} "${details.column}" yet:`, ...details.unmet.map((item) => `- ${item.rule}: ${item.detail}`)].join("\n");
10705
11256
  }
10706
11257
  //#endregion
10707
- //#region src/domain/flow-rules.ts
10708
- /** The column the standard flow guards, and the columns it lets cards in from, by slug. */
10709
- const STANDARD_FLOW_TARGET = "done";
10710
- const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
10711
- /** A column's flow rules, out of what the row stores. */
10712
- function readFlowRules(column) {
10713
- return {
10714
- allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
10715
- wipLimit: column.wipLimit
10716
- };
10717
- }
10718
- /** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
10719
- function serializeAllowedFrom(allowedFrom) {
10720
- return allowedFrom === null ? null : JSON.stringify(allowedFrom);
10721
- }
10722
- /**
10723
- * The flow rules a card entering `column` does not meet, where it may come from
10724
- * first. Pure: what the board holds arrives in `facts`. A card being created
10725
- * comes from no column, so a column that names where cards come from does not
10726
- * take it.
10727
- */
10728
- function evaluateFlowRules(card, column, rules, facts) {
10729
- const unmet = [];
10730
- if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
10731
- rule: "allowed_from",
10732
- reason: "not_allowed_from",
10733
- detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
10734
- });
10735
- if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
10736
- rule: "wip_limit",
10737
- reason: "column_full",
10738
- detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
10739
- });
10740
- return unmet;
10741
- }
10742
- function describeAllowedFrom(name, allowedFrom, columnNames) {
10743
- const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
10744
- return names.length === 0 ? `${name} accepts cards from no column; only a person may put a card here` : `${name} accepts cards from ${joinWords(names)} only`;
10745
- }
10746
- /** `A`, `A and B`, `A, B and C`. */
10747
- function joinWords(words) {
10748
- return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
10749
- }
10750
- /**
10751
- * The standard flow on these columns: Done, and the columns it takes cards from
10752
- * — In Review, and QA when there is one — in board order. `null` when the board
10753
- * has no Done, or neither of the two.
10754
- */
10755
- function standardFlowOf(columns) {
10756
- const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
10757
- const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
10758
- return target && sources.length > 0 ? {
10759
- target,
10760
- sources
10761
- } : null;
10762
- }
10763
- //#endregion
10764
11258
  //#region src/ops/columns.ts
10765
11259
  /** The columns of one workspace, in board order. */
10766
11260
  async function listColumns(store, workspaceId) {
@@ -12438,48 +12932,6 @@ async function notReadyReason(store, card, toDo) {
12438
12932
  return toDo !== null && card.statusId === toDo.id ? null : "not_in_to_do";
12439
12933
  }
12440
12934
  //#endregion
12441
- //#region src/ops/column-entries.ts
12442
- /**
12443
- * Every entry into a column on the named boards at or after `since`, in order
12444
- * of `(at, changeId)` — the one signal an app dispatching work on column
12445
- * changes needs. It sees every writer, an outside `kanbo` on the same database
12446
- * included, because it reads the cards' own history.
12447
- *
12448
- * A page never ends inside a second: when `limit` cuts into one, the rest of
12449
- * that second comes too, so a page may be longer than `limit`. A reader keeps
12450
- * its cursor at the last `at` it read after a short page — a write in that
12451
- * second may still commit — and drops the `changeId`s it has seen; after a page
12452
- * of `limit` or more it moves on to the last `at` + 1, which a second holding
12453
- * more entries than `limit` would otherwise never let it pass.
12454
- */
12455
- async function listColumnEntries(store, input) {
12456
- const page = await store.fieldChanges.listByFieldSince("statusId", input);
12457
- return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
12458
- }
12459
- /** The page with every change of its last second, not only those `limit` let in. */
12460
- async function withWholeLastSecond(store, input, page) {
12461
- const last = page.at(-1).createdAt;
12462
- const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
12463
- workspaceIds: input.workspaceIds,
12464
- since: last,
12465
- until: last
12466
- });
12467
- return [...page.filter((change) => change.createdAt < last), ...lastSecond];
12468
- }
12469
- /** A status change as an entry; a change that took the card out of every column enters none. */
12470
- function toColumnEntry(change) {
12471
- return change.toValue === null ? [] : [{
12472
- changeId: change.id,
12473
- issueId: change.issueId,
12474
- workspaceId: change.workspaceId,
12475
- fromStatusId: change.fromValue,
12476
- toStatusId: change.toValue,
12477
- actorKind: change.actorKind,
12478
- actorId: change.actorId,
12479
- at: change.createdAt
12480
- }];
12481
- }
12482
- //#endregion
12483
12935
  //#region src/ops/context-refs.ts
12484
12936
  const ContextRefsJsonSchema = string().transform((raw) => JSON.parse(raw)).pipe(array(string()));
12485
12937
  /** Add a context ref at the end of the card's list, recording the change in its history. */
@@ -12610,33 +13062,6 @@ async function migrateCards(store, input) {
12610
13062
  };
12611
13063
  }
12612
13064
  //#endregion
12613
- //#region src/domain/card-display-title.ts
12614
- /**
12615
- * What a card is called wherever a person or an agent reads it by name.
12616
- *
12617
- * A card written as a description alone is titled with its own key (every
12618
- * writer does that). Named by its key, such a card says nothing — so its name
12619
- * is the first line of its description instead. A browser bundle that does not
12620
- * load this package keeps the same rule on its own, tested with the same
12621
- * examples.
12622
- */
12623
- /** How long a description line may run before it is cut with an ellipsis. */
12624
- const DESCRIPTION_LINE_LIMIT = 120;
12625
- function cardDisplayTitle(card) {
12626
- if (card.title !== card.id) return card.title;
12627
- return descriptionFirstLine(card.description) ?? card.title;
12628
- }
12629
- function descriptionFirstLine(description) {
12630
- for (const rawLine of (description ?? "").split("\n")) {
12631
- const line = stripMarkdown(rawLine);
12632
- if (line) return line.length > DESCRIPTION_LINE_LIMIT ? `${line.slice(0, 119).trimEnd()}…` : line;
12633
- }
12634
- return null;
12635
- }
12636
- function stripMarkdown(line) {
12637
- return line.replace(/^\s*(?:>\s*)+/, "").replace(/^\s*#{1,6}\s+/, "").replace(/^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ x]\]\s+)?/i, "").replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<[^>]+>/g, "").replace(/[*_~`]/g, "").replace(/\s+/g, " ").trim();
12638
- }
12639
- //#endregion
12640
13065
  //#region src/ops/agent-rules.ts
12641
13066
  /**
12642
13067
  * The sentences that tell an agent how to work this board, written once.
@@ -13253,7 +13678,9 @@ function createBoardOps(store) {
13253
13678
  listReady: async (input) => await listReady(store, input),
13254
13679
  queryReady: async (input) => await queryReady(store, input),
13255
13680
  listColumnEntries: async (input) => await listColumnEntries(store, input),
13256
- buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId)
13681
+ buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId),
13682
+ readBoardMetricsHistory: async (workspaceIds) => await readBoardMetricsHistory(store, workspaceIds),
13683
+ boardMetrics: async (input, now) => await boardMetrics(store, input, now)
13257
13684
  };
13258
13685
  }
13259
13686
  //#endregion
@@ -19624,6 +20051,10 @@ function createStoreOver(database) {
19624
20051
  },
19625
20052
  comments: {
19626
20053
  listByIssue: async (issueId) => (await database.select().from(issueComments$1).where(eq(issueComments$1.issueId, issueId)).orderBy(asc(issueComments$1.createdAt), asc(issueComments$1.seq))).map(toIssueComment),
20054
+ listByWorkspaces: async (workspaceIds, authorKinds) => {
20055
+ if (workspaceIds.length === 0 || authorKinds.length === 0) return [];
20056
+ return (await database.select(getTableColumns(issueComments$1)).from(issueComments$1).innerJoin(issues$1, eq(issues$1.id, issueComments$1.issueId)).where(and(inArray(issues$1.workspaceId, [...workspaceIds]), inArray(issueComments$1.authorKind, [...authorKinds]))).orderBy(asc(issueComments$1.createdAt), asc(issueComments$1.seq))).map(toIssueComment);
20057
+ },
19627
20058
  listLatestByIssues: async (issueIds, authorKinds) => {
19628
20059
  if (issueIds.length === 0 || authorKinds?.length === 0) return [];
19629
20060
  const ranked = database.select({
@@ -19660,6 +20091,10 @@ function createStoreOver(database) {
19660
20091
  listByIssue: async (issueId) => (await database.select().from(issueRuns$1).where(eq(issueRuns$1.issueId, issueId)).orderBy(asc(issueRuns$1.startedAt), asc(issueRuns$1.seq))).map(toIssueRun),
19661
20092
  listRunning: async (issueId) => (await database.select().from(issueRuns$1).where(and(eq(issueRuns$1.issueId, issueId), eq(issueRuns$1.state, "running"))).orderBy(asc(issueRuns$1.startedAt), asc(issueRuns$1.seq))).map(toIssueRun),
19662
20093
  listRunningWithSession: async () => (await database.select().from(issueRuns$1).where(and(eq(issueRuns$1.state, "running"), isNotNull(issueRuns$1.chatSessionId))).orderBy(asc(issueRuns$1.startedAt), asc(issueRuns$1.seq))).map(toIssueRun),
20094
+ listByWorkspaces: async (workspaceIds) => {
20095
+ if (workspaceIds.length === 0) return [];
20096
+ return (await database.select(getTableColumns(issueRuns$1)).from(issueRuns$1).innerJoin(issues$1, eq(issues$1.id, issueRuns$1.issueId)).where(inArray(issues$1.workspaceId, [...workspaceIds])).orderBy(asc(issueRuns$1.startedAt), asc(issueRuns$1.seq))).map(toIssueRun);
20097
+ },
19663
20098
  findById: async (runId) => {
19664
20099
  const [row] = await database.select().from(issueRuns$1).where(eq(issueRuns$1.id, runId)).limit(1);
19665
20100
  return row ? toIssueRun(row) : null;
@@ -21071,6 +21506,10 @@ function createSqliteBoardStoreCore(resolveDatabase) {
21071
21506
  },
21072
21507
  comments: {
21073
21508
  listByIssue: (issueId) => resolveDatabase().select().from(issueComments).where(eq(issueComments.issueId, issueId)).orderBy(asc(issueComments.createdAt), COMMENT_INSERTION_ORDER).all(),
21509
+ listByWorkspaces: (workspaceIds, authorKinds) => {
21510
+ if (workspaceIds.length === 0 || authorKinds.length === 0) return [];
21511
+ return resolveDatabase().select(getTableColumns(issueComments)).from(issueComments).innerJoin(issues, eq(issues.id, issueComments.issueId)).where(and(inArray(issues.workspaceId, [...workspaceIds]), inArray(issueComments.authorKind, [...authorKinds]))).orderBy(asc(issueComments.createdAt), COMMENT_INSERTION_ORDER).all();
21512
+ },
21074
21513
  listLatestByIssues: (issueIds, authorKinds) => {
21075
21514
  if (issueIds.length === 0 || authorKinds?.length === 0) return [];
21076
21515
  const database = resolveDatabase();
@@ -21100,6 +21539,10 @@ function createSqliteBoardStoreCore(resolveDatabase) {
21100
21539
  listByIssue: (issueId) => resolveDatabase().select().from(issueRuns).where(eq(issueRuns.issueId, issueId)).orderBy(asc(issueRuns.startedAt), RUN_INSERTION_ORDER).all(),
21101
21540
  listRunning: (issueId) => resolveDatabase().select().from(issueRuns).where(and(eq(issueRuns.issueId, issueId), eq(issueRuns.state, "running"))).orderBy(asc(issueRuns.startedAt), RUN_INSERTION_ORDER).all(),
21102
21541
  listRunningWithSession: () => resolveDatabase().select().from(issueRuns).where(and(eq(issueRuns.state, "running"), isNotNull(issueRuns.chatSessionId))).orderBy(asc(issueRuns.startedAt), RUN_INSERTION_ORDER).all(),
21542
+ listByWorkspaces: (workspaceIds) => {
21543
+ if (workspaceIds.length === 0) return [];
21544
+ return resolveDatabase().select(getTableColumns(issueRuns)).from(issueRuns).innerJoin(issues, eq(issues.id, issueRuns.issueId)).where(inArray(issues.workspaceId, [...workspaceIds])).orderBy(asc(issueRuns.startedAt), RUN_INSERTION_ORDER).all();
21545
+ },
21103
21546
  findById: (runId) => resolveDatabase().select().from(issueRuns).where(eq(issueRuns.id, runId)).get() ?? null,
21104
21547
  findByChatSessionId: (chatSessionId) => resolveDatabase().select().from(issueRuns).where(eq(issueRuns.chatSessionId, chatSessionId)).orderBy(desc(issueRuns.startedAt), sql`${issueRuns}.rowid desc`).get() ?? null,
21105
21548
  countByIssue: (issueId) => {
@@ -21232,6 +21675,7 @@ function createSqliteBoardStore(options) {
21232
21675
  comments: {
21233
21676
  listByIssue: async (issueId) => core.comments.listByIssue(issueId),
21234
21677
  listLatestByIssues: async (issueIds, authorKinds) => core.comments.listLatestByIssues(issueIds, authorKinds),
21678
+ listByWorkspaces: async (workspaceIds, authorKinds) => core.comments.listByWorkspaces(workspaceIds, authorKinds),
21235
21679
  findById: async (commentId) => core.comments.findById(commentId),
21236
21680
  findByDedupeKey: async (issueId, dedupeKey) => core.comments.findByDedupeKey(issueId, dedupeKey),
21237
21681
  listByDedupeKeyPrefix: async (issueId, prefix) => core.comments.listByDedupeKeyPrefix(issueId, prefix),
@@ -21243,6 +21687,7 @@ function createSqliteBoardStore(options) {
21243
21687
  listByIssue: async (issueId) => core.runs.listByIssue(issueId),
21244
21688
  listRunning: async (issueId) => core.runs.listRunning(issueId),
21245
21689
  listRunningWithSession: async () => core.runs.listRunningWithSession(),
21690
+ listByWorkspaces: async (workspaceIds) => core.runs.listByWorkspaces(workspaceIds),
21246
21691
  findById: async (runId) => core.runs.findById(runId),
21247
21692
  findByChatSessionId: async (chatSessionId) => core.runs.findByChatSessionId(chatSessionId),
21248
21693
  countByIssue: async (issueId) => core.runs.countByIssue(issueId),
@@ -24739,6 +25184,17 @@ function parseCount(value) {
24739
25184
  return count;
24740
25185
  }
24741
25186
  /**
25187
+ * A date as the command line takes it: `YYYY-MM-DD`, read as a UTC day, or a
25188
+ * unix-seconds number taken as it is. `end` turns a day into its last second,
25189
+ * so a range or a sprint ending on a day runs through that day.
25190
+ */
25191
+ function parseCalendarDay(value, end) {
25192
+ if (/^\d+$/.test(value)) return Number(value);
25193
+ const day = /^\d{4}-\d{2}-\d{2}$/.test(value) ? Date.parse(`${value}T00:00:00Z`) : NaN;
25194
+ if (Number.isNaN(day) || new Date(day).toISOString().slice(0, 10) !== value) throw new CliError(1, `Expected a date as YYYY-MM-DD or unix seconds, got "${value}".`);
25195
+ return day / 1e3 + (end ? 86399 : 0);
25196
+ }
25197
+ /**
24742
25198
  * The card a caller named, in the workspace the command is about.
24743
25199
  *
24744
25200
  * People and agents refer to a card by whatever they have in front of them: the
@@ -44692,7 +45148,7 @@ function startUpdateCheck(deps = {}) {
44692
45148
  setTimeout: globalThis.setTimeout,
44693
45149
  clearTimeout: globalThis.clearTimeout
44694
45150
  };
44695
- const current = deps.currentVersion ?? "0.5.0";
45151
+ const current = deps.currentVersion ?? "0.6.0";
44696
45152
  const controller = new AbortController();
44697
45153
  let found = null;
44698
45154
  const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
@@ -49724,6 +50180,10 @@ const BOARD_ERROR_RESPONSES = {
49724
50180
  status: 400,
49725
50181
  message: "A column accepts cards from other columns of its own board, and holds at least one card"
49726
50182
  },
50183
+ board_metrics_range_invalid: {
50184
+ status: 400,
50185
+ message: "The range starts after it ends"
50186
+ },
49727
50187
  board_nothing_ready: {
49728
50188
  status: 409,
49729
50189
  message: "No card in To Do is ready to take"
@@ -51543,17 +52003,6 @@ function parsePort(value) {
51543
52003
  //#endregion
51544
52004
  //#region src/cli/commands/sprint.ts
51545
52005
  /**
51546
- * A date as the command line takes it: `YYYY-MM-DD`, read as a UTC day, or a
51547
- * unix-seconds number taken as it is. `end` turns a day into its last second,
51548
- * so a sprint due on a day is still current throughout that day.
51549
- */
51550
- function parseDate(value, end) {
51551
- if (/^\d+$/.test(value)) return Number(value);
51552
- const day = /^\d{4}-\d{2}-\d{2}$/.test(value) ? Date.parse(`${value}T00:00:00Z`) : NaN;
51553
- if (Number.isNaN(day)) throw new CliError(1, `Expected a date as YYYY-MM-DD or unix seconds, got "${value}".`);
51554
- return day / 1e3 + (end ? 86399 : 0);
51555
- }
51556
- /**
51557
52006
  * `kanbo sprint …` — milestones read as sprints (ruling 5x-3).
51558
52007
  *
51559
52008
  * Listing and creating are anybody's. Closing one is a person's decision
@@ -51573,7 +52022,7 @@ function registerSprintCommands(program) {
51573
52022
  };
51574
52023
  });
51575
52024
  });
51576
- withBoardOptions(sprint.command("create").description("Start a sprint with a first and a last day").requiredOption("--title <title>", "what the sprint is called").requiredOption("--start <date>", "first day, as YYYY-MM-DD (UTC) or unix seconds", (value) => parseDate(value, false)).requiredOption("--due <date>", "last day, as YYYY-MM-DD (UTC, through its end) or unix seconds", (value) => parseDate(value, true)).option("--description <text>", "what the sprint is for")).action(async (options) => {
52025
+ withBoardOptions(sprint.command("create").description("Start a sprint with a first and a last day").requiredOption("--title <title>", "what the sprint is called").requiredOption("--start <date>", "first day, as YYYY-MM-DD (UTC) or unix seconds", (value) => parseCalendarDay(value, false)).requiredOption("--due <date>", "last day, as YYYY-MM-DD (UTC, through its end) or unix seconds", (value) => parseCalendarDay(value, true)).option("--description <text>", "what the sprint is for")).action(async (options) => {
51577
52026
  await runBoardCommand(options, "write", async (session) => {
51578
52027
  const created = await session.ops.createMilestone({
51579
52028
  workspaceId: session.workspace.id,
@@ -51616,6 +52065,113 @@ function registerSprintCommands(program) {
51616
52065
  });
51617
52066
  }
51618
52067
  //#endregion
52068
+ //#region src/cli/metrics-view.ts
52069
+ /** Every field `kanbo stats --json` may name, in the order the value carries them. */
52070
+ const BOARD_METRICS_FIELDS = [
52071
+ "range",
52072
+ "now",
52073
+ "milestoneId",
52074
+ "columns",
52075
+ "cycleTime",
52076
+ "leadTime",
52077
+ "throughput",
52078
+ "wip",
52079
+ "aging",
52080
+ "timeInColumn",
52081
+ "cumulativeFlow",
52082
+ "finished",
52083
+ "returns",
52084
+ "attempts",
52085
+ "canceled",
52086
+ "agents",
52087
+ "people"
52088
+ ];
52089
+ const LABEL_WIDTH = 16;
52090
+ /** A duration as the command line prints it: minutes under an hour, hours under a day, then days, to one decimal; a dash for none. */
52091
+ function formatDuration(seconds) {
52092
+ if (seconds === null) return "—";
52093
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
52094
+ if (seconds < 86400) return `${Number((seconds / 3600).toFixed(1))}h`;
52095
+ return `${Number((seconds / 86400).toFixed(1))}d`;
52096
+ }
52097
+ function percent(share) {
52098
+ return share === null ? "—" : `${Math.round(share * 100)}%`;
52099
+ }
52100
+ function decimal(value) {
52101
+ return value === null ? "—" : String(Number(value.toFixed(1)));
52102
+ }
52103
+ function count(n, noun) {
52104
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
52105
+ }
52106
+ function day(seconds) {
52107
+ return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
52108
+ }
52109
+ /** A labelled block: the first item beside the label, the rest under it; a dash when there is nothing. */
52110
+ function block(label, items) {
52111
+ const [first = "—", ...rest] = items;
52112
+ return [`${label.padEnd(LABEL_WIDTH)}${first}`, ...rest.map((item) => `${" ".repeat(LABEL_WIDTH)}${item}`)];
52113
+ }
52114
+ /** Items joined on one line, or none at all. */
52115
+ function oneLine(items) {
52116
+ return items.length === 0 ? [] : [items.join(" · ")];
52117
+ }
52118
+ /**
52119
+ * The metrics as a person reads them in a terminal: the range, then one block
52120
+ * per figure. The cumulative flow is a series per day and has no one-screen
52121
+ * form, so the last line says how to get it.
52122
+ */
52123
+ function describeMetrics(metrics, sprint) {
52124
+ const column = (slug) => sanitizeTerminalText(metrics.columns.find((candidate) => candidate.slug === slug)?.name ?? slug);
52125
+ const by = metrics.returns.byActorKind;
52126
+ const lines = [`${day(metrics.range.from)} → ${day(metrics.range.to)}`];
52127
+ if (sprint) lines.push(`${sanitizeTerminalText(sprint.title)}: ${sprint.cards.open + sprint.cards.done} planned · ${sprint.cards.done} done`);
52128
+ if (metrics.range.from > metrics.range.to) return [...lines, "Nothing measured yet: the range starts after now."].join("\n");
52129
+ lines.push(...block("Cycle time", [`p50 ${formatDuration(metrics.cycleTime.p50)} · p85 ${formatDuration(metrics.cycleTime.p85)} (${count(metrics.cycleTime.cards, "card")})`]), ...block("Lead time", [`p50 ${formatDuration(metrics.leadTime.p50)} · p85 ${formatDuration(metrics.leadTime.p85)}`]), ...block("Throughput", oneLine(metrics.throughput.map((week) => `${week.week} ${week.finished}`))), ...block("In progress", oneLine(metrics.wip.map((wip) => `${column(wip.slug)} ${wip.cards}${wip.limit === null ? "" : `/${wip.limit}`}`))), ...block("First pass", [`${percent(metrics.returns.firstPassRate)} · attempts ${decimal(metrics.attempts.perFinishedCard)} per card`]), ...block("Returns", [`${metrics.returns.total} (people ${by.user} · agents ${by.agent} · outside ${by.external}) · ${decimal(metrics.returns.perFinishedCard)} per card`]), ...block("Canceled", [String(metrics.canceled)]), ...block("Aging", metrics.aging.map((card) => `${card.issueId} ${column(card.slug)} ${formatDuration(card.ageSeconds)}${card.overCycleP85 ? " (older than p85)" : ""}`)), ...block("Time in column", oneLine(metrics.timeInColumn.filter((time) => time.seconds > 0).map((time) => `${column(time.slug)} ${formatDuration(time.seconds)}`))), ...block("Agents", metrics.agents.map(describeAgent)), ...block("People", metrics.people.map(describePerson)), "Cumulative flow by day: kanbo stats --json cumulativeFlow");
52130
+ return lines.join("\n");
52131
+ }
52132
+ function describeAgent(agent) {
52133
+ const { runs } = agent;
52134
+ return `${sanitizeTerminalText(agent.agentName)}: ${count(runs.total, "run")} (${runs.finished} finished · ${runs.failed} failed · ${runs.stopped} stopped · ${runs.running} running) · median ${formatDuration(agent.medianRunSeconds)} · ${count(agent.cardsFinished, "card")} finished · ${percent(agent.returnRate)} returned`;
52135
+ }
52136
+ function describePerson(person) {
52137
+ return `${sanitizeTerminalText(person.id ?? "unnamed")}: ${person.created} created · ${person.approvals} approved · ${person.returns} returned · ${person.moves} moved`;
52138
+ }
52139
+ const WEEK_SECONDS = 604800;
52140
+ /** The Monday that makes the range `DEFAULT_STATS_WEEKS` ISO weeks long, ending with the week of `now`. */
52141
+ function defaultStatsFrom(now) {
52142
+ return isoWeekOf(now).start - 11 * WEEK_SECONDS;
52143
+ }
52144
+ /**
52145
+ * `kanbo stats` — how work flows on this board (spec 2026-11-05, Part 4).
52146
+ *
52147
+ * Reading only, so it is anybody's, an agent's shell included. A sprint sets
52148
+ * the range to its own dates and keeps its cards; `--from` and `--to` still
52149
+ * win over either end.
52150
+ */
52151
+ function registerStatsCommand(program) {
52152
+ withBoardOptions(program.command("stats").description("How work flows on this board: cycle time, throughput, work in progress, returns, agents").option("--from <date>", `first day, YYYY-MM-DD (UTC) or unix seconds (12 weeks back when left out)`, (value) => parseCalendarDay(value, false)).option("--to <date>", "last day, YYYY-MM-DD (UTC, through its end) or unix seconds (now when left out)", (value) => parseCalendarDay(value, true)).option("--sprint <id>", "only the cards of this sprint, over its dates")).action(async (options) => {
52153
+ await runBoardCommand(options, "read", async (session) => {
52154
+ const now = currentUnixSeconds();
52155
+ const sprint = options.sprint === void 0 ? null : (await session.ops.listSprints(session.workspace.id, now)).find((candidate) => candidate.id === options.sprint) ?? null;
52156
+ if (options.sprint !== void 0 && sprint === null) throw new BoardError("issue_milestone_not_found", {
52157
+ milestoneId: options.sprint,
52158
+ workspaceId: session.workspace.id
52159
+ });
52160
+ const metrics = await session.ops.boardMetrics({
52161
+ workspaceIds: [session.workspace.id],
52162
+ from: options.from ?? sprint?.startDate ?? defaultStatsFrom(now),
52163
+ to: options.to ?? sprint?.dueDate ?? now,
52164
+ milestoneId: sprint?.id ?? null
52165
+ }, now);
52166
+ return {
52167
+ value: metrics,
52168
+ text: describeMetrics(metrics, sprint),
52169
+ fields: BOARD_METRICS_FIELDS
52170
+ };
52171
+ });
52172
+ });
52173
+ }
52174
+ //#endregion
51619
52175
  //#region src/cli/commands/uninstall.ts
51620
52176
  function registerUninstallCommand(program) {
51621
52177
  program.command("uninstall").description("Disconnect kanbo from your agents; the board stays unless --purge").option("--project", "only this project's files").option("--global", "only your own files, for every project").option("--purge", "also remove this project's board settings, and its board file after you confirm").option("--yes", "do it without asking (never deletes a board file)").option("--json [fields]", "print what was done as JSON; name comma-separated fields to print only those").action(async (options) => {
@@ -51796,6 +52352,7 @@ function registerKanboCommands(program, context = {}) {
51796
52352
  registerReadyCommand(program);
51797
52353
  registerPrimeCommand(program);
51798
52354
  registerSprintCommands(program);
52355
+ registerStatsCommand(program);
51799
52356
  registerServeCommand(program);
51800
52357
  program.commandsGroup("For agents and integrations:");
51801
52358
  registerMcpCommand(program);