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
package/dist/cli-main.cjs CHANGED
@@ -212,6 +212,26 @@ var BoardError = class extends Error {
212
212
  }
213
213
  };
214
214
  //#endregion
215
+ //#region src/domain/iso-week.ts
216
+ const DAY_SECONDS$2 = 86400;
217
+ /** The ISO week a moment falls in, read in UTC like every board date (ruling 5x-6). */
218
+ function isoWeekOf(seconds) {
219
+ const day = Math.floor(seconds / DAY_SECONDS$2);
220
+ const monday = day - (day + 3) % 7;
221
+ const thursday = monday + 3;
222
+ const year = (/* @__PURE__ */ new Date(thursday * DAY_SECONDS$2 * 1e3)).getUTCFullYear();
223
+ const januaryFirst = Date.UTC(year, 0, 1) / 1e3 / DAY_SECONDS$2;
224
+ const week = Math.floor((thursday - januaryFirst) / 7) + 1;
225
+ return {
226
+ week: `${year}-W${String(week).padStart(2, "0")}`,
227
+ start: monday * DAY_SECONDS$2
228
+ };
229
+ }
230
+ /** The UTC day a moment falls on, as `2026-01-05`. */
231
+ function utcDayLabel(seconds) {
232
+ return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
233
+ }
234
+ //#endregion
215
235
  //#region src/domain/status-name.ts
216
236
  /**
217
237
  * The columns a workspace gets the first time someone opens its board, in board
@@ -272,6 +292,14 @@ const BOARD_ERROR_TEXTS = {
272
292
  text: "Only a person can approve a card.",
273
293
  next: AS_A_PERSON
274
294
  }),
295
+ board_card_not_ready: (details) => ({
296
+ 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.`,
297
+ next: "kanbo ready"
298
+ }),
299
+ board_card_run_active: (details) => ({
300
+ text: `${named(details.issueId)} already has a run going: ${named(details.agentName)}'s, run ${named(details.runId)}.`,
301
+ next: `kanbo run finish ${named(details.runId)} --state stopped`
302
+ }),
275
303
  board_column_name_taken: (details) => ({
276
304
  text: `Another column, ${named(details.takenBy)}, already has that name (same slug). Pick another name.`,
277
305
  next: "kanbo columns list"
@@ -302,6 +330,15 @@ const BOARD_ERROR_TEXTS = {
302
330
  }),
303
331
  board_entry_rule_invalid: (details) => ({ text: `Unknown column rule: ${list(details.rules)}. Use any of ${list(details.allowed)}.` }),
304
332
  board_execution_mode_locked: (details) => ({ text: `${named(details.issueId)} has been started, so where it is worked (${named(details.executionMode)}) can't change now.` }),
333
+ 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.` } : {
334
+ text: `A column accepts cards only from other columns of its own board, and ${list(details.allowedFrom)} is not one.`,
335
+ next: "kanbo columns list"
336
+ },
337
+ board_metrics_range_invalid: (details) => typeof details.from === "number" && typeof details.to === "number" ? { text: `The range starts after it ends: it starts on ${utcDayLabel(details.from)} and ends on ${utcDayLabel(details.to)}.` } : { text: "The range starts after it ends." },
338
+ board_nothing_ready: () => ({
339
+ text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
340
+ next: "kanbo ready"
341
+ }),
305
342
  board_pull_request_invalid: (details) => ({ text: `"${named(details.value)}" is not a GitHub pull request. Give its link or owner/repo#number.` }),
306
343
  board_pull_request_not_found: (details) => ({
307
344
  text: `${named(details.issueId)} has no pull request link "${named(details.linkId)}".`,
@@ -367,7 +404,7 @@ const BOARD_ERROR_TEXTS = {
367
404
  issue_relation_self_reference: () => ({ text: "A card can't be linked to itself." }),
368
405
  issue_status_name_empty: () => ({ text: "Give the column a name." }),
369
406
  issue_status_not_found: (details) => ({
370
- text: `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
407
+ 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.`,
371
408
  next: "kanbo columns list"
372
409
  }),
373
410
  issue_status_reference_conflict: () => ({ text: "Name the column once: by its id or by its name, not both." }),
@@ -3561,7 +3598,7 @@ new Command();
3561
3598
  * `package.version`. A build carries it as a literal; running from source
3562
3599
  * (tests, `tsx`) reads the package's own `package.json` instead.
3563
3600
  */
3564
- const KANBO_PACKAGE_VERSION = "0.4.0";
3601
+ const KANBO_PACKAGE_VERSION = "0.6.0";
3565
3602
  //#endregion
3566
3603
  //#region src/cli/agent-shell.ts
3567
3604
  /**
@@ -3700,6 +3737,7 @@ const HUMAN_ONLY_ACTIONS = [
3700
3737
  "return",
3701
3738
  "sprint close",
3702
3739
  "columns rules",
3740
+ "columns flow",
3703
3741
  "columns add",
3704
3742
  "columns rename",
3705
3743
  "columns move",
@@ -3713,6 +3751,7 @@ const HUMAN_ONLY_MESSAGES = {
3713
3751
  "return": describeReturnRefusal,
3714
3752
  "sprint close": describeSprintCloseRefusal,
3715
3753
  "columns rules": describeColumnRulesRefusal,
3754
+ "columns flow": describeColumnRulesRefusal,
3716
3755
  "columns add": describeColumnChangesRefusal,
3717
3756
  "columns rename": describeColumnChangesRefusal,
3718
3757
  "columns move": describeColumnChangesRefusal,
@@ -9833,18 +9872,18 @@ function activityToken(token) {
9833
9872
  function currentUnixSeconds() {
9834
9873
  return Math.floor(Date.now() / 1e3);
9835
9874
  }
9836
- const DAY_SECONDS = 86400;
9875
+ const DAY_SECONDS$1 = 86400;
9837
9876
  /**
9838
9877
  * A board date is a calendar day (ruling 5x-6): a start is kept as the first
9839
9878
  * second of its UTC day, a due date as the last, so a sprint runs through its
9840
9879
  * due day wherever the person reading it is.
9841
9880
  */
9842
9881
  function utcDayStart(seconds) {
9843
- return Math.floor(seconds / DAY_SECONDS) * DAY_SECONDS;
9882
+ return Math.floor(seconds / DAY_SECONDS$1) * DAY_SECONDS$1;
9844
9883
  }
9845
9884
  /** The last second of the UTC day `seconds` falls on — see `utcDayStart`. */
9846
9885
  function utcDayEnd(seconds) {
9847
- return utcDayStart(seconds) + DAY_SECONDS - 1;
9886
+ return utcDayStart(seconds) + DAY_SECONDS$1 - 1;
9848
9887
  }
9849
9888
  /** The largest unix seconds a moment is read as: past it, the number is milliseconds (year 5138 onwards). */
9850
9889
  const MAX_UNIX_SECONDS = 1e11;
@@ -10014,6 +10053,14 @@ const HIDDEN_ACTIVITY_FIELDS = /* @__PURE__ */ new Set([
10014
10053
  "order",
10015
10054
  "waitingFor"
10016
10055
  ]);
10056
+ /**
10057
+ * The column a card was created in, recorded as a change from no column at the
10058
+ * card's own creation time. The feed's `created` item already stands for it,
10059
+ * and the field history keeps it.
10060
+ */
10061
+ function isStartingColumn(change, issue) {
10062
+ return change.field === "statusId" && change.fromValue === null && change.createdAt === issue.createdAt;
10063
+ }
10017
10064
  const ACTIVITY_FIELD_BY_RAW_FIELD = {
10018
10065
  assigneeId: "assignee",
10019
10066
  description: "description",
@@ -10054,7 +10101,7 @@ async function listActivity(store, issueId, resolver) {
10054
10101
  workspaceById: resolver.workspaceById
10055
10102
  };
10056
10103
  const commentItems = await Promise.all((await store.comments.listByIssue(issueId)).map(async (comment) => await toCommentActivityItem(comment, resolver)));
10057
- const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
10104
+ 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);
10058
10105
  return [
10059
10106
  {
10060
10107
  id: `${issue.id}:created`,
@@ -10252,6 +10299,536 @@ function previewComment(comment) {
10252
10299
  };
10253
10300
  }
10254
10301
  //#endregion
10302
+ //#region src/domain/card-display-title.ts
10303
+ /**
10304
+ * What a card is called wherever a person or an agent reads it by name.
10305
+ *
10306
+ * A card written as a description alone is titled with its own key (every
10307
+ * writer does that). Named by its key, such a card says nothing — so its name
10308
+ * is the first line of its description instead. A browser bundle that does not
10309
+ * load this package keeps the same rule on its own, tested with the same
10310
+ * examples.
10311
+ */
10312
+ /** How long a description line may run before it is cut with an ellipsis. */
10313
+ const DESCRIPTION_LINE_LIMIT = 120;
10314
+ function cardDisplayTitle(card) {
10315
+ if (card.title !== card.id) return card.title;
10316
+ return descriptionFirstLine(card.description) ?? card.title;
10317
+ }
10318
+ function descriptionFirstLine(description) {
10319
+ for (const rawLine of (description ?? "").split("\n")) {
10320
+ const line = stripMarkdown(rawLine);
10321
+ if (line) return line.length > DESCRIPTION_LINE_LIMIT ? `${line.slice(0, 119).trimEnd()}…` : line;
10322
+ }
10323
+ return null;
10324
+ }
10325
+ function stripMarkdown(line) {
10326
+ return line.replace(/^\s*(?:>\s*)+/, "").replace(/^\s*#{1,6}\s+/, "").replace(/^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ x]\]\s+)?/i, "").replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<[^>]+>/g, "").replace(/[*_~`]/g, "").replace(/\s+/g, " ").trim();
10327
+ }
10328
+ //#endregion
10329
+ //#region src/domain/flow-rules.ts
10330
+ /** The column the standard flow guards, and the columns it lets cards in from, by slug. */
10331
+ const STANDARD_FLOW_TARGET = "done";
10332
+ const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
10333
+ /** A column's flow rules, out of what the row stores. */
10334
+ function readFlowRules(column) {
10335
+ return {
10336
+ allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
10337
+ wipLimit: column.wipLimit
10338
+ };
10339
+ }
10340
+ /** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
10341
+ function serializeAllowedFrom(allowedFrom) {
10342
+ return allowedFrom === null ? null : JSON.stringify(allowedFrom);
10343
+ }
10344
+ /**
10345
+ * The flow rules a card entering `column` does not meet, where it may come from
10346
+ * first. Pure: what the board holds arrives in `facts`. A card being created
10347
+ * comes from no column, so a column that names where cards come from does not
10348
+ * take it.
10349
+ */
10350
+ function evaluateFlowRules(card, column, rules, facts) {
10351
+ const unmet = [];
10352
+ if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
10353
+ rule: "allowed_from",
10354
+ reason: "not_allowed_from",
10355
+ detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
10356
+ });
10357
+ if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
10358
+ rule: "wip_limit",
10359
+ reason: "column_full",
10360
+ detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
10361
+ });
10362
+ return unmet;
10363
+ }
10364
+ function describeAllowedFrom(name, allowedFrom, columnNames) {
10365
+ const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
10366
+ return names.length === 0 ? `${name} accepts cards from no column; only a person may put a card here` : `${name} accepts cards from ${joinWords(names)} only`;
10367
+ }
10368
+ /** `A`, `A and B`, `A, B and C`. */
10369
+ function joinWords(words) {
10370
+ return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
10371
+ }
10372
+ /**
10373
+ * The standard flow on these columns: Done, and the columns it takes cards from
10374
+ * — In Review, and QA when there is one — in board order. `null` when the board
10375
+ * has no Done, or neither of the two.
10376
+ */
10377
+ function standardFlowOf(columns) {
10378
+ const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
10379
+ const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
10380
+ return target && sources.length > 0 ? {
10381
+ target,
10382
+ sources
10383
+ } : null;
10384
+ }
10385
+ //#endregion
10386
+ //#region src/ops/card-timeline.ts
10387
+ /**
10388
+ * A card's columns over time, oldest first, from its creation on: the column
10389
+ * it was created in, then one stretch per move. A card created before 0.5.0
10390
+ * carries no entry for its creation; one created since carries `null → column`
10391
+ * at its creation, which leaves a stretch of no length in no column.
10392
+ *
10393
+ * The feed lists moves by time and then by change id, and a change id is
10394
+ * random, so two moves of one card in the same second can come back in either
10395
+ * order. Within one second the moves are put back in the order they chain —
10396
+ * each leaves the column the one before it entered — which is the order they
10397
+ * were made in (ruling R012-5). Moves that do not chain keep the feed's order.
10398
+ */
10399
+ function buildCardTimeline(card, entries) {
10400
+ const ordered = chainSameSecond(entries.toSorted((left, right) => left.at - right.at || (left.changeId < right.changeId ? -1 : left.changeId > right.changeId ? 1 : 0)));
10401
+ const stretches = [];
10402
+ const first = ordered[0];
10403
+ let current = {
10404
+ statusId: first ? first.fromStatusId : card.statusId,
10405
+ from: card.createdAt,
10406
+ to: null
10407
+ };
10408
+ for (const entry of ordered) {
10409
+ stretches.push({
10410
+ ...current,
10411
+ to: entry.at
10412
+ });
10413
+ current = {
10414
+ statusId: entry.toStatusId,
10415
+ from: entry.at,
10416
+ to: null
10417
+ };
10418
+ }
10419
+ stretches.push(current);
10420
+ return stretches;
10421
+ }
10422
+ /** The column the card was in at `at` — a move at `at` has already happened — or `undefined` before the card existed. */
10423
+ function statusAt(stretches, at) {
10424
+ return stretches.find((stretch) => stretch.from <= at && (stretch.to === null || at < stretch.to))?.statusId;
10425
+ }
10426
+ function chainSameSecond(entries) {
10427
+ const ordered = [];
10428
+ let start = 0;
10429
+ while (start < entries.length) {
10430
+ let end = start;
10431
+ while (end < entries.length && entries[end].at === entries[start].at) end++;
10432
+ const group = entries.slice(start, end);
10433
+ const previous = ordered.at(-1);
10434
+ let current = previous ? previous.toStatusId : headOf(group)?.fromStatusId;
10435
+ while (group.length > 0) {
10436
+ let next = group.findIndex((entry) => entry.fromStatusId === current);
10437
+ if (next < 0) next = Math.max(group.findIndex((entry) => entry === headOf(group)), 0);
10438
+ const [picked] = group.splice(next, 1);
10439
+ ordered.push(picked);
10440
+ current = picked.toStatusId;
10441
+ }
10442
+ start = end;
10443
+ }
10444
+ return ordered;
10445
+ }
10446
+ /** The move nothing else in the group feeds into. */
10447
+ function headOf(group) {
10448
+ return group.find((entry) => !group.some((other) => other !== entry && other.toStatusId === entry.fromStatusId));
10449
+ }
10450
+ //#endregion
10451
+ //#region src/ops/board-metrics.ts
10452
+ const DAY_SECONDS = 86400;
10453
+ const WEEK_SECONDS$1 = 7 * DAY_SECONDS;
10454
+ /**
10455
+ * The value at `percent` of `values` by nearest rank (ruling R012-2): the
10456
+ * ⌈percent·n/100⌉-th in ascending order. Always a value that occurred; `null`
10457
+ * for none.
10458
+ */
10459
+ function nearestRank(values, percent) {
10460
+ if (values.length === 0) return null;
10461
+ const sorted = values.toSorted((left, right) => left - right);
10462
+ return sorted[Math.max(Math.ceil(percent * sorted.length / 100), 1) - 1];
10463
+ }
10464
+ /** Who a run counts under in the agent rows: the host's agent id when the run names one, else the name it was started under. */
10465
+ function boardMetricsAgentKey(run) {
10466
+ return run.agentId ?? run.agentName;
10467
+ }
10468
+ function scopeOf(history, input) {
10469
+ const workspaceIds = new Set(input.workspaceIds);
10470
+ const milestoneId = input.milestoneId ?? null;
10471
+ const statusById = new Map(history.statuses.filter((status) => workspaceIds.has(status.workspaceId)).map((status) => [status.id, status]));
10472
+ const cards = history.cards.filter((card) => workspaceIds.has(card.workspaceId)).filter((card) => milestoneId === null || card.milestoneId === milestoneId);
10473
+ const isCanceled = (card) => card.statusId !== null && statusById.get(card.statusId)?.category === "canceled";
10474
+ return {
10475
+ statusById,
10476
+ counted: cards.filter((card) => !isCanceled(card)),
10477
+ canceled: cards.filter(isCanceled)
10478
+ };
10479
+ }
10480
+ /** Who made a return, as `returns.byActorKind` counts it: a person, an agent (a provider target stands in for one), or a writer from outside. */
10481
+ function returnerOf(entry) {
10482
+ if (entry.actorKind === "user") return "user";
10483
+ return entry.actorKind === "system" ? "external" : "agent";
10484
+ }
10485
+ /**
10486
+ * The metrics of `input` over rows already read. Pure: the same rows give the
10487
+ * same answer in any order, so a host may put several boards' rows together
10488
+ * and compute once — percentiles of parts do not add up to the whole's.
10489
+ */
10490
+ function computeBoardMetrics(history, input, now) {
10491
+ if (input.from > input.to) throw new BoardError("board_metrics_range_invalid", {
10492
+ from: input.from,
10493
+ to: input.to
10494
+ });
10495
+ const milestoneId = input.milestoneId ?? null;
10496
+ if (milestoneId !== null && !history.milestones.some((milestone) => milestone.id === milestoneId && input.workspaceIds.includes(milestone.workspaceId))) throw new BoardError("issue_milestone_not_found", { milestoneId });
10497
+ const from = input.from;
10498
+ const to = Math.min(input.to, now);
10499
+ const inRange = (at) => from <= at && at <= to;
10500
+ const { statusById, counted, canceled } = scopeOf(history, input);
10501
+ const columns = groupColumns(input.workspaceIds, [...statusById.values()]);
10502
+ const slugOf = (statusId) => {
10503
+ const status = statusId === null ? void 0 : statusById.get(statusId);
10504
+ return status ? normalizeStatusName(status.name) : null;
10505
+ };
10506
+ const categoryOf = (statusId) => statusId === null ? null : statusById.get(statusId)?.category ?? null;
10507
+ const isReturn = (entry) => {
10508
+ const source = entry.fromStatusId === null ? void 0 : statusById.get(entry.fromStatusId);
10509
+ const target = statusById.get(entry.toStatusId);
10510
+ return source !== void 0 && target !== void 0 && source.workspaceId === target.workspaceId && target.order < source.order && (target.category === "unstarted" || target.category === "started");
10511
+ };
10512
+ const entriesByCard = groupBy(history.entries, (entry) => entry.issueId);
10513
+ const timelineOf = (card) => buildCardTimeline(card, entriesByCard.get(card.id) ?? []);
10514
+ const countedIds = new Set(counted.map((card) => card.id));
10515
+ const facts = counted.map((card) => {
10516
+ const stretches = timelineOf(card);
10517
+ return {
10518
+ card,
10519
+ stretches,
10520
+ startedAt: stretches.find((stretch) => categoryOf(stretch.statusId) === "started")?.from ?? null
10521
+ };
10522
+ });
10523
+ const finished = facts.filter((fact) => categoryOf(fact.card.statusId) === "completed").map((fact) => ({
10524
+ ...fact,
10525
+ finishedAt: fact.stretches.at(-1).from
10526
+ })).filter((fact) => inRange(fact.finishedAt)).toSorted((left, right) => left.finishedAt - right.finishedAt || left.card.id.localeCompare(right.card.id));
10527
+ const runsByCard = groupBy(history.runs.filter((run) => countedIds.has(run.issueId)), (run) => run.issueId);
10528
+ const returnsByCard = groupBy(history.entries.filter((entry) => countedIds.has(entry.issueId) && isReturn(entry)), (entry) => entry.issueId);
10529
+ const finishedCards = finished.map((fact) => ({
10530
+ ...cardOf(fact.card),
10531
+ startedAt: fact.startedAt,
10532
+ finishedAt: fact.finishedAt,
10533
+ cycleSeconds: fact.startedAt === null ? null : fact.finishedAt - fact.startedAt,
10534
+ leadSeconds: fact.finishedAt - fact.card.createdAt,
10535
+ returns: returnsByCard.get(fact.card.id)?.length ?? 0,
10536
+ runs: runsByCard.get(fact.card.id)?.length ?? 0
10537
+ }));
10538
+ const cycleTime = durations(finishedCards.flatMap((card) => card.cycleSeconds === null ? [] : [card.cycleSeconds]));
10539
+ const leadTime = durations(finishedCards.map((card) => card.leadSeconds));
10540
+ const throughput = [];
10541
+ for (let start = isoWeekOf(from).start; from <= to && start <= to; start += WEEK_SECONDS$1) throughput.push({
10542
+ week: isoWeekOf(start).week,
10543
+ start,
10544
+ finished: finishedCards.filter((card) => card.finishedAt >= start && card.finishedAt < start + WEEK_SECONDS$1).length
10545
+ });
10546
+ const inProgress = facts.filter((fact) => categoryOf(fact.card.statusId) === "started");
10547
+ const wip = columns.filter((column) => column.category === "started").map((column) => {
10548
+ const limits = [...statusById.values()].filter((status) => normalizeStatusName(status.name) === column.slug).map((status) => readFlowRules(status).wipLimit);
10549
+ return {
10550
+ slug: column.slug,
10551
+ cards: inProgress.filter((fact) => slugOf(fact.card.statusId) === column.slug).length,
10552
+ limit: limits.every((limit) => limit !== null) ? limits.reduce((sum, limit) => sum + limit, 0) : null
10553
+ };
10554
+ });
10555
+ const aging = inProgress.map((fact) => {
10556
+ const current = fact.stretches.at(-1);
10557
+ const startedAt = fact.startedAt ?? current.from;
10558
+ return {
10559
+ ...cardOf(fact.card),
10560
+ slug: slugOf(fact.card.statusId),
10561
+ startedAt,
10562
+ ageSeconds: now - startedAt,
10563
+ inColumnSeconds: now - current.from,
10564
+ overCycleP85: cycleTime.p85 !== null && now - startedAt > cycleTime.p85
10565
+ };
10566
+ }).toSorted((left, right) => right.ageSeconds - left.ageSeconds || left.issueId.localeCompare(right.issueId));
10567
+ const columnSeconds = new Map(columns.map((column) => [column.slug, 0]));
10568
+ for (const fact of finished) for (const stretch of fact.stretches) {
10569
+ const slug = slugOf(stretch.statusId);
10570
+ const end = Math.min(stretch.to ?? fact.finishedAt, fact.finishedAt);
10571
+ if (slug !== null && end > stretch.from) columnSeconds.set(slug, (columnSeconds.get(slug) ?? 0) + end - stretch.from);
10572
+ }
10573
+ const cumulativeFlow = [];
10574
+ for (let dayStart = utcDayStart(from); from <= to && dayStart <= to; dayStart += DAY_SECONDS) {
10575
+ const at = Math.min(dayStart + DAY_SECONDS - 1, to);
10576
+ const cards = Object.fromEntries(columns.map((column) => [column.slug, 0]));
10577
+ for (const fact of facts) {
10578
+ const statusId = statusAt(fact.stretches, at);
10579
+ const slug = statusId === void 0 ? null : slugOf(statusId);
10580
+ if (slug !== null) cards[slug] = (cards[slug] ?? 0) + 1;
10581
+ }
10582
+ cumulativeFlow.push({
10583
+ day: utcDayLabel(dayStart),
10584
+ at,
10585
+ cards
10586
+ });
10587
+ }
10588
+ const finishedReturns = finished.flatMap((fact) => returnsByCard.get(fact.card.id) ?? []);
10589
+ const byActorKind = {
10590
+ user: 0,
10591
+ agent: 0,
10592
+ external: 0
10593
+ };
10594
+ for (const entry of finishedReturns) byActorKind[returnerOf(entry)]++;
10595
+ const runTotal = finishedCards.reduce((sum, card) => sum + card.runs, 0);
10596
+ const share = (part, whole) => whole === 0 ? null : part / whole;
10597
+ return {
10598
+ range: {
10599
+ from,
10600
+ to
10601
+ },
10602
+ now,
10603
+ milestoneId,
10604
+ columns,
10605
+ cycleTime,
10606
+ leadTime,
10607
+ throughput,
10608
+ wip,
10609
+ aging,
10610
+ timeInColumn: columns.map((column) => ({
10611
+ slug: column.slug,
10612
+ seconds: columnSeconds.get(column.slug) ?? 0
10613
+ })),
10614
+ cumulativeFlow,
10615
+ finished: finishedCards,
10616
+ returns: {
10617
+ total: finishedReturns.length,
10618
+ perFinishedCard: share(finishedReturns.length, finishedCards.length),
10619
+ firstPassRate: share(finishedCards.filter((card) => card.returns === 0).length, finishedCards.length),
10620
+ byActorKind
10621
+ },
10622
+ attempts: {
10623
+ runs: runTotal,
10624
+ perFinishedCard: share(runTotal, finishedCards.length)
10625
+ },
10626
+ canceled: canceled.filter((card) => inRange(timelineOf(card).at(-1).from)).length,
10627
+ agents: agentRows(history.runs.filter((run) => countedIds.has(run.issueId)), finishedCards, inRange),
10628
+ people: personRows(counted, history, countedIds, inRange, isReturn)
10629
+ };
10630
+ }
10631
+ function agentRows(runs, finished, inRange) {
10632
+ const finishedIds = new Set(finished.map((card) => card.issueId));
10633
+ const returned = new Set(finished.filter((card) => card.returns > 0).map((card) => card.issueId));
10634
+ const rows = [];
10635
+ for (const [key, own] of groupBy(runs, boardMetricsAgentKey)) {
10636
+ const ranged = own.filter((run) => inRange(run.startedAt));
10637
+ const worked = new Set(own.map((run) => run.issueId).filter((issueId) => finishedIds.has(issueId)));
10638
+ if (ranged.length === 0 && worked.size === 0) continue;
10639
+ const newest = own.toSorted((left, right) => right.startedAt - left.startedAt || right.id.localeCompare(left.id))[0];
10640
+ const ended = ranged.flatMap((run) => run.endedAt === null ? [] : [run.endedAt - run.startedAt]);
10641
+ rows.push({
10642
+ key,
10643
+ agentId: newest.agentId,
10644
+ agentName: newest.agentName,
10645
+ runs: {
10646
+ total: ranged.length,
10647
+ finished: ranged.filter((run) => run.state === "finished").length,
10648
+ failed: ranged.filter((run) => run.state === "failed").length,
10649
+ stopped: ranged.filter((run) => run.state === "stopped").length,
10650
+ running: ranged.filter((run) => run.state === "running").length
10651
+ },
10652
+ medianRunSeconds: nearestRank(ended, 50),
10653
+ cardsFinished: worked.size,
10654
+ returnRate: worked.size === 0 ? null : [...worked].filter((issueId) => returned.has(issueId)).length / worked.size
10655
+ });
10656
+ }
10657
+ return rows.toSorted((left, right) => right.runs.total - left.runs.total || left.key.localeCompare(right.key));
10658
+ }
10659
+ function personRows(counted, history, countedIds, inRange, isReturn) {
10660
+ const rows = /* @__PURE__ */ new Map();
10661
+ const row = (id) => {
10662
+ let found = rows.get(id);
10663
+ if (!found) {
10664
+ found = {
10665
+ id,
10666
+ created: 0,
10667
+ approvals: 0,
10668
+ returns: 0,
10669
+ moves: 0
10670
+ };
10671
+ rows.set(id, found);
10672
+ }
10673
+ return found;
10674
+ };
10675
+ for (const card of counted) if (card.createdByKind === "user" && inRange(card.createdAt)) row(card.createdById).created++;
10676
+ for (const comment of history.approvals) if (comment.authorKind === "system.approved" && countedIds.has(comment.issueId) && inRange(comment.createdAt)) row(comment.authorId).approvals++;
10677
+ for (const entry of history.entries) if (entry.actorKind === "user" && entry.fromStatusId !== null && countedIds.has(entry.issueId) && inRange(entry.at)) {
10678
+ const person = row(entry.actorId);
10679
+ person.moves++;
10680
+ if (isReturn(entry)) person.returns++;
10681
+ }
10682
+ const activity = (person) => person.created + person.approvals + person.returns + person.moves;
10683
+ return [...rows.values()].toSorted((left, right) => activity(right) - activity(left) || (left.id ?? "￿").localeCompare(right.id ?? "￿"));
10684
+ }
10685
+ /**
10686
+ * The workspaces' columns as one board shows them, one per slug (ruling
10687
+ * R012-8): the first workspace's columns set the order, and a column only a
10688
+ * later one has goes right after the column before it there. Name and category
10689
+ * are the first workspace's that has it.
10690
+ */
10691
+ function groupColumns(workspaceIds, statuses) {
10692
+ const columns = [];
10693
+ for (const workspaceId of new Set(workspaceIds)) {
10694
+ let previous = -1;
10695
+ const own = statuses.filter((status) => status.workspaceId === workspaceId).toSorted((left, right) => left.order - right.order || left.id.localeCompare(right.id));
10696
+ for (const status of own) {
10697
+ const slug = normalizeStatusName(status.name);
10698
+ let index = columns.findIndex((column) => column.slug === slug);
10699
+ if (index === -1) {
10700
+ index = previous + 1;
10701
+ columns.splice(index, 0, {
10702
+ slug,
10703
+ name: status.name,
10704
+ category: status.category
10705
+ });
10706
+ }
10707
+ previous = index;
10708
+ }
10709
+ }
10710
+ return columns;
10711
+ }
10712
+ function durations(values) {
10713
+ return {
10714
+ cards: values.length,
10715
+ p50: nearestRank(values, 50),
10716
+ p85: nearestRank(values, 85)
10717
+ };
10718
+ }
10719
+ function cardOf(card) {
10720
+ return {
10721
+ issueId: card.id,
10722
+ workspaceId: card.workspaceId,
10723
+ title: cardDisplayTitle(card)
10724
+ };
10725
+ }
10726
+ function groupBy(items, keyOf) {
10727
+ const groups = /* @__PURE__ */ new Map();
10728
+ for (const item of items) {
10729
+ const key = keyOf(item);
10730
+ const group = groups.get(key);
10731
+ if (group) group.push(item);
10732
+ else groups.set(key, [item]);
10733
+ }
10734
+ return groups;
10735
+ }
10736
+ //#endregion
10737
+ //#region src/ops/column-entries.ts
10738
+ /**
10739
+ * Every entry into a column on the named boards at or after `since`, in order
10740
+ * of `(at, changeId)` — the one signal an app dispatching work on column
10741
+ * changes needs. It sees every writer, an outside `kanbo` on the same database
10742
+ * included, because it reads the cards' own history.
10743
+ *
10744
+ * A page never ends inside a second: when `limit` cuts into one, the rest of
10745
+ * that second comes too, so a page may be longer than `limit`. A reader keeps
10746
+ * its cursor at the last `at` it read after a short page — a write in that
10747
+ * second may still commit — and drops the `changeId`s it has seen; after a page
10748
+ * of `limit` or more it moves on to the last `at` + 1, which a second holding
10749
+ * more entries than `limit` would otherwise never let it pass.
10750
+ */
10751
+ async function listColumnEntries(store, input) {
10752
+ const page = await store.fieldChanges.listByFieldSince("statusId", input);
10753
+ return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
10754
+ }
10755
+ /** The page with every change of its last second, not only those `limit` let in. */
10756
+ async function withWholeLastSecond(store, input, page) {
10757
+ const last = page.at(-1).createdAt;
10758
+ const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
10759
+ workspaceIds: input.workspaceIds,
10760
+ since: last,
10761
+ until: last
10762
+ });
10763
+ return [...page.filter((change) => change.createdAt < last), ...lastSecond];
10764
+ }
10765
+ /** A status change as an entry; a change that took the card out of every column enters none. */
10766
+ function toColumnEntry(change) {
10767
+ return change.toValue === null ? [] : [{
10768
+ changeId: change.id,
10769
+ issueId: change.issueId,
10770
+ workspaceId: change.workspaceId,
10771
+ fromStatusId: change.fromValue,
10772
+ toStatusId: change.toValue,
10773
+ actorKind: change.actorKind,
10774
+ actorId: change.actorId,
10775
+ at: change.createdAt
10776
+ }];
10777
+ }
10778
+ //#endregion
10779
+ //#region src/ops/board-metrics-read.ts
10780
+ /** The comment kind the metrics read: a person's approvals. Returns are moves (R012-7). */
10781
+ const APPROVAL_KINDS = ["system.approved"];
10782
+ /** `listColumnEntries` from the board's first column change on (drift check, item 3). */
10783
+ const FROM_THE_FIRST_CHANGE = 0;
10784
+ /**
10785
+ * Every row the metrics of these workspaces are computed from: three
10786
+ * statements per workspace (columns, milestones, cards) plus three across all
10787
+ * of them (column changes through the `(field, created_at)` index, runs,
10788
+ * approvals); none per card.
10789
+ *
10790
+ * The rows are read and the metrics computed in TypeScript rather than in SQL
10791
+ * (ruling R012-1): a board is thousands of rows, not millions; percentiles, ISO
10792
+ * weeks and a day-by-day replay would otherwise be written once per dialect;
10793
+ * and a host whose workspaces live on several boards can put their rows
10794
+ * together and compute once.
10795
+ */
10796
+ async function readBoardMetricsHistory(store, workspaceIds) {
10797
+ const ids = [...new Set(workspaceIds)];
10798
+ if (ids.length === 0) return {
10799
+ statuses: [],
10800
+ milestones: [],
10801
+ cards: [],
10802
+ entries: [],
10803
+ runs: [],
10804
+ approvals: []
10805
+ };
10806
+ const perWorkspace = async (read) => (await Promise.all(ids.map(read))).flat();
10807
+ const [statuses, milestones, cards, entries, runs, approvals] = await Promise.all([
10808
+ perWorkspace(async (id) => await store.statuses.listByWorkspace(id)),
10809
+ perWorkspace(async (id) => await store.milestones.listByWorkspace(id)),
10810
+ perWorkspace(async (id) => await store.issues.listByWorkspace(id)),
10811
+ listColumnEntries(store, {
10812
+ workspaceIds: ids,
10813
+ since: FROM_THE_FIRST_CHANGE
10814
+ }),
10815
+ store.runs.listByWorkspaces(ids),
10816
+ store.comments.listByWorkspaces(ids, APPROVAL_KINDS)
10817
+ ]);
10818
+ return {
10819
+ statuses,
10820
+ milestones,
10821
+ cards,
10822
+ entries,
10823
+ runs,
10824
+ approvals
10825
+ };
10826
+ }
10827
+ /** How work flows on the board over `input`'s range (`computeBoardMetrics`), read from the store; without cost. */
10828
+ async function boardMetrics(store, input, now = currentUnixSeconds()) {
10829
+ return computeBoardMetrics(await readBoardMetricsHistory(store, input.workspaceIds), input, now);
10830
+ }
10831
+ //#endregion
10255
10832
  //#region src/domain/field-changes.ts
10256
10833
  /**
10257
10834
  * The issue columns whose edits are written to the audit trail.
@@ -10747,6 +11324,7 @@ async function applyColumnTemplate(store, workspaceId, columns, options, scope)
10747
11324
  workspaceId,
10748
11325
  ...column
10749
11326
  }, order);
11327
+ await writeStandardFlow(tx, await listColumns(tx, workspaceId));
10750
11328
  return {
10751
11329
  statuses: await listColumns(tx, workspaceId),
10752
11330
  added: columns.map((column) => column.name)
@@ -10856,6 +11434,77 @@ async function setColumnEntryRules(store, workspaceId, nameOrId, entryRules, act
10856
11434
  return await updateColumn(tx, (await requireColumn(tx, workspaceId, nameOrId)).id, { entryRules }, actor, { tx });
10857
11435
  }, scope);
10858
11436
  }
11437
+ /** The largest WIP limit: what a Postgres `integer` column holds. */
11438
+ const MAX_WIP_LIMIT = 2147483647;
11439
+ /**
11440
+ * Set where a card may enter the column from and how many cards it holds:
11441
+ * `allowedFrom` the ids of other columns of its board (`null` for any, `[]` for
11442
+ * none), kept once each in board order; `wipLimit` a whole number from 1 to 2147483647, or `null`. A
11443
+ * person's to set, like the entry rules: an agent that could open a column
11444
+ * could walk any card into it.
11445
+ */
11446
+ async function setColumnFlowRules(store, columnId, rules, actor, scope) {
11447
+ assertPersonSetsEntryRules(actor, { statusId: columnId });
11448
+ if (rules.wipLimit !== null && !(Number.isSafeInteger(rules.wipLimit) && rules.wipLimit >= 1 && rules.wipLimit <= MAX_WIP_LIMIT)) throw new BoardError("board_flow_rule_invalid", {
11449
+ statusId: columnId,
11450
+ wipLimit: rules.wipLimit
11451
+ });
11452
+ return await runBoardWrite(store, async ({ tx }) => {
11453
+ const column = await tx.statuses.findById(columnId);
11454
+ if (!column) throw new BoardError("issue_status_not_found", { statusId: columnId });
11455
+ const allowedFrom = rules.allowedFrom === null ? null : allowedFromInBoardOrder(await listColumns(tx, column.workspaceId), column, rules.allowedFrom);
11456
+ await tx.statuses.update(columnId, {
11457
+ allowedFrom: serializeAllowedFrom(allowedFrom),
11458
+ wipLimit: rules.wipLimit
11459
+ });
11460
+ return await tx.statuses.findById(columnId);
11461
+ }, scope);
11462
+ }
11463
+ /** The ids asked for, once each and in board order — or the refusal naming those that are not another column of this board. */
11464
+ function allowedFromInBoardOrder(columns, column, requested) {
11465
+ const others = columns.filter((other) => other.id !== column.id);
11466
+ const invalid = requested.filter((id) => !others.some((other) => other.id === id));
11467
+ if (invalid.length > 0) throw new BoardError("board_flow_rule_invalid", {
11468
+ statusId: column.id,
11469
+ allowedFrom: invalid
11470
+ });
11471
+ return others.filter((other) => requested.includes(other.id)).map((other) => other.id);
11472
+ }
11473
+ /**
11474
+ * Put the standard flow on a board: Done accepts cards from In Review only —
11475
+ * and from QA, when the board has it — so nothing reaches Done but through
11476
+ * review. Done's WIP limit and every other column stay as they are. A board
11477
+ * without Done, or with neither In Review nor QA, has nothing to apply it to.
11478
+ */
11479
+ async function applyStandardFlow(store, workspaceId, actor, scope) {
11480
+ assertPersonSetsEntryRules(actor, { workspaceId });
11481
+ return await runBoardWrite(store, async ({ tx }) => {
11482
+ const columns = await listColumns(tx, workspaceId);
11483
+ if (!await writeStandardFlow(tx, columns)) throw new BoardError("issue_status_not_found", {
11484
+ workspaceId,
11485
+ statusName: columns.some((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET) ? "In Review" : "Done"
11486
+ });
11487
+ return await listColumns(tx, workspaceId);
11488
+ }, scope);
11489
+ }
11490
+ /** Done takes cards from the board's In Review and QA; `false`, with nothing written, when the board has no such columns. */
11491
+ async function writeStandardFlow(store, columns) {
11492
+ const flow = standardFlowOf(columns);
11493
+ if (!flow) return false;
11494
+ await store.statuses.update(flow.target.id, { allowedFrom: serializeAllowedFrom(flow.sources.map((column) => column.id)) });
11495
+ return true;
11496
+ }
11497
+ /**
11498
+ * Take a deleted column out of every list of where cards may come from. A list
11499
+ * it empties stays empty: a column that took cards only from it does not open
11500
+ * to every column.
11501
+ */
11502
+ async function dropFromAllowedFrom(store, workspaceId, statusId) {
11503
+ for (const other of await listColumns(store, workspaceId)) {
11504
+ const { allowedFrom } = readFlowRules(other);
11505
+ if (allowedFrom?.includes(statusId)) await store.statuses.update(other.id, { allowedFrom: serializeAllowedFrom(allowedFrom.filter((id) => id !== statusId)) });
11506
+ }
11507
+ }
10859
11508
  /**
10860
11509
  * Take an empty column off the board, and number the columns left 0..n-1 in
10861
11510
  * the same write.
@@ -10882,6 +11531,7 @@ async function deleteColumn(store, statusId, scope, actor) {
10882
11531
  hint: "Move its cards to another column first, or remove it with removeColumn and moveCardsTo."
10883
11532
  });
10884
11533
  await tx.statuses.delete(statusId);
11534
+ await dropFromAllowedFrom(tx, column.workspaceId, statusId);
10885
11535
  await renumberColumns(tx, column.workspaceId);
10886
11536
  }, scope);
10887
11537
  }
@@ -11161,24 +11811,46 @@ function pullRequestFactKeys({ owner, repo, number }) {
11161
11811
  //#endregion
11162
11812
  //#region src/ops/entry-rules.ts
11163
11813
  /**
11164
- * The column's entry rules the card does not meet (ruling 6-2). A column with
11165
- * no rules answers at once, without reading anything; otherwise only the facts
11166
- * the column's rules need are read.
11814
+ * The column's entry rules and flow rules the card does not meet, entry rules
11815
+ * first (ruling 6-2; board-workflow spec, Part 2). A column with no rules
11816
+ * answers at once, without reading anything; otherwise only the facts its
11817
+ * rules need are read. `entering` counts the cards this same write already let
11818
+ * into the column, so a bulk move is held to the WIP limit as a whole.
11167
11819
  */
11168
- async function checkColumnEntry(store, card, column) {
11820
+ async function checkColumnEntry(store, card, column, entering) {
11169
11821
  const rules = readEntryRules(column);
11170
- if (rules.length === 0) return [];
11171
- return evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
11822
+ const unmet = rules.length === 0 ? [] : evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
11823
+ const flow = readFlowRules(column);
11824
+ if (flow.allowedFrom === null && flow.wipLimit === null) return unmet;
11825
+ return [...unmet, ...evaluateFlowRules(card, column, flow, await readFlowRuleFacts(store, column, flow, entering))];
11826
+ }
11827
+ /** 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. */
11828
+ async function readFlowRuleFacts(store, column, flow, entering) {
11829
+ const columnNames = flow.allowedFrom === null ? /* @__PURE__ */ new Map() : new Map((await store.statuses.listByWorkspace(column.workspaceId)).map((other) => [other.id, other.name]));
11830
+ if (flow.wipLimit === null) return {
11831
+ columnNames,
11832
+ cardsInColumn: 0
11833
+ };
11834
+ await store.statuses.lockById(column.id);
11835
+ return {
11836
+ columnNames,
11837
+ cardsInColumn: (await store.issues.listPage({
11838
+ workspaceId: column.workspaceId,
11839
+ statusIds: [column.id],
11840
+ limit: 1
11841
+ })).total + entering
11842
+ };
11172
11843
  }
11173
11844
  /**
11174
- * Hold a card at the door of a column that asks for more than it has (ruling
11175
- * 6-3). Anyone but a person — or a person's own terminal, `placesForPerson`
11176
- * (ruling 6-6) — is refused with `board_column_rules_unmet`, which
11177
- * names the column and every unmet rule; a person is let through, and the
11178
- * unmet rules come back so the caller can warn them.
11845
+ * Hold a card at the door of a column that asks for more than it has — entry
11846
+ * rules (ruling 6-3), or flow rules: a column it may not come from, or a column
11847
+ * that is full. Anyone but a person — or a person's own terminal,
11848
+ * `placesForPerson` (ruling 6-6) — is refused with `board_column_rules_unmet`,
11849
+ * which names the column and everything unmet; a person is let through, and
11850
+ * the unmet list comes back so the caller can warn them.
11179
11851
  */
11180
- async function enforceColumnEntry(store, card, column, actor) {
11181
- const unmet = await checkColumnEntry(store, card, column);
11852
+ async function enforceColumnEntry(store, card, column, actor, entering = 0) {
11853
+ const unmet = await checkColumnEntry(store, card, column, entering);
11182
11854
  if (unmet.length > 0 && actor.kind !== "user" && !actor.placesForPerson) throw new BoardError("board_column_rules_unmet", {
11183
11855
  issueId: card.id,
11184
11856
  column: column.name,
@@ -11191,14 +11863,17 @@ async function enforceColumnEntry(store, card, column, actor) {
11191
11863
  * The same door for several cards entering one column at once — a bulk status
11192
11864
  * change. All or nothing: the first card an agent may not move refuses the
11193
11865
  * whole change. A person gets back the cards that entered with rules unmet.
11866
+ * Each card counts against the column's WIP limit as the cards before it in the same change entered it.
11194
11867
  */
11195
11868
  async function enforceColumnEntryForCards(store, issueIds, statusId, actor) {
11196
11869
  const column = await store.statuses.findById(statusId);
11197
11870
  if (!column) throw new BoardError("issue_status_not_found", { statusId });
11198
11871
  const entered = [];
11872
+ let entering = 0;
11199
11873
  for (const card of await store.issues.listByIds(issueIds)) {
11200
11874
  if (card.statusId === column.id) continue;
11201
- const unmet = await enforceColumnEntry(store, card, column, actor);
11875
+ const unmet = await enforceColumnEntry(store, card, column, actor, entering);
11876
+ entering++;
11202
11877
  if (unmet.length > 0) entered.push({
11203
11878
  issueId: card.id,
11204
11879
  unmet
@@ -11353,11 +12028,24 @@ const STOPPED_BY_YOU_STATUS_LINE = "stopped by you";
11353
12028
  * the board reads the same whether the agent was launched by an app or from
11354
12029
  * somewhere else entirely. The attempt number is simply how many runs the card
11355
12030
  * has had — a card whose runs were all launched elsewhere still counts them.
12031
+ *
12032
+ * A card has one run going at a time. A start on a card whose run is still going
12033
+ * is refused with `board_card_run_active`, naming that run — whichever path it
12034
+ * came by, an app's launch, `kanbo run start`, MCP, or a second app on a shared
12035
+ * Postgres board. The card's row is locked first, so two starts racing for it
12036
+ * are settled one after the other.
11356
12037
  */
11357
12038
  async function startRun(store, issueId, input, actor, scope) {
11358
12039
  const externalSessionRef = readExternalSessionRef(input.externalSessionRef) ?? null;
11359
12040
  return await runBoardWrite(store, async ({ tx }) => {
11360
- const card = await requireCard$1(tx, issueId);
12041
+ const card = await tx.issues.lockById(issueId);
12042
+ if (!card) throw new BoardError("issue_not_found", { issueId });
12043
+ const [active] = await tx.runs.listRunning(issueId);
12044
+ if (active) throw new BoardError("board_card_run_active", {
12045
+ issueId,
12046
+ runId: active.id,
12047
+ agentName: active.agentName
12048
+ });
11361
12049
  const attempt = await tx.runs.countByIssue(issueId) + 1;
11362
12050
  const executionMode = input.executionMode ?? card.executionMode;
11363
12051
  const run = await tx.runs.create({
@@ -11623,7 +12311,8 @@ async function createCard(store, input, actor, scope) {
11623
12311
  const column = statusId === null ? null : await tx.statuses.findById(statusId);
11624
12312
  const unmet = column ? await enforceColumnEntry(tx, {
11625
12313
  id: null,
11626
- description: input.description ?? null
12314
+ description: input.description ?? null,
12315
+ statusId: null
11627
12316
  }, column, actor) : [];
11628
12317
  const milestoneId = await requireMilestoneInWorkspace(tx, workspaceId, input.milestoneId ?? null);
11629
12318
  const parentIssueId = await requireParentInWorkspace(tx, workspaceId, input.parentIssueId ?? null);
@@ -11631,8 +12320,9 @@ async function createCard(store, input, actor, scope) {
11631
12320
  const now = currentUnixSeconds();
11632
12321
  for (let attempt = 1;; attempt++) {
11633
12322
  const identity = await nextIssueIdentity(input.workspace, tx);
12323
+ let created;
11634
12324
  try {
11635
- return withUnmetRules(await tx.issues.create({
12325
+ created = await tx.issues.create({
11636
12326
  id: identity.id,
11637
12327
  workspaceId,
11638
12328
  title: input.title?.trim() || identity.id,
@@ -11656,12 +12346,25 @@ async function createCard(store, input, actor, scope) {
11656
12346
  order,
11657
12347
  createdAt: now,
11658
12348
  updatedAt: now
11659
- }), unmet);
12349
+ });
11660
12350
  } catch (error) {
11661
12351
  const numberTaken = await tx.issues.findByNumber(workspaceId, identity.number);
11662
12352
  const keyTaken = await tx.issues.findById(identity.id);
11663
12353
  if (attempt >= NUMBERING_ATTEMPTS || !numberTaken && !keyTaken) throw error;
12354
+ continue;
11664
12355
  }
12356
+ if (statusId !== null) await tx.fieldChanges.create({
12357
+ id: (0, node_crypto.randomUUID)(),
12358
+ issueId: created.id,
12359
+ field: "statusId",
12360
+ fromValue: null,
12361
+ toValue: statusId,
12362
+ actorKind: toStoredActorKind(actor),
12363
+ actorId: actor.id ?? null,
12364
+ sourceChatSessionId: actor.chatSessionId ?? null,
12365
+ createdAt: now
12366
+ });
12367
+ return withUnmetRules(created, unmet);
11665
12368
  }
11666
12369
  }, scope);
11667
12370
  }
@@ -11717,7 +12420,8 @@ async function updateCard(store, issueId, input, actor, scope) {
11717
12420
  const description = "description" in updates ? updates.description ?? null : card.description;
11718
12421
  unmet = await enforceColumnEntry(tx, {
11719
12422
  id: card.id,
11720
- description
12423
+ description,
12424
+ statusId: card.statusId
11721
12425
  }, column, actor);
11722
12426
  }
11723
12427
  if ("assigneeKind" in input) updates.assigneeKind = input.assigneeKind ?? null;
@@ -12116,6 +12820,118 @@ async function reorderCards(store, workspaceId, orderedIds, scope) {
12116
12820
  }, scope);
12117
12821
  }
12118
12822
  //#endregion
12823
+ //#region src/ops/ready.ts
12824
+ /** The column an agent takes its next card from. */
12825
+ const READY_COLUMN_SLUG = "to_do";
12826
+ /**
12827
+ * The cards an agent may pick up right now: spelled out, nobody working on them,
12828
+ * nobody's turn but the agent's.
12829
+ *
12830
+ * "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
12831
+ * run is taken, and a card waiting for a person belongs to that person until
12832
+ * they answer. What is left is the queue — in board order, so the card someone
12833
+ * dragged to the top is the one an agent asking for work gets.
12834
+ */
12835
+ async function listReady(store, input) {
12836
+ return (await queryReady(store, input)).cards;
12837
+ }
12838
+ /**
12839
+ * One page of the ready cards, how many are ready in all, and the columns read
12840
+ * on the way — the rule above, asked of the database in one statement rather
12841
+ * than of every card on the board.
12842
+ */
12843
+ async function queryReady(store, input) {
12844
+ const columns = await listColumns(store, input.workspaceId);
12845
+ const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
12846
+ if (!column) return {
12847
+ cards: [],
12848
+ total: 0,
12849
+ columns
12850
+ };
12851
+ return {
12852
+ ...await store.issues.listPage({
12853
+ workspaceId: input.workspaceId,
12854
+ statusIds: [column.id],
12855
+ waitingForPerson: false,
12856
+ hasActiveRun: false,
12857
+ limit: input.limit,
12858
+ offset: input.offset
12859
+ }),
12860
+ columns
12861
+ };
12862
+ }
12863
+ //#endregion
12864
+ //#region src/ops/claim.ts
12865
+ /**
12866
+ * Take a card to work on, in one board write: pick it, start its run, and move
12867
+ * it into the board's first `started` column (In Progress on a standard
12868
+ * board), its entry and flow rules checked on the way in. Anything refused
12869
+ * leaves nothing written.
12870
+ *
12871
+ * Two claimers never get the same card. On SQLite the write holds the file
12872
+ * from its first read (`begin immediate`), so the second claimer reads the
12873
+ * board after the first one's commit. On Postgres the picked card's row is
12874
+ * locked (`select … for update`): a claimer that waited on the lock finds the
12875
+ * card taken, and moves on to the next ready one. The target's row is locked
12876
+ * first, always, in the order a move into it locks (column, then card), so a
12877
+ * claim and a move never wait on each other in a circle.
12878
+ */
12879
+ async function claimCard(store, input, actor, scope) {
12880
+ return await runBoardWrite(store, async ({ tx }) => {
12881
+ const columns = await listColumns(tx, input.workspaceId);
12882
+ const target = columns.find((column) => column.category === "started");
12883
+ if (!target) throw new BoardError("issue_status_not_found", {
12884
+ workspaceId: input.workspaceId,
12885
+ category: "started"
12886
+ });
12887
+ await tx.statuses.lockById(target.id);
12888
+ const toDo = columns.find((column) => normalizeStatusName(column.name) === "to_do") ?? null;
12889
+ const card = input.issueId === void 0 ? await takeFirstReady(tx, input.workspaceId, toDo) : await takeNamed(tx, input.workspaceId, input.issueId, toDo);
12890
+ const run = await startRun(tx, card.id, input.run, actor, { tx });
12891
+ return {
12892
+ card: await updateCard(tx, card.id, { statusId: target.id }, actor, { tx }),
12893
+ run,
12894
+ column: target
12895
+ };
12896
+ }, scope);
12897
+ }
12898
+ /**
12899
+ * The first ready card, locked. A card another claimer took while this one
12900
+ * waited for its lock is passed over, and the next read no longer lists it; the
12901
+ * cards passed over are counted so the loop ends with the list.
12902
+ */
12903
+ async function takeFirstReady(store, workspaceId, toDo) {
12904
+ const passed = /* @__PURE__ */ new Set();
12905
+ for (;;) {
12906
+ const { cards } = await queryReady(store, {
12907
+ workspaceId,
12908
+ limit: passed.size + 1
12909
+ });
12910
+ const candidate = cards.find((card) => !passed.has(card.id));
12911
+ if (!candidate) throw new BoardError("board_nothing_ready", { workspaceId });
12912
+ const locked = await store.issues.lockById(candidate.id);
12913
+ if (locked && await notReadyReason(store, locked, toDo) === null) return locked;
12914
+ passed.add(candidate.id);
12915
+ }
12916
+ }
12917
+ /** The named card, locked, when it is on this board and ready — or why not. */
12918
+ async function takeNamed(store, workspaceId, issueId, toDo) {
12919
+ const card = await store.issues.lockById(issueId);
12920
+ if (!card || card.workspaceId !== workspaceId) throw new BoardError("issue_not_found", { issueId });
12921
+ const reason = await notReadyReason(store, card, toDo);
12922
+ if (reason) throw new BoardError("board_card_not_ready", {
12923
+ issueId,
12924
+ reason
12925
+ });
12926
+ return card;
12927
+ }
12928
+ /** `queryReady`'s rule for one card already read: nobody on it, nobody waited for, in To Do. */
12929
+ async function notReadyReason(store, card, toDo) {
12930
+ if ((await store.runs.listRunning(card.id)).length > 0) return "run_active";
12931
+ if (card.waitingFor === "human") return "waiting_for_person";
12932
+ return toDo !== null && card.statusId === toDo.id ? null : "not_in_to_do";
12933
+ }
12934
+ //#endregion
12119
12935
  //#region src/ops/context-refs.ts
12120
12936
  const ContextRefsJsonSchema = string().transform((raw) => JSON.parse(raw)).pipe(array(string()));
12121
12937
  /** Add a context ref at the end of the card's list, recording the change in its history. */
@@ -12246,33 +13062,6 @@ async function migrateCards(store, input) {
12246
13062
  };
12247
13063
  }
12248
13064
  //#endregion
12249
- //#region src/domain/card-display-title.ts
12250
- /**
12251
- * What a card is called wherever a person or an agent reads it by name.
12252
- *
12253
- * A card written as a description alone is titled with its own key (every
12254
- * writer does that). Named by its key, such a card says nothing — so its name
12255
- * is the first line of its description instead. A browser bundle that does not
12256
- * load this package keeps the same rule on its own, tested with the same
12257
- * examples.
12258
- */
12259
- /** How long a description line may run before it is cut with an ellipsis. */
12260
- const DESCRIPTION_LINE_LIMIT = 120;
12261
- function cardDisplayTitle(card) {
12262
- if (card.title !== card.id) return card.title;
12263
- return descriptionFirstLine(card.description) ?? card.title;
12264
- }
12265
- function descriptionFirstLine(description) {
12266
- for (const rawLine of (description ?? "").split("\n")) {
12267
- const line = stripMarkdown(rawLine);
12268
- if (line) return line.length > DESCRIPTION_LINE_LIMIT ? `${line.slice(0, 119).trimEnd()}…` : line;
12269
- }
12270
- return null;
12271
- }
12272
- function stripMarkdown(line) {
12273
- 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();
12274
- }
12275
- //#endregion
12276
13065
  //#region src/ops/agent-rules.ts
12277
13066
  /**
12278
13067
  * The sentences that tell an agent how to work this board, written once.
@@ -12300,6 +13089,7 @@ function subtaskSplitSentence(parentReference) {
12300
13089
  }
12301
13090
  const SUBTASK_RULE = `${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("the parent card's id")}`;
12302
13091
  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.";
13092
+ 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.";
12303
13093
  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.";
12304
13094
  /** Said by the `kanbo init` block and the MCP instructions, which name the comment command or tool beside it. */
12305
13095
  const COMMENT_RULE = "Findings, results, decisions and questions go on the card as comments.";
@@ -12323,6 +13113,7 @@ const BOARD_RULES = [
12323
13113
  EXECUTION_MODE_RULE,
12324
13114
  SUBTASK_RULE,
12325
13115
  PULL_REQUEST_RULE,
13116
+ CLAIM_RULE,
12326
13117
  OWN_SESSION_RULE,
12327
13118
  "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."
12328
13119
  ];
@@ -12338,12 +13129,14 @@ const BOARD_RULES = [
12338
13129
  async function buildPrimeText(store, workspaceId) {
12339
13130
  const columns = await listColumns(store, workspaceId);
12340
13131
  const ruled = columns.some((column) => readEntryRules(column).length > 0);
13132
+ const flowing = columns.some((column) => hasFlowRules(column));
12341
13133
  return [
12342
13134
  ...await describeReturnedCards(store, workspaceId, columns),
12343
13135
  "Columns of this board, in board order:",
12344
13136
  "",
12345
- ...columns.length > 0 ? columns.map(describeColumnLine) : ["- (this board has no columns yet)"],
13137
+ ...columns.length > 0 ? columns.map((column) => describeColumnLine(column, columns)) : ["- (this board has no columns yet)"],
12346
13138
  ...ruled ? ["", ENTRY_RULES_NOTE] : [],
13139
+ ...flowing ? ["", FLOW_RULES_NOTE] : [],
12347
13140
  "",
12348
13141
  "How this board works:",
12349
13142
  "",
@@ -12385,11 +13178,22 @@ async function describeReturnedCards(store, workspaceId, columns) {
12385
13178
  * without any reads exactly as it did before they existed.
12386
13179
  */
12387
13180
  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.";
12388
- function describeColumnLine(column) {
13181
+ /** Said under the columns only when one of them has flow rules. */
13182
+ 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.";
13183
+ function hasFlowRules(column) {
13184
+ const { allowedFrom, wipLimit } = readFlowRules(column);
13185
+ return allowedFrom !== null || wipLimit !== null;
13186
+ }
13187
+ function describeColumnLine(column, columns) {
12389
13188
  const slug = normalizeStatusName(column.name);
12390
13189
  const rules = readEntryRules(column);
12391
- const requires = rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : "";
12392
- return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${requires}` : `- ${column.name} (\`${slug}\`)${requires ? ` —${requires}` : ""}`;
13190
+ const said = `${rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : ""}${describeFlow(column, columns)}`;
13191
+ return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${said}` : `- ${column.name} (\`${slug}\`)${said ? ` —${said}` : ""}`;
13192
+ }
13193
+ /** Where a column takes cards from and how many it holds, as the prime text says it; nothing for a column without flow rules. */
13194
+ function describeFlow(column, columns) {
13195
+ const { allowedFrom, wipLimit } = readFlowRules(column);
13196
+ 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"}.`}`;
12393
13197
  }
12394
13198
  //#endregion
12395
13199
  //#region src/domain/pull-request-ref.ts
@@ -12559,47 +13363,6 @@ async function queryCards(store, input) {
12559
13363
  };
12560
13364
  }
12561
13365
  //#endregion
12562
- //#region src/ops/ready.ts
12563
- /** The column an agent takes its next card from. */
12564
- const READY_COLUMN_SLUG = "to_do";
12565
- /**
12566
- * The cards an agent may pick up right now: spelled out, nobody working on them,
12567
- * nobody's turn but the agent's.
12568
- *
12569
- * "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
12570
- * run is taken, and a card waiting for a person belongs to that person until
12571
- * they answer. What is left is the queue — in board order, so the card someone
12572
- * dragged to the top is the one an agent asking for work gets.
12573
- */
12574
- async function listReady(store, input) {
12575
- return (await queryReady(store, input)).cards;
12576
- }
12577
- /**
12578
- * One page of the ready cards, how many are ready in all, and the columns read
12579
- * on the way — the rule above, asked of the database in one statement rather
12580
- * than of every card on the board.
12581
- */
12582
- async function queryReady(store, input) {
12583
- const columns = await listColumns(store, input.workspaceId);
12584
- const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
12585
- if (!column) return {
12586
- cards: [],
12587
- total: 0,
12588
- columns
12589
- };
12590
- return {
12591
- ...await store.issues.listPage({
12592
- workspaceId: input.workspaceId,
12593
- statusIds: [column.id],
12594
- waitingForPerson: false,
12595
- hasActiveRun: false,
12596
- limit: input.limit,
12597
- offset: input.offset
12598
- }),
12599
- columns
12600
- };
12601
- }
12602
- //#endregion
12603
13366
  //#region src/ops/relations.ts
12604
13367
  /**
12605
13368
  * Every edge touching the card, in either direction, each with the card on its
@@ -12889,6 +13652,8 @@ function createBoardOps(store) {
12889
13652
  updateColumn: async (statusId, patch, actor, scope) => await updateColumn(store, statusId, patch, actor, scope),
12890
13653
  describeColumn: async (workspaceId, nameOrId, description, scope) => await describeColumn(store, workspaceId, nameOrId, description, scope),
12891
13654
  setColumnEntryRules: async (workspaceId, nameOrId, entryRules, actor, scope) => await setColumnEntryRules(store, workspaceId, nameOrId, entryRules, actor, scope),
13655
+ setColumnFlowRules: async (columnId, rules, actor, scope) => await setColumnFlowRules(store, columnId, rules, actor, scope),
13656
+ applyStandardFlow: async (workspaceId, actor, scope) => await applyStandardFlow(store, workspaceId, actor, scope),
12892
13657
  enforceColumnEntryForCards: async (issueIds, statusId, actor) => await enforceColumnEntryForCards(store, issueIds, statusId, actor),
12893
13658
  deleteColumn: async (statusId, scope, actor) => await deleteColumn(store, statusId, scope, actor),
12894
13659
  reorderColumns: async (workspaceId, orderedIds, scope, actor) => await reorderColumns(store, workspaceId, orderedIds, scope, actor),
@@ -12899,6 +13664,7 @@ function createBoardOps(store) {
12899
13664
  moveColumn: async (workspaceId, nameOrId, position, actor, scope) => await moveColumn$1(store, workspaceId, nameOrId, position, actor, scope),
12900
13665
  removeColumn: async (workspaceId, nameOrId, options, actor, scope) => await removeColumn$1(store, workspaceId, nameOrId, options, actor, scope),
12901
13666
  startRun: async (issueId, input, actor, scope) => await startRun(store, issueId, input, actor, scope),
13667
+ claimCard: async (input, actor, scope) => await claimCard(store, input, actor, scope),
12902
13668
  attachRunExecution: async (runId, input, scope) => await attachRunExecution(store, runId, input, scope),
12903
13669
  recordRunSessionRef: async (runId, ref, scope) => await recordRunSessionRef(store, runId, ref, scope),
12904
13670
  clearRunSessionRef: async (runId, actor, options, scope) => await clearRunSessionRef(store, runId, actor, options, scope),
@@ -12911,7 +13677,10 @@ function createBoardOps(store) {
12911
13677
  readBoardProjectionForIssue: async (issueId) => await readBoardProjectionForIssue(store, issueId),
12912
13678
  listReady: async (input) => await listReady(store, input),
12913
13679
  queryReady: async (input) => await queryReady(store, input),
12914
- buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId)
13680
+ listColumnEntries: async (input) => await listColumnEntries(store, input),
13681
+ buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId),
13682
+ readBoardMetricsHistory: async (workspaceIds) => await readBoardMetricsHistory(store, workspaceIds),
13683
+ boardMetrics: async (input, now) => await boardMetrics(store, input, now)
12915
13684
  };
12916
13685
  }
12917
13686
  //#endregion
@@ -18777,6 +19546,10 @@ const issueStatuses$1 = pgTable("issue_statuses", {
18777
19546
  order: integer$1("order").notNull().default(0),
18778
19547
  /** A JSON array of `ENTRY_RULES`, or `null` for none — see the SQLite schema. */
18779
19548
  entryRules: text$1("entry_rules"),
19549
+ /** A JSON array of the status ids a card may enter from, or `null` for any — see the SQLite schema. */
19550
+ allowedFrom: text$1("allowed_from"),
19551
+ /** The most cards the column holds, or `null` — see the SQLite schema. */
19552
+ wipLimit: integer$1("wip_limit"),
18780
19553
  ...createdAt$1()
18781
19554
  }, (table) => [index$1("issue_statuses_workspace_id_idx").on(table.workspaceId), uniqueIndex$1("issue_statuses_workspace_name_unique").on(table.workspaceId, table.name)]);
18782
19555
  const issueMilestones$1 = pgTable("issue_milestones", {
@@ -18924,7 +19697,7 @@ const issueFieldChanges$1 = pgTable("issue_field_changes", {
18924
19697
  ...createdAt$1(),
18925
19698
  /** Insertion order, standing in for the `rowid` the SQLite board orders changes by. */
18926
19699
  seq: bigserial("seq", { mode: "number" }).notNull()
18927
- }, (table) => [index$1("issue_field_changes_issue_id_idx").on(table.issueId)]);
19700
+ }, (table) => [index$1("issue_field_changes_issue_id_idx").on(table.issueId), index$1("issue_field_changes_field_created_at_idx").on(table.field, table.createdAt)]);
18928
19701
  /** A pull request someone said belongs to a card — see the SQLite twin for what the row is and is not. */
18929
19702
  const issuePullRequests$1 = pgTable("issue_pull_requests", {
18930
19703
  id: textPk$1(),
@@ -19000,6 +19773,10 @@ function toIssueComment({ seq: _seq, ...comment }) {
19000
19773
  function toIssueFieldChange({ seq: _seq, ...change }) {
19001
19774
  return change;
19002
19775
  }
19776
+ /** A field change with its card's workspace, without `seq`, for the same reason. */
19777
+ function toWorkspaceFieldChange({ seq: _seq, ...change }) {
19778
+ return change;
19779
+ }
19003
19780
  /** The pull-request link without `seq`, for the same reason. */
19004
19781
  function toIssuePullRequest({ seq: _seq, ...link }) {
19005
19782
  return link;
@@ -19206,6 +19983,10 @@ function createStoreOver(database) {
19206
19983
  const [row] = await database.select().from(issues$1).where(eq(issues$1.id, issueId)).limit(1);
19207
19984
  return row ?? null;
19208
19985
  },
19986
+ lockById: async (issueId) => {
19987
+ const [row] = await database.select().from(issues$1).where(eq(issues$1.id, issueId)).limit(1).for("update");
19988
+ return row ?? null;
19989
+ },
19209
19990
  findInWorkspace: async (workspaceId, issueId) => {
19210
19991
  const [row] = await database.select().from(issues$1).where(and(eq(issues$1.id, issueId), eq(issues$1.workspaceId, workspaceId))).limit(1);
19211
19992
  return row ?? null;
@@ -19270,6 +20051,10 @@ function createStoreOver(database) {
19270
20051
  },
19271
20052
  comments: {
19272
20053
  listByIssue: async (issueId) => (await database.select().from(issueComments$1).where(eq(issueComments$1.issueId, issueId)).orderBy(asc(issueComments$1.createdAt), asc(issueComments$1.seq))).map(toIssueComment),
20054
+ listByWorkspaces: async (workspaceIds, authorKinds) => {
20055
+ if (workspaceIds.length === 0 || authorKinds.length === 0) return [];
20056
+ return (await database.select(getTableColumns(issueComments$1)).from(issueComments$1).innerJoin(issues$1, eq(issues$1.id, issueComments$1.issueId)).where(and(inArray(issues$1.workspaceId, [...workspaceIds]), inArray(issueComments$1.authorKind, [...authorKinds]))).orderBy(asc(issueComments$1.createdAt), asc(issueComments$1.seq))).map(toIssueComment);
20057
+ },
19273
20058
  listLatestByIssues: async (issueIds, authorKinds) => {
19274
20059
  if (issueIds.length === 0 || authorKinds?.length === 0) return [];
19275
20060
  const ranked = database.select({
@@ -19306,6 +20091,10 @@ function createStoreOver(database) {
19306
20091
  listByIssue: async (issueId) => (await database.select().from(issueRuns$1).where(eq(issueRuns$1.issueId, issueId)).orderBy(asc(issueRuns$1.startedAt), asc(issueRuns$1.seq))).map(toIssueRun),
19307
20092
  listRunning: async (issueId) => (await database.select().from(issueRuns$1).where(and(eq(issueRuns$1.issueId, issueId), eq(issueRuns$1.state, "running"))).orderBy(asc(issueRuns$1.startedAt), asc(issueRuns$1.seq))).map(toIssueRun),
19308
20093
  listRunningWithSession: async () => (await database.select().from(issueRuns$1).where(and(eq(issueRuns$1.state, "running"), isNotNull(issueRuns$1.chatSessionId))).orderBy(asc(issueRuns$1.startedAt), asc(issueRuns$1.seq))).map(toIssueRun),
20094
+ listByWorkspaces: async (workspaceIds) => {
20095
+ if (workspaceIds.length === 0) return [];
20096
+ return (await database.select(getTableColumns(issueRuns$1)).from(issueRuns$1).innerJoin(issues$1, eq(issues$1.id, issueRuns$1.issueId)).where(inArray(issues$1.workspaceId, [...workspaceIds])).orderBy(asc(issueRuns$1.startedAt), asc(issueRuns$1.seq))).map(toIssueRun);
20097
+ },
19309
20098
  findById: async (runId) => {
19310
20099
  const [row] = await database.select().from(issueRuns$1).where(eq(issueRuns$1.id, runId)).limit(1);
19311
20100
  return row ? toIssueRun(row) : null;
@@ -19357,6 +20146,10 @@ function createStoreOver(database) {
19357
20146
  fieldChanges: {
19358
20147
  listByIssue: async (issueId) => (await database.select().from(issueFieldChanges$1).where(eq(issueFieldChanges$1.issueId, issueId)).orderBy(asc(issueFieldChanges$1.createdAt), asc(issueFieldChanges$1.seq))).map(toIssueFieldChange),
19359
20148
  listByIssueField: async (issueId, field) => (await database.select().from(issueFieldChanges$1).where(and(eq(issueFieldChanges$1.issueId, issueId), eq(issueFieldChanges$1.field, field))).orderBy(asc(issueFieldChanges$1.createdAt), asc(issueFieldChanges$1.seq))).map(toIssueFieldChange),
20149
+ listByFieldSince: async (field, query) => query.workspaceIds.length === 0 ? [] : (await database.select({
20150
+ ...getTableColumns(issueFieldChanges$1),
20151
+ workspaceId: issues$1.workspaceId
20152
+ }).from(issueFieldChanges$1).innerJoin(issues$1, eq(issues$1.id, issueFieldChanges$1.issueId)).where(and(eq(issueFieldChanges$1.field, field), isNotNull(issueFieldChanges$1.toValue), gte(issueFieldChanges$1.createdAt, query.since), query.until === void 0 ? void 0 : lte(issueFieldChanges$1.createdAt, query.until), inArray(issues$1.workspaceId, [...query.workspaceIds]))).orderBy(asc(issueFieldChanges$1.createdAt), asc(issueFieldChanges$1.id)).limit(query.limit ?? Number.MAX_SAFE_INTEGER)).map(toWorkspaceFieldChange),
19360
20153
  create: async (values) => {
19361
20154
  await database.insert(issueFieldChanges$1).values(values);
19362
20155
  }
@@ -20174,6 +20967,14 @@ const issueStatuses = sqliteTable("issue_statuses", {
20174
20967
  * through `readEntryRules`, never by hand.
20175
20968
  */
20176
20969
  entryRules: text("entry_rules"),
20970
+ /**
20971
+ * The columns a card may enter this one from — a JSON array of status ids;
20972
+ * `null` for any column, `[]` for none (only a person puts a card here). Read
20973
+ * it through `readFlowRules`, never by hand.
20974
+ */
20975
+ allowedFrom: text("allowed_from"),
20976
+ /** The most cards this column holds, 1 or more; `null` for no limit. */
20977
+ wipLimit: int("wip_limit"),
20177
20978
  ...createdAt()
20178
20979
  }, (table) => ({
20179
20980
  byWorkspace: index("issue_statuses_workspace_id_idx").on(table.workspaceId),
@@ -20321,7 +21122,11 @@ const issueFieldChanges = sqliteTable("issue_field_changes", {
20321
21122
  actorId: text("actor_id"),
20322
21123
  sourceChatSessionId: text("source_chat_session_id"),
20323
21124
  ...createdAt()
20324
- }, (table) => ({ byIssue: index("issue_field_changes_issue_id_idx").on(table.issueId) }));
21125
+ }, (table) => ({
21126
+ byIssue: index("issue_field_changes_issue_id_idx").on(table.issueId),
21127
+ /** The column-entry feed reads one field across a board by time. */
21128
+ byFieldCreatedAt: index("issue_field_changes_field_created_at_idx").on(table.field, table.createdAt)
21129
+ }));
20325
21130
  /**
20326
21131
  * A pull request someone said belongs to a card.
20327
21132
  *
@@ -20664,6 +21469,7 @@ function createSqliteBoardStoreCore(resolveDatabase) {
20664
21469
  }).from(issues).where(and(eq(issues.workspaceId, workspaceId), isNotNull(issues.milestoneId))).groupBy(issues.milestoneId, issues.statusId).all(),
20665
21470
  listByIds: (issueIds) => resolveDatabase().select().from(issues).where(inArray(issues.id, issueIds)).all(),
20666
21471
  findById: (issueId) => resolveDatabase().select().from(issues).where(eq(issues.id, issueId)).get() ?? null,
21472
+ lockById: (issueId) => resolveDatabase().select().from(issues).where(eq(issues.id, issueId)).get() ?? null,
20667
21473
  findInWorkspace: (workspaceId, issueId) => resolveDatabase().select().from(issues).where(and(eq(issues.id, issueId), eq(issues.workspaceId, workspaceId))).get() ?? null,
20668
21474
  findByNumber: (workspaceId, number) => resolveDatabase().select().from(issues).where(and(eq(issues.workspaceId, workspaceId), eq(issues.number, number))).get() ?? null,
20669
21475
  maxNumber: (workspaceId) => {
@@ -20700,6 +21506,10 @@ function createSqliteBoardStoreCore(resolveDatabase) {
20700
21506
  },
20701
21507
  comments: {
20702
21508
  listByIssue: (issueId) => resolveDatabase().select().from(issueComments).where(eq(issueComments.issueId, issueId)).orderBy(asc(issueComments.createdAt), COMMENT_INSERTION_ORDER).all(),
21509
+ listByWorkspaces: (workspaceIds, authorKinds) => {
21510
+ if (workspaceIds.length === 0 || authorKinds.length === 0) return [];
21511
+ return resolveDatabase().select(getTableColumns(issueComments)).from(issueComments).innerJoin(issues, eq(issues.id, issueComments.issueId)).where(and(inArray(issues.workspaceId, [...workspaceIds]), inArray(issueComments.authorKind, [...authorKinds]))).orderBy(asc(issueComments.createdAt), COMMENT_INSERTION_ORDER).all();
21512
+ },
20703
21513
  listLatestByIssues: (issueIds, authorKinds) => {
20704
21514
  if (issueIds.length === 0 || authorKinds?.length === 0) return [];
20705
21515
  const database = resolveDatabase();
@@ -20729,6 +21539,10 @@ function createSqliteBoardStoreCore(resolveDatabase) {
20729
21539
  listByIssue: (issueId) => resolveDatabase().select().from(issueRuns).where(eq(issueRuns.issueId, issueId)).orderBy(asc(issueRuns.startedAt), RUN_INSERTION_ORDER).all(),
20730
21540
  listRunning: (issueId) => resolveDatabase().select().from(issueRuns).where(and(eq(issueRuns.issueId, issueId), eq(issueRuns.state, "running"))).orderBy(asc(issueRuns.startedAt), RUN_INSERTION_ORDER).all(),
20731
21541
  listRunningWithSession: () => resolveDatabase().select().from(issueRuns).where(and(eq(issueRuns.state, "running"), isNotNull(issueRuns.chatSessionId))).orderBy(asc(issueRuns.startedAt), RUN_INSERTION_ORDER).all(),
21542
+ listByWorkspaces: (workspaceIds) => {
21543
+ if (workspaceIds.length === 0) return [];
21544
+ return resolveDatabase().select(getTableColumns(issueRuns)).from(issueRuns).innerJoin(issues, eq(issues.id, issueRuns.issueId)).where(inArray(issues.workspaceId, [...workspaceIds])).orderBy(asc(issueRuns.startedAt), RUN_INSERTION_ORDER).all();
21545
+ },
20732
21546
  findById: (runId) => resolveDatabase().select().from(issueRuns).where(eq(issueRuns.id, runId)).get() ?? null,
20733
21547
  findByChatSessionId: (chatSessionId) => resolveDatabase().select().from(issueRuns).where(eq(issueRuns.chatSessionId, chatSessionId)).orderBy(desc(issueRuns.startedAt), sql`${issueRuns}.rowid desc`).get() ?? null,
20734
21548
  countByIssue: (issueId) => {
@@ -20761,6 +21575,10 @@ function createSqliteBoardStoreCore(resolveDatabase) {
20761
21575
  fieldChanges: {
20762
21576
  listByIssue: (issueId) => resolveDatabase().select().from(issueFieldChanges).where(eq(issueFieldChanges.issueId, issueId)).orderBy(asc(issueFieldChanges.createdAt), FIELD_CHANGE_INSERTION_ORDER).all(),
20763
21577
  listByIssueField: (issueId, field) => resolveDatabase().select().from(issueFieldChanges).where(and(eq(issueFieldChanges.issueId, issueId), eq(issueFieldChanges.field, field))).orderBy(asc(issueFieldChanges.createdAt), FIELD_CHANGE_INSERTION_ORDER).all(),
21578
+ listByFieldSince: (field, query) => query.workspaceIds.length === 0 ? [] : resolveDatabase().select({
21579
+ ...getTableColumns(issueFieldChanges),
21580
+ workspaceId: issues.workspaceId
21581
+ }).from(issueFieldChanges).innerJoin(issues, eq(issues.id, issueFieldChanges.issueId)).where(and(eq(issueFieldChanges.field, field), isNotNull(issueFieldChanges.toValue), gte(issueFieldChanges.createdAt, query.since), query.until === void 0 ? void 0 : lte(issueFieldChanges.createdAt, query.until), inArray(issues.workspaceId, [...query.workspaceIds]))).orderBy(asc(issueFieldChanges.createdAt), asc(issueFieldChanges.id)).limit(query.limit ?? Number.MAX_SAFE_INTEGER).all(),
20764
21582
  create: (values) => {
20765
21583
  resolveDatabase().insert(issueFieldChanges).values(values).run();
20766
21584
  }
@@ -20841,6 +21659,7 @@ function createSqliteBoardStore(options) {
20841
21659
  countByMilestone: async (workspaceId) => core.issues.countByMilestone(workspaceId),
20842
21660
  listByIds: async (issueIds) => core.issues.listByIds(issueIds),
20843
21661
  findById: async (issueId) => core.issues.findById(issueId),
21662
+ lockById: async (issueId) => core.issues.lockById(issueId),
20844
21663
  findInWorkspace: async (workspaceId, issueId) => core.issues.findInWorkspace(workspaceId, issueId),
20845
21664
  findByNumber: async (workspaceId, number) => core.issues.findByNumber(workspaceId, number),
20846
21665
  maxNumber: async (workspaceId) => core.issues.maxNumber(workspaceId),
@@ -20856,6 +21675,7 @@ function createSqliteBoardStore(options) {
20856
21675
  comments: {
20857
21676
  listByIssue: async (issueId) => core.comments.listByIssue(issueId),
20858
21677
  listLatestByIssues: async (issueIds, authorKinds) => core.comments.listLatestByIssues(issueIds, authorKinds),
21678
+ listByWorkspaces: async (workspaceIds, authorKinds) => core.comments.listByWorkspaces(workspaceIds, authorKinds),
20859
21679
  findById: async (commentId) => core.comments.findById(commentId),
20860
21680
  findByDedupeKey: async (issueId, dedupeKey) => core.comments.findByDedupeKey(issueId, dedupeKey),
20861
21681
  listByDedupeKeyPrefix: async (issueId, prefix) => core.comments.listByDedupeKeyPrefix(issueId, prefix),
@@ -20867,6 +21687,7 @@ function createSqliteBoardStore(options) {
20867
21687
  listByIssue: async (issueId) => core.runs.listByIssue(issueId),
20868
21688
  listRunning: async (issueId) => core.runs.listRunning(issueId),
20869
21689
  listRunningWithSession: async () => core.runs.listRunningWithSession(),
21690
+ listByWorkspaces: async (workspaceIds) => core.runs.listByWorkspaces(workspaceIds),
20870
21691
  findById: async (runId) => core.runs.findById(runId),
20871
21692
  findByChatSessionId: async (chatSessionId) => core.runs.findByChatSessionId(chatSessionId),
20872
21693
  countByIssue: async (issueId) => core.runs.countByIssue(issueId),
@@ -20886,6 +21707,7 @@ function createSqliteBoardStore(options) {
20886
21707
  fieldChanges: {
20887
21708
  listByIssue: async (issueId) => core.fieldChanges.listByIssue(issueId),
20888
21709
  listByIssueField: async (issueId, field) => core.fieldChanges.listByIssueField(issueId, field),
21710
+ listByFieldSince: async (field, query) => core.fieldChanges.listByFieldSince(field, query),
20889
21711
  create: async (values) => core.fieldChanges.create(values)
20890
21712
  },
20891
21713
  pullRequests: {
@@ -24007,9 +24829,10 @@ const SCHEMA_NOT_INSTALLED_MESSAGE = "This Postgres database has no kanbo board
24007
24829
  * caller's own risk but never written to — a write shaped by this build's idea
24008
24830
  * of the schema is how a person's board gets a column full of nothing. A file
24009
24831
  * of this package's own is this package's to migrate, so the same refusal
24010
- * points at `kanbo migrate` instead of at the app. Reads stay open either way,
24011
- * because a person who cannot migrate right now should still be able to look at
24012
- * their cards.
24832
+ * points at `kanbo migrate` instead of at the app. Reads are held to the lesser
24833
+ * bar of `assertReadableBoard`: a file only a stamp behind is still read, so a
24834
+ * person who cannot migrate right now can look at their cards, but one missing
24835
+ * a table or column this build reads is refused with the same sentence.
24013
24836
  */
24014
24837
  function assertWritableBoard(database, owner) {
24015
24838
  try {
@@ -24020,6 +24843,26 @@ function assertWritableBoard(database, owner) {
24020
24843
  }
24021
24844
  }
24022
24845
  /**
24846
+ * Refuse a read of a board file that lacks a table or a column this build
24847
+ * reads — a file a migration behind — with the sentence a write gets, instead
24848
+ * of letting the first statement fail on what is missing. A file whose only
24849
+ * difference is its schema stamp is still read: every statement this build
24850
+ * makes works on it, and a person who cannot migrate now can still look at
24851
+ * their cards.
24852
+ */
24853
+ function assertReadableBoard(database, owner) {
24854
+ try {
24855
+ assertBoardSchema$1(database);
24856
+ } catch (error) {
24857
+ if (!(error instanceof BoardError && error.code === "board_schema_outdated")) throw error;
24858
+ if (lacksWhatThisBuildReads(error.details)) throw new CliError(3, owner === "kanbo" ? FILE_SCHEMA_OUTDATED_MESSAGE : SCHEMA_OUTDATED_MESSAGE);
24859
+ }
24860
+ }
24861
+ /** Whether `assertBoardSchema` found a table or a column missing, rather than only another stamp. */
24862
+ function lacksWhatThisBuildReads(details) {
24863
+ return details !== void 0 && ("missingTables" in details || "missingAddedTables" in details || "missingColumns" in details);
24864
+ }
24865
+ /**
24023
24866
  * Refuse an external board this build cannot speak for — before reading it as
24024
24867
  * well as before writing to it.
24025
24868
  *
@@ -24258,6 +25101,7 @@ async function openFileSession(target, options, mode, cwd) {
24258
25101
  assertWritableBoard(board.database, target.owner);
24259
25102
  };
24260
25103
  if (mode === "write") assertWritableBoard(board.database, target.owner);
25104
+ else assertReadableBoard(board.database, target.owner);
24261
25105
  const workspace = resolveWorkspace(target.owner === "kanbo" ? { kind: "file" } : {
24262
25106
  kind: "sqlite",
24263
25107
  database: board.database
@@ -24281,8 +25125,9 @@ async function openFileSession(target, options, mode, cwd) {
24281
25125
  /**
24282
25126
  * The same, on a board that is a database of its own.
24283
25127
  *
24284
- * The guard runs whatever the command came to do. A board file that this build
24285
- * cannot write to is still a board worth reading; a connection string pointing
25128
+ * The guard runs whatever the command came to do. A board file whose only
25129
+ * difference from this build is its schema stamp is still a board worth
25130
+ * reading; a connection string pointing
24286
25131
  * at a database with no board in it is nothing to read at all, and every
24287
25132
  * command would otherwise fail one statement later with the driver's own words.
24288
25133
  */
@@ -24339,6 +25184,17 @@ function parseCount(value) {
24339
25184
  return count;
24340
25185
  }
24341
25186
  /**
25187
+ * A date as the command line takes it: `YYYY-MM-DD`, read as a UTC day, or a
25188
+ * unix-seconds number taken as it is. `end` turns a day into its last second,
25189
+ * so a range or a sprint ending on a day runs through that day.
25190
+ */
25191
+ function parseCalendarDay(value, end) {
25192
+ if (/^\d+$/.test(value)) return Number(value);
25193
+ const day = /^\d{4}-\d{2}-\d{2}$/.test(value) ? Date.parse(`${value}T00:00:00Z`) : NaN;
25194
+ if (Number.isNaN(day) || new Date(day).toISOString().slice(0, 10) !== value) throw new CliError(1, `Expected a date as YYYY-MM-DD or unix seconds, got "${value}".`);
25195
+ return day / 1e3 + (end ? 86399 : 0);
25196
+ }
25197
+ /**
24342
25198
  * The card a caller named, in the workspace the command is about.
24343
25199
  *
24344
25200
  * People and agents refer to a card by whatever they have in front of them: the
@@ -24580,7 +25436,9 @@ function projectCardActiveRun(run) {
24580
25436
  startedAt: run.startedAt
24581
25437
  };
24582
25438
  }
24583
- function projectColumn(column) {
25439
+ /** A column as the command line prints it; `board` is every column, to name the ones it accepts cards from. */
25440
+ function projectColumn(column, board) {
25441
+ const { allowedFrom, wipLimit } = readFlowRules(column);
24584
25442
  return {
24585
25443
  id: column.id,
24586
25444
  name: column.name,
@@ -24588,7 +25446,9 @@ function projectColumn(column) {
24588
25446
  description: column.description,
24589
25447
  category: column.category,
24590
25448
  order: column.order,
24591
- entryRules: readEntryRules(column)
25449
+ entryRules: readEntryRules(column),
25450
+ allowedFrom: allowedFrom === null ? null : board.filter((other) => allowedFrom.includes(other.id)).map((other) => normalizeStatusName(other.name)),
25451
+ wipLimit
24592
25452
  };
24593
25453
  }
24594
25454
  function projectSprint(sprint) {
@@ -24660,7 +25520,9 @@ function describeColumns(columns) {
24660
25520
  return columns.map((column) => [
24661
25521
  `${padToWidth(sanitizeTerminalText(column.slug), slugWidth)} ${sanitizeTerminalText(column.name)}`,
24662
25522
  column.description ? ` — ${sanitizeTerminalText(column.description)}` : "",
24663
- column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : ""
25523
+ column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : "",
25524
+ column.allowedFrom === null ? "" : ` [from: ${column.allowedFrom.length > 0 ? column.allowedFrom.join(", ") : "no column"}]`,
25525
+ column.wipLimit === null ? "" : ` [wip: ${column.wipLimit}]`
24664
25526
  ].join("")).join("\n");
24665
25527
  }
24666
25528
  /** A unix-seconds date as the UTC day it falls on, `—` for none. */
@@ -24854,6 +25716,7 @@ function colorsWanted() {
24854
25716
  const KANBO_TOOL_CLI_EQUIVALENTS = {
24855
25717
  kanbo_prime: { kanbo: "kanbo prime" },
24856
25718
  kanbo_ready: { kanbo: "kanbo ready" },
25719
+ kanbo_card_claim: { kanbo: "kanbo claim [card] [--agent <name>]" },
24857
25720
  kanbo_columns: { kanbo: "kanbo columns list" },
24858
25721
  kanbo_sprints: { kanbo: "kanbo sprint list" },
24859
25722
  kanbo_card_get: { kanbo: "kanbo card get <id>" },
@@ -25270,16 +26133,22 @@ function projectKanboSprint(sprint) {
25270
26133
  doneCards: sprint.cards.done
25271
26134
  };
25272
26135
  }
25273
- /** A column of the board, from the row either transport holds. */
25274
- function projectKanboColumn(column) {
26136
+ /** A column of the board, from the row either transport holds; `board` is every column, to name the ones it accepts cards from. */
26137
+ function projectKanboColumn(column, board) {
25275
26138
  const stored = column.entryRules;
26139
+ const allowedIds = typeof column.allowedFrom === "string" ? readFlowRules({
26140
+ allowedFrom: column.allowedFrom,
26141
+ wipLimit: null
26142
+ }).allowedFrom : column.allowedFrom ?? null;
25276
26143
  return {
25277
26144
  id: column.id,
25278
26145
  name: column.name,
25279
26146
  slug: normalizeStatusName(column.name),
25280
26147
  description: column.description,
25281
26148
  order: column.order,
25282
- entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null })
26149
+ entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null }),
26150
+ allowedFrom: allowedIds === null ? null : board.filter((other) => allowedIds.includes(other.id)).map((other) => normalizeStatusName(other.name)),
26151
+ wipLimit: column.wipLimit ?? null
25283
26152
  };
25284
26153
  }
25285
26154
  /** A comment as a card read lists it, from the row either transport holds. */
@@ -25393,7 +26262,7 @@ function readMoment(value) {
25393
26262
  const cardMoveTool = defineKanboTool({
25394
26263
  name: "kanbo_card_move",
25395
26264
  title: "Move a card",
25396
- 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}`,
26265
+ 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}`,
25397
26266
  inputSchema: {
25398
26267
  card: cardArgument,
25399
26268
  column: string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`."),
@@ -25465,7 +26334,7 @@ const cardUpdateTool = defineKanboTool({
25465
26334
  const columnsTool = defineKanboTool({
25466
26335
  name: "kanbo_columns",
25467
26336
  title: "Columns of the board",
25468
- 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.",
26337
+ 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).",
25469
26338
  inputSchema: {},
25470
26339
  run: async (transport) => await transport.columns()
25471
26340
  });
@@ -25479,6 +26348,38 @@ const primeTool = defineKanboTool({
25479
26348
  inputSchema: {},
25480
26349
  run: async (transport) => (await transport.prime()).text
25481
26350
  });
26351
+ //#endregion
26352
+ //#region src/mcp/tools/card-claim.ts
26353
+ /**
26354
+ * Taking a card to work on, in one step: the run starts and the card moves out
26355
+ * of To Do into the board's first work-in-progress column in the same write,
26356
+ * so two agents never get the same card. Published to every host, including
26357
+ * one that records the runs of the agents it launches: a claim is how an agent
26358
+ * in any terminal takes work.
26359
+ */
26360
+ const cardClaimTool = defineKanboTool({
26361
+ name: "kanbo_card_claim",
26362
+ title: "Claim a card",
26363
+ 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.",
26364
+ inputSchema: {
26365
+ card: cardArgument.optional(),
26366
+ agent: string().min(1).optional().describe("What to call whoever works the card; who this server writes as when absent.")
26367
+ },
26368
+ aliases: {
26369
+ card: [...CARD_ALIASES, "issue"],
26370
+ agent: ["agentName"]
26371
+ },
26372
+ example: { card: "TST-5" },
26373
+ run: async (transport, input) => {
26374
+ const claimed = await transport.cardClaim(input);
26375
+ return {
26376
+ card: compactKanboCard(claimed.card),
26377
+ run: claimed.run.id,
26378
+ column: claimed.column.slug,
26379
+ finishWith: `kanbo_run_finish {"run":"${claimed.run.id}","state":"finished"}`
26380
+ };
26381
+ }
26382
+ });
25482
26383
  /** The cards an agent may pick up right now. */
25483
26384
  const readyTool = defineKanboTool({
25484
26385
  name: "kanbo_ready",
@@ -25567,13 +26468,14 @@ const runStartTool = defineKanboTool({
25567
26468
  /**
25568
26469
  * Every tool the board has, in the order it declares them.
25569
26470
  *
25570
- * The list is the board's, not a server's: the same sixteen are registered
26471
+ * The list is the board's, not a server's: the same seventeen are registered
25571
26472
  * whether they run against a server or against the board directly, and the
25572
26473
  * only thing a host decides is which of them it publishes.
25573
26474
  */
25574
26475
  const KANBO_TOOLS = [
25575
26476
  primeTool,
25576
26477
  readyTool,
26478
+ cardClaimTool,
25577
26479
  columnsTool,
25578
26480
  defineKanboTool({
25579
26481
  name: "kanbo_sprints",
@@ -25963,10 +26865,12 @@ function buildCapabilitiesManifest() {
25963
26865
  "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.",
25964
26866
  `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.`,
25965
26867
  "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.",
26868
+ "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.",
26869
+ "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.",
25966
26870
  "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.",
25967
26871
  "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).",
25968
26872
  "`kanbo migrate` and `kanbo roles apply` refuse a host app's own database file: the app migrates it, and this package never touches it.",
25969
- "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.",
26873
+ "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.",
25970
26874
  "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."
25971
26875
  ]
25972
26876
  };
@@ -26111,6 +27015,52 @@ function registerCapabilitiesCommand(program) {
26111
27015
  });
26112
27016
  }
26113
27017
  //#endregion
27018
+ //#region src/cli/commands/claim.ts
27019
+ /**
27020
+ * `kanbo claim [card]` — take a card to work on, in one step.
27021
+ *
27022
+ * The run starts and the card moves out of To Do into the board's first
27023
+ * work-in-progress column in one write, so two agents in two terminals never
27024
+ * take the same card. Filed as a placement: in a person's own terminal the
27025
+ * column's rules warn, in an agent's shell they refuse.
27026
+ */
27027
+ function registerClaimCommand(program) {
27028
+ 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) => {
27029
+ await runBoardCommand(options, "write", async (session) => {
27030
+ const actor = createPlacementActor();
27031
+ const named = reference === void 0 ? void 0 : (await requireCard(session, reference)).id;
27032
+ const claimed = await session.ops.claimCard({
27033
+ workspaceId: session.workspace.id,
27034
+ issueId: named,
27035
+ run: {
27036
+ agentName: options.agent ?? actor.id ?? "agent",
27037
+ externalSessionRef: options.session ?? sessionRefFromEnvironment() ?? void 0,
27038
+ launchedByKind: "external"
27039
+ }
27040
+ }, actor);
27041
+ const [columns, projection, runs] = await Promise.all([
27042
+ session.ops.listColumns(session.workspace.id),
27043
+ session.ops.readBoardProjectionForIssue(claimed.card.id),
27044
+ session.ops.listRuns(claimed.card.id)
27045
+ ]);
27046
+ const card = projectCard(claimed.card, columns, projection);
27047
+ for (const item of claimed.card.unmetRules ?? []) console.error(`kanbo: warning: ${card.id} entered "${claimed.column.name}" without ${item.rule}: ${item.detail}`);
27048
+ const run = projectRun({
27049
+ ...claimed.run,
27050
+ attempt: runs.find((each) => each.id === claimed.run.id)?.attempt ?? runs.length
27051
+ });
27052
+ return {
27053
+ value: {
27054
+ card,
27055
+ run,
27056
+ column: projectColumn(claimed.column, columns)
27057
+ },
27058
+ 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`
27059
+ };
27060
+ });
27061
+ });
27062
+ }
27063
+ //#endregion
26114
27064
  //#region src/cli/card-extras.ts
26115
27065
  async function readCardExtras(session, card, columns, include, commentLimit) {
26116
27066
  const wants = new Set(include);
@@ -26491,10 +27441,11 @@ const COLUMN_CATEGORIES = [
26491
27441
  ];
26492
27442
  /** The whole board's columns after a change, and the sentence that says what changed. */
26493
27443
  async function changed(session, message, extra = {}) {
26494
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
27444
+ const board = await session.ops.listColumns(session.workspace.id);
27445
+ const views = board.map((column) => projectColumn(column, board));
26495
27446
  return {
26496
27447
  value: {
26497
- ...extra,
27448
+ ...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, typeof value === "number" || Array.isArray(value) ? value : projectColumn(value, board)])),
26498
27449
  columns: views
26499
27450
  },
26500
27451
  text: `${message}\n\n${describeColumns(views)}`
@@ -26523,14 +27474,14 @@ async function addColumn(session, request, actor) {
26523
27474
  category: request.category ?? spec.category,
26524
27475
  position: anchor ? request.after !== void 0 ? { after: anchor.id } : { before: anchor.id } : void 0
26525
27476
  }, actor);
26526
- return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: projectColumn(created) });
27477
+ return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: created });
26527
27478
  }
26528
27479
  /** Said after a rename: agents' instructions name no column but To Do, and `kanbo prime` gives them the rest. */
26529
27480
  const RENAME_NOTE = "Agents read column names from kanbo prime; rename is picked up there.";
26530
27481
  async function renameColumn(session, column, newName, actor) {
26531
27482
  const before = await requireNamedColumn(session, column);
26532
27483
  const renamed = await session.ops.renameColumn(session.workspace.id, before.id, newName, actor);
26533
- 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) });
27484
+ 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 });
26534
27485
  }
26535
27486
  /** `--first`, `--last`, `--before <column>` or `--after <column>` — exactly one. */
26536
27487
  function readColumnPosition(options) {
@@ -26562,7 +27513,7 @@ async function removeColumn(session, column, moveCardsTo, actor) {
26562
27513
  const result = await session.ops.removeColumn(session.workspace.id, removing.id, { moveCardsTo }, actor);
26563
27514
  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}`);
26564
27515
  return await changed(session, result.movedTo ? `Removed ${result.removed.name} and moved ${describeCount(result.movedCards)} to ${result.movedTo.name}.` : `Removed ${result.removed.name}.`, {
26565
- removed: projectColumn(result.removed),
27516
+ removed: result.removed,
26566
27517
  movedCards: result.movedCards
26567
27518
  });
26568
27519
  }
@@ -26686,7 +27637,7 @@ async function runColumnsMenu(ui) {
26686
27637
  if (showList) say$2(ui, [
26687
27638
  "",
26688
27639
  "Columns of this board:",
26689
- describeColumns(columns.map(projectColumn))
27640
+ describeColumns(columns.map((column) => projectColumn(column, columns)))
26690
27641
  ].join("\n"));
26691
27642
  let choice;
26692
27643
  try {
@@ -26914,7 +27865,8 @@ function registerColumnsCommands(program) {
26914
27865
  const columns = program.command("columns").description("See and change the board's columns (alone: a menu)");
26915
27866
  withBoardOptions(columns.command("list").description("List the columns in order, with the short name to move cards by")).action(async (options) => {
26916
27867
  await runBoardCommand(options, "read", async (session) => {
26917
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
27868
+ const board = await session.ops.listColumns(session.workspace.id);
27869
+ const views = board.map((column) => projectColumn(column, board));
26918
27870
  return {
26919
27871
  value: views,
26920
27872
  text: describeColumns(views)
@@ -26923,7 +27875,7 @@ function registerColumnsCommands(program) {
26923
27875
  });
26924
27876
  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) => {
26925
27877
  await runBoardCommand(options, "write", async (session) => {
26926
- const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text));
27878
+ const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text), await session.ops.listColumns(session.workspace.id));
26927
27879
  return {
26928
27880
  value: view,
26929
27881
  text: describeColumns([view])
@@ -26934,13 +27886,34 @@ function registerColumnsCommands(program) {
26934
27886
  const actor = requireHumanActor("columns rules", typedWords(command));
26935
27887
  const rules = readRequestedRules(options);
26936
27888
  await runBoardCommand(options, "write", async (session) => {
26937
- const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor));
27889
+ const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor), await session.ops.listColumns(session.workspace.id));
26938
27890
  return {
26939
27891
  value: view,
26940
27892
  text: describeColumns([view])
26941
27893
  };
26942
27894
  });
26943
27895
  });
27896
+ 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) => {
27897
+ const actor = requireHumanActor("columns flow", typedWords(command));
27898
+ if (options.from === void 0 && options.wip === void 0) throw new CliError(1, "Pass --from <columns|any>, --wip <count|none>, or both.");
27899
+ const wipLimit = options.wip === void 0 ? void 0 : readWipLimit(options.wip);
27900
+ await runBoardCommand(options, "write", async (session) => {
27901
+ const workspaceId = session.workspace.id;
27902
+ const board = await session.ops.listColumns(workspaceId);
27903
+ const target = await session.ops.requireColumn(workspaceId, column);
27904
+ const current = readFlowRules(target);
27905
+ await session.ops.setColumnFlowRules(target.id, {
27906
+ allowedFrom: options.from === void 0 ? current.allowedFrom : readAllowedFrom(board, workspaceId, options.from),
27907
+ wipLimit: wipLimit === void 0 ? current.wipLimit : wipLimit
27908
+ }, actor);
27909
+ const after = await session.ops.listColumns(workspaceId);
27910
+ const views = after.map((each) => projectColumn(each, after));
27911
+ return {
27912
+ value: views.find((view) => view.id === target.id),
27913
+ text: describeColumns(views)
27914
+ };
27915
+ });
27916
+ });
26944
27917
  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) => {
26945
27918
  const actor = requireHumanActor("columns add", typedWords(command));
26946
27919
  await runBoardCommand(options, "write", async (session) => await addColumn(session, {
@@ -26982,7 +27955,7 @@ function registerColumnsCommands(program) {
26982
27955
  mode: "add-missing",
26983
27956
  actor
26984
27957
  });
26985
- const views = statuses.map(projectColumn);
27958
+ const views = statuses.map((column) => projectColumn(column, statuses));
26986
27959
  return {
26987
27960
  value: {
26988
27961
  added,
@@ -27006,7 +27979,8 @@ function registerColumnsCommands(program) {
27006
27979
  return;
27007
27980
  }
27008
27981
  await runBoardCommand({}, "read", async (session) => {
27009
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
27982
+ const board = await session.ops.listColumns(session.workspace.id);
27983
+ const views = board.map((column) => projectColumn(column, board));
27010
27984
  return {
27011
27985
  value: views,
27012
27986
  text: describeColumns(views)
@@ -27036,6 +28010,26 @@ function readRequestedRules(options) {
27036
28010
  if (options.require === void 0) throw new CliError(1, `Pass --require ${ENTRY_RULES.join(",")} (any of them) or --clear.`);
27037
28011
  return options.require.split(",").map((rule) => rule.trim()).filter(Boolean);
27038
28012
  }
28013
+ /** `--from`: `any` for every column, else the columns named, by slug, name or id. */
28014
+ function readAllowedFrom(board, workspaceId, value) {
28015
+ if (value.trim().toLowerCase() === "any") return null;
28016
+ const names = value.split(",").map((name) => name.trim()).filter(Boolean);
28017
+ if (names.length === 0) throw new CliError(1, "Name the columns a card may come from (--from in_review,qa), or --from any.");
28018
+ return names.map((name) => {
28019
+ const found = matchColumn(board, workspaceId, name);
28020
+ if (!found) throw new BoardError("issue_status_not_found", {
28021
+ workspaceId,
28022
+ statusName: name
28023
+ });
28024
+ return found.id;
28025
+ });
28026
+ }
28027
+ /** `--wip`: `none` for no limit, else a whole number of cards, 1 or more. */
28028
+ function readWipLimit(value) {
28029
+ if (value.trim().toLowerCase() === "none") return null;
28030
+ if (!/^\d+$/.test(value.trim()) || Number(value) < 1) throw new CliError(1, "--wip takes a whole number of cards, 1 or more, or none.");
28031
+ return Number(value);
28032
+ }
27039
28033
  //#endregion
27040
28034
  //#region node_modules/zod/v3/helpers/util.js
27041
28035
  var util;
@@ -42978,7 +43972,7 @@ const INSTRUCTION_END_PATTERN = /<!-- KANBO_END\s*-->/g;
42978
43972
  const POINTER_KINDS = /* @__PURE__ */ new Set(["short", "global"]);
42979
43973
  /** The commands an agent uses, spelled once for the guide and the `kanbo prime` cheat sheet. */
42980
43974
  const COMMAND = {
42981
- ready: "kanbo ready",
43975
+ claim: "kanbo claim",
42982
43976
  move: "kanbo card move <id> <column>",
42983
43977
  statusLine: "kanbo card status-line <id> --text \"...\"",
42984
43978
  subtask: "kanbo card create --description \"...\" --parent <id>",
@@ -42997,7 +43991,7 @@ const AGENT_GUIDE_TEXT = [
42997
43991
  "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.",
42998
43992
  "",
42999
43993
  ...[
43000
- `- Take a card from \`${COMMAND.ready}\`.`,
43994
+ `- 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.`,
43001
43995
  `- ${MOVE_CARD_RULE} (\`${COMMAND.move}\`)`,
43002
43996
  `- ${STATUS_LINE_RULE} (\`${COMMAND.statusLine}\`)`,
43003
43997
  `- ${SUBTASK_RULE} (\`${COMMAND.subtask}\`)`,
@@ -43027,12 +44021,13 @@ const COMMAND_SHEET = [
43027
44021
  "- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
43028
44022
  "- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
43029
44023
  "- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
43030
- "- `kanbo run start TST-5 --agent claude` — record a run you started yourself; it prints the finish command",
44024
+ "- `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",
44025
+ "- `kanbo run start TST-5 --agent claude` — record a run on a card you did not claim; it prints the finish command",
43031
44026
  "- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
43032
44027
  `- \`${COMMAND.capabilities}\` — everything else, rarely needed`
43033
44028
  ].join("\n");
43034
44029
  /** How to take work and who approves it — said by both blocks after their first sentence. */
43035
- const POINTER_TAIL = `Take work with \`${COMMAND.ready}\`. ${PERSON_ONLY_RULE}`;
44030
+ const POINTER_TAIL = `Take work with \`${COMMAND.claim}\`. ${PERSON_ONLY_RULE}`;
43036
44031
  /** The body of the project block: what `kanbo instructions short` prints. */
43037
44032
  const PROJECT_BODY = [
43038
44033
  "## Kanbo board",
@@ -43052,7 +44047,7 @@ const GLOBAL_BODY = [
43052
44047
  * The block, markers included, with this body's version and hash on the start
43053
44048
  * marker — and, when given, the kind of text it is (`k=orchestrator`).
43054
44049
  */
43055
- function wrapInstructionBlock(body, version = 2, kind) {
44050
+ function wrapInstructionBlock(body, version = 3, kind) {
43056
44051
  return `<!-- KANBO_START v${version}${kind ? ` k=${kind}` : ""} h=${hashBody(body)} -->\n${body}\n${INSTRUCTION_END}`;
43057
44052
  }
43058
44053
  /** The block `kanbo init --instructions …` writes into a project's own file. */
@@ -43081,7 +44076,7 @@ function inspectBlock(text, current) {
43081
44076
  kind
43082
44077
  });
43083
44078
  if (fields.trim() === "") return inspection(LEGACY_BLOCK_BODIES.includes(body) ? "legacy" : "edited");
43084
- if (version !== void 0 && Number(version) > 2) return inspection("newer");
44079
+ if (version !== void 0 && Number(version) > 3) return inspection("newer");
43085
44080
  if (kind !== null && !POINTER_KINDS.has(kind)) return inspection("chosen");
43086
44081
  if (version === void 0 || hash === void 0 || hashBody(body) !== hash) return inspection("edited");
43087
44082
  return inspection(normalized.slice(found.start, found.end) === current ? "current" : "outdated");
@@ -44153,7 +45148,7 @@ function startUpdateCheck(deps = {}) {
44153
45148
  setTimeout: globalThis.setTimeout,
44154
45149
  clearTimeout: globalThis.clearTimeout
44155
45150
  };
44156
- const current = deps.currentVersion ?? "0.4.0";
45151
+ const current = deps.currentVersion ?? "0.6.0";
44157
45152
  const controller = new AbortController();
44158
45153
  let found = null;
44159
45154
  const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
@@ -46670,7 +47665,7 @@ const KANBO_MCP_INSTRUCTIONS = [
46670
47665
  "kanbo is a kanban board shared by people and agents; these tools read and change its cards.",
46671
47666
  "",
46672
47667
  `- At the start of a session call ${tool("kanbo_prime")} — it returns this board's columns and what each one means.`,
46673
- `- Take work from ${tool("kanbo_ready")}.`,
47668
+ `- 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.`,
46674
47669
  `- ${MOVE_CARD_RULE} (${tool("kanbo_card_move")})`,
46675
47670
  `- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
46676
47671
  `- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
@@ -46761,7 +47756,7 @@ const ORCHESTRATOR_GUIDE = [
46761
47756
  "## Start of every session",
46762
47757
  "1. Run `kanbo prime` (tool: `kanbo_prime`) and read the column descriptions. On this board the description is the contract, not the name.",
46763
47758
  "2. Run `kanbo ready` (`kanbo_ready`). If nothing is ready, say so and stop.",
46764
- "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.",
47759
+ "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.",
46765
47760
  "",
46766
47761
  "## While working",
46767
47762
  "- 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.",
@@ -46788,7 +47783,7 @@ const ORCHESTRATOR_GUIDE = [
46788
47783
  "- Returned: the reason is the newest comment. The card is back in an earlier column; start a new run and address the reason first.",
46789
47784
  "",
46790
47785
  "## Never",
46791
- "- 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.",
47786
+ "- 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.",
46792
47787
  "- Never delete or edit the board's database directly."
46793
47788
  ].join("\n");
46794
47789
  //#endregion
@@ -46833,7 +47828,7 @@ function registerInstructionsCommand(program, dependencies = {}) {
46833
47828
  const found = parseKind(kind);
46834
47829
  const body = await readInstructionText(found);
46835
47830
  const pointer = found === "short" || found === "global";
46836
- const text = options.markers ? wrapInstructionBlock(body, 2, pointer ? void 0 : found) : body;
47831
+ const text = options.markers ? wrapInstructionBlock(body, 3, pointer ? void 0 : found) : body;
46837
47832
  const hints = process.stderr.isTTY === true;
46838
47833
  if (hints) process.stderr.write(`${HEADER}\n\n`);
46839
47834
  console.log(text);
@@ -48266,7 +49261,8 @@ async function createDbTransport(input) {
48266
49261
  const workspaceId = workspace.id;
48267
49262
  const actor = input.actor;
48268
49263
  async function readColumns() {
48269
- return (await ops.listColumns(workspaceId)).map(projectKanboColumn);
49264
+ const rows = await ops.listColumns(workspaceId);
49265
+ return rows.map((row) => projectKanboColumn(row, rows));
48270
49266
  }
48271
49267
  /** One card with what the run registry says about it, in the shape a tool prints. */
48272
49268
  async function project(card, columns) {
@@ -48316,7 +49312,7 @@ async function createDbTransport(input) {
48316
49312
  * the waiting and returned ones last said at most two more.
48317
49313
  */
48318
49314
  async function projectPage(page, { attend = true } = {}) {
48319
- const columns = page.columns.map(projectKanboColumn);
49315
+ const columns = page.columns.map((column) => projectKanboColumn(column, page.columns));
48320
49316
  const runs = await ops.readBoardProjectionForIssues(page.cards.map((card) => card.id));
48321
49317
  const attention = attend ? await ops.readCardAttention(page.cards, runs.map((run) => run.activeRun !== null)) : [];
48322
49318
  return page.cards.map((card, index) => projectKanboCard({
@@ -48448,6 +49444,25 @@ async function createDbTransport(input) {
48448
49444
  state,
48449
49445
  errorText
48450
49446
  }, actor));
49447
+ },
49448
+ cardClaim: async ({ card, agent }) => {
49449
+ await session.assertWritable();
49450
+ const named = card === void 0 ? void 0 : (await resolveCard(card)).id;
49451
+ const claimed = await ops.claimCard({
49452
+ workspaceId,
49453
+ issueId: named,
49454
+ run: {
49455
+ agentName: agent ?? actor.id ?? "agent",
49456
+ externalSessionRef: sessionRefFromEnvironment() ?? void 0,
49457
+ launchedByKind: "external"
49458
+ }
49459
+ }, actor);
49460
+ const columns = await readColumns();
49461
+ return {
49462
+ card: await project(claimed.card, columns),
49463
+ run: projectKanboRun(claimed.run),
49464
+ column: columns.find((column) => column.id === claimed.column.id)
49465
+ };
48451
49466
  }
48452
49467
  };
48453
49468
  }
@@ -48489,7 +49504,8 @@ const TRANSPORT_METHODS = {
48489
49504
  statusLine: true,
48490
49505
  waitApproval: true,
48491
49506
  runStart: true,
48492
- runFinish: true
49507
+ runFinish: true,
49508
+ cardClaim: true
48493
49509
  };
48494
49510
  async function openMcpBoard(input) {
48495
49511
  try {
@@ -49095,7 +50111,7 @@ function sendBoardPage(response, pathname, assets) {
49095
50111
  * answering for a person — approving a card, sending it back or closing a
49096
50112
  * sprint — as anyone but one is 403, dropping a pull request link somebody else made is the same 403
49097
50113
  * for the same reason, and acting on a run that has already ended is 409,
49098
- * because the answer would have been yes a moment earlier. An agent putting a
50114
+ * 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
49099
50115
  * card in a column whose entry rules it does not meet yet is 409 for the same
49100
50116
  * reason turned around: the answer will be yes once the card meets them, and
49101
50117
  * `details.unmet` says what is missing. Deleting a column that still holds
@@ -49113,6 +50129,14 @@ const BOARD_ERROR_RESPONSES = {
49113
50129
  message: "Only a person can approve a card",
49114
50130
  code: "issue_approval_requires_user"
49115
50131
  },
50132
+ board_card_not_ready: {
50133
+ status: 409,
50134
+ message: "That card is not there to take: it is not in To Do, waits for a person, or has a run going"
50135
+ },
50136
+ board_card_run_active: {
50137
+ status: 409,
50138
+ message: "The card already has a run going"
50139
+ },
49116
50140
  board_column_name_taken: {
49117
50141
  status: 400,
49118
50142
  message: "Another column already has that name"
@@ -49152,6 +50176,18 @@ const BOARD_ERROR_RESPONSES = {
49152
50176
  status: 400,
49153
50177
  message: "Execution mode cannot change once the card has been launched"
49154
50178
  },
50179
+ board_flow_rule_invalid: {
50180
+ status: 400,
50181
+ message: "A column accepts cards from other columns of its own board, and holds at least one card"
50182
+ },
50183
+ board_metrics_range_invalid: {
50184
+ status: 400,
50185
+ message: "The range starts after it ends"
50186
+ },
50187
+ board_nothing_ready: {
50188
+ status: 409,
50189
+ message: "No card in To Do is ready to take"
50190
+ },
49155
50191
  board_pull_request_invalid: {
49156
50192
  status: 400,
49157
50193
  message: "Not a GitHub pull request: give its URL or owner/repo#number"
@@ -49567,6 +50603,14 @@ const startRunBody = object$1({
49567
50603
  ]).optional(),
49568
50604
  externalSessionRef: string().nullable().optional()
49569
50605
  });
50606
+ const claimBody = object$1({
50607
+ workspaceId: id,
50608
+ issueId: id.optional(),
50609
+ agentName: id.optional(),
50610
+ branch: string().nullable().optional(),
50611
+ executionMode: executionMode.optional(),
50612
+ externalSessionRef: string().nullable().optional()
50613
+ });
49570
50614
  const finishRunBody = object$1({
49571
50615
  state: _enum([
49572
50616
  "finished",
@@ -49613,7 +50657,8 @@ async function toWrittenCardView(ops, card) {
49613
50657
  function toStatusView(status) {
49614
50658
  return {
49615
50659
  ...status,
49616
- entryRules: readEntryRules(status)
50660
+ entryRules: readEntryRules(status),
50661
+ allowedFrom: readFlowRules(status).allowedFrom
49617
50662
  };
49618
50663
  }
49619
50664
  function toCommentView(comment, personWriterId = null) {
@@ -50048,6 +51093,31 @@ function createIssueRoutes(session, options = {}) {
50048
51093
  }, actors.placement));
50049
51094
  }
50050
51095
  }),
51096
+ route({
51097
+ method: "POST",
51098
+ path: "/issues/claim",
51099
+ body: claimBody,
51100
+ write: true,
51101
+ handle: async ({ body, actors }) => {
51102
+ const named = requireWorkspace(body.workspaceId);
51103
+ if (body.issueId !== void 0) await requireCard(body.issueId);
51104
+ const claimed = await ops.claimCard({
51105
+ workspaceId: named,
51106
+ issueId: body.issueId,
51107
+ run: {
51108
+ agentName: body.agentName ?? actors.writer.id ?? "agent",
51109
+ branch: body.branch,
51110
+ executionMode: body.executionMode,
51111
+ externalSessionRef: body.externalSessionRef
51112
+ }
51113
+ }, actors.placement);
51114
+ return {
51115
+ card: await toWrittenCardView(ops, claimed.card),
51116
+ run: await toRunView(claimed.run),
51117
+ column: toStatusView(claimed.column)
51118
+ };
51119
+ }
51120
+ }),
50051
51121
  route({
50052
51122
  method: "PATCH",
50053
51123
  path: "/issues/bulk",
@@ -50933,17 +52003,6 @@ function parsePort(value) {
50933
52003
  //#endregion
50934
52004
  //#region src/cli/commands/sprint.ts
50935
52005
  /**
50936
- * A date as the command line takes it: `YYYY-MM-DD`, read as a UTC day, or a
50937
- * unix-seconds number taken as it is. `end` turns a day into its last second,
50938
- * so a sprint due on a day is still current throughout that day.
50939
- */
50940
- function parseDate(value, end) {
50941
- if (/^\d+$/.test(value)) return Number(value);
50942
- const day = /^\d{4}-\d{2}-\d{2}$/.test(value) ? Date.parse(`${value}T00:00:00Z`) : NaN;
50943
- if (Number.isNaN(day)) throw new CliError(1, `Expected a date as YYYY-MM-DD or unix seconds, got "${value}".`);
50944
- return day / 1e3 + (end ? 86399 : 0);
50945
- }
50946
- /**
50947
52006
  * `kanbo sprint …` — milestones read as sprints (ruling 5x-3).
50948
52007
  *
50949
52008
  * Listing and creating are anybody's. Closing one is a person's decision
@@ -50963,7 +52022,7 @@ function registerSprintCommands(program) {
50963
52022
  };
50964
52023
  });
50965
52024
  });
50966
- withBoardOptions(sprint.command("create").description("Start a sprint with a first and a last day").requiredOption("--title <title>", "what the sprint is called").requiredOption("--start <date>", "first day, as YYYY-MM-DD (UTC) or unix seconds", (value) => parseDate(value, false)).requiredOption("--due <date>", "last day, as YYYY-MM-DD (UTC, through its end) or unix seconds", (value) => parseDate(value, true)).option("--description <text>", "what the sprint is for")).action(async (options) => {
52025
+ withBoardOptions(sprint.command("create").description("Start a sprint with a first and a last day").requiredOption("--title <title>", "what the sprint is called").requiredOption("--start <date>", "first day, as YYYY-MM-DD (UTC) or unix seconds", (value) => parseCalendarDay(value, false)).requiredOption("--due <date>", "last day, as YYYY-MM-DD (UTC, through its end) or unix seconds", (value) => parseCalendarDay(value, true)).option("--description <text>", "what the sprint is for")).action(async (options) => {
50967
52026
  await runBoardCommand(options, "write", async (session) => {
50968
52027
  const created = await session.ops.createMilestone({
50969
52028
  workspaceId: session.workspace.id,
@@ -51006,6 +52065,113 @@ function registerSprintCommands(program) {
51006
52065
  });
51007
52066
  }
51008
52067
  //#endregion
52068
+ //#region src/cli/metrics-view.ts
52069
+ /** Every field `kanbo stats --json` may name, in the order the value carries them. */
52070
+ const BOARD_METRICS_FIELDS = [
52071
+ "range",
52072
+ "now",
52073
+ "milestoneId",
52074
+ "columns",
52075
+ "cycleTime",
52076
+ "leadTime",
52077
+ "throughput",
52078
+ "wip",
52079
+ "aging",
52080
+ "timeInColumn",
52081
+ "cumulativeFlow",
52082
+ "finished",
52083
+ "returns",
52084
+ "attempts",
52085
+ "canceled",
52086
+ "agents",
52087
+ "people"
52088
+ ];
52089
+ const LABEL_WIDTH = 16;
52090
+ /** A duration as the command line prints it: minutes under an hour, hours under a day, then days, to one decimal; a dash for none. */
52091
+ function formatDuration(seconds) {
52092
+ if (seconds === null) return "—";
52093
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
52094
+ if (seconds < 86400) return `${Number((seconds / 3600).toFixed(1))}h`;
52095
+ return `${Number((seconds / 86400).toFixed(1))}d`;
52096
+ }
52097
+ function percent(share) {
52098
+ return share === null ? "—" : `${Math.round(share * 100)}%`;
52099
+ }
52100
+ function decimal(value) {
52101
+ return value === null ? "—" : String(Number(value.toFixed(1)));
52102
+ }
52103
+ function count(n, noun) {
52104
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
52105
+ }
52106
+ function day(seconds) {
52107
+ return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
52108
+ }
52109
+ /** A labelled block: the first item beside the label, the rest under it; a dash when there is nothing. */
52110
+ function block(label, items) {
52111
+ const [first = "—", ...rest] = items;
52112
+ return [`${label.padEnd(LABEL_WIDTH)}${first}`, ...rest.map((item) => `${" ".repeat(LABEL_WIDTH)}${item}`)];
52113
+ }
52114
+ /** Items joined on one line, or none at all. */
52115
+ function oneLine(items) {
52116
+ return items.length === 0 ? [] : [items.join(" · ")];
52117
+ }
52118
+ /**
52119
+ * The metrics as a person reads them in a terminal: the range, then one block
52120
+ * per figure. The cumulative flow is a series per day and has no one-screen
52121
+ * form, so the last line says how to get it.
52122
+ */
52123
+ function describeMetrics(metrics, sprint) {
52124
+ const column = (slug) => sanitizeTerminalText(metrics.columns.find((candidate) => candidate.slug === slug)?.name ?? slug);
52125
+ const by = metrics.returns.byActorKind;
52126
+ const lines = [`${day(metrics.range.from)} → ${day(metrics.range.to)}`];
52127
+ if (sprint) lines.push(`${sanitizeTerminalText(sprint.title)}: ${sprint.cards.open + sprint.cards.done} planned · ${sprint.cards.done} done`);
52128
+ if (metrics.range.from > metrics.range.to) return [...lines, "Nothing measured yet: the range starts after now."].join("\n");
52129
+ lines.push(...block("Cycle time", [`p50 ${formatDuration(metrics.cycleTime.p50)} · p85 ${formatDuration(metrics.cycleTime.p85)} (${count(metrics.cycleTime.cards, "card")})`]), ...block("Lead time", [`p50 ${formatDuration(metrics.leadTime.p50)} · p85 ${formatDuration(metrics.leadTime.p85)}`]), ...block("Throughput", oneLine(metrics.throughput.map((week) => `${week.week} ${week.finished}`))), ...block("In progress", oneLine(metrics.wip.map((wip) => `${column(wip.slug)} ${wip.cards}${wip.limit === null ? "" : `/${wip.limit}`}`))), ...block("First pass", [`${percent(metrics.returns.firstPassRate)} · attempts ${decimal(metrics.attempts.perFinishedCard)} per card`]), ...block("Returns", [`${metrics.returns.total} (people ${by.user} · agents ${by.agent} · outside ${by.external}) · ${decimal(metrics.returns.perFinishedCard)} per card`]), ...block("Canceled", [String(metrics.canceled)]), ...block("Aging", metrics.aging.map((card) => `${card.issueId} ${column(card.slug)} ${formatDuration(card.ageSeconds)}${card.overCycleP85 ? " (older than p85)" : ""}`)), ...block("Time in column", oneLine(metrics.timeInColumn.filter((time) => time.seconds > 0).map((time) => `${column(time.slug)} ${formatDuration(time.seconds)}`))), ...block("Agents", metrics.agents.map(describeAgent)), ...block("People", metrics.people.map(describePerson)), "Cumulative flow by day: kanbo stats --json cumulativeFlow");
52130
+ return lines.join("\n");
52131
+ }
52132
+ function describeAgent(agent) {
52133
+ const { runs } = agent;
52134
+ return `${sanitizeTerminalText(agent.agentName)}: ${count(runs.total, "run")} (${runs.finished} finished · ${runs.failed} failed · ${runs.stopped} stopped · ${runs.running} running) · median ${formatDuration(agent.medianRunSeconds)} · ${count(agent.cardsFinished, "card")} finished · ${percent(agent.returnRate)} returned`;
52135
+ }
52136
+ function describePerson(person) {
52137
+ return `${sanitizeTerminalText(person.id ?? "unnamed")}: ${person.created} created · ${person.approvals} approved · ${person.returns} returned · ${person.moves} moved`;
52138
+ }
52139
+ const WEEK_SECONDS = 604800;
52140
+ /** The Monday that makes the range `DEFAULT_STATS_WEEKS` ISO weeks long, ending with the week of `now`. */
52141
+ function defaultStatsFrom(now) {
52142
+ return isoWeekOf(now).start - 11 * WEEK_SECONDS;
52143
+ }
52144
+ /**
52145
+ * `kanbo stats` — how work flows on this board (spec 2026-11-05, Part 4).
52146
+ *
52147
+ * Reading only, so it is anybody's, an agent's shell included. A sprint sets
52148
+ * the range to its own dates and keeps its cards; `--from` and `--to` still
52149
+ * win over either end.
52150
+ */
52151
+ function registerStatsCommand(program) {
52152
+ withBoardOptions(program.command("stats").description("How work flows on this board: cycle time, throughput, work in progress, returns, agents").option("--from <date>", `first day, YYYY-MM-DD (UTC) or unix seconds (12 weeks back when left out)`, (value) => parseCalendarDay(value, false)).option("--to <date>", "last day, YYYY-MM-DD (UTC, through its end) or unix seconds (now when left out)", (value) => parseCalendarDay(value, true)).option("--sprint <id>", "only the cards of this sprint, over its dates")).action(async (options) => {
52153
+ await runBoardCommand(options, "read", async (session) => {
52154
+ const now = currentUnixSeconds();
52155
+ const sprint = options.sprint === void 0 ? null : (await session.ops.listSprints(session.workspace.id, now)).find((candidate) => candidate.id === options.sprint) ?? null;
52156
+ if (options.sprint !== void 0 && sprint === null) throw new BoardError("issue_milestone_not_found", {
52157
+ milestoneId: options.sprint,
52158
+ workspaceId: session.workspace.id
52159
+ });
52160
+ const metrics = await session.ops.boardMetrics({
52161
+ workspaceIds: [session.workspace.id],
52162
+ from: options.from ?? sprint?.startDate ?? defaultStatsFrom(now),
52163
+ to: options.to ?? sprint?.dueDate ?? now,
52164
+ milestoneId: sprint?.id ?? null
52165
+ }, now);
52166
+ return {
52167
+ value: metrics,
52168
+ text: describeMetrics(metrics, sprint),
52169
+ fields: BOARD_METRICS_FIELDS
52170
+ };
52171
+ });
52172
+ });
52173
+ }
52174
+ //#endregion
51009
52175
  //#region src/cli/commands/uninstall.ts
51010
52176
  function registerUninstallCommand(program) {
51011
52177
  program.command("uninstall").description("Disconnect kanbo from your agents; the board stays unless --purge").option("--project", "only this project's files").option("--global", "only your own files, for every project").option("--purge", "also remove this project's board settings, and its board file after you confirm").option("--yes", "do it without asking (never deletes a board file)").option("--json [fields]", "print what was done as JSON; name comma-separated fields to print only those").action(async (options) => {
@@ -51186,9 +52352,11 @@ function registerKanboCommands(program, context = {}) {
51186
52352
  registerReadyCommand(program);
51187
52353
  registerPrimeCommand(program);
51188
52354
  registerSprintCommands(program);
52355
+ registerStatsCommand(program);
51189
52356
  registerServeCommand(program);
51190
52357
  program.commandsGroup("For agents and integrations:");
51191
52358
  registerMcpCommand(program);
52359
+ registerClaimCommand(program);
51192
52360
  registerRunCommands(program);
51193
52361
  registerCapabilitiesCommand(program);
51194
52362
  program.commandsGroup("Shared Postgres boards:");