kanbo-cli 0.3.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 (46) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +2 -0
  3. package/dist/cli-main.cjs +1347 -254
  4. package/dist/drizzle-postgres/0006_flow_rules_and_column_entries.sql +3 -0
  5. package/dist/drizzle-postgres/README.md +8 -0
  6. package/dist/drizzle-postgres/meta/0006_snapshot.json +1290 -0
  7. package/dist/drizzle-postgres/meta/_journal.json +7 -0
  8. package/dist/drizzle-sqlite/0005_flow_rules_and_column_entries.sql +3 -0
  9. package/dist/drizzle-sqlite/README.md +8 -0
  10. package/dist/drizzle-sqlite/meta/0005_snapshot.json +1097 -0
  11. package/dist/drizzle-sqlite/meta/_journal.json +7 -0
  12. package/dist/lib/{approval-BsBk2xVr.d.cts → approval-CnJJfwfi.d.mts} +30 -6
  13. package/dist/lib/{approval-LRZ6ontC.d.mts → approval-DNfUhYD5.d.cts} +30 -6
  14. package/dist/lib/{board-store-D_Mpfb84.d.mts → board-store-D2x2w1wG.d.cts} +26 -2
  15. package/dist/lib/{board-store-Dx-mw2I6.d.cts → board-store-DN5Y2U2y.d.mts} +26 -2
  16. package/dist/lib/index.cjs +3 -1
  17. package/dist/lib/index.d.cts +96 -6
  18. package/dist/lib/index.d.mts +96 -6
  19. package/dist/lib/index.mjs +2 -2
  20. package/dist/lib/{manifest-QPoQl8tn.cjs → manifest-DUGRyC7A.cjs} +1378 -300
  21. package/dist/lib/{manifest-v2e9x_C8.mjs → manifest-t4LvFgzH.mjs} +1369 -303
  22. package/dist/lib/mcp/index.cjs +35 -6
  23. package/dist/lib/mcp/index.d.cts +23 -4
  24. package/dist/lib/mcp/index.d.mts +23 -4
  25. package/dist/lib/mcp/index.mjs +35 -6
  26. package/dist/lib/{migrate-DtBeW3Fs.mjs → migrate-BUsaTJlj.mjs} +8 -1
  27. package/dist/lib/{migrate-Dca5WdYd.cjs → migrate-BoGrqK9m.cjs} +7 -0
  28. package/dist/lib/postgres/index.cjs +1 -1
  29. package/dist/lib/postgres/index.d.cts +69 -1
  30. package/dist/lib/postgres/index.d.mts +69 -1
  31. package/dist/lib/postgres/index.mjs +1 -1
  32. package/dist/lib/{roles.sql-BoOZSkBp.mjs → roles.sql-BDmmNUdx.mjs} +18 -2
  33. package/dist/lib/{roles.sql-E58x7mLS.cjs → roles.sql-CBFxSHy7.cjs} +17 -1
  34. package/dist/lib/{schema-D0MyXcej.d.cts → schema-Cwx3HosC.d.cts} +72 -0
  35. package/dist/lib/{schema-D0MyXcej.d.mts → schema-Cwx3HosC.d.mts} +72 -0
  36. package/dist/lib/sqlite/index.cjs +1 -1
  37. package/dist/lib/sqlite/index.d.cts +2 -2
  38. package/dist/lib/sqlite/index.d.mts +2 -2
  39. package/dist/lib/sqlite/index.mjs +1 -1
  40. package/dist/lib/sqlite/schema.cjs +13 -1
  41. package/dist/lib/sqlite/schema.d.cts +1 -1
  42. package/dist/lib/sqlite/schema.d.mts +1 -1
  43. package/dist/lib/sqlite/schema.mjs +13 -1
  44. package/dist/page/board.css +1622 -327
  45. package/dist/page/board.js +2 -2
  46. package/package.json +1 -1
@@ -21,8 +21,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
21
21
  }) : target, mod));
22
22
  //#endregion
23
23
  const require_migrations_path = require("./migrations-path-DBCE17dT.cjs");
24
- const require_roles_sql = require("./roles.sql-E58x7mLS.cjs");
25
- const require_migrate = require("./migrate-Dca5WdYd.cjs");
24
+ const require_roles_sql = require("./roles.sql-CBFxSHy7.cjs");
25
+ const require_migrate = require("./migrate-BoGrqK9m.cjs");
26
26
  let commander = require("commander");
27
27
  let zod = require("zod");
28
28
  let node_os = require("node:os");
@@ -77,22 +77,26 @@ const AGENT_SHELL_MARKERS = [
77
77
  {
78
78
  variable: "CLAUDECODE",
79
79
  matches: (value) => value === "1",
80
- label: "CLAUDECODE=1"
80
+ label: "CLAUDECODE=1",
81
+ tool: "claude"
81
82
  },
82
83
  {
83
84
  variable: "GEMINI_CLI",
84
85
  matches: (value) => value === "1",
85
- label: "GEMINI_CLI=1"
86
+ label: "GEMINI_CLI=1",
87
+ tool: "gemini"
86
88
  },
87
89
  {
88
90
  variable: "CURSOR_AGENT",
89
91
  matches: (value) => value !== "",
90
- label: "CURSOR_AGENT"
92
+ label: "CURSOR_AGENT",
93
+ tool: "cursor"
91
94
  },
92
95
  {
93
96
  variable: "CODEX_THREAD_ID",
94
97
  matches: (value) => value !== "",
95
- label: "CODEX_THREAD_ID"
98
+ label: "CODEX_THREAD_ID",
99
+ tool: "codex"
96
100
  }
97
101
  ];
98
102
  /**
@@ -110,6 +114,15 @@ function readAgentShellMarker(env = process.env) {
110
114
  return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.label ?? null;
111
115
  }
112
116
  /**
117
+ * The name of the agent tool this shell belongs to (`claude`, `gemini`,
118
+ * `cursor`, `codex`), `agent` when only `KANBO_ACTOR_KIND=agent` says so, or
119
+ * `null` for a person's shell. Same precedence as `readAgentShellMarker`.
120
+ */
121
+ function readAgentTool(env = process.env) {
122
+ if (readAgentShellMarker(env) === null) return null;
123
+ return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.tool ?? "agent";
124
+ }
125
+ /**
113
126
  * Every exit code this tool promises, and what it means — the capabilities
114
127
  * manifest's `exitCodes` reads this rather than naming the codes again. `0` and
115
128
  * `1` are not declared above: they need no name of their own, success and "I
@@ -283,6 +296,7 @@ const HUMAN_ONLY_ACTIONS = [
283
296
  "return",
284
297
  "sprint close",
285
298
  "columns rules",
299
+ "columns flow",
286
300
  "columns add",
287
301
  "columns rename",
288
302
  "columns move",
@@ -296,6 +310,7 @@ const HUMAN_ONLY_MESSAGES = {
296
310
  "return": describeReturnRefusal,
297
311
  "sprint close": describeSprintCloseRefusal,
298
312
  "columns rules": describeColumnRulesRefusal,
313
+ "columns flow": describeColumnRulesRefusal,
299
314
  "columns add": describeColumnChangesRefusal,
300
315
  "columns rename": describeColumnChangesRefusal,
301
316
  "columns move": describeColumnChangesRefusal,
@@ -305,11 +320,15 @@ const HUMAN_ONLY_MESSAGES = {
305
320
  "run clear-session": describeRunSessionRefRefusal,
306
321
  "run attach-session --replace": describeRunSessionRefRefusal
307
322
  };
308
- /** The actor every board write from this tool is filed under. */
323
+ /**
324
+ * The actor every board write from this tool is filed under: `KANBO_ACTOR_ID`,
325
+ * else the agent tool of an agent's shell (`claude`, `codex`, ...), else the
326
+ * operating system's user.
327
+ */
309
328
  function createCliActor() {
310
329
  return {
311
330
  kind: "external",
312
- id: process.env.KANBO_ACTOR_ID?.trim() || safeUsername(),
331
+ id: process.env.KANBO_ACTOR_ID?.trim() || readAgentTool() || safeUsername(),
313
332
  name: (0, node_os.hostname)()
314
333
  };
315
334
  }
@@ -347,6 +366,14 @@ function createPlacementActor() {
347
366
  placesForPerson: true
348
367
  };
349
368
  }
369
+ /** Who the board page writes as: the person at this machine — `KANBO_ACTOR_ID` in a person's shell, else the OS user; never an agent tool, since the page is read and typed in by a person. */
370
+ function createServeWriter() {
371
+ return {
372
+ kind: "external",
373
+ id: (isAgentShell() ? "" : process.env.KANBO_ACTOR_ID?.trim() ?? "") || safeUsername(),
374
+ name: (0, node_os.hostname)()
375
+ };
376
+ }
350
377
  /**
351
378
  * The person who owns this shell, as the board files a person. Only for a
352
379
  * caller that has already asked `isAgentShell` — `requireHumanActor` for a
@@ -764,6 +791,14 @@ const HIDDEN_ACTIVITY_FIELDS = /* @__PURE__ */ new Set([
764
791
  "order",
765
792
  "waitingFor"
766
793
  ]);
794
+ /**
795
+ * The column a card was created in, recorded as a change from no column at the
796
+ * card's own creation time. The feed's `created` item already stands for it,
797
+ * and the field history keeps it.
798
+ */
799
+ function isStartingColumn(change, issue) {
800
+ return change.field === "statusId" && change.fromValue === null && change.createdAt === issue.createdAt;
801
+ }
767
802
  const ACTIVITY_FIELD_BY_RAW_FIELD = {
768
803
  assigneeId: "assignee",
769
804
  description: "description",
@@ -804,7 +839,7 @@ async function listActivity(store, issueId, resolver) {
804
839
  workspaceById: resolver.workspaceById
805
840
  };
806
841
  const commentItems = await Promise.all((await store.comments.listByIssue(issueId)).map(async (comment) => await toCommentActivityItem(comment, resolver)));
807
- const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
842
+ const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).filter((change) => !isStartingColumn(change, issue)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
808
843
  return [
809
844
  {
810
845
  id: `${issue.id}:created`,
@@ -1347,7 +1382,9 @@ const UNMET_ENTRY_RULE_REASONS = [
1347
1382
  "ci_red",
1348
1383
  "approval_pending",
1349
1384
  "returned",
1350
- "not_approved"
1385
+ "not_approved",
1386
+ "not_allowed_from",
1387
+ "column_full"
1351
1388
  ];
1352
1389
  const entryRulesSchema = zod.z.array(zod.z.enum(ENTRY_RULES));
1353
1390
  /**
@@ -1494,6 +1531,65 @@ function describeColumnRefusal(details) {
1494
1531
  return [`board_column_rules_unmet: ${details.issueId ? `${details.issueId} cannot enter` : "A new card cannot be created in"} "${details.column}" yet:`, ...details.unmet.map((item) => `- ${item.rule}: ${item.detail}`)].join("\n");
1495
1532
  }
1496
1533
  //#endregion
1534
+ //#region src/domain/flow-rules.ts
1535
+ /** The two flow rules, as an unmet entry names them. */
1536
+ const FLOW_RULES = ["allowed_from", "wip_limit"];
1537
+ /** The column the standard flow guards, and the columns it lets cards in from, by slug. */
1538
+ const STANDARD_FLOW_TARGET = "done";
1539
+ const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
1540
+ /** A column's flow rules, out of what the row stores. */
1541
+ function readFlowRules(column) {
1542
+ return {
1543
+ allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
1544
+ wipLimit: column.wipLimit
1545
+ };
1546
+ }
1547
+ /** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
1548
+ function serializeAllowedFrom(allowedFrom) {
1549
+ return allowedFrom === null ? null : JSON.stringify(allowedFrom);
1550
+ }
1551
+ /**
1552
+ * The flow rules a card entering `column` does not meet, where it may come from
1553
+ * first. Pure: what the board holds arrives in `facts`. A card being created
1554
+ * comes from no column, so a column that names where cards come from does not
1555
+ * take it.
1556
+ */
1557
+ function evaluateFlowRules(card, column, rules, facts) {
1558
+ const unmet = [];
1559
+ if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
1560
+ rule: "allowed_from",
1561
+ reason: "not_allowed_from",
1562
+ detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
1563
+ });
1564
+ if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
1565
+ rule: "wip_limit",
1566
+ reason: "column_full",
1567
+ detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
1568
+ });
1569
+ return unmet;
1570
+ }
1571
+ function describeAllowedFrom(name, allowedFrom, columnNames) {
1572
+ const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
1573
+ return names.length === 0 ? `${name} accepts cards from no column; only a person may put a card here` : `${name} accepts cards from ${joinWords(names)} only`;
1574
+ }
1575
+ /** `A`, `A and B`, `A, B and C`. */
1576
+ function joinWords(words) {
1577
+ return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
1578
+ }
1579
+ /**
1580
+ * The standard flow on these columns: Done, and the columns it takes cards from
1581
+ * — In Review, and QA when there is one — in board order. `null` when the board
1582
+ * has no Done, or neither of the two.
1583
+ */
1584
+ function standardFlowOf(columns) {
1585
+ const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
1586
+ const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
1587
+ return target && sources.length > 0 ? {
1588
+ target,
1589
+ sources
1590
+ } : null;
1591
+ }
1592
+ //#endregion
1497
1593
  //#region src/ops/columns.ts
1498
1594
  /** The columns of one workspace, in board order. */
1499
1595
  async function listColumns(store, workspaceId) {
@@ -1563,6 +1659,7 @@ async function applyColumnTemplate(store, workspaceId, columns, options, scope)
1563
1659
  workspaceId,
1564
1660
  ...column
1565
1661
  }, order);
1662
+ await writeStandardFlow(tx, await listColumns(tx, workspaceId));
1566
1663
  return {
1567
1664
  statuses: await listColumns(tx, workspaceId),
1568
1665
  added: columns.map((column) => column.name)
@@ -1672,6 +1769,80 @@ async function setColumnEntryRules(store, workspaceId, nameOrId, entryRules, act
1672
1769
  return await updateColumn(tx, (await requireColumn(tx, workspaceId, nameOrId)).id, { entryRules }, actor, { tx });
1673
1770
  }, scope);
1674
1771
  }
1772
+ /** The largest WIP limit: what a Postgres `integer` column holds. */
1773
+ const MAX_WIP_LIMIT = 2147483647;
1774
+ /**
1775
+ * Set where a card may enter the column from and how many cards it holds:
1776
+ * `allowedFrom` the ids of other columns of its board (`null` for any, `[]` for
1777
+ * none), kept once each in board order; `wipLimit` a whole number from 1 to 2147483647, or `null`. A
1778
+ * person's to set, like the entry rules: an agent that could open a column
1779
+ * could walk any card into it.
1780
+ */
1781
+ async function setColumnFlowRules(store, columnId, rules, actor, scope) {
1782
+ assertPersonSetsEntryRules(actor, { statusId: columnId });
1783
+ if (rules.wipLimit !== null && !(Number.isSafeInteger(rules.wipLimit) && rules.wipLimit >= 1 && rules.wipLimit <= MAX_WIP_LIMIT)) throw new require_migrations_path.BoardError("board_flow_rule_invalid", {
1784
+ statusId: columnId,
1785
+ wipLimit: rules.wipLimit
1786
+ });
1787
+ return await runBoardWrite(store, async ({ tx }) => {
1788
+ const column = await tx.statuses.findById(columnId);
1789
+ if (!column) throw new require_migrations_path.BoardError("issue_status_not_found", { statusId: columnId });
1790
+ const allowedFrom = rules.allowedFrom === null ? null : allowedFromInBoardOrder(await listColumns(tx, column.workspaceId), column, rules.allowedFrom);
1791
+ await tx.statuses.update(columnId, {
1792
+ allowedFrom: serializeAllowedFrom(allowedFrom),
1793
+ wipLimit: rules.wipLimit
1794
+ });
1795
+ return await tx.statuses.findById(columnId);
1796
+ }, scope);
1797
+ }
1798
+ /** The ids asked for, once each and in board order — or the refusal naming those that are not another column of this board. */
1799
+ function allowedFromInBoardOrder(columns, column, requested) {
1800
+ const others = columns.filter((other) => other.id !== column.id);
1801
+ const invalid = requested.filter((id) => !others.some((other) => other.id === id));
1802
+ if (invalid.length > 0) throw new require_migrations_path.BoardError("board_flow_rule_invalid", {
1803
+ statusId: column.id,
1804
+ allowedFrom: invalid
1805
+ });
1806
+ return others.filter((other) => requested.includes(other.id)).map((other) => other.id);
1807
+ }
1808
+ /**
1809
+ * Put the standard flow on a board: Done accepts cards from In Review only —
1810
+ * and from QA, when the board has it — so nothing reaches Done but through
1811
+ * review. Done's WIP limit and every other column stay as they are. A board
1812
+ * without Done, or with neither In Review nor QA, has nothing to apply it to.
1813
+ */
1814
+ async function applyStandardFlow(store, workspaceId, actor, scope) {
1815
+ assertPersonSetsEntryRules(actor, { workspaceId });
1816
+ return await runBoardWrite(store, async ({ tx }) => {
1817
+ const columns = await listColumns(tx, workspaceId);
1818
+ if (!await writeStandardFlow(tx, columns)) {
1819
+ const hasDone = columns.some((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
1820
+ throw new require_migrations_path.BoardError("issue_status_not_found", {
1821
+ workspaceId,
1822
+ statusName: hasDone ? "In Review" : "Done"
1823
+ });
1824
+ }
1825
+ return await listColumns(tx, workspaceId);
1826
+ }, scope);
1827
+ }
1828
+ /** Done takes cards from the board's In Review and QA; `false`, with nothing written, when the board has no such columns. */
1829
+ async function writeStandardFlow(store, columns) {
1830
+ const flow = standardFlowOf(columns);
1831
+ if (!flow) return false;
1832
+ await store.statuses.update(flow.target.id, { allowedFrom: serializeAllowedFrom(flow.sources.map((column) => column.id)) });
1833
+ return true;
1834
+ }
1835
+ /**
1836
+ * Take a deleted column out of every list of where cards may come from. A list
1837
+ * it empties stays empty: a column that took cards only from it does not open
1838
+ * to every column.
1839
+ */
1840
+ async function dropFromAllowedFrom(store, workspaceId, statusId) {
1841
+ for (const other of await listColumns(store, workspaceId)) {
1842
+ const { allowedFrom } = readFlowRules(other);
1843
+ if (allowedFrom?.includes(statusId)) await store.statuses.update(other.id, { allowedFrom: serializeAllowedFrom(allowedFrom.filter((id) => id !== statusId)) });
1844
+ }
1845
+ }
1675
1846
  /**
1676
1847
  * Take an empty column off the board, and number the columns left 0..n-1 in
1677
1848
  * the same write.
@@ -1698,6 +1869,7 @@ async function deleteColumn(store, statusId, scope, actor) {
1698
1869
  hint: "Move its cards to another column first, or remove it with removeColumn and moveCardsTo."
1699
1870
  });
1700
1871
  await tx.statuses.delete(statusId);
1872
+ await dropFromAllowedFrom(tx, column.workspaceId, statusId);
1701
1873
  await renumberColumns(tx, column.workspaceId);
1702
1874
  }, scope);
1703
1875
  }
@@ -1977,24 +2149,46 @@ function pullRequestFactKeys({ owner, repo, number }) {
1977
2149
  //#endregion
1978
2150
  //#region src/ops/entry-rules.ts
1979
2151
  /**
1980
- * The column's entry rules the card does not meet (ruling 6-2). A column with
1981
- * no rules answers at once, without reading anything; otherwise only the facts
1982
- * the column's rules need are read.
2152
+ * The column's entry rules and flow rules the card does not meet, entry rules
2153
+ * first (ruling 6-2; board-workflow spec, Part 2). A column with no rules
2154
+ * answers at once, without reading anything; otherwise only the facts its
2155
+ * rules need are read. `entering` counts the cards this same write already let
2156
+ * into the column, so a bulk move is held to the WIP limit as a whole.
1983
2157
  */
1984
- async function checkColumnEntry(store, card, column) {
2158
+ async function checkColumnEntry(store, card, column, entering) {
1985
2159
  const rules = readEntryRules(column);
1986
- if (rules.length === 0) return [];
1987
- return evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
2160
+ const unmet = rules.length === 0 ? [] : evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
2161
+ const flow = readFlowRules(column);
2162
+ if (flow.allowedFrom === null && flow.wipLimit === null) return unmet;
2163
+ return [...unmet, ...evaluateFlowRules(card, column, flow, await readFlowRuleFacts(store, column, flow, entering))];
2164
+ }
2165
+ /** What the flow rules need: the board's column names when the column names where cards come from, and its card count when it has a limit. */
2166
+ async function readFlowRuleFacts(store, column, flow, entering) {
2167
+ const columnNames = flow.allowedFrom === null ? /* @__PURE__ */ new Map() : new Map((await store.statuses.listByWorkspace(column.workspaceId)).map((other) => [other.id, other.name]));
2168
+ if (flow.wipLimit === null) return {
2169
+ columnNames,
2170
+ cardsInColumn: 0
2171
+ };
2172
+ await store.statuses.lockById(column.id);
2173
+ return {
2174
+ columnNames,
2175
+ cardsInColumn: (await store.issues.listPage({
2176
+ workspaceId: column.workspaceId,
2177
+ statusIds: [column.id],
2178
+ limit: 1
2179
+ })).total + entering
2180
+ };
1988
2181
  }
1989
2182
  /**
1990
- * Hold a card at the door of a column that asks for more than it has (ruling
1991
- * 6-3). Anyone but a person — or a person's own terminal, `placesForPerson`
1992
- * (ruling 6-6) — is refused with `board_column_rules_unmet`, which
1993
- * names the column and every unmet rule; a person is let through, and the
1994
- * unmet rules come back so the caller can warn them.
2183
+ * Hold a card at the door of a column that asks for more than it has — entry
2184
+ * rules (ruling 6-3), or flow rules: a column it may not come from, or a column
2185
+ * that is full. Anyone but a person — or a person's own terminal,
2186
+ * `placesForPerson` (ruling 6-6) — is refused with `board_column_rules_unmet`,
2187
+ * which names the column and everything unmet; a person is let through, and
2188
+ * the unmet list comes back so the caller can warn them.
1995
2189
  */
1996
- async function enforceColumnEntry(store, card, column, actor) {
1997
- const unmet = await checkColumnEntry(store, card, column);
2190
+ async function enforceColumnEntry(store, card, column, actor, entering = 0) {
2191
+ const unmet = await checkColumnEntry(store, card, column, entering);
1998
2192
  if (unmet.length > 0 && actor.kind !== "user" && !actor.placesForPerson) throw new require_migrations_path.BoardError("board_column_rules_unmet", {
1999
2193
  issueId: card.id,
2000
2194
  column: column.name,
@@ -2007,14 +2201,17 @@ async function enforceColumnEntry(store, card, column, actor) {
2007
2201
  * The same door for several cards entering one column at once — a bulk status
2008
2202
  * change. All or nothing: the first card an agent may not move refuses the
2009
2203
  * whole change. A person gets back the cards that entered with rules unmet.
2204
+ * Each card counts against the column's WIP limit as the cards before it in the same change entered it.
2010
2205
  */
2011
2206
  async function enforceColumnEntryForCards(store, issueIds, statusId, actor) {
2012
2207
  const column = await store.statuses.findById(statusId);
2013
2208
  if (!column) throw new require_migrations_path.BoardError("issue_status_not_found", { statusId });
2014
2209
  const entered = [];
2210
+ let entering = 0;
2015
2211
  for (const card of await store.issues.listByIds(issueIds)) {
2016
2212
  if (card.statusId === column.id) continue;
2017
- const unmet = await enforceColumnEntry(store, card, column, actor);
2213
+ const unmet = await enforceColumnEntry(store, card, column, actor, entering);
2214
+ entering++;
2018
2215
  if (unmet.length > 0) entered.push({
2019
2216
  issueId: card.id,
2020
2217
  unmet
@@ -2169,11 +2366,24 @@ const STOPPED_BY_YOU_STATUS_LINE = "stopped by you";
2169
2366
  * the board reads the same whether the agent was launched by an app or from
2170
2367
  * somewhere else entirely. The attempt number is simply how many runs the card
2171
2368
  * has had — a card whose runs were all launched elsewhere still counts them.
2369
+ *
2370
+ * A card has one run going at a time. A start on a card whose run is still going
2371
+ * is refused with `board_card_run_active`, naming that run — whichever path it
2372
+ * came by, an app's launch, `kanbo run start`, MCP, or a second app on a shared
2373
+ * Postgres board. The card's row is locked first, so two starts racing for it
2374
+ * are settled one after the other.
2172
2375
  */
2173
2376
  async function startRun(store, issueId, input, actor, scope) {
2174
2377
  const externalSessionRef = readExternalSessionRef(input.externalSessionRef) ?? null;
2175
2378
  return await runBoardWrite(store, async ({ tx }) => {
2176
- const card = await requireCard$1(tx, issueId);
2379
+ const card = await tx.issues.lockById(issueId);
2380
+ if (!card) throw new require_migrations_path.BoardError("issue_not_found", { issueId });
2381
+ const [active] = await tx.runs.listRunning(issueId);
2382
+ if (active) throw new require_migrations_path.BoardError("board_card_run_active", {
2383
+ issueId,
2384
+ runId: active.id,
2385
+ agentName: active.agentName
2386
+ });
2177
2387
  const attempt = await tx.runs.countByIssue(issueId) + 1;
2178
2388
  const executionMode = input.executionMode ?? card.executionMode;
2179
2389
  const run = await tx.runs.create({
@@ -2439,7 +2649,8 @@ async function createCard(store, input, actor, scope) {
2439
2649
  const column = statusId === null ? null : await tx.statuses.findById(statusId);
2440
2650
  const unmet = column ? await enforceColumnEntry(tx, {
2441
2651
  id: null,
2442
- description: input.description ?? null
2652
+ description: input.description ?? null,
2653
+ statusId: null
2443
2654
  }, column, actor) : [];
2444
2655
  const milestoneId = await requireMilestoneInWorkspace(tx, workspaceId, input.milestoneId ?? null);
2445
2656
  const parentIssueId = await requireParentInWorkspace(tx, workspaceId, input.parentIssueId ?? null);
@@ -2447,8 +2658,9 @@ async function createCard(store, input, actor, scope) {
2447
2658
  const now = currentUnixSeconds();
2448
2659
  for (let attempt = 1;; attempt++) {
2449
2660
  const identity = await nextIssueIdentity(input.workspace, tx);
2661
+ let created;
2450
2662
  try {
2451
- return withUnmetRules(await tx.issues.create({
2663
+ created = await tx.issues.create({
2452
2664
  id: identity.id,
2453
2665
  workspaceId,
2454
2666
  title: input.title?.trim() || identity.id,
@@ -2472,12 +2684,25 @@ async function createCard(store, input, actor, scope) {
2472
2684
  order,
2473
2685
  createdAt: now,
2474
2686
  updatedAt: now
2475
- }), unmet);
2687
+ });
2476
2688
  } catch (error) {
2477
2689
  const numberTaken = await tx.issues.findByNumber(workspaceId, identity.number);
2478
2690
  const keyTaken = await tx.issues.findById(identity.id);
2479
2691
  if (attempt >= NUMBERING_ATTEMPTS || !numberTaken && !keyTaken) throw error;
2692
+ continue;
2480
2693
  }
2694
+ if (statusId !== null) await tx.fieldChanges.create({
2695
+ id: (0, node_crypto.randomUUID)(),
2696
+ issueId: created.id,
2697
+ field: "statusId",
2698
+ fromValue: null,
2699
+ toValue: statusId,
2700
+ actorKind: toStoredActorKind(actor),
2701
+ actorId: actor.id ?? null,
2702
+ sourceChatSessionId: actor.chatSessionId ?? null,
2703
+ createdAt: now
2704
+ });
2705
+ return withUnmetRules(created, unmet);
2481
2706
  }
2482
2707
  }, scope);
2483
2708
  }
@@ -2533,7 +2758,8 @@ async function updateCard(store, issueId, input, actor, scope) {
2533
2758
  const description = "description" in updates ? updates.description ?? null : card.description;
2534
2759
  unmet = await enforceColumnEntry(tx, {
2535
2760
  id: card.id,
2536
- description
2761
+ description,
2762
+ statusId: card.statusId
2537
2763
  }, column, actor);
2538
2764
  }
2539
2765
  if ("assigneeKind" in input) updates.assigneeKind = input.assigneeKind ?? null;
@@ -2836,12 +3062,33 @@ async function requireParentInWorkspace(store, workspaceId, parentIssueId, issue
2836
3062
  issueId,
2837
3063
  parentIssueId
2838
3064
  });
2839
- if (!await store.issues.findInWorkspace(workspaceId, parentIssueId)) throw new require_migrations_path.BoardError("issue_parent_not_found", {
3065
+ const parent = await store.issues.findInWorkspace(workspaceId, parentIssueId);
3066
+ if (!parent) throw new require_migrations_path.BoardError("issue_parent_not_found", {
2840
3067
  workspaceId,
2841
3068
  parentIssueId
2842
3069
  });
3070
+ if (issueId !== void 0 && await isUnder(store, parent, issueId)) throw new require_migrations_path.BoardError("issue_parent_cycle", {
3071
+ issueId,
3072
+ parentIssueId
3073
+ });
2843
3074
  return parentIssueId;
2844
3075
  }
3076
+ /**
3077
+ * Is the card somewhere under `ancestorId` — its parent, its parent's parent,
3078
+ * and so on up? A card put under one of its own sub-cards would make a loop no
3079
+ * reader of the board can walk to the top of. The cards already seen end the
3080
+ * walk, so a loop the data holds from before cannot hang it.
3081
+ */
3082
+ async function isUnder(store, card, ancestorId) {
3083
+ const seen = /* @__PURE__ */ new Set();
3084
+ let id = card.parentIssueId;
3085
+ while (id !== null && !seen.has(id)) {
3086
+ if (id === ancestorId) return true;
3087
+ seen.add(id);
3088
+ id = (await store.issues.findById(id))?.parentIssueId ?? null;
3089
+ }
3090
+ return false;
3091
+ }
2845
3092
  /** Is the card's own number already taken by another card in the target workspace? */
2846
3093
  async function numberTakenByAnother(store, workspaceId, card) {
2847
3094
  const holder = await store.issues.findByNumber(workspaceId, card.number);
@@ -2911,6 +3158,160 @@ async function reorderCards(store, workspaceId, orderedIds, scope) {
2911
3158
  }, scope);
2912
3159
  }
2913
3160
  //#endregion
3161
+ //#region src/ops/ready.ts
3162
+ /** The column an agent takes its next card from. */
3163
+ const READY_COLUMN_SLUG = "to_do";
3164
+ /**
3165
+ * The cards an agent may pick up right now: spelled out, nobody working on them,
3166
+ * nobody's turn but the agent's.
3167
+ *
3168
+ * "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
3169
+ * run is taken, and a card waiting for a person belongs to that person until
3170
+ * they answer. What is left is the queue — in board order, so the card someone
3171
+ * dragged to the top is the one an agent asking for work gets.
3172
+ */
3173
+ async function listReady(store, input) {
3174
+ return (await queryReady(store, input)).cards;
3175
+ }
3176
+ /**
3177
+ * One page of the ready cards, how many are ready in all, and the columns read
3178
+ * on the way — the rule above, asked of the database in one statement rather
3179
+ * than of every card on the board.
3180
+ */
3181
+ async function queryReady(store, input) {
3182
+ const columns = await listColumns(store, input.workspaceId);
3183
+ const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
3184
+ if (!column) return {
3185
+ cards: [],
3186
+ total: 0,
3187
+ columns
3188
+ };
3189
+ return {
3190
+ ...await store.issues.listPage({
3191
+ workspaceId: input.workspaceId,
3192
+ statusIds: [column.id],
3193
+ waitingForPerson: false,
3194
+ hasActiveRun: false,
3195
+ limit: input.limit,
3196
+ offset: input.offset
3197
+ }),
3198
+ columns
3199
+ };
3200
+ }
3201
+ //#endregion
3202
+ //#region src/ops/claim.ts
3203
+ /**
3204
+ * Take a card to work on, in one board write: pick it, start its run, and move
3205
+ * it into the board's first `started` column (In Progress on a standard
3206
+ * board), its entry and flow rules checked on the way in. Anything refused
3207
+ * leaves nothing written.
3208
+ *
3209
+ * Two claimers never get the same card. On SQLite the write holds the file
3210
+ * from its first read (`begin immediate`), so the second claimer reads the
3211
+ * board after the first one's commit. On Postgres the picked card's row is
3212
+ * locked (`select … for update`): a claimer that waited on the lock finds the
3213
+ * card taken, and moves on to the next ready one. The target's row is locked
3214
+ * first, always, in the order a move into it locks (column, then card), so a
3215
+ * claim and a move never wait on each other in a circle.
3216
+ */
3217
+ async function claimCard(store, input, actor, scope) {
3218
+ return await runBoardWrite(store, async ({ tx }) => {
3219
+ const columns = await listColumns(tx, input.workspaceId);
3220
+ const target = columns.find((column) => column.category === "started");
3221
+ if (!target) throw new require_migrations_path.BoardError("issue_status_not_found", {
3222
+ workspaceId: input.workspaceId,
3223
+ category: "started"
3224
+ });
3225
+ await tx.statuses.lockById(target.id);
3226
+ const toDo = columns.find((column) => normalizeStatusName(column.name) === "to_do") ?? null;
3227
+ const card = input.issueId === void 0 ? await takeFirstReady(tx, input.workspaceId, toDo) : await takeNamed(tx, input.workspaceId, input.issueId, toDo);
3228
+ const run = await startRun(tx, card.id, input.run, actor, { tx });
3229
+ return {
3230
+ card: await updateCard(tx, card.id, { statusId: target.id }, actor, { tx }),
3231
+ run,
3232
+ column: target
3233
+ };
3234
+ }, scope);
3235
+ }
3236
+ /**
3237
+ * The first ready card, locked. A card another claimer took while this one
3238
+ * waited for its lock is passed over, and the next read no longer lists it; the
3239
+ * cards passed over are counted so the loop ends with the list.
3240
+ */
3241
+ async function takeFirstReady(store, workspaceId, toDo) {
3242
+ const passed = /* @__PURE__ */ new Set();
3243
+ for (;;) {
3244
+ const { cards } = await queryReady(store, {
3245
+ workspaceId,
3246
+ limit: passed.size + 1
3247
+ });
3248
+ const candidate = cards.find((card) => !passed.has(card.id));
3249
+ if (!candidate) throw new require_migrations_path.BoardError("board_nothing_ready", { workspaceId });
3250
+ const locked = await store.issues.lockById(candidate.id);
3251
+ if (locked && await notReadyReason(store, locked, toDo) === null) return locked;
3252
+ passed.add(candidate.id);
3253
+ }
3254
+ }
3255
+ /** The named card, locked, when it is on this board and ready — or why not. */
3256
+ async function takeNamed(store, workspaceId, issueId, toDo) {
3257
+ const card = await store.issues.lockById(issueId);
3258
+ if (!card || card.workspaceId !== workspaceId) throw new require_migrations_path.BoardError("issue_not_found", { issueId });
3259
+ const reason = await notReadyReason(store, card, toDo);
3260
+ if (reason) throw new require_migrations_path.BoardError("board_card_not_ready", {
3261
+ issueId,
3262
+ reason
3263
+ });
3264
+ return card;
3265
+ }
3266
+ /** `queryReady`'s rule for one card already read: nobody on it, nobody waited for, in To Do. */
3267
+ async function notReadyReason(store, card, toDo) {
3268
+ if ((await store.runs.listRunning(card.id)).length > 0) return "run_active";
3269
+ if (card.waitingFor === "human") return "waiting_for_person";
3270
+ return toDo !== null && card.statusId === toDo.id ? null : "not_in_to_do";
3271
+ }
3272
+ //#endregion
3273
+ //#region src/ops/column-entries.ts
3274
+ /**
3275
+ * Every entry into a column on the named boards at or after `since`, in order
3276
+ * of `(at, changeId)` — the one signal an app dispatching work on column
3277
+ * changes needs. It sees every writer, an outside `kanbo` on the same database
3278
+ * included, because it reads the cards' own history.
3279
+ *
3280
+ * A page never ends inside a second: when `limit` cuts into one, the rest of
3281
+ * that second comes too, so a page may be longer than `limit`. A reader keeps
3282
+ * its cursor at the last `at` it read after a short page — a write in that
3283
+ * second may still commit — and drops the `changeId`s it has seen; after a page
3284
+ * of `limit` or more it moves on to the last `at` + 1, which a second holding
3285
+ * more entries than `limit` would otherwise never let it pass.
3286
+ */
3287
+ async function listColumnEntries(store, input) {
3288
+ const page = await store.fieldChanges.listByFieldSince("statusId", input);
3289
+ return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
3290
+ }
3291
+ /** The page with every change of its last second, not only those `limit` let in. */
3292
+ async function withWholeLastSecond(store, input, page) {
3293
+ const last = page.at(-1).createdAt;
3294
+ const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
3295
+ workspaceIds: input.workspaceIds,
3296
+ since: last,
3297
+ until: last
3298
+ });
3299
+ return [...page.filter((change) => change.createdAt < last), ...lastSecond];
3300
+ }
3301
+ /** A status change as an entry; a change that took the card out of every column enters none. */
3302
+ function toColumnEntry(change) {
3303
+ return change.toValue === null ? [] : [{
3304
+ changeId: change.id,
3305
+ issueId: change.issueId,
3306
+ workspaceId: change.workspaceId,
3307
+ fromStatusId: change.fromValue,
3308
+ toStatusId: change.toValue,
3309
+ actorKind: change.actorKind,
3310
+ actorId: change.actorId,
3311
+ at: change.createdAt
3312
+ }];
3313
+ }
3314
+ //#endregion
2914
3315
  //#region src/ops/context-refs.ts
2915
3316
  const ContextRefsJsonSchema = zod.z.string().transform((raw) => JSON.parse(raw)).pipe(zod.z.array(zod.z.string()));
2916
3317
  /** Add a context ref at the end of the card's list, recording the change in its history. */
@@ -3095,6 +3496,7 @@ function subtaskSplitSentence(parentReference) {
3095
3496
  }
3096
3497
  const SUBTASK_RULE = `${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("the parent card's id")}`;
3097
3498
  const PULL_REQUEST_RULE = "Opened a pull request? Link it to the card. Its state and CI checks then reach the card as comments on their own — nothing else tells the board a pull request exists.";
3499
+ const CLAIM_RULE = "Taking a card from To Do yourself? Claim it instead of starting a run and moving it: the claim does both in one step, and no other agent gets the same card. Finish the run when you stop.";
3098
3500
  const OWN_SESSION_RULE = "Started the work yourself, not launched by an app that tracks the run for you? Record the run when you start and finish it when you stop. kanbo fills in the Claude Code or Codex session you are in; never invent a session id.";
3099
3501
  /** Said by the `kanbo init` block and the MCP instructions, which name the comment command or tool beside it. */
3100
3502
  const COMMENT_RULE = "Findings, results, decisions and questions go on the card as comments.";
@@ -3118,6 +3520,7 @@ const BOARD_RULES = [
3118
3520
  EXECUTION_MODE_RULE,
3119
3521
  SUBTASK_RULE,
3120
3522
  PULL_REQUEST_RULE,
3523
+ CLAIM_RULE,
3121
3524
  OWN_SESSION_RULE,
3122
3525
  "Read the column descriptions above before moving a card, and do all of this through the board tools or commands you have — a statement in your reply changes nothing."
3123
3526
  ];
@@ -3133,12 +3536,14 @@ const BOARD_RULES = [
3133
3536
  async function buildPrimeText(store, workspaceId) {
3134
3537
  const columns = await listColumns(store, workspaceId);
3135
3538
  const ruled = columns.some((column) => readEntryRules(column).length > 0);
3539
+ const flowing = columns.some((column) => hasFlowRules(column));
3136
3540
  return [
3137
3541
  ...await describeReturnedCards(store, workspaceId, columns),
3138
3542
  "Columns of this board, in board order:",
3139
3543
  "",
3140
- ...columns.length > 0 ? columns.map(describeColumnLine) : ["- (this board has no columns yet)"],
3544
+ ...columns.length > 0 ? columns.map((column) => describeColumnLine(column, columns)) : ["- (this board has no columns yet)"],
3141
3545
  ...ruled ? ["", ENTRY_RULES_NOTE] : [],
3546
+ ...flowing ? ["", FLOW_RULES_NOTE] : [],
3142
3547
  "",
3143
3548
  "How this board works:",
3144
3549
  "",
@@ -3180,11 +3585,22 @@ async function describeReturnedCards(store, workspaceId, columns) {
3180
3585
  * without any reads exactly as it did before they existed.
3181
3586
  */
3182
3587
  const ENTRY_RULES_NOTE = "A column that lists requirements refuses a card that does not meet them yet, with the list of what is missing: do that work first, then move the card. A person may still move it.";
3183
- function describeColumnLine(column) {
3588
+ /** Said under the columns only when one of them has flow rules. */
3589
+ const FLOW_RULES_NOTE = "A column that says which columns it accepts cards from, or how many cards it holds, refuses a move that breaks that and says why: move the card through a column it accepts, or wait until a card leaves. A person may still move it.";
3590
+ function hasFlowRules(column) {
3591
+ const { allowedFrom, wipLimit } = readFlowRules(column);
3592
+ return allowedFrom !== null || wipLimit !== null;
3593
+ }
3594
+ function describeColumnLine(column, columns) {
3184
3595
  const slug = normalizeStatusName(column.name);
3185
3596
  const rules = readEntryRules(column);
3186
- const requires = rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : "";
3187
- return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${requires}` : `- ${column.name} (\`${slug}\`)${requires ? ` —${requires}` : ""}`;
3597
+ const said = `${rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : ""}${describeFlow(column, columns)}`;
3598
+ return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${said}` : `- ${column.name} (\`${slug}\`)${said ? ` —${said}` : ""}`;
3599
+ }
3600
+ /** Where a column takes cards from and how many it holds, as the prime text says it; nothing for a column without flow rules. */
3601
+ function describeFlow(column, columns) {
3602
+ const { allowedFrom, wipLimit } = readFlowRules(column);
3603
+ return `${allowedFrom === null ? "" : allowedFrom.length === 0 ? " Accepts no card from another column: only a person puts a card here." : ` Accepts cards from ${joinWords(columns.filter((other) => allowedFrom.includes(other.id)).map((other) => `\`${normalizeStatusName(other.name)}\``))} only.`}${wipLimit === null ? "" : ` Holds at most ${wipLimit} ${wipLimit === 1 ? "card" : "cards"}.`}`;
3188
3604
  }
3189
3605
  //#endregion
3190
3606
  //#region src/domain/pull-request-ref.ts
@@ -3354,47 +3770,6 @@ async function queryCards(store, input) {
3354
3770
  };
3355
3771
  }
3356
3772
  //#endregion
3357
- //#region src/ops/ready.ts
3358
- /** The column an agent takes its next card from. */
3359
- const READY_COLUMN_SLUG = "to_do";
3360
- /**
3361
- * The cards an agent may pick up right now: spelled out, nobody working on them,
3362
- * nobody's turn but the agent's.
3363
- *
3364
- * "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
3365
- * run is taken, and a card waiting for a person belongs to that person until
3366
- * they answer. What is left is the queue — in board order, so the card someone
3367
- * dragged to the top is the one an agent asking for work gets.
3368
- */
3369
- async function listReady(store, input) {
3370
- return (await queryReady(store, input)).cards;
3371
- }
3372
- /**
3373
- * One page of the ready cards, how many are ready in all, and the columns read
3374
- * on the way — the rule above, asked of the database in one statement rather
3375
- * than of every card on the board.
3376
- */
3377
- async function queryReady(store, input) {
3378
- const columns = await listColumns(store, input.workspaceId);
3379
- const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
3380
- if (!column) return {
3381
- cards: [],
3382
- total: 0,
3383
- columns
3384
- };
3385
- return {
3386
- ...await store.issues.listPage({
3387
- workspaceId: input.workspaceId,
3388
- statusIds: [column.id],
3389
- waitingForPerson: false,
3390
- hasActiveRun: false,
3391
- limit: input.limit,
3392
- offset: input.offset
3393
- }),
3394
- columns
3395
- };
3396
- }
3397
- //#endregion
3398
3773
  //#region src/ops/relations.ts
3399
3774
  /**
3400
3775
  * Every edge touching the card, in either direction, each with the card on its
@@ -3684,6 +4059,8 @@ function createBoardOps(store) {
3684
4059
  updateColumn: async (statusId, patch, actor, scope) => await updateColumn(store, statusId, patch, actor, scope),
3685
4060
  describeColumn: async (workspaceId, nameOrId, description, scope) => await describeColumn(store, workspaceId, nameOrId, description, scope),
3686
4061
  setColumnEntryRules: async (workspaceId, nameOrId, entryRules, actor, scope) => await setColumnEntryRules(store, workspaceId, nameOrId, entryRules, actor, scope),
4062
+ setColumnFlowRules: async (columnId, rules, actor, scope) => await setColumnFlowRules(store, columnId, rules, actor, scope),
4063
+ applyStandardFlow: async (workspaceId, actor, scope) => await applyStandardFlow(store, workspaceId, actor, scope),
3687
4064
  enforceColumnEntryForCards: async (issueIds, statusId, actor) => await enforceColumnEntryForCards(store, issueIds, statusId, actor),
3688
4065
  deleteColumn: async (statusId, scope, actor) => await deleteColumn(store, statusId, scope, actor),
3689
4066
  reorderColumns: async (workspaceId, orderedIds, scope, actor) => await reorderColumns(store, workspaceId, orderedIds, scope, actor),
@@ -3694,6 +4071,7 @@ function createBoardOps(store) {
3694
4071
  moveColumn: async (workspaceId, nameOrId, position, actor, scope) => await moveColumn$1(store, workspaceId, nameOrId, position, actor, scope),
3695
4072
  removeColumn: async (workspaceId, nameOrId, options, actor, scope) => await removeColumn$1(store, workspaceId, nameOrId, options, actor, scope),
3696
4073
  startRun: async (issueId, input, actor, scope) => await startRun(store, issueId, input, actor, scope),
4074
+ claimCard: async (input, actor, scope) => await claimCard(store, input, actor, scope),
3697
4075
  attachRunExecution: async (runId, input, scope) => await attachRunExecution(store, runId, input, scope),
3698
4076
  recordRunSessionRef: async (runId, ref, scope) => await recordRunSessionRef(store, runId, ref, scope),
3699
4077
  clearRunSessionRef: async (runId, actor, options, scope) => await clearRunSessionRef(store, runId, actor, options, scope),
@@ -3706,66 +4084,11 @@ function createBoardOps(store) {
3706
4084
  readBoardProjectionForIssue: async (issueId) => await readBoardProjectionForIssue(store, issueId),
3707
4085
  listReady: async (input) => await listReady(store, input),
3708
4086
  queryReady: async (input) => await queryReady(store, input),
4087
+ listColumnEntries: async (input) => await listColumnEntries(store, input),
3709
4088
  buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId)
3710
4089
  };
3711
4090
  }
3712
4091
  //#endregion
3713
- //#region src/domain/database-url.ts
3714
- /**
3715
- * A board's connection string, as it may be said out loud.
3716
- *
3717
- * Everything a person needs to recognise which board they are talking to is
3718
- * the host and the database name. The password is the one part that must never
3719
- * reach a terminal, a log, a `--json` result or a tool's answer — and it is not
3720
- * the only part: a connection string carries options in its query, and a
3721
- * `?password=…` there is as much a secret as the one in front of the host.
3722
- *
3723
- * It is a fact about the value rather than about the command line, which is why
3724
- * it lives here: the MCP tools hand back messages they did not write either,
3725
- * and they must not be made to carry the board tool's own runtime to say them
3726
- * safely.
3727
- */
3728
- /** What a connection string is called when there is nothing safe to print of it. */
3729
- const MASKED_UNREADABLE_URL = "postgres://***";
3730
- /** What is printed in place of a password, and of a query that may hide one. */
3731
- const MASK = "***";
3732
- /**
3733
- * A connection string inside a longer line. Anything but whitespace and the
3734
- * quotes a message may wrap it in belongs to the URL — a password is allowed
3735
- * every character a URL is, so the match has to be greedy to be safe.
3736
- */
3737
- const DATABASE_URL_PATTERN = /\bpostgres(?:ql)?:\/\/[^\s'"`]+/gi;
3738
- /**
3739
- * The connection string with everything secret taken out of it.
3740
- *
3741
- * A string this cannot take apart is not examined to find out why — it is
3742
- * replaced entirely, because whatever is wrong with it, the secret is still
3743
- * somewhere inside. A missing scheme counts as that: `user:secret@host/db`
3744
- * parses, as a `user:` URL whose whole password is in the path, and printing it
3745
- * back would say the secret out loud.
3746
- */
3747
- function maskDatabaseUrl(url) {
3748
- try {
3749
- const parsed = new URL(url);
3750
- if (!parsed.host) return MASKED_UNREADABLE_URL;
3751
- if (parsed.password) parsed.password = MASK;
3752
- if (parsed.search) parsed.search = MASK;
3753
- return parsed.toString();
3754
- } catch {
3755
- return MASKED_UNREADABLE_URL;
3756
- }
3757
- }
3758
- /**
3759
- * Every connection string inside a line of text, masked.
3760
- *
3761
- * A message this package did not write — a driver's, a migrator's — may carry
3762
- * the whole connection string in the middle of a sentence, and it reaches the
3763
- * same terminal, or the same agent, as everything else.
3764
- */
3765
- function maskDatabaseUrls(text) {
3766
- return text.replace(DATABASE_URL_PATTERN, (url) => maskDatabaseUrl(url));
3767
- }
3768
- //#endregion
3769
4092
  //#region src/cli/setup/fs-retry.ts
3770
4093
  /**
3771
4094
  * A file operation tried again while something else holds the file.
@@ -4045,6 +4368,62 @@ function ignoreBoardFile(projectDir, absoluteDbPath) {
4045
4368
  (0, node_fs.writeFileSync)(path, kept ? `${kept}\n${missing.join("\n")}\n` : `${missing.join("\n")}\n`);
4046
4369
  }
4047
4370
  //#endregion
4371
+ //#region src/domain/database-url.ts
4372
+ /**
4373
+ * A board's connection string, as it may be said out loud.
4374
+ *
4375
+ * Everything a person needs to recognise which board they are talking to is
4376
+ * the host and the database name. The password is the one part that must never
4377
+ * reach a terminal, a log, a `--json` result or a tool's answer — and it is not
4378
+ * the only part: a connection string carries options in its query, and a
4379
+ * `?password=…` there is as much a secret as the one in front of the host.
4380
+ *
4381
+ * It is a fact about the value rather than about the command line, which is why
4382
+ * it lives here: the MCP tools hand back messages they did not write either,
4383
+ * and they must not be made to carry the board tool's own runtime to say them
4384
+ * safely.
4385
+ */
4386
+ /** What a connection string is called when there is nothing safe to print of it. */
4387
+ const MASKED_UNREADABLE_URL = "postgres://***";
4388
+ /** What is printed in place of a password, and of a query that may hide one. */
4389
+ const MASK = "***";
4390
+ /**
4391
+ * A connection string inside a longer line. Anything but whitespace and the
4392
+ * quotes a message may wrap it in belongs to the URL — a password is allowed
4393
+ * every character a URL is, so the match has to be greedy to be safe.
4394
+ */
4395
+ const DATABASE_URL_PATTERN = /\bpostgres(?:ql)?:\/\/[^\s'"`]+/gi;
4396
+ /**
4397
+ * The connection string with everything secret taken out of it.
4398
+ *
4399
+ * A string this cannot take apart is not examined to find out why — it is
4400
+ * replaced entirely, because whatever is wrong with it, the secret is still
4401
+ * somewhere inside. A missing scheme counts as that: `user:secret@host/db`
4402
+ * parses, as a `user:` URL whose whole password is in the path, and printing it
4403
+ * back would say the secret out loud.
4404
+ */
4405
+ function maskDatabaseUrl(url) {
4406
+ try {
4407
+ const parsed = new URL(url);
4408
+ if (!parsed.host) return MASKED_UNREADABLE_URL;
4409
+ if (parsed.password) parsed.password = MASK;
4410
+ if (parsed.search) parsed.search = MASK;
4411
+ return parsed.toString();
4412
+ } catch {
4413
+ return MASKED_UNREADABLE_URL;
4414
+ }
4415
+ }
4416
+ /**
4417
+ * Every connection string inside a line of text, masked.
4418
+ *
4419
+ * A message this package did not write — a driver's, a migrator's — may carry
4420
+ * the whole connection string in the middle of a sentence, and it reaches the
4421
+ * same terminal, or the same agent, as everything else.
4422
+ */
4423
+ function maskDatabaseUrls(text) {
4424
+ return text.replace(DATABASE_URL_PATTERN, (url) => maskDatabaseUrl(url));
4425
+ }
4426
+ //#endregion
4048
4427
  //#region src/cli/db-target.ts
4049
4428
  /**
4050
4429
  * Which board the command works on.
@@ -4293,6 +4672,111 @@ async function openWithPostgresJs(url) {
4293
4672
  };
4294
4673
  }
4295
4674
  //#endregion
4675
+ //#region src/cli/project-registry.ts
4676
+ /**
4677
+ * Which project folders kanbo has opened a board in.
4678
+ *
4679
+ * Every project keeps its board in its own folder, so nothing else knows which
4680
+ * other projects exist. This list, in the user's home, is what lets one board
4681
+ * page offer them all. It holds a folder and a time — never a connection
4682
+ * string, never board data — and deleting it is safe: it fills again as the
4683
+ * projects are used.
4684
+ */
4685
+ /** The file's name inside kanbo's per-user folder. */
4686
+ const REGISTRY_FILE_NAME = "projects.json";
4687
+ /** The file is private to the user, as a binding is. */
4688
+ const REGISTRY_FILE_MODE = 384;
4689
+ /** The folder holding it is private to the user too. */
4690
+ const REGISTRY_DIRECTORY_MODE = 448;
4691
+ /**
4692
+ * A project noted within this many seconds is not noted again, so an ordinary
4693
+ * command does not rewrite the file: a day is fresh enough for "recently used".
4694
+ */
4695
+ const RECENTLY_NOTED_SECONDS = 86400;
4696
+ const RegisteredProjectSchema = zod.z.object({
4697
+ path: zod.z.string().min(1),
4698
+ lastSeenAt: zod.z.number().int().nonnegative()
4699
+ });
4700
+ const RegistrySchema = zod.z.object({
4701
+ schemaVersion: zod.z.literal(1),
4702
+ projects: zod.z.array(RegisteredProjectSchema)
4703
+ });
4704
+ /** `$KANBO_HOME/projects.json`, else `~/.kanbo/projects.json`. */
4705
+ function projectRegistryPath(env = process.env) {
4706
+ const home = env.KANBO_HOME?.trim();
4707
+ return (0, node_path.join)(home || (0, node_path.join)((0, node_os.homedir)(), ".kanbo"), REGISTRY_FILE_NAME);
4708
+ }
4709
+ /** The projects, most recently seen first; `[]` when the file is missing or does not parse. */
4710
+ function readProjectRegistry(path = projectRegistryPath()) {
4711
+ return readEntries(path).sort((a, b) => b.lastSeenAt - a.lastSeenAt);
4712
+ }
4713
+ /**
4714
+ * Note that `projectDir` was just used. Writes only when the folder is new or
4715
+ * was last noted more than a day ago, so ordinary commands do not rewrite the file.
4716
+ * Drops entries whose `.kanbo/binding.json` is gone.
4717
+ */
4718
+ function rememberProject(projectDir, options = {}) {
4719
+ const now = options.now ?? Math.floor(Date.now() / 1e3);
4720
+ const path = options.path ?? projectRegistryPath();
4721
+ const canonical = canonicalPath$1(projectDir);
4722
+ const entries = readEntries(path);
4723
+ const known = entries.find((entry) => entry.path === canonical);
4724
+ if (known && now - known.lastSeenAt <= RECENTLY_NOTED_SECONDS) return;
4725
+ const kept = entries.filter((entry) => entry.path !== canonical && (0, node_fs.existsSync)((0, node_path.join)(entry.path, BINDING_FILE_PATH)));
4726
+ kept.push({
4727
+ path: canonical,
4728
+ lastSeenAt: now
4729
+ });
4730
+ writeEntries(path, kept);
4731
+ }
4732
+ /**
4733
+ * `rememberProject`, for the places that must not fail because of it: a home
4734
+ * directory that cannot be written is not a reason for `kanbo card list` to fail.
4735
+ * With `KANBO_DEBUG=1` the reason is printed on stderr.
4736
+ */
4737
+ function rememberProjectQuietly(projectDir) {
4738
+ const path = projectRegistryPath();
4739
+ try {
4740
+ rememberProject(projectDir, { path });
4741
+ } catch (error) {
4742
+ if (process.env.KANBO_DEBUG === "1") console.error(`kanbo: could not note this project in ${path}: ${error instanceof Error ? error.message : String(error)}`);
4743
+ }
4744
+ }
4745
+ function canonicalPath$1(projectDir) {
4746
+ const absolute = (0, node_path.resolve)(projectDir);
4747
+ try {
4748
+ return (0, node_fs.realpathSync)(absolute);
4749
+ } catch {
4750
+ return absolute;
4751
+ }
4752
+ }
4753
+ function readEntries(path) {
4754
+ try {
4755
+ const parsed = RegistrySchema.safeParse(JSON.parse((0, node_fs.readFileSync)(path, "utf8")));
4756
+ return parsed.success ? parsed.data.projects : [];
4757
+ } catch {
4758
+ return [];
4759
+ }
4760
+ }
4761
+ function writeEntries(path, projects) {
4762
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(path), {
4763
+ recursive: true,
4764
+ mode: REGISTRY_DIRECTORY_MODE
4765
+ });
4766
+ const temporaryPath = `${path}.${process.pid}.tmp`;
4767
+ try {
4768
+ (0, node_fs.writeFileSync)(temporaryPath, `${JSON.stringify(RegistrySchema.parse({
4769
+ schemaVersion: 1,
4770
+ projects
4771
+ }), null, 2)}\n`, { mode: REGISTRY_FILE_MODE });
4772
+ (0, node_fs.chmodSync)(temporaryPath, REGISTRY_FILE_MODE);
4773
+ renameWithRetry(temporaryPath, path);
4774
+ } catch (error) {
4775
+ (0, node_fs.rmSync)(temporaryPath, { force: true });
4776
+ throw error;
4777
+ }
4778
+ }
4779
+ //#endregion
4296
4780
  //#region src/cli/schema-guard.ts
4297
4781
  const SCHEMA_OUTDATED_MESSAGE = "The app's database is older than this kanbo. Let the app it belongs to update it first.";
4298
4782
  /** The same, for a board file this package created and migrates itself. */
@@ -4306,9 +4790,10 @@ const SCHEMA_NOT_INSTALLED_MESSAGE = "This Postgres database has no kanbo board
4306
4790
  * caller's own risk but never written to — a write shaped by this build's idea
4307
4791
  * of the schema is how a person's board gets a column full of nothing. A file
4308
4792
  * of this package's own is this package's to migrate, so the same refusal
4309
- * points at `kanbo migrate` instead of at the app. Reads stay open either way,
4310
- * because a person who cannot migrate right now should still be able to look at
4311
- * their cards.
4793
+ * points at `kanbo migrate` instead of at the app. Reads are held to the lesser
4794
+ * bar of `assertReadableBoard`: a file only a stamp behind is still read, so a
4795
+ * person who cannot migrate right now can look at their cards, but one missing
4796
+ * a table or column this build reads is refused with the same sentence.
4312
4797
  */
4313
4798
  function assertWritableBoard(database, owner) {
4314
4799
  try {
@@ -4319,6 +4804,26 @@ function assertWritableBoard(database, owner) {
4319
4804
  }
4320
4805
  }
4321
4806
  /**
4807
+ * Refuse a read of a board file that lacks a table or a column this build
4808
+ * reads — a file a migration behind — with the sentence a write gets, instead
4809
+ * of letting the first statement fail on what is missing. A file whose only
4810
+ * difference is its schema stamp is still read: every statement this build
4811
+ * makes works on it, and a person who cannot migrate now can still look at
4812
+ * their cards.
4813
+ */
4814
+ function assertReadableBoard(database, owner) {
4815
+ try {
4816
+ require_migrate.assertBoardSchema(database);
4817
+ } catch (error) {
4818
+ if (!(error instanceof require_migrations_path.BoardError && error.code === "board_schema_outdated")) throw error;
4819
+ if (lacksWhatThisBuildReads(error.details)) throw new CliError(3, owner === "kanbo" ? FILE_SCHEMA_OUTDATED_MESSAGE : SCHEMA_OUTDATED_MESSAGE);
4820
+ }
4821
+ }
4822
+ /** Whether `assertBoardSchema` found a table or a column missing, rather than only another stamp. */
4823
+ function lacksWhatThisBuildReads(details) {
4824
+ return details !== void 0 && ("missingTables" in details || "missingAddedTables" in details || "missingColumns" in details);
4825
+ }
4826
+ /**
4322
4827
  * Refuse an external board this build cannot speak for — before reading it as
4323
4828
  * well as before writing to it.
4324
4829
  *
@@ -4538,15 +5043,17 @@ function withBoardOptions(command) {
4538
5043
  * as a client is talking to it. A failure on the way out closes the board first
4539
5044
  * — a caller that never received a session has nothing to close.
4540
5045
  */
4541
- async function openBoardSession(options, mode) {
4542
- const cwd = process.cwd();
5046
+ async function openBoardSession(options, mode, cwd = process.cwd()) {
4543
5047
  const target = resolveDbTarget({
4544
5048
  explicitPath: options.db,
4545
5049
  explicitUrl: options.databaseUrl,
4546
5050
  asAgent: options.asAgent,
4547
5051
  cwd
4548
5052
  });
4549
- return target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
5053
+ const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
5054
+ const found = findBinding(cwd);
5055
+ if (found) rememberProjectQuietly(found.projectDir);
5056
+ return session;
4550
5057
  }
4551
5058
  async function openFileSession(target, options, mode, cwd) {
4552
5059
  const board = await require_migrate.openBoardDatabase(target.path);
@@ -4555,6 +5062,7 @@ async function openFileSession(target, options, mode, cwd) {
4555
5062
  assertWritableBoard(board.database, target.owner);
4556
5063
  };
4557
5064
  if (mode === "write") assertWritableBoard(board.database, target.owner);
5065
+ else assertReadableBoard(board.database, target.owner);
4558
5066
  const workspace = resolveWorkspace(target.owner === "kanbo" ? { kind: "file" } : {
4559
5067
  kind: "sqlite",
4560
5068
  database: board.database
@@ -4578,8 +5086,9 @@ async function openFileSession(target, options, mode, cwd) {
4578
5086
  /**
4579
5087
  * The same, on a board that is a database of its own.
4580
5088
  *
4581
- * The guard runs whatever the command came to do. A board file that this build
4582
- * cannot write to is still a board worth reading; a connection string pointing
5089
+ * The guard runs whatever the command came to do. A board file whose only
5090
+ * difference from this build is its schema stamp is still a board worth
5091
+ * reading; a connection string pointing
4583
5092
  * at a database with no board in it is nothing to read at all, and every
4584
5093
  * command would otherwise fail one statement later with the driver's own words.
4585
5094
  */
@@ -4877,7 +5386,9 @@ function projectCardActiveRun(run) {
4877
5386
  startedAt: run.startedAt
4878
5387
  };
4879
5388
  }
4880
- function projectColumn(column) {
5389
+ /** A column as the command line prints it; `board` is every column, to name the ones it accepts cards from. */
5390
+ function projectColumn(column, board) {
5391
+ const { allowedFrom, wipLimit } = readFlowRules(column);
4881
5392
  return {
4882
5393
  id: column.id,
4883
5394
  name: column.name,
@@ -4885,7 +5396,9 @@ function projectColumn(column) {
4885
5396
  description: column.description,
4886
5397
  category: column.category,
4887
5398
  order: column.order,
4888
- entryRules: readEntryRules(column)
5399
+ entryRules: readEntryRules(column),
5400
+ allowedFrom: allowedFrom === null ? null : board.filter((other) => allowedFrom.includes(other.id)).map((other) => normalizeStatusName(other.name)),
5401
+ wipLimit
4889
5402
  };
4890
5403
  }
4891
5404
  function projectSprint(sprint) {
@@ -4957,7 +5470,9 @@ function describeColumns(columns) {
4957
5470
  return columns.map((column) => [
4958
5471
  `${padToWidth(sanitizeTerminalText(column.slug), slugWidth)} ${sanitizeTerminalText(column.name)}`,
4959
5472
  column.description ? ` — ${sanitizeTerminalText(column.description)}` : "",
4960
- column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : ""
5473
+ column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : "",
5474
+ column.allowedFrom === null ? "" : ` [from: ${column.allowedFrom.length > 0 ? column.allowedFrom.join(", ") : "no column"}]`,
5475
+ column.wipLimit === null ? "" : ` [wip: ${column.wipLimit}]`
4961
5476
  ].join("")).join("\n");
4962
5477
  }
4963
5478
  /** A unix-seconds date as the UTC day it falls on, `—` for none. */
@@ -5279,6 +5794,52 @@ function registerCapabilitiesCommand(program) {
5279
5794
  });
5280
5795
  }
5281
5796
  //#endregion
5797
+ //#region src/cli/commands/claim.ts
5798
+ /**
5799
+ * `kanbo claim [card]` — take a card to work on, in one step.
5800
+ *
5801
+ * The run starts and the card moves out of To Do into the board's first
5802
+ * work-in-progress column in one write, so two agents in two terminals never
5803
+ * take the same card. Filed as a placement: in a person's own terminal the
5804
+ * column's rules warn, in an agent's shell they refuse.
5805
+ */
5806
+ function registerClaimCommand(program) {
5807
+ withBoardOptions(program.command("claim").description("Take a card to work on: start your run and move it out of To Do, in one step").argument("[card]", "the card, as MAN-012, MAN-12 or 12 (default: the first ready card)").option("--agent <name>", "your name, e.g. claude (default: the name this shell writes as)").option("--session <ref>", "your session, to find it later: claude:<session id> or codex:<session id> (default: the one Claude Code or Codex names in the environment)")).action(async (reference, options) => {
5808
+ await runBoardCommand(options, "write", async (session) => {
5809
+ const actor = createPlacementActor();
5810
+ const named = reference === void 0 ? void 0 : (await requireCard(session, reference)).id;
5811
+ const claimed = await session.ops.claimCard({
5812
+ workspaceId: session.workspace.id,
5813
+ issueId: named,
5814
+ run: {
5815
+ agentName: options.agent ?? actor.id ?? "agent",
5816
+ externalSessionRef: options.session ?? sessionRefFromEnvironment() ?? void 0,
5817
+ launchedByKind: "external"
5818
+ }
5819
+ }, actor);
5820
+ const [columns, projection, runs] = await Promise.all([
5821
+ session.ops.listColumns(session.workspace.id),
5822
+ session.ops.readBoardProjectionForIssue(claimed.card.id),
5823
+ session.ops.listRuns(claimed.card.id)
5824
+ ]);
5825
+ const card = projectCard(claimed.card, columns, projection);
5826
+ for (const item of claimed.card.unmetRules ?? []) console.error(`kanbo: warning: ${card.id} entered "${claimed.column.name}" without ${item.rule}: ${item.detail}`);
5827
+ const run = projectRun({
5828
+ ...claimed.run,
5829
+ attempt: runs.find((each) => each.id === claimed.run.id)?.attempt ?? runs.length
5830
+ });
5831
+ return {
5832
+ value: {
5833
+ card,
5834
+ run,
5835
+ column: projectColumn(claimed.column, columns)
5836
+ },
5837
+ text: `Claimed ${card.id}: run ${run.id} started, card moved to ${claimed.column.name}\nWhen you are done: kanbo run finish ${run.id} --state finished`
5838
+ };
5839
+ });
5840
+ });
5841
+ }
5842
+ //#endregion
5282
5843
  //#region src/mcp/transport.ts
5283
5844
  /** What `kanbo_card_get` may bring along with the card. */
5284
5845
  const KANBO_CARD_INCLUDES = [
@@ -5395,16 +5956,22 @@ function projectKanboSprint(sprint) {
5395
5956
  doneCards: sprint.cards.done
5396
5957
  };
5397
5958
  }
5398
- /** A column of the board, from the row either transport holds. */
5399
- function projectKanboColumn(column) {
5959
+ /** A column of the board, from the row either transport holds; `board` is every column, to name the ones it accepts cards from. */
5960
+ function projectKanboColumn(column, board) {
5400
5961
  const stored = column.entryRules;
5962
+ const allowedIds = typeof column.allowedFrom === "string" ? readFlowRules({
5963
+ allowedFrom: column.allowedFrom,
5964
+ wipLimit: null
5965
+ }).allowedFrom : column.allowedFrom ?? null;
5401
5966
  return {
5402
5967
  id: column.id,
5403
5968
  name: column.name,
5404
5969
  slug: normalizeStatusName(column.name),
5405
5970
  description: column.description,
5406
5971
  order: column.order,
5407
- entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null })
5972
+ entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null }),
5973
+ allowedFrom: allowedIds === null ? null : board.filter((other) => allowedIds.includes(other.id)).map((other) => normalizeStatusName(other.name)),
5974
+ wipLimit: column.wipLimit ?? null
5408
5975
  };
5409
5976
  }
5410
5977
  /**
@@ -5431,9 +5998,14 @@ function pageOf(cards, input) {
5431
5998
  }
5432
5999
  /** A comment as a card read lists it, from the row either transport holds. */
5433
6000
  function projectKanboCardComment(comment) {
6001
+ const decision = comment.authorKind === "system.approved" || comment.authorKind === "system.returned";
6002
+ const author = decision ? "user" : normalizeCommentAuthorKind(comment.authorKind);
6003
+ const named = author !== "system" || decision;
6004
+ const outside = author === "system" && isOutsideWriter(comment) && comment.authorKind === "system";
5434
6005
  return {
5435
6006
  id: comment.id,
5436
- author: normalizeCommentAuthorKind(comment.authorKind),
6007
+ author,
6008
+ by: named || outside ? comment.authorId ?? author : "kanbo",
5437
6009
  content: comment.content,
5438
6010
  createdAt: comment.createdAt
5439
6011
  };
@@ -5504,7 +6076,7 @@ function describeCardExtras(extras) {
5504
6076
  })))].join("\n"));
5505
6077
  if (extras.comments && extras.comments.length > 0) {
5506
6078
  const heading = extras.commentCount === extras.comments.length ? "Comments:" : `Comments (last ${extras.comments.length} of ${extras.commentCount}):`;
5507
- sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${comment.author}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
6079
+ sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${sanitizeTerminalText(comment.by)}${comment.author === "user" ? " (person)" : ""}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
5508
6080
  }
5509
6081
  if (extras.runs && extras.runs.length > 0) sections.push(["Runs:", ...extras.runs.map((run) => `#${run.attempt} ${run.state} ${sanitizeTerminalText(run.agentName)} ${formatMoment(run.startedAt)} ${run.id}`)].join("\n"));
5510
6082
  if (extras.history && extras.history.length > 0) sections.push(["History:", ...extras.history.map((change) => `${formatMoment(change.createdAt)} ${change.actor} ${change.field}: ${sanitizeTerminalText(change.from ?? "—")} → ${sanitizeTerminalText(change.to ?? "—")}`)].join("\n"));
@@ -5602,10 +6174,11 @@ function registerCardCommands(program) {
5602
6174
  }, createPlacementActor()));
5603
6175
  });
5604
6176
  });
5605
- withBoardOptions(card.command("update").description("Change a card's title, description, priority or labels").argument("<card>", "the card, as MAN-012, MAN-12 or 12").option("--title <title>", "what the card is called").option("--description <text>", "what the card is about").option("--priority <priority>", `one of ${PRIORITIES$1.join(", ")}`, parsePriority).option("--labels <labels>", "comma-separated labels, replacing the ones the card has", parseLabels).option("--execution-mode <mode>", "where the work happens: worktree (its own checkout) or main", parseExecutionMode)).action(async (reference, options) => {
6177
+ withBoardOptions(card.command("update").description("Change a card's title, description, priority, labels or parent").argument("<card>", "the card, as MAN-012, MAN-12 or 12").option("--title <title>", "what the card is called").option("--description <text>", "what the card is about").option("--priority <priority>", `one of ${PRIORITIES$1.join(", ")}`, parsePriority).option("--labels <labels>", "comma-separated labels, replacing the ones the card has", parseLabels).option("--execution-mode <mode>", "where the work happens: worktree (its own checkout) or main", parseExecutionMode).option("--parent <card>", "the card to put it under; none puts it on the top level")).action(async (reference, options) => {
5606
6178
  await runBoardCommand(options, "write", async (session) => {
5607
6179
  const input = readUpdateInput(options);
5608
6180
  const existing = await requireCard(session, reference);
6181
+ if (options.parent !== void 0) input.parentIssueId = options.parent === "none" ? null : (await requireCard(session, options.parent)).id;
5609
6182
  return await presentCard(session, await session.ops.updateCard(existing.id, input, createPlacementActor()));
5610
6183
  });
5611
6184
  });
@@ -5746,9 +6319,10 @@ async function presentCard(session, card) {
5746
6319
  };
5747
6320
  }
5748
6321
  /**
5749
- * The fields `card update` was asked to change. A command that names none of
5750
- * them is refused rather than obeyed: an empty update still bumps the board
5751
- * version, and every reader would be told the board changed when it did not.
6322
+ * The fields `card update` was asked to change — all but the parent, a card
6323
+ * of the board, looked up there once the card itself is. A command that names
6324
+ * none of them is refused rather than obeyed: an empty update still bumps the
6325
+ * board version, and every reader would be told the board changed when it did not.
5752
6326
  */
5753
6327
  function readUpdateInput(options) {
5754
6328
  const input = {};
@@ -5757,7 +6331,7 @@ function readUpdateInput(options) {
5757
6331
  if (options.priority !== void 0) input.priority = options.priority;
5758
6332
  if (options.labels !== void 0) input.labels = options.labels;
5759
6333
  if (options.executionMode !== void 0) input.executionMode = options.executionMode;
5760
- if (Object.keys(input).length === 0) throw new CliError(1, "Nothing to update. Pass --title, --description, --priority, --labels or --execution-mode.");
6334
+ if (Object.keys(input).length === 0 && options.parent === void 0) throw new CliError(1, "Nothing to update. Pass --title, --description, --priority, --labels, --execution-mode or --parent.");
5761
6335
  return input;
5762
6336
  }
5763
6337
  function parsePriority(value) {
@@ -5827,10 +6401,11 @@ const COLUMN_CATEGORIES = [
5827
6401
  ];
5828
6402
  /** The whole board's columns after a change, and the sentence that says what changed. */
5829
6403
  async function changed(session, message, extra = {}) {
5830
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
6404
+ const board = await session.ops.listColumns(session.workspace.id);
6405
+ const views = board.map((column) => projectColumn(column, board));
5831
6406
  return {
5832
6407
  value: {
5833
- ...extra,
6408
+ ...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, typeof value === "number" || Array.isArray(value) ? value : projectColumn(value, board)])),
5834
6409
  columns: views
5835
6410
  },
5836
6411
  text: `${message}\n\n${describeColumns(views)}`
@@ -5859,14 +6434,14 @@ async function addColumn(session, request, actor) {
5859
6434
  category: request.category ?? spec.category,
5860
6435
  position: anchor ? request.after !== void 0 ? { after: anchor.id } : { before: anchor.id } : void 0
5861
6436
  }, actor);
5862
- return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: projectColumn(created) });
6437
+ return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: created });
5863
6438
  }
5864
6439
  /** Said after a rename: agents' instructions name no column but To Do, and `kanbo prime` gives them the rest. */
5865
6440
  const RENAME_NOTE = "Agents read column names from kanbo prime; rename is picked up there.";
5866
6441
  async function renameColumn(session, column, newName, actor) {
5867
6442
  const before = await requireNamedColumn(session, column);
5868
6443
  const renamed = await session.ops.renameColumn(session.workspace.id, before.id, newName, actor);
5869
- return await changed(session, before.name === renamed.name ? `${renamed.name} already has that name.` : `Renamed ${before.name} to ${renamed.name} (slug ${normalizeStatusName(renamed.name)}).\n${RENAME_NOTE}`, { renamed: projectColumn(renamed) });
6444
+ return await changed(session, before.name === renamed.name ? `${renamed.name} already has that name.` : `Renamed ${before.name} to ${renamed.name} (slug ${normalizeStatusName(renamed.name)}).\n${RENAME_NOTE}`, { renamed });
5870
6445
  }
5871
6446
  /** `--first`, `--last`, `--before <column>` or `--after <column>` — exactly one. */
5872
6447
  function readColumnPosition(options) {
@@ -5898,7 +6473,7 @@ async function removeColumn(session, column, moveCardsTo, actor) {
5898
6473
  const result = await session.ops.removeColumn(session.workspace.id, removing.id, { moveCardsTo }, actor);
5899
6474
  for (const card of result.unmetRules ?? []) for (const item of card.unmet) console.error(`kanbo: warning: ${card.issueId} entered "${result.movedTo?.name ?? "—"}" without ${item.rule}: ${item.detail}`);
5900
6475
  return await changed(session, result.movedTo ? `Removed ${result.removed.name} and moved ${describeCount$1(result.movedCards)} to ${result.movedTo.name}.` : `Removed ${result.removed.name}.`, {
5901
- removed: projectColumn(result.removed),
6476
+ removed: result.removed,
5902
6477
  movedCards: result.movedCards
5903
6478
  });
5904
6479
  }
@@ -5942,6 +6517,14 @@ const BOARD_ERROR_TEXTS = {
5942
6517
  text: "Only a person can approve a card.",
5943
6518
  next: AS_A_PERSON
5944
6519
  }),
6520
+ board_card_not_ready: (details) => ({
6521
+ text: details.reason === "run_active" ? `${named(details.issueId)} is taken: it has a run going.` : details.reason === "waiting_for_person" ? `${named(details.issueId)} is waiting for a person.` : `${named(details.issueId)} is not in To Do, so it is not there to take.`,
6522
+ next: "kanbo ready"
6523
+ }),
6524
+ board_card_run_active: (details) => ({
6525
+ text: `${named(details.issueId)} already has a run going: ${named(details.agentName)}'s, run ${named(details.runId)}.`,
6526
+ next: `kanbo run finish ${named(details.runId)} --state stopped`
6527
+ }),
5945
6528
  board_column_name_taken: (details) => ({
5946
6529
  text: `Another column, ${named(details.takenBy)}, already has that name (same slug). Pick another name.`,
5947
6530
  next: "kanbo columns list"
@@ -5972,6 +6555,14 @@ const BOARD_ERROR_TEXTS = {
5972
6555
  }),
5973
6556
  board_entry_rule_invalid: (details) => ({ text: `Unknown column rule: ${list(details.rules)}. Use any of ${list(details.allowed)}.` }),
5974
6557
  board_execution_mode_locked: (details) => ({ text: `${named(details.issueId)} has been started, so where it is worked (${named(details.executionMode)}) can't change now.` }),
6558
+ board_flow_rule_invalid: (details) => details.wipLimit !== void 0 ? { text: `A column holds at least 1 card; ${named(details.wipLimit)} is not a limit. Use none for no limit.` } : {
6559
+ text: `A column accepts cards only from other columns of its own board, and ${list(details.allowedFrom)} is not one.`,
6560
+ next: "kanbo columns list"
6561
+ },
6562
+ board_nothing_ready: () => ({
6563
+ text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
6564
+ next: "kanbo ready"
6565
+ }),
5975
6566
  board_pull_request_invalid: (details) => ({ text: `"${named(details.value)}" is not a GitHub pull request. Give its link or owner/repo#number.` }),
5976
6567
  board_pull_request_not_found: (details) => ({
5977
6568
  text: `${named(details.issueId)} has no pull request link "${named(details.linkId)}".`,
@@ -6027,6 +6618,7 @@ const BOARD_ERROR_TEXTS = {
6027
6618
  text: `No card "${named(details.issueId)}" here.`,
6028
6619
  next: "kanbo card list"
6029
6620
  }),
6621
+ issue_parent_cycle: () => ({ text: "A card can't be put under one of its own sub-cards." }),
6030
6622
  issue_parent_not_found: (details) => ({
6031
6623
  text: `No card "${named(details.parentIssueId)}" here to put this one under.`,
6032
6624
  next: "kanbo card list"
@@ -6036,7 +6628,7 @@ const BOARD_ERROR_TEXTS = {
6036
6628
  issue_relation_self_reference: () => ({ text: "A card can't be linked to itself." }),
6037
6629
  issue_status_name_empty: () => ({ text: "Give the column a name." }),
6038
6630
  issue_status_not_found: (details) => ({
6039
- text: `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
6631
+ text: details.category !== void 0 ? `This board has no ${named(details.category)} column for a claimed card to go to.` : `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
6040
6632
  next: "kanbo columns list"
6041
6633
  }),
6042
6634
  issue_status_reference_conflict: () => ({ text: "Name the column once: by its id or by its name, not both." }),
@@ -6240,7 +6832,7 @@ async function runColumnsMenu(ui) {
6240
6832
  if (showList) say$1(ui, [
6241
6833
  "",
6242
6834
  "Columns of this board:",
6243
- describeColumns(columns.map(projectColumn))
6835
+ describeColumns(columns.map((column) => projectColumn(column, columns)))
6244
6836
  ].join("\n"));
6245
6837
  let choice;
6246
6838
  try {
@@ -6468,7 +7060,8 @@ function registerColumnsCommands(program) {
6468
7060
  const columns = program.command("columns").description("See and change the board's columns (alone: a menu)");
6469
7061
  withBoardOptions(columns.command("list").description("List the columns in order, with the short name to move cards by")).action(async (options) => {
6470
7062
  await runBoardCommand(options, "read", async (session) => {
6471
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
7063
+ const board = await session.ops.listColumns(session.workspace.id);
7064
+ const views = board.map((column) => projectColumn(column, board));
6472
7065
  return {
6473
7066
  value: views,
6474
7067
  text: describeColumns(views)
@@ -6477,7 +7070,7 @@ function registerColumnsCommands(program) {
6477
7070
  });
6478
7071
  withBoardOptions(columns.command("describe").description("Say in one line when a card belongs in a column").argument("<column>", "the column, by slug, name or id").requiredOption("--text <text>", "what the column means")).action(async (column, options) => {
6479
7072
  await runBoardCommand(options, "write", async (session) => {
6480
- const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text));
7073
+ const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text), await session.ops.listColumns(session.workspace.id));
6481
7074
  return {
6482
7075
  value: view,
6483
7076
  text: describeColumns([view])
@@ -6488,13 +7081,34 @@ function registerColumnsCommands(program) {
6488
7081
  const actor = requireHumanActor("columns rules", typedWords(command));
6489
7082
  const rules = readRequestedRules(options);
6490
7083
  await runBoardCommand(options, "write", async (session) => {
6491
- const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor));
7084
+ const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor), await session.ops.listColumns(session.workspace.id));
6492
7085
  return {
6493
7086
  value: view,
6494
7087
  text: describeColumns([view])
6495
7088
  };
6496
7089
  });
6497
7090
  });
7091
+ withBoardOptions(columns.command("flow").description("Set which columns a card may come from, and how many cards a column holds").argument("<column>", "the column, by slug, name or id").option("--from <columns>", "comma-separated columns a card may enter it from, or any").option("--wip <count>", "the most cards it holds, or none")).action(async (column, options, command) => {
7092
+ const actor = requireHumanActor("columns flow", typedWords(command));
7093
+ if (options.from === void 0 && options.wip === void 0) throw new CliError(1, "Pass --from <columns|any>, --wip <count|none>, or both.");
7094
+ const wipLimit = options.wip === void 0 ? void 0 : readWipLimit(options.wip);
7095
+ await runBoardCommand(options, "write", async (session) => {
7096
+ const workspaceId = session.workspace.id;
7097
+ const board = await session.ops.listColumns(workspaceId);
7098
+ const target = await session.ops.requireColumn(workspaceId, column);
7099
+ const current = readFlowRules(target);
7100
+ await session.ops.setColumnFlowRules(target.id, {
7101
+ allowedFrom: options.from === void 0 ? current.allowedFrom : readAllowedFrom(board, workspaceId, options.from),
7102
+ wipLimit: wipLimit === void 0 ? current.wipLimit : wipLimit
7103
+ }, actor);
7104
+ const after = await session.ops.listColumns(workspaceId);
7105
+ const views = after.map((each) => projectColumn(each, after));
7106
+ return {
7107
+ value: views.find((view) => view.id === target.id),
7108
+ text: describeColumns(views)
7109
+ };
7110
+ });
7111
+ });
6498
7112
  withBoardOptions(columns.command("add").description("Add a column (before Done unless you say where)").argument("<name>", "what the column is called").option("--after <column>", "put it right after this column").option("--before <column>", "put it right before this column").option("--description <text>", "when a card belongs in it, in one line agents read").addOption(new commander.Option("--category <category>", "what kind of column it is").choices(COLUMN_CATEGORIES))).action(async (name, options, command) => {
6499
7113
  const actor = requireHumanActor("columns add", typedWords(command));
6500
7114
  await runBoardCommand(options, "write", async (session) => await addColumn(session, {
@@ -6536,7 +7150,7 @@ function registerColumnsCommands(program) {
6536
7150
  mode: "add-missing",
6537
7151
  actor
6538
7152
  });
6539
- const views = statuses.map(projectColumn);
7153
+ const views = statuses.map((column) => projectColumn(column, statuses));
6540
7154
  return {
6541
7155
  value: {
6542
7156
  added,
@@ -6560,7 +7174,8 @@ function registerColumnsCommands(program) {
6560
7174
  return;
6561
7175
  }
6562
7176
  await runBoardCommand({}, "read", async (session) => {
6563
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
7177
+ const board = await session.ops.listColumns(session.workspace.id);
7178
+ const views = board.map((column) => projectColumn(column, board));
6564
7179
  return {
6565
7180
  value: views,
6566
7181
  text: describeColumns(views)
@@ -6590,6 +7205,26 @@ function readRequestedRules(options) {
6590
7205
  if (options.require === void 0) throw new CliError(1, `Pass --require ${ENTRY_RULES.join(",")} (any of them) or --clear.`);
6591
7206
  return options.require.split(",").map((rule) => rule.trim()).filter(Boolean);
6592
7207
  }
7208
+ /** `--from`: `any` for every column, else the columns named, by slug, name or id. */
7209
+ function readAllowedFrom(board, workspaceId, value) {
7210
+ if (value.trim().toLowerCase() === "any") return null;
7211
+ const names = value.split(",").map((name) => name.trim()).filter(Boolean);
7212
+ if (names.length === 0) throw new CliError(1, "Name the columns a card may come from (--from in_review,qa), or --from any.");
7213
+ return names.map((name) => {
7214
+ const found = matchColumn(board, workspaceId, name);
7215
+ if (!found) throw new require_migrations_path.BoardError("issue_status_not_found", {
7216
+ workspaceId,
7217
+ statusName: name
7218
+ });
7219
+ return found.id;
7220
+ });
7221
+ }
7222
+ /** `--wip`: `none` for no limit, else a whole number of cards, 1 or more. */
7223
+ function readWipLimit(value) {
7224
+ if (value.trim().toLowerCase() === "none") return null;
7225
+ if (!/^\d+$/.test(value.trim()) || Number(value) < 1) throw new CliError(1, "--wip takes a whole number of cards, 1 or more, or none.");
7226
+ return Number(value);
7227
+ }
6593
7228
  //#endregion
6594
7229
  //#region src/package-version.ts
6595
7230
  (0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
@@ -6599,7 +7234,7 @@ function readRequestedRules(options) {
6599
7234
  * `package.version`. A build carries it as a literal; running from source
6600
7235
  * (tests, `tsx`) reads the package's own `package.json` instead.
6601
7236
  */
6602
- const KANBO_PACKAGE_VERSION = "0.3.0";
7237
+ const KANBO_PACKAGE_VERSION = "0.5.0";
6603
7238
  //#endregion
6604
7239
  //#region src/cli/setup/paths.ts
6605
7240
  /**
@@ -6948,7 +7583,7 @@ const INSTRUCTION_END_PATTERN = /<!-- KANBO_END\s*-->/g;
6948
7583
  const POINTER_KINDS = /* @__PURE__ */ new Set(["short", "global"]);
6949
7584
  /** The commands an agent uses, spelled once for the guide and the `kanbo prime` cheat sheet. */
6950
7585
  const COMMAND = {
6951
- ready: "kanbo ready",
7586
+ claim: "kanbo claim",
6952
7587
  move: "kanbo card move <id> <column>",
6953
7588
  statusLine: "kanbo card status-line <id> --text \"...\"",
6954
7589
  subtask: "kanbo card create --description \"...\" --parent <id>",
@@ -6967,7 +7602,7 @@ const AGENT_GUIDE_TEXT = [
6967
7602
  "Work on this project is tracked on a kanbo board. Before starting a task run `kanbo prime` — it prints the board's columns and what each one means.",
6968
7603
  "",
6969
7604
  ...[
6970
- `- Take a card from \`${COMMAND.ready}\`.`,
7605
+ `- Take a card with \`${COMMAND.claim}\`: it starts your run and moves the card into work in one step, and no other agent gets the same card.`,
6971
7606
  `- ${MOVE_CARD_RULE} (\`${COMMAND.move}\`)`,
6972
7607
  `- ${STATUS_LINE_RULE} (\`${COMMAND.statusLine}\`)`,
6973
7608
  `- ${SUBTASK_RULE} (\`${COMMAND.subtask}\`)`,
@@ -6995,13 +7630,15 @@ const COMMAND_SHEET = [
6995
7630
  "- `kanbo card status-line TST-5 --text \"Running the tests\"` — what is happening on the card right now",
6996
7631
  "- `kanbo card comment TST-5 --content \"Added slugify with a test\"` — a finding, result, decision or question",
6997
7632
  "- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
7633
+ "- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
6998
7634
  "- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
6999
- "- `kanbo run start TST-5 --agent claude` — record a run you started yourself; it prints the finish command",
7635
+ "- `kanbo claim` — take the first ready card: starts your run and moves it into work in one step (`kanbo claim TST-5` for that card); it prints the finish command",
7636
+ "- `kanbo run start TST-5 --agent claude` — record a run on a card you did not claim; it prints the finish command",
7000
7637
  "- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
7001
7638
  `- \`${COMMAND.capabilities}\` — everything else, rarely needed`
7002
7639
  ].join("\n");
7003
7640
  /** How to take work and who approves it — said by both blocks after their first sentence. */
7004
- const POINTER_TAIL = `Take work with \`${COMMAND.ready}\`. ${PERSON_ONLY_RULE}`;
7641
+ const POINTER_TAIL = `Take work with \`${COMMAND.claim}\`. ${PERSON_ONLY_RULE}`;
7005
7642
  /** The body of the project block: what `kanbo instructions short` prints. */
7006
7643
  const PROJECT_BODY = [
7007
7644
  "## Kanbo board",
@@ -7021,7 +7658,7 @@ const GLOBAL_BODY = [
7021
7658
  * The block, markers included, with this body's version and hash on the start
7022
7659
  * marker — and, when given, the kind of text it is (`k=orchestrator`).
7023
7660
  */
7024
- function wrapInstructionBlock(body, version = 2, kind) {
7661
+ function wrapInstructionBlock(body, version = 3, kind) {
7025
7662
  return `<!-- KANBO_START v${version}${kind ? ` k=${kind}` : ""} h=${hashBody(body)} -->\n${body}\n${INSTRUCTION_END}`;
7026
7663
  }
7027
7664
  /** The block `kanbo init --instructions …` writes into a project's own file. */
@@ -7050,7 +7687,7 @@ function inspectBlock(text, current) {
7050
7687
  kind
7051
7688
  });
7052
7689
  if (fields.trim() === "") return inspection(LEGACY_BLOCK_BODIES.includes(body) ? "legacy" : "edited");
7053
- if (version !== void 0 && Number(version) > 2) return inspection("newer");
7690
+ if (version !== void 0 && Number(version) > 3) return inspection("newer");
7054
7691
  if (kind !== null && !POINTER_KINDS.has(kind)) return inspection("chosen");
7055
7692
  if (version === void 0 || hash === void 0 || hashBody(body) !== hash) return inspection("edited");
7056
7693
  return inspection(normalized.slice(found.start, found.end) === current ? "current" : "outdated");
@@ -8080,7 +8717,7 @@ function startUpdateCheck(deps = {}) {
8080
8717
  setTimeout: globalThis.setTimeout,
8081
8718
  clearTimeout: globalThis.clearTimeout
8082
8719
  };
8083
- const current = deps.currentVersion ?? "0.3.0";
8720
+ const current = deps.currentVersion ?? "0.5.0";
8084
8721
  const controller = new AbortController();
8085
8722
  let found = null;
8086
8723
  const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
@@ -9317,6 +9954,7 @@ async function applyInitPlan(plan) {
9317
9954
  }
9318
9955
  if (plan.target.kind === "postgres" && plan.migrate) columns = await migrateSharedBoard(plan.target.url, plan.workspace.id, plan.columns);
9319
9956
  const bindingPath = writeBinding(plan.projectDir, plan.binding);
9957
+ rememberProjectQuietly(plan.projectDir);
9320
9958
  const files = plan.fileChanges.map(({ kind, change }) => ({
9321
9959
  kind,
9322
9960
  ...applyFileChange(change)
@@ -10064,7 +10702,7 @@ async function planAgents(projectDir, agents, options) {
10064
10702
  }
10065
10703
  /** The board this project is bound to, and the workspace on it. */
10066
10704
  async function resolveBoard(projectDir, options) {
10067
- if (options.file !== void 0 || namesNoBoard(projectDir, options)) return await resolveOwnFile(projectDir, options);
10705
+ if (options.file !== void 0 || namesNoBoard$1(projectDir, options)) return await resolveOwnFile(projectDir, options);
10068
10706
  const target = resolveInitTarget(projectDir, options);
10069
10707
  if (target.kind === "postgres") return {
10070
10708
  target,
@@ -10083,7 +10721,7 @@ async function resolveBoard(projectDir, options) {
10083
10721
  * does — a person on a machine with nothing else installed gets a board, not a
10084
10722
  * refusal that names a flag.
10085
10723
  */
10086
- function namesNoBoard(projectDir, options) {
10724
+ function namesNoBoard$1(projectDir, options) {
10087
10725
  return !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DB_PATH?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(projectDir) === null;
10088
10726
  }
10089
10727
  /** The workspace a host database — or the board file this project is already bound to — says this project is. */
@@ -10511,11 +11149,12 @@ const KANBO_MCP_INSTRUCTIONS = [
10511
11149
  "kanbo is a kanban board shared by people and agents; these tools read and change its cards.",
10512
11150
  "",
10513
11151
  `- At the start of a session call ${tool("kanbo_prime")} — it returns this board's columns and what each one means.`,
10514
- `- Take work from ${tool("kanbo_ready")}.`,
11152
+ `- Take work from ${tool("kanbo_ready")}, and claim the card with ${tool("kanbo_card_claim")} before you start: it starts your run and moves the card into work in one step, so no other agent takes it.`,
10515
11153
  `- ${MOVE_CARD_RULE} (${tool("kanbo_card_move")})`,
10516
11154
  `- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
10517
11155
  `- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
10518
11156
  `- ${SUBTASK_RULE} (${tool("kanbo_card_create")} with \`parent\`)`,
11157
+ `- A card at the wrong level is moved with ${tool("kanbo_card_update")} and \`parent\` (\`"none"\` for the top level).`,
10519
11158
  `- ${WAIT_FOR_PERSON_RULE} (${tool("kanbo_wait_approval")})`,
10520
11159
  `- ${PERSON_ONLY_RULE}`,
10521
11160
  `- Every tool, rule and canonical string: the resource \`${KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI}\`.`
@@ -10601,7 +11240,7 @@ const ORCHESTRATOR_GUIDE = [
10601
11240
  "## Start of every session",
10602
11241
  "1. Run `kanbo prime` (tool: `kanbo_prime`) and read the column descriptions. On this board the description is the contract, not the name.",
10603
11242
  "2. Run `kanbo ready` (`kanbo_ready`). If nothing is ready, say so and stop.",
10604
- "3. Take the first card. Run `kanbo run start <card> --agent orchestrator` (`kanbo_run_start`) and move it to the column this board uses for work in progress — `kanbo prime` lists the columns and what each is for — (`kanbo_card_move`) before doing anything else.",
11243
+ "3. Take the first card with `kanbo claim --agent orchestrator` (`kanbo_card_claim`): it starts your run and moves the card out of To Do into the board's first work-in-progress column in one step, so no other agent takes it, and it prints the run id you finish later. If another agent got there first, run `kanbo ready` again.",
10605
11244
  "",
10606
11245
  "## While working",
10607
11246
  "- Write a status line (`kanbo card status-line <card> --text \"…\"`, `kanbo_status_line`) at every step and before and after anything that takes more than a minute. One sentence, present tense.",
@@ -10628,7 +11267,7 @@ const ORCHESTRATOR_GUIDE = [
10628
11267
  "- Returned: the reason is the newest comment. The card is back in an earlier column; start a new run and address the reason first.",
10629
11268
  "",
10630
11269
  "## Never",
10631
- "- Never run `kanbo approve`, `kanbo return`, `kanbo sprint close`, `kanbo run clear-session`, or anything that changes the columns: `kanbo columns add`, `kanbo columns rename`, `kanbo columns move`, `kanbo columns remove`, `kanbo columns template`, `kanbo columns add-standard`, `kanbo columns rules`. They are for a person and will fail in your shell anyway.",
11270
+ "- Never run `kanbo approve`, `kanbo return`, `kanbo sprint close`, `kanbo run clear-session`, or anything that changes the columns: `kanbo columns add`, `kanbo columns rename`, `kanbo columns move`, `kanbo columns remove`, `kanbo columns template`, `kanbo columns add-standard`, `kanbo columns rules`, `kanbo columns flow`. They are for a person and will fail in your shell anyway.",
10632
11271
  "- Never delete or edit the board's database directly."
10633
11272
  ].join("\n");
10634
11273
  //#endregion
@@ -10673,7 +11312,7 @@ function registerInstructionsCommand(program, dependencies = {}) {
10673
11312
  const found = parseKind(kind);
10674
11313
  const body = await readInstructionText(found);
10675
11314
  const pointer = found === "short" || found === "global";
10676
- const text = options.markers ? wrapInstructionBlock(body, 2, pointer ? void 0 : found) : body;
11315
+ const text = options.markers ? wrapInstructionBlock(body, 3, pointer ? void 0 : found) : body;
10677
11316
  const hints = process.stderr.isTTY === true;
10678
11317
  if (hints) process.stderr.write(`${HEADER}\n\n`);
10679
11318
  console.log(text);
@@ -10895,61 +11534,6 @@ const cardCommentTool = defineKanboTool({
10895
11534
  },
10896
11535
  run: async (transport, input) => await transport.cardComment(input)
10897
11536
  });
10898
- //#endregion
10899
- //#region src/mcp/tools/card-create.ts
10900
- /** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
10901
- const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pass `parent` = that card's id (its key, e.g. `MAN-012`).";
10902
- /** A new card, or a sub-card of the one being worked on. */
10903
- const cardCreateTool = defineKanboTool({
10904
- name: "kanbo_card_create",
10905
- title: "Create a card",
10906
- description: `Put a new card on this board. ${SUBTASK_SENTENCE} ${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("pass `parent`")}`,
10907
- inputSchema: {
10908
- title: zod.z.string().optional().describe("What the card is called. Leave it out and the card is titled with its own key; the description then says what the task is."),
10909
- description: zod.z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
10910
- column: zod.z.string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
10911
- parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
10912
- executionMode: executionModeArgument.optional()
10913
- },
10914
- example: {
10915
- description: "Add slugify(text) with a test",
10916
- parent: "TST-1"
10917
- },
10918
- run: async (transport, input) => await transport.cardCreate(input)
10919
- });
10920
- //#endregion
10921
- //#region src/mcp/tools/card-get.ts
10922
- /** One card in full, with what an agent reads next to it. */
10923
- const cardGetTool = defineKanboTool({
10924
- name: "kanbo_card_get",
10925
- title: "Read a card",
10926
- description: `Read one card in full — description, column, status line, whose turn it is, parent (\`parentIssueId\`) — with its last 10 comments and its subtasks (\`subCards\`), in one call. Its runs, field history and pull requests too: include: ["comments","subCards","runs","history","prs"].`,
10927
- inputSchema: {
10928
- card: cardArgument,
10929
- include: zod.z.array(zod.z.enum(KANBO_CARD_INCLUDES)).optional().describe("What to read with the card; [\"comments\",\"subCards\"] when absent, [] for the card alone."),
10930
- commentLimit: zod.z.number().int().positive().optional().describe(`How many of the latest comments to include; 10 when absent.`)
10931
- },
10932
- example: { card: "TST-5" },
10933
- run: async (transport, input) => await transport.cardGet(input)
10934
- });
10935
- //#endregion
10936
- //#region src/mcp/tools/card-link-pr.ts
10937
- /** A pull request the card's work went into, named on the card so its facts reach it. */
10938
- const cardLinkPrTool = defineKanboTool({
10939
- name: "kanbo_card_link_pr",
10940
- title: "Link a pull request to a card",
10941
- description: "Opened a pull request for this card? Link it here. Its state and CI results then reach the card as comments, without you writing them. Linking the same pull request again changes nothing.",
10942
- inputSchema: {
10943
- card: cardArgument,
10944
- url: zod.z.string().min(1).describe("The pull request, as https://github.com/<owner>/<repo>/pull/<number> or <owner>/<repo>#<number>.")
10945
- },
10946
- needs: { url: "the pull request" },
10947
- example: {
10948
- card: "TST-5",
10949
- url: "https://github.com/owner/repo/pull/12"
10950
- },
10951
- run: async (transport, input) => await transport.cardLinkPullRequest(input)
10952
- });
10953
11537
  /** The `detail` and `fields` arguments the list tools share. */
10954
11538
  const cardListViewArguments = {
10955
11539
  detail: zod.z.enum(["compact", "full"]).optional().describe("compact (default): id, title, column slug, status line, waiting, parent, run, updatedAt; returned, and the last comment of a waiting or returned card. full: every field, description included."),
@@ -10988,6 +11572,20 @@ function compactKanboCard(card) {
10988
11572
  ...card.lastComment ? { lastComment: card.lastComment } : {}
10989
11573
  };
10990
11574
  }
11575
+ /** The `detail` argument of the tools that write a card: a list's own word, for the one card a write answers with. */
11576
+ const writtenCardDetailArgument = zod.z.enum(["compact", "full"]).optional().describe("compact (default): the card as kanbo_card_list prints it. full: every field, description included.");
11577
+ /** What each tool that writes a card says about its answer, in its description. */
11578
+ const WRITTEN_CARD_ANSWER_SENTENCE = "Answers the card compact, as kanbo_card_list prints it; detail: \"full\" for every field.";
11579
+ /**
11580
+ * The card a write hands back, as the tool answers with it: compact and on one
11581
+ * line, as a list prints it. An agent writes its card at every step — a status
11582
+ * line, a move, an update — and reads each answer again on every later turn,
11583
+ * so a whole card, description included, would cost it again and again.
11584
+ * `detail: "full"` answers every field, as `kanbo_card_get` prints the card alone.
11585
+ */
11586
+ function formatWrittenKanboCard(card, detail) {
11587
+ return detail === "full" ? JSON.stringify(card, null, 2) : JSON.stringify(compactKanboCard(card));
11588
+ }
10991
11589
  function viewCard(card, view) {
10992
11590
  if (view.fields) return Object.fromEntries([["id", card.id], ...view.fields.map((field) => [field, card[field]])]);
10993
11591
  return view.detail === "full" ? card : compactKanboCard(card);
@@ -11011,6 +11609,62 @@ function formatKanboCardPage(page, view, returned) {
11011
11609
  const opening = `{${returned && returned.cards.length > 0 ? `"returned":[\n${returned.cards.map((card) => JSON.stringify(viewCard(card, view))).join(",\n")}\n],${returned.total > returned.cards.length ? `"returnedTotal":${returned.total},` : ""}` : ""}${JSON.stringify(head).slice(1, -1)}`;
11012
11610
  return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
11013
11611
  }
11612
+ //#endregion
11613
+ //#region src/mcp/tools/card-create.ts
11614
+ /** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
11615
+ const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pass `parent` = that card's id (its key, e.g. `MAN-012`).";
11616
+ /** A new card, or a sub-card of the one being worked on. */
11617
+ const cardCreateTool = defineKanboTool({
11618
+ name: "kanbo_card_create",
11619
+ title: "Create a card",
11620
+ description: `Put a new card on this board. ${SUBTASK_SENTENCE} ${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("pass `parent`")} Answers the card compact, as kanbo_card_list prints it; detail: "full" for every field.`,
11621
+ inputSchema: {
11622
+ title: zod.z.string().optional().describe("What the card is called. Leave it out and the card is titled with its own key; the description then says what the task is."),
11623
+ description: zod.z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
11624
+ column: zod.z.string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
11625
+ parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
11626
+ executionMode: executionModeArgument.optional(),
11627
+ detail: writtenCardDetailArgument
11628
+ },
11629
+ example: {
11630
+ description: "Add slugify(text) with a test",
11631
+ parent: "TST-1"
11632
+ },
11633
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardCreate(input), detail)
11634
+ });
11635
+ //#endregion
11636
+ //#region src/mcp/tools/card-get.ts
11637
+ /** One card in full, with what an agent reads next to it. */
11638
+ const cardGetTool = defineKanboTool({
11639
+ name: "kanbo_card_get",
11640
+ title: "Read a card",
11641
+ description: `Read one card in full — description, column, status line, whose turn it is, parent (\`parentIssueId\`) — with its last 10 comments and its subtasks (\`subCards\`), in one call. Its runs, field history and pull requests too: include: ["comments","subCards","runs","history","prs"].`,
11642
+ inputSchema: {
11643
+ card: cardArgument,
11644
+ include: zod.z.array(zod.z.enum(KANBO_CARD_INCLUDES)).optional().describe("What to read with the card; [\"comments\",\"subCards\"] when absent, [] for the card alone."),
11645
+ commentLimit: zod.z.number().int().positive().optional().describe(`How many of the latest comments to include; 10 when absent.`)
11646
+ },
11647
+ example: { card: "TST-5" },
11648
+ run: async (transport, input) => await transport.cardGet(input)
11649
+ });
11650
+ //#endregion
11651
+ //#region src/mcp/tools/card-link-pr.ts
11652
+ /** A pull request the card's work went into, named on the card so its facts reach it. */
11653
+ const cardLinkPrTool = defineKanboTool({
11654
+ name: "kanbo_card_link_pr",
11655
+ title: "Link a pull request to a card",
11656
+ description: "Opened a pull request for this card? Link it here. Its state and CI results then reach the card as comments, without you writing them. Linking the same pull request again changes nothing.",
11657
+ inputSchema: {
11658
+ card: cardArgument,
11659
+ url: zod.z.string().min(1).describe("The pull request, as https://github.com/<owner>/<repo>/pull/<number> or <owner>/<repo>#<number>.")
11660
+ },
11661
+ needs: { url: "the pull request" },
11662
+ example: {
11663
+ card: "TST-5",
11664
+ url: "https://github.com/owner/repo/pull/12"
11665
+ },
11666
+ run: async (transport, input) => await transport.cardLinkPullRequest(input)
11667
+ });
11014
11668
  /** The cards on the board a question picks, one page of them. */
11015
11669
  const cardListTool = defineKanboTool({
11016
11670
  name: "kanbo_card_list",
@@ -11065,10 +11719,11 @@ function readMoment(value) {
11065
11719
  const cardMoveTool = defineKanboTool({
11066
11720
  name: "kanbo_card_move",
11067
11721
  title: "Move a card",
11068
- 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.",
11722
+ description: `Move a card to another column the moment its real state changes. Read kanbo_columns first: a card in the wrong column is a lie, and a column refuses a card that does not meet its entryRules yet, that comes from a column it does not accept (allowedFrom), or that would overfill it (wipLimit), listing what is missing. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
11069
11723
  inputSchema: {
11070
11724
  card: cardArgument,
11071
- column: zod.z.string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`.")
11725
+ column: zod.z.string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`."),
11726
+ detail: writtenCardDetailArgument
11072
11727
  },
11073
11728
  aliases: { column: ["to"] },
11074
11729
  needs: { column: "the column slug, e.g. in_progress" },
@@ -11076,7 +11731,7 @@ const cardMoveTool = defineKanboTool({
11076
11731
  card: "TST-5",
11077
11732
  column: "in_progress"
11078
11733
  },
11079
- run: async (transport, input) => await transport.cardMove(input)
11734
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardMove(input), detail)
11080
11735
  });
11081
11736
  //#endregion
11082
11737
  //#region src/mcp/tools/card-pull-requests.ts
@@ -11091,11 +11746,11 @@ const cardPullRequestsTool = defineKanboTool({
11091
11746
  });
11092
11747
  //#endregion
11093
11748
  //#region src/mcp/tools/card-update.ts
11094
- /** What a card says: its title, what it is about, how urgent it is, how it is labelled. */
11749
+ /** What a card says — its title, what it is about, how urgent it is, how it is labelled — and which card it sits under. */
11095
11750
  const cardUpdateTool = defineKanboTool({
11096
11751
  name: "kanbo_card_update",
11097
11752
  title: "Update a card",
11098
- description: "Change what a card says: its title, its description, its priority, its labels or where its work happens.",
11753
+ description: `Change what a card says: its title, its description, its priority, its labels or where its work happens. A card at the wrong level moves with \`parent\`: another card's id, or "none" for the top level. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
11099
11754
  inputSchema: {
11100
11755
  card: cardArgument,
11101
11756
  title: zod.z.string().min(1).optional().describe("What the card is called."),
@@ -11108,18 +11763,26 @@ const cardUpdateTool = defineKanboTool({
11108
11763
  "urgent"
11109
11764
  ]).optional(),
11110
11765
  labels: zod.z.array(zod.z.string()).optional().describe("The labels the card carries, replacing the ones it has."),
11111
- executionMode: executionModeArgument.optional()
11766
+ executionMode: executionModeArgument.optional(),
11767
+ parent: cardArgument.optional().describe("The card to put this one under, by its id or key, e.g. MAN-012; \"none\" makes it a top-level card."),
11768
+ detail: writtenCardDetailArgument
11112
11769
  },
11113
11770
  example: {
11114
11771
  card: "TST-5",
11115
11772
  title: "Add slugify"
11116
11773
  },
11117
- run: async (transport, { card, ...patch }) => {
11118
- if (Object.keys(patch).length === 0) throw new Error("Nothing to update. Pass title, description, priority, labels or executionMode.");
11119
- return await transport.cardUpdate({
11774
+ prepare: (input) => {
11775
+ const { parent, ...rest } = input;
11776
+ return parent === null ? rest : input;
11777
+ },
11778
+ run: async (transport, { card, detail, parent, ...patch }) => {
11779
+ if (Object.keys(patch).length === 0 && parent === void 0) throw new Error("Nothing to update. Pass title, description, priority, labels, executionMode or parent.");
11780
+ const placement = parent === void 0 ? {} : { parent: parent === "none" ? null : parent };
11781
+ return formatWrittenKanboCard(await transport.cardUpdate({
11120
11782
  card,
11121
- ...patch
11122
- });
11783
+ ...patch,
11784
+ ...placement
11785
+ }), detail);
11123
11786
  }
11124
11787
  });
11125
11788
  //#endregion
@@ -11128,7 +11791,7 @@ const cardUpdateTool = defineKanboTool({
11128
11791
  const columnsTool = defineKanboTool({
11129
11792
  name: "kanbo_columns",
11130
11793
  title: "Columns of the board",
11131
- description: "List this board's columns in board order, each with the slug kanbo_card_move takes, what the column means here, and the entryRules a card must meet before you move it in.",
11794
+ description: "List this board's columns in board order, each with the slug kanbo_card_move takes, what the column means here, the entryRules a card must meet before you move it in, the columns it accepts cards from (allowedFrom, slugs; null for any) and the most cards it holds (wipLimit).",
11132
11795
  inputSchema: {},
11133
11796
  run: async (transport) => await transport.columns()
11134
11797
  });
@@ -11142,6 +11805,38 @@ const primeTool = defineKanboTool({
11142
11805
  inputSchema: {},
11143
11806
  run: async (transport) => (await transport.prime()).text
11144
11807
  });
11808
+ //#endregion
11809
+ //#region src/mcp/tools/card-claim.ts
11810
+ /**
11811
+ * Taking a card to work on, in one step: the run starts and the card moves out
11812
+ * of To Do into the board's first work-in-progress column in the same write,
11813
+ * so two agents never get the same card. Published to every host, including
11814
+ * one that records the runs of the agents it launches: a claim is how an agent
11815
+ * in any terminal takes work.
11816
+ */
11817
+ const cardClaimTool = defineKanboTool({
11818
+ name: "kanbo_card_claim",
11819
+ title: "Claim a card",
11820
+ description: "Take a card to work on, in one step: it starts your run and moves the card from To Do into the board's first work-in-progress column. Without `card`, takes the first ready card. Two agents never get the same card: the one that loses is told board_nothing_ready or board_card_not_ready. Answers the card compact, the run id, the column, and the kanbo_run_finish call that ends the run.",
11821
+ inputSchema: {
11822
+ card: cardArgument.optional(),
11823
+ agent: zod.z.string().min(1).optional().describe("What to call whoever works the card; who this server writes as when absent.")
11824
+ },
11825
+ aliases: {
11826
+ card: [...CARD_ALIASES, "issue"],
11827
+ agent: ["agentName"]
11828
+ },
11829
+ example: { card: "TST-5" },
11830
+ run: async (transport, input) => {
11831
+ const claimed = await transport.cardClaim(input);
11832
+ return {
11833
+ card: compactKanboCard(claimed.card),
11834
+ run: claimed.run.id,
11835
+ column: claimed.column.slug,
11836
+ finishWith: `kanbo_run_finish {"run":"${claimed.run.id}","state":"finished"}`
11837
+ };
11838
+ }
11839
+ });
11145
11840
  /** The cards an agent may pick up right now. */
11146
11841
  const readyTool = defineKanboTool({
11147
11842
  name: "kanbo_ready",
@@ -11230,13 +11925,14 @@ const runStartTool = defineKanboTool({
11230
11925
  /**
11231
11926
  * Every tool the board has, in the order it declares them.
11232
11927
  *
11233
- * The list is the board's, not a server's: the same sixteen are registered
11928
+ * The list is the board's, not a server's: the same seventeen are registered
11234
11929
  * whether they run against a server or against the board directly, and the
11235
11930
  * only thing a host decides is which of them it publishes.
11236
11931
  */
11237
11932
  const KANBO_TOOLS = [
11238
11933
  primeTool,
11239
11934
  readyTool,
11935
+ cardClaimTool,
11240
11936
  columnsTool,
11241
11937
  defineKanboTool({
11242
11938
  name: "kanbo_sprints",
@@ -11256,10 +11952,11 @@ const KANBO_TOOLS = [
11256
11952
  defineKanboTool({
11257
11953
  name: "kanbo_status_line",
11258
11954
  title: "Set the status line",
11259
- description: "Say what the card is doing right now: `card` and `text` (one sentence). Write one at every step, including before and after anything long-running.",
11955
+ description: "Say what the card is doing right now: `card` and `text` (one sentence). Write one at every step, including before and after anything long-running. " + WRITTEN_CARD_ANSWER_SENTENCE,
11260
11956
  inputSchema: {
11261
11957
  card: cardArgument,
11262
- text: zod.z.string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`.")
11958
+ text: zod.z.string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`."),
11959
+ detail: writtenCardDetailArgument
11263
11960
  },
11264
11961
  aliases: { text: ["content"] },
11265
11962
  needs: { text: "the status line, one sentence" },
@@ -11267,22 +11964,23 @@ const KANBO_TOOLS = [
11267
11964
  card: "TST-5",
11268
11965
  text: "Running the tests"
11269
11966
  },
11270
- run: async (transport, input) => await transport.statusLine(input)
11967
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.statusLine(input), detail)
11271
11968
  }),
11272
11969
  defineKanboTool({
11273
11970
  name: "kanbo_wait_approval",
11274
11971
  title: "Hand the card to a person",
11275
- description: "Mark the card as waiting for a person and end your turn: `card`, and `text` for the status line the person reads. A person approves or returns it; you will be told the answer.",
11972
+ description: `Mark the card as waiting for a person and end your turn: \`card\`, and \`text\` for the status line the person reads. A person approves or returns it; you will be told the answer. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
11276
11973
  inputSchema: {
11277
11974
  card: cardArgument,
11278
- text: zod.z.string().min(1).optional().describe("The status line to leave, saying what the person is being asked to look at. Also accepted as `content`.")
11975
+ text: zod.z.string().min(1).optional().describe("The status line to leave, saying what the person is being asked to look at. Also accepted as `content`."),
11976
+ detail: writtenCardDetailArgument
11279
11977
  },
11280
11978
  aliases: { text: ["content"] },
11281
11979
  example: {
11282
11980
  card: "TST-5",
11283
11981
  text: "Ready for review: slugify added with a test"
11284
11982
  },
11285
- run: async (transport, input) => await transport.waitApproval(input)
11983
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.waitApproval(input), detail)
11286
11984
  }),
11287
11985
  runStartTool,
11288
11986
  runFinishTool
@@ -11366,7 +12064,8 @@ async function createDbTransport(input) {
11366
12064
  const workspaceId = workspace.id;
11367
12065
  const actor = input.actor;
11368
12066
  async function readColumns() {
11369
- return (await ops.listColumns(workspaceId)).map(projectKanboColumn);
12067
+ const rows = await ops.listColumns(workspaceId);
12068
+ return rows.map((row) => projectKanboColumn(row, rows));
11370
12069
  }
11371
12070
  /** One card with what the run registry says about it, in the shape a tool prints. */
11372
12071
  async function project(card, columns) {
@@ -11416,7 +12115,7 @@ async function createDbTransport(input) {
11416
12115
  * the waiting and returned ones last said at most two more.
11417
12116
  */
11418
12117
  async function projectPage(page, { attend = true } = {}) {
11419
- const columns = page.columns.map(projectKanboColumn);
12118
+ const columns = page.columns.map((column) => projectKanboColumn(column, page.columns));
11420
12119
  const runs = await ops.readBoardProjectionForIssues(page.cards.map((card) => card.id));
11421
12120
  const attention = attend ? await ops.readCardAttention(page.cards, runs.map((run) => run.activeRun !== null)) : [];
11422
12121
  return page.cards.map((card, index) => projectKanboCard({
@@ -11483,10 +12182,14 @@ async function createDbTransport(input) {
11483
12182
  executionMode
11484
12183
  }, actor));
11485
12184
  },
11486
- cardUpdate: async ({ card, ...patch }) => {
12185
+ cardUpdate: async ({ card, parent, ...patch }) => {
11487
12186
  await session.assertWritable();
11488
12187
  const existing = await resolveCard(card);
11489
- return await project(await ops.updateCard(existing.id, patch, actor));
12188
+ const placement = parent === void 0 ? {} : { parentIssueId: parent === null ? null : (await resolveCard(parent)).id };
12189
+ return await project(await ops.updateCard(existing.id, {
12190
+ ...patch,
12191
+ ...placement
12192
+ }, actor));
11490
12193
  },
11491
12194
  cardMove: async ({ card, column }) => {
11492
12195
  await session.assertWritable();
@@ -11544,6 +12247,25 @@ async function createDbTransport(input) {
11544
12247
  state,
11545
12248
  errorText
11546
12249
  }, actor));
12250
+ },
12251
+ cardClaim: async ({ card, agent }) => {
12252
+ await session.assertWritable();
12253
+ const named = card === void 0 ? void 0 : (await resolveCard(card)).id;
12254
+ const claimed = await ops.claimCard({
12255
+ workspaceId,
12256
+ issueId: named,
12257
+ run: {
12258
+ agentName: agent ?? actor.id ?? "agent",
12259
+ externalSessionRef: sessionRefFromEnvironment() ?? void 0,
12260
+ launchedByKind: "external"
12261
+ }
12262
+ }, actor);
12263
+ const columns = await readColumns();
12264
+ return {
12265
+ card: await project(claimed.card, columns),
12266
+ run: projectKanboRun(claimed.run),
12267
+ column: columns.find((column) => column.id === claimed.column.id)
12268
+ };
11547
12269
  }
11548
12270
  };
11549
12271
  }
@@ -11585,7 +12307,8 @@ const TRANSPORT_METHODS = {
11585
12307
  statusLine: true,
11586
12308
  waitApproval: true,
11587
12309
  runStart: true,
11588
- runFinish: true
12310
+ runFinish: true,
12311
+ cardClaim: true
11589
12312
  };
11590
12313
  async function openMcpBoard(input) {
11591
12314
  try {
@@ -11640,13 +12363,40 @@ function withUpdateNote(board, peek) {
11640
12363
  }
11641
12364
  //#endregion
11642
12365
  //#region src/cli/commands/mcp.ts
12366
+ /**
12367
+ * The actor of a `kanbo mcp` session, which is always an agent's: `KANBO_ACTOR_ID`,
12368
+ * else the agent tool, else `agent` until the client says its name (see
12369
+ * `nameActorAfterClient`).
12370
+ */
12371
+ function createMcpActor() {
12372
+ const tool = readAgentTool();
12373
+ const namedByShell = Boolean(process.env.KANBO_ACTOR_ID?.trim() || tool && tool !== "agent");
12374
+ const actor = createCliActor();
12375
+ if (!namedByShell) actor.id = "agent";
12376
+ return {
12377
+ actor,
12378
+ namedByShell
12379
+ };
12380
+ }
12381
+ /**
12382
+ * Files the session's writes under the client's own name (`Test Client` ->
12383
+ * `test-client`) once it has initialized. `actor` is the object the board
12384
+ * holds, so later writes see the new id.
12385
+ */
12386
+ function nameActorAfterClient(server, actor) {
12387
+ server.server.oninitialized = () => {
12388
+ const id = (server.server.getClientVersion()?.name)?.trim().toLowerCase().replace(/\s+/g, "-").slice(0, 40);
12389
+ if (id) actor.id = id;
12390
+ };
12391
+ }
11643
12392
  function registerMcpCommand(program) {
11644
12393
  program.command("mcp").description("Run the board's MCP server for an agent (it starts this itself)").option("--db <path>", "the board file to open").option("--database-url <url>", "a shared Postgres board to use instead of a board file").option("--workspace <nameOrId>", "the project, when this folder is not bound to one").action(async (options) => {
12394
+ const { actor, namedByShell } = createMcpActor();
11645
12395
  const board = await openMcpBoard({
11646
12396
  dbPath: options.db,
11647
12397
  databaseUrl: options.databaseUrl,
11648
12398
  workspaceId: options.workspace,
11649
- actor: createCliActor()
12399
+ actor
11650
12400
  });
11651
12401
  let closing;
11652
12402
  const close = () => closing ??= board.close().catch((error) => {
@@ -11655,6 +12405,7 @@ function registerMcpCommand(program) {
11655
12405
  const check = isUpdateCheckDisabled() ? null : startUpdateCheck();
11656
12406
  const server = createKanboMcpServer(check ? withUpdateNote(board, check.peek) : board, { includeRunTools: true });
11657
12407
  server.server.onclose = () => void close();
12408
+ if (!namedByShell) nameActorAfterClient(server, actor);
11658
12409
  try {
11659
12410
  await server.connect(new _modelcontextprotocol_sdk_server_stdio_js.StdioServerTransport());
11660
12411
  } catch (error) {
@@ -12163,7 +12914,7 @@ function sendBoardPage(response, pathname, assets) {
12163
12914
  * answering for a person — approving a card, sending it back or closing a
12164
12915
  * sprint — as anyone but one is 403, dropping a pull request link somebody else made is the same 403
12165
12916
  * for the same reason, and acting on a run that has already ended is 409,
12166
- * because the answer would have been yes a moment earlier. An agent putting a
12917
+ * because the answer would have been yes a moment earlier. Starting a run on a card that has one going, and claiming a card nobody can take right now, are 409 for the same reason. An agent putting a
12167
12918
  * card in a column whose entry rules it does not meet yet is 409 for the same
12168
12919
  * reason turned around: the answer will be yes once the card meets them, and
12169
12920
  * `details.unmet` says what is missing. Deleting a column that still holds
@@ -12181,6 +12932,14 @@ const BOARD_ERROR_RESPONSES = {
12181
12932
  message: "Only a person can approve a card",
12182
12933
  code: "issue_approval_requires_user"
12183
12934
  },
12935
+ board_card_not_ready: {
12936
+ status: 409,
12937
+ message: "That card is not there to take: it is not in To Do, waits for a person, or has a run going"
12938
+ },
12939
+ board_card_run_active: {
12940
+ status: 409,
12941
+ message: "The card already has a run going"
12942
+ },
12184
12943
  board_column_name_taken: {
12185
12944
  status: 400,
12186
12945
  message: "Another column already has that name"
@@ -12220,6 +12979,14 @@ const BOARD_ERROR_RESPONSES = {
12220
12979
  status: 400,
12221
12980
  message: "Execution mode cannot change once the card has been launched"
12222
12981
  },
12982
+ board_flow_rule_invalid: {
12983
+ status: 400,
12984
+ message: "A column accepts cards from other columns of its own board, and holds at least one card"
12985
+ },
12986
+ board_nothing_ready: {
12987
+ status: 409,
12988
+ message: "No card in To Do is ready to take"
12989
+ },
12223
12990
  board_pull_request_invalid: {
12224
12991
  status: 400,
12225
12992
  message: "Not a GitHub pull request: give its URL or owner/repo#number"
@@ -12299,6 +13066,10 @@ const BOARD_ERROR_RESPONSES = {
12299
13066
  status: 404,
12300
13067
  message: "Issue not found"
12301
13068
  },
13069
+ issue_parent_cycle: {
13070
+ status: 400,
13071
+ message: "Issue cannot be put under one of its own sub-issues"
13072
+ },
12302
13073
  issue_parent_not_found: {
12303
13074
  status: 404,
12304
13075
  message: "Parent issue not found"
@@ -12631,6 +13402,14 @@ const startRunBody = zod.z.object({
12631
13402
  ]).optional(),
12632
13403
  externalSessionRef: zod.z.string().nullable().optional()
12633
13404
  });
13405
+ const claimBody = zod.z.object({
13406
+ workspaceId: id,
13407
+ issueId: id.optional(),
13408
+ agentName: id.optional(),
13409
+ branch: zod.z.string().nullable().optional(),
13410
+ executionMode: executionMode.optional(),
13411
+ externalSessionRef: zod.z.string().nullable().optional()
13412
+ });
12634
13413
  const finishRunBody = zod.z.object({
12635
13414
  state: zod.z.enum([
12636
13415
  "finished",
@@ -12677,13 +13456,24 @@ async function toWrittenCardView(ops, card) {
12677
13456
  function toStatusView(status) {
12678
13457
  return {
12679
13458
  ...status,
12680
- entryRules: readEntryRules(status)
13459
+ entryRules: readEntryRules(status),
13460
+ allowedFrom: readFlowRules(status).allowedFrom
12681
13461
  };
12682
13462
  }
12683
- function toCommentView(comment) {
13463
+ function toCommentView(comment, personWriterId = null) {
13464
+ if (comment.authorKind === "system.approved" || comment.authorKind === "system.returned") return {
13465
+ ...comment,
13466
+ author: {
13467
+ kind: "user",
13468
+ id: comment.authorId ?? "__self__",
13469
+ displayName: "You",
13470
+ avatarUrl: null,
13471
+ label: null
13472
+ }
13473
+ };
12684
13474
  return {
12685
13475
  ...comment,
12686
- author: resolveActor(commentAuthorReference(comment))
13476
+ author: resolveActor(commentAuthorReference(comment), personWriterId)
12687
13477
  };
12688
13478
  }
12689
13479
  /**
@@ -12691,23 +13481,34 @@ function toCommentView(comment) {
12691
13481
  *
12692
13482
  * For the kinds that need nobody else's tables: an outside writer is shown
12693
13483
  * under the name it gave, the board writing for itself is `kanbo`, and a person
12694
- * is `You`. An agent and a provider target are rows of whatever app launched
13484
+ * is `You`. So is an outside writer that is the person who started this
13485
+ * server (`personWriterId`): their own writes carry the name their shell files
13486
+ * them under. An agent and a provider target are rows of whatever app launched
12695
13487
  * them, which this server cannot read, so they are shown by the id the board
12696
13488
  * holds for them.
12697
13489
  */
12698
- function resolveActor(actor) {
13490
+ function resolveActor(actor, personWriterId) {
12699
13491
  if (actor.kind === "system") {
12700
13492
  const provenance = {
12701
13493
  authorId: actor.id,
12702
13494
  sourceChatSessionId: actor.sourceChatSessionId
12703
13495
  };
12704
- if (isOutsideWriter(provenance)) return {
12705
- kind: "system",
12706
- id: provenance.authorId,
12707
- displayName: provenance.authorId,
12708
- avatarUrl: null,
12709
- label: "External"
12710
- };
13496
+ if (isOutsideWriter(provenance)) {
13497
+ if (personWriterId !== null && provenance.authorId === personWriterId) return {
13498
+ kind: "user",
13499
+ id: provenance.authorId,
13500
+ displayName: "You",
13501
+ avatarUrl: null,
13502
+ label: null
13503
+ };
13504
+ return {
13505
+ kind: "system",
13506
+ id: provenance.authorId,
13507
+ displayName: provenance.authorId,
13508
+ avatarUrl: null,
13509
+ label: "External"
13510
+ };
13511
+ }
12711
13512
  return {
12712
13513
  kind: "system",
12713
13514
  id: null,
@@ -12743,13 +13544,13 @@ function resolveActor(actor) {
12743
13544
  * serves, named by its identity; actors as `resolveActor` reads them; and an
12744
13545
  * agent assignee by its id.
12745
13546
  */
12746
- function createActivityResolver(workspace) {
13547
+ function createActivityResolver(workspace, personWriterId = null) {
12747
13548
  return {
12748
13549
  workspaceById: /* @__PURE__ */ new Map([[workspace.id, {
12749
13550
  id: workspace.id,
12750
13551
  name: workspace.name
12751
13552
  }]]),
12752
- resolveActor: async (actor) => resolveActor(actor),
13553
+ resolveActor: async (actor) => resolveActor(actor, personWriterId),
12753
13554
  resolveAgentName: async (agentId) => agentId
12754
13555
  };
12755
13556
  }
@@ -12770,10 +13571,15 @@ function createActivityResolver(workspace) {
12770
13571
  const OK = { ok: true };
12771
13572
  /** How many cards a search answers with when the caller names no limit. */
12772
13573
  const DEFAULT_SEARCH_LIMIT = 20;
12773
- function createIssueRoutes(session) {
13574
+ /**
13575
+ * `personWriterId` is the id the person who started the server is filed under;
13576
+ * their writes read as `You`, everyone else's by name.
13577
+ */
13578
+ function createIssueRoutes(session, options = {}) {
13579
+ const personWriterId = options.personWriterId ?? null;
12774
13580
  const { ops, store, workspace } = session;
12775
13581
  const workspaceId = workspace.id;
12776
- const activityResolver = createActivityResolver(workspace);
13582
+ const activityResolver = createActivityResolver(workspace, personWriterId);
12777
13583
  /** The served workspace, when that is the one the caller named — or named none. */
12778
13584
  function requireWorkspace(named) {
12779
13585
  if (named !== void 0 && named !== workspaceId) throw new require_migrations_path.BoardError("issue_workspace_not_found", { workspaceId: named });
@@ -13058,6 +13864,15 @@ function createIssueRoutes(session) {
13058
13864
  return updated;
13059
13865
  }
13060
13866
  }),
13867
+ route({
13868
+ method: "GET",
13869
+ path: "/issues/workspace",
13870
+ handle: async () => ({
13871
+ id: workspace.id,
13872
+ identifier: workspace.identifier,
13873
+ name: workspace.name
13874
+ })
13875
+ }),
13061
13876
  route({
13062
13877
  method: "GET",
13063
13878
  path: "/issues/:id",
@@ -13077,6 +13892,31 @@ function createIssueRoutes(session) {
13077
13892
  }, actors.placement));
13078
13893
  }
13079
13894
  }),
13895
+ route({
13896
+ method: "POST",
13897
+ path: "/issues/claim",
13898
+ body: claimBody,
13899
+ write: true,
13900
+ handle: async ({ body, actors }) => {
13901
+ const named = requireWorkspace(body.workspaceId);
13902
+ if (body.issueId !== void 0) await requireCard(body.issueId);
13903
+ const claimed = await ops.claimCard({
13904
+ workspaceId: named,
13905
+ issueId: body.issueId,
13906
+ run: {
13907
+ agentName: body.agentName ?? actors.writer.id ?? "agent",
13908
+ branch: body.branch,
13909
+ executionMode: body.executionMode,
13910
+ externalSessionRef: body.externalSessionRef
13911
+ }
13912
+ }, actors.placement);
13913
+ return {
13914
+ card: await toWrittenCardView(ops, claimed.card),
13915
+ run: await toRunView(claimed.run),
13916
+ column: toStatusView(claimed.column)
13917
+ };
13918
+ }
13919
+ }),
13080
13920
  route({
13081
13921
  method: "PATCH",
13082
13922
  path: "/issues/bulk",
@@ -13211,7 +14051,7 @@ function createIssueRoutes(session) {
13211
14051
  path: "/issues/:id/comments",
13212
14052
  handle: async ({ params }) => {
13213
14053
  await requireCard(params.id);
13214
- return (await ops.listComments(params.id)).map(toCommentView);
14054
+ return (await ops.listComments(params.id)).map((comment) => toCommentView(comment, personWriterId));
13215
14055
  }
13216
14056
  }),
13217
14057
  route({
@@ -13224,7 +14064,7 @@ function createIssueRoutes(session) {
13224
14064
  return toCommentView(await ops.addComment({
13225
14065
  issueId: params.id,
13226
14066
  content: body.content
13227
- }, actors.writer));
14067
+ }, actors.person), personWriterId);
13228
14068
  }
13229
14069
  }),
13230
14070
  route({
@@ -13353,7 +14193,7 @@ function assertServeBinding(host, token) {
13353
14193
  }
13354
14194
  async function startKanboServer(options) {
13355
14195
  assertServeBinding(options.host, options.token);
13356
- const routes = createIssueRoutes(options.session);
14196
+ const routes = createIssueRoutes(options.session, { personWriterId: options.actors.writer.id });
13357
14197
  const listenHost = stripBrackets(options.host);
13358
14198
  let boundPort = 0;
13359
14199
  const handle = createRequestHandler(routes, options, () => boundPort);
@@ -13399,6 +14239,11 @@ const SAFE_METHODS = /* @__PURE__ */ new Set([
13399
14239
  "HEAD",
13400
14240
  "OPTIONS"
13401
14241
  ]);
14242
+ /** What the board page tells the server about itself. */
14243
+ const PRESENCE_BODY = zod.z.object({
14244
+ tabId: zod.z.string().min(1).max(64).regex(/^[A-Za-z0-9-]+$/),
14245
+ state: zod.z.enum(["open", "closed"])
14246
+ });
13402
14247
  function createRequestHandler(routes, options, boundPort) {
13403
14248
  const token = options.token || null;
13404
14249
  const corsOrigins = new Set(options.corsOrigins ?? []);
@@ -13459,17 +14304,41 @@ function createRequestHandler(routes, options, boundPort) {
13459
14304
  throw new ServeError(401, "unauthorized", "A valid bearer token is required");
13460
14305
  }
13461
14306
  if (!SAFE_METHODS.has(request.method ?? "GET") && !isJsonContentType(request.headers["content-type"])) throw new ServeError(415, "unsupported_media_type", "Send the request as content-type: application/json");
13462
- const matched = matchRoute(routes, request.method ?? "GET", url.pathname);
13463
- if (!matched) throw new ServeError(404, "not_found", "Not Found");
14307
+ if (url.pathname === "/page/presence" && request.method === "POST") {
14308
+ if (!options.presence) {
14309
+ response.writeHead(204).end();
14310
+ return;
14311
+ }
14312
+ const parsed = PRESENCE_BODY.safeParse(await readJsonBody(request));
14313
+ if (!parsed.success) throw validationError("body", parsed.error);
14314
+ if (parsed.data.state === "open") options.presence.seen(parsed.data.tabId);
14315
+ else options.presence.closed(parsed.data.tabId);
14316
+ sendJson(response, 200, { ok: true });
14317
+ return;
14318
+ }
14319
+ const projectPath = readProjectPath(url.pathname);
14320
+ if (url.pathname === "/projects" && request.method === "GET" && options.projects) {
14321
+ sendJson(response, 200, options.projects.list());
14322
+ return;
14323
+ }
14324
+ const matched = projectPath ? null : matchRoute(routes, request.method ?? "GET", url.pathname);
14325
+ if (!projectPath && !matched) throw new ServeError(404, "not_found", "Not Found");
14326
+ if (projectPath && !options.projects) throw new ServeError(404, "not_found", "Not Found");
13464
14327
  const body = await readJsonBody(request);
13465
14328
  const actors = resolveRequestActors(options.actors, {
13466
14329
  actorHeader: request.headers[ACTOR_HEADER],
13467
14330
  authenticated: token !== null
13468
14331
  });
13469
14332
  const result = queue.then(async () => {
13470
- if (matched.route.write) await options.session.assertWritable();
13471
- return await matched.route.run({
13472
- params: matched.params,
14333
+ const target = projectPath && options.projects ? await options.projects.resolve(projectPath.projectId) : {
14334
+ session: options.session,
14335
+ routes
14336
+ };
14337
+ const found = matched ?? matchRoute(target.routes, request.method ?? "GET", projectPath?.rest ?? url.pathname);
14338
+ if (!found) throw new ServeError(404, "not_found", "Not Found");
14339
+ if (found.route.write) await target.session.assertWritable();
14340
+ return await found.route.run({
14341
+ params: found.params,
13473
14342
  query: readQuery(url.searchParams),
13474
14343
  body,
13475
14344
  actors
@@ -13495,6 +14364,21 @@ function createRequestHandler(routes, options, boundPort) {
13495
14364
  }
13496
14365
  };
13497
14366
  }
14367
+ /** `/projects/<id>/issues/...` as the project's id and the `/issues/...` path under it; `null` for any other path. */
14368
+ function readProjectPath(pathname) {
14369
+ const segments = pathname.split("/").filter(Boolean);
14370
+ if (segments[0] !== "projects" || segments.length < 3 || segments[2] !== "issues") return null;
14371
+ let projectId;
14372
+ try {
14373
+ projectId = decodeURIComponent(segments[1]);
14374
+ } catch {
14375
+ return null;
14376
+ }
14377
+ return {
14378
+ projectId,
14379
+ rest: `/${segments.slice(2).join("/")}`
14380
+ };
14381
+ }
13498
14382
  function isJsonContentType(value) {
13499
14383
  return value?.split(";")[0].trim().toLowerCase() === "application/json";
13500
14384
  }
@@ -13638,6 +14522,146 @@ function readTextOrNull(path) {
13638
14522
  }
13639
14523
  }
13640
14524
  //#endregion
14525
+ //#region src/serve/page-presence.ts
14526
+ function createPagePresence(options) {
14527
+ const closedGraceMs = options.closedGraceMs ?? 5e3;
14528
+ const staleMs = options.staleMs ?? 12e4;
14529
+ const tickMs = options.tickMs ?? 1e4;
14530
+ const now = options.now ?? Date.now;
14531
+ const tabs = /* @__PURE__ */ new Map();
14532
+ let finished = false;
14533
+ let graceTimer = null;
14534
+ let lastTick = now();
14535
+ function stop() {
14536
+ finished = true;
14537
+ clearInterval(ticker);
14538
+ if (graceTimer) {
14539
+ clearTimeout(graceTimer);
14540
+ graceTimer = null;
14541
+ }
14542
+ }
14543
+ function idle() {
14544
+ if (finished) return;
14545
+ stop();
14546
+ options.onIdle();
14547
+ }
14548
+ const ticker = setInterval(() => {
14549
+ const current = now();
14550
+ const asleep = current - lastTick > 3 * tickMs;
14551
+ lastTick = current;
14552
+ if (asleep) {
14553
+ for (const id of tabs.keys()) tabs.set(id, current);
14554
+ return;
14555
+ }
14556
+ if (tabs.size === 0) return;
14557
+ for (const [id, lastSeen] of tabs) if (current - lastSeen > staleMs) tabs.delete(id);
14558
+ if (tabs.size === 0) idle();
14559
+ }, tickMs);
14560
+ ticker.unref();
14561
+ return {
14562
+ seen: (tabId) => {
14563
+ if (!finished) tabs.set(tabId, now());
14564
+ },
14565
+ closed: (tabId) => {
14566
+ if (finished || !tabs.delete(tabId) || tabs.size > 0) return;
14567
+ if (graceTimer) clearTimeout(graceTimer);
14568
+ graceTimer = setTimeout(() => {
14569
+ graceTimer = null;
14570
+ if (tabs.size === 0) idle();
14571
+ }, closedGraceMs);
14572
+ graceTimer.unref();
14573
+ },
14574
+ stop
14575
+ };
14576
+ }
14577
+ //#endregion
14578
+ //#region src/serve/projects.ts
14579
+ function createServeProjects(input) {
14580
+ const routesOptions = { personWriterId: input.personWriterId ?? null };
14581
+ const opened = /* @__PURE__ */ new Map();
14582
+ let defaultOpened = null;
14583
+ /** Every project that can be offered right now, default first, with the folder each lives in. */
14584
+ function entries() {
14585
+ const defaultDir = input.defaultProject?.dir ?? null;
14586
+ const dirs = [...defaultDir === null ? [] : [defaultDir], ...input.readRegistry().map((project) => project.path).filter((path) => path !== defaultDir)];
14587
+ const found = [];
14588
+ for (const dir of dirs) {
14589
+ const binding = input.readBinding(dir);
14590
+ if (binding === null && dir !== defaultDir) continue;
14591
+ found.push({
14592
+ view: viewOf(dir, binding, dir === defaultDir),
14593
+ dir
14594
+ });
14595
+ }
14596
+ return found;
14597
+ }
14598
+ return {
14599
+ list: () => entries().map((entry) => entry.view),
14600
+ resolve: async (projectId) => {
14601
+ const entry = entries().find((candidate) => candidate.view.id === projectId);
14602
+ if (!entry) throw new ServeError(404, "project_not_found", "No project with that id is on this computer's list");
14603
+ if (entry.view.isDefault && input.defaultProject) {
14604
+ defaultOpened ??= {
14605
+ session: input.defaultProject.session,
14606
+ routes: createIssueRoutes(input.defaultProject.session, routesOptions)
14607
+ };
14608
+ return defaultOpened;
14609
+ }
14610
+ const cached = opened.get(projectId);
14611
+ if (cached) return cached;
14612
+ let session;
14613
+ try {
14614
+ session = await input.openSession(entry.dir);
14615
+ } catch (error) {
14616
+ throw new ServeError(503, "project_unavailable", describeFailure(error).message);
14617
+ }
14618
+ const project = {
14619
+ session,
14620
+ routes: createIssueRoutes(session, routesOptions)
14621
+ };
14622
+ opened.set(projectId, project);
14623
+ return project;
14624
+ },
14625
+ close: async () => {
14626
+ const sessions = [...opened.values()].map((project) => project.session);
14627
+ opened.clear();
14628
+ await Promise.allSettled(sessions.map(async (session) => await session.close()));
14629
+ }
14630
+ };
14631
+ }
14632
+ function projectId(dir) {
14633
+ return (0, node_crypto.createHash)("sha256").update(dir).digest("hex").slice(0, 12);
14634
+ }
14635
+ function viewOf(dir, binding, isDefault) {
14636
+ return {
14637
+ id: projectId(dir),
14638
+ name: dir.split(/[\\/]/).filter(Boolean).pop() ?? dir,
14639
+ folder: tildify(dir),
14640
+ storage: storageOf(dir, binding),
14641
+ isDefault
14642
+ };
14643
+ }
14644
+ /** Where the board lives. The connection string is read here to name its host, and goes no further. */
14645
+ function storageOf(dir, binding) {
14646
+ if (binding?.databaseUrl) return {
14647
+ kind: "postgres",
14648
+ where: hostOf(maskDatabaseUrl(binding.databaseUrl))
14649
+ };
14650
+ return {
14651
+ kind: "file",
14652
+ path: tildify((0, node_path.resolve)(dir, binding?.dbPath ?? ".kanbo/board.db"))
14653
+ };
14654
+ }
14655
+ /** The host of an already masked connection string; a string that is not one names no host. */
14656
+ function hostOf(maskedUrl) {
14657
+ try {
14658
+ const { host } = new URL(maskedUrl);
14659
+ return host && !host.includes("*") ? host : "a Postgres database";
14660
+ } catch {
14661
+ return "a Postgres database";
14662
+ }
14663
+ }
14664
+ //#endregion
13641
14665
  //#region src/cli/commands/serve.ts
13642
14666
  function registerServeCommand(program) {
13643
14667
  program.command("serve").description("Open the board in your browser").option("--port <port>", "port to listen on", String(DEFAULT_SERVE_PORT)).option("--host <host>", "address to listen on; anything but this computer needs a token", DEFAULT_SERVE_HOST).option("--db <path>", "the board file to open").option("--database-url <url>", "a shared Postgres board to use instead of a board file").option("--workspace <nameOrId>", "the project, when this folder is not bound to one").option("--token <token>", "a password every request must carry (or set KANBO_SERVE_TOKEN); made up for you when left out").option("--cors-origin <origin>", "let pages from this web address call the board; repeat for more", collect, []).option("--no-open", "print the board page link without opening it in the browser").action(async (options) => {
@@ -13645,7 +14669,7 @@ function registerServeCommand(program) {
13645
14669
  status: (line) => console.error(line),
13646
14670
  page: (line) => console.log(line)
13647
14671
  });
13648
- await waitForInterrupt();
14672
+ if (await waitForStop(running.idle) === "idle") console.error("kanbo serve: the last board tab was closed — stopped.");
13649
14673
  try {
13650
14674
  await running.close();
13651
14675
  process.exit(0);
@@ -13657,26 +14681,46 @@ function registerServeCommand(program) {
13657
14681
  }
13658
14682
  /**
13659
14683
  * Open the board and serve it — everything `kanbo serve` does but wait. The
13660
- * command waits for Ctrl-C and ends the process; the home menu waits for
13661
- * Ctrl-C and comes back to its menu.
14684
+ * command waits for Ctrl-C (or the last board tab closing) and ends the
14685
+ * process; the home menu waits the same and comes back to its menu.
13662
14686
  */
13663
14687
  async function startServe(options, output) {
13664
14688
  const port = parsePort(options.port);
13665
14689
  const access = resolveServeAccess(options.host, options.token?.trim() || process.env.KANBO_SERVE_TOKEN?.trim() || null);
14690
+ const here = findBinding(process.cwd());
14691
+ const mostRecent = here === null && namesNoBoard(options) ? readProjectRegistry()[0] : void 0;
14692
+ const defaultDir = here?.projectDir ?? mostRecent?.path ?? null;
13666
14693
  const session = await openBoardSession({
13667
14694
  db: options.db,
13668
14695
  databaseUrl: options.databaseUrl,
13669
14696
  workspace: options.workspace
13670
- }, "read");
14697
+ }, "read", mostRecent?.path);
13671
14698
  const agentShell = isAgentShell();
13672
14699
  const actors = {
13673
- writer: createCliActor(),
14700
+ writer: createServeWriter(),
13674
14701
  person: agentShell ? null : createPersonActor()
13675
14702
  };
14703
+ const projects = createServeProjects({
14704
+ defaultProject: defaultDir === null ? null : {
14705
+ dir: defaultDir,
14706
+ session
14707
+ },
14708
+ readRegistry: () => readProjectRegistry(),
14709
+ readBinding: (dir) => readProjectBinding(dir),
14710
+ openSession: async (dir) => await openBoardSession({}, "read", dir),
14711
+ personWriterId: actors.writer.id
14712
+ });
14713
+ const opensBrowser = !agentShell && process.stdout.isTTY === true && options.open;
14714
+ let presence;
14715
+ const idle = new Promise((resolve) => {
14716
+ presence = opensBrowser ? createPagePresence({ onIdle: resolve }) : void 0;
14717
+ });
13676
14718
  let server;
13677
14719
  try {
13678
14720
  server = await startKanboServer({
13679
14721
  session,
14722
+ projects,
14723
+ presence,
13680
14724
  actors,
13681
14725
  host: options.host,
13682
14726
  port,
@@ -13684,10 +14728,12 @@ async function startServe(options, output) {
13684
14728
  corsOrigins: options.corsOrigin
13685
14729
  });
13686
14730
  } catch (error) {
14731
+ presence?.stop();
13687
14732
  await session.close();
13688
14733
  throw error;
13689
14734
  }
13690
- output.status(`kanbo serve: ${server.url} — workspace ${session.workspace.id}, bearer token required${access.generated ? " (generated for this run)" : ""}`);
14735
+ const projectCount = projects.list().length;
14736
+ output.status(`kanbo serve: ${server.url} — workspace ${session.workspace.id}, ${projectCount} ${projectCount === 1 ? "project" : "projects"}, bearer token required${access.generated ? " (generated for this run)" : ""}`);
13691
14737
  output.status(`kanbo serve: ${describePersonRights(actors)}`);
13692
14738
  presentServePage({
13693
14739
  url: server.url,
@@ -13701,25 +14747,40 @@ async function startServe(options, output) {
13701
14747
  });
13702
14748
  return {
13703
14749
  link: access.generated ? `${server.url}/#token=${access.token}` : `${server.url}/`,
14750
+ idle,
13704
14751
  close: async () => {
14752
+ presence?.stop();
13705
14753
  try {
13706
14754
  await server.close();
13707
14755
  } finally {
13708
- await session.close();
14756
+ try {
14757
+ await projects.close();
14758
+ } finally {
14759
+ await session.close();
14760
+ }
13709
14761
  }
13710
14762
  }
13711
14763
  };
13712
14764
  }
13713
- /** Settles on the first Ctrl-C (SIGINT) or SIGTERM, and stops listening for both. */
13714
- function waitForInterrupt() {
14765
+ /** Whether neither a flag nor the environment says which board to open. */
14766
+ function namesNoBoard(options) {
14767
+ return !options.db && !options.databaseUrl && !options.workspace && !process.env.KANBO_DB_PATH && !process.env.KANBO_DATABASE_URL && !process.env.KANBO_WORKSPACE_ID;
14768
+ }
14769
+ /**
14770
+ * Settles on the first Ctrl-C (SIGINT) or SIGTERM, or when `idle` settles, and
14771
+ * stops listening for both signals whichever it was.
14772
+ */
14773
+ function waitForStop(idle) {
13715
14774
  return new Promise((resolve) => {
13716
- const stop = () => {
13717
- process.off("SIGINT", stop);
13718
- process.off("SIGTERM", stop);
13719
- resolve();
14775
+ const finish = (why) => {
14776
+ process.off("SIGINT", onSignal);
14777
+ process.off("SIGTERM", onSignal);
14778
+ resolve(why);
13720
14779
  };
13721
- process.on("SIGINT", stop);
13722
- process.on("SIGTERM", stop);
14780
+ const onSignal = () => finish("interrupt");
14781
+ process.on("SIGINT", onSignal);
14782
+ process.on("SIGTERM", onSignal);
14783
+ idle.then(() => finish("idle"));
13723
14784
  });
13724
14785
  }
13725
14786
  function collect(value, previous) {
@@ -13990,6 +15051,7 @@ function registerKanboCommands(program, context = {}) {
13990
15051
  registerServeCommand(program);
13991
15052
  program.commandsGroup("For agents and integrations:");
13992
15053
  registerMcpCommand(program);
15054
+ registerClaimCommand(program);
13993
15055
  registerRunCommands(program);
13994
15056
  registerCapabilitiesCommand(program);
13995
15057
  program.commandsGroup("Shared Postgres boards:");
@@ -14013,6 +15075,7 @@ function registerKanboCommands(program, context = {}) {
14013
15075
  const KANBO_TOOL_NAMES = [
14014
15076
  "kanbo_prime",
14015
15077
  "kanbo_ready",
15078
+ "kanbo_card_claim",
14016
15079
  "kanbo_columns",
14017
15080
  "kanbo_sprints",
14018
15081
  "kanbo_card_get",
@@ -14038,12 +15101,13 @@ const KANBO_TOOL_NAMES = [
14038
15101
  const KANBO_TOOL_CLI_EQUIVALENTS = {
14039
15102
  kanbo_prime: { kanbo: "kanbo prime" },
14040
15103
  kanbo_ready: { kanbo: "kanbo ready" },
15104
+ kanbo_card_claim: { kanbo: "kanbo claim [card] [--agent <name>]" },
14041
15105
  kanbo_columns: { kanbo: "kanbo columns list" },
14042
15106
  kanbo_sprints: { kanbo: "kanbo sprint list" },
14043
15107
  kanbo_card_get: { kanbo: "kanbo card get <id>" },
14044
15108
  kanbo_card_list: { kanbo: "kanbo card list" },
14045
15109
  kanbo_card_create: { kanbo: "kanbo card create --description <text> [--title <title>] [--parent <id>]" },
14046
- kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>]" },
15110
+ kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>] [--parent <id>]" },
14047
15111
  kanbo_card_move: { kanbo: "kanbo card move <id> <column>" },
14048
15112
  kanbo_card_comment: { kanbo: "kanbo card comment <id> --content <text>" },
14049
15113
  kanbo_card_link_pr: { kanbo: "kanbo card pr add <id> <url>" },
@@ -14185,10 +15249,12 @@ function buildCapabilitiesManifest() {
14185
15249
  "Only a person may close a sprint. `kanbo sprint close` refuses an agent's shell (exit 4), and the operation refuses any actor but a person; there is no MCP tool that closes one — `kanbo_sprints` only reads. Unlike approval, the database role does not enforce this.",
14186
15250
  `The agent role (\`${require_roles_sql.BOARD_AGENT_ROLE}\`) may not delete a card at all — it holds select, insert and update on \`issues\` and nothing more, even though it may delete freely on every other board table.`,
14187
15251
  "A column may ask a card to meet entry rules before it enters (`entryRules`). Anyone but a person moving, creating or bulk-moving a card into it early is refused with `board_column_rules_unmet` and one line per unmet rule (CLI exit 1); a person goes through and is told what is missing. Only a person sets the rules — `kanbo columns rules` refuses an agent's shell (exit 4). The database role does not enforce them.",
15252
+ "A column may name the columns it accepts cards from (`allowedFrom`) and the most cards it holds (`wipLimit`). Anyone but a person moving, creating, bulk-moving or claiming a card against them is refused with `board_column_rules_unmet` (`allowed_from`, `wip_limit`); a person goes through and is told. A new board's Done accepts cards from In Review only. Only a person sets them — `kanbo columns flow` refuses an agent's shell (exit 4). The database role does not enforce them.",
15253
+ "A claim (`kanbo claim`, `kanbo_card_claim`, `POST /issues/claim`) takes a ready card from To Do, starts its run and moves it into the board's first `started` column in one write; two claimers never get the same card, and the one that loses is told `board_nothing_ready` or `board_card_not_ready`. A card has one run going at a time: starting a second is refused with `board_card_run_active`, on every path.",
14188
15254
  "Only a person changes the board's columns: `kanbo columns add`, `rename`, `move`, `remove`, `template` and `add-standard` refuse an agent's shell (exit 4), and the rename, move, remove and add-missing operations refuse any actor but a person (`board_column_structure_requires_user`); no MCP tool changes columns. To Do can be neither removed nor renamed away from its slug `to_do`: `kanbo ready` takes work from it.",
14189
15255
  "No board tool or command deletes a card, agent role or not. Cancelling one is a move to another column. The one exception is `kanbo serve`'s `DELETE /issues/:id`, which it answers only for a request presenting its token, on a server a person started, that has not sent `x-kanbo-actor: agent` (403 `issue_delete_requires_user` otherwise).",
14190
15256
  "`kanbo migrate` and `kanbo roles apply` refuse a host app's own database file: the app migrates it, and this package never touches it.",
14191
- "A write to a board older than this build's schema epoch is refused (exit 3) until `kanbo migrate` runs (on a host app's database, until the app migrates it) — reads stay open either way.",
15257
+ "A write to a board older than this build's schema epoch is refused (exit 3) until `kanbo migrate` runs (on a host app's database, until the app migrates it), and so is a read of a board file that lacks a table or a column this build reads; a board whose only difference is its schema stamp stays readable.",
14192
15258
  "There is no MCP tool that approves a card, and there never will be: `kanbo_wait_approval` hands the card to a person and ends the turn; only a person takes it back."
14193
15259
  ]
14194
15260
  };
@@ -14278,6 +15344,12 @@ Object.defineProperty(exports, "ENTRY_RULE_DESCRIPTIONS", {
14278
15344
  return ENTRY_RULE_DESCRIPTIONS;
14279
15345
  }
14280
15346
  });
15347
+ Object.defineProperty(exports, "FLOW_RULES", {
15348
+ enumerable: true,
15349
+ get: function() {
15350
+ return FLOW_RULES;
15351
+ }
15352
+ });
14281
15353
  Object.defineProperty(exports, "KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI", {
14282
15354
  enumerable: true,
14283
15355
  get: function() {
@@ -14608,6 +15680,12 @@ Object.defineProperty(exports, "readEntryRules", {
14608
15680
  return readEntryRules;
14609
15681
  }
14610
15682
  });
15683
+ Object.defineProperty(exports, "readFlowRules", {
15684
+ enumerable: true,
15685
+ get: function() {
15686
+ return readFlowRules;
15687
+ }
15688
+ });
14611
15689
  Object.defineProperty(exports, "readIssuePrefix", {
14612
15690
  enumerable: true,
14613
15691
  get: function() {