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
@@ -1,6 +1,6 @@
1
1
  import { a as BoardError } from "./migrations-path-ClaYRNwk.mjs";
2
- import { c as getBoardMigrationsPath, i as BOARD_PERSON_ROLE, l as createPostgresBoardStore, n as AGENT_WAITING_REFUSAL, o as BOARD_ROLE_SQL, r as BOARD_AGENT_ROLE, s as assertBoardSchema, t as AGENT_APPROVAL_COMMENT_REFUSAL } from "./roles.sql-BoOZSkBp.mjs";
3
- import { a as openBoardDatabase, c as boardQueryLogger, d as foldCase, i as markBoardFileOwnedByKanbo, l as createSqliteBoardStore, n as migrateBoardFile, o as readBoardFileContents, r as assertBoardSchema$1 } from "./migrate-DtBeW3Fs.mjs";
2
+ import { c as getBoardMigrationsPath, i as BOARD_PERSON_ROLE, l as createPostgresBoardStore, n as AGENT_WAITING_REFUSAL, o as BOARD_ROLE_SQL, r as BOARD_AGENT_ROLE, s as assertBoardSchema, t as AGENT_APPROVAL_COMMENT_REFUSAL } from "./roles.sql-9OkYvsNM.mjs";
3
+ import { a as openBoardDatabase, c as boardQueryLogger, d as foldCase, i as markBoardFileOwnedByKanbo, l as createSqliteBoardStore, n as migrateBoardFile, o as readBoardFileContents, r as assertBoardSchema$1 } from "./migrate-Bgj_aABY.mjs";
4
4
  import { Command, Option } from "commander";
5
5
  import { z } from "zod";
6
6
  import { homedir, hostname, userInfo } from "node:os";
@@ -271,6 +271,7 @@ const HUMAN_ONLY_ACTIONS = [
271
271
  "return",
272
272
  "sprint close",
273
273
  "columns rules",
274
+ "columns flow",
274
275
  "columns add",
275
276
  "columns rename",
276
277
  "columns move",
@@ -284,6 +285,7 @@ const HUMAN_ONLY_MESSAGES = {
284
285
  "return": describeReturnRefusal,
285
286
  "sprint close": describeSprintCloseRefusal,
286
287
  "columns rules": describeColumnRulesRefusal,
288
+ "columns flow": describeColumnRulesRefusal,
287
289
  "columns add": describeColumnChangesRefusal,
288
290
  "columns rename": describeColumnChangesRefusal,
289
291
  "columns move": describeColumnChangesRefusal,
@@ -583,18 +585,18 @@ function activityToken(token) {
583
585
  function currentUnixSeconds() {
584
586
  return Math.floor(Date.now() / 1e3);
585
587
  }
586
- const DAY_SECONDS = 86400;
588
+ const DAY_SECONDS$2 = 86400;
587
589
  /**
588
590
  * A board date is a calendar day (ruling 5x-6): a start is kept as the first
589
591
  * second of its UTC day, a due date as the last, so a sprint runs through its
590
592
  * due day wherever the person reading it is.
591
593
  */
592
594
  function utcDayStart(seconds) {
593
- return Math.floor(seconds / DAY_SECONDS) * DAY_SECONDS;
595
+ return Math.floor(seconds / DAY_SECONDS$2) * DAY_SECONDS$2;
594
596
  }
595
597
  /** The last second of the UTC day `seconds` falls on — see `utcDayStart`. */
596
598
  function utcDayEnd(seconds) {
597
- return utcDayStart(seconds) + DAY_SECONDS - 1;
599
+ return utcDayStart(seconds) + DAY_SECONDS$2 - 1;
598
600
  }
599
601
  /** The largest unix seconds a moment is read as: past it, the number is milliseconds (year 5138 onwards). */
600
602
  const MAX_UNIX_SECONDS = 1e11;
@@ -764,6 +766,14 @@ const HIDDEN_ACTIVITY_FIELDS = /* @__PURE__ */ new Set([
764
766
  "order",
765
767
  "waitingFor"
766
768
  ]);
769
+ /**
770
+ * The column a card was created in, recorded as a change from no column at the
771
+ * card's own creation time. The feed's `created` item already stands for it,
772
+ * and the field history keeps it.
773
+ */
774
+ function isStartingColumn(change, issue) {
775
+ return change.field === "statusId" && change.fromValue === null && change.createdAt === issue.createdAt;
776
+ }
767
777
  const ACTIVITY_FIELD_BY_RAW_FIELD = {
768
778
  assigneeId: "assignee",
769
779
  description: "description",
@@ -804,7 +814,7 @@ async function listActivity(store, issueId, resolver) {
804
814
  workspaceById: resolver.workspaceById
805
815
  };
806
816
  const commentItems = await Promise.all((await store.comments.listByIssue(issueId)).map(async (comment) => await toCommentActivityItem(comment, resolver)));
807
- const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
817
+ 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);
808
818
  return [
809
819
  {
810
820
  id: `${issue.id}:created`,
@@ -1002,62 +1012,31 @@ function previewComment(comment) {
1002
1012
  };
1003
1013
  }
1004
1014
  //#endregion
1005
- //#region src/domain/field-changes.ts
1015
+ //#region src/domain/card-display-title.ts
1006
1016
  /**
1007
- * The issue columns whose edits are written to the audit trail.
1017
+ * What a card is called wherever a person or an agent reads it by name.
1008
1018
  *
1009
- * `statusLine` is absent on purpose: `writeStatusLine` records every line it
1010
- * writes itself, and no patch ever carries the column. `waitingFor` is absent
1011
- * too — whose turn it is shows in the card's own line and in the
1012
- * `system.approved` / `system.returned` comments around it, so a row saying the
1013
- * same thing a third time would only crowd the history.
1014
- */
1015
- const TRACKED_FIELDS = [
1016
- "workspaceId",
1017
- "title",
1018
- "description",
1019
- "priority",
1020
- "labels",
1021
- "milestoneId",
1022
- "parentIssueId",
1023
- "statusId",
1024
- "assigneeKind",
1025
- "assigneeId",
1026
- "dueDate",
1027
- "delegateAgentId",
1028
- "delegateProviderTargetId",
1029
- "contextRefs",
1030
- "executionMode",
1031
- "order"
1032
- ];
1033
- /**
1034
- * The audit rows one patch produces: a row per tracked field whose stringified
1035
- * value actually changed. Nothing is written here — the caller inserts what
1036
- * comes back, so the diff stays a pure function of the row, the patch, the
1037
- * actor and the clock.
1019
+ * A card written as a description alone is titled with its own key (every
1020
+ * writer does that). Named by its key, such a card says nothing — so its name
1021
+ * is the first line of its description instead. A browser bundle that does not
1022
+ * load this package keeps the same rule on its own, tested with the same
1023
+ * examples.
1038
1024
  */
1039
- function diffFieldChanges(before, updates, actor, now) {
1040
- const rows = [];
1041
- for (const field of TRACKED_FIELDS) {
1042
- if (!(field in updates)) continue;
1043
- const rawBefore = before[field === "labels" ? "labels" : field];
1044
- const rawAfter = updates[field];
1045
- const fromValue = rawBefore == null ? null : String(rawBefore);
1046
- const toValue = rawAfter == null ? null : String(rawAfter);
1047
- if (fromValue === toValue) continue;
1048
- rows.push({
1049
- id: randomUUID(),
1050
- issueId: before.id,
1051
- field,
1052
- fromValue,
1053
- toValue,
1054
- actorKind: actor.kind,
1055
- actorId: actor.id ?? null,
1056
- sourceChatSessionId: actor.sourceChatSessionId ?? null,
1057
- createdAt: now
1058
- });
1025
+ /** How long a description line may run before it is cut with an ellipsis. */
1026
+ const DESCRIPTION_LINE_LIMIT = 120;
1027
+ function cardDisplayTitle(card) {
1028
+ if (card.title !== card.id) return card.title;
1029
+ return descriptionFirstLine(card.description) ?? card.title;
1030
+ }
1031
+ function descriptionFirstLine(description) {
1032
+ for (const rawLine of (description ?? "").split("\n")) {
1033
+ const line = stripMarkdown(rawLine);
1034
+ if (line) return line.length > DESCRIPTION_LINE_LIMIT ? `${line.slice(0, 119).trimEnd()}…` : line;
1059
1035
  }
1060
- return rows;
1036
+ return null;
1037
+ }
1038
+ function stripMarkdown(line) {
1039
+ return line.replace(/^\s*(?:>\s*)+/, "").replace(/^\s*#{1,6}\s+/, "").replace(/^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ x]\]\s+)?/i, "").replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<[^>]+>/g, "").replace(/[*_~`]/g, "").replace(/\s+/g, " ").trim();
1061
1040
  }
1062
1041
  //#endregion
1063
1042
  //#region src/domain/status-name.ts
@@ -1112,6 +1091,603 @@ function normalizeStatusName(value) {
1112
1091
  return value.trim().toLowerCase().replace(/[\s-]+/g, "_");
1113
1092
  }
1114
1093
  //#endregion
1094
+ //#region src/domain/flow-rules.ts
1095
+ /** The two flow rules, as an unmet entry names them. */
1096
+ const FLOW_RULES = ["allowed_from", "wip_limit"];
1097
+ /** The column the standard flow guards, and the columns it lets cards in from, by slug. */
1098
+ const STANDARD_FLOW_TARGET = "done";
1099
+ const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
1100
+ /** A column's flow rules, out of what the row stores. */
1101
+ function readFlowRules(column) {
1102
+ return {
1103
+ allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
1104
+ wipLimit: column.wipLimit
1105
+ };
1106
+ }
1107
+ /** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
1108
+ function serializeAllowedFrom(allowedFrom) {
1109
+ return allowedFrom === null ? null : JSON.stringify(allowedFrom);
1110
+ }
1111
+ /**
1112
+ * The flow rules a card entering `column` does not meet, where it may come from
1113
+ * first. Pure: what the board holds arrives in `facts`. A card being created
1114
+ * comes from no column, so a column that names where cards come from does not
1115
+ * take it.
1116
+ */
1117
+ function evaluateFlowRules(card, column, rules, facts) {
1118
+ const unmet = [];
1119
+ if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
1120
+ rule: "allowed_from",
1121
+ reason: "not_allowed_from",
1122
+ detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
1123
+ });
1124
+ if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
1125
+ rule: "wip_limit",
1126
+ reason: "column_full",
1127
+ detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
1128
+ });
1129
+ return unmet;
1130
+ }
1131
+ function describeAllowedFrom(name, allowedFrom, columnNames) {
1132
+ const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
1133
+ return names.length === 0 ? `${name} accepts cards from no column; only a person may put a card here` : `${name} accepts cards from ${joinWords(names)} only`;
1134
+ }
1135
+ /** `A`, `A and B`, `A, B and C`. */
1136
+ function joinWords(words) {
1137
+ return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
1138
+ }
1139
+ /**
1140
+ * The standard flow on these columns: Done, and the columns it takes cards from
1141
+ * — In Review, and QA when there is one — in board order. `null` when the board
1142
+ * has no Done, or neither of the two.
1143
+ */
1144
+ function standardFlowOf(columns) {
1145
+ const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
1146
+ const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
1147
+ return target && sources.length > 0 ? {
1148
+ target,
1149
+ sources
1150
+ } : null;
1151
+ }
1152
+ //#endregion
1153
+ //#region src/domain/iso-week.ts
1154
+ const DAY_SECONDS$1 = 86400;
1155
+ /** The ISO week a moment falls in, read in UTC like every board date (ruling 5x-6). */
1156
+ function isoWeekOf(seconds) {
1157
+ const day = Math.floor(seconds / DAY_SECONDS$1);
1158
+ const monday = day - (day + 3) % 7;
1159
+ const thursday = monday + 3;
1160
+ const year = (/* @__PURE__ */ new Date(thursday * DAY_SECONDS$1 * 1e3)).getUTCFullYear();
1161
+ const januaryFirst = Date.UTC(year, 0, 1) / 1e3 / DAY_SECONDS$1;
1162
+ const week = Math.floor((thursday - januaryFirst) / 7) + 1;
1163
+ return {
1164
+ week: `${year}-W${String(week).padStart(2, "0")}`,
1165
+ start: monday * DAY_SECONDS$1
1166
+ };
1167
+ }
1168
+ /** The UTC day a moment falls on, as `2026-01-05`. */
1169
+ function utcDayLabel(seconds) {
1170
+ return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
1171
+ }
1172
+ //#endregion
1173
+ //#region src/ops/card-timeline.ts
1174
+ /**
1175
+ * A card's columns over time, oldest first, from its creation on: the column
1176
+ * it was created in, then one stretch per move. A card created before 0.5.0
1177
+ * carries no entry for its creation; one created since carries `null → column`
1178
+ * at its creation, which leaves a stretch of no length in no column.
1179
+ *
1180
+ * The feed lists moves by time and then by change id, and a change id is
1181
+ * random, so two moves of one card in the same second can come back in either
1182
+ * order. Within one second the moves are put back in the order they chain —
1183
+ * each leaves the column the one before it entered — which is the order they
1184
+ * were made in (ruling R012-5). Moves that do not chain keep the feed's order.
1185
+ */
1186
+ function buildCardTimeline(card, entries) {
1187
+ const ordered = chainSameSecond(entries.toSorted((left, right) => left.at - right.at || (left.changeId < right.changeId ? -1 : left.changeId > right.changeId ? 1 : 0)));
1188
+ const stretches = [];
1189
+ const first = ordered[0];
1190
+ let current = {
1191
+ statusId: first ? first.fromStatusId : card.statusId,
1192
+ from: card.createdAt,
1193
+ to: null
1194
+ };
1195
+ for (const entry of ordered) {
1196
+ stretches.push({
1197
+ ...current,
1198
+ to: entry.at
1199
+ });
1200
+ current = {
1201
+ statusId: entry.toStatusId,
1202
+ from: entry.at,
1203
+ to: null
1204
+ };
1205
+ }
1206
+ stretches.push(current);
1207
+ return stretches;
1208
+ }
1209
+ /** The column the card was in at `at` — a move at `at` has already happened — or `undefined` before the card existed. */
1210
+ function statusAt(stretches, at) {
1211
+ return stretches.find((stretch) => stretch.from <= at && (stretch.to === null || at < stretch.to))?.statusId;
1212
+ }
1213
+ function chainSameSecond(entries) {
1214
+ const ordered = [];
1215
+ let start = 0;
1216
+ while (start < entries.length) {
1217
+ let end = start;
1218
+ while (end < entries.length && entries[end].at === entries[start].at) end++;
1219
+ const group = entries.slice(start, end);
1220
+ const previous = ordered.at(-1);
1221
+ let current = previous ? previous.toStatusId : headOf(group)?.fromStatusId;
1222
+ while (group.length > 0) {
1223
+ let next = group.findIndex((entry) => entry.fromStatusId === current);
1224
+ if (next < 0) next = Math.max(group.findIndex((entry) => entry === headOf(group)), 0);
1225
+ const [picked] = group.splice(next, 1);
1226
+ ordered.push(picked);
1227
+ current = picked.toStatusId;
1228
+ }
1229
+ start = end;
1230
+ }
1231
+ return ordered;
1232
+ }
1233
+ /** The move nothing else in the group feeds into. */
1234
+ function headOf(group) {
1235
+ return group.find((entry) => !group.some((other) => other !== entry && other.toStatusId === entry.fromStatusId));
1236
+ }
1237
+ //#endregion
1238
+ //#region src/ops/board-metrics.ts
1239
+ const DAY_SECONDS = 86400;
1240
+ const WEEK_SECONDS$1 = 7 * DAY_SECONDS;
1241
+ /**
1242
+ * The value at `percent` of `values` by nearest rank (ruling R012-2): the
1243
+ * ⌈percent·n/100⌉-th in ascending order. Always a value that occurred; `null`
1244
+ * for none.
1245
+ */
1246
+ function nearestRank(values, percent) {
1247
+ if (values.length === 0) return null;
1248
+ const sorted = values.toSorted((left, right) => left - right);
1249
+ return sorted[Math.max(Math.ceil(percent * sorted.length / 100), 1) - 1];
1250
+ }
1251
+ /** Who a run counts under in the agent rows: the host's agent id when the run names one, else the name it was started under. */
1252
+ function boardMetricsAgentKey(run) {
1253
+ return run.agentId ?? run.agentName;
1254
+ }
1255
+ function scopeOf(history, input) {
1256
+ const workspaceIds = new Set(input.workspaceIds);
1257
+ const milestoneId = input.milestoneId ?? null;
1258
+ const statusById = new Map(history.statuses.filter((status) => workspaceIds.has(status.workspaceId)).map((status) => [status.id, status]));
1259
+ const cards = history.cards.filter((card) => workspaceIds.has(card.workspaceId)).filter((card) => milestoneId === null || card.milestoneId === milestoneId);
1260
+ const isCanceled = (card) => card.statusId !== null && statusById.get(card.statusId)?.category === "canceled";
1261
+ return {
1262
+ statusById,
1263
+ counted: cards.filter((card) => !isCanceled(card)),
1264
+ canceled: cards.filter(isCanceled)
1265
+ };
1266
+ }
1267
+ /**
1268
+ * The runs of the counted cards, and apart those of the cards in a canceled
1269
+ * column (R012-6): what a host prices to put a cost beside the metrics, and the
1270
+ * spend on canceled work on a line of its own.
1271
+ */
1272
+ function boardMetricsRuns(history, input) {
1273
+ const { counted, canceled } = scopeOf(history, input);
1274
+ const countedIds = new Set(counted.map((card) => card.id));
1275
+ const canceledIds = new Set(canceled.map((card) => card.id));
1276
+ return {
1277
+ counted: history.runs.filter((run) => countedIds.has(run.issueId)),
1278
+ canceled: history.runs.filter((run) => canceledIds.has(run.issueId))
1279
+ };
1280
+ }
1281
+ /** Who made a return, as `returns.byActorKind` counts it: a person, an agent (a provider target stands in for one), or a writer from outside. */
1282
+ function returnerOf(entry) {
1283
+ if (entry.actorKind === "user") return "user";
1284
+ return entry.actorKind === "system" ? "external" : "agent";
1285
+ }
1286
+ /**
1287
+ * The metrics of `input` over rows already read. Pure: the same rows give the
1288
+ * same answer in any order, so a host may put several boards' rows together
1289
+ * and compute once — percentiles of parts do not add up to the whole's.
1290
+ */
1291
+ function computeBoardMetrics(history, input, now) {
1292
+ if (input.from > input.to) throw new BoardError("board_metrics_range_invalid", {
1293
+ from: input.from,
1294
+ to: input.to
1295
+ });
1296
+ const milestoneId = input.milestoneId ?? null;
1297
+ if (milestoneId !== null && !history.milestones.some((milestone) => milestone.id === milestoneId && input.workspaceIds.includes(milestone.workspaceId))) throw new BoardError("issue_milestone_not_found", { milestoneId });
1298
+ const from = input.from;
1299
+ const to = Math.min(input.to, now);
1300
+ const inRange = (at) => from <= at && at <= to;
1301
+ const { statusById, counted, canceled } = scopeOf(history, input);
1302
+ const columns = groupColumns(input.workspaceIds, [...statusById.values()]);
1303
+ const slugOf = (statusId) => {
1304
+ const status = statusId === null ? void 0 : statusById.get(statusId);
1305
+ return status ? normalizeStatusName(status.name) : null;
1306
+ };
1307
+ const categoryOf = (statusId) => statusId === null ? null : statusById.get(statusId)?.category ?? null;
1308
+ const isReturn = (entry) => {
1309
+ const source = entry.fromStatusId === null ? void 0 : statusById.get(entry.fromStatusId);
1310
+ const target = statusById.get(entry.toStatusId);
1311
+ return source !== void 0 && target !== void 0 && source.workspaceId === target.workspaceId && target.order < source.order && (target.category === "unstarted" || target.category === "started");
1312
+ };
1313
+ const entriesByCard = groupBy(history.entries, (entry) => entry.issueId);
1314
+ const timelineOf = (card) => buildCardTimeline(card, entriesByCard.get(card.id) ?? []);
1315
+ const countedIds = new Set(counted.map((card) => card.id));
1316
+ const facts = counted.map((card) => {
1317
+ const stretches = timelineOf(card);
1318
+ return {
1319
+ card,
1320
+ stretches,
1321
+ startedAt: stretches.find((stretch) => categoryOf(stretch.statusId) === "started")?.from ?? null
1322
+ };
1323
+ });
1324
+ const finished = facts.filter((fact) => categoryOf(fact.card.statusId) === "completed").map((fact) => ({
1325
+ ...fact,
1326
+ finishedAt: fact.stretches.at(-1).from
1327
+ })).filter((fact) => inRange(fact.finishedAt)).toSorted((left, right) => left.finishedAt - right.finishedAt || left.card.id.localeCompare(right.card.id));
1328
+ const runsByCard = groupBy(history.runs.filter((run) => countedIds.has(run.issueId)), (run) => run.issueId);
1329
+ const returnsByCard = groupBy(history.entries.filter((entry) => countedIds.has(entry.issueId) && isReturn(entry)), (entry) => entry.issueId);
1330
+ const finishedCards = finished.map((fact) => ({
1331
+ ...cardOf(fact.card),
1332
+ startedAt: fact.startedAt,
1333
+ finishedAt: fact.finishedAt,
1334
+ cycleSeconds: fact.startedAt === null ? null : fact.finishedAt - fact.startedAt,
1335
+ leadSeconds: fact.finishedAt - fact.card.createdAt,
1336
+ returns: returnsByCard.get(fact.card.id)?.length ?? 0,
1337
+ runs: runsByCard.get(fact.card.id)?.length ?? 0
1338
+ }));
1339
+ const cycleTime = durations(finishedCards.flatMap((card) => card.cycleSeconds === null ? [] : [card.cycleSeconds]));
1340
+ const leadTime = durations(finishedCards.map((card) => card.leadSeconds));
1341
+ const throughput = [];
1342
+ for (let start = isoWeekOf(from).start; from <= to && start <= to; start += WEEK_SECONDS$1) throughput.push({
1343
+ week: isoWeekOf(start).week,
1344
+ start,
1345
+ finished: finishedCards.filter((card) => card.finishedAt >= start && card.finishedAt < start + WEEK_SECONDS$1).length
1346
+ });
1347
+ const inProgress = facts.filter((fact) => categoryOf(fact.card.statusId) === "started");
1348
+ const wip = columns.filter((column) => column.category === "started").map((column) => {
1349
+ const limits = [...statusById.values()].filter((status) => normalizeStatusName(status.name) === column.slug).map((status) => readFlowRules(status).wipLimit);
1350
+ return {
1351
+ slug: column.slug,
1352
+ cards: inProgress.filter((fact) => slugOf(fact.card.statusId) === column.slug).length,
1353
+ limit: limits.every((limit) => limit !== null) ? limits.reduce((sum, limit) => sum + limit, 0) : null
1354
+ };
1355
+ });
1356
+ const aging = inProgress.map((fact) => {
1357
+ const current = fact.stretches.at(-1);
1358
+ const startedAt = fact.startedAt ?? current.from;
1359
+ return {
1360
+ ...cardOf(fact.card),
1361
+ slug: slugOf(fact.card.statusId),
1362
+ startedAt,
1363
+ ageSeconds: now - startedAt,
1364
+ inColumnSeconds: now - current.from,
1365
+ overCycleP85: cycleTime.p85 !== null && now - startedAt > cycleTime.p85
1366
+ };
1367
+ }).toSorted((left, right) => right.ageSeconds - left.ageSeconds || left.issueId.localeCompare(right.issueId));
1368
+ const columnSeconds = new Map(columns.map((column) => [column.slug, 0]));
1369
+ for (const fact of finished) for (const stretch of fact.stretches) {
1370
+ const slug = slugOf(stretch.statusId);
1371
+ const end = Math.min(stretch.to ?? fact.finishedAt, fact.finishedAt);
1372
+ if (slug !== null && end > stretch.from) columnSeconds.set(slug, (columnSeconds.get(slug) ?? 0) + end - stretch.from);
1373
+ }
1374
+ const cumulativeFlow = [];
1375
+ for (let dayStart = utcDayStart(from); from <= to && dayStart <= to; dayStart += DAY_SECONDS) {
1376
+ const at = Math.min(dayStart + DAY_SECONDS - 1, to);
1377
+ const cards = Object.fromEntries(columns.map((column) => [column.slug, 0]));
1378
+ for (const fact of facts) {
1379
+ const statusId = statusAt(fact.stretches, at);
1380
+ const slug = statusId === void 0 ? null : slugOf(statusId);
1381
+ if (slug !== null) cards[slug] = (cards[slug] ?? 0) + 1;
1382
+ }
1383
+ cumulativeFlow.push({
1384
+ day: utcDayLabel(dayStart),
1385
+ at,
1386
+ cards
1387
+ });
1388
+ }
1389
+ const finishedReturns = finished.flatMap((fact) => returnsByCard.get(fact.card.id) ?? []);
1390
+ const byActorKind = {
1391
+ user: 0,
1392
+ agent: 0,
1393
+ external: 0
1394
+ };
1395
+ for (const entry of finishedReturns) byActorKind[returnerOf(entry)]++;
1396
+ const runTotal = finishedCards.reduce((sum, card) => sum + card.runs, 0);
1397
+ const share = (part, whole) => whole === 0 ? null : part / whole;
1398
+ return {
1399
+ range: {
1400
+ from,
1401
+ to
1402
+ },
1403
+ now,
1404
+ milestoneId,
1405
+ columns,
1406
+ cycleTime,
1407
+ leadTime,
1408
+ throughput,
1409
+ wip,
1410
+ aging,
1411
+ timeInColumn: columns.map((column) => ({
1412
+ slug: column.slug,
1413
+ seconds: columnSeconds.get(column.slug) ?? 0
1414
+ })),
1415
+ cumulativeFlow,
1416
+ finished: finishedCards,
1417
+ returns: {
1418
+ total: finishedReturns.length,
1419
+ perFinishedCard: share(finishedReturns.length, finishedCards.length),
1420
+ firstPassRate: share(finishedCards.filter((card) => card.returns === 0).length, finishedCards.length),
1421
+ byActorKind
1422
+ },
1423
+ attempts: {
1424
+ runs: runTotal,
1425
+ perFinishedCard: share(runTotal, finishedCards.length)
1426
+ },
1427
+ canceled: canceled.filter((card) => inRange(timelineOf(card).at(-1).from)).length,
1428
+ agents: agentRows(history.runs.filter((run) => countedIds.has(run.issueId)), finishedCards, inRange),
1429
+ people: personRows(counted, history, countedIds, inRange, isReturn)
1430
+ };
1431
+ }
1432
+ function agentRows(runs, finished, inRange) {
1433
+ const finishedIds = new Set(finished.map((card) => card.issueId));
1434
+ const returned = new Set(finished.filter((card) => card.returns > 0).map((card) => card.issueId));
1435
+ const rows = [];
1436
+ for (const [key, own] of groupBy(runs, boardMetricsAgentKey)) {
1437
+ const ranged = own.filter((run) => inRange(run.startedAt));
1438
+ const worked = new Set(own.map((run) => run.issueId).filter((issueId) => finishedIds.has(issueId)));
1439
+ if (ranged.length === 0 && worked.size === 0) continue;
1440
+ const newest = own.toSorted((left, right) => right.startedAt - left.startedAt || right.id.localeCompare(left.id))[0];
1441
+ const ended = ranged.flatMap((run) => run.endedAt === null ? [] : [run.endedAt - run.startedAt]);
1442
+ rows.push({
1443
+ key,
1444
+ agentId: newest.agentId,
1445
+ agentName: newest.agentName,
1446
+ runs: {
1447
+ total: ranged.length,
1448
+ finished: ranged.filter((run) => run.state === "finished").length,
1449
+ failed: ranged.filter((run) => run.state === "failed").length,
1450
+ stopped: ranged.filter((run) => run.state === "stopped").length,
1451
+ running: ranged.filter((run) => run.state === "running").length
1452
+ },
1453
+ medianRunSeconds: nearestRank(ended, 50),
1454
+ cardsFinished: worked.size,
1455
+ returnRate: worked.size === 0 ? null : [...worked].filter((issueId) => returned.has(issueId)).length / worked.size
1456
+ });
1457
+ }
1458
+ return rows.toSorted((left, right) => right.runs.total - left.runs.total || left.key.localeCompare(right.key));
1459
+ }
1460
+ function personRows(counted, history, countedIds, inRange, isReturn) {
1461
+ const rows = /* @__PURE__ */ new Map();
1462
+ const row = (id) => {
1463
+ let found = rows.get(id);
1464
+ if (!found) {
1465
+ found = {
1466
+ id,
1467
+ created: 0,
1468
+ approvals: 0,
1469
+ returns: 0,
1470
+ moves: 0
1471
+ };
1472
+ rows.set(id, found);
1473
+ }
1474
+ return found;
1475
+ };
1476
+ for (const card of counted) if (card.createdByKind === "user" && inRange(card.createdAt)) row(card.createdById).created++;
1477
+ for (const comment of history.approvals) if (comment.authorKind === "system.approved" && countedIds.has(comment.issueId) && inRange(comment.createdAt)) row(comment.authorId).approvals++;
1478
+ for (const entry of history.entries) if (entry.actorKind === "user" && entry.fromStatusId !== null && countedIds.has(entry.issueId) && inRange(entry.at)) {
1479
+ const person = row(entry.actorId);
1480
+ person.moves++;
1481
+ if (isReturn(entry)) person.returns++;
1482
+ }
1483
+ const activity = (person) => person.created + person.approvals + person.returns + person.moves;
1484
+ return [...rows.values()].toSorted((left, right) => activity(right) - activity(left) || (left.id ?? "￿").localeCompare(right.id ?? "￿"));
1485
+ }
1486
+ /**
1487
+ * The workspaces' columns as one board shows them, one per slug (ruling
1488
+ * R012-8): the first workspace's columns set the order, and a column only a
1489
+ * later one has goes right after the column before it there. Name and category
1490
+ * are the first workspace's that has it.
1491
+ */
1492
+ function groupColumns(workspaceIds, statuses) {
1493
+ const columns = [];
1494
+ for (const workspaceId of new Set(workspaceIds)) {
1495
+ let previous = -1;
1496
+ const own = statuses.filter((status) => status.workspaceId === workspaceId).toSorted((left, right) => left.order - right.order || left.id.localeCompare(right.id));
1497
+ for (const status of own) {
1498
+ const slug = normalizeStatusName(status.name);
1499
+ let index = columns.findIndex((column) => column.slug === slug);
1500
+ if (index === -1) {
1501
+ index = previous + 1;
1502
+ columns.splice(index, 0, {
1503
+ slug,
1504
+ name: status.name,
1505
+ category: status.category
1506
+ });
1507
+ }
1508
+ previous = index;
1509
+ }
1510
+ }
1511
+ return columns;
1512
+ }
1513
+ function durations(values) {
1514
+ return {
1515
+ cards: values.length,
1516
+ p50: nearestRank(values, 50),
1517
+ p85: nearestRank(values, 85)
1518
+ };
1519
+ }
1520
+ function cardOf(card) {
1521
+ return {
1522
+ issueId: card.id,
1523
+ workspaceId: card.workspaceId,
1524
+ title: cardDisplayTitle(card)
1525
+ };
1526
+ }
1527
+ function groupBy(items, keyOf) {
1528
+ const groups = /* @__PURE__ */ new Map();
1529
+ for (const item of items) {
1530
+ const key = keyOf(item);
1531
+ const group = groups.get(key);
1532
+ if (group) group.push(item);
1533
+ else groups.set(key, [item]);
1534
+ }
1535
+ return groups;
1536
+ }
1537
+ //#endregion
1538
+ //#region src/ops/column-entries.ts
1539
+ /**
1540
+ * Every entry into a column on the named boards at or after `since`, in order
1541
+ * of `(at, changeId)` — the one signal an app dispatching work on column
1542
+ * changes needs. It sees every writer, an outside `kanbo` on the same database
1543
+ * included, because it reads the cards' own history.
1544
+ *
1545
+ * A page never ends inside a second: when `limit` cuts into one, the rest of
1546
+ * that second comes too, so a page may be longer than `limit`. A reader keeps
1547
+ * its cursor at the last `at` it read after a short page — a write in that
1548
+ * second may still commit — and drops the `changeId`s it has seen; after a page
1549
+ * of `limit` or more it moves on to the last `at` + 1, which a second holding
1550
+ * more entries than `limit` would otherwise never let it pass.
1551
+ */
1552
+ async function listColumnEntries(store, input) {
1553
+ const page = await store.fieldChanges.listByFieldSince("statusId", input);
1554
+ return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
1555
+ }
1556
+ /** The page with every change of its last second, not only those `limit` let in. */
1557
+ async function withWholeLastSecond(store, input, page) {
1558
+ const last = page.at(-1).createdAt;
1559
+ const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
1560
+ workspaceIds: input.workspaceIds,
1561
+ since: last,
1562
+ until: last
1563
+ });
1564
+ return [...page.filter((change) => change.createdAt < last), ...lastSecond];
1565
+ }
1566
+ /** A status change as an entry; a change that took the card out of every column enters none. */
1567
+ function toColumnEntry(change) {
1568
+ return change.toValue === null ? [] : [{
1569
+ changeId: change.id,
1570
+ issueId: change.issueId,
1571
+ workspaceId: change.workspaceId,
1572
+ fromStatusId: change.fromValue,
1573
+ toStatusId: change.toValue,
1574
+ actorKind: change.actorKind,
1575
+ actorId: change.actorId,
1576
+ at: change.createdAt
1577
+ }];
1578
+ }
1579
+ //#endregion
1580
+ //#region src/ops/board-metrics-read.ts
1581
+ /** The comment kind the metrics read: a person's approvals. Returns are moves (R012-7). */
1582
+ const APPROVAL_KINDS = ["system.approved"];
1583
+ /** `listColumnEntries` from the board's first column change on (drift check, item 3). */
1584
+ const FROM_THE_FIRST_CHANGE = 0;
1585
+ /**
1586
+ * Every row the metrics of these workspaces are computed from: three
1587
+ * statements per workspace (columns, milestones, cards) plus three across all
1588
+ * of them (column changes through the `(field, created_at)` index, runs,
1589
+ * approvals); none per card.
1590
+ *
1591
+ * The rows are read and the metrics computed in TypeScript rather than in SQL
1592
+ * (ruling R012-1): a board is thousands of rows, not millions; percentiles, ISO
1593
+ * weeks and a day-by-day replay would otherwise be written once per dialect;
1594
+ * and a host whose workspaces live on several boards can put their rows
1595
+ * together and compute once.
1596
+ */
1597
+ async function readBoardMetricsHistory(store, workspaceIds) {
1598
+ const ids = [...new Set(workspaceIds)];
1599
+ if (ids.length === 0) return {
1600
+ statuses: [],
1601
+ milestones: [],
1602
+ cards: [],
1603
+ entries: [],
1604
+ runs: [],
1605
+ approvals: []
1606
+ };
1607
+ const perWorkspace = async (read) => (await Promise.all(ids.map(read))).flat();
1608
+ const [statuses, milestones, cards, entries, runs, approvals] = await Promise.all([
1609
+ perWorkspace(async (id) => await store.statuses.listByWorkspace(id)),
1610
+ perWorkspace(async (id) => await store.milestones.listByWorkspace(id)),
1611
+ perWorkspace(async (id) => await store.issues.listByWorkspace(id)),
1612
+ listColumnEntries(store, {
1613
+ workspaceIds: ids,
1614
+ since: FROM_THE_FIRST_CHANGE
1615
+ }),
1616
+ store.runs.listByWorkspaces(ids),
1617
+ store.comments.listByWorkspaces(ids, APPROVAL_KINDS)
1618
+ ]);
1619
+ return {
1620
+ statuses,
1621
+ milestones,
1622
+ cards,
1623
+ entries,
1624
+ runs,
1625
+ approvals
1626
+ };
1627
+ }
1628
+ /** How work flows on the board over `input`'s range (`computeBoardMetrics`), read from the store; without cost. */
1629
+ async function boardMetrics(store, input, now = currentUnixSeconds()) {
1630
+ return computeBoardMetrics(await readBoardMetricsHistory(store, input.workspaceIds), input, now);
1631
+ }
1632
+ //#endregion
1633
+ //#region src/domain/field-changes.ts
1634
+ /**
1635
+ * The issue columns whose edits are written to the audit trail.
1636
+ *
1637
+ * `statusLine` is absent on purpose: `writeStatusLine` records every line it
1638
+ * writes itself, and no patch ever carries the column. `waitingFor` is absent
1639
+ * too — whose turn it is shows in the card's own line and in the
1640
+ * `system.approved` / `system.returned` comments around it, so a row saying the
1641
+ * same thing a third time would only crowd the history.
1642
+ */
1643
+ const TRACKED_FIELDS = [
1644
+ "workspaceId",
1645
+ "title",
1646
+ "description",
1647
+ "priority",
1648
+ "labels",
1649
+ "milestoneId",
1650
+ "parentIssueId",
1651
+ "statusId",
1652
+ "assigneeKind",
1653
+ "assigneeId",
1654
+ "dueDate",
1655
+ "delegateAgentId",
1656
+ "delegateProviderTargetId",
1657
+ "contextRefs",
1658
+ "executionMode",
1659
+ "order"
1660
+ ];
1661
+ /**
1662
+ * The audit rows one patch produces: a row per tracked field whose stringified
1663
+ * value actually changed. Nothing is written here — the caller inserts what
1664
+ * comes back, so the diff stays a pure function of the row, the patch, the
1665
+ * actor and the clock.
1666
+ */
1667
+ function diffFieldChanges(before, updates, actor, now) {
1668
+ const rows = [];
1669
+ for (const field of TRACKED_FIELDS) {
1670
+ if (!(field in updates)) continue;
1671
+ const rawBefore = before[field === "labels" ? "labels" : field];
1672
+ const rawAfter = updates[field];
1673
+ const fromValue = rawBefore == null ? null : String(rawBefore);
1674
+ const toValue = rawAfter == null ? null : String(rawAfter);
1675
+ if (fromValue === toValue) continue;
1676
+ rows.push({
1677
+ id: randomUUID(),
1678
+ issueId: before.id,
1679
+ field,
1680
+ fromValue,
1681
+ toValue,
1682
+ actorKind: actor.kind,
1683
+ actorId: actor.id ?? null,
1684
+ sourceChatSessionId: actor.sourceChatSessionId ?? null,
1685
+ createdAt: now
1686
+ });
1687
+ }
1688
+ return rows;
1689
+ }
1690
+ //#endregion
1115
1691
  //#region src/ops/change-seq.ts
1116
1692
  /** The `kanban_meta` row every board write bumps. */
1117
1693
  const BOARD_CHANGE_KEY = "board";
@@ -1347,7 +1923,9 @@ const UNMET_ENTRY_RULE_REASONS = [
1347
1923
  "ci_red",
1348
1924
  "approval_pending",
1349
1925
  "returned",
1350
- "not_approved"
1926
+ "not_approved",
1927
+ "not_allowed_from",
1928
+ "column_full"
1351
1929
  ];
1352
1930
  const entryRulesSchema = z.array(z.enum(ENTRY_RULES));
1353
1931
  /**
@@ -1563,6 +2141,7 @@ async function applyColumnTemplate(store, workspaceId, columns, options, scope)
1563
2141
  workspaceId,
1564
2142
  ...column
1565
2143
  }, order);
2144
+ await writeStandardFlow(tx, await listColumns(tx, workspaceId));
1566
2145
  return {
1567
2146
  statuses: await listColumns(tx, workspaceId),
1568
2147
  added: columns.map((column) => column.name)
@@ -1672,6 +2251,80 @@ async function setColumnEntryRules(store, workspaceId, nameOrId, entryRules, act
1672
2251
  return await updateColumn(tx, (await requireColumn(tx, workspaceId, nameOrId)).id, { entryRules }, actor, { tx });
1673
2252
  }, scope);
1674
2253
  }
2254
+ /** The largest WIP limit: what a Postgres `integer` column holds. */
2255
+ const MAX_WIP_LIMIT = 2147483647;
2256
+ /**
2257
+ * Set where a card may enter the column from and how many cards it holds:
2258
+ * `allowedFrom` the ids of other columns of its board (`null` for any, `[]` for
2259
+ * none), kept once each in board order; `wipLimit` a whole number from 1 to 2147483647, or `null`. A
2260
+ * person's to set, like the entry rules: an agent that could open a column
2261
+ * could walk any card into it.
2262
+ */
2263
+ async function setColumnFlowRules(store, columnId, rules, actor, scope) {
2264
+ assertPersonSetsEntryRules(actor, { statusId: columnId });
2265
+ if (rules.wipLimit !== null && !(Number.isSafeInteger(rules.wipLimit) && rules.wipLimit >= 1 && rules.wipLimit <= MAX_WIP_LIMIT)) throw new BoardError("board_flow_rule_invalid", {
2266
+ statusId: columnId,
2267
+ wipLimit: rules.wipLimit
2268
+ });
2269
+ return await runBoardWrite(store, async ({ tx }) => {
2270
+ const column = await tx.statuses.findById(columnId);
2271
+ if (!column) throw new BoardError("issue_status_not_found", { statusId: columnId });
2272
+ const allowedFrom = rules.allowedFrom === null ? null : allowedFromInBoardOrder(await listColumns(tx, column.workspaceId), column, rules.allowedFrom);
2273
+ await tx.statuses.update(columnId, {
2274
+ allowedFrom: serializeAllowedFrom(allowedFrom),
2275
+ wipLimit: rules.wipLimit
2276
+ });
2277
+ return await tx.statuses.findById(columnId);
2278
+ }, scope);
2279
+ }
2280
+ /** The ids asked for, once each and in board order — or the refusal naming those that are not another column of this board. */
2281
+ function allowedFromInBoardOrder(columns, column, requested) {
2282
+ const others = columns.filter((other) => other.id !== column.id);
2283
+ const invalid = requested.filter((id) => !others.some((other) => other.id === id));
2284
+ if (invalid.length > 0) throw new BoardError("board_flow_rule_invalid", {
2285
+ statusId: column.id,
2286
+ allowedFrom: invalid
2287
+ });
2288
+ return others.filter((other) => requested.includes(other.id)).map((other) => other.id);
2289
+ }
2290
+ /**
2291
+ * Put the standard flow on a board: Done accepts cards from In Review only —
2292
+ * and from QA, when the board has it — so nothing reaches Done but through
2293
+ * review. Done's WIP limit and every other column stay as they are. A board
2294
+ * without Done, or with neither In Review nor QA, has nothing to apply it to.
2295
+ */
2296
+ async function applyStandardFlow(store, workspaceId, actor, scope) {
2297
+ assertPersonSetsEntryRules(actor, { workspaceId });
2298
+ return await runBoardWrite(store, async ({ tx }) => {
2299
+ const columns = await listColumns(tx, workspaceId);
2300
+ if (!await writeStandardFlow(tx, columns)) {
2301
+ const hasDone = columns.some((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
2302
+ throw new BoardError("issue_status_not_found", {
2303
+ workspaceId,
2304
+ statusName: hasDone ? "In Review" : "Done"
2305
+ });
2306
+ }
2307
+ return await listColumns(tx, workspaceId);
2308
+ }, scope);
2309
+ }
2310
+ /** Done takes cards from the board's In Review and QA; `false`, with nothing written, when the board has no such columns. */
2311
+ async function writeStandardFlow(store, columns) {
2312
+ const flow = standardFlowOf(columns);
2313
+ if (!flow) return false;
2314
+ await store.statuses.update(flow.target.id, { allowedFrom: serializeAllowedFrom(flow.sources.map((column) => column.id)) });
2315
+ return true;
2316
+ }
2317
+ /**
2318
+ * Take a deleted column out of every list of where cards may come from. A list
2319
+ * it empties stays empty: a column that took cards only from it does not open
2320
+ * to every column.
2321
+ */
2322
+ async function dropFromAllowedFrom(store, workspaceId, statusId) {
2323
+ for (const other of await listColumns(store, workspaceId)) {
2324
+ const { allowedFrom } = readFlowRules(other);
2325
+ if (allowedFrom?.includes(statusId)) await store.statuses.update(other.id, { allowedFrom: serializeAllowedFrom(allowedFrom.filter((id) => id !== statusId)) });
2326
+ }
2327
+ }
1675
2328
  /**
1676
2329
  * Take an empty column off the board, and number the columns left 0..n-1 in
1677
2330
  * the same write.
@@ -1698,6 +2351,7 @@ async function deleteColumn(store, statusId, scope, actor) {
1698
2351
  hint: "Move its cards to another column first, or remove it with removeColumn and moveCardsTo."
1699
2352
  });
1700
2353
  await tx.statuses.delete(statusId);
2354
+ await dropFromAllowedFrom(tx, column.workspaceId, statusId);
1701
2355
  await renumberColumns(tx, column.workspaceId);
1702
2356
  }, scope);
1703
2357
  }
@@ -1977,24 +2631,46 @@ function pullRequestFactKeys({ owner, repo, number }) {
1977
2631
  //#endregion
1978
2632
  //#region src/ops/entry-rules.ts
1979
2633
  /**
1980
- * The column's entry rules the card does not meet (ruling 6-2). A column with
1981
- * no rules answers at once, without reading anything; otherwise only the facts
1982
- * the column's rules need are read.
2634
+ * The column's entry rules and flow rules the card does not meet, entry rules
2635
+ * first (ruling 6-2; board-workflow spec, Part 2). A column with no rules
2636
+ * answers at once, without reading anything; otherwise only the facts its
2637
+ * rules need are read. `entering` counts the cards this same write already let
2638
+ * into the column, so a bulk move is held to the WIP limit as a whole.
1983
2639
  */
1984
- async function checkColumnEntry(store, card, column) {
2640
+ async function checkColumnEntry(store, card, column, entering) {
1985
2641
  const rules = readEntryRules(column);
1986
- if (rules.length === 0) return [];
1987
- return evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
2642
+ const unmet = rules.length === 0 ? [] : evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
2643
+ const flow = readFlowRules(column);
2644
+ if (flow.allowedFrom === null && flow.wipLimit === null) return unmet;
2645
+ return [...unmet, ...evaluateFlowRules(card, column, flow, await readFlowRuleFacts(store, column, flow, entering))];
2646
+ }
2647
+ /** 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. */
2648
+ async function readFlowRuleFacts(store, column, flow, entering) {
2649
+ const columnNames = flow.allowedFrom === null ? /* @__PURE__ */ new Map() : new Map((await store.statuses.listByWorkspace(column.workspaceId)).map((other) => [other.id, other.name]));
2650
+ if (flow.wipLimit === null) return {
2651
+ columnNames,
2652
+ cardsInColumn: 0
2653
+ };
2654
+ await store.statuses.lockById(column.id);
2655
+ return {
2656
+ columnNames,
2657
+ cardsInColumn: (await store.issues.listPage({
2658
+ workspaceId: column.workspaceId,
2659
+ statusIds: [column.id],
2660
+ limit: 1
2661
+ })).total + entering
2662
+ };
1988
2663
  }
1989
2664
  /**
1990
- * Hold a card at the door of a column that asks for more than it has (ruling
1991
- * 6-3). Anyone but a person — or a person's own terminal, `placesForPerson`
1992
- * (ruling 6-6) — is refused with `board_column_rules_unmet`, which
1993
- * names the column and every unmet rule; a person is let through, and the
1994
- * unmet rules come back so the caller can warn them.
2665
+ * Hold a card at the door of a column that asks for more than it has — entry
2666
+ * rules (ruling 6-3), or flow rules: a column it may not come from, or a column
2667
+ * that is full. Anyone but a person — or a person's own terminal,
2668
+ * `placesForPerson` (ruling 6-6) — is refused with `board_column_rules_unmet`,
2669
+ * which names the column and everything unmet; a person is let through, and
2670
+ * the unmet list comes back so the caller can warn them.
1995
2671
  */
1996
- async function enforceColumnEntry(store, card, column, actor) {
1997
- const unmet = await checkColumnEntry(store, card, column);
2672
+ async function enforceColumnEntry(store, card, column, actor, entering = 0) {
2673
+ const unmet = await checkColumnEntry(store, card, column, entering);
1998
2674
  if (unmet.length > 0 && actor.kind !== "user" && !actor.placesForPerson) throw new BoardError("board_column_rules_unmet", {
1999
2675
  issueId: card.id,
2000
2676
  column: column.name,
@@ -2007,14 +2683,17 @@ async function enforceColumnEntry(store, card, column, actor) {
2007
2683
  * The same door for several cards entering one column at once — a bulk status
2008
2684
  * change. All or nothing: the first card an agent may not move refuses the
2009
2685
  * whole change. A person gets back the cards that entered with rules unmet.
2686
+ * Each card counts against the column's WIP limit as the cards before it in the same change entered it.
2010
2687
  */
2011
2688
  async function enforceColumnEntryForCards(store, issueIds, statusId, actor) {
2012
2689
  const column = await store.statuses.findById(statusId);
2013
2690
  if (!column) throw new BoardError("issue_status_not_found", { statusId });
2014
2691
  const entered = [];
2692
+ let entering = 0;
2015
2693
  for (const card of await store.issues.listByIds(issueIds)) {
2016
2694
  if (card.statusId === column.id) continue;
2017
- const unmet = await enforceColumnEntry(store, card, column, actor);
2695
+ const unmet = await enforceColumnEntry(store, card, column, actor, entering);
2696
+ entering++;
2018
2697
  if (unmet.length > 0) entered.push({
2019
2698
  issueId: card.id,
2020
2699
  unmet
@@ -2169,11 +2848,24 @@ const STOPPED_BY_YOU_STATUS_LINE = "stopped by you";
2169
2848
  * the board reads the same whether the agent was launched by an app or from
2170
2849
  * somewhere else entirely. The attempt number is simply how many runs the card
2171
2850
  * has had — a card whose runs were all launched elsewhere still counts them.
2851
+ *
2852
+ * A card has one run going at a time. A start on a card whose run is still going
2853
+ * is refused with `board_card_run_active`, naming that run — whichever path it
2854
+ * came by, an app's launch, `kanbo run start`, MCP, or a second app on a shared
2855
+ * Postgres board. The card's row is locked first, so two starts racing for it
2856
+ * are settled one after the other.
2172
2857
  */
2173
2858
  async function startRun(store, issueId, input, actor, scope) {
2174
2859
  const externalSessionRef = readExternalSessionRef(input.externalSessionRef) ?? null;
2175
2860
  return await runBoardWrite(store, async ({ tx }) => {
2176
- const card = await requireCard$1(tx, issueId);
2861
+ const card = await tx.issues.lockById(issueId);
2862
+ if (!card) throw new BoardError("issue_not_found", { issueId });
2863
+ const [active] = await tx.runs.listRunning(issueId);
2864
+ if (active) throw new BoardError("board_card_run_active", {
2865
+ issueId,
2866
+ runId: active.id,
2867
+ agentName: active.agentName
2868
+ });
2177
2869
  const attempt = await tx.runs.countByIssue(issueId) + 1;
2178
2870
  const executionMode = input.executionMode ?? card.executionMode;
2179
2871
  const run = await tx.runs.create({
@@ -2439,7 +3131,8 @@ async function createCard(store, input, actor, scope) {
2439
3131
  const column = statusId === null ? null : await tx.statuses.findById(statusId);
2440
3132
  const unmet = column ? await enforceColumnEntry(tx, {
2441
3133
  id: null,
2442
- description: input.description ?? null
3134
+ description: input.description ?? null,
3135
+ statusId: null
2443
3136
  }, column, actor) : [];
2444
3137
  const milestoneId = await requireMilestoneInWorkspace(tx, workspaceId, input.milestoneId ?? null);
2445
3138
  const parentIssueId = await requireParentInWorkspace(tx, workspaceId, input.parentIssueId ?? null);
@@ -2447,8 +3140,9 @@ async function createCard(store, input, actor, scope) {
2447
3140
  const now = currentUnixSeconds();
2448
3141
  for (let attempt = 1;; attempt++) {
2449
3142
  const identity = await nextIssueIdentity(input.workspace, tx);
3143
+ let created;
2450
3144
  try {
2451
- return withUnmetRules(await tx.issues.create({
3145
+ created = await tx.issues.create({
2452
3146
  id: identity.id,
2453
3147
  workspaceId,
2454
3148
  title: input.title?.trim() || identity.id,
@@ -2472,12 +3166,25 @@ async function createCard(store, input, actor, scope) {
2472
3166
  order,
2473
3167
  createdAt: now,
2474
3168
  updatedAt: now
2475
- }), unmet);
3169
+ });
2476
3170
  } catch (error) {
2477
3171
  const numberTaken = await tx.issues.findByNumber(workspaceId, identity.number);
2478
3172
  const keyTaken = await tx.issues.findById(identity.id);
2479
3173
  if (attempt >= NUMBERING_ATTEMPTS || !numberTaken && !keyTaken) throw error;
3174
+ continue;
2480
3175
  }
3176
+ if (statusId !== null) await tx.fieldChanges.create({
3177
+ id: randomUUID(),
3178
+ issueId: created.id,
3179
+ field: "statusId",
3180
+ fromValue: null,
3181
+ toValue: statusId,
3182
+ actorKind: toStoredActorKind(actor),
3183
+ actorId: actor.id ?? null,
3184
+ sourceChatSessionId: actor.chatSessionId ?? null,
3185
+ createdAt: now
3186
+ });
3187
+ return withUnmetRules(created, unmet);
2481
3188
  }
2482
3189
  }, scope);
2483
3190
  }
@@ -2533,7 +3240,8 @@ async function updateCard(store, issueId, input, actor, scope) {
2533
3240
  const description = "description" in updates ? updates.description ?? null : card.description;
2534
3241
  unmet = await enforceColumnEntry(tx, {
2535
3242
  id: card.id,
2536
- description
3243
+ description,
3244
+ statusId: card.statusId
2537
3245
  }, column, actor);
2538
3246
  }
2539
3247
  if ("assigneeKind" in input) updates.assigneeKind = input.assigneeKind ?? null;
@@ -2873,63 +3581,175 @@ async function numberTakenByAnother(store, workspaceId, card) {
2873
3581
  /** How far apart a reorder, or a move onto another board, spaces the cards it places. */
2874
3582
  const CARD_ORDER_GAP = 1024;
2875
3583
  /**
2876
- * Apply one change to many cards, in one write, each card's history recording
2877
- * what changed on it.
3584
+ * Apply one change to many cards, in one write, each card's history recording
3585
+ * what changed on it.
3586
+ *
3587
+ * Every card is checked before anything is written: a column or a milestone
3588
+ * must belong to each card's own workspace, and the column's door holds for
3589
+ * every card at once — an agent moving one card that does not qualify moves
3590
+ * none (ruling 6-3). An id the board does not hold is skipped, not refused.
3591
+ */
3592
+ async function bulkUpdateCards(store, issueIds, input, actor, scope) {
3593
+ if (issueIds.length === 0) return { updated: 0 };
3594
+ const uniqueIssueIds = [...new Set(issueIds)];
3595
+ const updates = { updatedAt: currentUnixSeconds() };
3596
+ if ("statusId" in input) updates.statusId = input.statusId ?? null;
3597
+ if (input.priority !== void 0) updates.priority = input.priority;
3598
+ if (input.labels !== void 0) updates.labels = JSON.stringify(input.labels);
3599
+ if ("milestoneId" in input) updates.milestoneId = input.milestoneId ?? null;
3600
+ if ("assigneeKind" in input) updates.assigneeKind = input.assigneeKind ?? null;
3601
+ if ("assigneeId" in input) updates.assigneeId = input.assigneeId ?? null;
3602
+ if ("dueDate" in input) updates.dueDate = input.dueDate ?? null;
3603
+ return await runBoardWrite(store, async ({ tx }) => {
3604
+ const cards = await tx.issues.listByIds(uniqueIssueIds);
3605
+ for (const card of cards) {
3606
+ if (input.statusId != null && !await tx.statuses.findInWorkspace(card.workspaceId, input.statusId)) throw new BoardError("issue_status_not_found", {
3607
+ workspaceId: card.workspaceId,
3608
+ statusId: input.statusId
3609
+ });
3610
+ if ("milestoneId" in input) await requireMilestoneInWorkspace(tx, card.workspaceId, input.milestoneId ?? null);
3611
+ }
3612
+ const unmetRules = input.statusId ? await enforceColumnEntryForCards(tx, uniqueIssueIds, input.statusId, actor) : [];
3613
+ for (const card of cards) await recordFieldChanges(tx, card, updates, actor);
3614
+ const updated = await tx.issues.updateMany(uniqueIssueIds, updates);
3615
+ return unmetRules.length > 0 ? {
3616
+ updated,
3617
+ unmetRules
3618
+ } : { updated };
3619
+ }, scope);
3620
+ }
3621
+ /**
3622
+ * Give the named cards of one workspace their positions in the order named.
3623
+ *
3624
+ * Positions are spaced by a gap rather than packed, so a card dropped between
3625
+ * two others later can take a midpoint without moving its neighbours. The whole
3626
+ * order is one write — a drag that half-applied would leave a board no client
3627
+ * could reconcile — and a card that is gone or belongs to another workspace is
3628
+ * skipped rather than failing the rest of the drop.
3629
+ */
3630
+ async function reorderCards(store, workspaceId, orderedIds, scope) {
3631
+ await runBoardWrite(store, async ({ tx }) => {
3632
+ for (const [index, issueId] of orderedIds.entries()) {
3633
+ const card = await tx.issues.findById(issueId);
3634
+ if (!card || card.workspaceId !== workspaceId) continue;
3635
+ await tx.issues.update(issueId, {
3636
+ order: (index + 1) * CARD_ORDER_GAP,
3637
+ updatedAt: currentUnixSeconds()
3638
+ });
3639
+ }
3640
+ }, scope);
3641
+ }
3642
+ //#endregion
3643
+ //#region src/ops/ready.ts
3644
+ /** The column an agent takes its next card from. */
3645
+ const READY_COLUMN_SLUG = "to_do";
3646
+ /**
3647
+ * The cards an agent may pick up right now: spelled out, nobody working on them,
3648
+ * nobody's turn but the agent's.
3649
+ *
3650
+ * "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
3651
+ * run is taken, and a card waiting for a person belongs to that person until
3652
+ * they answer. What is left is the queue — in board order, so the card someone
3653
+ * dragged to the top is the one an agent asking for work gets.
3654
+ */
3655
+ async function listReady(store, input) {
3656
+ return (await queryReady(store, input)).cards;
3657
+ }
3658
+ /**
3659
+ * One page of the ready cards, how many are ready in all, and the columns read
3660
+ * on the way — the rule above, asked of the database in one statement rather
3661
+ * than of every card on the board.
3662
+ */
3663
+ async function queryReady(store, input) {
3664
+ const columns = await listColumns(store, input.workspaceId);
3665
+ const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
3666
+ if (!column) return {
3667
+ cards: [],
3668
+ total: 0,
3669
+ columns
3670
+ };
3671
+ return {
3672
+ ...await store.issues.listPage({
3673
+ workspaceId: input.workspaceId,
3674
+ statusIds: [column.id],
3675
+ waitingForPerson: false,
3676
+ hasActiveRun: false,
3677
+ limit: input.limit,
3678
+ offset: input.offset
3679
+ }),
3680
+ columns
3681
+ };
3682
+ }
3683
+ //#endregion
3684
+ //#region src/ops/claim.ts
3685
+ /**
3686
+ * Take a card to work on, in one board write: pick it, start its run, and move
3687
+ * it into the board's first `started` column (In Progress on a standard
3688
+ * board), its entry and flow rules checked on the way in. Anything refused
3689
+ * leaves nothing written.
2878
3690
  *
2879
- * Every card is checked before anything is written: a column or a milestone
2880
- * must belong to each card's own workspace, and the column's door holds for
2881
- * every card at once — an agent moving one card that does not qualify moves
2882
- * none (ruling 6-3). An id the board does not hold is skipped, not refused.
3691
+ * Two claimers never get the same card. On SQLite the write holds the file
3692
+ * from its first read (`begin immediate`), so the second claimer reads the
3693
+ * board after the first one's commit. On Postgres the picked card's row is
3694
+ * locked (`select … for update`): a claimer that waited on the lock finds the
3695
+ * card taken, and moves on to the next ready one. The target's row is locked
3696
+ * first, always, in the order a move into it locks (column, then card), so a
3697
+ * claim and a move never wait on each other in a circle.
2883
3698
  */
2884
- async function bulkUpdateCards(store, issueIds, input, actor, scope) {
2885
- if (issueIds.length === 0) return { updated: 0 };
2886
- const uniqueIssueIds = [...new Set(issueIds)];
2887
- const updates = { updatedAt: currentUnixSeconds() };
2888
- if ("statusId" in input) updates.statusId = input.statusId ?? null;
2889
- if (input.priority !== void 0) updates.priority = input.priority;
2890
- if (input.labels !== void 0) updates.labels = JSON.stringify(input.labels);
2891
- if ("milestoneId" in input) updates.milestoneId = input.milestoneId ?? null;
2892
- if ("assigneeKind" in input) updates.assigneeKind = input.assigneeKind ?? null;
2893
- if ("assigneeId" in input) updates.assigneeId = input.assigneeId ?? null;
2894
- if ("dueDate" in input) updates.dueDate = input.dueDate ?? null;
3699
+ async function claimCard(store, input, actor, scope) {
2895
3700
  return await runBoardWrite(store, async ({ tx }) => {
2896
- const cards = await tx.issues.listByIds(uniqueIssueIds);
2897
- for (const card of cards) {
2898
- if (input.statusId != null && !await tx.statuses.findInWorkspace(card.workspaceId, input.statusId)) throw new BoardError("issue_status_not_found", {
2899
- workspaceId: card.workspaceId,
2900
- statusId: input.statusId
2901
- });
2902
- if ("milestoneId" in input) await requireMilestoneInWorkspace(tx, card.workspaceId, input.milestoneId ?? null);
2903
- }
2904
- const unmetRules = input.statusId ? await enforceColumnEntryForCards(tx, uniqueIssueIds, input.statusId, actor) : [];
2905
- for (const card of cards) await recordFieldChanges(tx, card, updates, actor);
2906
- const updated = await tx.issues.updateMany(uniqueIssueIds, updates);
2907
- return unmetRules.length > 0 ? {
2908
- updated,
2909
- unmetRules
2910
- } : { updated };
3701
+ const columns = await listColumns(tx, input.workspaceId);
3702
+ const target = columns.find((column) => column.category === "started");
3703
+ if (!target) throw new BoardError("issue_status_not_found", {
3704
+ workspaceId: input.workspaceId,
3705
+ category: "started"
3706
+ });
3707
+ await tx.statuses.lockById(target.id);
3708
+ const toDo = columns.find((column) => normalizeStatusName(column.name) === "to_do") ?? null;
3709
+ const card = input.issueId === void 0 ? await takeFirstReady(tx, input.workspaceId, toDo) : await takeNamed(tx, input.workspaceId, input.issueId, toDo);
3710
+ const run = await startRun(tx, card.id, input.run, actor, { tx });
3711
+ return {
3712
+ card: await updateCard(tx, card.id, { statusId: target.id }, actor, { tx }),
3713
+ run,
3714
+ column: target
3715
+ };
2911
3716
  }, scope);
2912
3717
  }
2913
3718
  /**
2914
- * Give the named cards of one workspace their positions in the order named.
2915
- *
2916
- * Positions are spaced by a gap rather than packed, so a card dropped between
2917
- * two others later can take a midpoint without moving its neighbours. The whole
2918
- * order is one write — a drag that half-applied would leave a board no client
2919
- * could reconcile — and a card that is gone or belongs to another workspace is
2920
- * skipped rather than failing the rest of the drop.
3719
+ * The first ready card, locked. A card another claimer took while this one
3720
+ * waited for its lock is passed over, and the next read no longer lists it; the
3721
+ * cards passed over are counted so the loop ends with the list.
2921
3722
  */
2922
- async function reorderCards(store, workspaceId, orderedIds, scope) {
2923
- await runBoardWrite(store, async ({ tx }) => {
2924
- for (const [index, issueId] of orderedIds.entries()) {
2925
- const card = await tx.issues.findById(issueId);
2926
- if (!card || card.workspaceId !== workspaceId) continue;
2927
- await tx.issues.update(issueId, {
2928
- order: (index + 1) * CARD_ORDER_GAP,
2929
- updatedAt: currentUnixSeconds()
2930
- });
2931
- }
2932
- }, scope);
3723
+ async function takeFirstReady(store, workspaceId, toDo) {
3724
+ const passed = /* @__PURE__ */ new Set();
3725
+ for (;;) {
3726
+ const { cards } = await queryReady(store, {
3727
+ workspaceId,
3728
+ limit: passed.size + 1
3729
+ });
3730
+ const candidate = cards.find((card) => !passed.has(card.id));
3731
+ if (!candidate) throw new BoardError("board_nothing_ready", { workspaceId });
3732
+ const locked = await store.issues.lockById(candidate.id);
3733
+ if (locked && await notReadyReason(store, locked, toDo) === null) return locked;
3734
+ passed.add(candidate.id);
3735
+ }
3736
+ }
3737
+ /** The named card, locked, when it is on this board and ready — or why not. */
3738
+ async function takeNamed(store, workspaceId, issueId, toDo) {
3739
+ const card = await store.issues.lockById(issueId);
3740
+ if (!card || card.workspaceId !== workspaceId) throw new BoardError("issue_not_found", { issueId });
3741
+ const reason = await notReadyReason(store, card, toDo);
3742
+ if (reason) throw new BoardError("board_card_not_ready", {
3743
+ issueId,
3744
+ reason
3745
+ });
3746
+ return card;
3747
+ }
3748
+ /** `queryReady`'s rule for one card already read: nobody on it, nobody waited for, in To Do. */
3749
+ async function notReadyReason(store, card, toDo) {
3750
+ if ((await store.runs.listRunning(card.id)).length > 0) return "run_active";
3751
+ if (card.waitingFor === "human") return "waiting_for_person";
3752
+ return toDo !== null && card.statusId === toDo.id ? null : "not_in_to_do";
2933
3753
  }
2934
3754
  //#endregion
2935
3755
  //#region src/ops/context-refs.ts
@@ -3062,33 +3882,6 @@ async function migrateCards(store, input) {
3062
3882
  };
3063
3883
  }
3064
3884
  //#endregion
3065
- //#region src/domain/card-display-title.ts
3066
- /**
3067
- * What a card is called wherever a person or an agent reads it by name.
3068
- *
3069
- * A card written as a description alone is titled with its own key (every
3070
- * writer does that). Named by its key, such a card says nothing — so its name
3071
- * is the first line of its description instead. A browser bundle that does not
3072
- * load this package keeps the same rule on its own, tested with the same
3073
- * examples.
3074
- */
3075
- /** How long a description line may run before it is cut with an ellipsis. */
3076
- const DESCRIPTION_LINE_LIMIT = 120;
3077
- function cardDisplayTitle(card) {
3078
- if (card.title !== card.id) return card.title;
3079
- return descriptionFirstLine(card.description) ?? card.title;
3080
- }
3081
- function descriptionFirstLine(description) {
3082
- for (const rawLine of (description ?? "").split("\n")) {
3083
- const line = stripMarkdown(rawLine);
3084
- if (line) return line.length > DESCRIPTION_LINE_LIMIT ? `${line.slice(0, 119).trimEnd()}…` : line;
3085
- }
3086
- return null;
3087
- }
3088
- function stripMarkdown(line) {
3089
- 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();
3090
- }
3091
- //#endregion
3092
3885
  //#region src/ops/agent-rules.ts
3093
3886
  /**
3094
3887
  * The sentences that tell an agent how to work this board, written once.
@@ -3116,6 +3909,7 @@ function subtaskSplitSentence(parentReference) {
3116
3909
  }
3117
3910
  const SUBTASK_RULE = `${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("the parent card's id")}`;
3118
3911
  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.";
3912
+ 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.";
3119
3913
  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.";
3120
3914
  /** Said by the `kanbo init` block and the MCP instructions, which name the comment command or tool beside it. */
3121
3915
  const COMMENT_RULE = "Findings, results, decisions and questions go on the card as comments.";
@@ -3139,6 +3933,7 @@ const BOARD_RULES = [
3139
3933
  EXECUTION_MODE_RULE,
3140
3934
  SUBTASK_RULE,
3141
3935
  PULL_REQUEST_RULE,
3936
+ CLAIM_RULE,
3142
3937
  OWN_SESSION_RULE,
3143
3938
  "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."
3144
3939
  ];
@@ -3154,12 +3949,14 @@ const BOARD_RULES = [
3154
3949
  async function buildPrimeText(store, workspaceId) {
3155
3950
  const columns = await listColumns(store, workspaceId);
3156
3951
  const ruled = columns.some((column) => readEntryRules(column).length > 0);
3952
+ const flowing = columns.some((column) => hasFlowRules(column));
3157
3953
  return [
3158
3954
  ...await describeReturnedCards(store, workspaceId, columns),
3159
3955
  "Columns of this board, in board order:",
3160
3956
  "",
3161
- ...columns.length > 0 ? columns.map(describeColumnLine) : ["- (this board has no columns yet)"],
3957
+ ...columns.length > 0 ? columns.map((column) => describeColumnLine(column, columns)) : ["- (this board has no columns yet)"],
3162
3958
  ...ruled ? ["", ENTRY_RULES_NOTE] : [],
3959
+ ...flowing ? ["", FLOW_RULES_NOTE] : [],
3163
3960
  "",
3164
3961
  "How this board works:",
3165
3962
  "",
@@ -3201,11 +3998,22 @@ async function describeReturnedCards(store, workspaceId, columns) {
3201
3998
  * without any reads exactly as it did before they existed.
3202
3999
  */
3203
4000
  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.";
3204
- function describeColumnLine(column) {
4001
+ /** Said under the columns only when one of them has flow rules. */
4002
+ 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.";
4003
+ function hasFlowRules(column) {
4004
+ const { allowedFrom, wipLimit } = readFlowRules(column);
4005
+ return allowedFrom !== null || wipLimit !== null;
4006
+ }
4007
+ function describeColumnLine(column, columns) {
3205
4008
  const slug = normalizeStatusName(column.name);
3206
4009
  const rules = readEntryRules(column);
3207
- const requires = rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : "";
3208
- return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${requires}` : `- ${column.name} (\`${slug}\`)${requires ? ` —${requires}` : ""}`;
4010
+ const said = `${rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : ""}${describeFlow(column, columns)}`;
4011
+ return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${said}` : `- ${column.name} (\`${slug}\`)${said ? ` —${said}` : ""}`;
4012
+ }
4013
+ /** Where a column takes cards from and how many it holds, as the prime text says it; nothing for a column without flow rules. */
4014
+ function describeFlow(column, columns) {
4015
+ const { allowedFrom, wipLimit } = readFlowRules(column);
4016
+ 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"}.`}`;
3209
4017
  }
3210
4018
  //#endregion
3211
4019
  //#region src/domain/pull-request-ref.ts
@@ -3375,47 +4183,6 @@ async function queryCards(store, input) {
3375
4183
  };
3376
4184
  }
3377
4185
  //#endregion
3378
- //#region src/ops/ready.ts
3379
- /** The column an agent takes its next card from. */
3380
- const READY_COLUMN_SLUG = "to_do";
3381
- /**
3382
- * The cards an agent may pick up right now: spelled out, nobody working on them,
3383
- * nobody's turn but the agent's.
3384
- *
3385
- * "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
3386
- * run is taken, and a card waiting for a person belongs to that person until
3387
- * they answer. What is left is the queue — in board order, so the card someone
3388
- * dragged to the top is the one an agent asking for work gets.
3389
- */
3390
- async function listReady(store, input) {
3391
- return (await queryReady(store, input)).cards;
3392
- }
3393
- /**
3394
- * One page of the ready cards, how many are ready in all, and the columns read
3395
- * on the way — the rule above, asked of the database in one statement rather
3396
- * than of every card on the board.
3397
- */
3398
- async function queryReady(store, input) {
3399
- const columns = await listColumns(store, input.workspaceId);
3400
- const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
3401
- if (!column) return {
3402
- cards: [],
3403
- total: 0,
3404
- columns
3405
- };
3406
- return {
3407
- ...await store.issues.listPage({
3408
- workspaceId: input.workspaceId,
3409
- statusIds: [column.id],
3410
- waitingForPerson: false,
3411
- hasActiveRun: false,
3412
- limit: input.limit,
3413
- offset: input.offset
3414
- }),
3415
- columns
3416
- };
3417
- }
3418
- //#endregion
3419
4186
  //#region src/ops/relations.ts
3420
4187
  /**
3421
4188
  * Every edge touching the card, in either direction, each with the card on its
@@ -3705,6 +4472,8 @@ function createBoardOps(store) {
3705
4472
  updateColumn: async (statusId, patch, actor, scope) => await updateColumn(store, statusId, patch, actor, scope),
3706
4473
  describeColumn: async (workspaceId, nameOrId, description, scope) => await describeColumn(store, workspaceId, nameOrId, description, scope),
3707
4474
  setColumnEntryRules: async (workspaceId, nameOrId, entryRules, actor, scope) => await setColumnEntryRules(store, workspaceId, nameOrId, entryRules, actor, scope),
4475
+ setColumnFlowRules: async (columnId, rules, actor, scope) => await setColumnFlowRules(store, columnId, rules, actor, scope),
4476
+ applyStandardFlow: async (workspaceId, actor, scope) => await applyStandardFlow(store, workspaceId, actor, scope),
3708
4477
  enforceColumnEntryForCards: async (issueIds, statusId, actor) => await enforceColumnEntryForCards(store, issueIds, statusId, actor),
3709
4478
  deleteColumn: async (statusId, scope, actor) => await deleteColumn(store, statusId, scope, actor),
3710
4479
  reorderColumns: async (workspaceId, orderedIds, scope, actor) => await reorderColumns(store, workspaceId, orderedIds, scope, actor),
@@ -3715,6 +4484,7 @@ function createBoardOps(store) {
3715
4484
  moveColumn: async (workspaceId, nameOrId, position, actor, scope) => await moveColumn$1(store, workspaceId, nameOrId, position, actor, scope),
3716
4485
  removeColumn: async (workspaceId, nameOrId, options, actor, scope) => await removeColumn$1(store, workspaceId, nameOrId, options, actor, scope),
3717
4486
  startRun: async (issueId, input, actor, scope) => await startRun(store, issueId, input, actor, scope),
4487
+ claimCard: async (input, actor, scope) => await claimCard(store, input, actor, scope),
3718
4488
  attachRunExecution: async (runId, input, scope) => await attachRunExecution(store, runId, input, scope),
3719
4489
  recordRunSessionRef: async (runId, ref, scope) => await recordRunSessionRef(store, runId, ref, scope),
3720
4490
  clearRunSessionRef: async (runId, actor, options, scope) => await clearRunSessionRef(store, runId, actor, options, scope),
@@ -3727,7 +4497,10 @@ function createBoardOps(store) {
3727
4497
  readBoardProjectionForIssue: async (issueId) => await readBoardProjectionForIssue(store, issueId),
3728
4498
  listReady: async (input) => await listReady(store, input),
3729
4499
  queryReady: async (input) => await queryReady(store, input),
3730
- buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId)
4500
+ listColumnEntries: async (input) => await listColumnEntries(store, input),
4501
+ buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId),
4502
+ readBoardMetricsHistory: async (workspaceIds) => await readBoardMetricsHistory(store, workspaceIds),
4503
+ boardMetrics: async (input, now) => await boardMetrics(store, input, now)
3731
4504
  };
3732
4505
  }
3733
4506
  //#endregion
@@ -4432,9 +5205,10 @@ const SCHEMA_NOT_INSTALLED_MESSAGE = "This Postgres database has no kanbo board
4432
5205
  * caller's own risk but never written to — a write shaped by this build's idea
4433
5206
  * of the schema is how a person's board gets a column full of nothing. A file
4434
5207
  * of this package's own is this package's to migrate, so the same refusal
4435
- * points at `kanbo migrate` instead of at the app. Reads stay open either way,
4436
- * because a person who cannot migrate right now should still be able to look at
4437
- * their cards.
5208
+ * points at `kanbo migrate` instead of at the app. Reads are held to the lesser
5209
+ * bar of `assertReadableBoard`: a file only a stamp behind is still read, so a
5210
+ * person who cannot migrate right now can look at their cards, but one missing
5211
+ * a table or column this build reads is refused with the same sentence.
4438
5212
  */
4439
5213
  function assertWritableBoard(database, owner) {
4440
5214
  try {
@@ -4445,6 +5219,26 @@ function assertWritableBoard(database, owner) {
4445
5219
  }
4446
5220
  }
4447
5221
  /**
5222
+ * Refuse a read of a board file that lacks a table or a column this build
5223
+ * reads — a file a migration behind — with the sentence a write gets, instead
5224
+ * of letting the first statement fail on what is missing. A file whose only
5225
+ * difference is its schema stamp is still read: every statement this build
5226
+ * makes works on it, and a person who cannot migrate now can still look at
5227
+ * their cards.
5228
+ */
5229
+ function assertReadableBoard(database, owner) {
5230
+ try {
5231
+ assertBoardSchema$1(database);
5232
+ } catch (error) {
5233
+ if (!(error instanceof BoardError && error.code === "board_schema_outdated")) throw error;
5234
+ if (lacksWhatThisBuildReads(error.details)) throw new CliError(3, owner === "kanbo" ? FILE_SCHEMA_OUTDATED_MESSAGE : SCHEMA_OUTDATED_MESSAGE);
5235
+ }
5236
+ }
5237
+ /** Whether `assertBoardSchema` found a table or a column missing, rather than only another stamp. */
5238
+ function lacksWhatThisBuildReads(details) {
5239
+ return details !== void 0 && ("missingTables" in details || "missingAddedTables" in details || "missingColumns" in details);
5240
+ }
5241
+ /**
4448
5242
  * Refuse an external board this build cannot speak for — before reading it as
4449
5243
  * well as before writing to it.
4450
5244
  *
@@ -4683,6 +5477,7 @@ async function openFileSession(target, options, mode, cwd) {
4683
5477
  assertWritableBoard(board.database, target.owner);
4684
5478
  };
4685
5479
  if (mode === "write") assertWritableBoard(board.database, target.owner);
5480
+ else assertReadableBoard(board.database, target.owner);
4686
5481
  const workspace = resolveWorkspace(target.owner === "kanbo" ? { kind: "file" } : {
4687
5482
  kind: "sqlite",
4688
5483
  database: board.database
@@ -4706,8 +5501,9 @@ async function openFileSession(target, options, mode, cwd) {
4706
5501
  /**
4707
5502
  * The same, on a board that is a database of its own.
4708
5503
  *
4709
- * The guard runs whatever the command came to do. A board file that this build
4710
- * cannot write to is still a board worth reading; a connection string pointing
5504
+ * The guard runs whatever the command came to do. A board file whose only
5505
+ * difference from this build is its schema stamp is still a board worth
5506
+ * reading; a connection string pointing
4711
5507
  * at a database with no board in it is nothing to read at all, and every
4712
5508
  * command would otherwise fail one statement later with the driver's own words.
4713
5509
  */
@@ -4764,6 +5560,17 @@ function parseCount(value) {
4764
5560
  return count;
4765
5561
  }
4766
5562
  /**
5563
+ * A date as the command line takes it: `YYYY-MM-DD`, read as a UTC day, or a
5564
+ * unix-seconds number taken as it is. `end` turns a day into its last second,
5565
+ * so a range or a sprint ending on a day runs through that day.
5566
+ */
5567
+ function parseCalendarDay(value, end) {
5568
+ if (/^\d+$/.test(value)) return Number(value);
5569
+ const day = /^\d{4}-\d{2}-\d{2}$/.test(value) ? Date.parse(`${value}T00:00:00Z`) : NaN;
5570
+ if (Number.isNaN(day) || new Date(day).toISOString().slice(0, 10) !== value) throw new CliError(1, `Expected a date as YYYY-MM-DD or unix seconds, got "${value}".`);
5571
+ return day / 1e3 + (end ? 86399 : 0);
5572
+ }
5573
+ /**
4767
5574
  * The card a caller named, in the workspace the command is about.
4768
5575
  *
4769
5576
  * People and agents refer to a card by whatever they have in front of them: the
@@ -5005,7 +5812,9 @@ function projectCardActiveRun(run) {
5005
5812
  startedAt: run.startedAt
5006
5813
  };
5007
5814
  }
5008
- function projectColumn(column) {
5815
+ /** A column as the command line prints it; `board` is every column, to name the ones it accepts cards from. */
5816
+ function projectColumn(column, board) {
5817
+ const { allowedFrom, wipLimit } = readFlowRules(column);
5009
5818
  return {
5010
5819
  id: column.id,
5011
5820
  name: column.name,
@@ -5013,7 +5822,9 @@ function projectColumn(column) {
5013
5822
  description: column.description,
5014
5823
  category: column.category,
5015
5824
  order: column.order,
5016
- entryRules: readEntryRules(column)
5825
+ entryRules: readEntryRules(column),
5826
+ allowedFrom: allowedFrom === null ? null : board.filter((other) => allowedFrom.includes(other.id)).map((other) => normalizeStatusName(other.name)),
5827
+ wipLimit
5017
5828
  };
5018
5829
  }
5019
5830
  function projectSprint(sprint) {
@@ -5085,7 +5896,9 @@ function describeColumns(columns) {
5085
5896
  return columns.map((column) => [
5086
5897
  `${padToWidth(sanitizeTerminalText(column.slug), slugWidth)} ${sanitizeTerminalText(column.name)}`,
5087
5898
  column.description ? ` — ${sanitizeTerminalText(column.description)}` : "",
5088
- column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : ""
5899
+ column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : "",
5900
+ column.allowedFrom === null ? "" : ` [from: ${column.allowedFrom.length > 0 ? column.allowedFrom.join(", ") : "no column"}]`,
5901
+ column.wipLimit === null ? "" : ` [wip: ${column.wipLimit}]`
5089
5902
  ].join("")).join("\n");
5090
5903
  }
5091
5904
  /** A unix-seconds date as the UTC day it falls on, `—` for none. */
@@ -5407,6 +6220,52 @@ function registerCapabilitiesCommand(program) {
5407
6220
  });
5408
6221
  }
5409
6222
  //#endregion
6223
+ //#region src/cli/commands/claim.ts
6224
+ /**
6225
+ * `kanbo claim [card]` — take a card to work on, in one step.
6226
+ *
6227
+ * The run starts and the card moves out of To Do into the board's first
6228
+ * work-in-progress column in one write, so two agents in two terminals never
6229
+ * take the same card. Filed as a placement: in a person's own terminal the
6230
+ * column's rules warn, in an agent's shell they refuse.
6231
+ */
6232
+ function registerClaimCommand(program) {
6233
+ 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) => {
6234
+ await runBoardCommand(options, "write", async (session) => {
6235
+ const actor = createPlacementActor();
6236
+ const named = reference === void 0 ? void 0 : (await requireCard(session, reference)).id;
6237
+ const claimed = await session.ops.claimCard({
6238
+ workspaceId: session.workspace.id,
6239
+ issueId: named,
6240
+ run: {
6241
+ agentName: options.agent ?? actor.id ?? "agent",
6242
+ externalSessionRef: options.session ?? sessionRefFromEnvironment() ?? void 0,
6243
+ launchedByKind: "external"
6244
+ }
6245
+ }, actor);
6246
+ const [columns, projection, runs] = await Promise.all([
6247
+ session.ops.listColumns(session.workspace.id),
6248
+ session.ops.readBoardProjectionForIssue(claimed.card.id),
6249
+ session.ops.listRuns(claimed.card.id)
6250
+ ]);
6251
+ const card = projectCard(claimed.card, columns, projection);
6252
+ for (const item of claimed.card.unmetRules ?? []) console.error(`kanbo: warning: ${card.id} entered "${claimed.column.name}" without ${item.rule}: ${item.detail}`);
6253
+ const run = projectRun({
6254
+ ...claimed.run,
6255
+ attempt: runs.find((each) => each.id === claimed.run.id)?.attempt ?? runs.length
6256
+ });
6257
+ return {
6258
+ value: {
6259
+ card,
6260
+ run,
6261
+ column: projectColumn(claimed.column, columns)
6262
+ },
6263
+ 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`
6264
+ };
6265
+ });
6266
+ });
6267
+ }
6268
+ //#endregion
5410
6269
  //#region src/mcp/transport.ts
5411
6270
  /** What `kanbo_card_get` may bring along with the card. */
5412
6271
  const KANBO_CARD_INCLUDES = [
@@ -5523,16 +6382,22 @@ function projectKanboSprint(sprint) {
5523
6382
  doneCards: sprint.cards.done
5524
6383
  };
5525
6384
  }
5526
- /** A column of the board, from the row either transport holds. */
5527
- function projectKanboColumn(column) {
6385
+ /** A column of the board, from the row either transport holds; `board` is every column, to name the ones it accepts cards from. */
6386
+ function projectKanboColumn(column, board) {
5528
6387
  const stored = column.entryRules;
6388
+ const allowedIds = typeof column.allowedFrom === "string" ? readFlowRules({
6389
+ allowedFrom: column.allowedFrom,
6390
+ wipLimit: null
6391
+ }).allowedFrom : column.allowedFrom ?? null;
5529
6392
  return {
5530
6393
  id: column.id,
5531
6394
  name: column.name,
5532
6395
  slug: normalizeStatusName(column.name),
5533
6396
  description: column.description,
5534
6397
  order: column.order,
5535
- entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null })
6398
+ entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null }),
6399
+ allowedFrom: allowedIds === null ? null : board.filter((other) => allowedIds.includes(other.id)).map((other) => normalizeStatusName(other.name)),
6400
+ wipLimit: column.wipLimit ?? null
5536
6401
  };
5537
6402
  }
5538
6403
  /**
@@ -5962,10 +6827,11 @@ const COLUMN_CATEGORIES = [
5962
6827
  ];
5963
6828
  /** The whole board's columns after a change, and the sentence that says what changed. */
5964
6829
  async function changed(session, message, extra = {}) {
5965
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
6830
+ const board = await session.ops.listColumns(session.workspace.id);
6831
+ const views = board.map((column) => projectColumn(column, board));
5966
6832
  return {
5967
6833
  value: {
5968
- ...extra,
6834
+ ...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, typeof value === "number" || Array.isArray(value) ? value : projectColumn(value, board)])),
5969
6835
  columns: views
5970
6836
  },
5971
6837
  text: `${message}\n\n${describeColumns(views)}`
@@ -5994,14 +6860,14 @@ async function addColumn(session, request, actor) {
5994
6860
  category: request.category ?? spec.category,
5995
6861
  position: anchor ? request.after !== void 0 ? { after: anchor.id } : { before: anchor.id } : void 0
5996
6862
  }, actor);
5997
- return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: projectColumn(created) });
6863
+ return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: created });
5998
6864
  }
5999
6865
  /** Said after a rename: agents' instructions name no column but To Do, and `kanbo prime` gives them the rest. */
6000
6866
  const RENAME_NOTE = "Agents read column names from kanbo prime; rename is picked up there.";
6001
6867
  async function renameColumn(session, column, newName, actor) {
6002
6868
  const before = await requireNamedColumn(session, column);
6003
6869
  const renamed = await session.ops.renameColumn(session.workspace.id, before.id, newName, actor);
6004
- 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) });
6870
+ 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 });
6005
6871
  }
6006
6872
  /** `--first`, `--last`, `--before <column>` or `--after <column>` — exactly one. */
6007
6873
  function readColumnPosition(options) {
@@ -6033,7 +6899,7 @@ async function removeColumn(session, column, moveCardsTo, actor) {
6033
6899
  const result = await session.ops.removeColumn(session.workspace.id, removing.id, { moveCardsTo }, actor);
6034
6900
  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}`);
6035
6901
  return await changed(session, result.movedTo ? `Removed ${result.removed.name} and moved ${describeCount$1(result.movedCards)} to ${result.movedTo.name}.` : `Removed ${result.removed.name}.`, {
6036
- removed: projectColumn(result.removed),
6902
+ removed: result.removed,
6037
6903
  movedCards: result.movedCards
6038
6904
  });
6039
6905
  }
@@ -6077,6 +6943,14 @@ const BOARD_ERROR_TEXTS = {
6077
6943
  text: "Only a person can approve a card.",
6078
6944
  next: AS_A_PERSON
6079
6945
  }),
6946
+ board_card_not_ready: (details) => ({
6947
+ 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.`,
6948
+ next: "kanbo ready"
6949
+ }),
6950
+ board_card_run_active: (details) => ({
6951
+ text: `${named(details.issueId)} already has a run going: ${named(details.agentName)}'s, run ${named(details.runId)}.`,
6952
+ next: `kanbo run finish ${named(details.runId)} --state stopped`
6953
+ }),
6080
6954
  board_column_name_taken: (details) => ({
6081
6955
  text: `Another column, ${named(details.takenBy)}, already has that name (same slug). Pick another name.`,
6082
6956
  next: "kanbo columns list"
@@ -6107,6 +6981,15 @@ const BOARD_ERROR_TEXTS = {
6107
6981
  }),
6108
6982
  board_entry_rule_invalid: (details) => ({ text: `Unknown column rule: ${list(details.rules)}. Use any of ${list(details.allowed)}.` }),
6109
6983
  board_execution_mode_locked: (details) => ({ text: `${named(details.issueId)} has been started, so where it is worked (${named(details.executionMode)}) can't change now.` }),
6984
+ 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.` } : {
6985
+ text: `A column accepts cards only from other columns of its own board, and ${list(details.allowedFrom)} is not one.`,
6986
+ next: "kanbo columns list"
6987
+ },
6988
+ board_metrics_range_invalid: (details) => typeof details.from === "number" && typeof details.to === "number" ? { text: `The range starts after it ends: it starts on ${utcDayLabel(details.from)} and ends on ${utcDayLabel(details.to)}.` } : { text: "The range starts after it ends." },
6989
+ board_nothing_ready: () => ({
6990
+ text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
6991
+ next: "kanbo ready"
6992
+ }),
6110
6993
  board_pull_request_invalid: (details) => ({ text: `"${named(details.value)}" is not a GitHub pull request. Give its link or owner/repo#number.` }),
6111
6994
  board_pull_request_not_found: (details) => ({
6112
6995
  text: `${named(details.issueId)} has no pull request link "${named(details.linkId)}".`,
@@ -6172,7 +7055,7 @@ const BOARD_ERROR_TEXTS = {
6172
7055
  issue_relation_self_reference: () => ({ text: "A card can't be linked to itself." }),
6173
7056
  issue_status_name_empty: () => ({ text: "Give the column a name." }),
6174
7057
  issue_status_not_found: (details) => ({
6175
- text: `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
7058
+ 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.`,
6176
7059
  next: "kanbo columns list"
6177
7060
  }),
6178
7061
  issue_status_reference_conflict: () => ({ text: "Name the column once: by its id or by its name, not both." }),
@@ -6376,7 +7259,7 @@ async function runColumnsMenu(ui) {
6376
7259
  if (showList) say$1(ui, [
6377
7260
  "",
6378
7261
  "Columns of this board:",
6379
- describeColumns(columns.map(projectColumn))
7262
+ describeColumns(columns.map((column) => projectColumn(column, columns)))
6380
7263
  ].join("\n"));
6381
7264
  let choice;
6382
7265
  try {
@@ -6604,7 +7487,8 @@ function registerColumnsCommands(program) {
6604
7487
  const columns = program.command("columns").description("See and change the board's columns (alone: a menu)");
6605
7488
  withBoardOptions(columns.command("list").description("List the columns in order, with the short name to move cards by")).action(async (options) => {
6606
7489
  await runBoardCommand(options, "read", async (session) => {
6607
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
7490
+ const board = await session.ops.listColumns(session.workspace.id);
7491
+ const views = board.map((column) => projectColumn(column, board));
6608
7492
  return {
6609
7493
  value: views,
6610
7494
  text: describeColumns(views)
@@ -6613,7 +7497,7 @@ function registerColumnsCommands(program) {
6613
7497
  });
6614
7498
  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) => {
6615
7499
  await runBoardCommand(options, "write", async (session) => {
6616
- const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text));
7500
+ const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text), await session.ops.listColumns(session.workspace.id));
6617
7501
  return {
6618
7502
  value: view,
6619
7503
  text: describeColumns([view])
@@ -6624,13 +7508,34 @@ function registerColumnsCommands(program) {
6624
7508
  const actor = requireHumanActor("columns rules", typedWords(command));
6625
7509
  const rules = readRequestedRules(options);
6626
7510
  await runBoardCommand(options, "write", async (session) => {
6627
- const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor));
7511
+ const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor), await session.ops.listColumns(session.workspace.id));
6628
7512
  return {
6629
7513
  value: view,
6630
7514
  text: describeColumns([view])
6631
7515
  };
6632
7516
  });
6633
7517
  });
7518
+ 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) => {
7519
+ const actor = requireHumanActor("columns flow", typedWords(command));
7520
+ if (options.from === void 0 && options.wip === void 0) throw new CliError(1, "Pass --from <columns|any>, --wip <count|none>, or both.");
7521
+ const wipLimit = options.wip === void 0 ? void 0 : readWipLimit(options.wip);
7522
+ await runBoardCommand(options, "write", async (session) => {
7523
+ const workspaceId = session.workspace.id;
7524
+ const board = await session.ops.listColumns(workspaceId);
7525
+ const target = await session.ops.requireColumn(workspaceId, column);
7526
+ const current = readFlowRules(target);
7527
+ await session.ops.setColumnFlowRules(target.id, {
7528
+ allowedFrom: options.from === void 0 ? current.allowedFrom : readAllowedFrom(board, workspaceId, options.from),
7529
+ wipLimit: wipLimit === void 0 ? current.wipLimit : wipLimit
7530
+ }, actor);
7531
+ const after = await session.ops.listColumns(workspaceId);
7532
+ const views = after.map((each) => projectColumn(each, after));
7533
+ return {
7534
+ value: views.find((view) => view.id === target.id),
7535
+ text: describeColumns(views)
7536
+ };
7537
+ });
7538
+ });
6634
7539
  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 Option("--category <category>", "what kind of column it is").choices(COLUMN_CATEGORIES))).action(async (name, options, command) => {
6635
7540
  const actor = requireHumanActor("columns add", typedWords(command));
6636
7541
  await runBoardCommand(options, "write", async (session) => await addColumn(session, {
@@ -6672,7 +7577,7 @@ function registerColumnsCommands(program) {
6672
7577
  mode: "add-missing",
6673
7578
  actor
6674
7579
  });
6675
- const views = statuses.map(projectColumn);
7580
+ const views = statuses.map((column) => projectColumn(column, statuses));
6676
7581
  return {
6677
7582
  value: {
6678
7583
  added,
@@ -6696,7 +7601,8 @@ function registerColumnsCommands(program) {
6696
7601
  return;
6697
7602
  }
6698
7603
  await runBoardCommand({}, "read", async (session) => {
6699
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
7604
+ const board = await session.ops.listColumns(session.workspace.id);
7605
+ const views = board.map((column) => projectColumn(column, board));
6700
7606
  return {
6701
7607
  value: views,
6702
7608
  text: describeColumns(views)
@@ -6726,6 +7632,26 @@ function readRequestedRules(options) {
6726
7632
  if (options.require === void 0) throw new CliError(1, `Pass --require ${ENTRY_RULES.join(",")} (any of them) or --clear.`);
6727
7633
  return options.require.split(",").map((rule) => rule.trim()).filter(Boolean);
6728
7634
  }
7635
+ /** `--from`: `any` for every column, else the columns named, by slug, name or id. */
7636
+ function readAllowedFrom(board, workspaceId, value) {
7637
+ if (value.trim().toLowerCase() === "any") return null;
7638
+ const names = value.split(",").map((name) => name.trim()).filter(Boolean);
7639
+ if (names.length === 0) throw new CliError(1, "Name the columns a card may come from (--from in_review,qa), or --from any.");
7640
+ return names.map((name) => {
7641
+ const found = matchColumn(board, workspaceId, name);
7642
+ if (!found) throw new BoardError("issue_status_not_found", {
7643
+ workspaceId,
7644
+ statusName: name
7645
+ });
7646
+ return found.id;
7647
+ });
7648
+ }
7649
+ /** `--wip`: `none` for no limit, else a whole number of cards, 1 or more. */
7650
+ function readWipLimit(value) {
7651
+ if (value.trim().toLowerCase() === "none") return null;
7652
+ if (!/^\d+$/.test(value.trim()) || Number(value) < 1) throw new CliError(1, "--wip takes a whole number of cards, 1 or more, or none.");
7653
+ return Number(value);
7654
+ }
6729
7655
  //#endregion
6730
7656
  //#region src/package-version.ts
6731
7657
  dirname(fileURLToPath(import.meta.url));
@@ -6735,7 +7661,7 @@ dirname(fileURLToPath(import.meta.url));
6735
7661
  * `package.version`. A build carries it as a literal; running from source
6736
7662
  * (tests, `tsx`) reads the package's own `package.json` instead.
6737
7663
  */
6738
- const KANBO_PACKAGE_VERSION = "0.4.0";
7664
+ const KANBO_PACKAGE_VERSION = "0.6.0";
6739
7665
  //#endregion
6740
7666
  //#region src/cli/setup/paths.ts
6741
7667
  /**
@@ -7084,7 +8010,7 @@ const INSTRUCTION_END_PATTERN = /<!-- KANBO_END\s*-->/g;
7084
8010
  const POINTER_KINDS = /* @__PURE__ */ new Set(["short", "global"]);
7085
8011
  /** The commands an agent uses, spelled once for the guide and the `kanbo prime` cheat sheet. */
7086
8012
  const COMMAND = {
7087
- ready: "kanbo ready",
8013
+ claim: "kanbo claim",
7088
8014
  move: "kanbo card move <id> <column>",
7089
8015
  statusLine: "kanbo card status-line <id> --text \"...\"",
7090
8016
  subtask: "kanbo card create --description \"...\" --parent <id>",
@@ -7103,7 +8029,7 @@ const AGENT_GUIDE_TEXT = [
7103
8029
  "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.",
7104
8030
  "",
7105
8031
  ...[
7106
- `- Take a card from \`${COMMAND.ready}\`.`,
8032
+ `- 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.`,
7107
8033
  `- ${MOVE_CARD_RULE} (\`${COMMAND.move}\`)`,
7108
8034
  `- ${STATUS_LINE_RULE} (\`${COMMAND.statusLine}\`)`,
7109
8035
  `- ${SUBTASK_RULE} (\`${COMMAND.subtask}\`)`,
@@ -7133,12 +8059,13 @@ const COMMAND_SHEET = [
7133
8059
  "- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
7134
8060
  "- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
7135
8061
  "- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
7136
- "- `kanbo run start TST-5 --agent claude` — record a run you started yourself; it prints the finish command",
8062
+ "- `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",
8063
+ "- `kanbo run start TST-5 --agent claude` — record a run on a card you did not claim; it prints the finish command",
7137
8064
  "- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
7138
8065
  `- \`${COMMAND.capabilities}\` — everything else, rarely needed`
7139
8066
  ].join("\n");
7140
8067
  /** How to take work and who approves it — said by both blocks after their first sentence. */
7141
- const POINTER_TAIL = `Take work with \`${COMMAND.ready}\`. ${PERSON_ONLY_RULE}`;
8068
+ const POINTER_TAIL = `Take work with \`${COMMAND.claim}\`. ${PERSON_ONLY_RULE}`;
7142
8069
  /** The body of the project block: what `kanbo instructions short` prints. */
7143
8070
  const PROJECT_BODY = [
7144
8071
  "## Kanbo board",
@@ -7158,7 +8085,7 @@ const GLOBAL_BODY = [
7158
8085
  * The block, markers included, with this body's version and hash on the start
7159
8086
  * marker — and, when given, the kind of text it is (`k=orchestrator`).
7160
8087
  */
7161
- function wrapInstructionBlock(body, version = 2, kind) {
8088
+ function wrapInstructionBlock(body, version = 3, kind) {
7162
8089
  return `<!-- KANBO_START v${version}${kind ? ` k=${kind}` : ""} h=${hashBody(body)} -->\n${body}\n${INSTRUCTION_END}`;
7163
8090
  }
7164
8091
  /** The block `kanbo init --instructions …` writes into a project's own file. */
@@ -7187,7 +8114,7 @@ function inspectBlock(text, current) {
7187
8114
  kind
7188
8115
  });
7189
8116
  if (fields.trim() === "") return inspection(LEGACY_BLOCK_BODIES.includes(body) ? "legacy" : "edited");
7190
- if (version !== void 0 && Number(version) > 2) return inspection("newer");
8117
+ if (version !== void 0 && Number(version) > 3) return inspection("newer");
7191
8118
  if (kind !== null && !POINTER_KINDS.has(kind)) return inspection("chosen");
7192
8119
  if (version === void 0 || hash === void 0 || hashBody(body) !== hash) return inspection("edited");
7193
8120
  return inspection(normalized.slice(found.start, found.end) === current ? "current" : "outdated");
@@ -8217,7 +9144,7 @@ function startUpdateCheck(deps = {}) {
8217
9144
  setTimeout: globalThis.setTimeout,
8218
9145
  clearTimeout: globalThis.clearTimeout
8219
9146
  };
8220
- const current = deps.currentVersion ?? "0.4.0";
9147
+ const current = deps.currentVersion ?? "0.6.0";
8221
9148
  const controller = new AbortController();
8222
9149
  let found = null;
8223
9150
  const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
@@ -10649,7 +11576,7 @@ const KANBO_MCP_INSTRUCTIONS = [
10649
11576
  "kanbo is a kanban board shared by people and agents; these tools read and change its cards.",
10650
11577
  "",
10651
11578
  `- At the start of a session call ${tool("kanbo_prime")} — it returns this board's columns and what each one means.`,
10652
- `- Take work from ${tool("kanbo_ready")}.`,
11579
+ `- 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.`,
10653
11580
  `- ${MOVE_CARD_RULE} (${tool("kanbo_card_move")})`,
10654
11581
  `- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
10655
11582
  `- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
@@ -10740,7 +11667,7 @@ const ORCHESTRATOR_GUIDE = [
10740
11667
  "## Start of every session",
10741
11668
  "1. Run `kanbo prime` (tool: `kanbo_prime`) and read the column descriptions. On this board the description is the contract, not the name.",
10742
11669
  "2. Run `kanbo ready` (`kanbo_ready`). If nothing is ready, say so and stop.",
10743
- "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.",
11670
+ "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.",
10744
11671
  "",
10745
11672
  "## While working",
10746
11673
  "- 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.",
@@ -10767,7 +11694,7 @@ const ORCHESTRATOR_GUIDE = [
10767
11694
  "- Returned: the reason is the newest comment. The card is back in an earlier column; start a new run and address the reason first.",
10768
11695
  "",
10769
11696
  "## Never",
10770
- "- 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.",
11697
+ "- 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.",
10771
11698
  "- Never delete or edit the board's database directly."
10772
11699
  ].join("\n");
10773
11700
  //#endregion
@@ -10812,7 +11739,7 @@ function registerInstructionsCommand(program, dependencies = {}) {
10812
11739
  const found = parseKind(kind);
10813
11740
  const body = await readInstructionText(found);
10814
11741
  const pointer = found === "short" || found === "global";
10815
- const text = options.markers ? wrapInstructionBlock(body, 2, pointer ? void 0 : found) : body;
11742
+ const text = options.markers ? wrapInstructionBlock(body, 3, pointer ? void 0 : found) : body;
10816
11743
  const hints = process.stderr.isTTY === true;
10817
11744
  if (hints) process.stderr.write(`${HEADER}\n\n`);
10818
11745
  console.log(text);
@@ -11219,7 +12146,7 @@ function readMoment(value) {
11219
12146
  const cardMoveTool = defineKanboTool({
11220
12147
  name: "kanbo_card_move",
11221
12148
  title: "Move a card",
11222
- 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}`,
12149
+ 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}`,
11223
12150
  inputSchema: {
11224
12151
  card: cardArgument,
11225
12152
  column: z.string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`."),
@@ -11291,7 +12218,7 @@ const cardUpdateTool = defineKanboTool({
11291
12218
  const columnsTool = defineKanboTool({
11292
12219
  name: "kanbo_columns",
11293
12220
  title: "Columns of the board",
11294
- 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.",
12221
+ 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).",
11295
12222
  inputSchema: {},
11296
12223
  run: async (transport) => await transport.columns()
11297
12224
  });
@@ -11305,6 +12232,38 @@ const primeTool = defineKanboTool({
11305
12232
  inputSchema: {},
11306
12233
  run: async (transport) => (await transport.prime()).text
11307
12234
  });
12235
+ //#endregion
12236
+ //#region src/mcp/tools/card-claim.ts
12237
+ /**
12238
+ * Taking a card to work on, in one step: the run starts and the card moves out
12239
+ * of To Do into the board's first work-in-progress column in the same write,
12240
+ * so two agents never get the same card. Published to every host, including
12241
+ * one that records the runs of the agents it launches: a claim is how an agent
12242
+ * in any terminal takes work.
12243
+ */
12244
+ const cardClaimTool = defineKanboTool({
12245
+ name: "kanbo_card_claim",
12246
+ title: "Claim a card",
12247
+ 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.",
12248
+ inputSchema: {
12249
+ card: cardArgument.optional(),
12250
+ agent: z.string().min(1).optional().describe("What to call whoever works the card; who this server writes as when absent.")
12251
+ },
12252
+ aliases: {
12253
+ card: [...CARD_ALIASES, "issue"],
12254
+ agent: ["agentName"]
12255
+ },
12256
+ example: { card: "TST-5" },
12257
+ run: async (transport, input) => {
12258
+ const claimed = await transport.cardClaim(input);
12259
+ return {
12260
+ card: compactKanboCard(claimed.card),
12261
+ run: claimed.run.id,
12262
+ column: claimed.column.slug,
12263
+ finishWith: `kanbo_run_finish {"run":"${claimed.run.id}","state":"finished"}`
12264
+ };
12265
+ }
12266
+ });
11308
12267
  /** The cards an agent may pick up right now. */
11309
12268
  const readyTool = defineKanboTool({
11310
12269
  name: "kanbo_ready",
@@ -11393,13 +12352,14 @@ const runStartTool = defineKanboTool({
11393
12352
  /**
11394
12353
  * Every tool the board has, in the order it declares them.
11395
12354
  *
11396
- * The list is the board's, not a server's: the same sixteen are registered
12355
+ * The list is the board's, not a server's: the same seventeen are registered
11397
12356
  * whether they run against a server or against the board directly, and the
11398
12357
  * only thing a host decides is which of them it publishes.
11399
12358
  */
11400
12359
  const KANBO_TOOLS = [
11401
12360
  primeTool,
11402
12361
  readyTool,
12362
+ cardClaimTool,
11403
12363
  columnsTool,
11404
12364
  defineKanboTool({
11405
12365
  name: "kanbo_sprints",
@@ -11531,7 +12491,8 @@ async function createDbTransport(input) {
11531
12491
  const workspaceId = workspace.id;
11532
12492
  const actor = input.actor;
11533
12493
  async function readColumns() {
11534
- return (await ops.listColumns(workspaceId)).map(projectKanboColumn);
12494
+ const rows = await ops.listColumns(workspaceId);
12495
+ return rows.map((row) => projectKanboColumn(row, rows));
11535
12496
  }
11536
12497
  /** One card with what the run registry says about it, in the shape a tool prints. */
11537
12498
  async function project(card, columns) {
@@ -11581,7 +12542,7 @@ async function createDbTransport(input) {
11581
12542
  * the waiting and returned ones last said at most two more.
11582
12543
  */
11583
12544
  async function projectPage(page, { attend = true } = {}) {
11584
- const columns = page.columns.map(projectKanboColumn);
12545
+ const columns = page.columns.map((column) => projectKanboColumn(column, page.columns));
11585
12546
  const runs = await ops.readBoardProjectionForIssues(page.cards.map((card) => card.id));
11586
12547
  const attention = attend ? await ops.readCardAttention(page.cards, runs.map((run) => run.activeRun !== null)) : [];
11587
12548
  return page.cards.map((card, index) => projectKanboCard({
@@ -11713,6 +12674,25 @@ async function createDbTransport(input) {
11713
12674
  state,
11714
12675
  errorText
11715
12676
  }, actor));
12677
+ },
12678
+ cardClaim: async ({ card, agent }) => {
12679
+ await session.assertWritable();
12680
+ const named = card === void 0 ? void 0 : (await resolveCard(card)).id;
12681
+ const claimed = await ops.claimCard({
12682
+ workspaceId,
12683
+ issueId: named,
12684
+ run: {
12685
+ agentName: agent ?? actor.id ?? "agent",
12686
+ externalSessionRef: sessionRefFromEnvironment() ?? void 0,
12687
+ launchedByKind: "external"
12688
+ }
12689
+ }, actor);
12690
+ const columns = await readColumns();
12691
+ return {
12692
+ card: await project(claimed.card, columns),
12693
+ run: projectKanboRun(claimed.run),
12694
+ column: columns.find((column) => column.id === claimed.column.id)
12695
+ };
11716
12696
  }
11717
12697
  };
11718
12698
  }
@@ -11754,7 +12734,8 @@ const TRANSPORT_METHODS = {
11754
12734
  statusLine: true,
11755
12735
  waitApproval: true,
11756
12736
  runStart: true,
11757
- runFinish: true
12737
+ runFinish: true,
12738
+ cardClaim: true
11758
12739
  };
11759
12740
  async function openMcpBoard(input) {
11760
12741
  try {
@@ -12360,7 +13341,7 @@ function sendBoardPage(response, pathname, assets) {
12360
13341
  * answering for a person — approving a card, sending it back or closing a
12361
13342
  * sprint — as anyone but one is 403, dropping a pull request link somebody else made is the same 403
12362
13343
  * for the same reason, and acting on a run that has already ended is 409,
12363
- * because the answer would have been yes a moment earlier. An agent putting a
13344
+ * 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
12364
13345
  * card in a column whose entry rules it does not meet yet is 409 for the same
12365
13346
  * reason turned around: the answer will be yes once the card meets them, and
12366
13347
  * `details.unmet` says what is missing. Deleting a column that still holds
@@ -12378,6 +13359,14 @@ const BOARD_ERROR_RESPONSES = {
12378
13359
  message: "Only a person can approve a card",
12379
13360
  code: "issue_approval_requires_user"
12380
13361
  },
13362
+ board_card_not_ready: {
13363
+ status: 409,
13364
+ message: "That card is not there to take: it is not in To Do, waits for a person, or has a run going"
13365
+ },
13366
+ board_card_run_active: {
13367
+ status: 409,
13368
+ message: "The card already has a run going"
13369
+ },
12381
13370
  board_column_name_taken: {
12382
13371
  status: 400,
12383
13372
  message: "Another column already has that name"
@@ -12417,6 +13406,18 @@ const BOARD_ERROR_RESPONSES = {
12417
13406
  status: 400,
12418
13407
  message: "Execution mode cannot change once the card has been launched"
12419
13408
  },
13409
+ board_flow_rule_invalid: {
13410
+ status: 400,
13411
+ message: "A column accepts cards from other columns of its own board, and holds at least one card"
13412
+ },
13413
+ board_metrics_range_invalid: {
13414
+ status: 400,
13415
+ message: "The range starts after it ends"
13416
+ },
13417
+ board_nothing_ready: {
13418
+ status: 409,
13419
+ message: "No card in To Do is ready to take"
13420
+ },
12420
13421
  board_pull_request_invalid: {
12421
13422
  status: 400,
12422
13423
  message: "Not a GitHub pull request: give its URL or owner/repo#number"
@@ -12832,6 +13833,14 @@ const startRunBody = z.object({
12832
13833
  ]).optional(),
12833
13834
  externalSessionRef: z.string().nullable().optional()
12834
13835
  });
13836
+ const claimBody = z.object({
13837
+ workspaceId: id,
13838
+ issueId: id.optional(),
13839
+ agentName: id.optional(),
13840
+ branch: z.string().nullable().optional(),
13841
+ executionMode: executionMode.optional(),
13842
+ externalSessionRef: z.string().nullable().optional()
13843
+ });
12835
13844
  const finishRunBody = z.object({
12836
13845
  state: z.enum([
12837
13846
  "finished",
@@ -12878,7 +13887,8 @@ async function toWrittenCardView(ops, card) {
12878
13887
  function toStatusView(status) {
12879
13888
  return {
12880
13889
  ...status,
12881
- entryRules: readEntryRules(status)
13890
+ entryRules: readEntryRules(status),
13891
+ allowedFrom: readFlowRules(status).allowedFrom
12882
13892
  };
12883
13893
  }
12884
13894
  function toCommentView(comment, personWriterId = null) {
@@ -13313,6 +14323,31 @@ function createIssueRoutes(session, options = {}) {
13313
14323
  }, actors.placement));
13314
14324
  }
13315
14325
  }),
14326
+ route({
14327
+ method: "POST",
14328
+ path: "/issues/claim",
14329
+ body: claimBody,
14330
+ write: true,
14331
+ handle: async ({ body, actors }) => {
14332
+ const named = requireWorkspace(body.workspaceId);
14333
+ if (body.issueId !== void 0) await requireCard(body.issueId);
14334
+ const claimed = await ops.claimCard({
14335
+ workspaceId: named,
14336
+ issueId: body.issueId,
14337
+ run: {
14338
+ agentName: body.agentName ?? actors.writer.id ?? "agent",
14339
+ branch: body.branch,
14340
+ executionMode: body.executionMode,
14341
+ externalSessionRef: body.externalSessionRef
14342
+ }
14343
+ }, actors.placement);
14344
+ return {
14345
+ card: await toWrittenCardView(ops, claimed.card),
14346
+ run: await toRunView(claimed.run),
14347
+ column: toStatusView(claimed.column)
14348
+ };
14349
+ }
14350
+ }),
13316
14351
  route({
13317
14352
  method: "PATCH",
13318
14353
  path: "/issues/bulk",
@@ -14191,17 +15226,6 @@ function parsePort(value) {
14191
15226
  //#endregion
14192
15227
  //#region src/cli/commands/sprint.ts
14193
15228
  /**
14194
- * A date as the command line takes it: `YYYY-MM-DD`, read as a UTC day, or a
14195
- * unix-seconds number taken as it is. `end` turns a day into its last second,
14196
- * so a sprint due on a day is still current throughout that day.
14197
- */
14198
- function parseDate(value, end) {
14199
- if (/^\d+$/.test(value)) return Number(value);
14200
- const day = /^\d{4}-\d{2}-\d{2}$/.test(value) ? Date.parse(`${value}T00:00:00Z`) : NaN;
14201
- if (Number.isNaN(day)) throw new CliError(1, `Expected a date as YYYY-MM-DD or unix seconds, got "${value}".`);
14202
- return day / 1e3 + (end ? 86399 : 0);
14203
- }
14204
- /**
14205
15229
  * `kanbo sprint …` — milestones read as sprints (ruling 5x-3).
14206
15230
  *
14207
15231
  * Listing and creating are anybody's. Closing one is a person's decision
@@ -14221,7 +15245,7 @@ function registerSprintCommands(program) {
14221
15245
  };
14222
15246
  });
14223
15247
  });
14224
- withBoardOptions(sprint.command("create").description("Start a sprint with a first and a last day").requiredOption("--title <title>", "what the sprint is called").requiredOption("--start <date>", "first day, as YYYY-MM-DD (UTC) or unix seconds", (value) => parseDate(value, false)).requiredOption("--due <date>", "last day, as YYYY-MM-DD (UTC, through its end) or unix seconds", (value) => parseDate(value, true)).option("--description <text>", "what the sprint is for")).action(async (options) => {
15248
+ withBoardOptions(sprint.command("create").description("Start a sprint with a first and a last day").requiredOption("--title <title>", "what the sprint is called").requiredOption("--start <date>", "first day, as YYYY-MM-DD (UTC) or unix seconds", (value) => parseCalendarDay(value, false)).requiredOption("--due <date>", "last day, as YYYY-MM-DD (UTC, through its end) or unix seconds", (value) => parseCalendarDay(value, true)).option("--description <text>", "what the sprint is for")).action(async (options) => {
14225
15249
  await runBoardCommand(options, "write", async (session) => {
14226
15250
  const created = await session.ops.createMilestone({
14227
15251
  workspaceId: session.workspace.id,
@@ -14264,6 +15288,113 @@ function registerSprintCommands(program) {
14264
15288
  });
14265
15289
  }
14266
15290
  //#endregion
15291
+ //#region src/cli/metrics-view.ts
15292
+ /** Every field `kanbo stats --json` may name, in the order the value carries them. */
15293
+ const BOARD_METRICS_FIELDS = [
15294
+ "range",
15295
+ "now",
15296
+ "milestoneId",
15297
+ "columns",
15298
+ "cycleTime",
15299
+ "leadTime",
15300
+ "throughput",
15301
+ "wip",
15302
+ "aging",
15303
+ "timeInColumn",
15304
+ "cumulativeFlow",
15305
+ "finished",
15306
+ "returns",
15307
+ "attempts",
15308
+ "canceled",
15309
+ "agents",
15310
+ "people"
15311
+ ];
15312
+ const LABEL_WIDTH = 16;
15313
+ /** A duration as the command line prints it: minutes under an hour, hours under a day, then days, to one decimal; a dash for none. */
15314
+ function formatDuration(seconds) {
15315
+ if (seconds === null) return "—";
15316
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
15317
+ if (seconds < 86400) return `${Number((seconds / 3600).toFixed(1))}h`;
15318
+ return `${Number((seconds / 86400).toFixed(1))}d`;
15319
+ }
15320
+ function percent(share) {
15321
+ return share === null ? "—" : `${Math.round(share * 100)}%`;
15322
+ }
15323
+ function decimal(value) {
15324
+ return value === null ? "—" : String(Number(value.toFixed(1)));
15325
+ }
15326
+ function count(n, noun) {
15327
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
15328
+ }
15329
+ function day(seconds) {
15330
+ return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
15331
+ }
15332
+ /** A labelled block: the first item beside the label, the rest under it; a dash when there is nothing. */
15333
+ function block(label, items) {
15334
+ const [first = "—", ...rest] = items;
15335
+ return [`${label.padEnd(LABEL_WIDTH)}${first}`, ...rest.map((item) => `${" ".repeat(LABEL_WIDTH)}${item}`)];
15336
+ }
15337
+ /** Items joined on one line, or none at all. */
15338
+ function oneLine(items) {
15339
+ return items.length === 0 ? [] : [items.join(" · ")];
15340
+ }
15341
+ /**
15342
+ * The metrics as a person reads them in a terminal: the range, then one block
15343
+ * per figure. The cumulative flow is a series per day and has no one-screen
15344
+ * form, so the last line says how to get it.
15345
+ */
15346
+ function describeMetrics(metrics, sprint) {
15347
+ const column = (slug) => sanitizeTerminalText(metrics.columns.find((candidate) => candidate.slug === slug)?.name ?? slug);
15348
+ const by = metrics.returns.byActorKind;
15349
+ const lines = [`${day(metrics.range.from)} → ${day(metrics.range.to)}`];
15350
+ if (sprint) lines.push(`${sanitizeTerminalText(sprint.title)}: ${sprint.cards.open + sprint.cards.done} planned · ${sprint.cards.done} done`);
15351
+ if (metrics.range.from > metrics.range.to) return [...lines, "Nothing measured yet: the range starts after now."].join("\n");
15352
+ lines.push(...block("Cycle time", [`p50 ${formatDuration(metrics.cycleTime.p50)} · p85 ${formatDuration(metrics.cycleTime.p85)} (${count(metrics.cycleTime.cards, "card")})`]), ...block("Lead time", [`p50 ${formatDuration(metrics.leadTime.p50)} · p85 ${formatDuration(metrics.leadTime.p85)}`]), ...block("Throughput", oneLine(metrics.throughput.map((week) => `${week.week} ${week.finished}`))), ...block("In progress", oneLine(metrics.wip.map((wip) => `${column(wip.slug)} ${wip.cards}${wip.limit === null ? "" : `/${wip.limit}`}`))), ...block("First pass", [`${percent(metrics.returns.firstPassRate)} · attempts ${decimal(metrics.attempts.perFinishedCard)} per card`]), ...block("Returns", [`${metrics.returns.total} (people ${by.user} · agents ${by.agent} · outside ${by.external}) · ${decimal(metrics.returns.perFinishedCard)} per card`]), ...block("Canceled", [String(metrics.canceled)]), ...block("Aging", metrics.aging.map((card) => `${card.issueId} ${column(card.slug)} ${formatDuration(card.ageSeconds)}${card.overCycleP85 ? " (older than p85)" : ""}`)), ...block("Time in column", oneLine(metrics.timeInColumn.filter((time) => time.seconds > 0).map((time) => `${column(time.slug)} ${formatDuration(time.seconds)}`))), ...block("Agents", metrics.agents.map(describeAgent)), ...block("People", metrics.people.map(describePerson)), "Cumulative flow by day: kanbo stats --json cumulativeFlow");
15353
+ return lines.join("\n");
15354
+ }
15355
+ function describeAgent(agent) {
15356
+ const { runs } = agent;
15357
+ return `${sanitizeTerminalText(agent.agentName)}: ${count(runs.total, "run")} (${runs.finished} finished · ${runs.failed} failed · ${runs.stopped} stopped · ${runs.running} running) · median ${formatDuration(agent.medianRunSeconds)} · ${count(agent.cardsFinished, "card")} finished · ${percent(agent.returnRate)} returned`;
15358
+ }
15359
+ function describePerson(person) {
15360
+ return `${sanitizeTerminalText(person.id ?? "unnamed")}: ${person.created} created · ${person.approvals} approved · ${person.returns} returned · ${person.moves} moved`;
15361
+ }
15362
+ const WEEK_SECONDS = 604800;
15363
+ /** The Monday that makes the range `DEFAULT_STATS_WEEKS` ISO weeks long, ending with the week of `now`. */
15364
+ function defaultStatsFrom(now) {
15365
+ return isoWeekOf(now).start - 11 * WEEK_SECONDS;
15366
+ }
15367
+ /**
15368
+ * `kanbo stats` — how work flows on this board (spec 2026-11-05, Part 4).
15369
+ *
15370
+ * Reading only, so it is anybody's, an agent's shell included. A sprint sets
15371
+ * the range to its own dates and keeps its cards; `--from` and `--to` still
15372
+ * win over either end.
15373
+ */
15374
+ function registerStatsCommand(program) {
15375
+ withBoardOptions(program.command("stats").description("How work flows on this board: cycle time, throughput, work in progress, returns, agents").option("--from <date>", `first day, YYYY-MM-DD (UTC) or unix seconds (12 weeks back when left out)`, (value) => parseCalendarDay(value, false)).option("--to <date>", "last day, YYYY-MM-DD (UTC, through its end) or unix seconds (now when left out)", (value) => parseCalendarDay(value, true)).option("--sprint <id>", "only the cards of this sprint, over its dates")).action(async (options) => {
15376
+ await runBoardCommand(options, "read", async (session) => {
15377
+ const now = currentUnixSeconds();
15378
+ const sprint = options.sprint === void 0 ? null : (await session.ops.listSprints(session.workspace.id, now)).find((candidate) => candidate.id === options.sprint) ?? null;
15379
+ if (options.sprint !== void 0 && sprint === null) throw new BoardError("issue_milestone_not_found", {
15380
+ milestoneId: options.sprint,
15381
+ workspaceId: session.workspace.id
15382
+ });
15383
+ const metrics = await session.ops.boardMetrics({
15384
+ workspaceIds: [session.workspace.id],
15385
+ from: options.from ?? sprint?.startDate ?? defaultStatsFrom(now),
15386
+ to: options.to ?? sprint?.dueDate ?? now,
15387
+ milestoneId: sprint?.id ?? null
15388
+ }, now);
15389
+ return {
15390
+ value: metrics,
15391
+ text: describeMetrics(metrics, sprint),
15392
+ fields: BOARD_METRICS_FIELDS
15393
+ };
15394
+ });
15395
+ });
15396
+ }
15397
+ //#endregion
14267
15398
  //#region src/cli/commands/uninstall.ts
14268
15399
  function registerUninstallCommand(program) {
14269
15400
  program.command("uninstall").description("Disconnect kanbo from your agents; the board stays unless --purge").option("--project", "only this project's files").option("--global", "only your own files, for every project").option("--purge", "also remove this project's board settings, and its board file after you confirm").option("--yes", "do it without asking (never deletes a board file)").option("--json [fields]", "print what was done as JSON; name comma-separated fields to print only those").action(async (options) => {
@@ -14444,9 +15575,11 @@ function registerKanboCommands(program, context = {}) {
14444
15575
  registerReadyCommand(program);
14445
15576
  registerPrimeCommand(program);
14446
15577
  registerSprintCommands(program);
15578
+ registerStatsCommand(program);
14447
15579
  registerServeCommand(program);
14448
15580
  program.commandsGroup("For agents and integrations:");
14449
15581
  registerMcpCommand(program);
15582
+ registerClaimCommand(program);
14450
15583
  registerRunCommands(program);
14451
15584
  registerCapabilitiesCommand(program);
14452
15585
  program.commandsGroup("Shared Postgres boards:");
@@ -14470,6 +15603,7 @@ function registerKanboCommands(program, context = {}) {
14470
15603
  const KANBO_TOOL_NAMES = [
14471
15604
  "kanbo_prime",
14472
15605
  "kanbo_ready",
15606
+ "kanbo_card_claim",
14473
15607
  "kanbo_columns",
14474
15608
  "kanbo_sprints",
14475
15609
  "kanbo_card_get",
@@ -14495,6 +15629,7 @@ const KANBO_TOOL_NAMES = [
14495
15629
  const KANBO_TOOL_CLI_EQUIVALENTS = {
14496
15630
  kanbo_prime: { kanbo: "kanbo prime" },
14497
15631
  kanbo_ready: { kanbo: "kanbo ready" },
15632
+ kanbo_card_claim: { kanbo: "kanbo claim [card] [--agent <name>]" },
14498
15633
  kanbo_columns: { kanbo: "kanbo columns list" },
14499
15634
  kanbo_sprints: { kanbo: "kanbo sprint list" },
14500
15635
  kanbo_card_get: { kanbo: "kanbo card get <id>" },
@@ -14642,13 +15777,15 @@ function buildCapabilitiesManifest() {
14642
15777
  "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.",
14643
15778
  `The agent role (\`${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.`,
14644
15779
  "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.",
15780
+ "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.",
15781
+ "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.",
14645
15782
  "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.",
14646
15783
  "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).",
14647
15784
  "`kanbo migrate` and `kanbo roles apply` refuse a host app's own database file: the app migrates it, and this package never touches it.",
14648
- "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.",
15785
+ "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.",
14649
15786
  "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."
14650
15787
  ]
14651
15788
  };
14652
15789
  }
14653
15790
  //#endregion
14654
- export { COLUMN_TEMPLATES as $, projectKanboSprint as A, createBoardOps as B, pageOf as C, nextIssueIdentity as Ct, projectKanboFieldChange as D, projectKanboColumn as E, readIssuePrefix as Et, BINDING_FILE_PATH as F, ENTRY_RULES as G, formatExternalSessionRef as H, ignoreBoardFile as I, evaluateEntryRules as J, ENTRY_RULE_DESCRIPTIONS as K, readBinding as L, renderCapabilitiesMarkdown as M, maskDatabaseUrl as N, projectKanboPullRequest as O, maskDatabaseUrls as P, COLUMN_CATALOGUE as Q, readProjectBinding as R, matchesKanboCardQuery as S, formatIssueId as St, projectKanboCardComment as T, nextIssueOrder as Tt, parseExternalSessionRef as U, cardDisplayTitle as V, pullRequestFactKeys as W, readEntryRules as X, readChecklist as Y, serializeEntryRules as Z, suggestCardKey as _, parseActivityStringArray as _t, BOARD_ERROR_RESPONSES as a, TRACKED_FIELDS as at, DEFAULT_KANBO_COMMENT_LIMIT as b, normalizeCommentAuthorKind as bt, registerKanboTools as c, BOARD_ACTOR as ct, KANBO_MCP_INSTRUCTIONS as d, activityText as dt, COLUMN_TEMPLATE_IDS as et, KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI as f, activityToken as ft, isValidCardKey as g, isEmptyActivityValue as gt, CARD_KEY_PATTERN as h, formatPlainActivityValue as ht, KANBO_TOOL_NAMES as i, normalizeStatusName as it, projectKanboSubCard as j, projectKanboRun as k, KANBO_TOOLS as l, currentUnixSeconds as lt, registerKanboCapabilitiesResources as m, formatActivityIssueReference as mt, buildCapabilitiesManifest as n, runBoardWrite as nt, KANBO_MCP_SERVER_NAME as o, diffFieldChanges as ot, KANBO_CAPABILITIES_RESOURCE_URI as p, commentAuthorReference as pt, UNMET_ENTRY_RULE_REASONS as q, KANBO_TOOL_CLI_EQUIVALENTS as r, DEFAULT_STATUSES as rt, createKanboMcpServer as s, previewComment as st, CAPABILITIES_MANIFEST_VERSION as t, readChangeSeq as tt, buildKanboTools as u, activityKindOrder as ut, DEFAULT_BOARD_FILE_PATH as v, readSystemCommentKind as vt, projectKanboCard as w, nextIssueNumber as wt, KANBO_CARD_INCLUDES as x, toIssueView as xt, DEFAULT_KANBO_CARD_INCLUDES as y, isOutsideWriter as yt, writeBinding as z };
15791
+ export { COLUMN_TEMPLATE_IDS as $, projectKanboSprint as A, formatIssueId as At, createBoardOps as B, pageOf as C, formatPlainActivityValue as Ct, projectKanboFieldChange as D, isOutsideWriter as Dt, projectKanboColumn as E, readSystemCommentKind as Et, BINDING_FILE_PATH as F, ENTRY_RULE_DESCRIPTIONS as G, parseExternalSessionRef as H, ignoreBoardFile as I, readChecklist as J, UNMET_ENTRY_RULE_REASONS as K, readBinding as L, renderCapabilitiesMarkdown as M, nextIssueNumber as Mt, maskDatabaseUrl as N, nextIssueOrder as Nt, projectKanboPullRequest as O, normalizeCommentAuthorKind as Ot, maskDatabaseUrls as P, readIssuePrefix as Pt, COLUMN_TEMPLATES as Q, readProjectBinding as R, matchesKanboCardQuery as S, formatActivityIssueReference as St, projectKanboCardComment as T, parseActivityStringArray as Tt, pullRequestFactKeys as U, formatExternalSessionRef as V, ENTRY_RULES as W, serializeEntryRules as X, readEntryRules as Y, COLUMN_CATALOGUE as Z, suggestCardKey as _, currentUnixSeconds as _t, BOARD_ERROR_RESPONSES as a, boardMetricsRuns as at, DEFAULT_KANBO_COMMENT_LIMIT as b, activityToken as bt, registerKanboTools as c, isoWeekOf as ct, KANBO_MCP_INSTRUCTIONS as d, readFlowRules as dt, readChangeSeq as et, KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI as f, DEFAULT_STATUSES as ft, isValidCardKey as g, BOARD_ACTOR as gt, CARD_KEY_PATTERN as h, previewComment as ht, KANBO_TOOL_NAMES as i, boardMetricsAgentKey as it, projectKanboSubCard as j, nextIssueIdentity as jt, projectKanboRun as k, toIssueView as kt, KANBO_TOOLS as l, utcDayLabel as lt, registerKanboCapabilitiesResources as m, cardDisplayTitle as mt, buildCapabilitiesManifest as n, TRACKED_FIELDS as nt, KANBO_MCP_SERVER_NAME as o, computeBoardMetrics as ot, KANBO_CAPABILITIES_RESOURCE_URI as p, normalizeStatusName as pt, evaluateEntryRules as q, KANBO_TOOL_CLI_EQUIVALENTS as r, diffFieldChanges as rt, createKanboMcpServer as s, nearestRank as st, CAPABILITIES_MANIFEST_VERSION as t, runBoardWrite as tt, buildKanboTools as u, FLOW_RULES as ut, DEFAULT_BOARD_FILE_PATH as v, activityKindOrder as vt, projectKanboCard as w, isEmptyActivityValue as wt, KANBO_CARD_INCLUDES as x, commentAuthorReference as xt, DEFAULT_KANBO_CARD_INCLUDES as y, activityText as yt, writeBinding as z };