kanbo-cli 0.4.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/cli-main.cjs +1322 -154
  3. package/dist/drizzle-postgres/0006_flow_rules_and_column_entries.sql +3 -0
  4. package/dist/drizzle-postgres/README.md +8 -0
  5. package/dist/drizzle-postgres/meta/0006_snapshot.json +1290 -0
  6. package/dist/drizzle-postgres/meta/_journal.json +7 -0
  7. package/dist/drizzle-sqlite/0005_flow_rules_and_column_entries.sql +3 -0
  8. package/dist/drizzle-sqlite/README.md +8 -0
  9. package/dist/drizzle-sqlite/meta/0005_snapshot.json +1097 -0
  10. package/dist/drizzle-sqlite/meta/_journal.json +7 -0
  11. package/dist/lib/{approval-BsBk2xVr.d.cts → approval-CnJJfwfi.d.mts} +30 -6
  12. package/dist/lib/{approval-LRZ6ontC.d.mts → approval-DNfUhYD5.d.cts} +30 -6
  13. package/dist/lib/{board-store-D_Mpfb84.d.mts → board-store-BjhiH9TZ.d.mts} +38 -2
  14. package/dist/lib/{board-store-Dx-mw2I6.d.cts → board-store-CsyBXBwH.d.cts} +38 -2
  15. package/dist/lib/index.cjs +9 -1
  16. package/dist/lib/index.d.cts +302 -6
  17. package/dist/lib/index.d.mts +302 -6
  18. package/dist/lib/index.mjs +2 -2
  19. package/dist/lib/{manifest-DcIMYvWk.cjs → manifest-BFI4HGPh.cjs} +1410 -225
  20. package/dist/lib/{manifest-Dmo6IlWk.mjs → manifest-DHI1fkoW.mjs} +1395 -258
  21. package/dist/lib/mcp/index.cjs +24 -4
  22. package/dist/lib/mcp/index.d.cts +19 -4
  23. package/dist/lib/mcp/index.d.mts +19 -4
  24. package/dist/lib/mcp/index.mjs +24 -4
  25. package/dist/lib/{migrate-Dca5WdYd.cjs → migrate-36aT0rV0.cjs} +17 -0
  26. package/dist/lib/{migrate-DtBeW3Fs.mjs → migrate-Bgj_aABY.mjs} +18 -1
  27. package/dist/lib/postgres/index.cjs +1 -1
  28. package/dist/lib/postgres/index.d.cts +69 -1
  29. package/dist/lib/postgres/index.d.mts +69 -1
  30. package/dist/lib/postgres/index.mjs +1 -1
  31. package/dist/lib/{roles.sql-BoOZSkBp.mjs → roles.sql-9OkYvsNM.mjs} +26 -2
  32. package/dist/lib/{roles.sql-E58x7mLS.cjs → roles.sql-BC7k_zPh.cjs} +25 -1
  33. package/dist/lib/{schema-D0MyXcej.d.cts → schema-Cwx3HosC.d.cts} +72 -0
  34. package/dist/lib/{schema-D0MyXcej.d.mts → schema-Cwx3HosC.d.mts} +72 -0
  35. package/dist/lib/sqlite/index.cjs +1 -1
  36. package/dist/lib/sqlite/index.d.cts +2 -2
  37. package/dist/lib/sqlite/index.d.mts +2 -2
  38. package/dist/lib/sqlite/index.mjs +1 -1
  39. package/dist/lib/sqlite/schema.cjs +13 -1
  40. package/dist/lib/sqlite/schema.d.cts +1 -1
  41. package/dist/lib/sqlite/schema.d.mts +1 -1
  42. package/dist/lib/sqlite/schema.mjs +13 -1
  43. package/package.json +1 -1
@@ -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-E58x7mLS.cjs");
25
- const require_migrate = require("./migrate-Dca5WdYd.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");
@@ -296,6 +296,7 @@ const HUMAN_ONLY_ACTIONS = [
296
296
  "return",
297
297
  "sprint close",
298
298
  "columns rules",
299
+ "columns flow",
299
300
  "columns add",
300
301
  "columns rename",
301
302
  "columns move",
@@ -309,6 +310,7 @@ const HUMAN_ONLY_MESSAGES = {
309
310
  "return": describeReturnRefusal,
310
311
  "sprint close": describeSprintCloseRefusal,
311
312
  "columns rules": describeColumnRulesRefusal,
313
+ "columns flow": describeColumnRulesRefusal,
312
314
  "columns add": describeColumnChangesRefusal,
313
315
  "columns rename": describeColumnChangesRefusal,
314
316
  "columns move": describeColumnChangesRefusal,
@@ -608,18 +610,18 @@ function activityToken(token) {
608
610
  function currentUnixSeconds() {
609
611
  return Math.floor(Date.now() / 1e3);
610
612
  }
611
- const DAY_SECONDS = 86400;
613
+ const DAY_SECONDS$2 = 86400;
612
614
  /**
613
615
  * A board date is a calendar day (ruling 5x-6): a start is kept as the first
614
616
  * second of its UTC day, a due date as the last, so a sprint runs through its
615
617
  * due day wherever the person reading it is.
616
618
  */
617
619
  function utcDayStart(seconds) {
618
- return Math.floor(seconds / DAY_SECONDS) * DAY_SECONDS;
620
+ return Math.floor(seconds / DAY_SECONDS$2) * DAY_SECONDS$2;
619
621
  }
620
622
  /** The last second of the UTC day `seconds` falls on — see `utcDayStart`. */
621
623
  function utcDayEnd(seconds) {
622
- return utcDayStart(seconds) + DAY_SECONDS - 1;
624
+ return utcDayStart(seconds) + DAY_SECONDS$2 - 1;
623
625
  }
624
626
  /** The largest unix seconds a moment is read as: past it, the number is milliseconds (year 5138 onwards). */
625
627
  const MAX_UNIX_SECONDS = 1e11;
@@ -789,6 +791,14 @@ const HIDDEN_ACTIVITY_FIELDS = /* @__PURE__ */ new Set([
789
791
  "order",
790
792
  "waitingFor"
791
793
  ]);
794
+ /**
795
+ * The column a card was created in, recorded as a change from no column at the
796
+ * card's own creation time. The feed's `created` item already stands for it,
797
+ * and the field history keeps it.
798
+ */
799
+ function isStartingColumn(change, issue) {
800
+ return change.field === "statusId" && change.fromValue === null && change.createdAt === issue.createdAt;
801
+ }
792
802
  const ACTIVITY_FIELD_BY_RAW_FIELD = {
793
803
  assigneeId: "assignee",
794
804
  description: "description",
@@ -829,7 +839,7 @@ async function listActivity(store, issueId, resolver) {
829
839
  workspaceById: resolver.workspaceById
830
840
  };
831
841
  const commentItems = await Promise.all((await store.comments.listByIssue(issueId)).map(async (comment) => await toCommentActivityItem(comment, resolver)));
832
- const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
842
+ const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).filter((change) => !isStartingColumn(change, issue)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
833
843
  return [
834
844
  {
835
845
  id: `${issue.id}:created`,
@@ -1027,62 +1037,31 @@ function previewComment(comment) {
1027
1037
  };
1028
1038
  }
1029
1039
  //#endregion
1030
- //#region src/domain/field-changes.ts
1040
+ //#region src/domain/card-display-title.ts
1031
1041
  /**
1032
- * 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.
1033
1043
  *
1034
- * `statusLine` is absent on purpose: `writeStatusLine` records every line it
1035
- * writes itself, and no patch ever carries the column. `waitingFor` is absent
1036
- * too — whose turn it is shows in the card's own line and in the
1037
- * `system.approved` / `system.returned` comments around it, so a row saying the
1038
- * same thing a third time would only crowd the history.
1039
- */
1040
- const TRACKED_FIELDS = [
1041
- "workspaceId",
1042
- "title",
1043
- "description",
1044
- "priority",
1045
- "labels",
1046
- "milestoneId",
1047
- "parentIssueId",
1048
- "statusId",
1049
- "assigneeKind",
1050
- "assigneeId",
1051
- "dueDate",
1052
- "delegateAgentId",
1053
- "delegateProviderTargetId",
1054
- "contextRefs",
1055
- "executionMode",
1056
- "order"
1057
- ];
1058
- /**
1059
- * The audit rows one patch produces: a row per tracked field whose stringified
1060
- * value actually changed. Nothing is written here — the caller inserts what
1061
- * comes back, so the diff stays a pure function of the row, the patch, the
1062
- * 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.
1063
1049
  */
1064
- function diffFieldChanges(before, updates, actor, now) {
1065
- const rows = [];
1066
- for (const field of TRACKED_FIELDS) {
1067
- if (!(field in updates)) continue;
1068
- const rawBefore = before[field === "labels" ? "labels" : field];
1069
- const rawAfter = updates[field];
1070
- const fromValue = rawBefore == null ? null : String(rawBefore);
1071
- const toValue = rawAfter == null ? null : String(rawAfter);
1072
- if (fromValue === toValue) continue;
1073
- rows.push({
1074
- id: (0, node_crypto.randomUUID)(),
1075
- issueId: before.id,
1076
- field,
1077
- fromValue,
1078
- toValue,
1079
- actorKind: actor.kind,
1080
- actorId: actor.id ?? null,
1081
- sourceChatSessionId: actor.sourceChatSessionId ?? null,
1082
- createdAt: now
1083
- });
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;
1084
1060
  }
1085
- 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();
1086
1065
  }
1087
1066
  //#endregion
1088
1067
  //#region src/domain/status-name.ts
@@ -1137,6 +1116,603 @@ function normalizeStatusName(value) {
1137
1116
  return value.trim().toLowerCase().replace(/[\s-]+/g, "_");
1138
1117
  }
1139
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
1140
1716
  //#region src/ops/change-seq.ts
1141
1717
  /** The `kanban_meta` row every board write bumps. */
1142
1718
  const BOARD_CHANGE_KEY = "board";
@@ -1372,7 +1948,9 @@ const UNMET_ENTRY_RULE_REASONS = [
1372
1948
  "ci_red",
1373
1949
  "approval_pending",
1374
1950
  "returned",
1375
- "not_approved"
1951
+ "not_approved",
1952
+ "not_allowed_from",
1953
+ "column_full"
1376
1954
  ];
1377
1955
  const entryRulesSchema = zod.z.array(zod.z.enum(ENTRY_RULES));
1378
1956
  /**
@@ -1588,6 +2166,7 @@ async function applyColumnTemplate(store, workspaceId, columns, options, scope)
1588
2166
  workspaceId,
1589
2167
  ...column
1590
2168
  }, order);
2169
+ await writeStandardFlow(tx, await listColumns(tx, workspaceId));
1591
2170
  return {
1592
2171
  statuses: await listColumns(tx, workspaceId),
1593
2172
  added: columns.map((column) => column.name)
@@ -1697,6 +2276,80 @@ async function setColumnEntryRules(store, workspaceId, nameOrId, entryRules, act
1697
2276
  return await updateColumn(tx, (await requireColumn(tx, workspaceId, nameOrId)).id, { entryRules }, actor, { tx });
1698
2277
  }, scope);
1699
2278
  }
2279
+ /** The largest WIP limit: what a Postgres `integer` column holds. */
2280
+ const MAX_WIP_LIMIT = 2147483647;
2281
+ /**
2282
+ * Set where a card may enter the column from and how many cards it holds:
2283
+ * `allowedFrom` the ids of other columns of its board (`null` for any, `[]` for
2284
+ * none), kept once each in board order; `wipLimit` a whole number from 1 to 2147483647, or `null`. A
2285
+ * person's to set, like the entry rules: an agent that could open a column
2286
+ * could walk any card into it.
2287
+ */
2288
+ async function setColumnFlowRules(store, columnId, rules, actor, scope) {
2289
+ assertPersonSetsEntryRules(actor, { statusId: columnId });
2290
+ if (rules.wipLimit !== null && !(Number.isSafeInteger(rules.wipLimit) && rules.wipLimit >= 1 && rules.wipLimit <= MAX_WIP_LIMIT)) throw new require_migrations_path.BoardError("board_flow_rule_invalid", {
2291
+ statusId: columnId,
2292
+ wipLimit: rules.wipLimit
2293
+ });
2294
+ return await runBoardWrite(store, async ({ tx }) => {
2295
+ const column = await tx.statuses.findById(columnId);
2296
+ if (!column) throw new require_migrations_path.BoardError("issue_status_not_found", { statusId: columnId });
2297
+ const allowedFrom = rules.allowedFrom === null ? null : allowedFromInBoardOrder(await listColumns(tx, column.workspaceId), column, rules.allowedFrom);
2298
+ await tx.statuses.update(columnId, {
2299
+ allowedFrom: serializeAllowedFrom(allowedFrom),
2300
+ wipLimit: rules.wipLimit
2301
+ });
2302
+ return await tx.statuses.findById(columnId);
2303
+ }, scope);
2304
+ }
2305
+ /** The ids asked for, once each and in board order — or the refusal naming those that are not another column of this board. */
2306
+ function allowedFromInBoardOrder(columns, column, requested) {
2307
+ const others = columns.filter((other) => other.id !== column.id);
2308
+ const invalid = requested.filter((id) => !others.some((other) => other.id === id));
2309
+ if (invalid.length > 0) throw new require_migrations_path.BoardError("board_flow_rule_invalid", {
2310
+ statusId: column.id,
2311
+ allowedFrom: invalid
2312
+ });
2313
+ return others.filter((other) => requested.includes(other.id)).map((other) => other.id);
2314
+ }
2315
+ /**
2316
+ * Put the standard flow on a board: Done accepts cards from In Review only —
2317
+ * and from QA, when the board has it — so nothing reaches Done but through
2318
+ * review. Done's WIP limit and every other column stay as they are. A board
2319
+ * without Done, or with neither In Review nor QA, has nothing to apply it to.
2320
+ */
2321
+ async function applyStandardFlow(store, workspaceId, actor, scope) {
2322
+ assertPersonSetsEntryRules(actor, { workspaceId });
2323
+ return await runBoardWrite(store, async ({ tx }) => {
2324
+ const columns = await listColumns(tx, workspaceId);
2325
+ if (!await writeStandardFlow(tx, columns)) {
2326
+ const hasDone = columns.some((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
2327
+ throw new require_migrations_path.BoardError("issue_status_not_found", {
2328
+ workspaceId,
2329
+ statusName: hasDone ? "In Review" : "Done"
2330
+ });
2331
+ }
2332
+ return await listColumns(tx, workspaceId);
2333
+ }, scope);
2334
+ }
2335
+ /** Done takes cards from the board's In Review and QA; `false`, with nothing written, when the board has no such columns. */
2336
+ async function writeStandardFlow(store, columns) {
2337
+ const flow = standardFlowOf(columns);
2338
+ if (!flow) return false;
2339
+ await store.statuses.update(flow.target.id, { allowedFrom: serializeAllowedFrom(flow.sources.map((column) => column.id)) });
2340
+ return true;
2341
+ }
2342
+ /**
2343
+ * Take a deleted column out of every list of where cards may come from. A list
2344
+ * it empties stays empty: a column that took cards only from it does not open
2345
+ * to every column.
2346
+ */
2347
+ async function dropFromAllowedFrom(store, workspaceId, statusId) {
2348
+ for (const other of await listColumns(store, workspaceId)) {
2349
+ const { allowedFrom } = readFlowRules(other);
2350
+ if (allowedFrom?.includes(statusId)) await store.statuses.update(other.id, { allowedFrom: serializeAllowedFrom(allowedFrom.filter((id) => id !== statusId)) });
2351
+ }
2352
+ }
1700
2353
  /**
1701
2354
  * Take an empty column off the board, and number the columns left 0..n-1 in
1702
2355
  * the same write.
@@ -1723,6 +2376,7 @@ async function deleteColumn(store, statusId, scope, actor) {
1723
2376
  hint: "Move its cards to another column first, or remove it with removeColumn and moveCardsTo."
1724
2377
  });
1725
2378
  await tx.statuses.delete(statusId);
2379
+ await dropFromAllowedFrom(tx, column.workspaceId, statusId);
1726
2380
  await renumberColumns(tx, column.workspaceId);
1727
2381
  }, scope);
1728
2382
  }
@@ -2002,24 +2656,46 @@ function pullRequestFactKeys({ owner, repo, number }) {
2002
2656
  //#endregion
2003
2657
  //#region src/ops/entry-rules.ts
2004
2658
  /**
2005
- * The column's entry rules the card does not meet (ruling 6-2). A column with
2006
- * no rules answers at once, without reading anything; otherwise only the facts
2007
- * the column's rules need are read.
2659
+ * The column's entry rules and flow rules the card does not meet, entry rules
2660
+ * first (ruling 6-2; board-workflow spec, Part 2). A column with no rules
2661
+ * answers at once, without reading anything; otherwise only the facts its
2662
+ * rules need are read. `entering` counts the cards this same write already let
2663
+ * into the column, so a bulk move is held to the WIP limit as a whole.
2008
2664
  */
2009
- async function checkColumnEntry(store, card, column) {
2665
+ async function checkColumnEntry(store, card, column, entering) {
2010
2666
  const rules = readEntryRules(column);
2011
- if (rules.length === 0) return [];
2012
- return evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
2667
+ const unmet = rules.length === 0 ? [] : evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
2668
+ const flow = readFlowRules(column);
2669
+ if (flow.allowedFrom === null && flow.wipLimit === null) return unmet;
2670
+ return [...unmet, ...evaluateFlowRules(card, column, flow, await readFlowRuleFacts(store, column, flow, entering))];
2671
+ }
2672
+ /** What the flow rules need: the board's column names when the column names where cards come from, and its card count when it has a limit. */
2673
+ async function readFlowRuleFacts(store, column, flow, entering) {
2674
+ const columnNames = flow.allowedFrom === null ? /* @__PURE__ */ new Map() : new Map((await store.statuses.listByWorkspace(column.workspaceId)).map((other) => [other.id, other.name]));
2675
+ if (flow.wipLimit === null) return {
2676
+ columnNames,
2677
+ cardsInColumn: 0
2678
+ };
2679
+ await store.statuses.lockById(column.id);
2680
+ return {
2681
+ columnNames,
2682
+ cardsInColumn: (await store.issues.listPage({
2683
+ workspaceId: column.workspaceId,
2684
+ statusIds: [column.id],
2685
+ limit: 1
2686
+ })).total + entering
2687
+ };
2013
2688
  }
2014
2689
  /**
2015
- * Hold a card at the door of a column that asks for more than it has (ruling
2016
- * 6-3). Anyone but a person — or a person's own terminal, `placesForPerson`
2017
- * (ruling 6-6) — is refused with `board_column_rules_unmet`, which
2018
- * names the column and every unmet rule; a person is let through, and the
2019
- * unmet rules come back so the caller can warn them.
2690
+ * Hold a card at the door of a column that asks for more than it has — entry
2691
+ * rules (ruling 6-3), or flow rules: a column it may not come from, or a column
2692
+ * that is full. Anyone but a person — or a person's own terminal,
2693
+ * `placesForPerson` (ruling 6-6) — is refused with `board_column_rules_unmet`,
2694
+ * which names the column and everything unmet; a person is let through, and
2695
+ * the unmet list comes back so the caller can warn them.
2020
2696
  */
2021
- async function enforceColumnEntry(store, card, column, actor) {
2022
- const unmet = await checkColumnEntry(store, card, column);
2697
+ async function enforceColumnEntry(store, card, column, actor, entering = 0) {
2698
+ const unmet = await checkColumnEntry(store, card, column, entering);
2023
2699
  if (unmet.length > 0 && actor.kind !== "user" && !actor.placesForPerson) throw new require_migrations_path.BoardError("board_column_rules_unmet", {
2024
2700
  issueId: card.id,
2025
2701
  column: column.name,
@@ -2032,14 +2708,17 @@ async function enforceColumnEntry(store, card, column, actor) {
2032
2708
  * The same door for several cards entering one column at once — a bulk status
2033
2709
  * change. All or nothing: the first card an agent may not move refuses the
2034
2710
  * whole change. A person gets back the cards that entered with rules unmet.
2711
+ * Each card counts against the column's WIP limit as the cards before it in the same change entered it.
2035
2712
  */
2036
2713
  async function enforceColumnEntryForCards(store, issueIds, statusId, actor) {
2037
2714
  const column = await store.statuses.findById(statusId);
2038
2715
  if (!column) throw new require_migrations_path.BoardError("issue_status_not_found", { statusId });
2039
2716
  const entered = [];
2717
+ let entering = 0;
2040
2718
  for (const card of await store.issues.listByIds(issueIds)) {
2041
2719
  if (card.statusId === column.id) continue;
2042
- const unmet = await enforceColumnEntry(store, card, column, actor);
2720
+ const unmet = await enforceColumnEntry(store, card, column, actor, entering);
2721
+ entering++;
2043
2722
  if (unmet.length > 0) entered.push({
2044
2723
  issueId: card.id,
2045
2724
  unmet
@@ -2194,11 +2873,24 @@ const STOPPED_BY_YOU_STATUS_LINE = "stopped by you";
2194
2873
  * the board reads the same whether the agent was launched by an app or from
2195
2874
  * somewhere else entirely. The attempt number is simply how many runs the card
2196
2875
  * has had — a card whose runs were all launched elsewhere still counts them.
2876
+ *
2877
+ * A card has one run going at a time. A start on a card whose run is still going
2878
+ * is refused with `board_card_run_active`, naming that run — whichever path it
2879
+ * came by, an app's launch, `kanbo run start`, MCP, or a second app on a shared
2880
+ * Postgres board. The card's row is locked first, so two starts racing for it
2881
+ * are settled one after the other.
2197
2882
  */
2198
2883
  async function startRun(store, issueId, input, actor, scope) {
2199
2884
  const externalSessionRef = readExternalSessionRef(input.externalSessionRef) ?? null;
2200
2885
  return await runBoardWrite(store, async ({ tx }) => {
2201
- const card = await requireCard$1(tx, issueId);
2886
+ const card = await tx.issues.lockById(issueId);
2887
+ if (!card) throw new require_migrations_path.BoardError("issue_not_found", { issueId });
2888
+ const [active] = await tx.runs.listRunning(issueId);
2889
+ if (active) throw new require_migrations_path.BoardError("board_card_run_active", {
2890
+ issueId,
2891
+ runId: active.id,
2892
+ agentName: active.agentName
2893
+ });
2202
2894
  const attempt = await tx.runs.countByIssue(issueId) + 1;
2203
2895
  const executionMode = input.executionMode ?? card.executionMode;
2204
2896
  const run = await tx.runs.create({
@@ -2464,7 +3156,8 @@ async function createCard(store, input, actor, scope) {
2464
3156
  const column = statusId === null ? null : await tx.statuses.findById(statusId);
2465
3157
  const unmet = column ? await enforceColumnEntry(tx, {
2466
3158
  id: null,
2467
- description: input.description ?? null
3159
+ description: input.description ?? null,
3160
+ statusId: null
2468
3161
  }, column, actor) : [];
2469
3162
  const milestoneId = await requireMilestoneInWorkspace(tx, workspaceId, input.milestoneId ?? null);
2470
3163
  const parentIssueId = await requireParentInWorkspace(tx, workspaceId, input.parentIssueId ?? null);
@@ -2472,8 +3165,9 @@ async function createCard(store, input, actor, scope) {
2472
3165
  const now = currentUnixSeconds();
2473
3166
  for (let attempt = 1;; attempt++) {
2474
3167
  const identity = await nextIssueIdentity(input.workspace, tx);
3168
+ let created;
2475
3169
  try {
2476
- return withUnmetRules(await tx.issues.create({
3170
+ created = await tx.issues.create({
2477
3171
  id: identity.id,
2478
3172
  workspaceId,
2479
3173
  title: input.title?.trim() || identity.id,
@@ -2497,12 +3191,25 @@ async function createCard(store, input, actor, scope) {
2497
3191
  order,
2498
3192
  createdAt: now,
2499
3193
  updatedAt: now
2500
- }), unmet);
3194
+ });
2501
3195
  } catch (error) {
2502
3196
  const numberTaken = await tx.issues.findByNumber(workspaceId, identity.number);
2503
3197
  const keyTaken = await tx.issues.findById(identity.id);
2504
3198
  if (attempt >= NUMBERING_ATTEMPTS || !numberTaken && !keyTaken) throw error;
3199
+ continue;
2505
3200
  }
3201
+ if (statusId !== null) await tx.fieldChanges.create({
3202
+ id: (0, node_crypto.randomUUID)(),
3203
+ issueId: created.id,
3204
+ field: "statusId",
3205
+ fromValue: null,
3206
+ toValue: statusId,
3207
+ actorKind: toStoredActorKind(actor),
3208
+ actorId: actor.id ?? null,
3209
+ sourceChatSessionId: actor.chatSessionId ?? null,
3210
+ createdAt: now
3211
+ });
3212
+ return withUnmetRules(created, unmet);
2506
3213
  }
2507
3214
  }, scope);
2508
3215
  }
@@ -2558,7 +3265,8 @@ async function updateCard(store, issueId, input, actor, scope) {
2558
3265
  const description = "description" in updates ? updates.description ?? null : card.description;
2559
3266
  unmet = await enforceColumnEntry(tx, {
2560
3267
  id: card.id,
2561
- description
3268
+ description,
3269
+ statusId: card.statusId
2562
3270
  }, column, actor);
2563
3271
  }
2564
3272
  if ("assigneeKind" in input) updates.assigneeKind = input.assigneeKind ?? null;
@@ -2936,25 +3644,137 @@ async function bulkUpdateCards(store, issueIds, input, actor, scope) {
2936
3644
  }, scope);
2937
3645
  }
2938
3646
  /**
2939
- * Give the named cards of one workspace their positions in the order named.
2940
- *
2941
- * Positions are spaced by a gap rather than packed, so a card dropped between
2942
- * two others later can take a midpoint without moving its neighbours. The whole
2943
- * order is one write — a drag that half-applied would leave a board no client
2944
- * could reconcile — and a card that is gone or belongs to another workspace is
2945
- * skipped rather than failing the rest of the drop.
3647
+ * Give the named cards of one workspace their positions in the order named.
3648
+ *
3649
+ * Positions are spaced by a gap rather than packed, so a card dropped between
3650
+ * two others later can take a midpoint without moving its neighbours. The whole
3651
+ * order is one write — a drag that half-applied would leave a board no client
3652
+ * could reconcile — and a card that is gone or belongs to another workspace is
3653
+ * skipped rather than failing the rest of the drop.
3654
+ */
3655
+ async function reorderCards(store, workspaceId, orderedIds, scope) {
3656
+ await runBoardWrite(store, async ({ tx }) => {
3657
+ for (const [index, issueId] of orderedIds.entries()) {
3658
+ const card = await tx.issues.findById(issueId);
3659
+ if (!card || card.workspaceId !== workspaceId) continue;
3660
+ await tx.issues.update(issueId, {
3661
+ order: (index + 1) * CARD_ORDER_GAP,
3662
+ updatedAt: currentUnixSeconds()
3663
+ });
3664
+ }
3665
+ }, scope);
3666
+ }
3667
+ //#endregion
3668
+ //#region src/ops/ready.ts
3669
+ /** The column an agent takes its next card from. */
3670
+ const READY_COLUMN_SLUG = "to_do";
3671
+ /**
3672
+ * The cards an agent may pick up right now: spelled out, nobody working on them,
3673
+ * nobody's turn but the agent's.
3674
+ *
3675
+ * "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
3676
+ * run is taken, and a card waiting for a person belongs to that person until
3677
+ * they answer. What is left is the queue — in board order, so the card someone
3678
+ * dragged to the top is the one an agent asking for work gets.
3679
+ */
3680
+ async function listReady(store, input) {
3681
+ return (await queryReady(store, input)).cards;
3682
+ }
3683
+ /**
3684
+ * One page of the ready cards, how many are ready in all, and the columns read
3685
+ * on the way — the rule above, asked of the database in one statement rather
3686
+ * than of every card on the board.
3687
+ */
3688
+ async function queryReady(store, input) {
3689
+ const columns = await listColumns(store, input.workspaceId);
3690
+ const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
3691
+ if (!column) return {
3692
+ cards: [],
3693
+ total: 0,
3694
+ columns
3695
+ };
3696
+ return {
3697
+ ...await store.issues.listPage({
3698
+ workspaceId: input.workspaceId,
3699
+ statusIds: [column.id],
3700
+ waitingForPerson: false,
3701
+ hasActiveRun: false,
3702
+ limit: input.limit,
3703
+ offset: input.offset
3704
+ }),
3705
+ columns
3706
+ };
3707
+ }
3708
+ //#endregion
3709
+ //#region src/ops/claim.ts
3710
+ /**
3711
+ * Take a card to work on, in one board write: pick it, start its run, and move
3712
+ * it into the board's first `started` column (In Progress on a standard
3713
+ * board), its entry and flow rules checked on the way in. Anything refused
3714
+ * leaves nothing written.
3715
+ *
3716
+ * Two claimers never get the same card. On SQLite the write holds the file
3717
+ * from its first read (`begin immediate`), so the second claimer reads the
3718
+ * board after the first one's commit. On Postgres the picked card's row is
3719
+ * locked (`select … for update`): a claimer that waited on the lock finds the
3720
+ * card taken, and moves on to the next ready one. The target's row is locked
3721
+ * first, always, in the order a move into it locks (column, then card), so a
3722
+ * claim and a move never wait on each other in a circle.
3723
+ */
3724
+ async function claimCard(store, input, actor, scope) {
3725
+ return await runBoardWrite(store, async ({ tx }) => {
3726
+ const columns = await listColumns(tx, input.workspaceId);
3727
+ const target = columns.find((column) => column.category === "started");
3728
+ if (!target) throw new require_migrations_path.BoardError("issue_status_not_found", {
3729
+ workspaceId: input.workspaceId,
3730
+ category: "started"
3731
+ });
3732
+ await tx.statuses.lockById(target.id);
3733
+ const toDo = columns.find((column) => normalizeStatusName(column.name) === "to_do") ?? null;
3734
+ const card = input.issueId === void 0 ? await takeFirstReady(tx, input.workspaceId, toDo) : await takeNamed(tx, input.workspaceId, input.issueId, toDo);
3735
+ const run = await startRun(tx, card.id, input.run, actor, { tx });
3736
+ return {
3737
+ card: await updateCard(tx, card.id, { statusId: target.id }, actor, { tx }),
3738
+ run,
3739
+ column: target
3740
+ };
3741
+ }, scope);
3742
+ }
3743
+ /**
3744
+ * The first ready card, locked. A card another claimer took while this one
3745
+ * waited for its lock is passed over, and the next read no longer lists it; the
3746
+ * cards passed over are counted so the loop ends with the list.
2946
3747
  */
2947
- async function reorderCards(store, workspaceId, orderedIds, scope) {
2948
- await runBoardWrite(store, async ({ tx }) => {
2949
- for (const [index, issueId] of orderedIds.entries()) {
2950
- const card = await tx.issues.findById(issueId);
2951
- if (!card || card.workspaceId !== workspaceId) continue;
2952
- await tx.issues.update(issueId, {
2953
- order: (index + 1) * CARD_ORDER_GAP,
2954
- updatedAt: currentUnixSeconds()
2955
- });
2956
- }
2957
- }, scope);
3748
+ async function takeFirstReady(store, workspaceId, toDo) {
3749
+ const passed = /* @__PURE__ */ new Set();
3750
+ for (;;) {
3751
+ const { cards } = await queryReady(store, {
3752
+ workspaceId,
3753
+ limit: passed.size + 1
3754
+ });
3755
+ const candidate = cards.find((card) => !passed.has(card.id));
3756
+ if (!candidate) throw new require_migrations_path.BoardError("board_nothing_ready", { workspaceId });
3757
+ const locked = await store.issues.lockById(candidate.id);
3758
+ if (locked && await notReadyReason(store, locked, toDo) === null) return locked;
3759
+ passed.add(candidate.id);
3760
+ }
3761
+ }
3762
+ /** The named card, locked, when it is on this board and ready — or why not. */
3763
+ async function takeNamed(store, workspaceId, issueId, toDo) {
3764
+ const card = await store.issues.lockById(issueId);
3765
+ if (!card || card.workspaceId !== workspaceId) throw new require_migrations_path.BoardError("issue_not_found", { issueId });
3766
+ const reason = await notReadyReason(store, card, toDo);
3767
+ if (reason) throw new require_migrations_path.BoardError("board_card_not_ready", {
3768
+ issueId,
3769
+ reason
3770
+ });
3771
+ return card;
3772
+ }
3773
+ /** `queryReady`'s rule for one card already read: nobody on it, nobody waited for, in To Do. */
3774
+ async function notReadyReason(store, card, toDo) {
3775
+ if ((await store.runs.listRunning(card.id)).length > 0) return "run_active";
3776
+ if (card.waitingFor === "human") return "waiting_for_person";
3777
+ return toDo !== null && card.statusId === toDo.id ? null : "not_in_to_do";
2958
3778
  }
2959
3779
  //#endregion
2960
3780
  //#region src/ops/context-refs.ts
@@ -3087,33 +3907,6 @@ async function migrateCards(store, input) {
3087
3907
  };
3088
3908
  }
3089
3909
  //#endregion
3090
- //#region src/domain/card-display-title.ts
3091
- /**
3092
- * What a card is called wherever a person or an agent reads it by name.
3093
- *
3094
- * A card written as a description alone is titled with its own key (every
3095
- * writer does that). Named by its key, such a card says nothing — so its name
3096
- * is the first line of its description instead. A browser bundle that does not
3097
- * load this package keeps the same rule on its own, tested with the same
3098
- * examples.
3099
- */
3100
- /** How long a description line may run before it is cut with an ellipsis. */
3101
- const DESCRIPTION_LINE_LIMIT = 120;
3102
- function cardDisplayTitle(card) {
3103
- if (card.title !== card.id) return card.title;
3104
- return descriptionFirstLine(card.description) ?? card.title;
3105
- }
3106
- function descriptionFirstLine(description) {
3107
- for (const rawLine of (description ?? "").split("\n")) {
3108
- const line = stripMarkdown(rawLine);
3109
- if (line) return line.length > DESCRIPTION_LINE_LIMIT ? `${line.slice(0, 119).trimEnd()}…` : line;
3110
- }
3111
- return null;
3112
- }
3113
- function stripMarkdown(line) {
3114
- 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();
3115
- }
3116
- //#endregion
3117
3910
  //#region src/ops/agent-rules.ts
3118
3911
  /**
3119
3912
  * The sentences that tell an agent how to work this board, written once.
@@ -3141,6 +3934,7 @@ function subtaskSplitSentence(parentReference) {
3141
3934
  }
3142
3935
  const SUBTASK_RULE = `${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("the parent card's id")}`;
3143
3936
  const PULL_REQUEST_RULE = "Opened a pull request? Link it to the card. Its state and CI checks then reach the card as comments on their own — nothing else tells the board a pull request exists.";
3937
+ const CLAIM_RULE = "Taking a card from To Do yourself? Claim it instead of starting a run and moving it: the claim does both in one step, and no other agent gets the same card. Finish the run when you stop.";
3144
3938
  const OWN_SESSION_RULE = "Started the work yourself, not launched by an app that tracks the run for you? Record the run when you start and finish it when you stop. kanbo fills in the Claude Code or Codex session you are in; never invent a session id.";
3145
3939
  /** Said by the `kanbo init` block and the MCP instructions, which name the comment command or tool beside it. */
3146
3940
  const COMMENT_RULE = "Findings, results, decisions and questions go on the card as comments.";
@@ -3164,6 +3958,7 @@ const BOARD_RULES = [
3164
3958
  EXECUTION_MODE_RULE,
3165
3959
  SUBTASK_RULE,
3166
3960
  PULL_REQUEST_RULE,
3961
+ CLAIM_RULE,
3167
3962
  OWN_SESSION_RULE,
3168
3963
  "Read the column descriptions above before moving a card, and do all of this through the board tools or commands you have — a statement in your reply changes nothing."
3169
3964
  ];
@@ -3179,12 +3974,14 @@ const BOARD_RULES = [
3179
3974
  async function buildPrimeText(store, workspaceId) {
3180
3975
  const columns = await listColumns(store, workspaceId);
3181
3976
  const ruled = columns.some((column) => readEntryRules(column).length > 0);
3977
+ const flowing = columns.some((column) => hasFlowRules(column));
3182
3978
  return [
3183
3979
  ...await describeReturnedCards(store, workspaceId, columns),
3184
3980
  "Columns of this board, in board order:",
3185
3981
  "",
3186
- ...columns.length > 0 ? columns.map(describeColumnLine) : ["- (this board has no columns yet)"],
3982
+ ...columns.length > 0 ? columns.map((column) => describeColumnLine(column, columns)) : ["- (this board has no columns yet)"],
3187
3983
  ...ruled ? ["", ENTRY_RULES_NOTE] : [],
3984
+ ...flowing ? ["", FLOW_RULES_NOTE] : [],
3188
3985
  "",
3189
3986
  "How this board works:",
3190
3987
  "",
@@ -3226,11 +4023,22 @@ async function describeReturnedCards(store, workspaceId, columns) {
3226
4023
  * without any reads exactly as it did before they existed.
3227
4024
  */
3228
4025
  const ENTRY_RULES_NOTE = "A column that lists requirements refuses a card that does not meet them yet, with the list of what is missing: do that work first, then move the card. A person may still move it.";
3229
- function describeColumnLine(column) {
4026
+ /** Said under the columns only when one of them has flow rules. */
4027
+ const FLOW_RULES_NOTE = "A column that says which columns it accepts cards from, or how many cards it holds, refuses a move that breaks that and says why: move the card through a column it accepts, or wait until a card leaves. A person may still move it.";
4028
+ function hasFlowRules(column) {
4029
+ const { allowedFrom, wipLimit } = readFlowRules(column);
4030
+ return allowedFrom !== null || wipLimit !== null;
4031
+ }
4032
+ function describeColumnLine(column, columns) {
3230
4033
  const slug = normalizeStatusName(column.name);
3231
4034
  const rules = readEntryRules(column);
3232
- const requires = rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : "";
3233
- return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${requires}` : `- ${column.name} (\`${slug}\`)${requires ? ` —${requires}` : ""}`;
4035
+ const said = `${rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : ""}${describeFlow(column, columns)}`;
4036
+ return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${said}` : `- ${column.name} (\`${slug}\`)${said ? ` —${said}` : ""}`;
4037
+ }
4038
+ /** Where a column takes cards from and how many it holds, as the prime text says it; nothing for a column without flow rules. */
4039
+ function describeFlow(column, columns) {
4040
+ const { allowedFrom, wipLimit } = readFlowRules(column);
4041
+ return `${allowedFrom === null ? "" : allowedFrom.length === 0 ? " Accepts no card from another column: only a person puts a card here." : ` Accepts cards from ${joinWords(columns.filter((other) => allowedFrom.includes(other.id)).map((other) => `\`${normalizeStatusName(other.name)}\``))} only.`}${wipLimit === null ? "" : ` Holds at most ${wipLimit} ${wipLimit === 1 ? "card" : "cards"}.`}`;
3234
4042
  }
3235
4043
  //#endregion
3236
4044
  //#region src/domain/pull-request-ref.ts
@@ -3400,47 +4208,6 @@ async function queryCards(store, input) {
3400
4208
  };
3401
4209
  }
3402
4210
  //#endregion
3403
- //#region src/ops/ready.ts
3404
- /** The column an agent takes its next card from. */
3405
- const READY_COLUMN_SLUG = "to_do";
3406
- /**
3407
- * The cards an agent may pick up right now: spelled out, nobody working on them,
3408
- * nobody's turn but the agent's.
3409
- *
3410
- * "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
3411
- * run is taken, and a card waiting for a person belongs to that person until
3412
- * they answer. What is left is the queue — in board order, so the card someone
3413
- * dragged to the top is the one an agent asking for work gets.
3414
- */
3415
- async function listReady(store, input) {
3416
- return (await queryReady(store, input)).cards;
3417
- }
3418
- /**
3419
- * One page of the ready cards, how many are ready in all, and the columns read
3420
- * on the way — the rule above, asked of the database in one statement rather
3421
- * than of every card on the board.
3422
- */
3423
- async function queryReady(store, input) {
3424
- const columns = await listColumns(store, input.workspaceId);
3425
- const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
3426
- if (!column) return {
3427
- cards: [],
3428
- total: 0,
3429
- columns
3430
- };
3431
- return {
3432
- ...await store.issues.listPage({
3433
- workspaceId: input.workspaceId,
3434
- statusIds: [column.id],
3435
- waitingForPerson: false,
3436
- hasActiveRun: false,
3437
- limit: input.limit,
3438
- offset: input.offset
3439
- }),
3440
- columns
3441
- };
3442
- }
3443
- //#endregion
3444
4211
  //#region src/ops/relations.ts
3445
4212
  /**
3446
4213
  * Every edge touching the card, in either direction, each with the card on its
@@ -3730,6 +4497,8 @@ function createBoardOps(store) {
3730
4497
  updateColumn: async (statusId, patch, actor, scope) => await updateColumn(store, statusId, patch, actor, scope),
3731
4498
  describeColumn: async (workspaceId, nameOrId, description, scope) => await describeColumn(store, workspaceId, nameOrId, description, scope),
3732
4499
  setColumnEntryRules: async (workspaceId, nameOrId, entryRules, actor, scope) => await setColumnEntryRules(store, workspaceId, nameOrId, entryRules, actor, scope),
4500
+ setColumnFlowRules: async (columnId, rules, actor, scope) => await setColumnFlowRules(store, columnId, rules, actor, scope),
4501
+ applyStandardFlow: async (workspaceId, actor, scope) => await applyStandardFlow(store, workspaceId, actor, scope),
3733
4502
  enforceColumnEntryForCards: async (issueIds, statusId, actor) => await enforceColumnEntryForCards(store, issueIds, statusId, actor),
3734
4503
  deleteColumn: async (statusId, scope, actor) => await deleteColumn(store, statusId, scope, actor),
3735
4504
  reorderColumns: async (workspaceId, orderedIds, scope, actor) => await reorderColumns(store, workspaceId, orderedIds, scope, actor),
@@ -3740,6 +4509,7 @@ function createBoardOps(store) {
3740
4509
  moveColumn: async (workspaceId, nameOrId, position, actor, scope) => await moveColumn$1(store, workspaceId, nameOrId, position, actor, scope),
3741
4510
  removeColumn: async (workspaceId, nameOrId, options, actor, scope) => await removeColumn$1(store, workspaceId, nameOrId, options, actor, scope),
3742
4511
  startRun: async (issueId, input, actor, scope) => await startRun(store, issueId, input, actor, scope),
4512
+ claimCard: async (input, actor, scope) => await claimCard(store, input, actor, scope),
3743
4513
  attachRunExecution: async (runId, input, scope) => await attachRunExecution(store, runId, input, scope),
3744
4514
  recordRunSessionRef: async (runId, ref, scope) => await recordRunSessionRef(store, runId, ref, scope),
3745
4515
  clearRunSessionRef: async (runId, actor, options, scope) => await clearRunSessionRef(store, runId, actor, options, scope),
@@ -3752,7 +4522,10 @@ function createBoardOps(store) {
3752
4522
  readBoardProjectionForIssue: async (issueId) => await readBoardProjectionForIssue(store, issueId),
3753
4523
  listReady: async (input) => await listReady(store, input),
3754
4524
  queryReady: async (input) => await queryReady(store, input),
3755
- buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId)
4525
+ listColumnEntries: async (input) => await listColumnEntries(store, input),
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)
3756
4529
  };
3757
4530
  }
3758
4531
  //#endregion
@@ -4457,9 +5230,10 @@ const SCHEMA_NOT_INSTALLED_MESSAGE = "This Postgres database has no kanbo board
4457
5230
  * caller's own risk but never written to — a write shaped by this build's idea
4458
5231
  * of the schema is how a person's board gets a column full of nothing. A file
4459
5232
  * of this package's own is this package's to migrate, so the same refusal
4460
- * points at `kanbo migrate` instead of at the app. Reads stay open either way,
4461
- * because a person who cannot migrate right now should still be able to look at
4462
- * their cards.
5233
+ * points at `kanbo migrate` instead of at the app. Reads are held to the lesser
5234
+ * bar of `assertReadableBoard`: a file only a stamp behind is still read, so a
5235
+ * person who cannot migrate right now can look at their cards, but one missing
5236
+ * a table or column this build reads is refused with the same sentence.
4463
5237
  */
4464
5238
  function assertWritableBoard(database, owner) {
4465
5239
  try {
@@ -4470,6 +5244,26 @@ function assertWritableBoard(database, owner) {
4470
5244
  }
4471
5245
  }
4472
5246
  /**
5247
+ * Refuse a read of a board file that lacks a table or a column this build
5248
+ * reads — a file a migration behind — with the sentence a write gets, instead
5249
+ * of letting the first statement fail on what is missing. A file whose only
5250
+ * difference is its schema stamp is still read: every statement this build
5251
+ * makes works on it, and a person who cannot migrate now can still look at
5252
+ * their cards.
5253
+ */
5254
+ function assertReadableBoard(database, owner) {
5255
+ try {
5256
+ require_migrate.assertBoardSchema(database);
5257
+ } catch (error) {
5258
+ if (!(error instanceof require_migrations_path.BoardError && error.code === "board_schema_outdated")) throw error;
5259
+ if (lacksWhatThisBuildReads(error.details)) throw new CliError(3, owner === "kanbo" ? FILE_SCHEMA_OUTDATED_MESSAGE : SCHEMA_OUTDATED_MESSAGE);
5260
+ }
5261
+ }
5262
+ /** Whether `assertBoardSchema` found a table or a column missing, rather than only another stamp. */
5263
+ function lacksWhatThisBuildReads(details) {
5264
+ return details !== void 0 && ("missingTables" in details || "missingAddedTables" in details || "missingColumns" in details);
5265
+ }
5266
+ /**
4473
5267
  * Refuse an external board this build cannot speak for — before reading it as
4474
5268
  * well as before writing to it.
4475
5269
  *
@@ -4708,6 +5502,7 @@ async function openFileSession(target, options, mode, cwd) {
4708
5502
  assertWritableBoard(board.database, target.owner);
4709
5503
  };
4710
5504
  if (mode === "write") assertWritableBoard(board.database, target.owner);
5505
+ else assertReadableBoard(board.database, target.owner);
4711
5506
  const workspace = resolveWorkspace(target.owner === "kanbo" ? { kind: "file" } : {
4712
5507
  kind: "sqlite",
4713
5508
  database: board.database
@@ -4731,8 +5526,9 @@ async function openFileSession(target, options, mode, cwd) {
4731
5526
  /**
4732
5527
  * The same, on a board that is a database of its own.
4733
5528
  *
4734
- * The guard runs whatever the command came to do. A board file that this build
4735
- * cannot write to is still a board worth reading; a connection string pointing
5529
+ * The guard runs whatever the command came to do. A board file whose only
5530
+ * difference from this build is its schema stamp is still a board worth
5531
+ * reading; a connection string pointing
4736
5532
  * at a database with no board in it is nothing to read at all, and every
4737
5533
  * command would otherwise fail one statement later with the driver's own words.
4738
5534
  */
@@ -4789,6 +5585,17 @@ function parseCount(value) {
4789
5585
  return count;
4790
5586
  }
4791
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
+ /**
4792
5599
  * The card a caller named, in the workspace the command is about.
4793
5600
  *
4794
5601
  * People and agents refer to a card by whatever they have in front of them: the
@@ -5030,7 +5837,9 @@ function projectCardActiveRun(run) {
5030
5837
  startedAt: run.startedAt
5031
5838
  };
5032
5839
  }
5033
- function projectColumn(column) {
5840
+ /** A column as the command line prints it; `board` is every column, to name the ones it accepts cards from. */
5841
+ function projectColumn(column, board) {
5842
+ const { allowedFrom, wipLimit } = readFlowRules(column);
5034
5843
  return {
5035
5844
  id: column.id,
5036
5845
  name: column.name,
@@ -5038,7 +5847,9 @@ function projectColumn(column) {
5038
5847
  description: column.description,
5039
5848
  category: column.category,
5040
5849
  order: column.order,
5041
- entryRules: readEntryRules(column)
5850
+ entryRules: readEntryRules(column),
5851
+ allowedFrom: allowedFrom === null ? null : board.filter((other) => allowedFrom.includes(other.id)).map((other) => normalizeStatusName(other.name)),
5852
+ wipLimit
5042
5853
  };
5043
5854
  }
5044
5855
  function projectSprint(sprint) {
@@ -5110,7 +5921,9 @@ function describeColumns(columns) {
5110
5921
  return columns.map((column) => [
5111
5922
  `${padToWidth(sanitizeTerminalText(column.slug), slugWidth)} ${sanitizeTerminalText(column.name)}`,
5112
5923
  column.description ? ` — ${sanitizeTerminalText(column.description)}` : "",
5113
- column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : ""
5924
+ column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : "",
5925
+ column.allowedFrom === null ? "" : ` [from: ${column.allowedFrom.length > 0 ? column.allowedFrom.join(", ") : "no column"}]`,
5926
+ column.wipLimit === null ? "" : ` [wip: ${column.wipLimit}]`
5114
5927
  ].join("")).join("\n");
5115
5928
  }
5116
5929
  /** A unix-seconds date as the UTC day it falls on, `—` for none. */
@@ -5432,6 +6245,52 @@ function registerCapabilitiesCommand(program) {
5432
6245
  });
5433
6246
  }
5434
6247
  //#endregion
6248
+ //#region src/cli/commands/claim.ts
6249
+ /**
6250
+ * `kanbo claim [card]` — take a card to work on, in one step.
6251
+ *
6252
+ * The run starts and the card moves out of To Do into the board's first
6253
+ * work-in-progress column in one write, so two agents in two terminals never
6254
+ * take the same card. Filed as a placement: in a person's own terminal the
6255
+ * column's rules warn, in an agent's shell they refuse.
6256
+ */
6257
+ function registerClaimCommand(program) {
6258
+ withBoardOptions(program.command("claim").description("Take a card to work on: start your run and move it out of To Do, in one step").argument("[card]", "the card, as MAN-012, MAN-12 or 12 (default: the first ready card)").option("--agent <name>", "your name, e.g. claude (default: the name this shell writes as)").option("--session <ref>", "your session, to find it later: claude:<session id> or codex:<session id> (default: the one Claude Code or Codex names in the environment)")).action(async (reference, options) => {
6259
+ await runBoardCommand(options, "write", async (session) => {
6260
+ const actor = createPlacementActor();
6261
+ const named = reference === void 0 ? void 0 : (await requireCard(session, reference)).id;
6262
+ const claimed = await session.ops.claimCard({
6263
+ workspaceId: session.workspace.id,
6264
+ issueId: named,
6265
+ run: {
6266
+ agentName: options.agent ?? actor.id ?? "agent",
6267
+ externalSessionRef: options.session ?? sessionRefFromEnvironment() ?? void 0,
6268
+ launchedByKind: "external"
6269
+ }
6270
+ }, actor);
6271
+ const [columns, projection, runs] = await Promise.all([
6272
+ session.ops.listColumns(session.workspace.id),
6273
+ session.ops.readBoardProjectionForIssue(claimed.card.id),
6274
+ session.ops.listRuns(claimed.card.id)
6275
+ ]);
6276
+ const card = projectCard(claimed.card, columns, projection);
6277
+ for (const item of claimed.card.unmetRules ?? []) console.error(`kanbo: warning: ${card.id} entered "${claimed.column.name}" without ${item.rule}: ${item.detail}`);
6278
+ const run = projectRun({
6279
+ ...claimed.run,
6280
+ attempt: runs.find((each) => each.id === claimed.run.id)?.attempt ?? runs.length
6281
+ });
6282
+ return {
6283
+ value: {
6284
+ card,
6285
+ run,
6286
+ column: projectColumn(claimed.column, columns)
6287
+ },
6288
+ text: `Claimed ${card.id}: run ${run.id} started, card moved to ${claimed.column.name}\nWhen you are done: kanbo run finish ${run.id} --state finished`
6289
+ };
6290
+ });
6291
+ });
6292
+ }
6293
+ //#endregion
5435
6294
  //#region src/mcp/transport.ts
5436
6295
  /** What `kanbo_card_get` may bring along with the card. */
5437
6296
  const KANBO_CARD_INCLUDES = [
@@ -5548,16 +6407,22 @@ function projectKanboSprint(sprint) {
5548
6407
  doneCards: sprint.cards.done
5549
6408
  };
5550
6409
  }
5551
- /** A column of the board, from the row either transport holds. */
5552
- function projectKanboColumn(column) {
6410
+ /** A column of the board, from the row either transport holds; `board` is every column, to name the ones it accepts cards from. */
6411
+ function projectKanboColumn(column, board) {
5553
6412
  const stored = column.entryRules;
6413
+ const allowedIds = typeof column.allowedFrom === "string" ? readFlowRules({
6414
+ allowedFrom: column.allowedFrom,
6415
+ wipLimit: null
6416
+ }).allowedFrom : column.allowedFrom ?? null;
5554
6417
  return {
5555
6418
  id: column.id,
5556
6419
  name: column.name,
5557
6420
  slug: normalizeStatusName(column.name),
5558
6421
  description: column.description,
5559
6422
  order: column.order,
5560
- entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null })
6423
+ entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null }),
6424
+ allowedFrom: allowedIds === null ? null : board.filter((other) => allowedIds.includes(other.id)).map((other) => normalizeStatusName(other.name)),
6425
+ wipLimit: column.wipLimit ?? null
5561
6426
  };
5562
6427
  }
5563
6428
  /**
@@ -5987,10 +6852,11 @@ const COLUMN_CATEGORIES = [
5987
6852
  ];
5988
6853
  /** The whole board's columns after a change, and the sentence that says what changed. */
5989
6854
  async function changed(session, message, extra = {}) {
5990
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
6855
+ const board = await session.ops.listColumns(session.workspace.id);
6856
+ const views = board.map((column) => projectColumn(column, board));
5991
6857
  return {
5992
6858
  value: {
5993
- ...extra,
6859
+ ...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, typeof value === "number" || Array.isArray(value) ? value : projectColumn(value, board)])),
5994
6860
  columns: views
5995
6861
  },
5996
6862
  text: `${message}\n\n${describeColumns(views)}`
@@ -6019,14 +6885,14 @@ async function addColumn(session, request, actor) {
6019
6885
  category: request.category ?? spec.category,
6020
6886
  position: anchor ? request.after !== void 0 ? { after: anchor.id } : { before: anchor.id } : void 0
6021
6887
  }, actor);
6022
- return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: projectColumn(created) });
6888
+ return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: created });
6023
6889
  }
6024
6890
  /** Said after a rename: agents' instructions name no column but To Do, and `kanbo prime` gives them the rest. */
6025
6891
  const RENAME_NOTE = "Agents read column names from kanbo prime; rename is picked up there.";
6026
6892
  async function renameColumn(session, column, newName, actor) {
6027
6893
  const before = await requireNamedColumn(session, column);
6028
6894
  const renamed = await session.ops.renameColumn(session.workspace.id, before.id, newName, actor);
6029
- return await changed(session, before.name === renamed.name ? `${renamed.name} already has that name.` : `Renamed ${before.name} to ${renamed.name} (slug ${normalizeStatusName(renamed.name)}).\n${RENAME_NOTE}`, { renamed: projectColumn(renamed) });
6895
+ return await changed(session, before.name === renamed.name ? `${renamed.name} already has that name.` : `Renamed ${before.name} to ${renamed.name} (slug ${normalizeStatusName(renamed.name)}).\n${RENAME_NOTE}`, { renamed });
6030
6896
  }
6031
6897
  /** `--first`, `--last`, `--before <column>` or `--after <column>` — exactly one. */
6032
6898
  function readColumnPosition(options) {
@@ -6058,7 +6924,7 @@ async function removeColumn(session, column, moveCardsTo, actor) {
6058
6924
  const result = await session.ops.removeColumn(session.workspace.id, removing.id, { moveCardsTo }, actor);
6059
6925
  for (const card of result.unmetRules ?? []) for (const item of card.unmet) console.error(`kanbo: warning: ${card.issueId} entered "${result.movedTo?.name ?? "—"}" without ${item.rule}: ${item.detail}`);
6060
6926
  return await changed(session, result.movedTo ? `Removed ${result.removed.name} and moved ${describeCount$1(result.movedCards)} to ${result.movedTo.name}.` : `Removed ${result.removed.name}.`, {
6061
- removed: projectColumn(result.removed),
6927
+ removed: result.removed,
6062
6928
  movedCards: result.movedCards
6063
6929
  });
6064
6930
  }
@@ -6102,6 +6968,14 @@ const BOARD_ERROR_TEXTS = {
6102
6968
  text: "Only a person can approve a card.",
6103
6969
  next: AS_A_PERSON
6104
6970
  }),
6971
+ board_card_not_ready: (details) => ({
6972
+ text: details.reason === "run_active" ? `${named(details.issueId)} is taken: it has a run going.` : details.reason === "waiting_for_person" ? `${named(details.issueId)} is waiting for a person.` : `${named(details.issueId)} is not in To Do, so it is not there to take.`,
6973
+ next: "kanbo ready"
6974
+ }),
6975
+ board_card_run_active: (details) => ({
6976
+ text: `${named(details.issueId)} already has a run going: ${named(details.agentName)}'s, run ${named(details.runId)}.`,
6977
+ next: `kanbo run finish ${named(details.runId)} --state stopped`
6978
+ }),
6105
6979
  board_column_name_taken: (details) => ({
6106
6980
  text: `Another column, ${named(details.takenBy)}, already has that name (same slug). Pick another name.`,
6107
6981
  next: "kanbo columns list"
@@ -6132,6 +7006,15 @@ const BOARD_ERROR_TEXTS = {
6132
7006
  }),
6133
7007
  board_entry_rule_invalid: (details) => ({ text: `Unknown column rule: ${list(details.rules)}. Use any of ${list(details.allowed)}.` }),
6134
7008
  board_execution_mode_locked: (details) => ({ text: `${named(details.issueId)} has been started, so where it is worked (${named(details.executionMode)}) can't change now.` }),
7009
+ board_flow_rule_invalid: (details) => details.wipLimit !== void 0 ? { text: `A column holds at least 1 card; ${named(details.wipLimit)} is not a limit. Use none for no limit.` } : {
7010
+ text: `A column accepts cards only from other columns of its own board, and ${list(details.allowedFrom)} is not one.`,
7011
+ next: "kanbo columns list"
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." },
7014
+ board_nothing_ready: () => ({
7015
+ text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
7016
+ next: "kanbo ready"
7017
+ }),
6135
7018
  board_pull_request_invalid: (details) => ({ text: `"${named(details.value)}" is not a GitHub pull request. Give its link or owner/repo#number.` }),
6136
7019
  board_pull_request_not_found: (details) => ({
6137
7020
  text: `${named(details.issueId)} has no pull request link "${named(details.linkId)}".`,
@@ -6197,7 +7080,7 @@ const BOARD_ERROR_TEXTS = {
6197
7080
  issue_relation_self_reference: () => ({ text: "A card can't be linked to itself." }),
6198
7081
  issue_status_name_empty: () => ({ text: "Give the column a name." }),
6199
7082
  issue_status_not_found: (details) => ({
6200
- text: `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
7083
+ text: details.category !== void 0 ? `This board has no ${named(details.category)} column for a claimed card to go to.` : `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
6201
7084
  next: "kanbo columns list"
6202
7085
  }),
6203
7086
  issue_status_reference_conflict: () => ({ text: "Name the column once: by its id or by its name, not both." }),
@@ -6401,7 +7284,7 @@ async function runColumnsMenu(ui) {
6401
7284
  if (showList) say$1(ui, [
6402
7285
  "",
6403
7286
  "Columns of this board:",
6404
- describeColumns(columns.map(projectColumn))
7287
+ describeColumns(columns.map((column) => projectColumn(column, columns)))
6405
7288
  ].join("\n"));
6406
7289
  let choice;
6407
7290
  try {
@@ -6629,7 +7512,8 @@ function registerColumnsCommands(program) {
6629
7512
  const columns = program.command("columns").description("See and change the board's columns (alone: a menu)");
6630
7513
  withBoardOptions(columns.command("list").description("List the columns in order, with the short name to move cards by")).action(async (options) => {
6631
7514
  await runBoardCommand(options, "read", async (session) => {
6632
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
7515
+ const board = await session.ops.listColumns(session.workspace.id);
7516
+ const views = board.map((column) => projectColumn(column, board));
6633
7517
  return {
6634
7518
  value: views,
6635
7519
  text: describeColumns(views)
@@ -6638,7 +7522,7 @@ function registerColumnsCommands(program) {
6638
7522
  });
6639
7523
  withBoardOptions(columns.command("describe").description("Say in one line when a card belongs in a column").argument("<column>", "the column, by slug, name or id").requiredOption("--text <text>", "what the column means")).action(async (column, options) => {
6640
7524
  await runBoardCommand(options, "write", async (session) => {
6641
- const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text));
7525
+ const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text), await session.ops.listColumns(session.workspace.id));
6642
7526
  return {
6643
7527
  value: view,
6644
7528
  text: describeColumns([view])
@@ -6649,13 +7533,34 @@ function registerColumnsCommands(program) {
6649
7533
  const actor = requireHumanActor("columns rules", typedWords(command));
6650
7534
  const rules = readRequestedRules(options);
6651
7535
  await runBoardCommand(options, "write", async (session) => {
6652
- const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor));
7536
+ const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor), await session.ops.listColumns(session.workspace.id));
6653
7537
  return {
6654
7538
  value: view,
6655
7539
  text: describeColumns([view])
6656
7540
  };
6657
7541
  });
6658
7542
  });
7543
+ withBoardOptions(columns.command("flow").description("Set which columns a card may come from, and how many cards a column holds").argument("<column>", "the column, by slug, name or id").option("--from <columns>", "comma-separated columns a card may enter it from, or any").option("--wip <count>", "the most cards it holds, or none")).action(async (column, options, command) => {
7544
+ const actor = requireHumanActor("columns flow", typedWords(command));
7545
+ if (options.from === void 0 && options.wip === void 0) throw new CliError(1, "Pass --from <columns|any>, --wip <count|none>, or both.");
7546
+ const wipLimit = options.wip === void 0 ? void 0 : readWipLimit(options.wip);
7547
+ await runBoardCommand(options, "write", async (session) => {
7548
+ const workspaceId = session.workspace.id;
7549
+ const board = await session.ops.listColumns(workspaceId);
7550
+ const target = await session.ops.requireColumn(workspaceId, column);
7551
+ const current = readFlowRules(target);
7552
+ await session.ops.setColumnFlowRules(target.id, {
7553
+ allowedFrom: options.from === void 0 ? current.allowedFrom : readAllowedFrom(board, workspaceId, options.from),
7554
+ wipLimit: wipLimit === void 0 ? current.wipLimit : wipLimit
7555
+ }, actor);
7556
+ const after = await session.ops.listColumns(workspaceId);
7557
+ const views = after.map((each) => projectColumn(each, after));
7558
+ return {
7559
+ value: views.find((view) => view.id === target.id),
7560
+ text: describeColumns(views)
7561
+ };
7562
+ });
7563
+ });
6659
7564
  withBoardOptions(columns.command("add").description("Add a column (before Done unless you say where)").argument("<name>", "what the column is called").option("--after <column>", "put it right after this column").option("--before <column>", "put it right before this column").option("--description <text>", "when a card belongs in it, in one line agents read").addOption(new commander.Option("--category <category>", "what kind of column it is").choices(COLUMN_CATEGORIES))).action(async (name, options, command) => {
6660
7565
  const actor = requireHumanActor("columns add", typedWords(command));
6661
7566
  await runBoardCommand(options, "write", async (session) => await addColumn(session, {
@@ -6697,7 +7602,7 @@ function registerColumnsCommands(program) {
6697
7602
  mode: "add-missing",
6698
7603
  actor
6699
7604
  });
6700
- const views = statuses.map(projectColumn);
7605
+ const views = statuses.map((column) => projectColumn(column, statuses));
6701
7606
  return {
6702
7607
  value: {
6703
7608
  added,
@@ -6721,7 +7626,8 @@ function registerColumnsCommands(program) {
6721
7626
  return;
6722
7627
  }
6723
7628
  await runBoardCommand({}, "read", async (session) => {
6724
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
7629
+ const board = await session.ops.listColumns(session.workspace.id);
7630
+ const views = board.map((column) => projectColumn(column, board));
6725
7631
  return {
6726
7632
  value: views,
6727
7633
  text: describeColumns(views)
@@ -6751,6 +7657,26 @@ function readRequestedRules(options) {
6751
7657
  if (options.require === void 0) throw new CliError(1, `Pass --require ${ENTRY_RULES.join(",")} (any of them) or --clear.`);
6752
7658
  return options.require.split(",").map((rule) => rule.trim()).filter(Boolean);
6753
7659
  }
7660
+ /** `--from`: `any` for every column, else the columns named, by slug, name or id. */
7661
+ function readAllowedFrom(board, workspaceId, value) {
7662
+ if (value.trim().toLowerCase() === "any") return null;
7663
+ const names = value.split(",").map((name) => name.trim()).filter(Boolean);
7664
+ if (names.length === 0) throw new CliError(1, "Name the columns a card may come from (--from in_review,qa), or --from any.");
7665
+ return names.map((name) => {
7666
+ const found = matchColumn(board, workspaceId, name);
7667
+ if (!found) throw new require_migrations_path.BoardError("issue_status_not_found", {
7668
+ workspaceId,
7669
+ statusName: name
7670
+ });
7671
+ return found.id;
7672
+ });
7673
+ }
7674
+ /** `--wip`: `none` for no limit, else a whole number of cards, 1 or more. */
7675
+ function readWipLimit(value) {
7676
+ if (value.trim().toLowerCase() === "none") return null;
7677
+ if (!/^\d+$/.test(value.trim()) || Number(value) < 1) throw new CliError(1, "--wip takes a whole number of cards, 1 or more, or none.");
7678
+ return Number(value);
7679
+ }
6754
7680
  //#endregion
6755
7681
  //#region src/package-version.ts
6756
7682
  (0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
@@ -6760,7 +7686,7 @@ function readRequestedRules(options) {
6760
7686
  * `package.version`. A build carries it as a literal; running from source
6761
7687
  * (tests, `tsx`) reads the package's own `package.json` instead.
6762
7688
  */
6763
- const KANBO_PACKAGE_VERSION = "0.4.0";
7689
+ const KANBO_PACKAGE_VERSION = "0.6.0";
6764
7690
  //#endregion
6765
7691
  //#region src/cli/setup/paths.ts
6766
7692
  /**
@@ -7109,7 +8035,7 @@ const INSTRUCTION_END_PATTERN = /<!-- KANBO_END\s*-->/g;
7109
8035
  const POINTER_KINDS = /* @__PURE__ */ new Set(["short", "global"]);
7110
8036
  /** The commands an agent uses, spelled once for the guide and the `kanbo prime` cheat sheet. */
7111
8037
  const COMMAND = {
7112
- ready: "kanbo ready",
8038
+ claim: "kanbo claim",
7113
8039
  move: "kanbo card move <id> <column>",
7114
8040
  statusLine: "kanbo card status-line <id> --text \"...\"",
7115
8041
  subtask: "kanbo card create --description \"...\" --parent <id>",
@@ -7128,7 +8054,7 @@ const AGENT_GUIDE_TEXT = [
7128
8054
  "Work on this project is tracked on a kanbo board. Before starting a task run `kanbo prime` — it prints the board's columns and what each one means.",
7129
8055
  "",
7130
8056
  ...[
7131
- `- Take a card from \`${COMMAND.ready}\`.`,
8057
+ `- Take a card with \`${COMMAND.claim}\`: it starts your run and moves the card into work in one step, and no other agent gets the same card.`,
7132
8058
  `- ${MOVE_CARD_RULE} (\`${COMMAND.move}\`)`,
7133
8059
  `- ${STATUS_LINE_RULE} (\`${COMMAND.statusLine}\`)`,
7134
8060
  `- ${SUBTASK_RULE} (\`${COMMAND.subtask}\`)`,
@@ -7158,12 +8084,13 @@ const COMMAND_SHEET = [
7158
8084
  "- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
7159
8085
  "- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
7160
8086
  "- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
7161
- "- `kanbo run start TST-5 --agent claude` — record a run you started yourself; it prints the finish command",
8087
+ "- `kanbo claim` — take the first ready card: starts your run and moves it into work in one step (`kanbo claim TST-5` for that card); it prints the finish command",
8088
+ "- `kanbo run start TST-5 --agent claude` — record a run on a card you did not claim; it prints the finish command",
7162
8089
  "- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
7163
8090
  `- \`${COMMAND.capabilities}\` — everything else, rarely needed`
7164
8091
  ].join("\n");
7165
8092
  /** How to take work and who approves it — said by both blocks after their first sentence. */
7166
- const POINTER_TAIL = `Take work with \`${COMMAND.ready}\`. ${PERSON_ONLY_RULE}`;
8093
+ const POINTER_TAIL = `Take work with \`${COMMAND.claim}\`. ${PERSON_ONLY_RULE}`;
7167
8094
  /** The body of the project block: what `kanbo instructions short` prints. */
7168
8095
  const PROJECT_BODY = [
7169
8096
  "## Kanbo board",
@@ -7183,7 +8110,7 @@ const GLOBAL_BODY = [
7183
8110
  * The block, markers included, with this body's version and hash on the start
7184
8111
  * marker — and, when given, the kind of text it is (`k=orchestrator`).
7185
8112
  */
7186
- function wrapInstructionBlock(body, version = 2, kind) {
8113
+ function wrapInstructionBlock(body, version = 3, kind) {
7187
8114
  return `<!-- KANBO_START v${version}${kind ? ` k=${kind}` : ""} h=${hashBody(body)} -->\n${body}\n${INSTRUCTION_END}`;
7188
8115
  }
7189
8116
  /** The block `kanbo init --instructions …` writes into a project's own file. */
@@ -7212,7 +8139,7 @@ function inspectBlock(text, current) {
7212
8139
  kind
7213
8140
  });
7214
8141
  if (fields.trim() === "") return inspection(LEGACY_BLOCK_BODIES.includes(body) ? "legacy" : "edited");
7215
- if (version !== void 0 && Number(version) > 2) return inspection("newer");
8142
+ if (version !== void 0 && Number(version) > 3) return inspection("newer");
7216
8143
  if (kind !== null && !POINTER_KINDS.has(kind)) return inspection("chosen");
7217
8144
  if (version === void 0 || hash === void 0 || hashBody(body) !== hash) return inspection("edited");
7218
8145
  return inspection(normalized.slice(found.start, found.end) === current ? "current" : "outdated");
@@ -8242,7 +9169,7 @@ function startUpdateCheck(deps = {}) {
8242
9169
  setTimeout: globalThis.setTimeout,
8243
9170
  clearTimeout: globalThis.clearTimeout
8244
9171
  };
8245
- const current = deps.currentVersion ?? "0.4.0";
9172
+ const current = deps.currentVersion ?? "0.6.0";
8246
9173
  const controller = new AbortController();
8247
9174
  let found = null;
8248
9175
  const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
@@ -10674,7 +11601,7 @@ const KANBO_MCP_INSTRUCTIONS = [
10674
11601
  "kanbo is a kanban board shared by people and agents; these tools read and change its cards.",
10675
11602
  "",
10676
11603
  `- At the start of a session call ${tool("kanbo_prime")} — it returns this board's columns and what each one means.`,
10677
- `- Take work from ${tool("kanbo_ready")}.`,
11604
+ `- Take work from ${tool("kanbo_ready")}, and claim the card with ${tool("kanbo_card_claim")} before you start: it starts your run and moves the card into work in one step, so no other agent takes it.`,
10678
11605
  `- ${MOVE_CARD_RULE} (${tool("kanbo_card_move")})`,
10679
11606
  `- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
10680
11607
  `- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
@@ -10765,7 +11692,7 @@ const ORCHESTRATOR_GUIDE = [
10765
11692
  "## Start of every session",
10766
11693
  "1. Run `kanbo prime` (tool: `kanbo_prime`) and read the column descriptions. On this board the description is the contract, not the name.",
10767
11694
  "2. Run `kanbo ready` (`kanbo_ready`). If nothing is ready, say so and stop.",
10768
- "3. Take the first card. Run `kanbo run start <card> --agent orchestrator` (`kanbo_run_start`) and move it to the column this board uses for work in progress — `kanbo prime` lists the columns and what each is for — (`kanbo_card_move`) before doing anything else.",
11695
+ "3. Take the first card with `kanbo claim --agent orchestrator` (`kanbo_card_claim`): it starts your run and moves the card out of To Do into the board's first work-in-progress column in one step, so no other agent takes it, and it prints the run id you finish later. If another agent got there first, run `kanbo ready` again.",
10769
11696
  "",
10770
11697
  "## While working",
10771
11698
  "- Write a status line (`kanbo card status-line <card> --text \"…\"`, `kanbo_status_line`) at every step and before and after anything that takes more than a minute. One sentence, present tense.",
@@ -10792,7 +11719,7 @@ const ORCHESTRATOR_GUIDE = [
10792
11719
  "- Returned: the reason is the newest comment. The card is back in an earlier column; start a new run and address the reason first.",
10793
11720
  "",
10794
11721
  "## Never",
10795
- "- Never run `kanbo approve`, `kanbo return`, `kanbo sprint close`, `kanbo run clear-session`, or anything that changes the columns: `kanbo columns add`, `kanbo columns rename`, `kanbo columns move`, `kanbo columns remove`, `kanbo columns template`, `kanbo columns add-standard`, `kanbo columns rules`. They are for a person and will fail in your shell anyway.",
11722
+ "- Never run `kanbo approve`, `kanbo return`, `kanbo sprint close`, `kanbo run clear-session`, or anything that changes the columns: `kanbo columns add`, `kanbo columns rename`, `kanbo columns move`, `kanbo columns remove`, `kanbo columns template`, `kanbo columns add-standard`, `kanbo columns rules`, `kanbo columns flow`. They are for a person and will fail in your shell anyway.",
10796
11723
  "- Never delete or edit the board's database directly."
10797
11724
  ].join("\n");
10798
11725
  //#endregion
@@ -10837,7 +11764,7 @@ function registerInstructionsCommand(program, dependencies = {}) {
10837
11764
  const found = parseKind(kind);
10838
11765
  const body = await readInstructionText(found);
10839
11766
  const pointer = found === "short" || found === "global";
10840
- const text = options.markers ? wrapInstructionBlock(body, 2, pointer ? void 0 : found) : body;
11767
+ const text = options.markers ? wrapInstructionBlock(body, 3, pointer ? void 0 : found) : body;
10841
11768
  const hints = process.stderr.isTTY === true;
10842
11769
  if (hints) process.stderr.write(`${HEADER}\n\n`);
10843
11770
  console.log(text);
@@ -11244,7 +12171,7 @@ function readMoment(value) {
11244
12171
  const cardMoveTool = defineKanboTool({
11245
12172
  name: "kanbo_card_move",
11246
12173
  title: "Move a card",
11247
- description: `Move a card to another column the moment its real state changes. Read kanbo_columns first: a card in the wrong column is a lie, and a column with entryRules refuses a card that does not meet them yet, listing what is missing. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
12174
+ description: `Move a card to another column the moment its real state changes. Read kanbo_columns first: a card in the wrong column is a lie, and a column refuses a card that does not meet its entryRules yet, that comes from a column it does not accept (allowedFrom), or that would overfill it (wipLimit), listing what is missing. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
11248
12175
  inputSchema: {
11249
12176
  card: cardArgument,
11250
12177
  column: zod.z.string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`."),
@@ -11316,7 +12243,7 @@ const cardUpdateTool = defineKanboTool({
11316
12243
  const columnsTool = defineKanboTool({
11317
12244
  name: "kanbo_columns",
11318
12245
  title: "Columns of the board",
11319
- description: "List this board's columns in board order, each with the slug kanbo_card_move takes, what the column means here, and the entryRules a card must meet before you move it in.",
12246
+ description: "List this board's columns in board order, each with the slug kanbo_card_move takes, what the column means here, the entryRules a card must meet before you move it in, the columns it accepts cards from (allowedFrom, slugs; null for any) and the most cards it holds (wipLimit).",
11320
12247
  inputSchema: {},
11321
12248
  run: async (transport) => await transport.columns()
11322
12249
  });
@@ -11330,6 +12257,38 @@ const primeTool = defineKanboTool({
11330
12257
  inputSchema: {},
11331
12258
  run: async (transport) => (await transport.prime()).text
11332
12259
  });
12260
+ //#endregion
12261
+ //#region src/mcp/tools/card-claim.ts
12262
+ /**
12263
+ * Taking a card to work on, in one step: the run starts and the card moves out
12264
+ * of To Do into the board's first work-in-progress column in the same write,
12265
+ * so two agents never get the same card. Published to every host, including
12266
+ * one that records the runs of the agents it launches: a claim is how an agent
12267
+ * in any terminal takes work.
12268
+ */
12269
+ const cardClaimTool = defineKanboTool({
12270
+ name: "kanbo_card_claim",
12271
+ title: "Claim a card",
12272
+ description: "Take a card to work on, in one step: it starts your run and moves the card from To Do into the board's first work-in-progress column. Without `card`, takes the first ready card. Two agents never get the same card: the one that loses is told board_nothing_ready or board_card_not_ready. Answers the card compact, the run id, the column, and the kanbo_run_finish call that ends the run.",
12273
+ inputSchema: {
12274
+ card: cardArgument.optional(),
12275
+ agent: zod.z.string().min(1).optional().describe("What to call whoever works the card; who this server writes as when absent.")
12276
+ },
12277
+ aliases: {
12278
+ card: [...CARD_ALIASES, "issue"],
12279
+ agent: ["agentName"]
12280
+ },
12281
+ example: { card: "TST-5" },
12282
+ run: async (transport, input) => {
12283
+ const claimed = await transport.cardClaim(input);
12284
+ return {
12285
+ card: compactKanboCard(claimed.card),
12286
+ run: claimed.run.id,
12287
+ column: claimed.column.slug,
12288
+ finishWith: `kanbo_run_finish {"run":"${claimed.run.id}","state":"finished"}`
12289
+ };
12290
+ }
12291
+ });
11333
12292
  /** The cards an agent may pick up right now. */
11334
12293
  const readyTool = defineKanboTool({
11335
12294
  name: "kanbo_ready",
@@ -11418,13 +12377,14 @@ const runStartTool = defineKanboTool({
11418
12377
  /**
11419
12378
  * Every tool the board has, in the order it declares them.
11420
12379
  *
11421
- * The list is the board's, not a server's: the same sixteen are registered
12380
+ * The list is the board's, not a server's: the same seventeen are registered
11422
12381
  * whether they run against a server or against the board directly, and the
11423
12382
  * only thing a host decides is which of them it publishes.
11424
12383
  */
11425
12384
  const KANBO_TOOLS = [
11426
12385
  primeTool,
11427
12386
  readyTool,
12387
+ cardClaimTool,
11428
12388
  columnsTool,
11429
12389
  defineKanboTool({
11430
12390
  name: "kanbo_sprints",
@@ -11556,7 +12516,8 @@ async function createDbTransport(input) {
11556
12516
  const workspaceId = workspace.id;
11557
12517
  const actor = input.actor;
11558
12518
  async function readColumns() {
11559
- return (await ops.listColumns(workspaceId)).map(projectKanboColumn);
12519
+ const rows = await ops.listColumns(workspaceId);
12520
+ return rows.map((row) => projectKanboColumn(row, rows));
11560
12521
  }
11561
12522
  /** One card with what the run registry says about it, in the shape a tool prints. */
11562
12523
  async function project(card, columns) {
@@ -11606,7 +12567,7 @@ async function createDbTransport(input) {
11606
12567
  * the waiting and returned ones last said at most two more.
11607
12568
  */
11608
12569
  async function projectPage(page, { attend = true } = {}) {
11609
- const columns = page.columns.map(projectKanboColumn);
12570
+ const columns = page.columns.map((column) => projectKanboColumn(column, page.columns));
11610
12571
  const runs = await ops.readBoardProjectionForIssues(page.cards.map((card) => card.id));
11611
12572
  const attention = attend ? await ops.readCardAttention(page.cards, runs.map((run) => run.activeRun !== null)) : [];
11612
12573
  return page.cards.map((card, index) => projectKanboCard({
@@ -11738,6 +12699,25 @@ async function createDbTransport(input) {
11738
12699
  state,
11739
12700
  errorText
11740
12701
  }, actor));
12702
+ },
12703
+ cardClaim: async ({ card, agent }) => {
12704
+ await session.assertWritable();
12705
+ const named = card === void 0 ? void 0 : (await resolveCard(card)).id;
12706
+ const claimed = await ops.claimCard({
12707
+ workspaceId,
12708
+ issueId: named,
12709
+ run: {
12710
+ agentName: agent ?? actor.id ?? "agent",
12711
+ externalSessionRef: sessionRefFromEnvironment() ?? void 0,
12712
+ launchedByKind: "external"
12713
+ }
12714
+ }, actor);
12715
+ const columns = await readColumns();
12716
+ return {
12717
+ card: await project(claimed.card, columns),
12718
+ run: projectKanboRun(claimed.run),
12719
+ column: columns.find((column) => column.id === claimed.column.id)
12720
+ };
11741
12721
  }
11742
12722
  };
11743
12723
  }
@@ -11779,7 +12759,8 @@ const TRANSPORT_METHODS = {
11779
12759
  statusLine: true,
11780
12760
  waitApproval: true,
11781
12761
  runStart: true,
11782
- runFinish: true
12762
+ runFinish: true,
12763
+ cardClaim: true
11783
12764
  };
11784
12765
  async function openMcpBoard(input) {
11785
12766
  try {
@@ -12385,7 +13366,7 @@ function sendBoardPage(response, pathname, assets) {
12385
13366
  * answering for a person — approving a card, sending it back or closing a
12386
13367
  * sprint — as anyone but one is 403, dropping a pull request link somebody else made is the same 403
12387
13368
  * for the same reason, and acting on a run that has already ended is 409,
12388
- * because the answer would have been yes a moment earlier. An agent putting a
13369
+ * because the answer would have been yes a moment earlier. Starting a run on a card that has one going, and claiming a card nobody can take right now, are 409 for the same reason. An agent putting a
12389
13370
  * card in a column whose entry rules it does not meet yet is 409 for the same
12390
13371
  * reason turned around: the answer will be yes once the card meets them, and
12391
13372
  * `details.unmet` says what is missing. Deleting a column that still holds
@@ -12403,6 +13384,14 @@ const BOARD_ERROR_RESPONSES = {
12403
13384
  message: "Only a person can approve a card",
12404
13385
  code: "issue_approval_requires_user"
12405
13386
  },
13387
+ board_card_not_ready: {
13388
+ status: 409,
13389
+ message: "That card is not there to take: it is not in To Do, waits for a person, or has a run going"
13390
+ },
13391
+ board_card_run_active: {
13392
+ status: 409,
13393
+ message: "The card already has a run going"
13394
+ },
12406
13395
  board_column_name_taken: {
12407
13396
  status: 400,
12408
13397
  message: "Another column already has that name"
@@ -12442,6 +13431,18 @@ const BOARD_ERROR_RESPONSES = {
12442
13431
  status: 400,
12443
13432
  message: "Execution mode cannot change once the card has been launched"
12444
13433
  },
13434
+ board_flow_rule_invalid: {
13435
+ status: 400,
13436
+ message: "A column accepts cards from other columns of its own board, and holds at least one card"
13437
+ },
13438
+ board_metrics_range_invalid: {
13439
+ status: 400,
13440
+ message: "The range starts after it ends"
13441
+ },
13442
+ board_nothing_ready: {
13443
+ status: 409,
13444
+ message: "No card in To Do is ready to take"
13445
+ },
12445
13446
  board_pull_request_invalid: {
12446
13447
  status: 400,
12447
13448
  message: "Not a GitHub pull request: give its URL or owner/repo#number"
@@ -12857,6 +13858,14 @@ const startRunBody = zod.z.object({
12857
13858
  ]).optional(),
12858
13859
  externalSessionRef: zod.z.string().nullable().optional()
12859
13860
  });
13861
+ const claimBody = zod.z.object({
13862
+ workspaceId: id,
13863
+ issueId: id.optional(),
13864
+ agentName: id.optional(),
13865
+ branch: zod.z.string().nullable().optional(),
13866
+ executionMode: executionMode.optional(),
13867
+ externalSessionRef: zod.z.string().nullable().optional()
13868
+ });
12860
13869
  const finishRunBody = zod.z.object({
12861
13870
  state: zod.z.enum([
12862
13871
  "finished",
@@ -12903,7 +13912,8 @@ async function toWrittenCardView(ops, card) {
12903
13912
  function toStatusView(status) {
12904
13913
  return {
12905
13914
  ...status,
12906
- entryRules: readEntryRules(status)
13915
+ entryRules: readEntryRules(status),
13916
+ allowedFrom: readFlowRules(status).allowedFrom
12907
13917
  };
12908
13918
  }
12909
13919
  function toCommentView(comment, personWriterId = null) {
@@ -13338,6 +14348,31 @@ function createIssueRoutes(session, options = {}) {
13338
14348
  }, actors.placement));
13339
14349
  }
13340
14350
  }),
14351
+ route({
14352
+ method: "POST",
14353
+ path: "/issues/claim",
14354
+ body: claimBody,
14355
+ write: true,
14356
+ handle: async ({ body, actors }) => {
14357
+ const named = requireWorkspace(body.workspaceId);
14358
+ if (body.issueId !== void 0) await requireCard(body.issueId);
14359
+ const claimed = await ops.claimCard({
14360
+ workspaceId: named,
14361
+ issueId: body.issueId,
14362
+ run: {
14363
+ agentName: body.agentName ?? actors.writer.id ?? "agent",
14364
+ branch: body.branch,
14365
+ executionMode: body.executionMode,
14366
+ externalSessionRef: body.externalSessionRef
14367
+ }
14368
+ }, actors.placement);
14369
+ return {
14370
+ card: await toWrittenCardView(ops, claimed.card),
14371
+ run: await toRunView(claimed.run),
14372
+ column: toStatusView(claimed.column)
14373
+ };
14374
+ }
14375
+ }),
13341
14376
  route({
13342
14377
  method: "PATCH",
13343
14378
  path: "/issues/bulk",
@@ -14216,17 +15251,6 @@ function parsePort(value) {
14216
15251
  //#endregion
14217
15252
  //#region src/cli/commands/sprint.ts
14218
15253
  /**
14219
- * A date as the command line takes it: `YYYY-MM-DD`, read as a UTC day, or a
14220
- * unix-seconds number taken as it is. `end` turns a day into its last second,
14221
- * so a sprint due on a day is still current throughout that day.
14222
- */
14223
- function parseDate(value, end) {
14224
- if (/^\d+$/.test(value)) return Number(value);
14225
- const day = /^\d{4}-\d{2}-\d{2}$/.test(value) ? Date.parse(`${value}T00:00:00Z`) : NaN;
14226
- if (Number.isNaN(day)) throw new CliError(1, `Expected a date as YYYY-MM-DD or unix seconds, got "${value}".`);
14227
- return day / 1e3 + (end ? 86399 : 0);
14228
- }
14229
- /**
14230
15254
  * `kanbo sprint …` — milestones read as sprints (ruling 5x-3).
14231
15255
  *
14232
15256
  * Listing and creating are anybody's. Closing one is a person's decision
@@ -14246,7 +15270,7 @@ function registerSprintCommands(program) {
14246
15270
  };
14247
15271
  });
14248
15272
  });
14249
- 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) => {
14250
15274
  await runBoardCommand(options, "write", async (session) => {
14251
15275
  const created = await session.ops.createMilestone({
14252
15276
  workspaceId: session.workspace.id,
@@ -14289,6 +15313,113 @@ function registerSprintCommands(program) {
14289
15313
  });
14290
15314
  }
14291
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
14292
15423
  //#region src/cli/commands/uninstall.ts
14293
15424
  function registerUninstallCommand(program) {
14294
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) => {
@@ -14469,9 +15600,11 @@ function registerKanboCommands(program, context = {}) {
14469
15600
  registerReadyCommand(program);
14470
15601
  registerPrimeCommand(program);
14471
15602
  registerSprintCommands(program);
15603
+ registerStatsCommand(program);
14472
15604
  registerServeCommand(program);
14473
15605
  program.commandsGroup("For agents and integrations:");
14474
15606
  registerMcpCommand(program);
15607
+ registerClaimCommand(program);
14475
15608
  registerRunCommands(program);
14476
15609
  registerCapabilitiesCommand(program);
14477
15610
  program.commandsGroup("Shared Postgres boards:");
@@ -14495,6 +15628,7 @@ function registerKanboCommands(program, context = {}) {
14495
15628
  const KANBO_TOOL_NAMES = [
14496
15629
  "kanbo_prime",
14497
15630
  "kanbo_ready",
15631
+ "kanbo_card_claim",
14498
15632
  "kanbo_columns",
14499
15633
  "kanbo_sprints",
14500
15634
  "kanbo_card_get",
@@ -14520,6 +15654,7 @@ const KANBO_TOOL_NAMES = [
14520
15654
  const KANBO_TOOL_CLI_EQUIVALENTS = {
14521
15655
  kanbo_prime: { kanbo: "kanbo prime" },
14522
15656
  kanbo_ready: { kanbo: "kanbo ready" },
15657
+ kanbo_card_claim: { kanbo: "kanbo claim [card] [--agent <name>]" },
14523
15658
  kanbo_columns: { kanbo: "kanbo columns list" },
14524
15659
  kanbo_sprints: { kanbo: "kanbo sprint list" },
14525
15660
  kanbo_card_get: { kanbo: "kanbo card get <id>" },
@@ -14667,10 +15802,12 @@ function buildCapabilitiesManifest() {
14667
15802
  "Only a person may close a sprint. `kanbo sprint close` refuses an agent's shell (exit 4), and the operation refuses any actor but a person; there is no MCP tool that closes one — `kanbo_sprints` only reads. Unlike approval, the database role does not enforce this.",
14668
15803
  `The agent role (\`${require_roles_sql.BOARD_AGENT_ROLE}\`) may not delete a card at all — it holds select, insert and update on \`issues\` and nothing more, even though it may delete freely on every other board table.`,
14669
15804
  "A column may ask a card to meet entry rules before it enters (`entryRules`). Anyone but a person moving, creating or bulk-moving a card into it early is refused with `board_column_rules_unmet` and one line per unmet rule (CLI exit 1); a person goes through and is told what is missing. Only a person sets the rules — `kanbo columns rules` refuses an agent's shell (exit 4). The database role does not enforce them.",
15805
+ "A column may name the columns it accepts cards from (`allowedFrom`) and the most cards it holds (`wipLimit`). Anyone but a person moving, creating, bulk-moving or claiming a card against them is refused with `board_column_rules_unmet` (`allowed_from`, `wip_limit`); a person goes through and is told. A new board's Done accepts cards from In Review only. Only a person sets them — `kanbo columns flow` refuses an agent's shell (exit 4). The database role does not enforce them.",
15806
+ "A claim (`kanbo claim`, `kanbo_card_claim`, `POST /issues/claim`) takes a ready card from To Do, starts its run and moves it into the board's first `started` column in one write; two claimers never get the same card, and the one that loses is told `board_nothing_ready` or `board_card_not_ready`. A card has one run going at a time: starting a second is refused with `board_card_run_active`, on every path.",
14670
15807
  "Only a person changes the board's columns: `kanbo columns add`, `rename`, `move`, `remove`, `template` and `add-standard` refuse an agent's shell (exit 4), and the rename, move, remove and add-missing operations refuse any actor but a person (`board_column_structure_requires_user`); no MCP tool changes columns. To Do can be neither removed nor renamed away from its slug `to_do`: `kanbo ready` takes work from it.",
14671
15808
  "No board tool or command deletes a card, agent role or not. Cancelling one is a move to another column. The one exception is `kanbo serve`'s `DELETE /issues/:id`, which it answers only for a request presenting its token, on a server a person started, that has not sent `x-kanbo-actor: agent` (403 `issue_delete_requires_user` otherwise).",
14672
15809
  "`kanbo migrate` and `kanbo roles apply` refuse a host app's own database file: the app migrates it, and this package never touches it.",
14673
- "A write to a board older than this build's schema epoch is refused (exit 3) until `kanbo migrate` runs (on a host app's database, until the app migrates it) — reads stay open either way.",
15810
+ "A write to a board older than this build's schema epoch is refused (exit 3) until `kanbo migrate` runs (on a host app's database, until the app migrates it), and so is a read of a board file that lacks a table or a column this build reads; a board whose only difference is its schema stamp stays readable.",
14674
15811
  "There is no MCP tool that approves a card, and there never will be: `kanbo_wait_approval` hands the card to a person and ends the turn; only a person takes it back."
14675
15812
  ]
14676
15813
  };
@@ -14760,6 +15897,12 @@ Object.defineProperty(exports, "ENTRY_RULE_DESCRIPTIONS", {
14760
15897
  return ENTRY_RULE_DESCRIPTIONS;
14761
15898
  }
14762
15899
  });
15900
+ Object.defineProperty(exports, "FLOW_RULES", {
15901
+ enumerable: true,
15902
+ get: function() {
15903
+ return FLOW_RULES;
15904
+ }
15905
+ });
14763
15906
  Object.defineProperty(exports, "KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI", {
14764
15907
  enumerable: true,
14765
15908
  get: function() {
@@ -14838,6 +15981,18 @@ Object.defineProperty(exports, "activityToken", {
14838
15981
  return activityToken;
14839
15982
  }
14840
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
+ });
14841
15996
  Object.defineProperty(exports, "buildCapabilitiesManifest", {
14842
15997
  enumerable: true,
14843
15998
  get: function() {
@@ -14862,6 +16017,12 @@ Object.defineProperty(exports, "commentAuthorReference", {
14862
16017
  return commentAuthorReference;
14863
16018
  }
14864
16019
  });
16020
+ Object.defineProperty(exports, "computeBoardMetrics", {
16021
+ enumerable: true,
16022
+ get: function() {
16023
+ return computeBoardMetrics;
16024
+ }
16025
+ });
14865
16026
  Object.defineProperty(exports, "createBoardOps", {
14866
16027
  enumerable: true,
14867
16028
  get: function() {
@@ -14940,6 +16101,12 @@ Object.defineProperty(exports, "isValidCardKey", {
14940
16101
  return isValidCardKey;
14941
16102
  }
14942
16103
  });
16104
+ Object.defineProperty(exports, "isoWeekOf", {
16105
+ enumerable: true,
16106
+ get: function() {
16107
+ return isoWeekOf;
16108
+ }
16109
+ });
14943
16110
  Object.defineProperty(exports, "maskDatabaseUrl", {
14944
16111
  enumerable: true,
14945
16112
  get: function() {
@@ -14958,6 +16125,12 @@ Object.defineProperty(exports, "matchesKanboCardQuery", {
14958
16125
  return matchesKanboCardQuery;
14959
16126
  }
14960
16127
  });
16128
+ Object.defineProperty(exports, "nearestRank", {
16129
+ enumerable: true,
16130
+ get: function() {
16131
+ return nearestRank;
16132
+ }
16133
+ });
14961
16134
  Object.defineProperty(exports, "nextIssueIdentity", {
14962
16135
  enumerable: true,
14963
16136
  get: function() {
@@ -15090,6 +16263,12 @@ Object.defineProperty(exports, "readEntryRules", {
15090
16263
  return readEntryRules;
15091
16264
  }
15092
16265
  });
16266
+ Object.defineProperty(exports, "readFlowRules", {
16267
+ enumerable: true,
16268
+ get: function() {
16269
+ return readFlowRules;
16270
+ }
16271
+ });
15093
16272
  Object.defineProperty(exports, "readIssuePrefix", {
15094
16273
  enumerable: true,
15095
16274
  get: function() {
@@ -15150,6 +16329,12 @@ Object.defineProperty(exports, "toIssueView", {
15150
16329
  return toIssueView;
15151
16330
  }
15152
16331
  });
16332
+ Object.defineProperty(exports, "utcDayLabel", {
16333
+ enumerable: true,
16334
+ get: function() {
16335
+ return utcDayLabel;
16336
+ }
16337
+ });
15153
16338
  Object.defineProperty(exports, "writeBinding", {
15154
16339
  enumerable: true,
15155
16340
  get: function() {