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.
@@ -1,6 +1,6 @@
1
1
  import { a as BoardError } from "./migrations-path-ClaYRNwk.mjs";
2
- import { c as getBoardMigrationsPath, i as BOARD_PERSON_ROLE, l as createPostgresBoardStore, n as AGENT_WAITING_REFUSAL, o as BOARD_ROLE_SQL, r as BOARD_AGENT_ROLE, s as assertBoardSchema, t as AGENT_APPROVAL_COMMENT_REFUSAL } from "./roles.sql-BDmmNUdx.mjs";
3
- import { a as openBoardDatabase, c as boardQueryLogger, d as foldCase, i as markBoardFileOwnedByKanbo, l as createSqliteBoardStore, n as migrateBoardFile, o as readBoardFileContents, r as assertBoardSchema$1 } from "./migrate-BUsaTJlj.mjs";
2
+ import { c as getBoardMigrationsPath, i as BOARD_PERSON_ROLE, l as createPostgresBoardStore, n as AGENT_WAITING_REFUSAL, o as BOARD_ROLE_SQL, r as BOARD_AGENT_ROLE, s as assertBoardSchema, t as AGENT_APPROVAL_COMMENT_REFUSAL } from "./roles.sql-9OkYvsNM.mjs";
3
+ import { a as openBoardDatabase, c as boardQueryLogger, d as foldCase, i as markBoardFileOwnedByKanbo, l as createSqliteBoardStore, n as migrateBoardFile, o as readBoardFileContents, r as assertBoardSchema$1 } from "./migrate-Bgj_aABY.mjs";
4
4
  import { Command, Option } from "commander";
5
5
  import { z } from "zod";
6
6
  import { homedir, hostname, userInfo } from "node:os";
@@ -585,18 +585,18 @@ function activityToken(token) {
585
585
  function currentUnixSeconds() {
586
586
  return Math.floor(Date.now() / 1e3);
587
587
  }
588
- const DAY_SECONDS = 86400;
588
+ const DAY_SECONDS$2 = 86400;
589
589
  /**
590
590
  * A board date is a calendar day (ruling 5x-6): a start is kept as the first
591
591
  * second of its UTC day, a due date as the last, so a sprint runs through its
592
592
  * due day wherever the person reading it is.
593
593
  */
594
594
  function utcDayStart(seconds) {
595
- return Math.floor(seconds / DAY_SECONDS) * DAY_SECONDS;
595
+ return Math.floor(seconds / DAY_SECONDS$2) * DAY_SECONDS$2;
596
596
  }
597
597
  /** The last second of the UTC day `seconds` falls on — see `utcDayStart`. */
598
598
  function utcDayEnd(seconds) {
599
- return utcDayStart(seconds) + DAY_SECONDS - 1;
599
+ return utcDayStart(seconds) + DAY_SECONDS$2 - 1;
600
600
  }
601
601
  /** The largest unix seconds a moment is read as: past it, the number is milliseconds (year 5138 onwards). */
602
602
  const MAX_UNIX_SECONDS = 1e11;
@@ -1012,62 +1012,31 @@ function previewComment(comment) {
1012
1012
  };
1013
1013
  }
1014
1014
  //#endregion
1015
- //#region src/domain/field-changes.ts
1015
+ //#region src/domain/card-display-title.ts
1016
1016
  /**
1017
- * The issue columns whose edits are written to the audit trail.
1017
+ * What a card is called wherever a person or an agent reads it by name.
1018
1018
  *
1019
- * `statusLine` is absent on purpose: `writeStatusLine` records every line it
1020
- * writes itself, and no patch ever carries the column. `waitingFor` is absent
1021
- * too — whose turn it is shows in the card's own line and in the
1022
- * `system.approved` / `system.returned` comments around it, so a row saying the
1023
- * same thing a third time would only crowd the history.
1024
- */
1025
- const TRACKED_FIELDS = [
1026
- "workspaceId",
1027
- "title",
1028
- "description",
1029
- "priority",
1030
- "labels",
1031
- "milestoneId",
1032
- "parentIssueId",
1033
- "statusId",
1034
- "assigneeKind",
1035
- "assigneeId",
1036
- "dueDate",
1037
- "delegateAgentId",
1038
- "delegateProviderTargetId",
1039
- "contextRefs",
1040
- "executionMode",
1041
- "order"
1042
- ];
1043
- /**
1044
- * The audit rows one patch produces: a row per tracked field whose stringified
1045
- * value actually changed. Nothing is written here — the caller inserts what
1046
- * comes back, so the diff stays a pure function of the row, the patch, the
1047
- * actor and the clock.
1019
+ * A card written as a description alone is titled with its own key (every
1020
+ * writer does that). Named by its key, such a card says nothing — so its name
1021
+ * is the first line of its description instead. A browser bundle that does not
1022
+ * load this package keeps the same rule on its own, tested with the same
1023
+ * examples.
1048
1024
  */
1049
- function diffFieldChanges(before, updates, actor, now) {
1050
- const rows = [];
1051
- for (const field of TRACKED_FIELDS) {
1052
- if (!(field in updates)) continue;
1053
- const rawBefore = before[field === "labels" ? "labels" : field];
1054
- const rawAfter = updates[field];
1055
- const fromValue = rawBefore == null ? null : String(rawBefore);
1056
- const toValue = rawAfter == null ? null : String(rawAfter);
1057
- if (fromValue === toValue) continue;
1058
- rows.push({
1059
- id: randomUUID(),
1060
- issueId: before.id,
1061
- field,
1062
- fromValue,
1063
- toValue,
1064
- actorKind: actor.kind,
1065
- actorId: actor.id ?? null,
1066
- sourceChatSessionId: actor.sourceChatSessionId ?? null,
1067
- createdAt: now
1068
- });
1025
+ /** How long a description line may run before it is cut with an ellipsis. */
1026
+ const DESCRIPTION_LINE_LIMIT = 120;
1027
+ function cardDisplayTitle(card) {
1028
+ if (card.title !== card.id) return card.title;
1029
+ return descriptionFirstLine(card.description) ?? card.title;
1030
+ }
1031
+ function descriptionFirstLine(description) {
1032
+ for (const rawLine of (description ?? "").split("\n")) {
1033
+ const line = stripMarkdown(rawLine);
1034
+ if (line) return line.length > DESCRIPTION_LINE_LIMIT ? `${line.slice(0, 119).trimEnd()}…` : line;
1069
1035
  }
1070
- return rows;
1036
+ return null;
1037
+ }
1038
+ function stripMarkdown(line) {
1039
+ 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();
1071
1040
  }
1072
1041
  //#endregion
1073
1042
  //#region src/domain/status-name.ts
@@ -1122,6 +1091,603 @@ function normalizeStatusName(value) {
1122
1091
  return value.trim().toLowerCase().replace(/[\s-]+/g, "_");
1123
1092
  }
1124
1093
  //#endregion
1094
+ //#region src/domain/flow-rules.ts
1095
+ /** The two flow rules, as an unmet entry names them. */
1096
+ const FLOW_RULES = ["allowed_from", "wip_limit"];
1097
+ /** The column the standard flow guards, and the columns it lets cards in from, by slug. */
1098
+ const STANDARD_FLOW_TARGET = "done";
1099
+ const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
1100
+ /** A column's flow rules, out of what the row stores. */
1101
+ function readFlowRules(column) {
1102
+ return {
1103
+ allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
1104
+ wipLimit: column.wipLimit
1105
+ };
1106
+ }
1107
+ /** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
1108
+ function serializeAllowedFrom(allowedFrom) {
1109
+ return allowedFrom === null ? null : JSON.stringify(allowedFrom);
1110
+ }
1111
+ /**
1112
+ * The flow rules a card entering `column` does not meet, where it may come from
1113
+ * first. Pure: what the board holds arrives in `facts`. A card being created
1114
+ * comes from no column, so a column that names where cards come from does not
1115
+ * take it.
1116
+ */
1117
+ function evaluateFlowRules(card, column, rules, facts) {
1118
+ const unmet = [];
1119
+ if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
1120
+ rule: "allowed_from",
1121
+ reason: "not_allowed_from",
1122
+ detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
1123
+ });
1124
+ if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
1125
+ rule: "wip_limit",
1126
+ reason: "column_full",
1127
+ detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
1128
+ });
1129
+ return unmet;
1130
+ }
1131
+ function describeAllowedFrom(name, allowedFrom, columnNames) {
1132
+ const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
1133
+ 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`;
1134
+ }
1135
+ /** `A`, `A and B`, `A, B and C`. */
1136
+ function joinWords(words) {
1137
+ return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
1138
+ }
1139
+ /**
1140
+ * The standard flow on these columns: Done, and the columns it takes cards from
1141
+ * — In Review, and QA when there is one — in board order. `null` when the board
1142
+ * has no Done, or neither of the two.
1143
+ */
1144
+ function standardFlowOf(columns) {
1145
+ const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
1146
+ const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
1147
+ return target && sources.length > 0 ? {
1148
+ target,
1149
+ sources
1150
+ } : null;
1151
+ }
1152
+ //#endregion
1153
+ //#region src/domain/iso-week.ts
1154
+ const DAY_SECONDS$1 = 86400;
1155
+ /** The ISO week a moment falls in, read in UTC like every board date (ruling 5x-6). */
1156
+ function isoWeekOf(seconds) {
1157
+ const day = Math.floor(seconds / DAY_SECONDS$1);
1158
+ const monday = day - (day + 3) % 7;
1159
+ const thursday = monday + 3;
1160
+ const year = (/* @__PURE__ */ new Date(thursday * DAY_SECONDS$1 * 1e3)).getUTCFullYear();
1161
+ const januaryFirst = Date.UTC(year, 0, 1) / 1e3 / DAY_SECONDS$1;
1162
+ const week = Math.floor((thursday - januaryFirst) / 7) + 1;
1163
+ return {
1164
+ week: `${year}-W${String(week).padStart(2, "0")}`,
1165
+ start: monday * DAY_SECONDS$1
1166
+ };
1167
+ }
1168
+ /** The UTC day a moment falls on, as `2026-01-05`. */
1169
+ function utcDayLabel(seconds) {
1170
+ return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
1171
+ }
1172
+ //#endregion
1173
+ //#region src/ops/card-timeline.ts
1174
+ /**
1175
+ * A card's columns over time, oldest first, from its creation on: the column
1176
+ * it was created in, then one stretch per move. A card created before 0.5.0
1177
+ * carries no entry for its creation; one created since carries `null → column`
1178
+ * at its creation, which leaves a stretch of no length in no column.
1179
+ *
1180
+ * The feed lists moves by time and then by change id, and a change id is
1181
+ * random, so two moves of one card in the same second can come back in either
1182
+ * order. Within one second the moves are put back in the order they chain —
1183
+ * each leaves the column the one before it entered — which is the order they
1184
+ * were made in (ruling R012-5). Moves that do not chain keep the feed's order.
1185
+ */
1186
+ function buildCardTimeline(card, entries) {
1187
+ const ordered = chainSameSecond(entries.toSorted((left, right) => left.at - right.at || (left.changeId < right.changeId ? -1 : left.changeId > right.changeId ? 1 : 0)));
1188
+ const stretches = [];
1189
+ const first = ordered[0];
1190
+ let current = {
1191
+ statusId: first ? first.fromStatusId : card.statusId,
1192
+ from: card.createdAt,
1193
+ to: null
1194
+ };
1195
+ for (const entry of ordered) {
1196
+ stretches.push({
1197
+ ...current,
1198
+ to: entry.at
1199
+ });
1200
+ current = {
1201
+ statusId: entry.toStatusId,
1202
+ from: entry.at,
1203
+ to: null
1204
+ };
1205
+ }
1206
+ stretches.push(current);
1207
+ return stretches;
1208
+ }
1209
+ /** The column the card was in at `at` — a move at `at` has already happened — or `undefined` before the card existed. */
1210
+ function statusAt(stretches, at) {
1211
+ return stretches.find((stretch) => stretch.from <= at && (stretch.to === null || at < stretch.to))?.statusId;
1212
+ }
1213
+ function chainSameSecond(entries) {
1214
+ const ordered = [];
1215
+ let start = 0;
1216
+ while (start < entries.length) {
1217
+ let end = start;
1218
+ while (end < entries.length && entries[end].at === entries[start].at) end++;
1219
+ const group = entries.slice(start, end);
1220
+ const previous = ordered.at(-1);
1221
+ let current = previous ? previous.toStatusId : headOf(group)?.fromStatusId;
1222
+ while (group.length > 0) {
1223
+ let next = group.findIndex((entry) => entry.fromStatusId === current);
1224
+ if (next < 0) next = Math.max(group.findIndex((entry) => entry === headOf(group)), 0);
1225
+ const [picked] = group.splice(next, 1);
1226
+ ordered.push(picked);
1227
+ current = picked.toStatusId;
1228
+ }
1229
+ start = end;
1230
+ }
1231
+ return ordered;
1232
+ }
1233
+ /** The move nothing else in the group feeds into. */
1234
+ function headOf(group) {
1235
+ return group.find((entry) => !group.some((other) => other !== entry && other.toStatusId === entry.fromStatusId));
1236
+ }
1237
+ //#endregion
1238
+ //#region src/ops/board-metrics.ts
1239
+ const DAY_SECONDS = 86400;
1240
+ const WEEK_SECONDS$1 = 7 * DAY_SECONDS;
1241
+ /**
1242
+ * The value at `percent` of `values` by nearest rank (ruling R012-2): the
1243
+ * ⌈percent·n/100⌉-th in ascending order. Always a value that occurred; `null`
1244
+ * for none.
1245
+ */
1246
+ function nearestRank(values, percent) {
1247
+ if (values.length === 0) return null;
1248
+ const sorted = values.toSorted((left, right) => left - right);
1249
+ return sorted[Math.max(Math.ceil(percent * sorted.length / 100), 1) - 1];
1250
+ }
1251
+ /** 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. */
1252
+ function boardMetricsAgentKey(run) {
1253
+ return run.agentId ?? run.agentName;
1254
+ }
1255
+ function scopeOf(history, input) {
1256
+ const workspaceIds = new Set(input.workspaceIds);
1257
+ const milestoneId = input.milestoneId ?? null;
1258
+ const statusById = new Map(history.statuses.filter((status) => workspaceIds.has(status.workspaceId)).map((status) => [status.id, status]));
1259
+ const cards = history.cards.filter((card) => workspaceIds.has(card.workspaceId)).filter((card) => milestoneId === null || card.milestoneId === milestoneId);
1260
+ const isCanceled = (card) => card.statusId !== null && statusById.get(card.statusId)?.category === "canceled";
1261
+ return {
1262
+ statusById,
1263
+ counted: cards.filter((card) => !isCanceled(card)),
1264
+ canceled: cards.filter(isCanceled)
1265
+ };
1266
+ }
1267
+ /**
1268
+ * The runs of the counted cards, and apart those of the cards in a canceled
1269
+ * column (R012-6): what a host prices to put a cost beside the metrics, and the
1270
+ * spend on canceled work on a line of its own.
1271
+ */
1272
+ function boardMetricsRuns(history, input) {
1273
+ const { counted, canceled } = scopeOf(history, input);
1274
+ const countedIds = new Set(counted.map((card) => card.id));
1275
+ const canceledIds = new Set(canceled.map((card) => card.id));
1276
+ return {
1277
+ counted: history.runs.filter((run) => countedIds.has(run.issueId)),
1278
+ canceled: history.runs.filter((run) => canceledIds.has(run.issueId))
1279
+ };
1280
+ }
1281
+ /** 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. */
1282
+ function returnerOf(entry) {
1283
+ if (entry.actorKind === "user") return "user";
1284
+ return entry.actorKind === "system" ? "external" : "agent";
1285
+ }
1286
+ /**
1287
+ * The metrics of `input` over rows already read. Pure: the same rows give the
1288
+ * same answer in any order, so a host may put several boards' rows together
1289
+ * and compute once — percentiles of parts do not add up to the whole's.
1290
+ */
1291
+ function computeBoardMetrics(history, input, now) {
1292
+ if (input.from > input.to) throw new BoardError("board_metrics_range_invalid", {
1293
+ from: input.from,
1294
+ to: input.to
1295
+ });
1296
+ const milestoneId = input.milestoneId ?? null;
1297
+ if (milestoneId !== null && !history.milestones.some((milestone) => milestone.id === milestoneId && input.workspaceIds.includes(milestone.workspaceId))) throw new BoardError("issue_milestone_not_found", { milestoneId });
1298
+ const from = input.from;
1299
+ const to = Math.min(input.to, now);
1300
+ const inRange = (at) => from <= at && at <= to;
1301
+ const { statusById, counted, canceled } = scopeOf(history, input);
1302
+ const columns = groupColumns(input.workspaceIds, [...statusById.values()]);
1303
+ const slugOf = (statusId) => {
1304
+ const status = statusId === null ? void 0 : statusById.get(statusId);
1305
+ return status ? normalizeStatusName(status.name) : null;
1306
+ };
1307
+ const categoryOf = (statusId) => statusId === null ? null : statusById.get(statusId)?.category ?? null;
1308
+ const isReturn = (entry) => {
1309
+ const source = entry.fromStatusId === null ? void 0 : statusById.get(entry.fromStatusId);
1310
+ const target = statusById.get(entry.toStatusId);
1311
+ return source !== void 0 && target !== void 0 && source.workspaceId === target.workspaceId && target.order < source.order && (target.category === "unstarted" || target.category === "started");
1312
+ };
1313
+ const entriesByCard = groupBy(history.entries, (entry) => entry.issueId);
1314
+ const timelineOf = (card) => buildCardTimeline(card, entriesByCard.get(card.id) ?? []);
1315
+ const countedIds = new Set(counted.map((card) => card.id));
1316
+ const facts = counted.map((card) => {
1317
+ const stretches = timelineOf(card);
1318
+ return {
1319
+ card,
1320
+ stretches,
1321
+ startedAt: stretches.find((stretch) => categoryOf(stretch.statusId) === "started")?.from ?? null
1322
+ };
1323
+ });
1324
+ const finished = facts.filter((fact) => categoryOf(fact.card.statusId) === "completed").map((fact) => ({
1325
+ ...fact,
1326
+ finishedAt: fact.stretches.at(-1).from
1327
+ })).filter((fact) => inRange(fact.finishedAt)).toSorted((left, right) => left.finishedAt - right.finishedAt || left.card.id.localeCompare(right.card.id));
1328
+ const runsByCard = groupBy(history.runs.filter((run) => countedIds.has(run.issueId)), (run) => run.issueId);
1329
+ const returnsByCard = groupBy(history.entries.filter((entry) => countedIds.has(entry.issueId) && isReturn(entry)), (entry) => entry.issueId);
1330
+ const finishedCards = finished.map((fact) => ({
1331
+ ...cardOf(fact.card),
1332
+ startedAt: fact.startedAt,
1333
+ finishedAt: fact.finishedAt,
1334
+ cycleSeconds: fact.startedAt === null ? null : fact.finishedAt - fact.startedAt,
1335
+ leadSeconds: fact.finishedAt - fact.card.createdAt,
1336
+ returns: returnsByCard.get(fact.card.id)?.length ?? 0,
1337
+ runs: runsByCard.get(fact.card.id)?.length ?? 0
1338
+ }));
1339
+ const cycleTime = durations(finishedCards.flatMap((card) => card.cycleSeconds === null ? [] : [card.cycleSeconds]));
1340
+ const leadTime = durations(finishedCards.map((card) => card.leadSeconds));
1341
+ const throughput = [];
1342
+ for (let start = isoWeekOf(from).start; from <= to && start <= to; start += WEEK_SECONDS$1) throughput.push({
1343
+ week: isoWeekOf(start).week,
1344
+ start,
1345
+ finished: finishedCards.filter((card) => card.finishedAt >= start && card.finishedAt < start + WEEK_SECONDS$1).length
1346
+ });
1347
+ const inProgress = facts.filter((fact) => categoryOf(fact.card.statusId) === "started");
1348
+ const wip = columns.filter((column) => column.category === "started").map((column) => {
1349
+ const limits = [...statusById.values()].filter((status) => normalizeStatusName(status.name) === column.slug).map((status) => readFlowRules(status).wipLimit);
1350
+ return {
1351
+ slug: column.slug,
1352
+ cards: inProgress.filter((fact) => slugOf(fact.card.statusId) === column.slug).length,
1353
+ limit: limits.every((limit) => limit !== null) ? limits.reduce((sum, limit) => sum + limit, 0) : null
1354
+ };
1355
+ });
1356
+ const aging = inProgress.map((fact) => {
1357
+ const current = fact.stretches.at(-1);
1358
+ const startedAt = fact.startedAt ?? current.from;
1359
+ return {
1360
+ ...cardOf(fact.card),
1361
+ slug: slugOf(fact.card.statusId),
1362
+ startedAt,
1363
+ ageSeconds: now - startedAt,
1364
+ inColumnSeconds: now - current.from,
1365
+ overCycleP85: cycleTime.p85 !== null && now - startedAt > cycleTime.p85
1366
+ };
1367
+ }).toSorted((left, right) => right.ageSeconds - left.ageSeconds || left.issueId.localeCompare(right.issueId));
1368
+ const columnSeconds = new Map(columns.map((column) => [column.slug, 0]));
1369
+ for (const fact of finished) for (const stretch of fact.stretches) {
1370
+ const slug = slugOf(stretch.statusId);
1371
+ const end = Math.min(stretch.to ?? fact.finishedAt, fact.finishedAt);
1372
+ if (slug !== null && end > stretch.from) columnSeconds.set(slug, (columnSeconds.get(slug) ?? 0) + end - stretch.from);
1373
+ }
1374
+ const cumulativeFlow = [];
1375
+ for (let dayStart = utcDayStart(from); from <= to && dayStart <= to; dayStart += DAY_SECONDS) {
1376
+ const at = Math.min(dayStart + DAY_SECONDS - 1, to);
1377
+ const cards = Object.fromEntries(columns.map((column) => [column.slug, 0]));
1378
+ for (const fact of facts) {
1379
+ const statusId = statusAt(fact.stretches, at);
1380
+ const slug = statusId === void 0 ? null : slugOf(statusId);
1381
+ if (slug !== null) cards[slug] = (cards[slug] ?? 0) + 1;
1382
+ }
1383
+ cumulativeFlow.push({
1384
+ day: utcDayLabel(dayStart),
1385
+ at,
1386
+ cards
1387
+ });
1388
+ }
1389
+ const finishedReturns = finished.flatMap((fact) => returnsByCard.get(fact.card.id) ?? []);
1390
+ const byActorKind = {
1391
+ user: 0,
1392
+ agent: 0,
1393
+ external: 0
1394
+ };
1395
+ for (const entry of finishedReturns) byActorKind[returnerOf(entry)]++;
1396
+ const runTotal = finishedCards.reduce((sum, card) => sum + card.runs, 0);
1397
+ const share = (part, whole) => whole === 0 ? null : part / whole;
1398
+ return {
1399
+ range: {
1400
+ from,
1401
+ to
1402
+ },
1403
+ now,
1404
+ milestoneId,
1405
+ columns,
1406
+ cycleTime,
1407
+ leadTime,
1408
+ throughput,
1409
+ wip,
1410
+ aging,
1411
+ timeInColumn: columns.map((column) => ({
1412
+ slug: column.slug,
1413
+ seconds: columnSeconds.get(column.slug) ?? 0
1414
+ })),
1415
+ cumulativeFlow,
1416
+ finished: finishedCards,
1417
+ returns: {
1418
+ total: finishedReturns.length,
1419
+ perFinishedCard: share(finishedReturns.length, finishedCards.length),
1420
+ firstPassRate: share(finishedCards.filter((card) => card.returns === 0).length, finishedCards.length),
1421
+ byActorKind
1422
+ },
1423
+ attempts: {
1424
+ runs: runTotal,
1425
+ perFinishedCard: share(runTotal, finishedCards.length)
1426
+ },
1427
+ canceled: canceled.filter((card) => inRange(timelineOf(card).at(-1).from)).length,
1428
+ agents: agentRows(history.runs.filter((run) => countedIds.has(run.issueId)), finishedCards, inRange),
1429
+ people: personRows(counted, history, countedIds, inRange, isReturn)
1430
+ };
1431
+ }
1432
+ function agentRows(runs, finished, inRange) {
1433
+ const finishedIds = new Set(finished.map((card) => card.issueId));
1434
+ const returned = new Set(finished.filter((card) => card.returns > 0).map((card) => card.issueId));
1435
+ const rows = [];
1436
+ for (const [key, own] of groupBy(runs, boardMetricsAgentKey)) {
1437
+ const ranged = own.filter((run) => inRange(run.startedAt));
1438
+ const worked = new Set(own.map((run) => run.issueId).filter((issueId) => finishedIds.has(issueId)));
1439
+ if (ranged.length === 0 && worked.size === 0) continue;
1440
+ const newest = own.toSorted((left, right) => right.startedAt - left.startedAt || right.id.localeCompare(left.id))[0];
1441
+ const ended = ranged.flatMap((run) => run.endedAt === null ? [] : [run.endedAt - run.startedAt]);
1442
+ rows.push({
1443
+ key,
1444
+ agentId: newest.agentId,
1445
+ agentName: newest.agentName,
1446
+ runs: {
1447
+ total: ranged.length,
1448
+ finished: ranged.filter((run) => run.state === "finished").length,
1449
+ failed: ranged.filter((run) => run.state === "failed").length,
1450
+ stopped: ranged.filter((run) => run.state === "stopped").length,
1451
+ running: ranged.filter((run) => run.state === "running").length
1452
+ },
1453
+ medianRunSeconds: nearestRank(ended, 50),
1454
+ cardsFinished: worked.size,
1455
+ returnRate: worked.size === 0 ? null : [...worked].filter((issueId) => returned.has(issueId)).length / worked.size
1456
+ });
1457
+ }
1458
+ return rows.toSorted((left, right) => right.runs.total - left.runs.total || left.key.localeCompare(right.key));
1459
+ }
1460
+ function personRows(counted, history, countedIds, inRange, isReturn) {
1461
+ const rows = /* @__PURE__ */ new Map();
1462
+ const row = (id) => {
1463
+ let found = rows.get(id);
1464
+ if (!found) {
1465
+ found = {
1466
+ id,
1467
+ created: 0,
1468
+ approvals: 0,
1469
+ returns: 0,
1470
+ moves: 0
1471
+ };
1472
+ rows.set(id, found);
1473
+ }
1474
+ return found;
1475
+ };
1476
+ for (const card of counted) if (card.createdByKind === "user" && inRange(card.createdAt)) row(card.createdById).created++;
1477
+ for (const comment of history.approvals) if (comment.authorKind === "system.approved" && countedIds.has(comment.issueId) && inRange(comment.createdAt)) row(comment.authorId).approvals++;
1478
+ for (const entry of history.entries) if (entry.actorKind === "user" && entry.fromStatusId !== null && countedIds.has(entry.issueId) && inRange(entry.at)) {
1479
+ const person = row(entry.actorId);
1480
+ person.moves++;
1481
+ if (isReturn(entry)) person.returns++;
1482
+ }
1483
+ const activity = (person) => person.created + person.approvals + person.returns + person.moves;
1484
+ return [...rows.values()].toSorted((left, right) => activity(right) - activity(left) || (left.id ?? "￿").localeCompare(right.id ?? "￿"));
1485
+ }
1486
+ /**
1487
+ * The workspaces' columns as one board shows them, one per slug (ruling
1488
+ * R012-8): the first workspace's columns set the order, and a column only a
1489
+ * later one has goes right after the column before it there. Name and category
1490
+ * are the first workspace's that has it.
1491
+ */
1492
+ function groupColumns(workspaceIds, statuses) {
1493
+ const columns = [];
1494
+ for (const workspaceId of new Set(workspaceIds)) {
1495
+ let previous = -1;
1496
+ const own = statuses.filter((status) => status.workspaceId === workspaceId).toSorted((left, right) => left.order - right.order || left.id.localeCompare(right.id));
1497
+ for (const status of own) {
1498
+ const slug = normalizeStatusName(status.name);
1499
+ let index = columns.findIndex((column) => column.slug === slug);
1500
+ if (index === -1) {
1501
+ index = previous + 1;
1502
+ columns.splice(index, 0, {
1503
+ slug,
1504
+ name: status.name,
1505
+ category: status.category
1506
+ });
1507
+ }
1508
+ previous = index;
1509
+ }
1510
+ }
1511
+ return columns;
1512
+ }
1513
+ function durations(values) {
1514
+ return {
1515
+ cards: values.length,
1516
+ p50: nearestRank(values, 50),
1517
+ p85: nearestRank(values, 85)
1518
+ };
1519
+ }
1520
+ function cardOf(card) {
1521
+ return {
1522
+ issueId: card.id,
1523
+ workspaceId: card.workspaceId,
1524
+ title: cardDisplayTitle(card)
1525
+ };
1526
+ }
1527
+ function groupBy(items, keyOf) {
1528
+ const groups = /* @__PURE__ */ new Map();
1529
+ for (const item of items) {
1530
+ const key = keyOf(item);
1531
+ const group = groups.get(key);
1532
+ if (group) group.push(item);
1533
+ else groups.set(key, [item]);
1534
+ }
1535
+ return groups;
1536
+ }
1537
+ //#endregion
1538
+ //#region src/ops/column-entries.ts
1539
+ /**
1540
+ * Every entry into a column on the named boards at or after `since`, in order
1541
+ * of `(at, changeId)` — the one signal an app dispatching work on column
1542
+ * changes needs. It sees every writer, an outside `kanbo` on the same database
1543
+ * included, because it reads the cards' own history.
1544
+ *
1545
+ * A page never ends inside a second: when `limit` cuts into one, the rest of
1546
+ * that second comes too, so a page may be longer than `limit`. A reader keeps
1547
+ * its cursor at the last `at` it read after a short page — a write in that
1548
+ * second may still commit — and drops the `changeId`s it has seen; after a page
1549
+ * of `limit` or more it moves on to the last `at` + 1, which a second holding
1550
+ * more entries than `limit` would otherwise never let it pass.
1551
+ */
1552
+ async function listColumnEntries(store, input) {
1553
+ const page = await store.fieldChanges.listByFieldSince("statusId", input);
1554
+ return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
1555
+ }
1556
+ /** The page with every change of its last second, not only those `limit` let in. */
1557
+ async function withWholeLastSecond(store, input, page) {
1558
+ const last = page.at(-1).createdAt;
1559
+ const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
1560
+ workspaceIds: input.workspaceIds,
1561
+ since: last,
1562
+ until: last
1563
+ });
1564
+ return [...page.filter((change) => change.createdAt < last), ...lastSecond];
1565
+ }
1566
+ /** A status change as an entry; a change that took the card out of every column enters none. */
1567
+ function toColumnEntry(change) {
1568
+ return change.toValue === null ? [] : [{
1569
+ changeId: change.id,
1570
+ issueId: change.issueId,
1571
+ workspaceId: change.workspaceId,
1572
+ fromStatusId: change.fromValue,
1573
+ toStatusId: change.toValue,
1574
+ actorKind: change.actorKind,
1575
+ actorId: change.actorId,
1576
+ at: change.createdAt
1577
+ }];
1578
+ }
1579
+ //#endregion
1580
+ //#region src/ops/board-metrics-read.ts
1581
+ /** The comment kind the metrics read: a person's approvals. Returns are moves (R012-7). */
1582
+ const APPROVAL_KINDS = ["system.approved"];
1583
+ /** `listColumnEntries` from the board's first column change on (drift check, item 3). */
1584
+ const FROM_THE_FIRST_CHANGE = 0;
1585
+ /**
1586
+ * Every row the metrics of these workspaces are computed from: three
1587
+ * statements per workspace (columns, milestones, cards) plus three across all
1588
+ * of them (column changes through the `(field, created_at)` index, runs,
1589
+ * approvals); none per card.
1590
+ *
1591
+ * The rows are read and the metrics computed in TypeScript rather than in SQL
1592
+ * (ruling R012-1): a board is thousands of rows, not millions; percentiles, ISO
1593
+ * weeks and a day-by-day replay would otherwise be written once per dialect;
1594
+ * and a host whose workspaces live on several boards can put their rows
1595
+ * together and compute once.
1596
+ */
1597
+ async function readBoardMetricsHistory(store, workspaceIds) {
1598
+ const ids = [...new Set(workspaceIds)];
1599
+ if (ids.length === 0) return {
1600
+ statuses: [],
1601
+ milestones: [],
1602
+ cards: [],
1603
+ entries: [],
1604
+ runs: [],
1605
+ approvals: []
1606
+ };
1607
+ const perWorkspace = async (read) => (await Promise.all(ids.map(read))).flat();
1608
+ const [statuses, milestones, cards, entries, runs, approvals] = await Promise.all([
1609
+ perWorkspace(async (id) => await store.statuses.listByWorkspace(id)),
1610
+ perWorkspace(async (id) => await store.milestones.listByWorkspace(id)),
1611
+ perWorkspace(async (id) => await store.issues.listByWorkspace(id)),
1612
+ listColumnEntries(store, {
1613
+ workspaceIds: ids,
1614
+ since: FROM_THE_FIRST_CHANGE
1615
+ }),
1616
+ store.runs.listByWorkspaces(ids),
1617
+ store.comments.listByWorkspaces(ids, APPROVAL_KINDS)
1618
+ ]);
1619
+ return {
1620
+ statuses,
1621
+ milestones,
1622
+ cards,
1623
+ entries,
1624
+ runs,
1625
+ approvals
1626
+ };
1627
+ }
1628
+ /** How work flows on the board over `input`'s range (`computeBoardMetrics`), read from the store; without cost. */
1629
+ async function boardMetrics(store, input, now = currentUnixSeconds()) {
1630
+ return computeBoardMetrics(await readBoardMetricsHistory(store, input.workspaceIds), input, now);
1631
+ }
1632
+ //#endregion
1633
+ //#region src/domain/field-changes.ts
1634
+ /**
1635
+ * The issue columns whose edits are written to the audit trail.
1636
+ *
1637
+ * `statusLine` is absent on purpose: `writeStatusLine` records every line it
1638
+ * writes itself, and no patch ever carries the column. `waitingFor` is absent
1639
+ * too — whose turn it is shows in the card's own line and in the
1640
+ * `system.approved` / `system.returned` comments around it, so a row saying the
1641
+ * same thing a third time would only crowd the history.
1642
+ */
1643
+ const TRACKED_FIELDS = [
1644
+ "workspaceId",
1645
+ "title",
1646
+ "description",
1647
+ "priority",
1648
+ "labels",
1649
+ "milestoneId",
1650
+ "parentIssueId",
1651
+ "statusId",
1652
+ "assigneeKind",
1653
+ "assigneeId",
1654
+ "dueDate",
1655
+ "delegateAgentId",
1656
+ "delegateProviderTargetId",
1657
+ "contextRefs",
1658
+ "executionMode",
1659
+ "order"
1660
+ ];
1661
+ /**
1662
+ * The audit rows one patch produces: a row per tracked field whose stringified
1663
+ * value actually changed. Nothing is written here — the caller inserts what
1664
+ * comes back, so the diff stays a pure function of the row, the patch, the
1665
+ * actor and the clock.
1666
+ */
1667
+ function diffFieldChanges(before, updates, actor, now) {
1668
+ const rows = [];
1669
+ for (const field of TRACKED_FIELDS) {
1670
+ if (!(field in updates)) continue;
1671
+ const rawBefore = before[field === "labels" ? "labels" : field];
1672
+ const rawAfter = updates[field];
1673
+ const fromValue = rawBefore == null ? null : String(rawBefore);
1674
+ const toValue = rawAfter == null ? null : String(rawAfter);
1675
+ if (fromValue === toValue) continue;
1676
+ rows.push({
1677
+ id: randomUUID(),
1678
+ issueId: before.id,
1679
+ field,
1680
+ fromValue,
1681
+ toValue,
1682
+ actorKind: actor.kind,
1683
+ actorId: actor.id ?? null,
1684
+ sourceChatSessionId: actor.sourceChatSessionId ?? null,
1685
+ createdAt: now
1686
+ });
1687
+ }
1688
+ return rows;
1689
+ }
1690
+ //#endregion
1125
1691
  //#region src/ops/change-seq.ts
1126
1692
  /** The `kanban_meta` row every board write bumps. */
1127
1693
  const BOARD_CHANGE_KEY = "board";
@@ -1506,65 +2072,6 @@ function describeColumnRefusal(details) {
1506
2072
  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");
1507
2073
  }
1508
2074
  //#endregion
1509
- //#region src/domain/flow-rules.ts
1510
- /** The two flow rules, as an unmet entry names them. */
1511
- const FLOW_RULES = ["allowed_from", "wip_limit"];
1512
- /** The column the standard flow guards, and the columns it lets cards in from, by slug. */
1513
- const STANDARD_FLOW_TARGET = "done";
1514
- const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
1515
- /** A column's flow rules, out of what the row stores. */
1516
- function readFlowRules(column) {
1517
- return {
1518
- allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
1519
- wipLimit: column.wipLimit
1520
- };
1521
- }
1522
- /** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
1523
- function serializeAllowedFrom(allowedFrom) {
1524
- return allowedFrom === null ? null : JSON.stringify(allowedFrom);
1525
- }
1526
- /**
1527
- * The flow rules a card entering `column` does not meet, where it may come from
1528
- * first. Pure: what the board holds arrives in `facts`. A card being created
1529
- * comes from no column, so a column that names where cards come from does not
1530
- * take it.
1531
- */
1532
- function evaluateFlowRules(card, column, rules, facts) {
1533
- const unmet = [];
1534
- if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
1535
- rule: "allowed_from",
1536
- reason: "not_allowed_from",
1537
- detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
1538
- });
1539
- if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
1540
- rule: "wip_limit",
1541
- reason: "column_full",
1542
- detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
1543
- });
1544
- return unmet;
1545
- }
1546
- function describeAllowedFrom(name, allowedFrom, columnNames) {
1547
- const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
1548
- 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`;
1549
- }
1550
- /** `A`, `A and B`, `A, B and C`. */
1551
- function joinWords(words) {
1552
- return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
1553
- }
1554
- /**
1555
- * The standard flow on these columns: Done, and the columns it takes cards from
1556
- * — In Review, and QA when there is one — in board order. `null` when the board
1557
- * has no Done, or neither of the two.
1558
- */
1559
- function standardFlowOf(columns) {
1560
- const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
1561
- const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
1562
- return target && sources.length > 0 ? {
1563
- target,
1564
- sources
1565
- } : null;
1566
- }
1567
- //#endregion
1568
2075
  //#region src/ops/columns.ts
1569
2076
  /** The columns of one workspace, in board order. */
1570
2077
  async function listColumns(store, workspaceId) {
@@ -3245,48 +3752,6 @@ async function notReadyReason(store, card, toDo) {
3245
3752
  return toDo !== null && card.statusId === toDo.id ? null : "not_in_to_do";
3246
3753
  }
3247
3754
  //#endregion
3248
- //#region src/ops/column-entries.ts
3249
- /**
3250
- * Every entry into a column on the named boards at or after `since`, in order
3251
- * of `(at, changeId)` — the one signal an app dispatching work on column
3252
- * changes needs. It sees every writer, an outside `kanbo` on the same database
3253
- * included, because it reads the cards' own history.
3254
- *
3255
- * A page never ends inside a second: when `limit` cuts into one, the rest of
3256
- * that second comes too, so a page may be longer than `limit`. A reader keeps
3257
- * its cursor at the last `at` it read after a short page — a write in that
3258
- * second may still commit — and drops the `changeId`s it has seen; after a page
3259
- * of `limit` or more it moves on to the last `at` + 1, which a second holding
3260
- * more entries than `limit` would otherwise never let it pass.
3261
- */
3262
- async function listColumnEntries(store, input) {
3263
- const page = await store.fieldChanges.listByFieldSince("statusId", input);
3264
- return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
3265
- }
3266
- /** The page with every change of its last second, not only those `limit` let in. */
3267
- async function withWholeLastSecond(store, input, page) {
3268
- const last = page.at(-1).createdAt;
3269
- const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
3270
- workspaceIds: input.workspaceIds,
3271
- since: last,
3272
- until: last
3273
- });
3274
- return [...page.filter((change) => change.createdAt < last), ...lastSecond];
3275
- }
3276
- /** A status change as an entry; a change that took the card out of every column enters none. */
3277
- function toColumnEntry(change) {
3278
- return change.toValue === null ? [] : [{
3279
- changeId: change.id,
3280
- issueId: change.issueId,
3281
- workspaceId: change.workspaceId,
3282
- fromStatusId: change.fromValue,
3283
- toStatusId: change.toValue,
3284
- actorKind: change.actorKind,
3285
- actorId: change.actorId,
3286
- at: change.createdAt
3287
- }];
3288
- }
3289
- //#endregion
3290
3755
  //#region src/ops/context-refs.ts
3291
3756
  const ContextRefsJsonSchema = z.string().transform((raw) => JSON.parse(raw)).pipe(z.array(z.string()));
3292
3757
  /** Add a context ref at the end of the card's list, recording the change in its history. */
@@ -3417,33 +3882,6 @@ async function migrateCards(store, input) {
3417
3882
  };
3418
3883
  }
3419
3884
  //#endregion
3420
- //#region src/domain/card-display-title.ts
3421
- /**
3422
- * What a card is called wherever a person or an agent reads it by name.
3423
- *
3424
- * A card written as a description alone is titled with its own key (every
3425
- * writer does that). Named by its key, such a card says nothing — so its name
3426
- * is the first line of its description instead. A browser bundle that does not
3427
- * load this package keeps the same rule on its own, tested with the same
3428
- * examples.
3429
- */
3430
- /** How long a description line may run before it is cut with an ellipsis. */
3431
- const DESCRIPTION_LINE_LIMIT = 120;
3432
- function cardDisplayTitle(card) {
3433
- if (card.title !== card.id) return card.title;
3434
- return descriptionFirstLine(card.description) ?? card.title;
3435
- }
3436
- function descriptionFirstLine(description) {
3437
- for (const rawLine of (description ?? "").split("\n")) {
3438
- const line = stripMarkdown(rawLine);
3439
- if (line) return line.length > DESCRIPTION_LINE_LIMIT ? `${line.slice(0, 119).trimEnd()}…` : line;
3440
- }
3441
- return null;
3442
- }
3443
- function stripMarkdown(line) {
3444
- 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();
3445
- }
3446
- //#endregion
3447
3885
  //#region src/ops/agent-rules.ts
3448
3886
  /**
3449
3887
  * The sentences that tell an agent how to work this board, written once.
@@ -4060,7 +4498,9 @@ function createBoardOps(store) {
4060
4498
  listReady: async (input) => await listReady(store, input),
4061
4499
  queryReady: async (input) => await queryReady(store, input),
4062
4500
  listColumnEntries: async (input) => await listColumnEntries(store, input),
4063
- buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId)
4501
+ buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId),
4502
+ readBoardMetricsHistory: async (workspaceIds) => await readBoardMetricsHistory(store, workspaceIds),
4503
+ boardMetrics: async (input, now) => await boardMetrics(store, input, now)
4064
4504
  };
4065
4505
  }
4066
4506
  //#endregion
@@ -5120,6 +5560,17 @@ function parseCount(value) {
5120
5560
  return count;
5121
5561
  }
5122
5562
  /**
5563
+ * A date as the command line takes it: `YYYY-MM-DD`, read as a UTC day, or a
5564
+ * unix-seconds number taken as it is. `end` turns a day into its last second,
5565
+ * so a range or a sprint ending on a day runs through that day.
5566
+ */
5567
+ function parseCalendarDay(value, end) {
5568
+ if (/^\d+$/.test(value)) return Number(value);
5569
+ const day = /^\d{4}-\d{2}-\d{2}$/.test(value) ? Date.parse(`${value}T00:00:00Z`) : NaN;
5570
+ 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}".`);
5571
+ return day / 1e3 + (end ? 86399 : 0);
5572
+ }
5573
+ /**
5123
5574
  * The card a caller named, in the workspace the command is about.
5124
5575
  *
5125
5576
  * People and agents refer to a card by whatever they have in front of them: the
@@ -6534,6 +6985,7 @@ const BOARD_ERROR_TEXTS = {
6534
6985
  text: `A column accepts cards only from other columns of its own board, and ${list(details.allowedFrom)} is not one.`,
6535
6986
  next: "kanbo columns list"
6536
6987
  },
6988
+ 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." },
6537
6989
  board_nothing_ready: () => ({
6538
6990
  text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
6539
6991
  next: "kanbo ready"
@@ -7209,7 +7661,7 @@ dirname(fileURLToPath(import.meta.url));
7209
7661
  * `package.version`. A build carries it as a literal; running from source
7210
7662
  * (tests, `tsx`) reads the package's own `package.json` instead.
7211
7663
  */
7212
- const KANBO_PACKAGE_VERSION = "0.5.0";
7664
+ const KANBO_PACKAGE_VERSION = "0.6.0";
7213
7665
  //#endregion
7214
7666
  //#region src/cli/setup/paths.ts
7215
7667
  /**
@@ -8692,7 +9144,7 @@ function startUpdateCheck(deps = {}) {
8692
9144
  setTimeout: globalThis.setTimeout,
8693
9145
  clearTimeout: globalThis.clearTimeout
8694
9146
  };
8695
- const current = deps.currentVersion ?? "0.5.0";
9147
+ const current = deps.currentVersion ?? "0.6.0";
8696
9148
  const controller = new AbortController();
8697
9149
  let found = null;
8698
9150
  const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
@@ -12958,6 +13410,10 @@ const BOARD_ERROR_RESPONSES = {
12958
13410
  status: 400,
12959
13411
  message: "A column accepts cards from other columns of its own board, and holds at least one card"
12960
13412
  },
13413
+ board_metrics_range_invalid: {
13414
+ status: 400,
13415
+ message: "The range starts after it ends"
13416
+ },
12961
13417
  board_nothing_ready: {
12962
13418
  status: 409,
12963
13419
  message: "No card in To Do is ready to take"
@@ -14770,17 +15226,6 @@ function parsePort(value) {
14770
15226
  //#endregion
14771
15227
  //#region src/cli/commands/sprint.ts
14772
15228
  /**
14773
- * A date as the command line takes it: `YYYY-MM-DD`, read as a UTC day, or a
14774
- * unix-seconds number taken as it is. `end` turns a day into its last second,
14775
- * so a sprint due on a day is still current throughout that day.
14776
- */
14777
- function parseDate(value, end) {
14778
- if (/^\d+$/.test(value)) return Number(value);
14779
- const day = /^\d{4}-\d{2}-\d{2}$/.test(value) ? Date.parse(`${value}T00:00:00Z`) : NaN;
14780
- if (Number.isNaN(day)) throw new CliError(1, `Expected a date as YYYY-MM-DD or unix seconds, got "${value}".`);
14781
- return day / 1e3 + (end ? 86399 : 0);
14782
- }
14783
- /**
14784
15229
  * `kanbo sprint …` — milestones read as sprints (ruling 5x-3).
14785
15230
  *
14786
15231
  * Listing and creating are anybody's. Closing one is a person's decision
@@ -14800,7 +15245,7 @@ function registerSprintCommands(program) {
14800
15245
  };
14801
15246
  });
14802
15247
  });
14803
- 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) => {
15248
+ 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) => {
14804
15249
  await runBoardCommand(options, "write", async (session) => {
14805
15250
  const created = await session.ops.createMilestone({
14806
15251
  workspaceId: session.workspace.id,
@@ -14843,6 +15288,113 @@ function registerSprintCommands(program) {
14843
15288
  });
14844
15289
  }
14845
15290
  //#endregion
15291
+ //#region src/cli/metrics-view.ts
15292
+ /** Every field `kanbo stats --json` may name, in the order the value carries them. */
15293
+ const BOARD_METRICS_FIELDS = [
15294
+ "range",
15295
+ "now",
15296
+ "milestoneId",
15297
+ "columns",
15298
+ "cycleTime",
15299
+ "leadTime",
15300
+ "throughput",
15301
+ "wip",
15302
+ "aging",
15303
+ "timeInColumn",
15304
+ "cumulativeFlow",
15305
+ "finished",
15306
+ "returns",
15307
+ "attempts",
15308
+ "canceled",
15309
+ "agents",
15310
+ "people"
15311
+ ];
15312
+ const LABEL_WIDTH = 16;
15313
+ /** 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. */
15314
+ function formatDuration(seconds) {
15315
+ if (seconds === null) return "—";
15316
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
15317
+ if (seconds < 86400) return `${Number((seconds / 3600).toFixed(1))}h`;
15318
+ return `${Number((seconds / 86400).toFixed(1))}d`;
15319
+ }
15320
+ function percent(share) {
15321
+ return share === null ? "—" : `${Math.round(share * 100)}%`;
15322
+ }
15323
+ function decimal(value) {
15324
+ return value === null ? "—" : String(Number(value.toFixed(1)));
15325
+ }
15326
+ function count(n, noun) {
15327
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
15328
+ }
15329
+ function day(seconds) {
15330
+ return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
15331
+ }
15332
+ /** A labelled block: the first item beside the label, the rest under it; a dash when there is nothing. */
15333
+ function block(label, items) {
15334
+ const [first = "—", ...rest] = items;
15335
+ return [`${label.padEnd(LABEL_WIDTH)}${first}`, ...rest.map((item) => `${" ".repeat(LABEL_WIDTH)}${item}`)];
15336
+ }
15337
+ /** Items joined on one line, or none at all. */
15338
+ function oneLine(items) {
15339
+ return items.length === 0 ? [] : [items.join(" · ")];
15340
+ }
15341
+ /**
15342
+ * The metrics as a person reads them in a terminal: the range, then one block
15343
+ * per figure. The cumulative flow is a series per day and has no one-screen
15344
+ * form, so the last line says how to get it.
15345
+ */
15346
+ function describeMetrics(metrics, sprint) {
15347
+ const column = (slug) => sanitizeTerminalText(metrics.columns.find((candidate) => candidate.slug === slug)?.name ?? slug);
15348
+ const by = metrics.returns.byActorKind;
15349
+ const lines = [`${day(metrics.range.from)} → ${day(metrics.range.to)}`];
15350
+ if (sprint) lines.push(`${sanitizeTerminalText(sprint.title)}: ${sprint.cards.open + sprint.cards.done} planned · ${sprint.cards.done} done`);
15351
+ if (metrics.range.from > metrics.range.to) return [...lines, "Nothing measured yet: the range starts after now."].join("\n");
15352
+ 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");
15353
+ return lines.join("\n");
15354
+ }
15355
+ function describeAgent(agent) {
15356
+ const { runs } = agent;
15357
+ 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`;
15358
+ }
15359
+ function describePerson(person) {
15360
+ return `${sanitizeTerminalText(person.id ?? "unnamed")}: ${person.created} created · ${person.approvals} approved · ${person.returns} returned · ${person.moves} moved`;
15361
+ }
15362
+ const WEEK_SECONDS = 604800;
15363
+ /** The Monday that makes the range `DEFAULT_STATS_WEEKS` ISO weeks long, ending with the week of `now`. */
15364
+ function defaultStatsFrom(now) {
15365
+ return isoWeekOf(now).start - 11 * WEEK_SECONDS;
15366
+ }
15367
+ /**
15368
+ * `kanbo stats` — how work flows on this board (spec 2026-11-05, Part 4).
15369
+ *
15370
+ * Reading only, so it is anybody's, an agent's shell included. A sprint sets
15371
+ * the range to its own dates and keeps its cards; `--from` and `--to` still
15372
+ * win over either end.
15373
+ */
15374
+ function registerStatsCommand(program) {
15375
+ 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) => {
15376
+ await runBoardCommand(options, "read", async (session) => {
15377
+ const now = currentUnixSeconds();
15378
+ const sprint = options.sprint === void 0 ? null : (await session.ops.listSprints(session.workspace.id, now)).find((candidate) => candidate.id === options.sprint) ?? null;
15379
+ if (options.sprint !== void 0 && sprint === null) throw new BoardError("issue_milestone_not_found", {
15380
+ milestoneId: options.sprint,
15381
+ workspaceId: session.workspace.id
15382
+ });
15383
+ const metrics = await session.ops.boardMetrics({
15384
+ workspaceIds: [session.workspace.id],
15385
+ from: options.from ?? sprint?.startDate ?? defaultStatsFrom(now),
15386
+ to: options.to ?? sprint?.dueDate ?? now,
15387
+ milestoneId: sprint?.id ?? null
15388
+ }, now);
15389
+ return {
15390
+ value: metrics,
15391
+ text: describeMetrics(metrics, sprint),
15392
+ fields: BOARD_METRICS_FIELDS
15393
+ };
15394
+ });
15395
+ });
15396
+ }
15397
+ //#endregion
14846
15398
  //#region src/cli/commands/uninstall.ts
14847
15399
  function registerUninstallCommand(program) {
14848
15400
  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) => {
@@ -15023,6 +15575,7 @@ function registerKanboCommands(program, context = {}) {
15023
15575
  registerReadyCommand(program);
15024
15576
  registerPrimeCommand(program);
15025
15577
  registerSprintCommands(program);
15578
+ registerStatsCommand(program);
15026
15579
  registerServeCommand(program);
15027
15580
  program.commandsGroup("For agents and integrations:");
15028
15581
  registerMcpCommand(program);
@@ -15235,4 +15788,4 @@ function buildCapabilitiesManifest() {
15235
15788
  };
15236
15789
  }
15237
15790
  //#endregion
15238
- export { serializeEntryRules as $, projectKanboSprint as A, createBoardOps as B, pageOf as C, toIssueView as Ct, projectKanboFieldChange as D, nextIssueOrder as Dt, projectKanboColumn as E, nextIssueNumber as Et, BINDING_FILE_PATH as F, FLOW_RULES as G, formatExternalSessionRef as H, ignoreBoardFile as I, ENTRY_RULE_DESCRIPTIONS as J, readFlowRules as K, readBinding as L, renderCapabilitiesMarkdown as M, maskDatabaseUrl as N, projectKanboPullRequest as O, readIssuePrefix as Ot, maskDatabaseUrls as P, readEntryRules as Q, readProjectBinding as R, matchesKanboCardQuery as S, normalizeCommentAuthorKind as St, projectKanboCardComment as T, nextIssueIdentity as Tt, parseExternalSessionRef as U, cardDisplayTitle as V, pullRequestFactKeys as W, evaluateEntryRules as X, UNMET_ENTRY_RULE_REASONS as Y, readChecklist as Z, suggestCardKey as _, formatPlainActivityValue as _t, BOARD_ERROR_RESPONSES as a, DEFAULT_STATUSES as at, DEFAULT_KANBO_COMMENT_LIMIT as b, readSystemCommentKind as bt, registerKanboTools as c, diffFieldChanges as ct, KANBO_MCP_INSTRUCTIONS as d, currentUnixSeconds as dt, COLUMN_CATALOGUE as et, KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI as f, activityKindOrder as ft, isValidCardKey as g, formatActivityIssueReference as gt, CARD_KEY_PATTERN as h, commentAuthorReference as ht, KANBO_TOOL_NAMES as i, runBoardWrite as it, projectKanboSubCard as j, projectKanboRun as k, KANBO_TOOLS as l, previewComment as lt, registerKanboCapabilitiesResources as m, activityToken as mt, buildCapabilitiesManifest as n, COLUMN_TEMPLATE_IDS as nt, KANBO_MCP_SERVER_NAME as o, normalizeStatusName as ot, KANBO_CAPABILITIES_RESOURCE_URI as p, activityText as pt, ENTRY_RULES as q, KANBO_TOOL_CLI_EQUIVALENTS as r, readChangeSeq as rt, createKanboMcpServer as s, TRACKED_FIELDS as st, CAPABILITIES_MANIFEST_VERSION as t, COLUMN_TEMPLATES as tt, buildKanboTools as u, BOARD_ACTOR as ut, DEFAULT_BOARD_FILE_PATH as v, isEmptyActivityValue as vt, projectKanboCard as w, formatIssueId as wt, KANBO_CARD_INCLUDES as x, isOutsideWriter as xt, DEFAULT_KANBO_CARD_INCLUDES as y, parseActivityStringArray as yt, writeBinding as z };
15791
+ export { COLUMN_TEMPLATE_IDS as $, projectKanboSprint as A, formatIssueId as At, createBoardOps as B, pageOf as C, formatPlainActivityValue as Ct, projectKanboFieldChange as D, isOutsideWriter as Dt, projectKanboColumn as E, readSystemCommentKind as Et, BINDING_FILE_PATH as F, ENTRY_RULE_DESCRIPTIONS as G, parseExternalSessionRef as H, ignoreBoardFile as I, readChecklist as J, UNMET_ENTRY_RULE_REASONS as K, readBinding as L, renderCapabilitiesMarkdown as M, nextIssueNumber as Mt, maskDatabaseUrl as N, nextIssueOrder as Nt, projectKanboPullRequest as O, normalizeCommentAuthorKind as Ot, maskDatabaseUrls as P, readIssuePrefix as Pt, COLUMN_TEMPLATES as Q, readProjectBinding as R, matchesKanboCardQuery as S, formatActivityIssueReference as St, projectKanboCardComment as T, parseActivityStringArray as Tt, pullRequestFactKeys as U, formatExternalSessionRef as V, ENTRY_RULES as W, serializeEntryRules as X, readEntryRules as Y, COLUMN_CATALOGUE as Z, suggestCardKey as _, currentUnixSeconds as _t, BOARD_ERROR_RESPONSES as a, boardMetricsRuns as at, DEFAULT_KANBO_COMMENT_LIMIT as b, activityToken as bt, registerKanboTools as c, isoWeekOf as ct, KANBO_MCP_INSTRUCTIONS as d, readFlowRules as dt, readChangeSeq as et, KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI as f, DEFAULT_STATUSES as ft, isValidCardKey as g, BOARD_ACTOR as gt, CARD_KEY_PATTERN as h, previewComment as ht, KANBO_TOOL_NAMES as i, boardMetricsAgentKey as it, projectKanboSubCard as j, nextIssueIdentity as jt, projectKanboRun as k, toIssueView as kt, KANBO_TOOLS as l, utcDayLabel as lt, registerKanboCapabilitiesResources as m, cardDisplayTitle as mt, buildCapabilitiesManifest as n, TRACKED_FIELDS as nt, KANBO_MCP_SERVER_NAME as o, computeBoardMetrics as ot, KANBO_CAPABILITIES_RESOURCE_URI as p, normalizeStatusName as pt, evaluateEntryRules as q, KANBO_TOOL_CLI_EQUIVALENTS as r, diffFieldChanges as rt, createKanboMcpServer as s, nearestRank as st, CAPABILITIES_MANIFEST_VERSION as t, runBoardWrite as tt, buildKanboTools as u, FLOW_RULES as ut, DEFAULT_BOARD_FILE_PATH as v, activityKindOrder as vt, projectKanboCard as w, isEmptyActivityValue as wt, KANBO_CARD_INCLUDES as x, commentAuthorReference as xt, DEFAULT_KANBO_CARD_INCLUDES as y, activityText as yt, writeBinding as z };