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.
@@ -21,8 +21,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
21
21
  }) : target, mod));
22
22
  //#endregion
23
23
  const require_migrations_path = require("./migrations-path-DBCE17dT.cjs");
24
- const require_roles_sql = require("./roles.sql-CBFxSHy7.cjs");
25
- const require_migrate = require("./migrate-BoGrqK9m.cjs");
24
+ const require_roles_sql = require("./roles.sql-BC7k_zPh.cjs");
25
+ const require_migrate = require("./migrate-36aT0rV0.cjs");
26
26
  let commander = require("commander");
27
27
  let zod = require("zod");
28
28
  let node_os = require("node:os");
@@ -610,18 +610,18 @@ function activityToken(token) {
610
610
  function currentUnixSeconds() {
611
611
  return Math.floor(Date.now() / 1e3);
612
612
  }
613
- const DAY_SECONDS = 86400;
613
+ const DAY_SECONDS$2 = 86400;
614
614
  /**
615
615
  * A board date is a calendar day (ruling 5x-6): a start is kept as the first
616
616
  * second of its UTC day, a due date as the last, so a sprint runs through its
617
617
  * due day wherever the person reading it is.
618
618
  */
619
619
  function utcDayStart(seconds) {
620
- return Math.floor(seconds / DAY_SECONDS) * DAY_SECONDS;
620
+ return Math.floor(seconds / DAY_SECONDS$2) * DAY_SECONDS$2;
621
621
  }
622
622
  /** The last second of the UTC day `seconds` falls on — see `utcDayStart`. */
623
623
  function utcDayEnd(seconds) {
624
- return utcDayStart(seconds) + DAY_SECONDS - 1;
624
+ return utcDayStart(seconds) + DAY_SECONDS$2 - 1;
625
625
  }
626
626
  /** The largest unix seconds a moment is read as: past it, the number is milliseconds (year 5138 onwards). */
627
627
  const MAX_UNIX_SECONDS = 1e11;
@@ -1037,62 +1037,31 @@ function previewComment(comment) {
1037
1037
  };
1038
1038
  }
1039
1039
  //#endregion
1040
- //#region src/domain/field-changes.ts
1040
+ //#region src/domain/card-display-title.ts
1041
1041
  /**
1042
- * The issue columns whose edits are written to the audit trail.
1042
+ * What a card is called wherever a person or an agent reads it by name.
1043
1043
  *
1044
- * `statusLine` is absent on purpose: `writeStatusLine` records every line it
1045
- * writes itself, and no patch ever carries the column. `waitingFor` is absent
1046
- * too — whose turn it is shows in the card's own line and in the
1047
- * `system.approved` / `system.returned` comments around it, so a row saying the
1048
- * same thing a third time would only crowd the history.
1049
- */
1050
- const TRACKED_FIELDS = [
1051
- "workspaceId",
1052
- "title",
1053
- "description",
1054
- "priority",
1055
- "labels",
1056
- "milestoneId",
1057
- "parentIssueId",
1058
- "statusId",
1059
- "assigneeKind",
1060
- "assigneeId",
1061
- "dueDate",
1062
- "delegateAgentId",
1063
- "delegateProviderTargetId",
1064
- "contextRefs",
1065
- "executionMode",
1066
- "order"
1067
- ];
1068
- /**
1069
- * The audit rows one patch produces: a row per tracked field whose stringified
1070
- * value actually changed. Nothing is written here — the caller inserts what
1071
- * comes back, so the diff stays a pure function of the row, the patch, the
1072
- * actor and the clock.
1044
+ * A card written as a description alone is titled with its own key (every
1045
+ * writer does that). Named by its key, such a card says nothing — so its name
1046
+ * is the first line of its description instead. A browser bundle that does not
1047
+ * load this package keeps the same rule on its own, tested with the same
1048
+ * examples.
1073
1049
  */
1074
- function diffFieldChanges(before, updates, actor, now) {
1075
- const rows = [];
1076
- for (const field of TRACKED_FIELDS) {
1077
- if (!(field in updates)) continue;
1078
- const rawBefore = before[field === "labels" ? "labels" : field];
1079
- const rawAfter = updates[field];
1080
- const fromValue = rawBefore == null ? null : String(rawBefore);
1081
- const toValue = rawAfter == null ? null : String(rawAfter);
1082
- if (fromValue === toValue) continue;
1083
- rows.push({
1084
- id: (0, node_crypto.randomUUID)(),
1085
- issueId: before.id,
1086
- field,
1087
- fromValue,
1088
- toValue,
1089
- actorKind: actor.kind,
1090
- actorId: actor.id ?? null,
1091
- sourceChatSessionId: actor.sourceChatSessionId ?? null,
1092
- createdAt: now
1093
- });
1050
+ /** How long a description line may run before it is cut with an ellipsis. */
1051
+ const DESCRIPTION_LINE_LIMIT = 120;
1052
+ function cardDisplayTitle(card) {
1053
+ if (card.title !== card.id) return card.title;
1054
+ return descriptionFirstLine(card.description) ?? card.title;
1055
+ }
1056
+ function descriptionFirstLine(description) {
1057
+ for (const rawLine of (description ?? "").split("\n")) {
1058
+ const line = stripMarkdown(rawLine);
1059
+ if (line) return line.length > DESCRIPTION_LINE_LIMIT ? `${line.slice(0, 119).trimEnd()}…` : line;
1094
1060
  }
1095
- return rows;
1061
+ return null;
1062
+ }
1063
+ function stripMarkdown(line) {
1064
+ 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();
1096
1065
  }
1097
1066
  //#endregion
1098
1067
  //#region src/domain/status-name.ts
@@ -1147,6 +1116,603 @@ function normalizeStatusName(value) {
1147
1116
  return value.trim().toLowerCase().replace(/[\s-]+/g, "_");
1148
1117
  }
1149
1118
  //#endregion
1119
+ //#region src/domain/flow-rules.ts
1120
+ /** The two flow rules, as an unmet entry names them. */
1121
+ const FLOW_RULES = ["allowed_from", "wip_limit"];
1122
+ /** The column the standard flow guards, and the columns it lets cards in from, by slug. */
1123
+ const STANDARD_FLOW_TARGET = "done";
1124
+ const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
1125
+ /** A column's flow rules, out of what the row stores. */
1126
+ function readFlowRules(column) {
1127
+ return {
1128
+ allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
1129
+ wipLimit: column.wipLimit
1130
+ };
1131
+ }
1132
+ /** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
1133
+ function serializeAllowedFrom(allowedFrom) {
1134
+ return allowedFrom === null ? null : JSON.stringify(allowedFrom);
1135
+ }
1136
+ /**
1137
+ * The flow rules a card entering `column` does not meet, where it may come from
1138
+ * first. Pure: what the board holds arrives in `facts`. A card being created
1139
+ * comes from no column, so a column that names where cards come from does not
1140
+ * take it.
1141
+ */
1142
+ function evaluateFlowRules(card, column, rules, facts) {
1143
+ const unmet = [];
1144
+ if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
1145
+ rule: "allowed_from",
1146
+ reason: "not_allowed_from",
1147
+ detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
1148
+ });
1149
+ if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
1150
+ rule: "wip_limit",
1151
+ reason: "column_full",
1152
+ detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
1153
+ });
1154
+ return unmet;
1155
+ }
1156
+ function describeAllowedFrom(name, allowedFrom, columnNames) {
1157
+ const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
1158
+ 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`;
1159
+ }
1160
+ /** `A`, `A and B`, `A, B and C`. */
1161
+ function joinWords(words) {
1162
+ return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
1163
+ }
1164
+ /**
1165
+ * The standard flow on these columns: Done, and the columns it takes cards from
1166
+ * — In Review, and QA when there is one — in board order. `null` when the board
1167
+ * has no Done, or neither of the two.
1168
+ */
1169
+ function standardFlowOf(columns) {
1170
+ const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
1171
+ const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
1172
+ return target && sources.length > 0 ? {
1173
+ target,
1174
+ sources
1175
+ } : null;
1176
+ }
1177
+ //#endregion
1178
+ //#region src/domain/iso-week.ts
1179
+ const DAY_SECONDS$1 = 86400;
1180
+ /** The ISO week a moment falls in, read in UTC like every board date (ruling 5x-6). */
1181
+ function isoWeekOf(seconds) {
1182
+ const day = Math.floor(seconds / DAY_SECONDS$1);
1183
+ const monday = day - (day + 3) % 7;
1184
+ const thursday = monday + 3;
1185
+ const year = (/* @__PURE__ */ new Date(thursday * DAY_SECONDS$1 * 1e3)).getUTCFullYear();
1186
+ const januaryFirst = Date.UTC(year, 0, 1) / 1e3 / DAY_SECONDS$1;
1187
+ const week = Math.floor((thursday - januaryFirst) / 7) + 1;
1188
+ return {
1189
+ week: `${year}-W${String(week).padStart(2, "0")}`,
1190
+ start: monday * DAY_SECONDS$1
1191
+ };
1192
+ }
1193
+ /** The UTC day a moment falls on, as `2026-01-05`. */
1194
+ function utcDayLabel(seconds) {
1195
+ return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
1196
+ }
1197
+ //#endregion
1198
+ //#region src/ops/card-timeline.ts
1199
+ /**
1200
+ * A card's columns over time, oldest first, from its creation on: the column
1201
+ * it was created in, then one stretch per move. A card created before 0.5.0
1202
+ * carries no entry for its creation; one created since carries `null → column`
1203
+ * at its creation, which leaves a stretch of no length in no column.
1204
+ *
1205
+ * The feed lists moves by time and then by change id, and a change id is
1206
+ * random, so two moves of one card in the same second can come back in either
1207
+ * order. Within one second the moves are put back in the order they chain —
1208
+ * each leaves the column the one before it entered — which is the order they
1209
+ * were made in (ruling R012-5). Moves that do not chain keep the feed's order.
1210
+ */
1211
+ function buildCardTimeline(card, entries) {
1212
+ const ordered = chainSameSecond(entries.toSorted((left, right) => left.at - right.at || (left.changeId < right.changeId ? -1 : left.changeId > right.changeId ? 1 : 0)));
1213
+ const stretches = [];
1214
+ const first = ordered[0];
1215
+ let current = {
1216
+ statusId: first ? first.fromStatusId : card.statusId,
1217
+ from: card.createdAt,
1218
+ to: null
1219
+ };
1220
+ for (const entry of ordered) {
1221
+ stretches.push({
1222
+ ...current,
1223
+ to: entry.at
1224
+ });
1225
+ current = {
1226
+ statusId: entry.toStatusId,
1227
+ from: entry.at,
1228
+ to: null
1229
+ };
1230
+ }
1231
+ stretches.push(current);
1232
+ return stretches;
1233
+ }
1234
+ /** The column the card was in at `at` — a move at `at` has already happened — or `undefined` before the card existed. */
1235
+ function statusAt(stretches, at) {
1236
+ return stretches.find((stretch) => stretch.from <= at && (stretch.to === null || at < stretch.to))?.statusId;
1237
+ }
1238
+ function chainSameSecond(entries) {
1239
+ const ordered = [];
1240
+ let start = 0;
1241
+ while (start < entries.length) {
1242
+ let end = start;
1243
+ while (end < entries.length && entries[end].at === entries[start].at) end++;
1244
+ const group = entries.slice(start, end);
1245
+ const previous = ordered.at(-1);
1246
+ let current = previous ? previous.toStatusId : headOf(group)?.fromStatusId;
1247
+ while (group.length > 0) {
1248
+ let next = group.findIndex((entry) => entry.fromStatusId === current);
1249
+ if (next < 0) next = Math.max(group.findIndex((entry) => entry === headOf(group)), 0);
1250
+ const [picked] = group.splice(next, 1);
1251
+ ordered.push(picked);
1252
+ current = picked.toStatusId;
1253
+ }
1254
+ start = end;
1255
+ }
1256
+ return ordered;
1257
+ }
1258
+ /** The move nothing else in the group feeds into. */
1259
+ function headOf(group) {
1260
+ return group.find((entry) => !group.some((other) => other !== entry && other.toStatusId === entry.fromStatusId));
1261
+ }
1262
+ //#endregion
1263
+ //#region src/ops/board-metrics.ts
1264
+ const DAY_SECONDS = 86400;
1265
+ const WEEK_SECONDS$1 = 7 * DAY_SECONDS;
1266
+ /**
1267
+ * The value at `percent` of `values` by nearest rank (ruling R012-2): the
1268
+ * ⌈percent·n/100⌉-th in ascending order. Always a value that occurred; `null`
1269
+ * for none.
1270
+ */
1271
+ function nearestRank(values, percent) {
1272
+ if (values.length === 0) return null;
1273
+ const sorted = values.toSorted((left, right) => left - right);
1274
+ return sorted[Math.max(Math.ceil(percent * sorted.length / 100), 1) - 1];
1275
+ }
1276
+ /** 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. */
1277
+ function boardMetricsAgentKey(run) {
1278
+ return run.agentId ?? run.agentName;
1279
+ }
1280
+ function scopeOf(history, input) {
1281
+ const workspaceIds = new Set(input.workspaceIds);
1282
+ const milestoneId = input.milestoneId ?? null;
1283
+ const statusById = new Map(history.statuses.filter((status) => workspaceIds.has(status.workspaceId)).map((status) => [status.id, status]));
1284
+ const cards = history.cards.filter((card) => workspaceIds.has(card.workspaceId)).filter((card) => milestoneId === null || card.milestoneId === milestoneId);
1285
+ const isCanceled = (card) => card.statusId !== null && statusById.get(card.statusId)?.category === "canceled";
1286
+ return {
1287
+ statusById,
1288
+ counted: cards.filter((card) => !isCanceled(card)),
1289
+ canceled: cards.filter(isCanceled)
1290
+ };
1291
+ }
1292
+ /**
1293
+ * The runs of the counted cards, and apart those of the cards in a canceled
1294
+ * column (R012-6): what a host prices to put a cost beside the metrics, and the
1295
+ * spend on canceled work on a line of its own.
1296
+ */
1297
+ function boardMetricsRuns(history, input) {
1298
+ const { counted, canceled } = scopeOf(history, input);
1299
+ const countedIds = new Set(counted.map((card) => card.id));
1300
+ const canceledIds = new Set(canceled.map((card) => card.id));
1301
+ return {
1302
+ counted: history.runs.filter((run) => countedIds.has(run.issueId)),
1303
+ canceled: history.runs.filter((run) => canceledIds.has(run.issueId))
1304
+ };
1305
+ }
1306
+ /** 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. */
1307
+ function returnerOf(entry) {
1308
+ if (entry.actorKind === "user") return "user";
1309
+ return entry.actorKind === "system" ? "external" : "agent";
1310
+ }
1311
+ /**
1312
+ * The metrics of `input` over rows already read. Pure: the same rows give the
1313
+ * same answer in any order, so a host may put several boards' rows together
1314
+ * and compute once — percentiles of parts do not add up to the whole's.
1315
+ */
1316
+ function computeBoardMetrics(history, input, now) {
1317
+ if (input.from > input.to) throw new require_migrations_path.BoardError("board_metrics_range_invalid", {
1318
+ from: input.from,
1319
+ to: input.to
1320
+ });
1321
+ const milestoneId = input.milestoneId ?? null;
1322
+ if (milestoneId !== null && !history.milestones.some((milestone) => milestone.id === milestoneId && input.workspaceIds.includes(milestone.workspaceId))) throw new require_migrations_path.BoardError("issue_milestone_not_found", { milestoneId });
1323
+ const from = input.from;
1324
+ const to = Math.min(input.to, now);
1325
+ const inRange = (at) => from <= at && at <= to;
1326
+ const { statusById, counted, canceled } = scopeOf(history, input);
1327
+ const columns = groupColumns(input.workspaceIds, [...statusById.values()]);
1328
+ const slugOf = (statusId) => {
1329
+ const status = statusId === null ? void 0 : statusById.get(statusId);
1330
+ return status ? normalizeStatusName(status.name) : null;
1331
+ };
1332
+ const categoryOf = (statusId) => statusId === null ? null : statusById.get(statusId)?.category ?? null;
1333
+ const isReturn = (entry) => {
1334
+ const source = entry.fromStatusId === null ? void 0 : statusById.get(entry.fromStatusId);
1335
+ const target = statusById.get(entry.toStatusId);
1336
+ return source !== void 0 && target !== void 0 && source.workspaceId === target.workspaceId && target.order < source.order && (target.category === "unstarted" || target.category === "started");
1337
+ };
1338
+ const entriesByCard = groupBy(history.entries, (entry) => entry.issueId);
1339
+ const timelineOf = (card) => buildCardTimeline(card, entriesByCard.get(card.id) ?? []);
1340
+ const countedIds = new Set(counted.map((card) => card.id));
1341
+ const facts = counted.map((card) => {
1342
+ const stretches = timelineOf(card);
1343
+ return {
1344
+ card,
1345
+ stretches,
1346
+ startedAt: stretches.find((stretch) => categoryOf(stretch.statusId) === "started")?.from ?? null
1347
+ };
1348
+ });
1349
+ const finished = facts.filter((fact) => categoryOf(fact.card.statusId) === "completed").map((fact) => ({
1350
+ ...fact,
1351
+ finishedAt: fact.stretches.at(-1).from
1352
+ })).filter((fact) => inRange(fact.finishedAt)).toSorted((left, right) => left.finishedAt - right.finishedAt || left.card.id.localeCompare(right.card.id));
1353
+ const runsByCard = groupBy(history.runs.filter((run) => countedIds.has(run.issueId)), (run) => run.issueId);
1354
+ const returnsByCard = groupBy(history.entries.filter((entry) => countedIds.has(entry.issueId) && isReturn(entry)), (entry) => entry.issueId);
1355
+ const finishedCards = finished.map((fact) => ({
1356
+ ...cardOf(fact.card),
1357
+ startedAt: fact.startedAt,
1358
+ finishedAt: fact.finishedAt,
1359
+ cycleSeconds: fact.startedAt === null ? null : fact.finishedAt - fact.startedAt,
1360
+ leadSeconds: fact.finishedAt - fact.card.createdAt,
1361
+ returns: returnsByCard.get(fact.card.id)?.length ?? 0,
1362
+ runs: runsByCard.get(fact.card.id)?.length ?? 0
1363
+ }));
1364
+ const cycleTime = durations(finishedCards.flatMap((card) => card.cycleSeconds === null ? [] : [card.cycleSeconds]));
1365
+ const leadTime = durations(finishedCards.map((card) => card.leadSeconds));
1366
+ const throughput = [];
1367
+ for (let start = isoWeekOf(from).start; from <= to && start <= to; start += WEEK_SECONDS$1) throughput.push({
1368
+ week: isoWeekOf(start).week,
1369
+ start,
1370
+ finished: finishedCards.filter((card) => card.finishedAt >= start && card.finishedAt < start + WEEK_SECONDS$1).length
1371
+ });
1372
+ const inProgress = facts.filter((fact) => categoryOf(fact.card.statusId) === "started");
1373
+ const wip = columns.filter((column) => column.category === "started").map((column) => {
1374
+ const limits = [...statusById.values()].filter((status) => normalizeStatusName(status.name) === column.slug).map((status) => readFlowRules(status).wipLimit);
1375
+ return {
1376
+ slug: column.slug,
1377
+ cards: inProgress.filter((fact) => slugOf(fact.card.statusId) === column.slug).length,
1378
+ limit: limits.every((limit) => limit !== null) ? limits.reduce((sum, limit) => sum + limit, 0) : null
1379
+ };
1380
+ });
1381
+ const aging = inProgress.map((fact) => {
1382
+ const current = fact.stretches.at(-1);
1383
+ const startedAt = fact.startedAt ?? current.from;
1384
+ return {
1385
+ ...cardOf(fact.card),
1386
+ slug: slugOf(fact.card.statusId),
1387
+ startedAt,
1388
+ ageSeconds: now - startedAt,
1389
+ inColumnSeconds: now - current.from,
1390
+ overCycleP85: cycleTime.p85 !== null && now - startedAt > cycleTime.p85
1391
+ };
1392
+ }).toSorted((left, right) => right.ageSeconds - left.ageSeconds || left.issueId.localeCompare(right.issueId));
1393
+ const columnSeconds = new Map(columns.map((column) => [column.slug, 0]));
1394
+ for (const fact of finished) for (const stretch of fact.stretches) {
1395
+ const slug = slugOf(stretch.statusId);
1396
+ const end = Math.min(stretch.to ?? fact.finishedAt, fact.finishedAt);
1397
+ if (slug !== null && end > stretch.from) columnSeconds.set(slug, (columnSeconds.get(slug) ?? 0) + end - stretch.from);
1398
+ }
1399
+ const cumulativeFlow = [];
1400
+ for (let dayStart = utcDayStart(from); from <= to && dayStart <= to; dayStart += DAY_SECONDS) {
1401
+ const at = Math.min(dayStart + DAY_SECONDS - 1, to);
1402
+ const cards = Object.fromEntries(columns.map((column) => [column.slug, 0]));
1403
+ for (const fact of facts) {
1404
+ const statusId = statusAt(fact.stretches, at);
1405
+ const slug = statusId === void 0 ? null : slugOf(statusId);
1406
+ if (slug !== null) cards[slug] = (cards[slug] ?? 0) + 1;
1407
+ }
1408
+ cumulativeFlow.push({
1409
+ day: utcDayLabel(dayStart),
1410
+ at,
1411
+ cards
1412
+ });
1413
+ }
1414
+ const finishedReturns = finished.flatMap((fact) => returnsByCard.get(fact.card.id) ?? []);
1415
+ const byActorKind = {
1416
+ user: 0,
1417
+ agent: 0,
1418
+ external: 0
1419
+ };
1420
+ for (const entry of finishedReturns) byActorKind[returnerOf(entry)]++;
1421
+ const runTotal = finishedCards.reduce((sum, card) => sum + card.runs, 0);
1422
+ const share = (part, whole) => whole === 0 ? null : part / whole;
1423
+ return {
1424
+ range: {
1425
+ from,
1426
+ to
1427
+ },
1428
+ now,
1429
+ milestoneId,
1430
+ columns,
1431
+ cycleTime,
1432
+ leadTime,
1433
+ throughput,
1434
+ wip,
1435
+ aging,
1436
+ timeInColumn: columns.map((column) => ({
1437
+ slug: column.slug,
1438
+ seconds: columnSeconds.get(column.slug) ?? 0
1439
+ })),
1440
+ cumulativeFlow,
1441
+ finished: finishedCards,
1442
+ returns: {
1443
+ total: finishedReturns.length,
1444
+ perFinishedCard: share(finishedReturns.length, finishedCards.length),
1445
+ firstPassRate: share(finishedCards.filter((card) => card.returns === 0).length, finishedCards.length),
1446
+ byActorKind
1447
+ },
1448
+ attempts: {
1449
+ runs: runTotal,
1450
+ perFinishedCard: share(runTotal, finishedCards.length)
1451
+ },
1452
+ canceled: canceled.filter((card) => inRange(timelineOf(card).at(-1).from)).length,
1453
+ agents: agentRows(history.runs.filter((run) => countedIds.has(run.issueId)), finishedCards, inRange),
1454
+ people: personRows(counted, history, countedIds, inRange, isReturn)
1455
+ };
1456
+ }
1457
+ function agentRows(runs, finished, inRange) {
1458
+ const finishedIds = new Set(finished.map((card) => card.issueId));
1459
+ const returned = new Set(finished.filter((card) => card.returns > 0).map((card) => card.issueId));
1460
+ const rows = [];
1461
+ for (const [key, own] of groupBy(runs, boardMetricsAgentKey)) {
1462
+ const ranged = own.filter((run) => inRange(run.startedAt));
1463
+ const worked = new Set(own.map((run) => run.issueId).filter((issueId) => finishedIds.has(issueId)));
1464
+ if (ranged.length === 0 && worked.size === 0) continue;
1465
+ const newest = own.toSorted((left, right) => right.startedAt - left.startedAt || right.id.localeCompare(left.id))[0];
1466
+ const ended = ranged.flatMap((run) => run.endedAt === null ? [] : [run.endedAt - run.startedAt]);
1467
+ rows.push({
1468
+ key,
1469
+ agentId: newest.agentId,
1470
+ agentName: newest.agentName,
1471
+ runs: {
1472
+ total: ranged.length,
1473
+ finished: ranged.filter((run) => run.state === "finished").length,
1474
+ failed: ranged.filter((run) => run.state === "failed").length,
1475
+ stopped: ranged.filter((run) => run.state === "stopped").length,
1476
+ running: ranged.filter((run) => run.state === "running").length
1477
+ },
1478
+ medianRunSeconds: nearestRank(ended, 50),
1479
+ cardsFinished: worked.size,
1480
+ returnRate: worked.size === 0 ? null : [...worked].filter((issueId) => returned.has(issueId)).length / worked.size
1481
+ });
1482
+ }
1483
+ return rows.toSorted((left, right) => right.runs.total - left.runs.total || left.key.localeCompare(right.key));
1484
+ }
1485
+ function personRows(counted, history, countedIds, inRange, isReturn) {
1486
+ const rows = /* @__PURE__ */ new Map();
1487
+ const row = (id) => {
1488
+ let found = rows.get(id);
1489
+ if (!found) {
1490
+ found = {
1491
+ id,
1492
+ created: 0,
1493
+ approvals: 0,
1494
+ returns: 0,
1495
+ moves: 0
1496
+ };
1497
+ rows.set(id, found);
1498
+ }
1499
+ return found;
1500
+ };
1501
+ for (const card of counted) if (card.createdByKind === "user" && inRange(card.createdAt)) row(card.createdById).created++;
1502
+ for (const comment of history.approvals) if (comment.authorKind === "system.approved" && countedIds.has(comment.issueId) && inRange(comment.createdAt)) row(comment.authorId).approvals++;
1503
+ for (const entry of history.entries) if (entry.actorKind === "user" && entry.fromStatusId !== null && countedIds.has(entry.issueId) && inRange(entry.at)) {
1504
+ const person = row(entry.actorId);
1505
+ person.moves++;
1506
+ if (isReturn(entry)) person.returns++;
1507
+ }
1508
+ const activity = (person) => person.created + person.approvals + person.returns + person.moves;
1509
+ return [...rows.values()].toSorted((left, right) => activity(right) - activity(left) || (left.id ?? "￿").localeCompare(right.id ?? "￿"));
1510
+ }
1511
+ /**
1512
+ * The workspaces' columns as one board shows them, one per slug (ruling
1513
+ * R012-8): the first workspace's columns set the order, and a column only a
1514
+ * later one has goes right after the column before it there. Name and category
1515
+ * are the first workspace's that has it.
1516
+ */
1517
+ function groupColumns(workspaceIds, statuses) {
1518
+ const columns = [];
1519
+ for (const workspaceId of new Set(workspaceIds)) {
1520
+ let previous = -1;
1521
+ const own = statuses.filter((status) => status.workspaceId === workspaceId).toSorted((left, right) => left.order - right.order || left.id.localeCompare(right.id));
1522
+ for (const status of own) {
1523
+ const slug = normalizeStatusName(status.name);
1524
+ let index = columns.findIndex((column) => column.slug === slug);
1525
+ if (index === -1) {
1526
+ index = previous + 1;
1527
+ columns.splice(index, 0, {
1528
+ slug,
1529
+ name: status.name,
1530
+ category: status.category
1531
+ });
1532
+ }
1533
+ previous = index;
1534
+ }
1535
+ }
1536
+ return columns;
1537
+ }
1538
+ function durations(values) {
1539
+ return {
1540
+ cards: values.length,
1541
+ p50: nearestRank(values, 50),
1542
+ p85: nearestRank(values, 85)
1543
+ };
1544
+ }
1545
+ function cardOf(card) {
1546
+ return {
1547
+ issueId: card.id,
1548
+ workspaceId: card.workspaceId,
1549
+ title: cardDisplayTitle(card)
1550
+ };
1551
+ }
1552
+ function groupBy(items, keyOf) {
1553
+ const groups = /* @__PURE__ */ new Map();
1554
+ for (const item of items) {
1555
+ const key = keyOf(item);
1556
+ const group = groups.get(key);
1557
+ if (group) group.push(item);
1558
+ else groups.set(key, [item]);
1559
+ }
1560
+ return groups;
1561
+ }
1562
+ //#endregion
1563
+ //#region src/ops/column-entries.ts
1564
+ /**
1565
+ * Every entry into a column on the named boards at or after `since`, in order
1566
+ * of `(at, changeId)` — the one signal an app dispatching work on column
1567
+ * changes needs. It sees every writer, an outside `kanbo` on the same database
1568
+ * included, because it reads the cards' own history.
1569
+ *
1570
+ * A page never ends inside a second: when `limit` cuts into one, the rest of
1571
+ * that second comes too, so a page may be longer than `limit`. A reader keeps
1572
+ * its cursor at the last `at` it read after a short page — a write in that
1573
+ * second may still commit — and drops the `changeId`s it has seen; after a page
1574
+ * of `limit` or more it moves on to the last `at` + 1, which a second holding
1575
+ * more entries than `limit` would otherwise never let it pass.
1576
+ */
1577
+ async function listColumnEntries(store, input) {
1578
+ const page = await store.fieldChanges.listByFieldSince("statusId", input);
1579
+ return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
1580
+ }
1581
+ /** The page with every change of its last second, not only those `limit` let in. */
1582
+ async function withWholeLastSecond(store, input, page) {
1583
+ const last = page.at(-1).createdAt;
1584
+ const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
1585
+ workspaceIds: input.workspaceIds,
1586
+ since: last,
1587
+ until: last
1588
+ });
1589
+ return [...page.filter((change) => change.createdAt < last), ...lastSecond];
1590
+ }
1591
+ /** A status change as an entry; a change that took the card out of every column enters none. */
1592
+ function toColumnEntry(change) {
1593
+ return change.toValue === null ? [] : [{
1594
+ changeId: change.id,
1595
+ issueId: change.issueId,
1596
+ workspaceId: change.workspaceId,
1597
+ fromStatusId: change.fromValue,
1598
+ toStatusId: change.toValue,
1599
+ actorKind: change.actorKind,
1600
+ actorId: change.actorId,
1601
+ at: change.createdAt
1602
+ }];
1603
+ }
1604
+ //#endregion
1605
+ //#region src/ops/board-metrics-read.ts
1606
+ /** The comment kind the metrics read: a person's approvals. Returns are moves (R012-7). */
1607
+ const APPROVAL_KINDS = ["system.approved"];
1608
+ /** `listColumnEntries` from the board's first column change on (drift check, item 3). */
1609
+ const FROM_THE_FIRST_CHANGE = 0;
1610
+ /**
1611
+ * Every row the metrics of these workspaces are computed from: three
1612
+ * statements per workspace (columns, milestones, cards) plus three across all
1613
+ * of them (column changes through the `(field, created_at)` index, runs,
1614
+ * approvals); none per card.
1615
+ *
1616
+ * The rows are read and the metrics computed in TypeScript rather than in SQL
1617
+ * (ruling R012-1): a board is thousands of rows, not millions; percentiles, ISO
1618
+ * weeks and a day-by-day replay would otherwise be written once per dialect;
1619
+ * and a host whose workspaces live on several boards can put their rows
1620
+ * together and compute once.
1621
+ */
1622
+ async function readBoardMetricsHistory(store, workspaceIds) {
1623
+ const ids = [...new Set(workspaceIds)];
1624
+ if (ids.length === 0) return {
1625
+ statuses: [],
1626
+ milestones: [],
1627
+ cards: [],
1628
+ entries: [],
1629
+ runs: [],
1630
+ approvals: []
1631
+ };
1632
+ const perWorkspace = async (read) => (await Promise.all(ids.map(read))).flat();
1633
+ const [statuses, milestones, cards, entries, runs, approvals] = await Promise.all([
1634
+ perWorkspace(async (id) => await store.statuses.listByWorkspace(id)),
1635
+ perWorkspace(async (id) => await store.milestones.listByWorkspace(id)),
1636
+ perWorkspace(async (id) => await store.issues.listByWorkspace(id)),
1637
+ listColumnEntries(store, {
1638
+ workspaceIds: ids,
1639
+ since: FROM_THE_FIRST_CHANGE
1640
+ }),
1641
+ store.runs.listByWorkspaces(ids),
1642
+ store.comments.listByWorkspaces(ids, APPROVAL_KINDS)
1643
+ ]);
1644
+ return {
1645
+ statuses,
1646
+ milestones,
1647
+ cards,
1648
+ entries,
1649
+ runs,
1650
+ approvals
1651
+ };
1652
+ }
1653
+ /** How work flows on the board over `input`'s range (`computeBoardMetrics`), read from the store; without cost. */
1654
+ async function boardMetrics(store, input, now = currentUnixSeconds()) {
1655
+ return computeBoardMetrics(await readBoardMetricsHistory(store, input.workspaceIds), input, now);
1656
+ }
1657
+ //#endregion
1658
+ //#region src/domain/field-changes.ts
1659
+ /**
1660
+ * The issue columns whose edits are written to the audit trail.
1661
+ *
1662
+ * `statusLine` is absent on purpose: `writeStatusLine` records every line it
1663
+ * writes itself, and no patch ever carries the column. `waitingFor` is absent
1664
+ * too — whose turn it is shows in the card's own line and in the
1665
+ * `system.approved` / `system.returned` comments around it, so a row saying the
1666
+ * same thing a third time would only crowd the history.
1667
+ */
1668
+ const TRACKED_FIELDS = [
1669
+ "workspaceId",
1670
+ "title",
1671
+ "description",
1672
+ "priority",
1673
+ "labels",
1674
+ "milestoneId",
1675
+ "parentIssueId",
1676
+ "statusId",
1677
+ "assigneeKind",
1678
+ "assigneeId",
1679
+ "dueDate",
1680
+ "delegateAgentId",
1681
+ "delegateProviderTargetId",
1682
+ "contextRefs",
1683
+ "executionMode",
1684
+ "order"
1685
+ ];
1686
+ /**
1687
+ * The audit rows one patch produces: a row per tracked field whose stringified
1688
+ * value actually changed. Nothing is written here — the caller inserts what
1689
+ * comes back, so the diff stays a pure function of the row, the patch, the
1690
+ * actor and the clock.
1691
+ */
1692
+ function diffFieldChanges(before, updates, actor, now) {
1693
+ const rows = [];
1694
+ for (const field of TRACKED_FIELDS) {
1695
+ if (!(field in updates)) continue;
1696
+ const rawBefore = before[field === "labels" ? "labels" : field];
1697
+ const rawAfter = updates[field];
1698
+ const fromValue = rawBefore == null ? null : String(rawBefore);
1699
+ const toValue = rawAfter == null ? null : String(rawAfter);
1700
+ if (fromValue === toValue) continue;
1701
+ rows.push({
1702
+ id: (0, node_crypto.randomUUID)(),
1703
+ issueId: before.id,
1704
+ field,
1705
+ fromValue,
1706
+ toValue,
1707
+ actorKind: actor.kind,
1708
+ actorId: actor.id ?? null,
1709
+ sourceChatSessionId: actor.sourceChatSessionId ?? null,
1710
+ createdAt: now
1711
+ });
1712
+ }
1713
+ return rows;
1714
+ }
1715
+ //#endregion
1150
1716
  //#region src/ops/change-seq.ts
1151
1717
  /** The `kanban_meta` row every board write bumps. */
1152
1718
  const BOARD_CHANGE_KEY = "board";
@@ -1531,65 +2097,6 @@ function describeColumnRefusal(details) {
1531
2097
  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");
1532
2098
  }
1533
2099
  //#endregion
1534
- //#region src/domain/flow-rules.ts
1535
- /** The two flow rules, as an unmet entry names them. */
1536
- const FLOW_RULES = ["allowed_from", "wip_limit"];
1537
- /** The column the standard flow guards, and the columns it lets cards in from, by slug. */
1538
- const STANDARD_FLOW_TARGET = "done";
1539
- const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
1540
- /** A column's flow rules, out of what the row stores. */
1541
- function readFlowRules(column) {
1542
- return {
1543
- allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
1544
- wipLimit: column.wipLimit
1545
- };
1546
- }
1547
- /** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
1548
- function serializeAllowedFrom(allowedFrom) {
1549
- return allowedFrom === null ? null : JSON.stringify(allowedFrom);
1550
- }
1551
- /**
1552
- * The flow rules a card entering `column` does not meet, where it may come from
1553
- * first. Pure: what the board holds arrives in `facts`. A card being created
1554
- * comes from no column, so a column that names where cards come from does not
1555
- * take it.
1556
- */
1557
- function evaluateFlowRules(card, column, rules, facts) {
1558
- const unmet = [];
1559
- if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
1560
- rule: "allowed_from",
1561
- reason: "not_allowed_from",
1562
- detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
1563
- });
1564
- if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
1565
- rule: "wip_limit",
1566
- reason: "column_full",
1567
- detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
1568
- });
1569
- return unmet;
1570
- }
1571
- function describeAllowedFrom(name, allowedFrom, columnNames) {
1572
- const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
1573
- 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`;
1574
- }
1575
- /** `A`, `A and B`, `A, B and C`. */
1576
- function joinWords(words) {
1577
- return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
1578
- }
1579
- /**
1580
- * The standard flow on these columns: Done, and the columns it takes cards from
1581
- * — In Review, and QA when there is one — in board order. `null` when the board
1582
- * has no Done, or neither of the two.
1583
- */
1584
- function standardFlowOf(columns) {
1585
- const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
1586
- const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
1587
- return target && sources.length > 0 ? {
1588
- target,
1589
- sources
1590
- } : null;
1591
- }
1592
- //#endregion
1593
2100
  //#region src/ops/columns.ts
1594
2101
  /** The columns of one workspace, in board order. */
1595
2102
  async function listColumns(store, workspaceId) {
@@ -3270,48 +3777,6 @@ async function notReadyReason(store, card, toDo) {
3270
3777
  return toDo !== null && card.statusId === toDo.id ? null : "not_in_to_do";
3271
3778
  }
3272
3779
  //#endregion
3273
- //#region src/ops/column-entries.ts
3274
- /**
3275
- * Every entry into a column on the named boards at or after `since`, in order
3276
- * of `(at, changeId)` — the one signal an app dispatching work on column
3277
- * changes needs. It sees every writer, an outside `kanbo` on the same database
3278
- * included, because it reads the cards' own history.
3279
- *
3280
- * A page never ends inside a second: when `limit` cuts into one, the rest of
3281
- * that second comes too, so a page may be longer than `limit`. A reader keeps
3282
- * its cursor at the last `at` it read after a short page — a write in that
3283
- * second may still commit — and drops the `changeId`s it has seen; after a page
3284
- * of `limit` or more it moves on to the last `at` + 1, which a second holding
3285
- * more entries than `limit` would otherwise never let it pass.
3286
- */
3287
- async function listColumnEntries(store, input) {
3288
- const page = await store.fieldChanges.listByFieldSince("statusId", input);
3289
- return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
3290
- }
3291
- /** The page with every change of its last second, not only those `limit` let in. */
3292
- async function withWholeLastSecond(store, input, page) {
3293
- const last = page.at(-1).createdAt;
3294
- const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
3295
- workspaceIds: input.workspaceIds,
3296
- since: last,
3297
- until: last
3298
- });
3299
- return [...page.filter((change) => change.createdAt < last), ...lastSecond];
3300
- }
3301
- /** A status change as an entry; a change that took the card out of every column enters none. */
3302
- function toColumnEntry(change) {
3303
- return change.toValue === null ? [] : [{
3304
- changeId: change.id,
3305
- issueId: change.issueId,
3306
- workspaceId: change.workspaceId,
3307
- fromStatusId: change.fromValue,
3308
- toStatusId: change.toValue,
3309
- actorKind: change.actorKind,
3310
- actorId: change.actorId,
3311
- at: change.createdAt
3312
- }];
3313
- }
3314
- //#endregion
3315
3780
  //#region src/ops/context-refs.ts
3316
3781
  const ContextRefsJsonSchema = zod.z.string().transform((raw) => JSON.parse(raw)).pipe(zod.z.array(zod.z.string()));
3317
3782
  /** Add a context ref at the end of the card's list, recording the change in its history. */
@@ -3442,33 +3907,6 @@ async function migrateCards(store, input) {
3442
3907
  };
3443
3908
  }
3444
3909
  //#endregion
3445
- //#region src/domain/card-display-title.ts
3446
- /**
3447
- * What a card is called wherever a person or an agent reads it by name.
3448
- *
3449
- * A card written as a description alone is titled with its own key (every
3450
- * writer does that). Named by its key, such a card says nothing — so its name
3451
- * is the first line of its description instead. A browser bundle that does not
3452
- * load this package keeps the same rule on its own, tested with the same
3453
- * examples.
3454
- */
3455
- /** How long a description line may run before it is cut with an ellipsis. */
3456
- const DESCRIPTION_LINE_LIMIT = 120;
3457
- function cardDisplayTitle(card) {
3458
- if (card.title !== card.id) return card.title;
3459
- return descriptionFirstLine(card.description) ?? card.title;
3460
- }
3461
- function descriptionFirstLine(description) {
3462
- for (const rawLine of (description ?? "").split("\n")) {
3463
- const line = stripMarkdown(rawLine);
3464
- if (line) return line.length > DESCRIPTION_LINE_LIMIT ? `${line.slice(0, 119).trimEnd()}…` : line;
3465
- }
3466
- return null;
3467
- }
3468
- function stripMarkdown(line) {
3469
- 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();
3470
- }
3471
- //#endregion
3472
3910
  //#region src/ops/agent-rules.ts
3473
3911
  /**
3474
3912
  * The sentences that tell an agent how to work this board, written once.
@@ -4085,7 +4523,9 @@ function createBoardOps(store) {
4085
4523
  listReady: async (input) => await listReady(store, input),
4086
4524
  queryReady: async (input) => await queryReady(store, input),
4087
4525
  listColumnEntries: async (input) => await listColumnEntries(store, input),
4088
- buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId)
4526
+ buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId),
4527
+ readBoardMetricsHistory: async (workspaceIds) => await readBoardMetricsHistory(store, workspaceIds),
4528
+ boardMetrics: async (input, now) => await boardMetrics(store, input, now)
4089
4529
  };
4090
4530
  }
4091
4531
  //#endregion
@@ -5145,6 +5585,17 @@ function parseCount(value) {
5145
5585
  return count;
5146
5586
  }
5147
5587
  /**
5588
+ * A date as the command line takes it: `YYYY-MM-DD`, read as a UTC day, or a
5589
+ * unix-seconds number taken as it is. `end` turns a day into its last second,
5590
+ * so a range or a sprint ending on a day runs through that day.
5591
+ */
5592
+ function parseCalendarDay(value, end) {
5593
+ if (/^\d+$/.test(value)) return Number(value);
5594
+ const day = /^\d{4}-\d{2}-\d{2}$/.test(value) ? Date.parse(`${value}T00:00:00Z`) : NaN;
5595
+ 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}".`);
5596
+ return day / 1e3 + (end ? 86399 : 0);
5597
+ }
5598
+ /**
5148
5599
  * The card a caller named, in the workspace the command is about.
5149
5600
  *
5150
5601
  * People and agents refer to a card by whatever they have in front of them: the
@@ -6559,6 +7010,7 @@ const BOARD_ERROR_TEXTS = {
6559
7010
  text: `A column accepts cards only from other columns of its own board, and ${list(details.allowedFrom)} is not one.`,
6560
7011
  next: "kanbo columns list"
6561
7012
  },
7013
+ 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." },
6562
7014
  board_nothing_ready: () => ({
6563
7015
  text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
6564
7016
  next: "kanbo ready"
@@ -7234,7 +7686,7 @@ function readWipLimit(value) {
7234
7686
  * `package.version`. A build carries it as a literal; running from source
7235
7687
  * (tests, `tsx`) reads the package's own `package.json` instead.
7236
7688
  */
7237
- const KANBO_PACKAGE_VERSION = "0.5.0";
7689
+ const KANBO_PACKAGE_VERSION = "0.6.0";
7238
7690
  //#endregion
7239
7691
  //#region src/cli/setup/paths.ts
7240
7692
  /**
@@ -8717,7 +9169,7 @@ function startUpdateCheck(deps = {}) {
8717
9169
  setTimeout: globalThis.setTimeout,
8718
9170
  clearTimeout: globalThis.clearTimeout
8719
9171
  };
8720
- const current = deps.currentVersion ?? "0.5.0";
9172
+ const current = deps.currentVersion ?? "0.6.0";
8721
9173
  const controller = new AbortController();
8722
9174
  let found = null;
8723
9175
  const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
@@ -12983,6 +13435,10 @@ const BOARD_ERROR_RESPONSES = {
12983
13435
  status: 400,
12984
13436
  message: "A column accepts cards from other columns of its own board, and holds at least one card"
12985
13437
  },
13438
+ board_metrics_range_invalid: {
13439
+ status: 400,
13440
+ message: "The range starts after it ends"
13441
+ },
12986
13442
  board_nothing_ready: {
12987
13443
  status: 409,
12988
13444
  message: "No card in To Do is ready to take"
@@ -14795,17 +15251,6 @@ function parsePort(value) {
14795
15251
  //#endregion
14796
15252
  //#region src/cli/commands/sprint.ts
14797
15253
  /**
14798
- * A date as the command line takes it: `YYYY-MM-DD`, read as a UTC day, or a
14799
- * unix-seconds number taken as it is. `end` turns a day into its last second,
14800
- * so a sprint due on a day is still current throughout that day.
14801
- */
14802
- function parseDate(value, end) {
14803
- if (/^\d+$/.test(value)) return Number(value);
14804
- const day = /^\d{4}-\d{2}-\d{2}$/.test(value) ? Date.parse(`${value}T00:00:00Z`) : NaN;
14805
- if (Number.isNaN(day)) throw new CliError(1, `Expected a date as YYYY-MM-DD or unix seconds, got "${value}".`);
14806
- return day / 1e3 + (end ? 86399 : 0);
14807
- }
14808
- /**
14809
15254
  * `kanbo sprint …` — milestones read as sprints (ruling 5x-3).
14810
15255
  *
14811
15256
  * Listing and creating are anybody's. Closing one is a person's decision
@@ -14825,7 +15270,7 @@ function registerSprintCommands(program) {
14825
15270
  };
14826
15271
  });
14827
15272
  });
14828
- 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) => {
15273
+ 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) => {
14829
15274
  await runBoardCommand(options, "write", async (session) => {
14830
15275
  const created = await session.ops.createMilestone({
14831
15276
  workspaceId: session.workspace.id,
@@ -14868,6 +15313,113 @@ function registerSprintCommands(program) {
14868
15313
  });
14869
15314
  }
14870
15315
  //#endregion
15316
+ //#region src/cli/metrics-view.ts
15317
+ /** Every field `kanbo stats --json` may name, in the order the value carries them. */
15318
+ const BOARD_METRICS_FIELDS = [
15319
+ "range",
15320
+ "now",
15321
+ "milestoneId",
15322
+ "columns",
15323
+ "cycleTime",
15324
+ "leadTime",
15325
+ "throughput",
15326
+ "wip",
15327
+ "aging",
15328
+ "timeInColumn",
15329
+ "cumulativeFlow",
15330
+ "finished",
15331
+ "returns",
15332
+ "attempts",
15333
+ "canceled",
15334
+ "agents",
15335
+ "people"
15336
+ ];
15337
+ const LABEL_WIDTH = 16;
15338
+ /** 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. */
15339
+ function formatDuration(seconds) {
15340
+ if (seconds === null) return "—";
15341
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
15342
+ if (seconds < 86400) return `${Number((seconds / 3600).toFixed(1))}h`;
15343
+ return `${Number((seconds / 86400).toFixed(1))}d`;
15344
+ }
15345
+ function percent(share) {
15346
+ return share === null ? "—" : `${Math.round(share * 100)}%`;
15347
+ }
15348
+ function decimal(value) {
15349
+ return value === null ? "—" : String(Number(value.toFixed(1)));
15350
+ }
15351
+ function count(n, noun) {
15352
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
15353
+ }
15354
+ function day(seconds) {
15355
+ return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
15356
+ }
15357
+ /** A labelled block: the first item beside the label, the rest under it; a dash when there is nothing. */
15358
+ function block(label, items) {
15359
+ const [first = "—", ...rest] = items;
15360
+ return [`${label.padEnd(LABEL_WIDTH)}${first}`, ...rest.map((item) => `${" ".repeat(LABEL_WIDTH)}${item}`)];
15361
+ }
15362
+ /** Items joined on one line, or none at all. */
15363
+ function oneLine(items) {
15364
+ return items.length === 0 ? [] : [items.join(" · ")];
15365
+ }
15366
+ /**
15367
+ * The metrics as a person reads them in a terminal: the range, then one block
15368
+ * per figure. The cumulative flow is a series per day and has no one-screen
15369
+ * form, so the last line says how to get it.
15370
+ */
15371
+ function describeMetrics(metrics, sprint) {
15372
+ const column = (slug) => sanitizeTerminalText(metrics.columns.find((candidate) => candidate.slug === slug)?.name ?? slug);
15373
+ const by = metrics.returns.byActorKind;
15374
+ const lines = [`${day(metrics.range.from)} → ${day(metrics.range.to)}`];
15375
+ if (sprint) lines.push(`${sanitizeTerminalText(sprint.title)}: ${sprint.cards.open + sprint.cards.done} planned · ${sprint.cards.done} done`);
15376
+ if (metrics.range.from > metrics.range.to) return [...lines, "Nothing measured yet: the range starts after now."].join("\n");
15377
+ 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");
15378
+ return lines.join("\n");
15379
+ }
15380
+ function describeAgent(agent) {
15381
+ const { runs } = agent;
15382
+ 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`;
15383
+ }
15384
+ function describePerson(person) {
15385
+ return `${sanitizeTerminalText(person.id ?? "unnamed")}: ${person.created} created · ${person.approvals} approved · ${person.returns} returned · ${person.moves} moved`;
15386
+ }
15387
+ const WEEK_SECONDS = 604800;
15388
+ /** The Monday that makes the range `DEFAULT_STATS_WEEKS` ISO weeks long, ending with the week of `now`. */
15389
+ function defaultStatsFrom(now) {
15390
+ return isoWeekOf(now).start - 11 * WEEK_SECONDS;
15391
+ }
15392
+ /**
15393
+ * `kanbo stats` — how work flows on this board (spec 2026-11-05, Part 4).
15394
+ *
15395
+ * Reading only, so it is anybody's, an agent's shell included. A sprint sets
15396
+ * the range to its own dates and keeps its cards; `--from` and `--to` still
15397
+ * win over either end.
15398
+ */
15399
+ function registerStatsCommand(program) {
15400
+ 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) => {
15401
+ await runBoardCommand(options, "read", async (session) => {
15402
+ const now = currentUnixSeconds();
15403
+ const sprint = options.sprint === void 0 ? null : (await session.ops.listSprints(session.workspace.id, now)).find((candidate) => candidate.id === options.sprint) ?? null;
15404
+ if (options.sprint !== void 0 && sprint === null) throw new require_migrations_path.BoardError("issue_milestone_not_found", {
15405
+ milestoneId: options.sprint,
15406
+ workspaceId: session.workspace.id
15407
+ });
15408
+ const metrics = await session.ops.boardMetrics({
15409
+ workspaceIds: [session.workspace.id],
15410
+ from: options.from ?? sprint?.startDate ?? defaultStatsFrom(now),
15411
+ to: options.to ?? sprint?.dueDate ?? now,
15412
+ milestoneId: sprint?.id ?? null
15413
+ }, now);
15414
+ return {
15415
+ value: metrics,
15416
+ text: describeMetrics(metrics, sprint),
15417
+ fields: BOARD_METRICS_FIELDS
15418
+ };
15419
+ });
15420
+ });
15421
+ }
15422
+ //#endregion
14871
15423
  //#region src/cli/commands/uninstall.ts
14872
15424
  function registerUninstallCommand(program) {
14873
15425
  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) => {
@@ -15048,6 +15600,7 @@ function registerKanboCommands(program, context = {}) {
15048
15600
  registerReadyCommand(program);
15049
15601
  registerPrimeCommand(program);
15050
15602
  registerSprintCommands(program);
15603
+ registerStatsCommand(program);
15051
15604
  registerServeCommand(program);
15052
15605
  program.commandsGroup("For agents and integrations:");
15053
15606
  registerMcpCommand(program);
@@ -15428,6 +15981,18 @@ Object.defineProperty(exports, "activityToken", {
15428
15981
  return activityToken;
15429
15982
  }
15430
15983
  });
15984
+ Object.defineProperty(exports, "boardMetricsAgentKey", {
15985
+ enumerable: true,
15986
+ get: function() {
15987
+ return boardMetricsAgentKey;
15988
+ }
15989
+ });
15990
+ Object.defineProperty(exports, "boardMetricsRuns", {
15991
+ enumerable: true,
15992
+ get: function() {
15993
+ return boardMetricsRuns;
15994
+ }
15995
+ });
15431
15996
  Object.defineProperty(exports, "buildCapabilitiesManifest", {
15432
15997
  enumerable: true,
15433
15998
  get: function() {
@@ -15452,6 +16017,12 @@ Object.defineProperty(exports, "commentAuthorReference", {
15452
16017
  return commentAuthorReference;
15453
16018
  }
15454
16019
  });
16020
+ Object.defineProperty(exports, "computeBoardMetrics", {
16021
+ enumerable: true,
16022
+ get: function() {
16023
+ return computeBoardMetrics;
16024
+ }
16025
+ });
15455
16026
  Object.defineProperty(exports, "createBoardOps", {
15456
16027
  enumerable: true,
15457
16028
  get: function() {
@@ -15530,6 +16101,12 @@ Object.defineProperty(exports, "isValidCardKey", {
15530
16101
  return isValidCardKey;
15531
16102
  }
15532
16103
  });
16104
+ Object.defineProperty(exports, "isoWeekOf", {
16105
+ enumerable: true,
16106
+ get: function() {
16107
+ return isoWeekOf;
16108
+ }
16109
+ });
15533
16110
  Object.defineProperty(exports, "maskDatabaseUrl", {
15534
16111
  enumerable: true,
15535
16112
  get: function() {
@@ -15548,6 +16125,12 @@ Object.defineProperty(exports, "matchesKanboCardQuery", {
15548
16125
  return matchesKanboCardQuery;
15549
16126
  }
15550
16127
  });
16128
+ Object.defineProperty(exports, "nearestRank", {
16129
+ enumerable: true,
16130
+ get: function() {
16131
+ return nearestRank;
16132
+ }
16133
+ });
15551
16134
  Object.defineProperty(exports, "nextIssueIdentity", {
15552
16135
  enumerable: true,
15553
16136
  get: function() {
@@ -15746,6 +16329,12 @@ Object.defineProperty(exports, "toIssueView", {
15746
16329
  return toIssueView;
15747
16330
  }
15748
16331
  });
16332
+ Object.defineProperty(exports, "utcDayLabel", {
16333
+ enumerable: true,
16334
+ get: function() {
16335
+ return utcDayLabel;
16336
+ }
16337
+ });
15749
16338
  Object.defineProperty(exports, "writeBinding", {
15750
16339
  enumerable: true,
15751
16340
  get: function() {