kanbo-cli 0.4.0 → 0.5.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 +19 -0
  2. package/dist/cli-main.cjs +722 -111
  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-D2x2w1wG.d.cts} +26 -2
  14. package/dist/lib/{board-store-Dx-mw2I6.d.cts → board-store-DN5Y2U2y.d.mts} +26 -2
  15. package/dist/lib/index.cjs +3 -1
  16. package/dist/lib/index.d.cts +96 -6
  17. package/dist/lib/index.d.mts +96 -6
  18. package/dist/lib/index.mjs +2 -2
  19. package/dist/lib/{manifest-DcIMYvWk.cjs → manifest-DUGRyC7A.cjs} +708 -112
  20. package/dist/lib/{manifest-Dmo6IlWk.mjs → manifest-t4LvFgzH.mjs} +697 -113
  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-DtBeW3Fs.mjs → migrate-BUsaTJlj.mjs} +8 -1
  26. package/dist/lib/{migrate-Dca5WdYd.cjs → migrate-BoGrqK9m.cjs} +7 -0
  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-BDmmNUdx.mjs} +18 -2
  32. package/dist/lib/{roles.sql-E58x7mLS.cjs → roles.sql-CBFxSHy7.cjs} +17 -1
  33. package/dist/lib/{schema-D0MyXcej.d.cts → schema-Cwx3HosC.d.cts} +72 -0
  34. package/dist/lib/{schema-D0MyXcej.d.mts → schema-Cwx3HosC.d.mts} +72 -0
  35. package/dist/lib/sqlite/index.cjs +1 -1
  36. package/dist/lib/sqlite/index.d.cts +2 -2
  37. package/dist/lib/sqlite/index.d.mts +2 -2
  38. package/dist/lib/sqlite/index.mjs +1 -1
  39. package/dist/lib/sqlite/schema.cjs +13 -1
  40. package/dist/lib/sqlite/schema.d.cts +1 -1
  41. package/dist/lib/sqlite/schema.d.mts +1 -1
  42. package/dist/lib/sqlite/schema.mjs +13 -1
  43. package/package.json +1 -1
@@ -21,8 +21,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
21
21
  }) : target, mod));
22
22
  //#endregion
23
23
  const require_migrations_path = require("./migrations-path-DBCE17dT.cjs");
24
- const require_roles_sql = require("./roles.sql-E58x7mLS.cjs");
25
- const require_migrate = require("./migrate-Dca5WdYd.cjs");
24
+ const require_roles_sql = require("./roles.sql-CBFxSHy7.cjs");
25
+ const require_migrate = require("./migrate-BoGrqK9m.cjs");
26
26
  let commander = require("commander");
27
27
  let zod = require("zod");
28
28
  let node_os = require("node:os");
@@ -296,6 +296,7 @@ const HUMAN_ONLY_ACTIONS = [
296
296
  "return",
297
297
  "sprint close",
298
298
  "columns rules",
299
+ "columns flow",
299
300
  "columns add",
300
301
  "columns rename",
301
302
  "columns move",
@@ -309,6 +310,7 @@ const HUMAN_ONLY_MESSAGES = {
309
310
  "return": describeReturnRefusal,
310
311
  "sprint close": describeSprintCloseRefusal,
311
312
  "columns rules": describeColumnRulesRefusal,
313
+ "columns flow": describeColumnRulesRefusal,
312
314
  "columns add": describeColumnChangesRefusal,
313
315
  "columns rename": describeColumnChangesRefusal,
314
316
  "columns move": describeColumnChangesRefusal,
@@ -789,6 +791,14 @@ const HIDDEN_ACTIVITY_FIELDS = /* @__PURE__ */ new Set([
789
791
  "order",
790
792
  "waitingFor"
791
793
  ]);
794
+ /**
795
+ * The column a card was created in, recorded as a change from no column at the
796
+ * card's own creation time. The feed's `created` item already stands for it,
797
+ * and the field history keeps it.
798
+ */
799
+ function isStartingColumn(change, issue) {
800
+ return change.field === "statusId" && change.fromValue === null && change.createdAt === issue.createdAt;
801
+ }
792
802
  const ACTIVITY_FIELD_BY_RAW_FIELD = {
793
803
  assigneeId: "assignee",
794
804
  description: "description",
@@ -829,7 +839,7 @@ async function listActivity(store, issueId, resolver) {
829
839
  workspaceById: resolver.workspaceById
830
840
  };
831
841
  const commentItems = await Promise.all((await store.comments.listByIssue(issueId)).map(async (comment) => await toCommentActivityItem(comment, resolver)));
832
- const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
842
+ const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).filter((change) => !isStartingColumn(change, issue)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
833
843
  return [
834
844
  {
835
845
  id: `${issue.id}:created`,
@@ -1372,7 +1382,9 @@ const UNMET_ENTRY_RULE_REASONS = [
1372
1382
  "ci_red",
1373
1383
  "approval_pending",
1374
1384
  "returned",
1375
- "not_approved"
1385
+ "not_approved",
1386
+ "not_allowed_from",
1387
+ "column_full"
1376
1388
  ];
1377
1389
  const entryRulesSchema = zod.z.array(zod.z.enum(ENTRY_RULES));
1378
1390
  /**
@@ -1519,6 +1531,65 @@ function describeColumnRefusal(details) {
1519
1531
  return [`board_column_rules_unmet: ${details.issueId ? `${details.issueId} cannot enter` : "A new card cannot be created in"} "${details.column}" yet:`, ...details.unmet.map((item) => `- ${item.rule}: ${item.detail}`)].join("\n");
1520
1532
  }
1521
1533
  //#endregion
1534
+ //#region src/domain/flow-rules.ts
1535
+ /** The two flow rules, as an unmet entry names them. */
1536
+ const FLOW_RULES = ["allowed_from", "wip_limit"];
1537
+ /** The column the standard flow guards, and the columns it lets cards in from, by slug. */
1538
+ const STANDARD_FLOW_TARGET = "done";
1539
+ const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
1540
+ /** A column's flow rules, out of what the row stores. */
1541
+ function readFlowRules(column) {
1542
+ return {
1543
+ allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
1544
+ wipLimit: column.wipLimit
1545
+ };
1546
+ }
1547
+ /** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
1548
+ function serializeAllowedFrom(allowedFrom) {
1549
+ return allowedFrom === null ? null : JSON.stringify(allowedFrom);
1550
+ }
1551
+ /**
1552
+ * The flow rules a card entering `column` does not meet, where it may come from
1553
+ * first. Pure: what the board holds arrives in `facts`. A card being created
1554
+ * comes from no column, so a column that names where cards come from does not
1555
+ * take it.
1556
+ */
1557
+ function evaluateFlowRules(card, column, rules, facts) {
1558
+ const unmet = [];
1559
+ if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
1560
+ rule: "allowed_from",
1561
+ reason: "not_allowed_from",
1562
+ detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
1563
+ });
1564
+ if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
1565
+ rule: "wip_limit",
1566
+ reason: "column_full",
1567
+ detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
1568
+ });
1569
+ return unmet;
1570
+ }
1571
+ function describeAllowedFrom(name, allowedFrom, columnNames) {
1572
+ const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
1573
+ return names.length === 0 ? `${name} accepts cards from no column; only a person may put a card here` : `${name} accepts cards from ${joinWords(names)} only`;
1574
+ }
1575
+ /** `A`, `A and B`, `A, B and C`. */
1576
+ function joinWords(words) {
1577
+ return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
1578
+ }
1579
+ /**
1580
+ * The standard flow on these columns: Done, and the columns it takes cards from
1581
+ * — In Review, and QA when there is one — in board order. `null` when the board
1582
+ * has no Done, or neither of the two.
1583
+ */
1584
+ function standardFlowOf(columns) {
1585
+ const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
1586
+ const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
1587
+ return target && sources.length > 0 ? {
1588
+ target,
1589
+ sources
1590
+ } : null;
1591
+ }
1592
+ //#endregion
1522
1593
  //#region src/ops/columns.ts
1523
1594
  /** The columns of one workspace, in board order. */
1524
1595
  async function listColumns(store, workspaceId) {
@@ -1588,6 +1659,7 @@ async function applyColumnTemplate(store, workspaceId, columns, options, scope)
1588
1659
  workspaceId,
1589
1660
  ...column
1590
1661
  }, order);
1662
+ await writeStandardFlow(tx, await listColumns(tx, workspaceId));
1591
1663
  return {
1592
1664
  statuses: await listColumns(tx, workspaceId),
1593
1665
  added: columns.map((column) => column.name)
@@ -1697,6 +1769,80 @@ async function setColumnEntryRules(store, workspaceId, nameOrId, entryRules, act
1697
1769
  return await updateColumn(tx, (await requireColumn(tx, workspaceId, nameOrId)).id, { entryRules }, actor, { tx });
1698
1770
  }, scope);
1699
1771
  }
1772
+ /** The largest WIP limit: what a Postgres `integer` column holds. */
1773
+ const MAX_WIP_LIMIT = 2147483647;
1774
+ /**
1775
+ * Set where a card may enter the column from and how many cards it holds:
1776
+ * `allowedFrom` the ids of other columns of its board (`null` for any, `[]` for
1777
+ * none), kept once each in board order; `wipLimit` a whole number from 1 to 2147483647, or `null`. A
1778
+ * person's to set, like the entry rules: an agent that could open a column
1779
+ * could walk any card into it.
1780
+ */
1781
+ async function setColumnFlowRules(store, columnId, rules, actor, scope) {
1782
+ assertPersonSetsEntryRules(actor, { statusId: columnId });
1783
+ if (rules.wipLimit !== null && !(Number.isSafeInteger(rules.wipLimit) && rules.wipLimit >= 1 && rules.wipLimit <= MAX_WIP_LIMIT)) throw new require_migrations_path.BoardError("board_flow_rule_invalid", {
1784
+ statusId: columnId,
1785
+ wipLimit: rules.wipLimit
1786
+ });
1787
+ return await runBoardWrite(store, async ({ tx }) => {
1788
+ const column = await tx.statuses.findById(columnId);
1789
+ if (!column) throw new require_migrations_path.BoardError("issue_status_not_found", { statusId: columnId });
1790
+ const allowedFrom = rules.allowedFrom === null ? null : allowedFromInBoardOrder(await listColumns(tx, column.workspaceId), column, rules.allowedFrom);
1791
+ await tx.statuses.update(columnId, {
1792
+ allowedFrom: serializeAllowedFrom(allowedFrom),
1793
+ wipLimit: rules.wipLimit
1794
+ });
1795
+ return await tx.statuses.findById(columnId);
1796
+ }, scope);
1797
+ }
1798
+ /** The ids asked for, once each and in board order — or the refusal naming those that are not another column of this board. */
1799
+ function allowedFromInBoardOrder(columns, column, requested) {
1800
+ const others = columns.filter((other) => other.id !== column.id);
1801
+ const invalid = requested.filter((id) => !others.some((other) => other.id === id));
1802
+ if (invalid.length > 0) throw new require_migrations_path.BoardError("board_flow_rule_invalid", {
1803
+ statusId: column.id,
1804
+ allowedFrom: invalid
1805
+ });
1806
+ return others.filter((other) => requested.includes(other.id)).map((other) => other.id);
1807
+ }
1808
+ /**
1809
+ * Put the standard flow on a board: Done accepts cards from In Review only —
1810
+ * and from QA, when the board has it — so nothing reaches Done but through
1811
+ * review. Done's WIP limit and every other column stay as they are. A board
1812
+ * without Done, or with neither In Review nor QA, has nothing to apply it to.
1813
+ */
1814
+ async function applyStandardFlow(store, workspaceId, actor, scope) {
1815
+ assertPersonSetsEntryRules(actor, { workspaceId });
1816
+ return await runBoardWrite(store, async ({ tx }) => {
1817
+ const columns = await listColumns(tx, workspaceId);
1818
+ if (!await writeStandardFlow(tx, columns)) {
1819
+ const hasDone = columns.some((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
1820
+ throw new require_migrations_path.BoardError("issue_status_not_found", {
1821
+ workspaceId,
1822
+ statusName: hasDone ? "In Review" : "Done"
1823
+ });
1824
+ }
1825
+ return await listColumns(tx, workspaceId);
1826
+ }, scope);
1827
+ }
1828
+ /** Done takes cards from the board's In Review and QA; `false`, with nothing written, when the board has no such columns. */
1829
+ async function writeStandardFlow(store, columns) {
1830
+ const flow = standardFlowOf(columns);
1831
+ if (!flow) return false;
1832
+ await store.statuses.update(flow.target.id, { allowedFrom: serializeAllowedFrom(flow.sources.map((column) => column.id)) });
1833
+ return true;
1834
+ }
1835
+ /**
1836
+ * Take a deleted column out of every list of where cards may come from. A list
1837
+ * it empties stays empty: a column that took cards only from it does not open
1838
+ * to every column.
1839
+ */
1840
+ async function dropFromAllowedFrom(store, workspaceId, statusId) {
1841
+ for (const other of await listColumns(store, workspaceId)) {
1842
+ const { allowedFrom } = readFlowRules(other);
1843
+ if (allowedFrom?.includes(statusId)) await store.statuses.update(other.id, { allowedFrom: serializeAllowedFrom(allowedFrom.filter((id) => id !== statusId)) });
1844
+ }
1845
+ }
1700
1846
  /**
1701
1847
  * Take an empty column off the board, and number the columns left 0..n-1 in
1702
1848
  * the same write.
@@ -1723,6 +1869,7 @@ async function deleteColumn(store, statusId, scope, actor) {
1723
1869
  hint: "Move its cards to another column first, or remove it with removeColumn and moveCardsTo."
1724
1870
  });
1725
1871
  await tx.statuses.delete(statusId);
1872
+ await dropFromAllowedFrom(tx, column.workspaceId, statusId);
1726
1873
  await renumberColumns(tx, column.workspaceId);
1727
1874
  }, scope);
1728
1875
  }
@@ -2002,24 +2149,46 @@ function pullRequestFactKeys({ owner, repo, number }) {
2002
2149
  //#endregion
2003
2150
  //#region src/ops/entry-rules.ts
2004
2151
  /**
2005
- * The column's entry rules the card does not meet (ruling 6-2). A column with
2006
- * no rules answers at once, without reading anything; otherwise only the facts
2007
- * the column's rules need are read.
2152
+ * The column's entry rules and flow rules the card does not meet, entry rules
2153
+ * first (ruling 6-2; board-workflow spec, Part 2). A column with no rules
2154
+ * answers at once, without reading anything; otherwise only the facts its
2155
+ * rules need are read. `entering` counts the cards this same write already let
2156
+ * into the column, so a bulk move is held to the WIP limit as a whole.
2008
2157
  */
2009
- async function checkColumnEntry(store, card, column) {
2158
+ async function checkColumnEntry(store, card, column, entering) {
2010
2159
  const rules = readEntryRules(column);
2011
- if (rules.length === 0) return [];
2012
- return evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
2160
+ const unmet = rules.length === 0 ? [] : evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
2161
+ const flow = readFlowRules(column);
2162
+ if (flow.allowedFrom === null && flow.wipLimit === null) return unmet;
2163
+ return [...unmet, ...evaluateFlowRules(card, column, flow, await readFlowRuleFacts(store, column, flow, entering))];
2164
+ }
2165
+ /** 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. */
2166
+ async function readFlowRuleFacts(store, column, flow, entering) {
2167
+ const columnNames = flow.allowedFrom === null ? /* @__PURE__ */ new Map() : new Map((await store.statuses.listByWorkspace(column.workspaceId)).map((other) => [other.id, other.name]));
2168
+ if (flow.wipLimit === null) return {
2169
+ columnNames,
2170
+ cardsInColumn: 0
2171
+ };
2172
+ await store.statuses.lockById(column.id);
2173
+ return {
2174
+ columnNames,
2175
+ cardsInColumn: (await store.issues.listPage({
2176
+ workspaceId: column.workspaceId,
2177
+ statusIds: [column.id],
2178
+ limit: 1
2179
+ })).total + entering
2180
+ };
2013
2181
  }
2014
2182
  /**
2015
- * Hold a card at the door of a column that asks for more than it has (ruling
2016
- * 6-3). Anyone but a person — or a person's own terminal, `placesForPerson`
2017
- * (ruling 6-6) — is refused with `board_column_rules_unmet`, which
2018
- * names the column and every unmet rule; a person is let through, and the
2019
- * unmet rules come back so the caller can warn them.
2183
+ * Hold a card at the door of a column that asks for more than it has — entry
2184
+ * rules (ruling 6-3), or flow rules: a column it may not come from, or a column
2185
+ * that is full. Anyone but a person — or a person's own terminal,
2186
+ * `placesForPerson` (ruling 6-6) — is refused with `board_column_rules_unmet`,
2187
+ * which names the column and everything unmet; a person is let through, and
2188
+ * the unmet list comes back so the caller can warn them.
2020
2189
  */
2021
- async function enforceColumnEntry(store, card, column, actor) {
2022
- const unmet = await checkColumnEntry(store, card, column);
2190
+ async function enforceColumnEntry(store, card, column, actor, entering = 0) {
2191
+ const unmet = await checkColumnEntry(store, card, column, entering);
2023
2192
  if (unmet.length > 0 && actor.kind !== "user" && !actor.placesForPerson) throw new require_migrations_path.BoardError("board_column_rules_unmet", {
2024
2193
  issueId: card.id,
2025
2194
  column: column.name,
@@ -2032,14 +2201,17 @@ async function enforceColumnEntry(store, card, column, actor) {
2032
2201
  * The same door for several cards entering one column at once — a bulk status
2033
2202
  * change. All or nothing: the first card an agent may not move refuses the
2034
2203
  * whole change. A person gets back the cards that entered with rules unmet.
2204
+ * Each card counts against the column's WIP limit as the cards before it in the same change entered it.
2035
2205
  */
2036
2206
  async function enforceColumnEntryForCards(store, issueIds, statusId, actor) {
2037
2207
  const column = await store.statuses.findById(statusId);
2038
2208
  if (!column) throw new require_migrations_path.BoardError("issue_status_not_found", { statusId });
2039
2209
  const entered = [];
2210
+ let entering = 0;
2040
2211
  for (const card of await store.issues.listByIds(issueIds)) {
2041
2212
  if (card.statusId === column.id) continue;
2042
- const unmet = await enforceColumnEntry(store, card, column, actor);
2213
+ const unmet = await enforceColumnEntry(store, card, column, actor, entering);
2214
+ entering++;
2043
2215
  if (unmet.length > 0) entered.push({
2044
2216
  issueId: card.id,
2045
2217
  unmet
@@ -2194,11 +2366,24 @@ const STOPPED_BY_YOU_STATUS_LINE = "stopped by you";
2194
2366
  * the board reads the same whether the agent was launched by an app or from
2195
2367
  * somewhere else entirely. The attempt number is simply how many runs the card
2196
2368
  * has had — a card whose runs were all launched elsewhere still counts them.
2369
+ *
2370
+ * A card has one run going at a time. A start on a card whose run is still going
2371
+ * is refused with `board_card_run_active`, naming that run — whichever path it
2372
+ * came by, an app's launch, `kanbo run start`, MCP, or a second app on a shared
2373
+ * Postgres board. The card's row is locked first, so two starts racing for it
2374
+ * are settled one after the other.
2197
2375
  */
2198
2376
  async function startRun(store, issueId, input, actor, scope) {
2199
2377
  const externalSessionRef = readExternalSessionRef(input.externalSessionRef) ?? null;
2200
2378
  return await runBoardWrite(store, async ({ tx }) => {
2201
- const card = await requireCard$1(tx, issueId);
2379
+ const card = await tx.issues.lockById(issueId);
2380
+ if (!card) throw new require_migrations_path.BoardError("issue_not_found", { issueId });
2381
+ const [active] = await tx.runs.listRunning(issueId);
2382
+ if (active) throw new require_migrations_path.BoardError("board_card_run_active", {
2383
+ issueId,
2384
+ runId: active.id,
2385
+ agentName: active.agentName
2386
+ });
2202
2387
  const attempt = await tx.runs.countByIssue(issueId) + 1;
2203
2388
  const executionMode = input.executionMode ?? card.executionMode;
2204
2389
  const run = await tx.runs.create({
@@ -2464,7 +2649,8 @@ async function createCard(store, input, actor, scope) {
2464
2649
  const column = statusId === null ? null : await tx.statuses.findById(statusId);
2465
2650
  const unmet = column ? await enforceColumnEntry(tx, {
2466
2651
  id: null,
2467
- description: input.description ?? null
2652
+ description: input.description ?? null,
2653
+ statusId: null
2468
2654
  }, column, actor) : [];
2469
2655
  const milestoneId = await requireMilestoneInWorkspace(tx, workspaceId, input.milestoneId ?? null);
2470
2656
  const parentIssueId = await requireParentInWorkspace(tx, workspaceId, input.parentIssueId ?? null);
@@ -2472,8 +2658,9 @@ async function createCard(store, input, actor, scope) {
2472
2658
  const now = currentUnixSeconds();
2473
2659
  for (let attempt = 1;; attempt++) {
2474
2660
  const identity = await nextIssueIdentity(input.workspace, tx);
2661
+ let created;
2475
2662
  try {
2476
- return withUnmetRules(await tx.issues.create({
2663
+ created = await tx.issues.create({
2477
2664
  id: identity.id,
2478
2665
  workspaceId,
2479
2666
  title: input.title?.trim() || identity.id,
@@ -2497,12 +2684,25 @@ async function createCard(store, input, actor, scope) {
2497
2684
  order,
2498
2685
  createdAt: now,
2499
2686
  updatedAt: now
2500
- }), unmet);
2687
+ });
2501
2688
  } catch (error) {
2502
2689
  const numberTaken = await tx.issues.findByNumber(workspaceId, identity.number);
2503
2690
  const keyTaken = await tx.issues.findById(identity.id);
2504
2691
  if (attempt >= NUMBERING_ATTEMPTS || !numberTaken && !keyTaken) throw error;
2692
+ continue;
2505
2693
  }
2694
+ if (statusId !== null) await tx.fieldChanges.create({
2695
+ id: (0, node_crypto.randomUUID)(),
2696
+ issueId: created.id,
2697
+ field: "statusId",
2698
+ fromValue: null,
2699
+ toValue: statusId,
2700
+ actorKind: toStoredActorKind(actor),
2701
+ actorId: actor.id ?? null,
2702
+ sourceChatSessionId: actor.chatSessionId ?? null,
2703
+ createdAt: now
2704
+ });
2705
+ return withUnmetRules(created, unmet);
2506
2706
  }
2507
2707
  }, scope);
2508
2708
  }
@@ -2558,7 +2758,8 @@ async function updateCard(store, issueId, input, actor, scope) {
2558
2758
  const description = "description" in updates ? updates.description ?? null : card.description;
2559
2759
  unmet = await enforceColumnEntry(tx, {
2560
2760
  id: card.id,
2561
- description
2761
+ description,
2762
+ statusId: card.statusId
2562
2763
  }, column, actor);
2563
2764
  }
2564
2765
  if ("assigneeKind" in input) updates.assigneeKind = input.assigneeKind ?? null;
@@ -2957,6 +3158,160 @@ async function reorderCards(store, workspaceId, orderedIds, scope) {
2957
3158
  }, scope);
2958
3159
  }
2959
3160
  //#endregion
3161
+ //#region src/ops/ready.ts
3162
+ /** The column an agent takes its next card from. */
3163
+ const READY_COLUMN_SLUG = "to_do";
3164
+ /**
3165
+ * The cards an agent may pick up right now: spelled out, nobody working on them,
3166
+ * nobody's turn but the agent's.
3167
+ *
3168
+ * "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
3169
+ * run is taken, and a card waiting for a person belongs to that person until
3170
+ * they answer. What is left is the queue — in board order, so the card someone
3171
+ * dragged to the top is the one an agent asking for work gets.
3172
+ */
3173
+ async function listReady(store, input) {
3174
+ return (await queryReady(store, input)).cards;
3175
+ }
3176
+ /**
3177
+ * One page of the ready cards, how many are ready in all, and the columns read
3178
+ * on the way — the rule above, asked of the database in one statement rather
3179
+ * than of every card on the board.
3180
+ */
3181
+ async function queryReady(store, input) {
3182
+ const columns = await listColumns(store, input.workspaceId);
3183
+ const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
3184
+ if (!column) return {
3185
+ cards: [],
3186
+ total: 0,
3187
+ columns
3188
+ };
3189
+ return {
3190
+ ...await store.issues.listPage({
3191
+ workspaceId: input.workspaceId,
3192
+ statusIds: [column.id],
3193
+ waitingForPerson: false,
3194
+ hasActiveRun: false,
3195
+ limit: input.limit,
3196
+ offset: input.offset
3197
+ }),
3198
+ columns
3199
+ };
3200
+ }
3201
+ //#endregion
3202
+ //#region src/ops/claim.ts
3203
+ /**
3204
+ * Take a card to work on, in one board write: pick it, start its run, and move
3205
+ * it into the board's first `started` column (In Progress on a standard
3206
+ * board), its entry and flow rules checked on the way in. Anything refused
3207
+ * leaves nothing written.
3208
+ *
3209
+ * Two claimers never get the same card. On SQLite the write holds the file
3210
+ * from its first read (`begin immediate`), so the second claimer reads the
3211
+ * board after the first one's commit. On Postgres the picked card's row is
3212
+ * locked (`select … for update`): a claimer that waited on the lock finds the
3213
+ * card taken, and moves on to the next ready one. The target's row is locked
3214
+ * first, always, in the order a move into it locks (column, then card), so a
3215
+ * claim and a move never wait on each other in a circle.
3216
+ */
3217
+ async function claimCard(store, input, actor, scope) {
3218
+ return await runBoardWrite(store, async ({ tx }) => {
3219
+ const columns = await listColumns(tx, input.workspaceId);
3220
+ const target = columns.find((column) => column.category === "started");
3221
+ if (!target) throw new require_migrations_path.BoardError("issue_status_not_found", {
3222
+ workspaceId: input.workspaceId,
3223
+ category: "started"
3224
+ });
3225
+ await tx.statuses.lockById(target.id);
3226
+ const toDo = columns.find((column) => normalizeStatusName(column.name) === "to_do") ?? null;
3227
+ const card = input.issueId === void 0 ? await takeFirstReady(tx, input.workspaceId, toDo) : await takeNamed(tx, input.workspaceId, input.issueId, toDo);
3228
+ const run = await startRun(tx, card.id, input.run, actor, { tx });
3229
+ return {
3230
+ card: await updateCard(tx, card.id, { statusId: target.id }, actor, { tx }),
3231
+ run,
3232
+ column: target
3233
+ };
3234
+ }, scope);
3235
+ }
3236
+ /**
3237
+ * The first ready card, locked. A card another claimer took while this one
3238
+ * waited for its lock is passed over, and the next read no longer lists it; the
3239
+ * cards passed over are counted so the loop ends with the list.
3240
+ */
3241
+ async function takeFirstReady(store, workspaceId, toDo) {
3242
+ const passed = /* @__PURE__ */ new Set();
3243
+ for (;;) {
3244
+ const { cards } = await queryReady(store, {
3245
+ workspaceId,
3246
+ limit: passed.size + 1
3247
+ });
3248
+ const candidate = cards.find((card) => !passed.has(card.id));
3249
+ if (!candidate) throw new require_migrations_path.BoardError("board_nothing_ready", { workspaceId });
3250
+ const locked = await store.issues.lockById(candidate.id);
3251
+ if (locked && await notReadyReason(store, locked, toDo) === null) return locked;
3252
+ passed.add(candidate.id);
3253
+ }
3254
+ }
3255
+ /** The named card, locked, when it is on this board and ready — or why not. */
3256
+ async function takeNamed(store, workspaceId, issueId, toDo) {
3257
+ const card = await store.issues.lockById(issueId);
3258
+ if (!card || card.workspaceId !== workspaceId) throw new require_migrations_path.BoardError("issue_not_found", { issueId });
3259
+ const reason = await notReadyReason(store, card, toDo);
3260
+ if (reason) throw new require_migrations_path.BoardError("board_card_not_ready", {
3261
+ issueId,
3262
+ reason
3263
+ });
3264
+ return card;
3265
+ }
3266
+ /** `queryReady`'s rule for one card already read: nobody on it, nobody waited for, in To Do. */
3267
+ async function notReadyReason(store, card, toDo) {
3268
+ if ((await store.runs.listRunning(card.id)).length > 0) return "run_active";
3269
+ if (card.waitingFor === "human") return "waiting_for_person";
3270
+ return toDo !== null && card.statusId === toDo.id ? null : "not_in_to_do";
3271
+ }
3272
+ //#endregion
3273
+ //#region src/ops/column-entries.ts
3274
+ /**
3275
+ * Every entry into a column on the named boards at or after `since`, in order
3276
+ * of `(at, changeId)` — the one signal an app dispatching work on column
3277
+ * changes needs. It sees every writer, an outside `kanbo` on the same database
3278
+ * included, because it reads the cards' own history.
3279
+ *
3280
+ * A page never ends inside a second: when `limit` cuts into one, the rest of
3281
+ * that second comes too, so a page may be longer than `limit`. A reader keeps
3282
+ * its cursor at the last `at` it read after a short page — a write in that
3283
+ * second may still commit — and drops the `changeId`s it has seen; after a page
3284
+ * of `limit` or more it moves on to the last `at` + 1, which a second holding
3285
+ * more entries than `limit` would otherwise never let it pass.
3286
+ */
3287
+ async function listColumnEntries(store, input) {
3288
+ const page = await store.fieldChanges.listByFieldSince("statusId", input);
3289
+ return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
3290
+ }
3291
+ /** The page with every change of its last second, not only those `limit` let in. */
3292
+ async function withWholeLastSecond(store, input, page) {
3293
+ const last = page.at(-1).createdAt;
3294
+ const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
3295
+ workspaceIds: input.workspaceIds,
3296
+ since: last,
3297
+ until: last
3298
+ });
3299
+ return [...page.filter((change) => change.createdAt < last), ...lastSecond];
3300
+ }
3301
+ /** A status change as an entry; a change that took the card out of every column enters none. */
3302
+ function toColumnEntry(change) {
3303
+ return change.toValue === null ? [] : [{
3304
+ changeId: change.id,
3305
+ issueId: change.issueId,
3306
+ workspaceId: change.workspaceId,
3307
+ fromStatusId: change.fromValue,
3308
+ toStatusId: change.toValue,
3309
+ actorKind: change.actorKind,
3310
+ actorId: change.actorId,
3311
+ at: change.createdAt
3312
+ }];
3313
+ }
3314
+ //#endregion
2960
3315
  //#region src/ops/context-refs.ts
2961
3316
  const ContextRefsJsonSchema = zod.z.string().transform((raw) => JSON.parse(raw)).pipe(zod.z.array(zod.z.string()));
2962
3317
  /** Add a context ref at the end of the card's list, recording the change in its history. */
@@ -3141,6 +3496,7 @@ function subtaskSplitSentence(parentReference) {
3141
3496
  }
3142
3497
  const SUBTASK_RULE = `${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("the parent card's id")}`;
3143
3498
  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.";
3499
+ const CLAIM_RULE = "Taking a card from To Do yourself? Claim it instead of starting a run and moving it: the claim does both in one step, and no other agent gets the same card. Finish the run when you stop.";
3144
3500
  const OWN_SESSION_RULE = "Started the work yourself, not launched by an app that tracks the run for you? Record the run when you start and finish it when you stop. kanbo fills in the Claude Code or Codex session you are in; never invent a session id.";
3145
3501
  /** Said by the `kanbo init` block and the MCP instructions, which name the comment command or tool beside it. */
3146
3502
  const COMMENT_RULE = "Findings, results, decisions and questions go on the card as comments.";
@@ -3164,6 +3520,7 @@ const BOARD_RULES = [
3164
3520
  EXECUTION_MODE_RULE,
3165
3521
  SUBTASK_RULE,
3166
3522
  PULL_REQUEST_RULE,
3523
+ CLAIM_RULE,
3167
3524
  OWN_SESSION_RULE,
3168
3525
  "Read the column descriptions above before moving a card, and do all of this through the board tools or commands you have — a statement in your reply changes nothing."
3169
3526
  ];
@@ -3179,12 +3536,14 @@ const BOARD_RULES = [
3179
3536
  async function buildPrimeText(store, workspaceId) {
3180
3537
  const columns = await listColumns(store, workspaceId);
3181
3538
  const ruled = columns.some((column) => readEntryRules(column).length > 0);
3539
+ const flowing = columns.some((column) => hasFlowRules(column));
3182
3540
  return [
3183
3541
  ...await describeReturnedCards(store, workspaceId, columns),
3184
3542
  "Columns of this board, in board order:",
3185
3543
  "",
3186
- ...columns.length > 0 ? columns.map(describeColumnLine) : ["- (this board has no columns yet)"],
3544
+ ...columns.length > 0 ? columns.map((column) => describeColumnLine(column, columns)) : ["- (this board has no columns yet)"],
3187
3545
  ...ruled ? ["", ENTRY_RULES_NOTE] : [],
3546
+ ...flowing ? ["", FLOW_RULES_NOTE] : [],
3188
3547
  "",
3189
3548
  "How this board works:",
3190
3549
  "",
@@ -3226,11 +3585,22 @@ async function describeReturnedCards(store, workspaceId, columns) {
3226
3585
  * without any reads exactly as it did before they existed.
3227
3586
  */
3228
3587
  const ENTRY_RULES_NOTE = "A column that lists requirements refuses a card that does not meet them yet, with the list of what is missing: do that work first, then move the card. A person may still move it.";
3229
- function describeColumnLine(column) {
3588
+ /** Said under the columns only when one of them has flow rules. */
3589
+ 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.";
3590
+ function hasFlowRules(column) {
3591
+ const { allowedFrom, wipLimit } = readFlowRules(column);
3592
+ return allowedFrom !== null || wipLimit !== null;
3593
+ }
3594
+ function describeColumnLine(column, columns) {
3230
3595
  const slug = normalizeStatusName(column.name);
3231
3596
  const rules = readEntryRules(column);
3232
- const requires = rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : "";
3233
- return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${requires}` : `- ${column.name} (\`${slug}\`)${requires ? ` —${requires}` : ""}`;
3597
+ const said = `${rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : ""}${describeFlow(column, columns)}`;
3598
+ return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${said}` : `- ${column.name} (\`${slug}\`)${said ? ` —${said}` : ""}`;
3599
+ }
3600
+ /** Where a column takes cards from and how many it holds, as the prime text says it; nothing for a column without flow rules. */
3601
+ function describeFlow(column, columns) {
3602
+ const { allowedFrom, wipLimit } = readFlowRules(column);
3603
+ return `${allowedFrom === null ? "" : allowedFrom.length === 0 ? " Accepts no card from another column: only a person puts a card here." : ` Accepts cards from ${joinWords(columns.filter((other) => allowedFrom.includes(other.id)).map((other) => `\`${normalizeStatusName(other.name)}\``))} only.`}${wipLimit === null ? "" : ` Holds at most ${wipLimit} ${wipLimit === 1 ? "card" : "cards"}.`}`;
3234
3604
  }
3235
3605
  //#endregion
3236
3606
  //#region src/domain/pull-request-ref.ts
@@ -3400,47 +3770,6 @@ async function queryCards(store, input) {
3400
3770
  };
3401
3771
  }
3402
3772
  //#endregion
3403
- //#region src/ops/ready.ts
3404
- /** The column an agent takes its next card from. */
3405
- const READY_COLUMN_SLUG = "to_do";
3406
- /**
3407
- * The cards an agent may pick up right now: spelled out, nobody working on them,
3408
- * nobody's turn but the agent's.
3409
- *
3410
- * "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
3411
- * run is taken, and a card waiting for a person belongs to that person until
3412
- * they answer. What is left is the queue — in board order, so the card someone
3413
- * dragged to the top is the one an agent asking for work gets.
3414
- */
3415
- async function listReady(store, input) {
3416
- return (await queryReady(store, input)).cards;
3417
- }
3418
- /**
3419
- * One page of the ready cards, how many are ready in all, and the columns read
3420
- * on the way — the rule above, asked of the database in one statement rather
3421
- * than of every card on the board.
3422
- */
3423
- async function queryReady(store, input) {
3424
- const columns = await listColumns(store, input.workspaceId);
3425
- const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
3426
- if (!column) return {
3427
- cards: [],
3428
- total: 0,
3429
- columns
3430
- };
3431
- return {
3432
- ...await store.issues.listPage({
3433
- workspaceId: input.workspaceId,
3434
- statusIds: [column.id],
3435
- waitingForPerson: false,
3436
- hasActiveRun: false,
3437
- limit: input.limit,
3438
- offset: input.offset
3439
- }),
3440
- columns
3441
- };
3442
- }
3443
- //#endregion
3444
3773
  //#region src/ops/relations.ts
3445
3774
  /**
3446
3775
  * Every edge touching the card, in either direction, each with the card on its
@@ -3730,6 +4059,8 @@ function createBoardOps(store) {
3730
4059
  updateColumn: async (statusId, patch, actor, scope) => await updateColumn(store, statusId, patch, actor, scope),
3731
4060
  describeColumn: async (workspaceId, nameOrId, description, scope) => await describeColumn(store, workspaceId, nameOrId, description, scope),
3732
4061
  setColumnEntryRules: async (workspaceId, nameOrId, entryRules, actor, scope) => await setColumnEntryRules(store, workspaceId, nameOrId, entryRules, actor, scope),
4062
+ setColumnFlowRules: async (columnId, rules, actor, scope) => await setColumnFlowRules(store, columnId, rules, actor, scope),
4063
+ applyStandardFlow: async (workspaceId, actor, scope) => await applyStandardFlow(store, workspaceId, actor, scope),
3733
4064
  enforceColumnEntryForCards: async (issueIds, statusId, actor) => await enforceColumnEntryForCards(store, issueIds, statusId, actor),
3734
4065
  deleteColumn: async (statusId, scope, actor) => await deleteColumn(store, statusId, scope, actor),
3735
4066
  reorderColumns: async (workspaceId, orderedIds, scope, actor) => await reorderColumns(store, workspaceId, orderedIds, scope, actor),
@@ -3740,6 +4071,7 @@ function createBoardOps(store) {
3740
4071
  moveColumn: async (workspaceId, nameOrId, position, actor, scope) => await moveColumn$1(store, workspaceId, nameOrId, position, actor, scope),
3741
4072
  removeColumn: async (workspaceId, nameOrId, options, actor, scope) => await removeColumn$1(store, workspaceId, nameOrId, options, actor, scope),
3742
4073
  startRun: async (issueId, input, actor, scope) => await startRun(store, issueId, input, actor, scope),
4074
+ claimCard: async (input, actor, scope) => await claimCard(store, input, actor, scope),
3743
4075
  attachRunExecution: async (runId, input, scope) => await attachRunExecution(store, runId, input, scope),
3744
4076
  recordRunSessionRef: async (runId, ref, scope) => await recordRunSessionRef(store, runId, ref, scope),
3745
4077
  clearRunSessionRef: async (runId, actor, options, scope) => await clearRunSessionRef(store, runId, actor, options, scope),
@@ -3752,6 +4084,7 @@ function createBoardOps(store) {
3752
4084
  readBoardProjectionForIssue: async (issueId) => await readBoardProjectionForIssue(store, issueId),
3753
4085
  listReady: async (input) => await listReady(store, input),
3754
4086
  queryReady: async (input) => await queryReady(store, input),
4087
+ listColumnEntries: async (input) => await listColumnEntries(store, input),
3755
4088
  buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId)
3756
4089
  };
3757
4090
  }
@@ -4457,9 +4790,10 @@ const SCHEMA_NOT_INSTALLED_MESSAGE = "This Postgres database has no kanbo board
4457
4790
  * caller's own risk but never written to — a write shaped by this build's idea
4458
4791
  * of the schema is how a person's board gets a column full of nothing. A file
4459
4792
  * of this package's own is this package's to migrate, so the same refusal
4460
- * points at `kanbo migrate` instead of at the app. Reads stay open either way,
4461
- * because a person who cannot migrate right now should still be able to look at
4462
- * their cards.
4793
+ * points at `kanbo migrate` instead of at the app. Reads are held to the lesser
4794
+ * bar of `assertReadableBoard`: a file only a stamp behind is still read, so a
4795
+ * person who cannot migrate right now can look at their cards, but one missing
4796
+ * a table or column this build reads is refused with the same sentence.
4463
4797
  */
4464
4798
  function assertWritableBoard(database, owner) {
4465
4799
  try {
@@ -4470,6 +4804,26 @@ function assertWritableBoard(database, owner) {
4470
4804
  }
4471
4805
  }
4472
4806
  /**
4807
+ * Refuse a read of a board file that lacks a table or a column this build
4808
+ * reads — a file a migration behind — with the sentence a write gets, instead
4809
+ * of letting the first statement fail on what is missing. A file whose only
4810
+ * difference is its schema stamp is still read: every statement this build
4811
+ * makes works on it, and a person who cannot migrate now can still look at
4812
+ * their cards.
4813
+ */
4814
+ function assertReadableBoard(database, owner) {
4815
+ try {
4816
+ require_migrate.assertBoardSchema(database);
4817
+ } catch (error) {
4818
+ if (!(error instanceof require_migrations_path.BoardError && error.code === "board_schema_outdated")) throw error;
4819
+ if (lacksWhatThisBuildReads(error.details)) throw new CliError(3, owner === "kanbo" ? FILE_SCHEMA_OUTDATED_MESSAGE : SCHEMA_OUTDATED_MESSAGE);
4820
+ }
4821
+ }
4822
+ /** Whether `assertBoardSchema` found a table or a column missing, rather than only another stamp. */
4823
+ function lacksWhatThisBuildReads(details) {
4824
+ return details !== void 0 && ("missingTables" in details || "missingAddedTables" in details || "missingColumns" in details);
4825
+ }
4826
+ /**
4473
4827
  * Refuse an external board this build cannot speak for — before reading it as
4474
4828
  * well as before writing to it.
4475
4829
  *
@@ -4708,6 +5062,7 @@ async function openFileSession(target, options, mode, cwd) {
4708
5062
  assertWritableBoard(board.database, target.owner);
4709
5063
  };
4710
5064
  if (mode === "write") assertWritableBoard(board.database, target.owner);
5065
+ else assertReadableBoard(board.database, target.owner);
4711
5066
  const workspace = resolveWorkspace(target.owner === "kanbo" ? { kind: "file" } : {
4712
5067
  kind: "sqlite",
4713
5068
  database: board.database
@@ -4731,8 +5086,9 @@ async function openFileSession(target, options, mode, cwd) {
4731
5086
  /**
4732
5087
  * The same, on a board that is a database of its own.
4733
5088
  *
4734
- * The guard runs whatever the command came to do. A board file that this build
4735
- * cannot write to is still a board worth reading; a connection string pointing
5089
+ * The guard runs whatever the command came to do. A board file whose only
5090
+ * difference from this build is its schema stamp is still a board worth
5091
+ * reading; a connection string pointing
4736
5092
  * at a database with no board in it is nothing to read at all, and every
4737
5093
  * command would otherwise fail one statement later with the driver's own words.
4738
5094
  */
@@ -5030,7 +5386,9 @@ function projectCardActiveRun(run) {
5030
5386
  startedAt: run.startedAt
5031
5387
  };
5032
5388
  }
5033
- function projectColumn(column) {
5389
+ /** A column as the command line prints it; `board` is every column, to name the ones it accepts cards from. */
5390
+ function projectColumn(column, board) {
5391
+ const { allowedFrom, wipLimit } = readFlowRules(column);
5034
5392
  return {
5035
5393
  id: column.id,
5036
5394
  name: column.name,
@@ -5038,7 +5396,9 @@ function projectColumn(column) {
5038
5396
  description: column.description,
5039
5397
  category: column.category,
5040
5398
  order: column.order,
5041
- entryRules: readEntryRules(column)
5399
+ entryRules: readEntryRules(column),
5400
+ allowedFrom: allowedFrom === null ? null : board.filter((other) => allowedFrom.includes(other.id)).map((other) => normalizeStatusName(other.name)),
5401
+ wipLimit
5042
5402
  };
5043
5403
  }
5044
5404
  function projectSprint(sprint) {
@@ -5110,7 +5470,9 @@ function describeColumns(columns) {
5110
5470
  return columns.map((column) => [
5111
5471
  `${padToWidth(sanitizeTerminalText(column.slug), slugWidth)} ${sanitizeTerminalText(column.name)}`,
5112
5472
  column.description ? ` — ${sanitizeTerminalText(column.description)}` : "",
5113
- column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : ""
5473
+ column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : "",
5474
+ column.allowedFrom === null ? "" : ` [from: ${column.allowedFrom.length > 0 ? column.allowedFrom.join(", ") : "no column"}]`,
5475
+ column.wipLimit === null ? "" : ` [wip: ${column.wipLimit}]`
5114
5476
  ].join("")).join("\n");
5115
5477
  }
5116
5478
  /** A unix-seconds date as the UTC day it falls on, `—` for none. */
@@ -5432,6 +5794,52 @@ function registerCapabilitiesCommand(program) {
5432
5794
  });
5433
5795
  }
5434
5796
  //#endregion
5797
+ //#region src/cli/commands/claim.ts
5798
+ /**
5799
+ * `kanbo claim [card]` — take a card to work on, in one step.
5800
+ *
5801
+ * The run starts and the card moves out of To Do into the board's first
5802
+ * work-in-progress column in one write, so two agents in two terminals never
5803
+ * take the same card. Filed as a placement: in a person's own terminal the
5804
+ * column's rules warn, in an agent's shell they refuse.
5805
+ */
5806
+ function registerClaimCommand(program) {
5807
+ 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) => {
5808
+ await runBoardCommand(options, "write", async (session) => {
5809
+ const actor = createPlacementActor();
5810
+ const named = reference === void 0 ? void 0 : (await requireCard(session, reference)).id;
5811
+ const claimed = await session.ops.claimCard({
5812
+ workspaceId: session.workspace.id,
5813
+ issueId: named,
5814
+ run: {
5815
+ agentName: options.agent ?? actor.id ?? "agent",
5816
+ externalSessionRef: options.session ?? sessionRefFromEnvironment() ?? void 0,
5817
+ launchedByKind: "external"
5818
+ }
5819
+ }, actor);
5820
+ const [columns, projection, runs] = await Promise.all([
5821
+ session.ops.listColumns(session.workspace.id),
5822
+ session.ops.readBoardProjectionForIssue(claimed.card.id),
5823
+ session.ops.listRuns(claimed.card.id)
5824
+ ]);
5825
+ const card = projectCard(claimed.card, columns, projection);
5826
+ for (const item of claimed.card.unmetRules ?? []) console.error(`kanbo: warning: ${card.id} entered "${claimed.column.name}" without ${item.rule}: ${item.detail}`);
5827
+ const run = projectRun({
5828
+ ...claimed.run,
5829
+ attempt: runs.find((each) => each.id === claimed.run.id)?.attempt ?? runs.length
5830
+ });
5831
+ return {
5832
+ value: {
5833
+ card,
5834
+ run,
5835
+ column: projectColumn(claimed.column, columns)
5836
+ },
5837
+ 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`
5838
+ };
5839
+ });
5840
+ });
5841
+ }
5842
+ //#endregion
5435
5843
  //#region src/mcp/transport.ts
5436
5844
  /** What `kanbo_card_get` may bring along with the card. */
5437
5845
  const KANBO_CARD_INCLUDES = [
@@ -5548,16 +5956,22 @@ function projectKanboSprint(sprint) {
5548
5956
  doneCards: sprint.cards.done
5549
5957
  };
5550
5958
  }
5551
- /** A column of the board, from the row either transport holds. */
5552
- function projectKanboColumn(column) {
5959
+ /** A column of the board, from the row either transport holds; `board` is every column, to name the ones it accepts cards from. */
5960
+ function projectKanboColumn(column, board) {
5553
5961
  const stored = column.entryRules;
5962
+ const allowedIds = typeof column.allowedFrom === "string" ? readFlowRules({
5963
+ allowedFrom: column.allowedFrom,
5964
+ wipLimit: null
5965
+ }).allowedFrom : column.allowedFrom ?? null;
5554
5966
  return {
5555
5967
  id: column.id,
5556
5968
  name: column.name,
5557
5969
  slug: normalizeStatusName(column.name),
5558
5970
  description: column.description,
5559
5971
  order: column.order,
5560
- entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null })
5972
+ entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null }),
5973
+ allowedFrom: allowedIds === null ? null : board.filter((other) => allowedIds.includes(other.id)).map((other) => normalizeStatusName(other.name)),
5974
+ wipLimit: column.wipLimit ?? null
5561
5975
  };
5562
5976
  }
5563
5977
  /**
@@ -5987,10 +6401,11 @@ const COLUMN_CATEGORIES = [
5987
6401
  ];
5988
6402
  /** The whole board's columns after a change, and the sentence that says what changed. */
5989
6403
  async function changed(session, message, extra = {}) {
5990
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
6404
+ const board = await session.ops.listColumns(session.workspace.id);
6405
+ const views = board.map((column) => projectColumn(column, board));
5991
6406
  return {
5992
6407
  value: {
5993
- ...extra,
6408
+ ...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, typeof value === "number" || Array.isArray(value) ? value : projectColumn(value, board)])),
5994
6409
  columns: views
5995
6410
  },
5996
6411
  text: `${message}\n\n${describeColumns(views)}`
@@ -6019,14 +6434,14 @@ async function addColumn(session, request, actor) {
6019
6434
  category: request.category ?? spec.category,
6020
6435
  position: anchor ? request.after !== void 0 ? { after: anchor.id } : { before: anchor.id } : void 0
6021
6436
  }, actor);
6022
- return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: projectColumn(created) });
6437
+ return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: created });
6023
6438
  }
6024
6439
  /** Said after a rename: agents' instructions name no column but To Do, and `kanbo prime` gives them the rest. */
6025
6440
  const RENAME_NOTE = "Agents read column names from kanbo prime; rename is picked up there.";
6026
6441
  async function renameColumn(session, column, newName, actor) {
6027
6442
  const before = await requireNamedColumn(session, column);
6028
6443
  const renamed = await session.ops.renameColumn(session.workspace.id, before.id, newName, actor);
6029
- return await changed(session, before.name === renamed.name ? `${renamed.name} already has that name.` : `Renamed ${before.name} to ${renamed.name} (slug ${normalizeStatusName(renamed.name)}).\n${RENAME_NOTE}`, { renamed: projectColumn(renamed) });
6444
+ return await changed(session, before.name === renamed.name ? `${renamed.name} already has that name.` : `Renamed ${before.name} to ${renamed.name} (slug ${normalizeStatusName(renamed.name)}).\n${RENAME_NOTE}`, { renamed });
6030
6445
  }
6031
6446
  /** `--first`, `--last`, `--before <column>` or `--after <column>` — exactly one. */
6032
6447
  function readColumnPosition(options) {
@@ -6058,7 +6473,7 @@ async function removeColumn(session, column, moveCardsTo, actor) {
6058
6473
  const result = await session.ops.removeColumn(session.workspace.id, removing.id, { moveCardsTo }, actor);
6059
6474
  for (const card of result.unmetRules ?? []) for (const item of card.unmet) console.error(`kanbo: warning: ${card.issueId} entered "${result.movedTo?.name ?? "—"}" without ${item.rule}: ${item.detail}`);
6060
6475
  return await changed(session, result.movedTo ? `Removed ${result.removed.name} and moved ${describeCount$1(result.movedCards)} to ${result.movedTo.name}.` : `Removed ${result.removed.name}.`, {
6061
- removed: projectColumn(result.removed),
6476
+ removed: result.removed,
6062
6477
  movedCards: result.movedCards
6063
6478
  });
6064
6479
  }
@@ -6102,6 +6517,14 @@ const BOARD_ERROR_TEXTS = {
6102
6517
  text: "Only a person can approve a card.",
6103
6518
  next: AS_A_PERSON
6104
6519
  }),
6520
+ board_card_not_ready: (details) => ({
6521
+ 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.`,
6522
+ next: "kanbo ready"
6523
+ }),
6524
+ board_card_run_active: (details) => ({
6525
+ text: `${named(details.issueId)} already has a run going: ${named(details.agentName)}'s, run ${named(details.runId)}.`,
6526
+ next: `kanbo run finish ${named(details.runId)} --state stopped`
6527
+ }),
6105
6528
  board_column_name_taken: (details) => ({
6106
6529
  text: `Another column, ${named(details.takenBy)}, already has that name (same slug). Pick another name.`,
6107
6530
  next: "kanbo columns list"
@@ -6132,6 +6555,14 @@ const BOARD_ERROR_TEXTS = {
6132
6555
  }),
6133
6556
  board_entry_rule_invalid: (details) => ({ text: `Unknown column rule: ${list(details.rules)}. Use any of ${list(details.allowed)}.` }),
6134
6557
  board_execution_mode_locked: (details) => ({ text: `${named(details.issueId)} has been started, so where it is worked (${named(details.executionMode)}) can't change now.` }),
6558
+ 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.` } : {
6559
+ text: `A column accepts cards only from other columns of its own board, and ${list(details.allowedFrom)} is not one.`,
6560
+ next: "kanbo columns list"
6561
+ },
6562
+ board_nothing_ready: () => ({
6563
+ text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
6564
+ next: "kanbo ready"
6565
+ }),
6135
6566
  board_pull_request_invalid: (details) => ({ text: `"${named(details.value)}" is not a GitHub pull request. Give its link or owner/repo#number.` }),
6136
6567
  board_pull_request_not_found: (details) => ({
6137
6568
  text: `${named(details.issueId)} has no pull request link "${named(details.linkId)}".`,
@@ -6197,7 +6628,7 @@ const BOARD_ERROR_TEXTS = {
6197
6628
  issue_relation_self_reference: () => ({ text: "A card can't be linked to itself." }),
6198
6629
  issue_status_name_empty: () => ({ text: "Give the column a name." }),
6199
6630
  issue_status_not_found: (details) => ({
6200
- text: `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
6631
+ text: details.category !== void 0 ? `This board has no ${named(details.category)} column for a claimed card to go to.` : `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
6201
6632
  next: "kanbo columns list"
6202
6633
  }),
6203
6634
  issue_status_reference_conflict: () => ({ text: "Name the column once: by its id or by its name, not both." }),
@@ -6401,7 +6832,7 @@ async function runColumnsMenu(ui) {
6401
6832
  if (showList) say$1(ui, [
6402
6833
  "",
6403
6834
  "Columns of this board:",
6404
- describeColumns(columns.map(projectColumn))
6835
+ describeColumns(columns.map((column) => projectColumn(column, columns)))
6405
6836
  ].join("\n"));
6406
6837
  let choice;
6407
6838
  try {
@@ -6629,7 +7060,8 @@ function registerColumnsCommands(program) {
6629
7060
  const columns = program.command("columns").description("See and change the board's columns (alone: a menu)");
6630
7061
  withBoardOptions(columns.command("list").description("List the columns in order, with the short name to move cards by")).action(async (options) => {
6631
7062
  await runBoardCommand(options, "read", async (session) => {
6632
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
7063
+ const board = await session.ops.listColumns(session.workspace.id);
7064
+ const views = board.map((column) => projectColumn(column, board));
6633
7065
  return {
6634
7066
  value: views,
6635
7067
  text: describeColumns(views)
@@ -6638,7 +7070,7 @@ function registerColumnsCommands(program) {
6638
7070
  });
6639
7071
  withBoardOptions(columns.command("describe").description("Say in one line when a card belongs in a column").argument("<column>", "the column, by slug, name or id").requiredOption("--text <text>", "what the column means")).action(async (column, options) => {
6640
7072
  await runBoardCommand(options, "write", async (session) => {
6641
- const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text));
7073
+ const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text), await session.ops.listColumns(session.workspace.id));
6642
7074
  return {
6643
7075
  value: view,
6644
7076
  text: describeColumns([view])
@@ -6649,13 +7081,34 @@ function registerColumnsCommands(program) {
6649
7081
  const actor = requireHumanActor("columns rules", typedWords(command));
6650
7082
  const rules = readRequestedRules(options);
6651
7083
  await runBoardCommand(options, "write", async (session) => {
6652
- const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor));
7084
+ const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor), await session.ops.listColumns(session.workspace.id));
6653
7085
  return {
6654
7086
  value: view,
6655
7087
  text: describeColumns([view])
6656
7088
  };
6657
7089
  });
6658
7090
  });
7091
+ 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) => {
7092
+ const actor = requireHumanActor("columns flow", typedWords(command));
7093
+ if (options.from === void 0 && options.wip === void 0) throw new CliError(1, "Pass --from <columns|any>, --wip <count|none>, or both.");
7094
+ const wipLimit = options.wip === void 0 ? void 0 : readWipLimit(options.wip);
7095
+ await runBoardCommand(options, "write", async (session) => {
7096
+ const workspaceId = session.workspace.id;
7097
+ const board = await session.ops.listColumns(workspaceId);
7098
+ const target = await session.ops.requireColumn(workspaceId, column);
7099
+ const current = readFlowRules(target);
7100
+ await session.ops.setColumnFlowRules(target.id, {
7101
+ allowedFrom: options.from === void 0 ? current.allowedFrom : readAllowedFrom(board, workspaceId, options.from),
7102
+ wipLimit: wipLimit === void 0 ? current.wipLimit : wipLimit
7103
+ }, actor);
7104
+ const after = await session.ops.listColumns(workspaceId);
7105
+ const views = after.map((each) => projectColumn(each, after));
7106
+ return {
7107
+ value: views.find((view) => view.id === target.id),
7108
+ text: describeColumns(views)
7109
+ };
7110
+ });
7111
+ });
6659
7112
  withBoardOptions(columns.command("add").description("Add a column (before Done unless you say where)").argument("<name>", "what the column is called").option("--after <column>", "put it right after this column").option("--before <column>", "put it right before this column").option("--description <text>", "when a card belongs in it, in one line agents read").addOption(new commander.Option("--category <category>", "what kind of column it is").choices(COLUMN_CATEGORIES))).action(async (name, options, command) => {
6660
7113
  const actor = requireHumanActor("columns add", typedWords(command));
6661
7114
  await runBoardCommand(options, "write", async (session) => await addColumn(session, {
@@ -6697,7 +7150,7 @@ function registerColumnsCommands(program) {
6697
7150
  mode: "add-missing",
6698
7151
  actor
6699
7152
  });
6700
- const views = statuses.map(projectColumn);
7153
+ const views = statuses.map((column) => projectColumn(column, statuses));
6701
7154
  return {
6702
7155
  value: {
6703
7156
  added,
@@ -6721,7 +7174,8 @@ function registerColumnsCommands(program) {
6721
7174
  return;
6722
7175
  }
6723
7176
  await runBoardCommand({}, "read", async (session) => {
6724
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
7177
+ const board = await session.ops.listColumns(session.workspace.id);
7178
+ const views = board.map((column) => projectColumn(column, board));
6725
7179
  return {
6726
7180
  value: views,
6727
7181
  text: describeColumns(views)
@@ -6751,6 +7205,26 @@ function readRequestedRules(options) {
6751
7205
  if (options.require === void 0) throw new CliError(1, `Pass --require ${ENTRY_RULES.join(",")} (any of them) or --clear.`);
6752
7206
  return options.require.split(",").map((rule) => rule.trim()).filter(Boolean);
6753
7207
  }
7208
+ /** `--from`: `any` for every column, else the columns named, by slug, name or id. */
7209
+ function readAllowedFrom(board, workspaceId, value) {
7210
+ if (value.trim().toLowerCase() === "any") return null;
7211
+ const names = value.split(",").map((name) => name.trim()).filter(Boolean);
7212
+ if (names.length === 0) throw new CliError(1, "Name the columns a card may come from (--from in_review,qa), or --from any.");
7213
+ return names.map((name) => {
7214
+ const found = matchColumn(board, workspaceId, name);
7215
+ if (!found) throw new require_migrations_path.BoardError("issue_status_not_found", {
7216
+ workspaceId,
7217
+ statusName: name
7218
+ });
7219
+ return found.id;
7220
+ });
7221
+ }
7222
+ /** `--wip`: `none` for no limit, else a whole number of cards, 1 or more. */
7223
+ function readWipLimit(value) {
7224
+ if (value.trim().toLowerCase() === "none") return null;
7225
+ if (!/^\d+$/.test(value.trim()) || Number(value) < 1) throw new CliError(1, "--wip takes a whole number of cards, 1 or more, or none.");
7226
+ return Number(value);
7227
+ }
6754
7228
  //#endregion
6755
7229
  //#region src/package-version.ts
6756
7230
  (0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
@@ -6760,7 +7234,7 @@ function readRequestedRules(options) {
6760
7234
  * `package.version`. A build carries it as a literal; running from source
6761
7235
  * (tests, `tsx`) reads the package's own `package.json` instead.
6762
7236
  */
6763
- const KANBO_PACKAGE_VERSION = "0.4.0";
7237
+ const KANBO_PACKAGE_VERSION = "0.5.0";
6764
7238
  //#endregion
6765
7239
  //#region src/cli/setup/paths.ts
6766
7240
  /**
@@ -7109,7 +7583,7 @@ const INSTRUCTION_END_PATTERN = /<!-- KANBO_END\s*-->/g;
7109
7583
  const POINTER_KINDS = /* @__PURE__ */ new Set(["short", "global"]);
7110
7584
  /** The commands an agent uses, spelled once for the guide and the `kanbo prime` cheat sheet. */
7111
7585
  const COMMAND = {
7112
- ready: "kanbo ready",
7586
+ claim: "kanbo claim",
7113
7587
  move: "kanbo card move <id> <column>",
7114
7588
  statusLine: "kanbo card status-line <id> --text \"...\"",
7115
7589
  subtask: "kanbo card create --description \"...\" --parent <id>",
@@ -7128,7 +7602,7 @@ const AGENT_GUIDE_TEXT = [
7128
7602
  "Work on this project is tracked on a kanbo board. Before starting a task run `kanbo prime` — it prints the board's columns and what each one means.",
7129
7603
  "",
7130
7604
  ...[
7131
- `- Take a card from \`${COMMAND.ready}\`.`,
7605
+ `- Take a card with \`${COMMAND.claim}\`: it starts your run and moves the card into work in one step, and no other agent gets the same card.`,
7132
7606
  `- ${MOVE_CARD_RULE} (\`${COMMAND.move}\`)`,
7133
7607
  `- ${STATUS_LINE_RULE} (\`${COMMAND.statusLine}\`)`,
7134
7608
  `- ${SUBTASK_RULE} (\`${COMMAND.subtask}\`)`,
@@ -7158,12 +7632,13 @@ const COMMAND_SHEET = [
7158
7632
  "- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
7159
7633
  "- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
7160
7634
  "- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
7161
- "- `kanbo run start TST-5 --agent claude` — record a run you started yourself; it prints the finish command",
7635
+ "- `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",
7636
+ "- `kanbo run start TST-5 --agent claude` — record a run on a card you did not claim; it prints the finish command",
7162
7637
  "- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
7163
7638
  `- \`${COMMAND.capabilities}\` — everything else, rarely needed`
7164
7639
  ].join("\n");
7165
7640
  /** How to take work and who approves it — said by both blocks after their first sentence. */
7166
- const POINTER_TAIL = `Take work with \`${COMMAND.ready}\`. ${PERSON_ONLY_RULE}`;
7641
+ const POINTER_TAIL = `Take work with \`${COMMAND.claim}\`. ${PERSON_ONLY_RULE}`;
7167
7642
  /** The body of the project block: what `kanbo instructions short` prints. */
7168
7643
  const PROJECT_BODY = [
7169
7644
  "## Kanbo board",
@@ -7183,7 +7658,7 @@ const GLOBAL_BODY = [
7183
7658
  * The block, markers included, with this body's version and hash on the start
7184
7659
  * marker — and, when given, the kind of text it is (`k=orchestrator`).
7185
7660
  */
7186
- function wrapInstructionBlock(body, version = 2, kind) {
7661
+ function wrapInstructionBlock(body, version = 3, kind) {
7187
7662
  return `<!-- KANBO_START v${version}${kind ? ` k=${kind}` : ""} h=${hashBody(body)} -->\n${body}\n${INSTRUCTION_END}`;
7188
7663
  }
7189
7664
  /** The block `kanbo init --instructions …` writes into a project's own file. */
@@ -7212,7 +7687,7 @@ function inspectBlock(text, current) {
7212
7687
  kind
7213
7688
  });
7214
7689
  if (fields.trim() === "") return inspection(LEGACY_BLOCK_BODIES.includes(body) ? "legacy" : "edited");
7215
- if (version !== void 0 && Number(version) > 2) return inspection("newer");
7690
+ if (version !== void 0 && Number(version) > 3) return inspection("newer");
7216
7691
  if (kind !== null && !POINTER_KINDS.has(kind)) return inspection("chosen");
7217
7692
  if (version === void 0 || hash === void 0 || hashBody(body) !== hash) return inspection("edited");
7218
7693
  return inspection(normalized.slice(found.start, found.end) === current ? "current" : "outdated");
@@ -8242,7 +8717,7 @@ function startUpdateCheck(deps = {}) {
8242
8717
  setTimeout: globalThis.setTimeout,
8243
8718
  clearTimeout: globalThis.clearTimeout
8244
8719
  };
8245
- const current = deps.currentVersion ?? "0.4.0";
8720
+ const current = deps.currentVersion ?? "0.5.0";
8246
8721
  const controller = new AbortController();
8247
8722
  let found = null;
8248
8723
  const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
@@ -10674,7 +11149,7 @@ const KANBO_MCP_INSTRUCTIONS = [
10674
11149
  "kanbo is a kanban board shared by people and agents; these tools read and change its cards.",
10675
11150
  "",
10676
11151
  `- At the start of a session call ${tool("kanbo_prime")} — it returns this board's columns and what each one means.`,
10677
- `- Take work from ${tool("kanbo_ready")}.`,
11152
+ `- Take work from ${tool("kanbo_ready")}, and claim the card with ${tool("kanbo_card_claim")} before you start: it starts your run and moves the card into work in one step, so no other agent takes it.`,
10678
11153
  `- ${MOVE_CARD_RULE} (${tool("kanbo_card_move")})`,
10679
11154
  `- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
10680
11155
  `- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
@@ -10765,7 +11240,7 @@ const ORCHESTRATOR_GUIDE = [
10765
11240
  "## Start of every session",
10766
11241
  "1. Run `kanbo prime` (tool: `kanbo_prime`) and read the column descriptions. On this board the description is the contract, not the name.",
10767
11242
  "2. Run `kanbo ready` (`kanbo_ready`). If nothing is ready, say so and stop.",
10768
- "3. Take the first card. Run `kanbo run start <card> --agent orchestrator` (`kanbo_run_start`) and move it to the column this board uses for work in progress — `kanbo prime` lists the columns and what each is for — (`kanbo_card_move`) before doing anything else.",
11243
+ "3. Take the first card with `kanbo claim --agent orchestrator` (`kanbo_card_claim`): it starts your run and moves the card out of To Do into the board's first work-in-progress column in one step, so no other agent takes it, and it prints the run id you finish later. If another agent got there first, run `kanbo ready` again.",
10769
11244
  "",
10770
11245
  "## While working",
10771
11246
  "- Write a status line (`kanbo card status-line <card> --text \"…\"`, `kanbo_status_line`) at every step and before and after anything that takes more than a minute. One sentence, present tense.",
@@ -10792,7 +11267,7 @@ const ORCHESTRATOR_GUIDE = [
10792
11267
  "- Returned: the reason is the newest comment. The card is back in an earlier column; start a new run and address the reason first.",
10793
11268
  "",
10794
11269
  "## Never",
10795
- "- Never run `kanbo approve`, `kanbo return`, `kanbo sprint close`, `kanbo run clear-session`, or anything that changes the columns: `kanbo columns add`, `kanbo columns rename`, `kanbo columns move`, `kanbo columns remove`, `kanbo columns template`, `kanbo columns add-standard`, `kanbo columns rules`. They are for a person and will fail in your shell anyway.",
11270
+ "- Never run `kanbo approve`, `kanbo return`, `kanbo sprint close`, `kanbo run clear-session`, or anything that changes the columns: `kanbo columns add`, `kanbo columns rename`, `kanbo columns move`, `kanbo columns remove`, `kanbo columns template`, `kanbo columns add-standard`, `kanbo columns rules`, `kanbo columns flow`. They are for a person and will fail in your shell anyway.",
10796
11271
  "- Never delete or edit the board's database directly."
10797
11272
  ].join("\n");
10798
11273
  //#endregion
@@ -10837,7 +11312,7 @@ function registerInstructionsCommand(program, dependencies = {}) {
10837
11312
  const found = parseKind(kind);
10838
11313
  const body = await readInstructionText(found);
10839
11314
  const pointer = found === "short" || found === "global";
10840
- const text = options.markers ? wrapInstructionBlock(body, 2, pointer ? void 0 : found) : body;
11315
+ const text = options.markers ? wrapInstructionBlock(body, 3, pointer ? void 0 : found) : body;
10841
11316
  const hints = process.stderr.isTTY === true;
10842
11317
  if (hints) process.stderr.write(`${HEADER}\n\n`);
10843
11318
  console.log(text);
@@ -11244,7 +11719,7 @@ function readMoment(value) {
11244
11719
  const cardMoveTool = defineKanboTool({
11245
11720
  name: "kanbo_card_move",
11246
11721
  title: "Move a card",
11247
- description: `Move a card to another column the moment its real state changes. Read kanbo_columns first: a card in the wrong column is a lie, and a column with entryRules refuses a card that does not meet them yet, listing what is missing. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
11722
+ description: `Move a card to another column the moment its real state changes. Read kanbo_columns first: a card in the wrong column is a lie, and a column refuses a card that does not meet its entryRules yet, that comes from a column it does not accept (allowedFrom), or that would overfill it (wipLimit), listing what is missing. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
11248
11723
  inputSchema: {
11249
11724
  card: cardArgument,
11250
11725
  column: zod.z.string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`."),
@@ -11316,7 +11791,7 @@ const cardUpdateTool = defineKanboTool({
11316
11791
  const columnsTool = defineKanboTool({
11317
11792
  name: "kanbo_columns",
11318
11793
  title: "Columns of the board",
11319
- description: "List this board's columns in board order, each with the slug kanbo_card_move takes, what the column means here, and the entryRules a card must meet before you move it in.",
11794
+ description: "List this board's columns in board order, each with the slug kanbo_card_move takes, what the column means here, the entryRules a card must meet before you move it in, the columns it accepts cards from (allowedFrom, slugs; null for any) and the most cards it holds (wipLimit).",
11320
11795
  inputSchema: {},
11321
11796
  run: async (transport) => await transport.columns()
11322
11797
  });
@@ -11330,6 +11805,38 @@ const primeTool = defineKanboTool({
11330
11805
  inputSchema: {},
11331
11806
  run: async (transport) => (await transport.prime()).text
11332
11807
  });
11808
+ //#endregion
11809
+ //#region src/mcp/tools/card-claim.ts
11810
+ /**
11811
+ * Taking a card to work on, in one step: the run starts and the card moves out
11812
+ * of To Do into the board's first work-in-progress column in the same write,
11813
+ * so two agents never get the same card. Published to every host, including
11814
+ * one that records the runs of the agents it launches: a claim is how an agent
11815
+ * in any terminal takes work.
11816
+ */
11817
+ const cardClaimTool = defineKanboTool({
11818
+ name: "kanbo_card_claim",
11819
+ title: "Claim a card",
11820
+ 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.",
11821
+ inputSchema: {
11822
+ card: cardArgument.optional(),
11823
+ agent: zod.z.string().min(1).optional().describe("What to call whoever works the card; who this server writes as when absent.")
11824
+ },
11825
+ aliases: {
11826
+ card: [...CARD_ALIASES, "issue"],
11827
+ agent: ["agentName"]
11828
+ },
11829
+ example: { card: "TST-5" },
11830
+ run: async (transport, input) => {
11831
+ const claimed = await transport.cardClaim(input);
11832
+ return {
11833
+ card: compactKanboCard(claimed.card),
11834
+ run: claimed.run.id,
11835
+ column: claimed.column.slug,
11836
+ finishWith: `kanbo_run_finish {"run":"${claimed.run.id}","state":"finished"}`
11837
+ };
11838
+ }
11839
+ });
11333
11840
  /** The cards an agent may pick up right now. */
11334
11841
  const readyTool = defineKanboTool({
11335
11842
  name: "kanbo_ready",
@@ -11418,13 +11925,14 @@ const runStartTool = defineKanboTool({
11418
11925
  /**
11419
11926
  * Every tool the board has, in the order it declares them.
11420
11927
  *
11421
- * The list is the board's, not a server's: the same sixteen are registered
11928
+ * The list is the board's, not a server's: the same seventeen are registered
11422
11929
  * whether they run against a server or against the board directly, and the
11423
11930
  * only thing a host decides is which of them it publishes.
11424
11931
  */
11425
11932
  const KANBO_TOOLS = [
11426
11933
  primeTool,
11427
11934
  readyTool,
11935
+ cardClaimTool,
11428
11936
  columnsTool,
11429
11937
  defineKanboTool({
11430
11938
  name: "kanbo_sprints",
@@ -11556,7 +12064,8 @@ async function createDbTransport(input) {
11556
12064
  const workspaceId = workspace.id;
11557
12065
  const actor = input.actor;
11558
12066
  async function readColumns() {
11559
- return (await ops.listColumns(workspaceId)).map(projectKanboColumn);
12067
+ const rows = await ops.listColumns(workspaceId);
12068
+ return rows.map((row) => projectKanboColumn(row, rows));
11560
12069
  }
11561
12070
  /** One card with what the run registry says about it, in the shape a tool prints. */
11562
12071
  async function project(card, columns) {
@@ -11606,7 +12115,7 @@ async function createDbTransport(input) {
11606
12115
  * the waiting and returned ones last said at most two more.
11607
12116
  */
11608
12117
  async function projectPage(page, { attend = true } = {}) {
11609
- const columns = page.columns.map(projectKanboColumn);
12118
+ const columns = page.columns.map((column) => projectKanboColumn(column, page.columns));
11610
12119
  const runs = await ops.readBoardProjectionForIssues(page.cards.map((card) => card.id));
11611
12120
  const attention = attend ? await ops.readCardAttention(page.cards, runs.map((run) => run.activeRun !== null)) : [];
11612
12121
  return page.cards.map((card, index) => projectKanboCard({
@@ -11738,6 +12247,25 @@ async function createDbTransport(input) {
11738
12247
  state,
11739
12248
  errorText
11740
12249
  }, actor));
12250
+ },
12251
+ cardClaim: async ({ card, agent }) => {
12252
+ await session.assertWritable();
12253
+ const named = card === void 0 ? void 0 : (await resolveCard(card)).id;
12254
+ const claimed = await ops.claimCard({
12255
+ workspaceId,
12256
+ issueId: named,
12257
+ run: {
12258
+ agentName: agent ?? actor.id ?? "agent",
12259
+ externalSessionRef: sessionRefFromEnvironment() ?? void 0,
12260
+ launchedByKind: "external"
12261
+ }
12262
+ }, actor);
12263
+ const columns = await readColumns();
12264
+ return {
12265
+ card: await project(claimed.card, columns),
12266
+ run: projectKanboRun(claimed.run),
12267
+ column: columns.find((column) => column.id === claimed.column.id)
12268
+ };
11741
12269
  }
11742
12270
  };
11743
12271
  }
@@ -11779,7 +12307,8 @@ const TRANSPORT_METHODS = {
11779
12307
  statusLine: true,
11780
12308
  waitApproval: true,
11781
12309
  runStart: true,
11782
- runFinish: true
12310
+ runFinish: true,
12311
+ cardClaim: true
11783
12312
  };
11784
12313
  async function openMcpBoard(input) {
11785
12314
  try {
@@ -12385,7 +12914,7 @@ function sendBoardPage(response, pathname, assets) {
12385
12914
  * answering for a person — approving a card, sending it back or closing a
12386
12915
  * sprint — as anyone but one is 403, dropping a pull request link somebody else made is the same 403
12387
12916
  * for the same reason, and acting on a run that has already ended is 409,
12388
- * because the answer would have been yes a moment earlier. An agent putting a
12917
+ * because the answer would have been yes a moment earlier. Starting a run on a card that has one going, and claiming a card nobody can take right now, are 409 for the same reason. An agent putting a
12389
12918
  * card in a column whose entry rules it does not meet yet is 409 for the same
12390
12919
  * reason turned around: the answer will be yes once the card meets them, and
12391
12920
  * `details.unmet` says what is missing. Deleting a column that still holds
@@ -12403,6 +12932,14 @@ const BOARD_ERROR_RESPONSES = {
12403
12932
  message: "Only a person can approve a card",
12404
12933
  code: "issue_approval_requires_user"
12405
12934
  },
12935
+ board_card_not_ready: {
12936
+ status: 409,
12937
+ message: "That card is not there to take: it is not in To Do, waits for a person, or has a run going"
12938
+ },
12939
+ board_card_run_active: {
12940
+ status: 409,
12941
+ message: "The card already has a run going"
12942
+ },
12406
12943
  board_column_name_taken: {
12407
12944
  status: 400,
12408
12945
  message: "Another column already has that name"
@@ -12442,6 +12979,14 @@ const BOARD_ERROR_RESPONSES = {
12442
12979
  status: 400,
12443
12980
  message: "Execution mode cannot change once the card has been launched"
12444
12981
  },
12982
+ board_flow_rule_invalid: {
12983
+ status: 400,
12984
+ message: "A column accepts cards from other columns of its own board, and holds at least one card"
12985
+ },
12986
+ board_nothing_ready: {
12987
+ status: 409,
12988
+ message: "No card in To Do is ready to take"
12989
+ },
12445
12990
  board_pull_request_invalid: {
12446
12991
  status: 400,
12447
12992
  message: "Not a GitHub pull request: give its URL or owner/repo#number"
@@ -12857,6 +13402,14 @@ const startRunBody = zod.z.object({
12857
13402
  ]).optional(),
12858
13403
  externalSessionRef: zod.z.string().nullable().optional()
12859
13404
  });
13405
+ const claimBody = zod.z.object({
13406
+ workspaceId: id,
13407
+ issueId: id.optional(),
13408
+ agentName: id.optional(),
13409
+ branch: zod.z.string().nullable().optional(),
13410
+ executionMode: executionMode.optional(),
13411
+ externalSessionRef: zod.z.string().nullable().optional()
13412
+ });
12860
13413
  const finishRunBody = zod.z.object({
12861
13414
  state: zod.z.enum([
12862
13415
  "finished",
@@ -12903,7 +13456,8 @@ async function toWrittenCardView(ops, card) {
12903
13456
  function toStatusView(status) {
12904
13457
  return {
12905
13458
  ...status,
12906
- entryRules: readEntryRules(status)
13459
+ entryRules: readEntryRules(status),
13460
+ allowedFrom: readFlowRules(status).allowedFrom
12907
13461
  };
12908
13462
  }
12909
13463
  function toCommentView(comment, personWriterId = null) {
@@ -13338,6 +13892,31 @@ function createIssueRoutes(session, options = {}) {
13338
13892
  }, actors.placement));
13339
13893
  }
13340
13894
  }),
13895
+ route({
13896
+ method: "POST",
13897
+ path: "/issues/claim",
13898
+ body: claimBody,
13899
+ write: true,
13900
+ handle: async ({ body, actors }) => {
13901
+ const named = requireWorkspace(body.workspaceId);
13902
+ if (body.issueId !== void 0) await requireCard(body.issueId);
13903
+ const claimed = await ops.claimCard({
13904
+ workspaceId: named,
13905
+ issueId: body.issueId,
13906
+ run: {
13907
+ agentName: body.agentName ?? actors.writer.id ?? "agent",
13908
+ branch: body.branch,
13909
+ executionMode: body.executionMode,
13910
+ externalSessionRef: body.externalSessionRef
13911
+ }
13912
+ }, actors.placement);
13913
+ return {
13914
+ card: await toWrittenCardView(ops, claimed.card),
13915
+ run: await toRunView(claimed.run),
13916
+ column: toStatusView(claimed.column)
13917
+ };
13918
+ }
13919
+ }),
13341
13920
  route({
13342
13921
  method: "PATCH",
13343
13922
  path: "/issues/bulk",
@@ -14472,6 +15051,7 @@ function registerKanboCommands(program, context = {}) {
14472
15051
  registerServeCommand(program);
14473
15052
  program.commandsGroup("For agents and integrations:");
14474
15053
  registerMcpCommand(program);
15054
+ registerClaimCommand(program);
14475
15055
  registerRunCommands(program);
14476
15056
  registerCapabilitiesCommand(program);
14477
15057
  program.commandsGroup("Shared Postgres boards:");
@@ -14495,6 +15075,7 @@ function registerKanboCommands(program, context = {}) {
14495
15075
  const KANBO_TOOL_NAMES = [
14496
15076
  "kanbo_prime",
14497
15077
  "kanbo_ready",
15078
+ "kanbo_card_claim",
14498
15079
  "kanbo_columns",
14499
15080
  "kanbo_sprints",
14500
15081
  "kanbo_card_get",
@@ -14520,6 +15101,7 @@ const KANBO_TOOL_NAMES = [
14520
15101
  const KANBO_TOOL_CLI_EQUIVALENTS = {
14521
15102
  kanbo_prime: { kanbo: "kanbo prime" },
14522
15103
  kanbo_ready: { kanbo: "kanbo ready" },
15104
+ kanbo_card_claim: { kanbo: "kanbo claim [card] [--agent <name>]" },
14523
15105
  kanbo_columns: { kanbo: "kanbo columns list" },
14524
15106
  kanbo_sprints: { kanbo: "kanbo sprint list" },
14525
15107
  kanbo_card_get: { kanbo: "kanbo card get <id>" },
@@ -14667,10 +15249,12 @@ function buildCapabilitiesManifest() {
14667
15249
  "Only a person may close a sprint. `kanbo sprint close` refuses an agent's shell (exit 4), and the operation refuses any actor but a person; there is no MCP tool that closes one — `kanbo_sprints` only reads. Unlike approval, the database role does not enforce this.",
14668
15250
  `The agent role (\`${require_roles_sql.BOARD_AGENT_ROLE}\`) may not delete a card at all — it holds select, insert and update on \`issues\` and nothing more, even though it may delete freely on every other board table.`,
14669
15251
  "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.",
15252
+ "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.",
15253
+ "A claim (`kanbo claim`, `kanbo_card_claim`, `POST /issues/claim`) takes a ready card from To Do, starts its run and moves it into the board's first `started` column in one write; two claimers never get the same card, and the one that loses is told `board_nothing_ready` or `board_card_not_ready`. A card has one run going at a time: starting a second is refused with `board_card_run_active`, on every path.",
14670
15254
  "Only a person changes the board's columns: `kanbo columns add`, `rename`, `move`, `remove`, `template` and `add-standard` refuse an agent's shell (exit 4), and the rename, move, remove and add-missing operations refuse any actor but a person (`board_column_structure_requires_user`); no MCP tool changes columns. To Do can be neither removed nor renamed away from its slug `to_do`: `kanbo ready` takes work from it.",
14671
15255
  "No board tool or command deletes a card, agent role or not. Cancelling one is a move to another column. The one exception is `kanbo serve`'s `DELETE /issues/:id`, which it answers only for a request presenting its token, on a server a person started, that has not sent `x-kanbo-actor: agent` (403 `issue_delete_requires_user` otherwise).",
14672
15256
  "`kanbo migrate` and `kanbo roles apply` refuse a host app's own database file: the app migrates it, and this package never touches it.",
14673
- "A write to a board older than this build's schema epoch is refused (exit 3) until `kanbo migrate` runs (on a host app's database, until the app migrates it) — reads stay open either way.",
15257
+ "A write to a board older than this build's schema epoch is refused (exit 3) until `kanbo migrate` runs (on a host app's database, until the app migrates it), and so is a read of a board file that lacks a table or a column this build reads; a board whose only difference is its schema stamp stays readable.",
14674
15258
  "There is no MCP tool that approves a card, and there never will be: `kanbo_wait_approval` hands the card to a person and ends the turn; only a person takes it back."
14675
15259
  ]
14676
15260
  };
@@ -14760,6 +15344,12 @@ Object.defineProperty(exports, "ENTRY_RULE_DESCRIPTIONS", {
14760
15344
  return ENTRY_RULE_DESCRIPTIONS;
14761
15345
  }
14762
15346
  });
15347
+ Object.defineProperty(exports, "FLOW_RULES", {
15348
+ enumerable: true,
15349
+ get: function() {
15350
+ return FLOW_RULES;
15351
+ }
15352
+ });
14763
15353
  Object.defineProperty(exports, "KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI", {
14764
15354
  enumerable: true,
14765
15355
  get: function() {
@@ -15090,6 +15680,12 @@ Object.defineProperty(exports, "readEntryRules", {
15090
15680
  return readEntryRules;
15091
15681
  }
15092
15682
  });
15683
+ Object.defineProperty(exports, "readFlowRules", {
15684
+ enumerable: true,
15685
+ get: function() {
15686
+ return readFlowRules;
15687
+ }
15688
+ });
15093
15689
  Object.defineProperty(exports, "readIssuePrefix", {
15094
15690
  enumerable: true,
15095
15691
  get: function() {