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
package/dist/cli-main.cjs CHANGED
@@ -272,6 +272,14 @@ const BOARD_ERROR_TEXTS = {
272
272
  text: "Only a person can approve a card.",
273
273
  next: AS_A_PERSON
274
274
  }),
275
+ board_card_not_ready: (details) => ({
276
+ 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.`,
277
+ next: "kanbo ready"
278
+ }),
279
+ board_card_run_active: (details) => ({
280
+ text: `${named(details.issueId)} already has a run going: ${named(details.agentName)}'s, run ${named(details.runId)}.`,
281
+ next: `kanbo run finish ${named(details.runId)} --state stopped`
282
+ }),
275
283
  board_column_name_taken: (details) => ({
276
284
  text: `Another column, ${named(details.takenBy)}, already has that name (same slug). Pick another name.`,
277
285
  next: "kanbo columns list"
@@ -302,6 +310,14 @@ const BOARD_ERROR_TEXTS = {
302
310
  }),
303
311
  board_entry_rule_invalid: (details) => ({ text: `Unknown column rule: ${list(details.rules)}. Use any of ${list(details.allowed)}.` }),
304
312
  board_execution_mode_locked: (details) => ({ text: `${named(details.issueId)} has been started, so where it is worked (${named(details.executionMode)}) can't change now.` }),
313
+ 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.` } : {
314
+ text: `A column accepts cards only from other columns of its own board, and ${list(details.allowedFrom)} is not one.`,
315
+ next: "kanbo columns list"
316
+ },
317
+ board_nothing_ready: () => ({
318
+ text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
319
+ next: "kanbo ready"
320
+ }),
305
321
  board_pull_request_invalid: (details) => ({ text: `"${named(details.value)}" is not a GitHub pull request. Give its link or owner/repo#number.` }),
306
322
  board_pull_request_not_found: (details) => ({
307
323
  text: `${named(details.issueId)} has no pull request link "${named(details.linkId)}".`,
@@ -367,7 +383,7 @@ const BOARD_ERROR_TEXTS = {
367
383
  issue_relation_self_reference: () => ({ text: "A card can't be linked to itself." }),
368
384
  issue_status_name_empty: () => ({ text: "Give the column a name." }),
369
385
  issue_status_not_found: (details) => ({
370
- text: `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
386
+ 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
387
  next: "kanbo columns list"
372
388
  }),
373
389
  issue_status_reference_conflict: () => ({ text: "Name the column once: by its id or by its name, not both." }),
@@ -3561,7 +3577,7 @@ new Command();
3561
3577
  * `package.version`. A build carries it as a literal; running from source
3562
3578
  * (tests, `tsx`) reads the package's own `package.json` instead.
3563
3579
  */
3564
- const KANBO_PACKAGE_VERSION = "0.4.0";
3580
+ const KANBO_PACKAGE_VERSION = "0.5.0";
3565
3581
  //#endregion
3566
3582
  //#region src/cli/agent-shell.ts
3567
3583
  /**
@@ -3700,6 +3716,7 @@ const HUMAN_ONLY_ACTIONS = [
3700
3716
  "return",
3701
3717
  "sprint close",
3702
3718
  "columns rules",
3719
+ "columns flow",
3703
3720
  "columns add",
3704
3721
  "columns rename",
3705
3722
  "columns move",
@@ -3713,6 +3730,7 @@ const HUMAN_ONLY_MESSAGES = {
3713
3730
  "return": describeReturnRefusal,
3714
3731
  "sprint close": describeSprintCloseRefusal,
3715
3732
  "columns rules": describeColumnRulesRefusal,
3733
+ "columns flow": describeColumnRulesRefusal,
3716
3734
  "columns add": describeColumnChangesRefusal,
3717
3735
  "columns rename": describeColumnChangesRefusal,
3718
3736
  "columns move": describeColumnChangesRefusal,
@@ -10014,6 +10032,14 @@ const HIDDEN_ACTIVITY_FIELDS = /* @__PURE__ */ new Set([
10014
10032
  "order",
10015
10033
  "waitingFor"
10016
10034
  ]);
10035
+ /**
10036
+ * The column a card was created in, recorded as a change from no column at the
10037
+ * card's own creation time. The feed's `created` item already stands for it,
10038
+ * and the field history keeps it.
10039
+ */
10040
+ function isStartingColumn(change, issue) {
10041
+ return change.field === "statusId" && change.fromValue === null && change.createdAt === issue.createdAt;
10042
+ }
10017
10043
  const ACTIVITY_FIELD_BY_RAW_FIELD = {
10018
10044
  assigneeId: "assignee",
10019
10045
  description: "description",
@@ -10054,7 +10080,7 @@ async function listActivity(store, issueId, resolver) {
10054
10080
  workspaceById: resolver.workspaceById
10055
10081
  };
10056
10082
  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);
10083
+ 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
10084
  return [
10059
10085
  {
10060
10086
  id: `${issue.id}:created`,
@@ -10678,6 +10704,63 @@ function describeColumnRefusal(details) {
10678
10704
  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");
10679
10705
  }
10680
10706
  //#endregion
10707
+ //#region src/domain/flow-rules.ts
10708
+ /** The column the standard flow guards, and the columns it lets cards in from, by slug. */
10709
+ const STANDARD_FLOW_TARGET = "done";
10710
+ const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
10711
+ /** A column's flow rules, out of what the row stores. */
10712
+ function readFlowRules(column) {
10713
+ return {
10714
+ allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
10715
+ wipLimit: column.wipLimit
10716
+ };
10717
+ }
10718
+ /** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
10719
+ function serializeAllowedFrom(allowedFrom) {
10720
+ return allowedFrom === null ? null : JSON.stringify(allowedFrom);
10721
+ }
10722
+ /**
10723
+ * The flow rules a card entering `column` does not meet, where it may come from
10724
+ * first. Pure: what the board holds arrives in `facts`. A card being created
10725
+ * comes from no column, so a column that names where cards come from does not
10726
+ * take it.
10727
+ */
10728
+ function evaluateFlowRules(card, column, rules, facts) {
10729
+ const unmet = [];
10730
+ if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
10731
+ rule: "allowed_from",
10732
+ reason: "not_allowed_from",
10733
+ detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
10734
+ });
10735
+ if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
10736
+ rule: "wip_limit",
10737
+ reason: "column_full",
10738
+ detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
10739
+ });
10740
+ return unmet;
10741
+ }
10742
+ function describeAllowedFrom(name, allowedFrom, columnNames) {
10743
+ const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
10744
+ 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`;
10745
+ }
10746
+ /** `A`, `A and B`, `A, B and C`. */
10747
+ function joinWords(words) {
10748
+ return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
10749
+ }
10750
+ /**
10751
+ * The standard flow on these columns: Done, and the columns it takes cards from
10752
+ * — In Review, and QA when there is one — in board order. `null` when the board
10753
+ * has no Done, or neither of the two.
10754
+ */
10755
+ function standardFlowOf(columns) {
10756
+ const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
10757
+ const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
10758
+ return target && sources.length > 0 ? {
10759
+ target,
10760
+ sources
10761
+ } : null;
10762
+ }
10763
+ //#endregion
10681
10764
  //#region src/ops/columns.ts
10682
10765
  /** The columns of one workspace, in board order. */
10683
10766
  async function listColumns(store, workspaceId) {
@@ -10747,6 +10830,7 @@ async function applyColumnTemplate(store, workspaceId, columns, options, scope)
10747
10830
  workspaceId,
10748
10831
  ...column
10749
10832
  }, order);
10833
+ await writeStandardFlow(tx, await listColumns(tx, workspaceId));
10750
10834
  return {
10751
10835
  statuses: await listColumns(tx, workspaceId),
10752
10836
  added: columns.map((column) => column.name)
@@ -10856,6 +10940,77 @@ async function setColumnEntryRules(store, workspaceId, nameOrId, entryRules, act
10856
10940
  return await updateColumn(tx, (await requireColumn(tx, workspaceId, nameOrId)).id, { entryRules }, actor, { tx });
10857
10941
  }, scope);
10858
10942
  }
10943
+ /** The largest WIP limit: what a Postgres `integer` column holds. */
10944
+ const MAX_WIP_LIMIT = 2147483647;
10945
+ /**
10946
+ * Set where a card may enter the column from and how many cards it holds:
10947
+ * `allowedFrom` the ids of other columns of its board (`null` for any, `[]` for
10948
+ * none), kept once each in board order; `wipLimit` a whole number from 1 to 2147483647, or `null`. A
10949
+ * person's to set, like the entry rules: an agent that could open a column
10950
+ * could walk any card into it.
10951
+ */
10952
+ async function setColumnFlowRules(store, columnId, rules, actor, scope) {
10953
+ assertPersonSetsEntryRules(actor, { statusId: columnId });
10954
+ if (rules.wipLimit !== null && !(Number.isSafeInteger(rules.wipLimit) && rules.wipLimit >= 1 && rules.wipLimit <= MAX_WIP_LIMIT)) throw new BoardError("board_flow_rule_invalid", {
10955
+ statusId: columnId,
10956
+ wipLimit: rules.wipLimit
10957
+ });
10958
+ return await runBoardWrite(store, async ({ tx }) => {
10959
+ const column = await tx.statuses.findById(columnId);
10960
+ if (!column) throw new BoardError("issue_status_not_found", { statusId: columnId });
10961
+ const allowedFrom = rules.allowedFrom === null ? null : allowedFromInBoardOrder(await listColumns(tx, column.workspaceId), column, rules.allowedFrom);
10962
+ await tx.statuses.update(columnId, {
10963
+ allowedFrom: serializeAllowedFrom(allowedFrom),
10964
+ wipLimit: rules.wipLimit
10965
+ });
10966
+ return await tx.statuses.findById(columnId);
10967
+ }, scope);
10968
+ }
10969
+ /** The ids asked for, once each and in board order — or the refusal naming those that are not another column of this board. */
10970
+ function allowedFromInBoardOrder(columns, column, requested) {
10971
+ const others = columns.filter((other) => other.id !== column.id);
10972
+ const invalid = requested.filter((id) => !others.some((other) => other.id === id));
10973
+ if (invalid.length > 0) throw new BoardError("board_flow_rule_invalid", {
10974
+ statusId: column.id,
10975
+ allowedFrom: invalid
10976
+ });
10977
+ return others.filter((other) => requested.includes(other.id)).map((other) => other.id);
10978
+ }
10979
+ /**
10980
+ * Put the standard flow on a board: Done accepts cards from In Review only —
10981
+ * and from QA, when the board has it — so nothing reaches Done but through
10982
+ * review. Done's WIP limit and every other column stay as they are. A board
10983
+ * without Done, or with neither In Review nor QA, has nothing to apply it to.
10984
+ */
10985
+ async function applyStandardFlow(store, workspaceId, actor, scope) {
10986
+ assertPersonSetsEntryRules(actor, { workspaceId });
10987
+ return await runBoardWrite(store, async ({ tx }) => {
10988
+ const columns = await listColumns(tx, workspaceId);
10989
+ if (!await writeStandardFlow(tx, columns)) throw new BoardError("issue_status_not_found", {
10990
+ workspaceId,
10991
+ statusName: columns.some((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET) ? "In Review" : "Done"
10992
+ });
10993
+ return await listColumns(tx, workspaceId);
10994
+ }, scope);
10995
+ }
10996
+ /** Done takes cards from the board's In Review and QA; `false`, with nothing written, when the board has no such columns. */
10997
+ async function writeStandardFlow(store, columns) {
10998
+ const flow = standardFlowOf(columns);
10999
+ if (!flow) return false;
11000
+ await store.statuses.update(flow.target.id, { allowedFrom: serializeAllowedFrom(flow.sources.map((column) => column.id)) });
11001
+ return true;
11002
+ }
11003
+ /**
11004
+ * Take a deleted column out of every list of where cards may come from. A list
11005
+ * it empties stays empty: a column that took cards only from it does not open
11006
+ * to every column.
11007
+ */
11008
+ async function dropFromAllowedFrom(store, workspaceId, statusId) {
11009
+ for (const other of await listColumns(store, workspaceId)) {
11010
+ const { allowedFrom } = readFlowRules(other);
11011
+ if (allowedFrom?.includes(statusId)) await store.statuses.update(other.id, { allowedFrom: serializeAllowedFrom(allowedFrom.filter((id) => id !== statusId)) });
11012
+ }
11013
+ }
10859
11014
  /**
10860
11015
  * Take an empty column off the board, and number the columns left 0..n-1 in
10861
11016
  * the same write.
@@ -10882,6 +11037,7 @@ async function deleteColumn(store, statusId, scope, actor) {
10882
11037
  hint: "Move its cards to another column first, or remove it with removeColumn and moveCardsTo."
10883
11038
  });
10884
11039
  await tx.statuses.delete(statusId);
11040
+ await dropFromAllowedFrom(tx, column.workspaceId, statusId);
10885
11041
  await renumberColumns(tx, column.workspaceId);
10886
11042
  }, scope);
10887
11043
  }
@@ -11161,24 +11317,46 @@ function pullRequestFactKeys({ owner, repo, number }) {
11161
11317
  //#endregion
11162
11318
  //#region src/ops/entry-rules.ts
11163
11319
  /**
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.
11320
+ * The column's entry rules and flow rules the card does not meet, entry rules
11321
+ * first (ruling 6-2; board-workflow spec, Part 2). A column with no rules
11322
+ * answers at once, without reading anything; otherwise only the facts its
11323
+ * rules need are read. `entering` counts the cards this same write already let
11324
+ * into the column, so a bulk move is held to the WIP limit as a whole.
11167
11325
  */
11168
- async function checkColumnEntry(store, card, column) {
11326
+ async function checkColumnEntry(store, card, column, entering) {
11169
11327
  const rules = readEntryRules(column);
11170
- if (rules.length === 0) return [];
11171
- return evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
11328
+ const unmet = rules.length === 0 ? [] : evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
11329
+ const flow = readFlowRules(column);
11330
+ if (flow.allowedFrom === null && flow.wipLimit === null) return unmet;
11331
+ return [...unmet, ...evaluateFlowRules(card, column, flow, await readFlowRuleFacts(store, column, flow, entering))];
11332
+ }
11333
+ /** 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. */
11334
+ async function readFlowRuleFacts(store, column, flow, entering) {
11335
+ const columnNames = flow.allowedFrom === null ? /* @__PURE__ */ new Map() : new Map((await store.statuses.listByWorkspace(column.workspaceId)).map((other) => [other.id, other.name]));
11336
+ if (flow.wipLimit === null) return {
11337
+ columnNames,
11338
+ cardsInColumn: 0
11339
+ };
11340
+ await store.statuses.lockById(column.id);
11341
+ return {
11342
+ columnNames,
11343
+ cardsInColumn: (await store.issues.listPage({
11344
+ workspaceId: column.workspaceId,
11345
+ statusIds: [column.id],
11346
+ limit: 1
11347
+ })).total + entering
11348
+ };
11172
11349
  }
11173
11350
  /**
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.
11351
+ * Hold a card at the door of a column that asks for more than it has — entry
11352
+ * rules (ruling 6-3), or flow rules: a column it may not come from, or a column
11353
+ * that is full. Anyone but a person — or a person's own terminal,
11354
+ * `placesForPerson` (ruling 6-6) — is refused with `board_column_rules_unmet`,
11355
+ * which names the column and everything unmet; a person is let through, and
11356
+ * the unmet list comes back so the caller can warn them.
11179
11357
  */
11180
- async function enforceColumnEntry(store, card, column, actor) {
11181
- const unmet = await checkColumnEntry(store, card, column);
11358
+ async function enforceColumnEntry(store, card, column, actor, entering = 0) {
11359
+ const unmet = await checkColumnEntry(store, card, column, entering);
11182
11360
  if (unmet.length > 0 && actor.kind !== "user" && !actor.placesForPerson) throw new BoardError("board_column_rules_unmet", {
11183
11361
  issueId: card.id,
11184
11362
  column: column.name,
@@ -11191,14 +11369,17 @@ async function enforceColumnEntry(store, card, column, actor) {
11191
11369
  * The same door for several cards entering one column at once — a bulk status
11192
11370
  * change. All or nothing: the first card an agent may not move refuses the
11193
11371
  * whole change. A person gets back the cards that entered with rules unmet.
11372
+ * Each card counts against the column's WIP limit as the cards before it in the same change entered it.
11194
11373
  */
11195
11374
  async function enforceColumnEntryForCards(store, issueIds, statusId, actor) {
11196
11375
  const column = await store.statuses.findById(statusId);
11197
11376
  if (!column) throw new BoardError("issue_status_not_found", { statusId });
11198
11377
  const entered = [];
11378
+ let entering = 0;
11199
11379
  for (const card of await store.issues.listByIds(issueIds)) {
11200
11380
  if (card.statusId === column.id) continue;
11201
- const unmet = await enforceColumnEntry(store, card, column, actor);
11381
+ const unmet = await enforceColumnEntry(store, card, column, actor, entering);
11382
+ entering++;
11202
11383
  if (unmet.length > 0) entered.push({
11203
11384
  issueId: card.id,
11204
11385
  unmet
@@ -11353,11 +11534,24 @@ const STOPPED_BY_YOU_STATUS_LINE = "stopped by you";
11353
11534
  * the board reads the same whether the agent was launched by an app or from
11354
11535
  * somewhere else entirely. The attempt number is simply how many runs the card
11355
11536
  * has had — a card whose runs were all launched elsewhere still counts them.
11537
+ *
11538
+ * A card has one run going at a time. A start on a card whose run is still going
11539
+ * is refused with `board_card_run_active`, naming that run — whichever path it
11540
+ * came by, an app's launch, `kanbo run start`, MCP, or a second app on a shared
11541
+ * Postgres board. The card's row is locked first, so two starts racing for it
11542
+ * are settled one after the other.
11356
11543
  */
11357
11544
  async function startRun(store, issueId, input, actor, scope) {
11358
11545
  const externalSessionRef = readExternalSessionRef(input.externalSessionRef) ?? null;
11359
11546
  return await runBoardWrite(store, async ({ tx }) => {
11360
- const card = await requireCard$1(tx, issueId);
11547
+ const card = await tx.issues.lockById(issueId);
11548
+ if (!card) throw new BoardError("issue_not_found", { issueId });
11549
+ const [active] = await tx.runs.listRunning(issueId);
11550
+ if (active) throw new BoardError("board_card_run_active", {
11551
+ issueId,
11552
+ runId: active.id,
11553
+ agentName: active.agentName
11554
+ });
11361
11555
  const attempt = await tx.runs.countByIssue(issueId) + 1;
11362
11556
  const executionMode = input.executionMode ?? card.executionMode;
11363
11557
  const run = await tx.runs.create({
@@ -11623,7 +11817,8 @@ async function createCard(store, input, actor, scope) {
11623
11817
  const column = statusId === null ? null : await tx.statuses.findById(statusId);
11624
11818
  const unmet = column ? await enforceColumnEntry(tx, {
11625
11819
  id: null,
11626
- description: input.description ?? null
11820
+ description: input.description ?? null,
11821
+ statusId: null
11627
11822
  }, column, actor) : [];
11628
11823
  const milestoneId = await requireMilestoneInWorkspace(tx, workspaceId, input.milestoneId ?? null);
11629
11824
  const parentIssueId = await requireParentInWorkspace(tx, workspaceId, input.parentIssueId ?? null);
@@ -11631,8 +11826,9 @@ async function createCard(store, input, actor, scope) {
11631
11826
  const now = currentUnixSeconds();
11632
11827
  for (let attempt = 1;; attempt++) {
11633
11828
  const identity = await nextIssueIdentity(input.workspace, tx);
11829
+ let created;
11634
11830
  try {
11635
- return withUnmetRules(await tx.issues.create({
11831
+ created = await tx.issues.create({
11636
11832
  id: identity.id,
11637
11833
  workspaceId,
11638
11834
  title: input.title?.trim() || identity.id,
@@ -11656,12 +11852,25 @@ async function createCard(store, input, actor, scope) {
11656
11852
  order,
11657
11853
  createdAt: now,
11658
11854
  updatedAt: now
11659
- }), unmet);
11855
+ });
11660
11856
  } catch (error) {
11661
11857
  const numberTaken = await tx.issues.findByNumber(workspaceId, identity.number);
11662
11858
  const keyTaken = await tx.issues.findById(identity.id);
11663
11859
  if (attempt >= NUMBERING_ATTEMPTS || !numberTaken && !keyTaken) throw error;
11860
+ continue;
11664
11861
  }
11862
+ if (statusId !== null) await tx.fieldChanges.create({
11863
+ id: (0, node_crypto.randomUUID)(),
11864
+ issueId: created.id,
11865
+ field: "statusId",
11866
+ fromValue: null,
11867
+ toValue: statusId,
11868
+ actorKind: toStoredActorKind(actor),
11869
+ actorId: actor.id ?? null,
11870
+ sourceChatSessionId: actor.chatSessionId ?? null,
11871
+ createdAt: now
11872
+ });
11873
+ return withUnmetRules(created, unmet);
11665
11874
  }
11666
11875
  }, scope);
11667
11876
  }
@@ -11717,7 +11926,8 @@ async function updateCard(store, issueId, input, actor, scope) {
11717
11926
  const description = "description" in updates ? updates.description ?? null : card.description;
11718
11927
  unmet = await enforceColumnEntry(tx, {
11719
11928
  id: card.id,
11720
- description
11929
+ description,
11930
+ statusId: card.statusId
11721
11931
  }, column, actor);
11722
11932
  }
11723
11933
  if ("assigneeKind" in input) updates.assigneeKind = input.assigneeKind ?? null;
@@ -12116,6 +12326,160 @@ async function reorderCards(store, workspaceId, orderedIds, scope) {
12116
12326
  }, scope);
12117
12327
  }
12118
12328
  //#endregion
12329
+ //#region src/ops/ready.ts
12330
+ /** The column an agent takes its next card from. */
12331
+ const READY_COLUMN_SLUG = "to_do";
12332
+ /**
12333
+ * The cards an agent may pick up right now: spelled out, nobody working on them,
12334
+ * nobody's turn but the agent's.
12335
+ *
12336
+ * "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
12337
+ * run is taken, and a card waiting for a person belongs to that person until
12338
+ * they answer. What is left is the queue — in board order, so the card someone
12339
+ * dragged to the top is the one an agent asking for work gets.
12340
+ */
12341
+ async function listReady(store, input) {
12342
+ return (await queryReady(store, input)).cards;
12343
+ }
12344
+ /**
12345
+ * One page of the ready cards, how many are ready in all, and the columns read
12346
+ * on the way — the rule above, asked of the database in one statement rather
12347
+ * than of every card on the board.
12348
+ */
12349
+ async function queryReady(store, input) {
12350
+ const columns = await listColumns(store, input.workspaceId);
12351
+ const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
12352
+ if (!column) return {
12353
+ cards: [],
12354
+ total: 0,
12355
+ columns
12356
+ };
12357
+ return {
12358
+ ...await store.issues.listPage({
12359
+ workspaceId: input.workspaceId,
12360
+ statusIds: [column.id],
12361
+ waitingForPerson: false,
12362
+ hasActiveRun: false,
12363
+ limit: input.limit,
12364
+ offset: input.offset
12365
+ }),
12366
+ columns
12367
+ };
12368
+ }
12369
+ //#endregion
12370
+ //#region src/ops/claim.ts
12371
+ /**
12372
+ * Take a card to work on, in one board write: pick it, start its run, and move
12373
+ * it into the board's first `started` column (In Progress on a standard
12374
+ * board), its entry and flow rules checked on the way in. Anything refused
12375
+ * leaves nothing written.
12376
+ *
12377
+ * Two claimers never get the same card. On SQLite the write holds the file
12378
+ * from its first read (`begin immediate`), so the second claimer reads the
12379
+ * board after the first one's commit. On Postgres the picked card's row is
12380
+ * locked (`select … for update`): a claimer that waited on the lock finds the
12381
+ * card taken, and moves on to the next ready one. The target's row is locked
12382
+ * first, always, in the order a move into it locks (column, then card), so a
12383
+ * claim and a move never wait on each other in a circle.
12384
+ */
12385
+ async function claimCard(store, input, actor, scope) {
12386
+ return await runBoardWrite(store, async ({ tx }) => {
12387
+ const columns = await listColumns(tx, input.workspaceId);
12388
+ const target = columns.find((column) => column.category === "started");
12389
+ if (!target) throw new BoardError("issue_status_not_found", {
12390
+ workspaceId: input.workspaceId,
12391
+ category: "started"
12392
+ });
12393
+ await tx.statuses.lockById(target.id);
12394
+ const toDo = columns.find((column) => normalizeStatusName(column.name) === "to_do") ?? null;
12395
+ const card = input.issueId === void 0 ? await takeFirstReady(tx, input.workspaceId, toDo) : await takeNamed(tx, input.workspaceId, input.issueId, toDo);
12396
+ const run = await startRun(tx, card.id, input.run, actor, { tx });
12397
+ return {
12398
+ card: await updateCard(tx, card.id, { statusId: target.id }, actor, { tx }),
12399
+ run,
12400
+ column: target
12401
+ };
12402
+ }, scope);
12403
+ }
12404
+ /**
12405
+ * The first ready card, locked. A card another claimer took while this one
12406
+ * waited for its lock is passed over, and the next read no longer lists it; the
12407
+ * cards passed over are counted so the loop ends with the list.
12408
+ */
12409
+ async function takeFirstReady(store, workspaceId, toDo) {
12410
+ const passed = /* @__PURE__ */ new Set();
12411
+ for (;;) {
12412
+ const { cards } = await queryReady(store, {
12413
+ workspaceId,
12414
+ limit: passed.size + 1
12415
+ });
12416
+ const candidate = cards.find((card) => !passed.has(card.id));
12417
+ if (!candidate) throw new BoardError("board_nothing_ready", { workspaceId });
12418
+ const locked = await store.issues.lockById(candidate.id);
12419
+ if (locked && await notReadyReason(store, locked, toDo) === null) return locked;
12420
+ passed.add(candidate.id);
12421
+ }
12422
+ }
12423
+ /** The named card, locked, when it is on this board and ready — or why not. */
12424
+ async function takeNamed(store, workspaceId, issueId, toDo) {
12425
+ const card = await store.issues.lockById(issueId);
12426
+ if (!card || card.workspaceId !== workspaceId) throw new BoardError("issue_not_found", { issueId });
12427
+ const reason = await notReadyReason(store, card, toDo);
12428
+ if (reason) throw new BoardError("board_card_not_ready", {
12429
+ issueId,
12430
+ reason
12431
+ });
12432
+ return card;
12433
+ }
12434
+ /** `queryReady`'s rule for one card already read: nobody on it, nobody waited for, in To Do. */
12435
+ async function notReadyReason(store, card, toDo) {
12436
+ if ((await store.runs.listRunning(card.id)).length > 0) return "run_active";
12437
+ if (card.waitingFor === "human") return "waiting_for_person";
12438
+ return toDo !== null && card.statusId === toDo.id ? null : "not_in_to_do";
12439
+ }
12440
+ //#endregion
12441
+ //#region src/ops/column-entries.ts
12442
+ /**
12443
+ * Every entry into a column on the named boards at or after `since`, in order
12444
+ * of `(at, changeId)` — the one signal an app dispatching work on column
12445
+ * changes needs. It sees every writer, an outside `kanbo` on the same database
12446
+ * included, because it reads the cards' own history.
12447
+ *
12448
+ * A page never ends inside a second: when `limit` cuts into one, the rest of
12449
+ * that second comes too, so a page may be longer than `limit`. A reader keeps
12450
+ * its cursor at the last `at` it read after a short page — a write in that
12451
+ * second may still commit — and drops the `changeId`s it has seen; after a page
12452
+ * of `limit` or more it moves on to the last `at` + 1, which a second holding
12453
+ * more entries than `limit` would otherwise never let it pass.
12454
+ */
12455
+ async function listColumnEntries(store, input) {
12456
+ const page = await store.fieldChanges.listByFieldSince("statusId", input);
12457
+ return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
12458
+ }
12459
+ /** The page with every change of its last second, not only those `limit` let in. */
12460
+ async function withWholeLastSecond(store, input, page) {
12461
+ const last = page.at(-1).createdAt;
12462
+ const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
12463
+ workspaceIds: input.workspaceIds,
12464
+ since: last,
12465
+ until: last
12466
+ });
12467
+ return [...page.filter((change) => change.createdAt < last), ...lastSecond];
12468
+ }
12469
+ /** A status change as an entry; a change that took the card out of every column enters none. */
12470
+ function toColumnEntry(change) {
12471
+ return change.toValue === null ? [] : [{
12472
+ changeId: change.id,
12473
+ issueId: change.issueId,
12474
+ workspaceId: change.workspaceId,
12475
+ fromStatusId: change.fromValue,
12476
+ toStatusId: change.toValue,
12477
+ actorKind: change.actorKind,
12478
+ actorId: change.actorId,
12479
+ at: change.createdAt
12480
+ }];
12481
+ }
12482
+ //#endregion
12119
12483
  //#region src/ops/context-refs.ts
12120
12484
  const ContextRefsJsonSchema = string().transform((raw) => JSON.parse(raw)).pipe(array(string()));
12121
12485
  /** Add a context ref at the end of the card's list, recording the change in its history. */
@@ -12300,6 +12664,7 @@ function subtaskSplitSentence(parentReference) {
12300
12664
  }
12301
12665
  const SUBTASK_RULE = `${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("the parent card's id")}`;
12302
12666
  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.";
12667
+ 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
12668
  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
12669
  /** Said by the `kanbo init` block and the MCP instructions, which name the comment command or tool beside it. */
12305
12670
  const COMMENT_RULE = "Findings, results, decisions and questions go on the card as comments.";
@@ -12323,6 +12688,7 @@ const BOARD_RULES = [
12323
12688
  EXECUTION_MODE_RULE,
12324
12689
  SUBTASK_RULE,
12325
12690
  PULL_REQUEST_RULE,
12691
+ CLAIM_RULE,
12326
12692
  OWN_SESSION_RULE,
12327
12693
  "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
12694
  ];
@@ -12338,12 +12704,14 @@ const BOARD_RULES = [
12338
12704
  async function buildPrimeText(store, workspaceId) {
12339
12705
  const columns = await listColumns(store, workspaceId);
12340
12706
  const ruled = columns.some((column) => readEntryRules(column).length > 0);
12707
+ const flowing = columns.some((column) => hasFlowRules(column));
12341
12708
  return [
12342
12709
  ...await describeReturnedCards(store, workspaceId, columns),
12343
12710
  "Columns of this board, in board order:",
12344
12711
  "",
12345
- ...columns.length > 0 ? columns.map(describeColumnLine) : ["- (this board has no columns yet)"],
12712
+ ...columns.length > 0 ? columns.map((column) => describeColumnLine(column, columns)) : ["- (this board has no columns yet)"],
12346
12713
  ...ruled ? ["", ENTRY_RULES_NOTE] : [],
12714
+ ...flowing ? ["", FLOW_RULES_NOTE] : [],
12347
12715
  "",
12348
12716
  "How this board works:",
12349
12717
  "",
@@ -12385,11 +12753,22 @@ async function describeReturnedCards(store, workspaceId, columns) {
12385
12753
  * without any reads exactly as it did before they existed.
12386
12754
  */
12387
12755
  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) {
12756
+ /** Said under the columns only when one of them has flow rules. */
12757
+ 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.";
12758
+ function hasFlowRules(column) {
12759
+ const { allowedFrom, wipLimit } = readFlowRules(column);
12760
+ return allowedFrom !== null || wipLimit !== null;
12761
+ }
12762
+ function describeColumnLine(column, columns) {
12389
12763
  const slug = normalizeStatusName(column.name);
12390
12764
  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}` : ""}`;
12765
+ const said = `${rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : ""}${describeFlow(column, columns)}`;
12766
+ return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${said}` : `- ${column.name} (\`${slug}\`)${said ? ` —${said}` : ""}`;
12767
+ }
12768
+ /** Where a column takes cards from and how many it holds, as the prime text says it; nothing for a column without flow rules. */
12769
+ function describeFlow(column, columns) {
12770
+ const { allowedFrom, wipLimit } = readFlowRules(column);
12771
+ 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
12772
  }
12394
12773
  //#endregion
12395
12774
  //#region src/domain/pull-request-ref.ts
@@ -12559,47 +12938,6 @@ async function queryCards(store, input) {
12559
12938
  };
12560
12939
  }
12561
12940
  //#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
12941
  //#region src/ops/relations.ts
12604
12942
  /**
12605
12943
  * Every edge touching the card, in either direction, each with the card on its
@@ -12889,6 +13227,8 @@ function createBoardOps(store) {
12889
13227
  updateColumn: async (statusId, patch, actor, scope) => await updateColumn(store, statusId, patch, actor, scope),
12890
13228
  describeColumn: async (workspaceId, nameOrId, description, scope) => await describeColumn(store, workspaceId, nameOrId, description, scope),
12891
13229
  setColumnEntryRules: async (workspaceId, nameOrId, entryRules, actor, scope) => await setColumnEntryRules(store, workspaceId, nameOrId, entryRules, actor, scope),
13230
+ setColumnFlowRules: async (columnId, rules, actor, scope) => await setColumnFlowRules(store, columnId, rules, actor, scope),
13231
+ applyStandardFlow: async (workspaceId, actor, scope) => await applyStandardFlow(store, workspaceId, actor, scope),
12892
13232
  enforceColumnEntryForCards: async (issueIds, statusId, actor) => await enforceColumnEntryForCards(store, issueIds, statusId, actor),
12893
13233
  deleteColumn: async (statusId, scope, actor) => await deleteColumn(store, statusId, scope, actor),
12894
13234
  reorderColumns: async (workspaceId, orderedIds, scope, actor) => await reorderColumns(store, workspaceId, orderedIds, scope, actor),
@@ -12899,6 +13239,7 @@ function createBoardOps(store) {
12899
13239
  moveColumn: async (workspaceId, nameOrId, position, actor, scope) => await moveColumn$1(store, workspaceId, nameOrId, position, actor, scope),
12900
13240
  removeColumn: async (workspaceId, nameOrId, options, actor, scope) => await removeColumn$1(store, workspaceId, nameOrId, options, actor, scope),
12901
13241
  startRun: async (issueId, input, actor, scope) => await startRun(store, issueId, input, actor, scope),
13242
+ claimCard: async (input, actor, scope) => await claimCard(store, input, actor, scope),
12902
13243
  attachRunExecution: async (runId, input, scope) => await attachRunExecution(store, runId, input, scope),
12903
13244
  recordRunSessionRef: async (runId, ref, scope) => await recordRunSessionRef(store, runId, ref, scope),
12904
13245
  clearRunSessionRef: async (runId, actor, options, scope) => await clearRunSessionRef(store, runId, actor, options, scope),
@@ -12911,6 +13252,7 @@ function createBoardOps(store) {
12911
13252
  readBoardProjectionForIssue: async (issueId) => await readBoardProjectionForIssue(store, issueId),
12912
13253
  listReady: async (input) => await listReady(store, input),
12913
13254
  queryReady: async (input) => await queryReady(store, input),
13255
+ listColumnEntries: async (input) => await listColumnEntries(store, input),
12914
13256
  buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId)
12915
13257
  };
12916
13258
  }
@@ -18777,6 +19119,10 @@ const issueStatuses$1 = pgTable("issue_statuses", {
18777
19119
  order: integer$1("order").notNull().default(0),
18778
19120
  /** A JSON array of `ENTRY_RULES`, or `null` for none — see the SQLite schema. */
18779
19121
  entryRules: text$1("entry_rules"),
19122
+ /** A JSON array of the status ids a card may enter from, or `null` for any — see the SQLite schema. */
19123
+ allowedFrom: text$1("allowed_from"),
19124
+ /** The most cards the column holds, or `null` — see the SQLite schema. */
19125
+ wipLimit: integer$1("wip_limit"),
18780
19126
  ...createdAt$1()
18781
19127
  }, (table) => [index$1("issue_statuses_workspace_id_idx").on(table.workspaceId), uniqueIndex$1("issue_statuses_workspace_name_unique").on(table.workspaceId, table.name)]);
18782
19128
  const issueMilestones$1 = pgTable("issue_milestones", {
@@ -18924,7 +19270,7 @@ const issueFieldChanges$1 = pgTable("issue_field_changes", {
18924
19270
  ...createdAt$1(),
18925
19271
  /** Insertion order, standing in for the `rowid` the SQLite board orders changes by. */
18926
19272
  seq: bigserial("seq", { mode: "number" }).notNull()
18927
- }, (table) => [index$1("issue_field_changes_issue_id_idx").on(table.issueId)]);
19273
+ }, (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
19274
  /** A pull request someone said belongs to a card — see the SQLite twin for what the row is and is not. */
18929
19275
  const issuePullRequests$1 = pgTable("issue_pull_requests", {
18930
19276
  id: textPk$1(),
@@ -19000,6 +19346,10 @@ function toIssueComment({ seq: _seq, ...comment }) {
19000
19346
  function toIssueFieldChange({ seq: _seq, ...change }) {
19001
19347
  return change;
19002
19348
  }
19349
+ /** A field change with its card's workspace, without `seq`, for the same reason. */
19350
+ function toWorkspaceFieldChange({ seq: _seq, ...change }) {
19351
+ return change;
19352
+ }
19003
19353
  /** The pull-request link without `seq`, for the same reason. */
19004
19354
  function toIssuePullRequest({ seq: _seq, ...link }) {
19005
19355
  return link;
@@ -19206,6 +19556,10 @@ function createStoreOver(database) {
19206
19556
  const [row] = await database.select().from(issues$1).where(eq(issues$1.id, issueId)).limit(1);
19207
19557
  return row ?? null;
19208
19558
  },
19559
+ lockById: async (issueId) => {
19560
+ const [row] = await database.select().from(issues$1).where(eq(issues$1.id, issueId)).limit(1).for("update");
19561
+ return row ?? null;
19562
+ },
19209
19563
  findInWorkspace: async (workspaceId, issueId) => {
19210
19564
  const [row] = await database.select().from(issues$1).where(and(eq(issues$1.id, issueId), eq(issues$1.workspaceId, workspaceId))).limit(1);
19211
19565
  return row ?? null;
@@ -19357,6 +19711,10 @@ function createStoreOver(database) {
19357
19711
  fieldChanges: {
19358
19712
  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
19713
  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),
19714
+ listByFieldSince: async (field, query) => query.workspaceIds.length === 0 ? [] : (await database.select({
19715
+ ...getTableColumns(issueFieldChanges$1),
19716
+ workspaceId: issues$1.workspaceId
19717
+ }).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
19718
  create: async (values) => {
19361
19719
  await database.insert(issueFieldChanges$1).values(values);
19362
19720
  }
@@ -20174,6 +20532,14 @@ const issueStatuses = sqliteTable("issue_statuses", {
20174
20532
  * through `readEntryRules`, never by hand.
20175
20533
  */
20176
20534
  entryRules: text("entry_rules"),
20535
+ /**
20536
+ * The columns a card may enter this one from — a JSON array of status ids;
20537
+ * `null` for any column, `[]` for none (only a person puts a card here). Read
20538
+ * it through `readFlowRules`, never by hand.
20539
+ */
20540
+ allowedFrom: text("allowed_from"),
20541
+ /** The most cards this column holds, 1 or more; `null` for no limit. */
20542
+ wipLimit: int("wip_limit"),
20177
20543
  ...createdAt()
20178
20544
  }, (table) => ({
20179
20545
  byWorkspace: index("issue_statuses_workspace_id_idx").on(table.workspaceId),
@@ -20321,7 +20687,11 @@ const issueFieldChanges = sqliteTable("issue_field_changes", {
20321
20687
  actorId: text("actor_id"),
20322
20688
  sourceChatSessionId: text("source_chat_session_id"),
20323
20689
  ...createdAt()
20324
- }, (table) => ({ byIssue: index("issue_field_changes_issue_id_idx").on(table.issueId) }));
20690
+ }, (table) => ({
20691
+ byIssue: index("issue_field_changes_issue_id_idx").on(table.issueId),
20692
+ /** The column-entry feed reads one field across a board by time. */
20693
+ byFieldCreatedAt: index("issue_field_changes_field_created_at_idx").on(table.field, table.createdAt)
20694
+ }));
20325
20695
  /**
20326
20696
  * A pull request someone said belongs to a card.
20327
20697
  *
@@ -20664,6 +21034,7 @@ function createSqliteBoardStoreCore(resolveDatabase) {
20664
21034
  }).from(issues).where(and(eq(issues.workspaceId, workspaceId), isNotNull(issues.milestoneId))).groupBy(issues.milestoneId, issues.statusId).all(),
20665
21035
  listByIds: (issueIds) => resolveDatabase().select().from(issues).where(inArray(issues.id, issueIds)).all(),
20666
21036
  findById: (issueId) => resolveDatabase().select().from(issues).where(eq(issues.id, issueId)).get() ?? null,
21037
+ lockById: (issueId) => resolveDatabase().select().from(issues).where(eq(issues.id, issueId)).get() ?? null,
20667
21038
  findInWorkspace: (workspaceId, issueId) => resolveDatabase().select().from(issues).where(and(eq(issues.id, issueId), eq(issues.workspaceId, workspaceId))).get() ?? null,
20668
21039
  findByNumber: (workspaceId, number) => resolveDatabase().select().from(issues).where(and(eq(issues.workspaceId, workspaceId), eq(issues.number, number))).get() ?? null,
20669
21040
  maxNumber: (workspaceId) => {
@@ -20761,6 +21132,10 @@ function createSqliteBoardStoreCore(resolveDatabase) {
20761
21132
  fieldChanges: {
20762
21133
  listByIssue: (issueId) => resolveDatabase().select().from(issueFieldChanges).where(eq(issueFieldChanges.issueId, issueId)).orderBy(asc(issueFieldChanges.createdAt), FIELD_CHANGE_INSERTION_ORDER).all(),
20763
21134
  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(),
21135
+ listByFieldSince: (field, query) => query.workspaceIds.length === 0 ? [] : resolveDatabase().select({
21136
+ ...getTableColumns(issueFieldChanges),
21137
+ workspaceId: issues.workspaceId
21138
+ }).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
21139
  create: (values) => {
20765
21140
  resolveDatabase().insert(issueFieldChanges).values(values).run();
20766
21141
  }
@@ -20841,6 +21216,7 @@ function createSqliteBoardStore(options) {
20841
21216
  countByMilestone: async (workspaceId) => core.issues.countByMilestone(workspaceId),
20842
21217
  listByIds: async (issueIds) => core.issues.listByIds(issueIds),
20843
21218
  findById: async (issueId) => core.issues.findById(issueId),
21219
+ lockById: async (issueId) => core.issues.lockById(issueId),
20844
21220
  findInWorkspace: async (workspaceId, issueId) => core.issues.findInWorkspace(workspaceId, issueId),
20845
21221
  findByNumber: async (workspaceId, number) => core.issues.findByNumber(workspaceId, number),
20846
21222
  maxNumber: async (workspaceId) => core.issues.maxNumber(workspaceId),
@@ -20886,6 +21262,7 @@ function createSqliteBoardStore(options) {
20886
21262
  fieldChanges: {
20887
21263
  listByIssue: async (issueId) => core.fieldChanges.listByIssue(issueId),
20888
21264
  listByIssueField: async (issueId, field) => core.fieldChanges.listByIssueField(issueId, field),
21265
+ listByFieldSince: async (field, query) => core.fieldChanges.listByFieldSince(field, query),
20889
21266
  create: async (values) => core.fieldChanges.create(values)
20890
21267
  },
20891
21268
  pullRequests: {
@@ -24007,9 +24384,10 @@ const SCHEMA_NOT_INSTALLED_MESSAGE = "This Postgres database has no kanbo board
24007
24384
  * caller's own risk but never written to — a write shaped by this build's idea
24008
24385
  * of the schema is how a person's board gets a column full of nothing. A file
24009
24386
  * 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.
24387
+ * points at `kanbo migrate` instead of at the app. Reads are held to the lesser
24388
+ * bar of `assertReadableBoard`: a file only a stamp behind is still read, so a
24389
+ * person who cannot migrate right now can look at their cards, but one missing
24390
+ * a table or column this build reads is refused with the same sentence.
24013
24391
  */
24014
24392
  function assertWritableBoard(database, owner) {
24015
24393
  try {
@@ -24020,6 +24398,26 @@ function assertWritableBoard(database, owner) {
24020
24398
  }
24021
24399
  }
24022
24400
  /**
24401
+ * Refuse a read of a board file that lacks a table or a column this build
24402
+ * reads — a file a migration behind — with the sentence a write gets, instead
24403
+ * of letting the first statement fail on what is missing. A file whose only
24404
+ * difference is its schema stamp is still read: every statement this build
24405
+ * makes works on it, and a person who cannot migrate now can still look at
24406
+ * their cards.
24407
+ */
24408
+ function assertReadableBoard(database, owner) {
24409
+ try {
24410
+ assertBoardSchema$1(database);
24411
+ } catch (error) {
24412
+ if (!(error instanceof BoardError && error.code === "board_schema_outdated")) throw error;
24413
+ if (lacksWhatThisBuildReads(error.details)) throw new CliError(3, owner === "kanbo" ? FILE_SCHEMA_OUTDATED_MESSAGE : SCHEMA_OUTDATED_MESSAGE);
24414
+ }
24415
+ }
24416
+ /** Whether `assertBoardSchema` found a table or a column missing, rather than only another stamp. */
24417
+ function lacksWhatThisBuildReads(details) {
24418
+ return details !== void 0 && ("missingTables" in details || "missingAddedTables" in details || "missingColumns" in details);
24419
+ }
24420
+ /**
24023
24421
  * Refuse an external board this build cannot speak for — before reading it as
24024
24422
  * well as before writing to it.
24025
24423
  *
@@ -24258,6 +24656,7 @@ async function openFileSession(target, options, mode, cwd) {
24258
24656
  assertWritableBoard(board.database, target.owner);
24259
24657
  };
24260
24658
  if (mode === "write") assertWritableBoard(board.database, target.owner);
24659
+ else assertReadableBoard(board.database, target.owner);
24261
24660
  const workspace = resolveWorkspace(target.owner === "kanbo" ? { kind: "file" } : {
24262
24661
  kind: "sqlite",
24263
24662
  database: board.database
@@ -24281,8 +24680,9 @@ async function openFileSession(target, options, mode, cwd) {
24281
24680
  /**
24282
24681
  * The same, on a board that is a database of its own.
24283
24682
  *
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
24683
+ * The guard runs whatever the command came to do. A board file whose only
24684
+ * difference from this build is its schema stamp is still a board worth
24685
+ * reading; a connection string pointing
24286
24686
  * at a database with no board in it is nothing to read at all, and every
24287
24687
  * command would otherwise fail one statement later with the driver's own words.
24288
24688
  */
@@ -24580,7 +24980,9 @@ function projectCardActiveRun(run) {
24580
24980
  startedAt: run.startedAt
24581
24981
  };
24582
24982
  }
24583
- function projectColumn(column) {
24983
+ /** A column as the command line prints it; `board` is every column, to name the ones it accepts cards from. */
24984
+ function projectColumn(column, board) {
24985
+ const { allowedFrom, wipLimit } = readFlowRules(column);
24584
24986
  return {
24585
24987
  id: column.id,
24586
24988
  name: column.name,
@@ -24588,7 +24990,9 @@ function projectColumn(column) {
24588
24990
  description: column.description,
24589
24991
  category: column.category,
24590
24992
  order: column.order,
24591
- entryRules: readEntryRules(column)
24993
+ entryRules: readEntryRules(column),
24994
+ allowedFrom: allowedFrom === null ? null : board.filter((other) => allowedFrom.includes(other.id)).map((other) => normalizeStatusName(other.name)),
24995
+ wipLimit
24592
24996
  };
24593
24997
  }
24594
24998
  function projectSprint(sprint) {
@@ -24660,7 +25064,9 @@ function describeColumns(columns) {
24660
25064
  return columns.map((column) => [
24661
25065
  `${padToWidth(sanitizeTerminalText(column.slug), slugWidth)} ${sanitizeTerminalText(column.name)}`,
24662
25066
  column.description ? ` — ${sanitizeTerminalText(column.description)}` : "",
24663
- column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : ""
25067
+ column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : "",
25068
+ column.allowedFrom === null ? "" : ` [from: ${column.allowedFrom.length > 0 ? column.allowedFrom.join(", ") : "no column"}]`,
25069
+ column.wipLimit === null ? "" : ` [wip: ${column.wipLimit}]`
24664
25070
  ].join("")).join("\n");
24665
25071
  }
24666
25072
  /** A unix-seconds date as the UTC day it falls on, `—` for none. */
@@ -24854,6 +25260,7 @@ function colorsWanted() {
24854
25260
  const KANBO_TOOL_CLI_EQUIVALENTS = {
24855
25261
  kanbo_prime: { kanbo: "kanbo prime" },
24856
25262
  kanbo_ready: { kanbo: "kanbo ready" },
25263
+ kanbo_card_claim: { kanbo: "kanbo claim [card] [--agent <name>]" },
24857
25264
  kanbo_columns: { kanbo: "kanbo columns list" },
24858
25265
  kanbo_sprints: { kanbo: "kanbo sprint list" },
24859
25266
  kanbo_card_get: { kanbo: "kanbo card get <id>" },
@@ -25270,16 +25677,22 @@ function projectKanboSprint(sprint) {
25270
25677
  doneCards: sprint.cards.done
25271
25678
  };
25272
25679
  }
25273
- /** A column of the board, from the row either transport holds. */
25274
- function projectKanboColumn(column) {
25680
+ /** A column of the board, from the row either transport holds; `board` is every column, to name the ones it accepts cards from. */
25681
+ function projectKanboColumn(column, board) {
25275
25682
  const stored = column.entryRules;
25683
+ const allowedIds = typeof column.allowedFrom === "string" ? readFlowRules({
25684
+ allowedFrom: column.allowedFrom,
25685
+ wipLimit: null
25686
+ }).allowedFrom : column.allowedFrom ?? null;
25276
25687
  return {
25277
25688
  id: column.id,
25278
25689
  name: column.name,
25279
25690
  slug: normalizeStatusName(column.name),
25280
25691
  description: column.description,
25281
25692
  order: column.order,
25282
- entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null })
25693
+ entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null }),
25694
+ allowedFrom: allowedIds === null ? null : board.filter((other) => allowedIds.includes(other.id)).map((other) => normalizeStatusName(other.name)),
25695
+ wipLimit: column.wipLimit ?? null
25283
25696
  };
25284
25697
  }
25285
25698
  /** A comment as a card read lists it, from the row either transport holds. */
@@ -25393,7 +25806,7 @@ function readMoment(value) {
25393
25806
  const cardMoveTool = defineKanboTool({
25394
25807
  name: "kanbo_card_move",
25395
25808
  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}`,
25809
+ 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
25810
  inputSchema: {
25398
25811
  card: cardArgument,
25399
25812
  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 +25878,7 @@ const cardUpdateTool = defineKanboTool({
25465
25878
  const columnsTool = defineKanboTool({
25466
25879
  name: "kanbo_columns",
25467
25880
  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.",
25881
+ 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
25882
  inputSchema: {},
25470
25883
  run: async (transport) => await transport.columns()
25471
25884
  });
@@ -25479,6 +25892,38 @@ const primeTool = defineKanboTool({
25479
25892
  inputSchema: {},
25480
25893
  run: async (transport) => (await transport.prime()).text
25481
25894
  });
25895
+ //#endregion
25896
+ //#region src/mcp/tools/card-claim.ts
25897
+ /**
25898
+ * Taking a card to work on, in one step: the run starts and the card moves out
25899
+ * of To Do into the board's first work-in-progress column in the same write,
25900
+ * so two agents never get the same card. Published to every host, including
25901
+ * one that records the runs of the agents it launches: a claim is how an agent
25902
+ * in any terminal takes work.
25903
+ */
25904
+ const cardClaimTool = defineKanboTool({
25905
+ name: "kanbo_card_claim",
25906
+ title: "Claim a card",
25907
+ 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.",
25908
+ inputSchema: {
25909
+ card: cardArgument.optional(),
25910
+ agent: string().min(1).optional().describe("What to call whoever works the card; who this server writes as when absent.")
25911
+ },
25912
+ aliases: {
25913
+ card: [...CARD_ALIASES, "issue"],
25914
+ agent: ["agentName"]
25915
+ },
25916
+ example: { card: "TST-5" },
25917
+ run: async (transport, input) => {
25918
+ const claimed = await transport.cardClaim(input);
25919
+ return {
25920
+ card: compactKanboCard(claimed.card),
25921
+ run: claimed.run.id,
25922
+ column: claimed.column.slug,
25923
+ finishWith: `kanbo_run_finish {"run":"${claimed.run.id}","state":"finished"}`
25924
+ };
25925
+ }
25926
+ });
25482
25927
  /** The cards an agent may pick up right now. */
25483
25928
  const readyTool = defineKanboTool({
25484
25929
  name: "kanbo_ready",
@@ -25567,13 +26012,14 @@ const runStartTool = defineKanboTool({
25567
26012
  /**
25568
26013
  * Every tool the board has, in the order it declares them.
25569
26014
  *
25570
- * The list is the board's, not a server's: the same sixteen are registered
26015
+ * The list is the board's, not a server's: the same seventeen are registered
25571
26016
  * whether they run against a server or against the board directly, and the
25572
26017
  * only thing a host decides is which of them it publishes.
25573
26018
  */
25574
26019
  const KANBO_TOOLS = [
25575
26020
  primeTool,
25576
26021
  readyTool,
26022
+ cardClaimTool,
25577
26023
  columnsTool,
25578
26024
  defineKanboTool({
25579
26025
  name: "kanbo_sprints",
@@ -25963,10 +26409,12 @@ function buildCapabilitiesManifest() {
25963
26409
  "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
26410
  `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
26411
  "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.",
26412
+ "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.",
26413
+ "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
26414
  "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
26415
  "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
26416
  "`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.",
26417
+ "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
26418
  "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
26419
  ]
25972
26420
  };
@@ -26111,6 +26559,52 @@ function registerCapabilitiesCommand(program) {
26111
26559
  });
26112
26560
  }
26113
26561
  //#endregion
26562
+ //#region src/cli/commands/claim.ts
26563
+ /**
26564
+ * `kanbo claim [card]` — take a card to work on, in one step.
26565
+ *
26566
+ * The run starts and the card moves out of To Do into the board's first
26567
+ * work-in-progress column in one write, so two agents in two terminals never
26568
+ * take the same card. Filed as a placement: in a person's own terminal the
26569
+ * column's rules warn, in an agent's shell they refuse.
26570
+ */
26571
+ function registerClaimCommand(program) {
26572
+ 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) => {
26573
+ await runBoardCommand(options, "write", async (session) => {
26574
+ const actor = createPlacementActor();
26575
+ const named = reference === void 0 ? void 0 : (await requireCard(session, reference)).id;
26576
+ const claimed = await session.ops.claimCard({
26577
+ workspaceId: session.workspace.id,
26578
+ issueId: named,
26579
+ run: {
26580
+ agentName: options.agent ?? actor.id ?? "agent",
26581
+ externalSessionRef: options.session ?? sessionRefFromEnvironment() ?? void 0,
26582
+ launchedByKind: "external"
26583
+ }
26584
+ }, actor);
26585
+ const [columns, projection, runs] = await Promise.all([
26586
+ session.ops.listColumns(session.workspace.id),
26587
+ session.ops.readBoardProjectionForIssue(claimed.card.id),
26588
+ session.ops.listRuns(claimed.card.id)
26589
+ ]);
26590
+ const card = projectCard(claimed.card, columns, projection);
26591
+ for (const item of claimed.card.unmetRules ?? []) console.error(`kanbo: warning: ${card.id} entered "${claimed.column.name}" without ${item.rule}: ${item.detail}`);
26592
+ const run = projectRun({
26593
+ ...claimed.run,
26594
+ attempt: runs.find((each) => each.id === claimed.run.id)?.attempt ?? runs.length
26595
+ });
26596
+ return {
26597
+ value: {
26598
+ card,
26599
+ run,
26600
+ column: projectColumn(claimed.column, columns)
26601
+ },
26602
+ 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`
26603
+ };
26604
+ });
26605
+ });
26606
+ }
26607
+ //#endregion
26114
26608
  //#region src/cli/card-extras.ts
26115
26609
  async function readCardExtras(session, card, columns, include, commentLimit) {
26116
26610
  const wants = new Set(include);
@@ -26491,10 +26985,11 @@ const COLUMN_CATEGORIES = [
26491
26985
  ];
26492
26986
  /** The whole board's columns after a change, and the sentence that says what changed. */
26493
26987
  async function changed(session, message, extra = {}) {
26494
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
26988
+ const board = await session.ops.listColumns(session.workspace.id);
26989
+ const views = board.map((column) => projectColumn(column, board));
26495
26990
  return {
26496
26991
  value: {
26497
- ...extra,
26992
+ ...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, typeof value === "number" || Array.isArray(value) ? value : projectColumn(value, board)])),
26498
26993
  columns: views
26499
26994
  },
26500
26995
  text: `${message}\n\n${describeColumns(views)}`
@@ -26523,14 +27018,14 @@ async function addColumn(session, request, actor) {
26523
27018
  category: request.category ?? spec.category,
26524
27019
  position: anchor ? request.after !== void 0 ? { after: anchor.id } : { before: anchor.id } : void 0
26525
27020
  }, actor);
26526
- return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: projectColumn(created) });
27021
+ return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: created });
26527
27022
  }
26528
27023
  /** Said after a rename: agents' instructions name no column but To Do, and `kanbo prime` gives them the rest. */
26529
27024
  const RENAME_NOTE = "Agents read column names from kanbo prime; rename is picked up there.";
26530
27025
  async function renameColumn(session, column, newName, actor) {
26531
27026
  const before = await requireNamedColumn(session, column);
26532
27027
  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) });
27028
+ 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
27029
  }
26535
27030
  /** `--first`, `--last`, `--before <column>` or `--after <column>` — exactly one. */
26536
27031
  function readColumnPosition(options) {
@@ -26562,7 +27057,7 @@ async function removeColumn(session, column, moveCardsTo, actor) {
26562
27057
  const result = await session.ops.removeColumn(session.workspace.id, removing.id, { moveCardsTo }, actor);
26563
27058
  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
27059
  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),
27060
+ removed: result.removed,
26566
27061
  movedCards: result.movedCards
26567
27062
  });
26568
27063
  }
@@ -26686,7 +27181,7 @@ async function runColumnsMenu(ui) {
26686
27181
  if (showList) say$2(ui, [
26687
27182
  "",
26688
27183
  "Columns of this board:",
26689
- describeColumns(columns.map(projectColumn))
27184
+ describeColumns(columns.map((column) => projectColumn(column, columns)))
26690
27185
  ].join("\n"));
26691
27186
  let choice;
26692
27187
  try {
@@ -26914,7 +27409,8 @@ function registerColumnsCommands(program) {
26914
27409
  const columns = program.command("columns").description("See and change the board's columns (alone: a menu)");
26915
27410
  withBoardOptions(columns.command("list").description("List the columns in order, with the short name to move cards by")).action(async (options) => {
26916
27411
  await runBoardCommand(options, "read", async (session) => {
26917
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
27412
+ const board = await session.ops.listColumns(session.workspace.id);
27413
+ const views = board.map((column) => projectColumn(column, board));
26918
27414
  return {
26919
27415
  value: views,
26920
27416
  text: describeColumns(views)
@@ -26923,7 +27419,7 @@ function registerColumnsCommands(program) {
26923
27419
  });
26924
27420
  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
27421
  await runBoardCommand(options, "write", async (session) => {
26926
- const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text));
27422
+ const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text), await session.ops.listColumns(session.workspace.id));
26927
27423
  return {
26928
27424
  value: view,
26929
27425
  text: describeColumns([view])
@@ -26934,13 +27430,34 @@ function registerColumnsCommands(program) {
26934
27430
  const actor = requireHumanActor("columns rules", typedWords(command));
26935
27431
  const rules = readRequestedRules(options);
26936
27432
  await runBoardCommand(options, "write", async (session) => {
26937
- const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor));
27433
+ const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor), await session.ops.listColumns(session.workspace.id));
26938
27434
  return {
26939
27435
  value: view,
26940
27436
  text: describeColumns([view])
26941
27437
  };
26942
27438
  });
26943
27439
  });
27440
+ 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) => {
27441
+ const actor = requireHumanActor("columns flow", typedWords(command));
27442
+ if (options.from === void 0 && options.wip === void 0) throw new CliError(1, "Pass --from <columns|any>, --wip <count|none>, or both.");
27443
+ const wipLimit = options.wip === void 0 ? void 0 : readWipLimit(options.wip);
27444
+ await runBoardCommand(options, "write", async (session) => {
27445
+ const workspaceId = session.workspace.id;
27446
+ const board = await session.ops.listColumns(workspaceId);
27447
+ const target = await session.ops.requireColumn(workspaceId, column);
27448
+ const current = readFlowRules(target);
27449
+ await session.ops.setColumnFlowRules(target.id, {
27450
+ allowedFrom: options.from === void 0 ? current.allowedFrom : readAllowedFrom(board, workspaceId, options.from),
27451
+ wipLimit: wipLimit === void 0 ? current.wipLimit : wipLimit
27452
+ }, actor);
27453
+ const after = await session.ops.listColumns(workspaceId);
27454
+ const views = after.map((each) => projectColumn(each, after));
27455
+ return {
27456
+ value: views.find((view) => view.id === target.id),
27457
+ text: describeColumns(views)
27458
+ };
27459
+ });
27460
+ });
26944
27461
  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
27462
  const actor = requireHumanActor("columns add", typedWords(command));
26946
27463
  await runBoardCommand(options, "write", async (session) => await addColumn(session, {
@@ -26982,7 +27499,7 @@ function registerColumnsCommands(program) {
26982
27499
  mode: "add-missing",
26983
27500
  actor
26984
27501
  });
26985
- const views = statuses.map(projectColumn);
27502
+ const views = statuses.map((column) => projectColumn(column, statuses));
26986
27503
  return {
26987
27504
  value: {
26988
27505
  added,
@@ -27006,7 +27523,8 @@ function registerColumnsCommands(program) {
27006
27523
  return;
27007
27524
  }
27008
27525
  await runBoardCommand({}, "read", async (session) => {
27009
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
27526
+ const board = await session.ops.listColumns(session.workspace.id);
27527
+ const views = board.map((column) => projectColumn(column, board));
27010
27528
  return {
27011
27529
  value: views,
27012
27530
  text: describeColumns(views)
@@ -27036,6 +27554,26 @@ function readRequestedRules(options) {
27036
27554
  if (options.require === void 0) throw new CliError(1, `Pass --require ${ENTRY_RULES.join(",")} (any of them) or --clear.`);
27037
27555
  return options.require.split(",").map((rule) => rule.trim()).filter(Boolean);
27038
27556
  }
27557
+ /** `--from`: `any` for every column, else the columns named, by slug, name or id. */
27558
+ function readAllowedFrom(board, workspaceId, value) {
27559
+ if (value.trim().toLowerCase() === "any") return null;
27560
+ const names = value.split(",").map((name) => name.trim()).filter(Boolean);
27561
+ if (names.length === 0) throw new CliError(1, "Name the columns a card may come from (--from in_review,qa), or --from any.");
27562
+ return names.map((name) => {
27563
+ const found = matchColumn(board, workspaceId, name);
27564
+ if (!found) throw new BoardError("issue_status_not_found", {
27565
+ workspaceId,
27566
+ statusName: name
27567
+ });
27568
+ return found.id;
27569
+ });
27570
+ }
27571
+ /** `--wip`: `none` for no limit, else a whole number of cards, 1 or more. */
27572
+ function readWipLimit(value) {
27573
+ if (value.trim().toLowerCase() === "none") return null;
27574
+ if (!/^\d+$/.test(value.trim()) || Number(value) < 1) throw new CliError(1, "--wip takes a whole number of cards, 1 or more, or none.");
27575
+ return Number(value);
27576
+ }
27039
27577
  //#endregion
27040
27578
  //#region node_modules/zod/v3/helpers/util.js
27041
27579
  var util;
@@ -42978,7 +43516,7 @@ const INSTRUCTION_END_PATTERN = /<!-- KANBO_END\s*-->/g;
42978
43516
  const POINTER_KINDS = /* @__PURE__ */ new Set(["short", "global"]);
42979
43517
  /** The commands an agent uses, spelled once for the guide and the `kanbo prime` cheat sheet. */
42980
43518
  const COMMAND = {
42981
- ready: "kanbo ready",
43519
+ claim: "kanbo claim",
42982
43520
  move: "kanbo card move <id> <column>",
42983
43521
  statusLine: "kanbo card status-line <id> --text \"...\"",
42984
43522
  subtask: "kanbo card create --description \"...\" --parent <id>",
@@ -42997,7 +43535,7 @@ const AGENT_GUIDE_TEXT = [
42997
43535
  "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
43536
  "",
42999
43537
  ...[
43000
- `- Take a card from \`${COMMAND.ready}\`.`,
43538
+ `- 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
43539
  `- ${MOVE_CARD_RULE} (\`${COMMAND.move}\`)`,
43002
43540
  `- ${STATUS_LINE_RULE} (\`${COMMAND.statusLine}\`)`,
43003
43541
  `- ${SUBTASK_RULE} (\`${COMMAND.subtask}\`)`,
@@ -43027,12 +43565,13 @@ const COMMAND_SHEET = [
43027
43565
  "- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
43028
43566
  "- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
43029
43567
  "- `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",
43568
+ "- `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",
43569
+ "- `kanbo run start TST-5 --agent claude` — record a run on a card you did not claim; it prints the finish command",
43031
43570
  "- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
43032
43571
  `- \`${COMMAND.capabilities}\` — everything else, rarely needed`
43033
43572
  ].join("\n");
43034
43573
  /** 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}`;
43574
+ const POINTER_TAIL = `Take work with \`${COMMAND.claim}\`. ${PERSON_ONLY_RULE}`;
43036
43575
  /** The body of the project block: what `kanbo instructions short` prints. */
43037
43576
  const PROJECT_BODY = [
43038
43577
  "## Kanbo board",
@@ -43052,7 +43591,7 @@ const GLOBAL_BODY = [
43052
43591
  * The block, markers included, with this body's version and hash on the start
43053
43592
  * marker — and, when given, the kind of text it is (`k=orchestrator`).
43054
43593
  */
43055
- function wrapInstructionBlock(body, version = 2, kind) {
43594
+ function wrapInstructionBlock(body, version = 3, kind) {
43056
43595
  return `<!-- KANBO_START v${version}${kind ? ` k=${kind}` : ""} h=${hashBody(body)} -->\n${body}\n${INSTRUCTION_END}`;
43057
43596
  }
43058
43597
  /** The block `kanbo init --instructions …` writes into a project's own file. */
@@ -43081,7 +43620,7 @@ function inspectBlock(text, current) {
43081
43620
  kind
43082
43621
  });
43083
43622
  if (fields.trim() === "") return inspection(LEGACY_BLOCK_BODIES.includes(body) ? "legacy" : "edited");
43084
- if (version !== void 0 && Number(version) > 2) return inspection("newer");
43623
+ if (version !== void 0 && Number(version) > 3) return inspection("newer");
43085
43624
  if (kind !== null && !POINTER_KINDS.has(kind)) return inspection("chosen");
43086
43625
  if (version === void 0 || hash === void 0 || hashBody(body) !== hash) return inspection("edited");
43087
43626
  return inspection(normalized.slice(found.start, found.end) === current ? "current" : "outdated");
@@ -44153,7 +44692,7 @@ function startUpdateCheck(deps = {}) {
44153
44692
  setTimeout: globalThis.setTimeout,
44154
44693
  clearTimeout: globalThis.clearTimeout
44155
44694
  };
44156
- const current = deps.currentVersion ?? "0.4.0";
44695
+ const current = deps.currentVersion ?? "0.5.0";
44157
44696
  const controller = new AbortController();
44158
44697
  let found = null;
44159
44698
  const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
@@ -46670,7 +47209,7 @@ const KANBO_MCP_INSTRUCTIONS = [
46670
47209
  "kanbo is a kanban board shared by people and agents; these tools read and change its cards.",
46671
47210
  "",
46672
47211
  `- 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")}.`,
47212
+ `- 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
47213
  `- ${MOVE_CARD_RULE} (${tool("kanbo_card_move")})`,
46675
47214
  `- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
46676
47215
  `- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
@@ -46761,7 +47300,7 @@ const ORCHESTRATOR_GUIDE = [
46761
47300
  "## Start of every session",
46762
47301
  "1. Run `kanbo prime` (tool: `kanbo_prime`) and read the column descriptions. On this board the description is the contract, not the name.",
46763
47302
  "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.",
47303
+ "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
47304
  "",
46766
47305
  "## While working",
46767
47306
  "- 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 +47327,7 @@ const ORCHESTRATOR_GUIDE = [
46788
47327
  "- 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
47328
  "",
46790
47329
  "## 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.",
47330
+ "- 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
47331
  "- Never delete or edit the board's database directly."
46793
47332
  ].join("\n");
46794
47333
  //#endregion
@@ -46833,7 +47372,7 @@ function registerInstructionsCommand(program, dependencies = {}) {
46833
47372
  const found = parseKind(kind);
46834
47373
  const body = await readInstructionText(found);
46835
47374
  const pointer = found === "short" || found === "global";
46836
- const text = options.markers ? wrapInstructionBlock(body, 2, pointer ? void 0 : found) : body;
47375
+ const text = options.markers ? wrapInstructionBlock(body, 3, pointer ? void 0 : found) : body;
46837
47376
  const hints = process.stderr.isTTY === true;
46838
47377
  if (hints) process.stderr.write(`${HEADER}\n\n`);
46839
47378
  console.log(text);
@@ -48266,7 +48805,8 @@ async function createDbTransport(input) {
48266
48805
  const workspaceId = workspace.id;
48267
48806
  const actor = input.actor;
48268
48807
  async function readColumns() {
48269
- return (await ops.listColumns(workspaceId)).map(projectKanboColumn);
48808
+ const rows = await ops.listColumns(workspaceId);
48809
+ return rows.map((row) => projectKanboColumn(row, rows));
48270
48810
  }
48271
48811
  /** One card with what the run registry says about it, in the shape a tool prints. */
48272
48812
  async function project(card, columns) {
@@ -48316,7 +48856,7 @@ async function createDbTransport(input) {
48316
48856
  * the waiting and returned ones last said at most two more.
48317
48857
  */
48318
48858
  async function projectPage(page, { attend = true } = {}) {
48319
- const columns = page.columns.map(projectKanboColumn);
48859
+ const columns = page.columns.map((column) => projectKanboColumn(column, page.columns));
48320
48860
  const runs = await ops.readBoardProjectionForIssues(page.cards.map((card) => card.id));
48321
48861
  const attention = attend ? await ops.readCardAttention(page.cards, runs.map((run) => run.activeRun !== null)) : [];
48322
48862
  return page.cards.map((card, index) => projectKanboCard({
@@ -48448,6 +48988,25 @@ async function createDbTransport(input) {
48448
48988
  state,
48449
48989
  errorText
48450
48990
  }, actor));
48991
+ },
48992
+ cardClaim: async ({ card, agent }) => {
48993
+ await session.assertWritable();
48994
+ const named = card === void 0 ? void 0 : (await resolveCard(card)).id;
48995
+ const claimed = await ops.claimCard({
48996
+ workspaceId,
48997
+ issueId: named,
48998
+ run: {
48999
+ agentName: agent ?? actor.id ?? "agent",
49000
+ externalSessionRef: sessionRefFromEnvironment() ?? void 0,
49001
+ launchedByKind: "external"
49002
+ }
49003
+ }, actor);
49004
+ const columns = await readColumns();
49005
+ return {
49006
+ card: await project(claimed.card, columns),
49007
+ run: projectKanboRun(claimed.run),
49008
+ column: columns.find((column) => column.id === claimed.column.id)
49009
+ };
48451
49010
  }
48452
49011
  };
48453
49012
  }
@@ -48489,7 +49048,8 @@ const TRANSPORT_METHODS = {
48489
49048
  statusLine: true,
48490
49049
  waitApproval: true,
48491
49050
  runStart: true,
48492
- runFinish: true
49051
+ runFinish: true,
49052
+ cardClaim: true
48493
49053
  };
48494
49054
  async function openMcpBoard(input) {
48495
49055
  try {
@@ -49095,7 +49655,7 @@ function sendBoardPage(response, pathname, assets) {
49095
49655
  * answering for a person — approving a card, sending it back or closing a
49096
49656
  * sprint — as anyone but one is 403, dropping a pull request link somebody else made is the same 403
49097
49657
  * 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
49658
+ * 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
49659
  * card in a column whose entry rules it does not meet yet is 409 for the same
49100
49660
  * reason turned around: the answer will be yes once the card meets them, and
49101
49661
  * `details.unmet` says what is missing. Deleting a column that still holds
@@ -49113,6 +49673,14 @@ const BOARD_ERROR_RESPONSES = {
49113
49673
  message: "Only a person can approve a card",
49114
49674
  code: "issue_approval_requires_user"
49115
49675
  },
49676
+ board_card_not_ready: {
49677
+ status: 409,
49678
+ message: "That card is not there to take: it is not in To Do, waits for a person, or has a run going"
49679
+ },
49680
+ board_card_run_active: {
49681
+ status: 409,
49682
+ message: "The card already has a run going"
49683
+ },
49116
49684
  board_column_name_taken: {
49117
49685
  status: 400,
49118
49686
  message: "Another column already has that name"
@@ -49152,6 +49720,14 @@ const BOARD_ERROR_RESPONSES = {
49152
49720
  status: 400,
49153
49721
  message: "Execution mode cannot change once the card has been launched"
49154
49722
  },
49723
+ board_flow_rule_invalid: {
49724
+ status: 400,
49725
+ message: "A column accepts cards from other columns of its own board, and holds at least one card"
49726
+ },
49727
+ board_nothing_ready: {
49728
+ status: 409,
49729
+ message: "No card in To Do is ready to take"
49730
+ },
49155
49731
  board_pull_request_invalid: {
49156
49732
  status: 400,
49157
49733
  message: "Not a GitHub pull request: give its URL or owner/repo#number"
@@ -49567,6 +50143,14 @@ const startRunBody = object$1({
49567
50143
  ]).optional(),
49568
50144
  externalSessionRef: string().nullable().optional()
49569
50145
  });
50146
+ const claimBody = object$1({
50147
+ workspaceId: id,
50148
+ issueId: id.optional(),
50149
+ agentName: id.optional(),
50150
+ branch: string().nullable().optional(),
50151
+ executionMode: executionMode.optional(),
50152
+ externalSessionRef: string().nullable().optional()
50153
+ });
49570
50154
  const finishRunBody = object$1({
49571
50155
  state: _enum([
49572
50156
  "finished",
@@ -49613,7 +50197,8 @@ async function toWrittenCardView(ops, card) {
49613
50197
  function toStatusView(status) {
49614
50198
  return {
49615
50199
  ...status,
49616
- entryRules: readEntryRules(status)
50200
+ entryRules: readEntryRules(status),
50201
+ allowedFrom: readFlowRules(status).allowedFrom
49617
50202
  };
49618
50203
  }
49619
50204
  function toCommentView(comment, personWriterId = null) {
@@ -50048,6 +50633,31 @@ function createIssueRoutes(session, options = {}) {
50048
50633
  }, actors.placement));
50049
50634
  }
50050
50635
  }),
50636
+ route({
50637
+ method: "POST",
50638
+ path: "/issues/claim",
50639
+ body: claimBody,
50640
+ write: true,
50641
+ handle: async ({ body, actors }) => {
50642
+ const named = requireWorkspace(body.workspaceId);
50643
+ if (body.issueId !== void 0) await requireCard(body.issueId);
50644
+ const claimed = await ops.claimCard({
50645
+ workspaceId: named,
50646
+ issueId: body.issueId,
50647
+ run: {
50648
+ agentName: body.agentName ?? actors.writer.id ?? "agent",
50649
+ branch: body.branch,
50650
+ executionMode: body.executionMode,
50651
+ externalSessionRef: body.externalSessionRef
50652
+ }
50653
+ }, actors.placement);
50654
+ return {
50655
+ card: await toWrittenCardView(ops, claimed.card),
50656
+ run: await toRunView(claimed.run),
50657
+ column: toStatusView(claimed.column)
50658
+ };
50659
+ }
50660
+ }),
50051
50661
  route({
50052
50662
  method: "PATCH",
50053
50663
  path: "/issues/bulk",
@@ -51189,6 +51799,7 @@ function registerKanboCommands(program, context = {}) {
51189
51799
  registerServeCommand(program);
51190
51800
  program.commandsGroup("For agents and integrations:");
51191
51801
  registerMcpCommand(program);
51802
+ registerClaimCommand(program);
51192
51803
  registerRunCommands(program);
51193
51804
  registerCapabilitiesCommand(program);
51194
51805
  program.commandsGroup("Shared Postgres boards:");