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
@@ -1,6 +1,6 @@
1
1
  import { a as BoardError } from "./migrations-path-ClaYRNwk.mjs";
2
- import { c as getBoardMigrationsPath, i as BOARD_PERSON_ROLE, l as createPostgresBoardStore, n as AGENT_WAITING_REFUSAL, o as BOARD_ROLE_SQL, r as BOARD_AGENT_ROLE, s as assertBoardSchema, t as AGENT_APPROVAL_COMMENT_REFUSAL } from "./roles.sql-BoOZSkBp.mjs";
3
- import { a as openBoardDatabase, c as boardQueryLogger, d as foldCase, i as markBoardFileOwnedByKanbo, l as createSqliteBoardStore, n as migrateBoardFile, o as readBoardFileContents, r as assertBoardSchema$1 } from "./migrate-DtBeW3Fs.mjs";
2
+ import { c as getBoardMigrationsPath, i as BOARD_PERSON_ROLE, l as createPostgresBoardStore, n as AGENT_WAITING_REFUSAL, o as BOARD_ROLE_SQL, r as BOARD_AGENT_ROLE, s as assertBoardSchema, t as AGENT_APPROVAL_COMMENT_REFUSAL } from "./roles.sql-BDmmNUdx.mjs";
3
+ import { a as openBoardDatabase, c as boardQueryLogger, d as foldCase, i as markBoardFileOwnedByKanbo, l as createSqliteBoardStore, n as migrateBoardFile, o as readBoardFileContents, r as assertBoardSchema$1 } from "./migrate-BUsaTJlj.mjs";
4
4
  import { Command, Option } from "commander";
5
5
  import { z } from "zod";
6
6
  import { homedir, hostname, userInfo } from "node:os";
@@ -52,22 +52,26 @@ const AGENT_SHELL_MARKERS = [
52
52
  {
53
53
  variable: "CLAUDECODE",
54
54
  matches: (value) => value === "1",
55
- label: "CLAUDECODE=1"
55
+ label: "CLAUDECODE=1",
56
+ tool: "claude"
56
57
  },
57
58
  {
58
59
  variable: "GEMINI_CLI",
59
60
  matches: (value) => value === "1",
60
- label: "GEMINI_CLI=1"
61
+ label: "GEMINI_CLI=1",
62
+ tool: "gemini"
61
63
  },
62
64
  {
63
65
  variable: "CURSOR_AGENT",
64
66
  matches: (value) => value !== "",
65
- label: "CURSOR_AGENT"
67
+ label: "CURSOR_AGENT",
68
+ tool: "cursor"
66
69
  },
67
70
  {
68
71
  variable: "CODEX_THREAD_ID",
69
72
  matches: (value) => value !== "",
70
- label: "CODEX_THREAD_ID"
73
+ label: "CODEX_THREAD_ID",
74
+ tool: "codex"
71
75
  }
72
76
  ];
73
77
  /**
@@ -85,6 +89,15 @@ function readAgentShellMarker(env = process.env) {
85
89
  return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.label ?? null;
86
90
  }
87
91
  /**
92
+ * The name of the agent tool this shell belongs to (`claude`, `gemini`,
93
+ * `cursor`, `codex`), `agent` when only `KANBO_ACTOR_KIND=agent` says so, or
94
+ * `null` for a person's shell. Same precedence as `readAgentShellMarker`.
95
+ */
96
+ function readAgentTool(env = process.env) {
97
+ if (readAgentShellMarker(env) === null) return null;
98
+ return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.tool ?? "agent";
99
+ }
100
+ /**
88
101
  * Every exit code this tool promises, and what it means — the capabilities
89
102
  * manifest's `exitCodes` reads this rather than naming the codes again. `0` and
90
103
  * `1` are not declared above: they need no name of their own, success and "I
@@ -258,6 +271,7 @@ const HUMAN_ONLY_ACTIONS = [
258
271
  "return",
259
272
  "sprint close",
260
273
  "columns rules",
274
+ "columns flow",
261
275
  "columns add",
262
276
  "columns rename",
263
277
  "columns move",
@@ -271,6 +285,7 @@ const HUMAN_ONLY_MESSAGES = {
271
285
  "return": describeReturnRefusal,
272
286
  "sprint close": describeSprintCloseRefusal,
273
287
  "columns rules": describeColumnRulesRefusal,
288
+ "columns flow": describeColumnRulesRefusal,
274
289
  "columns add": describeColumnChangesRefusal,
275
290
  "columns rename": describeColumnChangesRefusal,
276
291
  "columns move": describeColumnChangesRefusal,
@@ -280,11 +295,15 @@ const HUMAN_ONLY_MESSAGES = {
280
295
  "run clear-session": describeRunSessionRefRefusal,
281
296
  "run attach-session --replace": describeRunSessionRefRefusal
282
297
  };
283
- /** The actor every board write from this tool is filed under. */
298
+ /**
299
+ * The actor every board write from this tool is filed under: `KANBO_ACTOR_ID`,
300
+ * else the agent tool of an agent's shell (`claude`, `codex`, ...), else the
301
+ * operating system's user.
302
+ */
284
303
  function createCliActor() {
285
304
  return {
286
305
  kind: "external",
287
- id: process.env.KANBO_ACTOR_ID?.trim() || safeUsername(),
306
+ id: process.env.KANBO_ACTOR_ID?.trim() || readAgentTool() || safeUsername(),
288
307
  name: hostname()
289
308
  };
290
309
  }
@@ -322,6 +341,14 @@ function createPlacementActor() {
322
341
  placesForPerson: true
323
342
  };
324
343
  }
344
+ /** 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. */
345
+ function createServeWriter() {
346
+ return {
347
+ kind: "external",
348
+ id: (isAgentShell() ? "" : process.env.KANBO_ACTOR_ID?.trim() ?? "") || safeUsername(),
349
+ name: hostname()
350
+ };
351
+ }
325
352
  /**
326
353
  * The person who owns this shell, as the board files a person. Only for a
327
354
  * caller that has already asked `isAgentShell` — `requireHumanActor` for a
@@ -739,6 +766,14 @@ const HIDDEN_ACTIVITY_FIELDS = /* @__PURE__ */ new Set([
739
766
  "order",
740
767
  "waitingFor"
741
768
  ]);
769
+ /**
770
+ * The column a card was created in, recorded as a change from no column at the
771
+ * card's own creation time. The feed's `created` item already stands for it,
772
+ * and the field history keeps it.
773
+ */
774
+ function isStartingColumn(change, issue) {
775
+ return change.field === "statusId" && change.fromValue === null && change.createdAt === issue.createdAt;
776
+ }
742
777
  const ACTIVITY_FIELD_BY_RAW_FIELD = {
743
778
  assigneeId: "assignee",
744
779
  description: "description",
@@ -779,7 +814,7 @@ async function listActivity(store, issueId, resolver) {
779
814
  workspaceById: resolver.workspaceById
780
815
  };
781
816
  const commentItems = await Promise.all((await store.comments.listByIssue(issueId)).map(async (comment) => await toCommentActivityItem(comment, resolver)));
782
- const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
817
+ const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).filter((change) => !isStartingColumn(change, issue)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
783
818
  return [
784
819
  {
785
820
  id: `${issue.id}:created`,
@@ -1322,7 +1357,9 @@ const UNMET_ENTRY_RULE_REASONS = [
1322
1357
  "ci_red",
1323
1358
  "approval_pending",
1324
1359
  "returned",
1325
- "not_approved"
1360
+ "not_approved",
1361
+ "not_allowed_from",
1362
+ "column_full"
1326
1363
  ];
1327
1364
  const entryRulesSchema = z.array(z.enum(ENTRY_RULES));
1328
1365
  /**
@@ -1469,6 +1506,65 @@ function describeColumnRefusal(details) {
1469
1506
  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");
1470
1507
  }
1471
1508
  //#endregion
1509
+ //#region src/domain/flow-rules.ts
1510
+ /** The two flow rules, as an unmet entry names them. */
1511
+ const FLOW_RULES = ["allowed_from", "wip_limit"];
1512
+ /** The column the standard flow guards, and the columns it lets cards in from, by slug. */
1513
+ const STANDARD_FLOW_TARGET = "done";
1514
+ const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
1515
+ /** A column's flow rules, out of what the row stores. */
1516
+ function readFlowRules(column) {
1517
+ return {
1518
+ allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
1519
+ wipLimit: column.wipLimit
1520
+ };
1521
+ }
1522
+ /** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
1523
+ function serializeAllowedFrom(allowedFrom) {
1524
+ return allowedFrom === null ? null : JSON.stringify(allowedFrom);
1525
+ }
1526
+ /**
1527
+ * The flow rules a card entering `column` does not meet, where it may come from
1528
+ * first. Pure: what the board holds arrives in `facts`. A card being created
1529
+ * comes from no column, so a column that names where cards come from does not
1530
+ * take it.
1531
+ */
1532
+ function evaluateFlowRules(card, column, rules, facts) {
1533
+ const unmet = [];
1534
+ if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
1535
+ rule: "allowed_from",
1536
+ reason: "not_allowed_from",
1537
+ detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
1538
+ });
1539
+ if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
1540
+ rule: "wip_limit",
1541
+ reason: "column_full",
1542
+ detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
1543
+ });
1544
+ return unmet;
1545
+ }
1546
+ function describeAllowedFrom(name, allowedFrom, columnNames) {
1547
+ const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
1548
+ 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`;
1549
+ }
1550
+ /** `A`, `A and B`, `A, B and C`. */
1551
+ function joinWords(words) {
1552
+ return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
1553
+ }
1554
+ /**
1555
+ * The standard flow on these columns: Done, and the columns it takes cards from
1556
+ * — In Review, and QA when there is one — in board order. `null` when the board
1557
+ * has no Done, or neither of the two.
1558
+ */
1559
+ function standardFlowOf(columns) {
1560
+ const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
1561
+ const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
1562
+ return target && sources.length > 0 ? {
1563
+ target,
1564
+ sources
1565
+ } : null;
1566
+ }
1567
+ //#endregion
1472
1568
  //#region src/ops/columns.ts
1473
1569
  /** The columns of one workspace, in board order. */
1474
1570
  async function listColumns(store, workspaceId) {
@@ -1538,6 +1634,7 @@ async function applyColumnTemplate(store, workspaceId, columns, options, scope)
1538
1634
  workspaceId,
1539
1635
  ...column
1540
1636
  }, order);
1637
+ await writeStandardFlow(tx, await listColumns(tx, workspaceId));
1541
1638
  return {
1542
1639
  statuses: await listColumns(tx, workspaceId),
1543
1640
  added: columns.map((column) => column.name)
@@ -1647,6 +1744,80 @@ async function setColumnEntryRules(store, workspaceId, nameOrId, entryRules, act
1647
1744
  return await updateColumn(tx, (await requireColumn(tx, workspaceId, nameOrId)).id, { entryRules }, actor, { tx });
1648
1745
  }, scope);
1649
1746
  }
1747
+ /** The largest WIP limit: what a Postgres `integer` column holds. */
1748
+ const MAX_WIP_LIMIT = 2147483647;
1749
+ /**
1750
+ * Set where a card may enter the column from and how many cards it holds:
1751
+ * `allowedFrom` the ids of other columns of its board (`null` for any, `[]` for
1752
+ * none), kept once each in board order; `wipLimit` a whole number from 1 to 2147483647, or `null`. A
1753
+ * person's to set, like the entry rules: an agent that could open a column
1754
+ * could walk any card into it.
1755
+ */
1756
+ async function setColumnFlowRules(store, columnId, rules, actor, scope) {
1757
+ assertPersonSetsEntryRules(actor, { statusId: columnId });
1758
+ if (rules.wipLimit !== null && !(Number.isSafeInteger(rules.wipLimit) && rules.wipLimit >= 1 && rules.wipLimit <= MAX_WIP_LIMIT)) throw new BoardError("board_flow_rule_invalid", {
1759
+ statusId: columnId,
1760
+ wipLimit: rules.wipLimit
1761
+ });
1762
+ return await runBoardWrite(store, async ({ tx }) => {
1763
+ const column = await tx.statuses.findById(columnId);
1764
+ if (!column) throw new BoardError("issue_status_not_found", { statusId: columnId });
1765
+ const allowedFrom = rules.allowedFrom === null ? null : allowedFromInBoardOrder(await listColumns(tx, column.workspaceId), column, rules.allowedFrom);
1766
+ await tx.statuses.update(columnId, {
1767
+ allowedFrom: serializeAllowedFrom(allowedFrom),
1768
+ wipLimit: rules.wipLimit
1769
+ });
1770
+ return await tx.statuses.findById(columnId);
1771
+ }, scope);
1772
+ }
1773
+ /** The ids asked for, once each and in board order — or the refusal naming those that are not another column of this board. */
1774
+ function allowedFromInBoardOrder(columns, column, requested) {
1775
+ const others = columns.filter((other) => other.id !== column.id);
1776
+ const invalid = requested.filter((id) => !others.some((other) => other.id === id));
1777
+ if (invalid.length > 0) throw new BoardError("board_flow_rule_invalid", {
1778
+ statusId: column.id,
1779
+ allowedFrom: invalid
1780
+ });
1781
+ return others.filter((other) => requested.includes(other.id)).map((other) => other.id);
1782
+ }
1783
+ /**
1784
+ * Put the standard flow on a board: Done accepts cards from In Review only —
1785
+ * and from QA, when the board has it — so nothing reaches Done but through
1786
+ * review. Done's WIP limit and every other column stay as they are. A board
1787
+ * without Done, or with neither In Review nor QA, has nothing to apply it to.
1788
+ */
1789
+ async function applyStandardFlow(store, workspaceId, actor, scope) {
1790
+ assertPersonSetsEntryRules(actor, { workspaceId });
1791
+ return await runBoardWrite(store, async ({ tx }) => {
1792
+ const columns = await listColumns(tx, workspaceId);
1793
+ if (!await writeStandardFlow(tx, columns)) {
1794
+ const hasDone = columns.some((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
1795
+ throw new BoardError("issue_status_not_found", {
1796
+ workspaceId,
1797
+ statusName: hasDone ? "In Review" : "Done"
1798
+ });
1799
+ }
1800
+ return await listColumns(tx, workspaceId);
1801
+ }, scope);
1802
+ }
1803
+ /** Done takes cards from the board's In Review and QA; `false`, with nothing written, when the board has no such columns. */
1804
+ async function writeStandardFlow(store, columns) {
1805
+ const flow = standardFlowOf(columns);
1806
+ if (!flow) return false;
1807
+ await store.statuses.update(flow.target.id, { allowedFrom: serializeAllowedFrom(flow.sources.map((column) => column.id)) });
1808
+ return true;
1809
+ }
1810
+ /**
1811
+ * Take a deleted column out of every list of where cards may come from. A list
1812
+ * it empties stays empty: a column that took cards only from it does not open
1813
+ * to every column.
1814
+ */
1815
+ async function dropFromAllowedFrom(store, workspaceId, statusId) {
1816
+ for (const other of await listColumns(store, workspaceId)) {
1817
+ const { allowedFrom } = readFlowRules(other);
1818
+ if (allowedFrom?.includes(statusId)) await store.statuses.update(other.id, { allowedFrom: serializeAllowedFrom(allowedFrom.filter((id) => id !== statusId)) });
1819
+ }
1820
+ }
1650
1821
  /**
1651
1822
  * Take an empty column off the board, and number the columns left 0..n-1 in
1652
1823
  * the same write.
@@ -1673,6 +1844,7 @@ async function deleteColumn(store, statusId, scope, actor) {
1673
1844
  hint: "Move its cards to another column first, or remove it with removeColumn and moveCardsTo."
1674
1845
  });
1675
1846
  await tx.statuses.delete(statusId);
1847
+ await dropFromAllowedFrom(tx, column.workspaceId, statusId);
1676
1848
  await renumberColumns(tx, column.workspaceId);
1677
1849
  }, scope);
1678
1850
  }
@@ -1952,24 +2124,46 @@ function pullRequestFactKeys({ owner, repo, number }) {
1952
2124
  //#endregion
1953
2125
  //#region src/ops/entry-rules.ts
1954
2126
  /**
1955
- * The column's entry rules the card does not meet (ruling 6-2). A column with
1956
- * no rules answers at once, without reading anything; otherwise only the facts
1957
- * the column's rules need are read.
2127
+ * The column's entry rules and flow rules the card does not meet, entry rules
2128
+ * first (ruling 6-2; board-workflow spec, Part 2). A column with no rules
2129
+ * answers at once, without reading anything; otherwise only the facts its
2130
+ * rules need are read. `entering` counts the cards this same write already let
2131
+ * into the column, so a bulk move is held to the WIP limit as a whole.
1958
2132
  */
1959
- async function checkColumnEntry(store, card, column) {
2133
+ async function checkColumnEntry(store, card, column, entering) {
1960
2134
  const rules = readEntryRules(column);
1961
- if (rules.length === 0) return [];
1962
- return evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
2135
+ const unmet = rules.length === 0 ? [] : evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
2136
+ const flow = readFlowRules(column);
2137
+ if (flow.allowedFrom === null && flow.wipLimit === null) return unmet;
2138
+ return [...unmet, ...evaluateFlowRules(card, column, flow, await readFlowRuleFacts(store, column, flow, entering))];
2139
+ }
2140
+ /** 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. */
2141
+ async function readFlowRuleFacts(store, column, flow, entering) {
2142
+ const columnNames = flow.allowedFrom === null ? /* @__PURE__ */ new Map() : new Map((await store.statuses.listByWorkspace(column.workspaceId)).map((other) => [other.id, other.name]));
2143
+ if (flow.wipLimit === null) return {
2144
+ columnNames,
2145
+ cardsInColumn: 0
2146
+ };
2147
+ await store.statuses.lockById(column.id);
2148
+ return {
2149
+ columnNames,
2150
+ cardsInColumn: (await store.issues.listPage({
2151
+ workspaceId: column.workspaceId,
2152
+ statusIds: [column.id],
2153
+ limit: 1
2154
+ })).total + entering
2155
+ };
1963
2156
  }
1964
2157
  /**
1965
- * Hold a card at the door of a column that asks for more than it has (ruling
1966
- * 6-3). Anyone but a person — or a person's own terminal, `placesForPerson`
1967
- * (ruling 6-6) — is refused with `board_column_rules_unmet`, which
1968
- * names the column and every unmet rule; a person is let through, and the
1969
- * unmet rules come back so the caller can warn them.
2158
+ * Hold a card at the door of a column that asks for more than it has — entry
2159
+ * rules (ruling 6-3), or flow rules: a column it may not come from, or a column
2160
+ * that is full. Anyone but a person — or a person's own terminal,
2161
+ * `placesForPerson` (ruling 6-6) — is refused with `board_column_rules_unmet`,
2162
+ * which names the column and everything unmet; a person is let through, and
2163
+ * the unmet list comes back so the caller can warn them.
1970
2164
  */
1971
- async function enforceColumnEntry(store, card, column, actor) {
1972
- const unmet = await checkColumnEntry(store, card, column);
2165
+ async function enforceColumnEntry(store, card, column, actor, entering = 0) {
2166
+ const unmet = await checkColumnEntry(store, card, column, entering);
1973
2167
  if (unmet.length > 0 && actor.kind !== "user" && !actor.placesForPerson) throw new BoardError("board_column_rules_unmet", {
1974
2168
  issueId: card.id,
1975
2169
  column: column.name,
@@ -1982,14 +2176,17 @@ async function enforceColumnEntry(store, card, column, actor) {
1982
2176
  * The same door for several cards entering one column at once — a bulk status
1983
2177
  * change. All or nothing: the first card an agent may not move refuses the
1984
2178
  * whole change. A person gets back the cards that entered with rules unmet.
2179
+ * Each card counts against the column's WIP limit as the cards before it in the same change entered it.
1985
2180
  */
1986
2181
  async function enforceColumnEntryForCards(store, issueIds, statusId, actor) {
1987
2182
  const column = await store.statuses.findById(statusId);
1988
2183
  if (!column) throw new BoardError("issue_status_not_found", { statusId });
1989
2184
  const entered = [];
2185
+ let entering = 0;
1990
2186
  for (const card of await store.issues.listByIds(issueIds)) {
1991
2187
  if (card.statusId === column.id) continue;
1992
- const unmet = await enforceColumnEntry(store, card, column, actor);
2188
+ const unmet = await enforceColumnEntry(store, card, column, actor, entering);
2189
+ entering++;
1993
2190
  if (unmet.length > 0) entered.push({
1994
2191
  issueId: card.id,
1995
2192
  unmet
@@ -2144,11 +2341,24 @@ const STOPPED_BY_YOU_STATUS_LINE = "stopped by you";
2144
2341
  * the board reads the same whether the agent was launched by an app or from
2145
2342
  * somewhere else entirely. The attempt number is simply how many runs the card
2146
2343
  * has had — a card whose runs were all launched elsewhere still counts them.
2344
+ *
2345
+ * A card has one run going at a time. A start on a card whose run is still going
2346
+ * is refused with `board_card_run_active`, naming that run — whichever path it
2347
+ * came by, an app's launch, `kanbo run start`, MCP, or a second app on a shared
2348
+ * Postgres board. The card's row is locked first, so two starts racing for it
2349
+ * are settled one after the other.
2147
2350
  */
2148
2351
  async function startRun(store, issueId, input, actor, scope) {
2149
2352
  const externalSessionRef = readExternalSessionRef(input.externalSessionRef) ?? null;
2150
2353
  return await runBoardWrite(store, async ({ tx }) => {
2151
- const card = await requireCard$1(tx, issueId);
2354
+ const card = await tx.issues.lockById(issueId);
2355
+ if (!card) throw new BoardError("issue_not_found", { issueId });
2356
+ const [active] = await tx.runs.listRunning(issueId);
2357
+ if (active) throw new BoardError("board_card_run_active", {
2358
+ issueId,
2359
+ runId: active.id,
2360
+ agentName: active.agentName
2361
+ });
2152
2362
  const attempt = await tx.runs.countByIssue(issueId) + 1;
2153
2363
  const executionMode = input.executionMode ?? card.executionMode;
2154
2364
  const run = await tx.runs.create({
@@ -2414,7 +2624,8 @@ async function createCard(store, input, actor, scope) {
2414
2624
  const column = statusId === null ? null : await tx.statuses.findById(statusId);
2415
2625
  const unmet = column ? await enforceColumnEntry(tx, {
2416
2626
  id: null,
2417
- description: input.description ?? null
2627
+ description: input.description ?? null,
2628
+ statusId: null
2418
2629
  }, column, actor) : [];
2419
2630
  const milestoneId = await requireMilestoneInWorkspace(tx, workspaceId, input.milestoneId ?? null);
2420
2631
  const parentIssueId = await requireParentInWorkspace(tx, workspaceId, input.parentIssueId ?? null);
@@ -2422,8 +2633,9 @@ async function createCard(store, input, actor, scope) {
2422
2633
  const now = currentUnixSeconds();
2423
2634
  for (let attempt = 1;; attempt++) {
2424
2635
  const identity = await nextIssueIdentity(input.workspace, tx);
2636
+ let created;
2425
2637
  try {
2426
- return withUnmetRules(await tx.issues.create({
2638
+ created = await tx.issues.create({
2427
2639
  id: identity.id,
2428
2640
  workspaceId,
2429
2641
  title: input.title?.trim() || identity.id,
@@ -2447,12 +2659,25 @@ async function createCard(store, input, actor, scope) {
2447
2659
  order,
2448
2660
  createdAt: now,
2449
2661
  updatedAt: now
2450
- }), unmet);
2662
+ });
2451
2663
  } catch (error) {
2452
2664
  const numberTaken = await tx.issues.findByNumber(workspaceId, identity.number);
2453
2665
  const keyTaken = await tx.issues.findById(identity.id);
2454
2666
  if (attempt >= NUMBERING_ATTEMPTS || !numberTaken && !keyTaken) throw error;
2667
+ continue;
2455
2668
  }
2669
+ if (statusId !== null) await tx.fieldChanges.create({
2670
+ id: randomUUID(),
2671
+ issueId: created.id,
2672
+ field: "statusId",
2673
+ fromValue: null,
2674
+ toValue: statusId,
2675
+ actorKind: toStoredActorKind(actor),
2676
+ actorId: actor.id ?? null,
2677
+ sourceChatSessionId: actor.chatSessionId ?? null,
2678
+ createdAt: now
2679
+ });
2680
+ return withUnmetRules(created, unmet);
2456
2681
  }
2457
2682
  }, scope);
2458
2683
  }
@@ -2508,7 +2733,8 @@ async function updateCard(store, issueId, input, actor, scope) {
2508
2733
  const description = "description" in updates ? updates.description ?? null : card.description;
2509
2734
  unmet = await enforceColumnEntry(tx, {
2510
2735
  id: card.id,
2511
- description
2736
+ description,
2737
+ statusId: card.statusId
2512
2738
  }, column, actor);
2513
2739
  }
2514
2740
  if ("assigneeKind" in input) updates.assigneeKind = input.assigneeKind ?? null;
@@ -2811,12 +3037,33 @@ async function requireParentInWorkspace(store, workspaceId, parentIssueId, issue
2811
3037
  issueId,
2812
3038
  parentIssueId
2813
3039
  });
2814
- if (!await store.issues.findInWorkspace(workspaceId, parentIssueId)) throw new BoardError("issue_parent_not_found", {
3040
+ const parent = await store.issues.findInWorkspace(workspaceId, parentIssueId);
3041
+ if (!parent) throw new BoardError("issue_parent_not_found", {
2815
3042
  workspaceId,
2816
3043
  parentIssueId
2817
3044
  });
3045
+ if (issueId !== void 0 && await isUnder(store, parent, issueId)) throw new BoardError("issue_parent_cycle", {
3046
+ issueId,
3047
+ parentIssueId
3048
+ });
2818
3049
  return parentIssueId;
2819
3050
  }
3051
+ /**
3052
+ * Is the card somewhere under `ancestorId` — its parent, its parent's parent,
3053
+ * and so on up? A card put under one of its own sub-cards would make a loop no
3054
+ * reader of the board can walk to the top of. The cards already seen end the
3055
+ * walk, so a loop the data holds from before cannot hang it.
3056
+ */
3057
+ async function isUnder(store, card, ancestorId) {
3058
+ const seen = /* @__PURE__ */ new Set();
3059
+ let id = card.parentIssueId;
3060
+ while (id !== null && !seen.has(id)) {
3061
+ if (id === ancestorId) return true;
3062
+ seen.add(id);
3063
+ id = (await store.issues.findById(id))?.parentIssueId ?? null;
3064
+ }
3065
+ return false;
3066
+ }
2820
3067
  /** Is the card's own number already taken by another card in the target workspace? */
2821
3068
  async function numberTakenByAnother(store, workspaceId, card) {
2822
3069
  const holder = await store.issues.findByNumber(workspaceId, card.number);
@@ -2886,6 +3133,160 @@ async function reorderCards(store, workspaceId, orderedIds, scope) {
2886
3133
  }, scope);
2887
3134
  }
2888
3135
  //#endregion
3136
+ //#region src/ops/ready.ts
3137
+ /** The column an agent takes its next card from. */
3138
+ const READY_COLUMN_SLUG = "to_do";
3139
+ /**
3140
+ * The cards an agent may pick up right now: spelled out, nobody working on them,
3141
+ * nobody's turn but the agent's.
3142
+ *
3143
+ * "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
3144
+ * run is taken, and a card waiting for a person belongs to that person until
3145
+ * they answer. What is left is the queue — in board order, so the card someone
3146
+ * dragged to the top is the one an agent asking for work gets.
3147
+ */
3148
+ async function listReady(store, input) {
3149
+ return (await queryReady(store, input)).cards;
3150
+ }
3151
+ /**
3152
+ * One page of the ready cards, how many are ready in all, and the columns read
3153
+ * on the way — the rule above, asked of the database in one statement rather
3154
+ * than of every card on the board.
3155
+ */
3156
+ async function queryReady(store, input) {
3157
+ const columns = await listColumns(store, input.workspaceId);
3158
+ const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
3159
+ if (!column) return {
3160
+ cards: [],
3161
+ total: 0,
3162
+ columns
3163
+ };
3164
+ return {
3165
+ ...await store.issues.listPage({
3166
+ workspaceId: input.workspaceId,
3167
+ statusIds: [column.id],
3168
+ waitingForPerson: false,
3169
+ hasActiveRun: false,
3170
+ limit: input.limit,
3171
+ offset: input.offset
3172
+ }),
3173
+ columns
3174
+ };
3175
+ }
3176
+ //#endregion
3177
+ //#region src/ops/claim.ts
3178
+ /**
3179
+ * Take a card to work on, in one board write: pick it, start its run, and move
3180
+ * it into the board's first `started` column (In Progress on a standard
3181
+ * board), its entry and flow rules checked on the way in. Anything refused
3182
+ * leaves nothing written.
3183
+ *
3184
+ * Two claimers never get the same card. On SQLite the write holds the file
3185
+ * from its first read (`begin immediate`), so the second claimer reads the
3186
+ * board after the first one's commit. On Postgres the picked card's row is
3187
+ * locked (`select … for update`): a claimer that waited on the lock finds the
3188
+ * card taken, and moves on to the next ready one. The target's row is locked
3189
+ * first, always, in the order a move into it locks (column, then card), so a
3190
+ * claim and a move never wait on each other in a circle.
3191
+ */
3192
+ async function claimCard(store, input, actor, scope) {
3193
+ return await runBoardWrite(store, async ({ tx }) => {
3194
+ const columns = await listColumns(tx, input.workspaceId);
3195
+ const target = columns.find((column) => column.category === "started");
3196
+ if (!target) throw new BoardError("issue_status_not_found", {
3197
+ workspaceId: input.workspaceId,
3198
+ category: "started"
3199
+ });
3200
+ await tx.statuses.lockById(target.id);
3201
+ const toDo = columns.find((column) => normalizeStatusName(column.name) === "to_do") ?? null;
3202
+ const card = input.issueId === void 0 ? await takeFirstReady(tx, input.workspaceId, toDo) : await takeNamed(tx, input.workspaceId, input.issueId, toDo);
3203
+ const run = await startRun(tx, card.id, input.run, actor, { tx });
3204
+ return {
3205
+ card: await updateCard(tx, card.id, { statusId: target.id }, actor, { tx }),
3206
+ run,
3207
+ column: target
3208
+ };
3209
+ }, scope);
3210
+ }
3211
+ /**
3212
+ * The first ready card, locked. A card another claimer took while this one
3213
+ * waited for its lock is passed over, and the next read no longer lists it; the
3214
+ * cards passed over are counted so the loop ends with the list.
3215
+ */
3216
+ async function takeFirstReady(store, workspaceId, toDo) {
3217
+ const passed = /* @__PURE__ */ new Set();
3218
+ for (;;) {
3219
+ const { cards } = await queryReady(store, {
3220
+ workspaceId,
3221
+ limit: passed.size + 1
3222
+ });
3223
+ const candidate = cards.find((card) => !passed.has(card.id));
3224
+ if (!candidate) throw new BoardError("board_nothing_ready", { workspaceId });
3225
+ const locked = await store.issues.lockById(candidate.id);
3226
+ if (locked && await notReadyReason(store, locked, toDo) === null) return locked;
3227
+ passed.add(candidate.id);
3228
+ }
3229
+ }
3230
+ /** The named card, locked, when it is on this board and ready — or why not. */
3231
+ async function takeNamed(store, workspaceId, issueId, toDo) {
3232
+ const card = await store.issues.lockById(issueId);
3233
+ if (!card || card.workspaceId !== workspaceId) throw new BoardError("issue_not_found", { issueId });
3234
+ const reason = await notReadyReason(store, card, toDo);
3235
+ if (reason) throw new BoardError("board_card_not_ready", {
3236
+ issueId,
3237
+ reason
3238
+ });
3239
+ return card;
3240
+ }
3241
+ /** `queryReady`'s rule for one card already read: nobody on it, nobody waited for, in To Do. */
3242
+ async function notReadyReason(store, card, toDo) {
3243
+ if ((await store.runs.listRunning(card.id)).length > 0) return "run_active";
3244
+ if (card.waitingFor === "human") return "waiting_for_person";
3245
+ return toDo !== null && card.statusId === toDo.id ? null : "not_in_to_do";
3246
+ }
3247
+ //#endregion
3248
+ //#region src/ops/column-entries.ts
3249
+ /**
3250
+ * Every entry into a column on the named boards at or after `since`, in order
3251
+ * of `(at, changeId)` — the one signal an app dispatching work on column
3252
+ * changes needs. It sees every writer, an outside `kanbo` on the same database
3253
+ * included, because it reads the cards' own history.
3254
+ *
3255
+ * A page never ends inside a second: when `limit` cuts into one, the rest of
3256
+ * that second comes too, so a page may be longer than `limit`. A reader keeps
3257
+ * its cursor at the last `at` it read after a short page — a write in that
3258
+ * second may still commit — and drops the `changeId`s it has seen; after a page
3259
+ * of `limit` or more it moves on to the last `at` + 1, which a second holding
3260
+ * more entries than `limit` would otherwise never let it pass.
3261
+ */
3262
+ async function listColumnEntries(store, input) {
3263
+ const page = await store.fieldChanges.listByFieldSince("statusId", input);
3264
+ return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
3265
+ }
3266
+ /** The page with every change of its last second, not only those `limit` let in. */
3267
+ async function withWholeLastSecond(store, input, page) {
3268
+ const last = page.at(-1).createdAt;
3269
+ const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
3270
+ workspaceIds: input.workspaceIds,
3271
+ since: last,
3272
+ until: last
3273
+ });
3274
+ return [...page.filter((change) => change.createdAt < last), ...lastSecond];
3275
+ }
3276
+ /** A status change as an entry; a change that took the card out of every column enters none. */
3277
+ function toColumnEntry(change) {
3278
+ return change.toValue === null ? [] : [{
3279
+ changeId: change.id,
3280
+ issueId: change.issueId,
3281
+ workspaceId: change.workspaceId,
3282
+ fromStatusId: change.fromValue,
3283
+ toStatusId: change.toValue,
3284
+ actorKind: change.actorKind,
3285
+ actorId: change.actorId,
3286
+ at: change.createdAt
3287
+ }];
3288
+ }
3289
+ //#endregion
2889
3290
  //#region src/ops/context-refs.ts
2890
3291
  const ContextRefsJsonSchema = z.string().transform((raw) => JSON.parse(raw)).pipe(z.array(z.string()));
2891
3292
  /** Add a context ref at the end of the card's list, recording the change in its history. */
@@ -3070,6 +3471,7 @@ function subtaskSplitSentence(parentReference) {
3070
3471
  }
3071
3472
  const SUBTASK_RULE = `${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("the parent card's id")}`;
3072
3473
  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.";
3474
+ 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.";
3073
3475
  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.";
3074
3476
  /** Said by the `kanbo init` block and the MCP instructions, which name the comment command or tool beside it. */
3075
3477
  const COMMENT_RULE = "Findings, results, decisions and questions go on the card as comments.";
@@ -3093,6 +3495,7 @@ const BOARD_RULES = [
3093
3495
  EXECUTION_MODE_RULE,
3094
3496
  SUBTASK_RULE,
3095
3497
  PULL_REQUEST_RULE,
3498
+ CLAIM_RULE,
3096
3499
  OWN_SESSION_RULE,
3097
3500
  "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."
3098
3501
  ];
@@ -3108,12 +3511,14 @@ const BOARD_RULES = [
3108
3511
  async function buildPrimeText(store, workspaceId) {
3109
3512
  const columns = await listColumns(store, workspaceId);
3110
3513
  const ruled = columns.some((column) => readEntryRules(column).length > 0);
3514
+ const flowing = columns.some((column) => hasFlowRules(column));
3111
3515
  return [
3112
3516
  ...await describeReturnedCards(store, workspaceId, columns),
3113
3517
  "Columns of this board, in board order:",
3114
3518
  "",
3115
- ...columns.length > 0 ? columns.map(describeColumnLine) : ["- (this board has no columns yet)"],
3519
+ ...columns.length > 0 ? columns.map((column) => describeColumnLine(column, columns)) : ["- (this board has no columns yet)"],
3116
3520
  ...ruled ? ["", ENTRY_RULES_NOTE] : [],
3521
+ ...flowing ? ["", FLOW_RULES_NOTE] : [],
3117
3522
  "",
3118
3523
  "How this board works:",
3119
3524
  "",
@@ -3155,11 +3560,22 @@ async function describeReturnedCards(store, workspaceId, columns) {
3155
3560
  * without any reads exactly as it did before they existed.
3156
3561
  */
3157
3562
  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.";
3158
- function describeColumnLine(column) {
3563
+ /** Said under the columns only when one of them has flow rules. */
3564
+ 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.";
3565
+ function hasFlowRules(column) {
3566
+ const { allowedFrom, wipLimit } = readFlowRules(column);
3567
+ return allowedFrom !== null || wipLimit !== null;
3568
+ }
3569
+ function describeColumnLine(column, columns) {
3159
3570
  const slug = normalizeStatusName(column.name);
3160
3571
  const rules = readEntryRules(column);
3161
- const requires = rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : "";
3162
- return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${requires}` : `- ${column.name} (\`${slug}\`)${requires ? ` —${requires}` : ""}`;
3572
+ const said = `${rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : ""}${describeFlow(column, columns)}`;
3573
+ return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${said}` : `- ${column.name} (\`${slug}\`)${said ? ` —${said}` : ""}`;
3574
+ }
3575
+ /** Where a column takes cards from and how many it holds, as the prime text says it; nothing for a column without flow rules. */
3576
+ function describeFlow(column, columns) {
3577
+ const { allowedFrom, wipLimit } = readFlowRules(column);
3578
+ 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"}.`}`;
3163
3579
  }
3164
3580
  //#endregion
3165
3581
  //#region src/domain/pull-request-ref.ts
@@ -3329,47 +3745,6 @@ async function queryCards(store, input) {
3329
3745
  };
3330
3746
  }
3331
3747
  //#endregion
3332
- //#region src/ops/ready.ts
3333
- /** The column an agent takes its next card from. */
3334
- const READY_COLUMN_SLUG = "to_do";
3335
- /**
3336
- * The cards an agent may pick up right now: spelled out, nobody working on them,
3337
- * nobody's turn but the agent's.
3338
- *
3339
- * "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
3340
- * run is taken, and a card waiting for a person belongs to that person until
3341
- * they answer. What is left is the queue — in board order, so the card someone
3342
- * dragged to the top is the one an agent asking for work gets.
3343
- */
3344
- async function listReady(store, input) {
3345
- return (await queryReady(store, input)).cards;
3346
- }
3347
- /**
3348
- * One page of the ready cards, how many are ready in all, and the columns read
3349
- * on the way — the rule above, asked of the database in one statement rather
3350
- * than of every card on the board.
3351
- */
3352
- async function queryReady(store, input) {
3353
- const columns = await listColumns(store, input.workspaceId);
3354
- const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
3355
- if (!column) return {
3356
- cards: [],
3357
- total: 0,
3358
- columns
3359
- };
3360
- return {
3361
- ...await store.issues.listPage({
3362
- workspaceId: input.workspaceId,
3363
- statusIds: [column.id],
3364
- waitingForPerson: false,
3365
- hasActiveRun: false,
3366
- limit: input.limit,
3367
- offset: input.offset
3368
- }),
3369
- columns
3370
- };
3371
- }
3372
- //#endregion
3373
3748
  //#region src/ops/relations.ts
3374
3749
  /**
3375
3750
  * Every edge touching the card, in either direction, each with the card on its
@@ -3659,6 +4034,8 @@ function createBoardOps(store) {
3659
4034
  updateColumn: async (statusId, patch, actor, scope) => await updateColumn(store, statusId, patch, actor, scope),
3660
4035
  describeColumn: async (workspaceId, nameOrId, description, scope) => await describeColumn(store, workspaceId, nameOrId, description, scope),
3661
4036
  setColumnEntryRules: async (workspaceId, nameOrId, entryRules, actor, scope) => await setColumnEntryRules(store, workspaceId, nameOrId, entryRules, actor, scope),
4037
+ setColumnFlowRules: async (columnId, rules, actor, scope) => await setColumnFlowRules(store, columnId, rules, actor, scope),
4038
+ applyStandardFlow: async (workspaceId, actor, scope) => await applyStandardFlow(store, workspaceId, actor, scope),
3662
4039
  enforceColumnEntryForCards: async (issueIds, statusId, actor) => await enforceColumnEntryForCards(store, issueIds, statusId, actor),
3663
4040
  deleteColumn: async (statusId, scope, actor) => await deleteColumn(store, statusId, scope, actor),
3664
4041
  reorderColumns: async (workspaceId, orderedIds, scope, actor) => await reorderColumns(store, workspaceId, orderedIds, scope, actor),
@@ -3669,6 +4046,7 @@ function createBoardOps(store) {
3669
4046
  moveColumn: async (workspaceId, nameOrId, position, actor, scope) => await moveColumn$1(store, workspaceId, nameOrId, position, actor, scope),
3670
4047
  removeColumn: async (workspaceId, nameOrId, options, actor, scope) => await removeColumn$1(store, workspaceId, nameOrId, options, actor, scope),
3671
4048
  startRun: async (issueId, input, actor, scope) => await startRun(store, issueId, input, actor, scope),
4049
+ claimCard: async (input, actor, scope) => await claimCard(store, input, actor, scope),
3672
4050
  attachRunExecution: async (runId, input, scope) => await attachRunExecution(store, runId, input, scope),
3673
4051
  recordRunSessionRef: async (runId, ref, scope) => await recordRunSessionRef(store, runId, ref, scope),
3674
4052
  clearRunSessionRef: async (runId, actor, options, scope) => await clearRunSessionRef(store, runId, actor, options, scope),
@@ -3681,66 +4059,11 @@ function createBoardOps(store) {
3681
4059
  readBoardProjectionForIssue: async (issueId) => await readBoardProjectionForIssue(store, issueId),
3682
4060
  listReady: async (input) => await listReady(store, input),
3683
4061
  queryReady: async (input) => await queryReady(store, input),
4062
+ listColumnEntries: async (input) => await listColumnEntries(store, input),
3684
4063
  buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId)
3685
4064
  };
3686
4065
  }
3687
4066
  //#endregion
3688
- //#region src/domain/database-url.ts
3689
- /**
3690
- * A board's connection string, as it may be said out loud.
3691
- *
3692
- * Everything a person needs to recognise which board they are talking to is
3693
- * the host and the database name. The password is the one part that must never
3694
- * reach a terminal, a log, a `--json` result or a tool's answer — and it is not
3695
- * the only part: a connection string carries options in its query, and a
3696
- * `?password=…` there is as much a secret as the one in front of the host.
3697
- *
3698
- * It is a fact about the value rather than about the command line, which is why
3699
- * it lives here: the MCP tools hand back messages they did not write either,
3700
- * and they must not be made to carry the board tool's own runtime to say them
3701
- * safely.
3702
- */
3703
- /** What a connection string is called when there is nothing safe to print of it. */
3704
- const MASKED_UNREADABLE_URL = "postgres://***";
3705
- /** What is printed in place of a password, and of a query that may hide one. */
3706
- const MASK = "***";
3707
- /**
3708
- * A connection string inside a longer line. Anything but whitespace and the
3709
- * quotes a message may wrap it in belongs to the URL — a password is allowed
3710
- * every character a URL is, so the match has to be greedy to be safe.
3711
- */
3712
- const DATABASE_URL_PATTERN = /\bpostgres(?:ql)?:\/\/[^\s'"`]+/gi;
3713
- /**
3714
- * The connection string with everything secret taken out of it.
3715
- *
3716
- * A string this cannot take apart is not examined to find out why — it is
3717
- * replaced entirely, because whatever is wrong with it, the secret is still
3718
- * somewhere inside. A missing scheme counts as that: `user:secret@host/db`
3719
- * parses, as a `user:` URL whose whole password is in the path, and printing it
3720
- * back would say the secret out loud.
3721
- */
3722
- function maskDatabaseUrl(url) {
3723
- try {
3724
- const parsed = new URL(url);
3725
- if (!parsed.host) return MASKED_UNREADABLE_URL;
3726
- if (parsed.password) parsed.password = MASK;
3727
- if (parsed.search) parsed.search = MASK;
3728
- return parsed.toString();
3729
- } catch {
3730
- return MASKED_UNREADABLE_URL;
3731
- }
3732
- }
3733
- /**
3734
- * Every connection string inside a line of text, masked.
3735
- *
3736
- * A message this package did not write — a driver's, a migrator's — may carry
3737
- * the whole connection string in the middle of a sentence, and it reaches the
3738
- * same terminal, or the same agent, as everything else.
3739
- */
3740
- function maskDatabaseUrls(text) {
3741
- return text.replace(DATABASE_URL_PATTERN, (url) => maskDatabaseUrl(url));
3742
- }
3743
- //#endregion
3744
4067
  //#region src/cli/setup/fs-retry.ts
3745
4068
  /**
3746
4069
  * A file operation tried again while something else holds the file.
@@ -4020,6 +4343,62 @@ function ignoreBoardFile(projectDir, absoluteDbPath) {
4020
4343
  writeFileSync(path, kept ? `${kept}\n${missing.join("\n")}\n` : `${missing.join("\n")}\n`);
4021
4344
  }
4022
4345
  //#endregion
4346
+ //#region src/domain/database-url.ts
4347
+ /**
4348
+ * A board's connection string, as it may be said out loud.
4349
+ *
4350
+ * Everything a person needs to recognise which board they are talking to is
4351
+ * the host and the database name. The password is the one part that must never
4352
+ * reach a terminal, a log, a `--json` result or a tool's answer — and it is not
4353
+ * the only part: a connection string carries options in its query, and a
4354
+ * `?password=…` there is as much a secret as the one in front of the host.
4355
+ *
4356
+ * It is a fact about the value rather than about the command line, which is why
4357
+ * it lives here: the MCP tools hand back messages they did not write either,
4358
+ * and they must not be made to carry the board tool's own runtime to say them
4359
+ * safely.
4360
+ */
4361
+ /** What a connection string is called when there is nothing safe to print of it. */
4362
+ const MASKED_UNREADABLE_URL = "postgres://***";
4363
+ /** What is printed in place of a password, and of a query that may hide one. */
4364
+ const MASK = "***";
4365
+ /**
4366
+ * A connection string inside a longer line. Anything but whitespace and the
4367
+ * quotes a message may wrap it in belongs to the URL — a password is allowed
4368
+ * every character a URL is, so the match has to be greedy to be safe.
4369
+ */
4370
+ const DATABASE_URL_PATTERN = /\bpostgres(?:ql)?:\/\/[^\s'"`]+/gi;
4371
+ /**
4372
+ * The connection string with everything secret taken out of it.
4373
+ *
4374
+ * A string this cannot take apart is not examined to find out why — it is
4375
+ * replaced entirely, because whatever is wrong with it, the secret is still
4376
+ * somewhere inside. A missing scheme counts as that: `user:secret@host/db`
4377
+ * parses, as a `user:` URL whose whole password is in the path, and printing it
4378
+ * back would say the secret out loud.
4379
+ */
4380
+ function maskDatabaseUrl(url) {
4381
+ try {
4382
+ const parsed = new URL(url);
4383
+ if (!parsed.host) return MASKED_UNREADABLE_URL;
4384
+ if (parsed.password) parsed.password = MASK;
4385
+ if (parsed.search) parsed.search = MASK;
4386
+ return parsed.toString();
4387
+ } catch {
4388
+ return MASKED_UNREADABLE_URL;
4389
+ }
4390
+ }
4391
+ /**
4392
+ * Every connection string inside a line of text, masked.
4393
+ *
4394
+ * A message this package did not write — a driver's, a migrator's — may carry
4395
+ * the whole connection string in the middle of a sentence, and it reaches the
4396
+ * same terminal, or the same agent, as everything else.
4397
+ */
4398
+ function maskDatabaseUrls(text) {
4399
+ return text.replace(DATABASE_URL_PATTERN, (url) => maskDatabaseUrl(url));
4400
+ }
4401
+ //#endregion
4023
4402
  //#region src/cli/db-target.ts
4024
4403
  /**
4025
4404
  * Which board the command works on.
@@ -4268,6 +4647,111 @@ async function openWithPostgresJs(url) {
4268
4647
  };
4269
4648
  }
4270
4649
  //#endregion
4650
+ //#region src/cli/project-registry.ts
4651
+ /**
4652
+ * Which project folders kanbo has opened a board in.
4653
+ *
4654
+ * Every project keeps its board in its own folder, so nothing else knows which
4655
+ * other projects exist. This list, in the user's home, is what lets one board
4656
+ * page offer them all. It holds a folder and a time — never a connection
4657
+ * string, never board data — and deleting it is safe: it fills again as the
4658
+ * projects are used.
4659
+ */
4660
+ /** The file's name inside kanbo's per-user folder. */
4661
+ const REGISTRY_FILE_NAME = "projects.json";
4662
+ /** The file is private to the user, as a binding is. */
4663
+ const REGISTRY_FILE_MODE = 384;
4664
+ /** The folder holding it is private to the user too. */
4665
+ const REGISTRY_DIRECTORY_MODE = 448;
4666
+ /**
4667
+ * A project noted within this many seconds is not noted again, so an ordinary
4668
+ * command does not rewrite the file: a day is fresh enough for "recently used".
4669
+ */
4670
+ const RECENTLY_NOTED_SECONDS = 86400;
4671
+ const RegisteredProjectSchema = z.object({
4672
+ path: z.string().min(1),
4673
+ lastSeenAt: z.number().int().nonnegative()
4674
+ });
4675
+ const RegistrySchema = z.object({
4676
+ schemaVersion: z.literal(1),
4677
+ projects: z.array(RegisteredProjectSchema)
4678
+ });
4679
+ /** `$KANBO_HOME/projects.json`, else `~/.kanbo/projects.json`. */
4680
+ function projectRegistryPath(env = process.env) {
4681
+ const home = env.KANBO_HOME?.trim();
4682
+ return join(home || join(homedir(), ".kanbo"), REGISTRY_FILE_NAME);
4683
+ }
4684
+ /** The projects, most recently seen first; `[]` when the file is missing or does not parse. */
4685
+ function readProjectRegistry(path = projectRegistryPath()) {
4686
+ return readEntries(path).sort((a, b) => b.lastSeenAt - a.lastSeenAt);
4687
+ }
4688
+ /**
4689
+ * Note that `projectDir` was just used. Writes only when the folder is new or
4690
+ * was last noted more than a day ago, so ordinary commands do not rewrite the file.
4691
+ * Drops entries whose `.kanbo/binding.json` is gone.
4692
+ */
4693
+ function rememberProject(projectDir, options = {}) {
4694
+ const now = options.now ?? Math.floor(Date.now() / 1e3);
4695
+ const path = options.path ?? projectRegistryPath();
4696
+ const canonical = canonicalPath$1(projectDir);
4697
+ const entries = readEntries(path);
4698
+ const known = entries.find((entry) => entry.path === canonical);
4699
+ if (known && now - known.lastSeenAt <= RECENTLY_NOTED_SECONDS) return;
4700
+ const kept = entries.filter((entry) => entry.path !== canonical && existsSync(join(entry.path, BINDING_FILE_PATH)));
4701
+ kept.push({
4702
+ path: canonical,
4703
+ lastSeenAt: now
4704
+ });
4705
+ writeEntries(path, kept);
4706
+ }
4707
+ /**
4708
+ * `rememberProject`, for the places that must not fail because of it: a home
4709
+ * directory that cannot be written is not a reason for `kanbo card list` to fail.
4710
+ * With `KANBO_DEBUG=1` the reason is printed on stderr.
4711
+ */
4712
+ function rememberProjectQuietly(projectDir) {
4713
+ const path = projectRegistryPath();
4714
+ try {
4715
+ rememberProject(projectDir, { path });
4716
+ } catch (error) {
4717
+ if (process.env.KANBO_DEBUG === "1") console.error(`kanbo: could not note this project in ${path}: ${error instanceof Error ? error.message : String(error)}`);
4718
+ }
4719
+ }
4720
+ function canonicalPath$1(projectDir) {
4721
+ const absolute = resolve(projectDir);
4722
+ try {
4723
+ return realpathSync(absolute);
4724
+ } catch {
4725
+ return absolute;
4726
+ }
4727
+ }
4728
+ function readEntries(path) {
4729
+ try {
4730
+ const parsed = RegistrySchema.safeParse(JSON.parse(readFileSync(path, "utf8")));
4731
+ return parsed.success ? parsed.data.projects : [];
4732
+ } catch {
4733
+ return [];
4734
+ }
4735
+ }
4736
+ function writeEntries(path, projects) {
4737
+ mkdirSync(dirname(path), {
4738
+ recursive: true,
4739
+ mode: REGISTRY_DIRECTORY_MODE
4740
+ });
4741
+ const temporaryPath = `${path}.${process.pid}.tmp`;
4742
+ try {
4743
+ writeFileSync(temporaryPath, `${JSON.stringify(RegistrySchema.parse({
4744
+ schemaVersion: 1,
4745
+ projects
4746
+ }), null, 2)}\n`, { mode: REGISTRY_FILE_MODE });
4747
+ chmodSync(temporaryPath, REGISTRY_FILE_MODE);
4748
+ renameWithRetry(temporaryPath, path);
4749
+ } catch (error) {
4750
+ rmSync(temporaryPath, { force: true });
4751
+ throw error;
4752
+ }
4753
+ }
4754
+ //#endregion
4271
4755
  //#region src/cli/schema-guard.ts
4272
4756
  const SCHEMA_OUTDATED_MESSAGE = "The app's database is older than this kanbo. Let the app it belongs to update it first.";
4273
4757
  /** The same, for a board file this package created and migrates itself. */
@@ -4281,9 +4765,10 @@ const SCHEMA_NOT_INSTALLED_MESSAGE = "This Postgres database has no kanbo board
4281
4765
  * caller's own risk but never written to — a write shaped by this build's idea
4282
4766
  * of the schema is how a person's board gets a column full of nothing. A file
4283
4767
  * of this package's own is this package's to migrate, so the same refusal
4284
- * points at `kanbo migrate` instead of at the app. Reads stay open either way,
4285
- * because a person who cannot migrate right now should still be able to look at
4286
- * their cards.
4768
+ * points at `kanbo migrate` instead of at the app. Reads are held to the lesser
4769
+ * bar of `assertReadableBoard`: a file only a stamp behind is still read, so a
4770
+ * person who cannot migrate right now can look at their cards, but one missing
4771
+ * a table or column this build reads is refused with the same sentence.
4287
4772
  */
4288
4773
  function assertWritableBoard(database, owner) {
4289
4774
  try {
@@ -4294,6 +4779,26 @@ function assertWritableBoard(database, owner) {
4294
4779
  }
4295
4780
  }
4296
4781
  /**
4782
+ * Refuse a read of a board file that lacks a table or a column this build
4783
+ * reads — a file a migration behind — with the sentence a write gets, instead
4784
+ * of letting the first statement fail on what is missing. A file whose only
4785
+ * difference is its schema stamp is still read: every statement this build
4786
+ * makes works on it, and a person who cannot migrate now can still look at
4787
+ * their cards.
4788
+ */
4789
+ function assertReadableBoard(database, owner) {
4790
+ try {
4791
+ assertBoardSchema$1(database);
4792
+ } catch (error) {
4793
+ if (!(error instanceof BoardError && error.code === "board_schema_outdated")) throw error;
4794
+ if (lacksWhatThisBuildReads(error.details)) throw new CliError(3, owner === "kanbo" ? FILE_SCHEMA_OUTDATED_MESSAGE : SCHEMA_OUTDATED_MESSAGE);
4795
+ }
4796
+ }
4797
+ /** Whether `assertBoardSchema` found a table or a column missing, rather than only another stamp. */
4798
+ function lacksWhatThisBuildReads(details) {
4799
+ return details !== void 0 && ("missingTables" in details || "missingAddedTables" in details || "missingColumns" in details);
4800
+ }
4801
+ /**
4297
4802
  * Refuse an external board this build cannot speak for — before reading it as
4298
4803
  * well as before writing to it.
4299
4804
  *
@@ -4513,15 +5018,17 @@ function withBoardOptions(command) {
4513
5018
  * as a client is talking to it. A failure on the way out closes the board first
4514
5019
  * — a caller that never received a session has nothing to close.
4515
5020
  */
4516
- async function openBoardSession(options, mode) {
4517
- const cwd = process.cwd();
5021
+ async function openBoardSession(options, mode, cwd = process.cwd()) {
4518
5022
  const target = resolveDbTarget({
4519
5023
  explicitPath: options.db,
4520
5024
  explicitUrl: options.databaseUrl,
4521
5025
  asAgent: options.asAgent,
4522
5026
  cwd
4523
5027
  });
4524
- return target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
5028
+ const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
5029
+ const found = findBinding(cwd);
5030
+ if (found) rememberProjectQuietly(found.projectDir);
5031
+ return session;
4525
5032
  }
4526
5033
  async function openFileSession(target, options, mode, cwd) {
4527
5034
  const board = await openBoardDatabase(target.path);
@@ -4530,6 +5037,7 @@ async function openFileSession(target, options, mode, cwd) {
4530
5037
  assertWritableBoard(board.database, target.owner);
4531
5038
  };
4532
5039
  if (mode === "write") assertWritableBoard(board.database, target.owner);
5040
+ else assertReadableBoard(board.database, target.owner);
4533
5041
  const workspace = resolveWorkspace(target.owner === "kanbo" ? { kind: "file" } : {
4534
5042
  kind: "sqlite",
4535
5043
  database: board.database
@@ -4553,8 +5061,9 @@ async function openFileSession(target, options, mode, cwd) {
4553
5061
  /**
4554
5062
  * The same, on a board that is a database of its own.
4555
5063
  *
4556
- * The guard runs whatever the command came to do. A board file that this build
4557
- * cannot write to is still a board worth reading; a connection string pointing
5064
+ * The guard runs whatever the command came to do. A board file whose only
5065
+ * difference from this build is its schema stamp is still a board worth
5066
+ * reading; a connection string pointing
4558
5067
  * at a database with no board in it is nothing to read at all, and every
4559
5068
  * command would otherwise fail one statement later with the driver's own words.
4560
5069
  */
@@ -4852,7 +5361,9 @@ function projectCardActiveRun(run) {
4852
5361
  startedAt: run.startedAt
4853
5362
  };
4854
5363
  }
4855
- function projectColumn(column) {
5364
+ /** A column as the command line prints it; `board` is every column, to name the ones it accepts cards from. */
5365
+ function projectColumn(column, board) {
5366
+ const { allowedFrom, wipLimit } = readFlowRules(column);
4856
5367
  return {
4857
5368
  id: column.id,
4858
5369
  name: column.name,
@@ -4860,7 +5371,9 @@ function projectColumn(column) {
4860
5371
  description: column.description,
4861
5372
  category: column.category,
4862
5373
  order: column.order,
4863
- entryRules: readEntryRules(column)
5374
+ entryRules: readEntryRules(column),
5375
+ allowedFrom: allowedFrom === null ? null : board.filter((other) => allowedFrom.includes(other.id)).map((other) => normalizeStatusName(other.name)),
5376
+ wipLimit
4864
5377
  };
4865
5378
  }
4866
5379
  function projectSprint(sprint) {
@@ -4932,7 +5445,9 @@ function describeColumns(columns) {
4932
5445
  return columns.map((column) => [
4933
5446
  `${padToWidth(sanitizeTerminalText(column.slug), slugWidth)} ${sanitizeTerminalText(column.name)}`,
4934
5447
  column.description ? ` — ${sanitizeTerminalText(column.description)}` : "",
4935
- column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : ""
5448
+ column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : "",
5449
+ column.allowedFrom === null ? "" : ` [from: ${column.allowedFrom.length > 0 ? column.allowedFrom.join(", ") : "no column"}]`,
5450
+ column.wipLimit === null ? "" : ` [wip: ${column.wipLimit}]`
4936
5451
  ].join("")).join("\n");
4937
5452
  }
4938
5453
  /** A unix-seconds date as the UTC day it falls on, `—` for none. */
@@ -5254,6 +5769,52 @@ function registerCapabilitiesCommand(program) {
5254
5769
  });
5255
5770
  }
5256
5771
  //#endregion
5772
+ //#region src/cli/commands/claim.ts
5773
+ /**
5774
+ * `kanbo claim [card]` — take a card to work on, in one step.
5775
+ *
5776
+ * The run starts and the card moves out of To Do into the board's first
5777
+ * work-in-progress column in one write, so two agents in two terminals never
5778
+ * take the same card. Filed as a placement: in a person's own terminal the
5779
+ * column's rules warn, in an agent's shell they refuse.
5780
+ */
5781
+ function registerClaimCommand(program) {
5782
+ 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) => {
5783
+ await runBoardCommand(options, "write", async (session) => {
5784
+ const actor = createPlacementActor();
5785
+ const named = reference === void 0 ? void 0 : (await requireCard(session, reference)).id;
5786
+ const claimed = await session.ops.claimCard({
5787
+ workspaceId: session.workspace.id,
5788
+ issueId: named,
5789
+ run: {
5790
+ agentName: options.agent ?? actor.id ?? "agent",
5791
+ externalSessionRef: options.session ?? sessionRefFromEnvironment() ?? void 0,
5792
+ launchedByKind: "external"
5793
+ }
5794
+ }, actor);
5795
+ const [columns, projection, runs] = await Promise.all([
5796
+ session.ops.listColumns(session.workspace.id),
5797
+ session.ops.readBoardProjectionForIssue(claimed.card.id),
5798
+ session.ops.listRuns(claimed.card.id)
5799
+ ]);
5800
+ const card = projectCard(claimed.card, columns, projection);
5801
+ for (const item of claimed.card.unmetRules ?? []) console.error(`kanbo: warning: ${card.id} entered "${claimed.column.name}" without ${item.rule}: ${item.detail}`);
5802
+ const run = projectRun({
5803
+ ...claimed.run,
5804
+ attempt: runs.find((each) => each.id === claimed.run.id)?.attempt ?? runs.length
5805
+ });
5806
+ return {
5807
+ value: {
5808
+ card,
5809
+ run,
5810
+ column: projectColumn(claimed.column, columns)
5811
+ },
5812
+ 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`
5813
+ };
5814
+ });
5815
+ });
5816
+ }
5817
+ //#endregion
5257
5818
  //#region src/mcp/transport.ts
5258
5819
  /** What `kanbo_card_get` may bring along with the card. */
5259
5820
  const KANBO_CARD_INCLUDES = [
@@ -5370,16 +5931,22 @@ function projectKanboSprint(sprint) {
5370
5931
  doneCards: sprint.cards.done
5371
5932
  };
5372
5933
  }
5373
- /** A column of the board, from the row either transport holds. */
5374
- function projectKanboColumn(column) {
5934
+ /** A column of the board, from the row either transport holds; `board` is every column, to name the ones it accepts cards from. */
5935
+ function projectKanboColumn(column, board) {
5375
5936
  const stored = column.entryRules;
5937
+ const allowedIds = typeof column.allowedFrom === "string" ? readFlowRules({
5938
+ allowedFrom: column.allowedFrom,
5939
+ wipLimit: null
5940
+ }).allowedFrom : column.allowedFrom ?? null;
5376
5941
  return {
5377
5942
  id: column.id,
5378
5943
  name: column.name,
5379
5944
  slug: normalizeStatusName(column.name),
5380
5945
  description: column.description,
5381
5946
  order: column.order,
5382
- entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null })
5947
+ entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null }),
5948
+ allowedFrom: allowedIds === null ? null : board.filter((other) => allowedIds.includes(other.id)).map((other) => normalizeStatusName(other.name)),
5949
+ wipLimit: column.wipLimit ?? null
5383
5950
  };
5384
5951
  }
5385
5952
  /**
@@ -5406,9 +5973,14 @@ function pageOf(cards, input) {
5406
5973
  }
5407
5974
  /** A comment as a card read lists it, from the row either transport holds. */
5408
5975
  function projectKanboCardComment(comment) {
5976
+ const decision = comment.authorKind === "system.approved" || comment.authorKind === "system.returned";
5977
+ const author = decision ? "user" : normalizeCommentAuthorKind(comment.authorKind);
5978
+ const named = author !== "system" || decision;
5979
+ const outside = author === "system" && isOutsideWriter(comment) && comment.authorKind === "system";
5409
5980
  return {
5410
5981
  id: comment.id,
5411
- author: normalizeCommentAuthorKind(comment.authorKind),
5982
+ author,
5983
+ by: named || outside ? comment.authorId ?? author : "kanbo",
5412
5984
  content: comment.content,
5413
5985
  createdAt: comment.createdAt
5414
5986
  };
@@ -5479,7 +6051,7 @@ function describeCardExtras(extras) {
5479
6051
  })))].join("\n"));
5480
6052
  if (extras.comments && extras.comments.length > 0) {
5481
6053
  const heading = extras.commentCount === extras.comments.length ? "Comments:" : `Comments (last ${extras.comments.length} of ${extras.commentCount}):`;
5482
- sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${comment.author}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
6054
+ sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${sanitizeTerminalText(comment.by)}${comment.author === "user" ? " (person)" : ""}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
5483
6055
  }
5484
6056
  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"));
5485
6057
  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"));
@@ -5577,10 +6149,11 @@ function registerCardCommands(program) {
5577
6149
  }, createPlacementActor()));
5578
6150
  });
5579
6151
  });
5580
- 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) => {
6152
+ 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) => {
5581
6153
  await runBoardCommand(options, "write", async (session) => {
5582
6154
  const input = readUpdateInput(options);
5583
6155
  const existing = await requireCard(session, reference);
6156
+ if (options.parent !== void 0) input.parentIssueId = options.parent === "none" ? null : (await requireCard(session, options.parent)).id;
5584
6157
  return await presentCard(session, await session.ops.updateCard(existing.id, input, createPlacementActor()));
5585
6158
  });
5586
6159
  });
@@ -5721,9 +6294,10 @@ async function presentCard(session, card) {
5721
6294
  };
5722
6295
  }
5723
6296
  /**
5724
- * The fields `card update` was asked to change. A command that names none of
5725
- * them is refused rather than obeyed: an empty update still bumps the board
5726
- * version, and every reader would be told the board changed when it did not.
6297
+ * The fields `card update` was asked to change — all but the parent, a card
6298
+ * of the board, looked up there once the card itself is. A command that names
6299
+ * none of them is refused rather than obeyed: an empty update still bumps the
6300
+ * board version, and every reader would be told the board changed when it did not.
5727
6301
  */
5728
6302
  function readUpdateInput(options) {
5729
6303
  const input = {};
@@ -5732,7 +6306,7 @@ function readUpdateInput(options) {
5732
6306
  if (options.priority !== void 0) input.priority = options.priority;
5733
6307
  if (options.labels !== void 0) input.labels = options.labels;
5734
6308
  if (options.executionMode !== void 0) input.executionMode = options.executionMode;
5735
- if (Object.keys(input).length === 0) throw new CliError(1, "Nothing to update. Pass --title, --description, --priority, --labels or --execution-mode.");
6309
+ 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.");
5736
6310
  return input;
5737
6311
  }
5738
6312
  function parsePriority(value) {
@@ -5802,10 +6376,11 @@ const COLUMN_CATEGORIES = [
5802
6376
  ];
5803
6377
  /** The whole board's columns after a change, and the sentence that says what changed. */
5804
6378
  async function changed(session, message, extra = {}) {
5805
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
6379
+ const board = await session.ops.listColumns(session.workspace.id);
6380
+ const views = board.map((column) => projectColumn(column, board));
5806
6381
  return {
5807
6382
  value: {
5808
- ...extra,
6383
+ ...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, typeof value === "number" || Array.isArray(value) ? value : projectColumn(value, board)])),
5809
6384
  columns: views
5810
6385
  },
5811
6386
  text: `${message}\n\n${describeColumns(views)}`
@@ -5834,14 +6409,14 @@ async function addColumn(session, request, actor) {
5834
6409
  category: request.category ?? spec.category,
5835
6410
  position: anchor ? request.after !== void 0 ? { after: anchor.id } : { before: anchor.id } : void 0
5836
6411
  }, actor);
5837
- return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: projectColumn(created) });
6412
+ return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: created });
5838
6413
  }
5839
6414
  /** Said after a rename: agents' instructions name no column but To Do, and `kanbo prime` gives them the rest. */
5840
6415
  const RENAME_NOTE = "Agents read column names from kanbo prime; rename is picked up there.";
5841
6416
  async function renameColumn(session, column, newName, actor) {
5842
6417
  const before = await requireNamedColumn(session, column);
5843
6418
  const renamed = await session.ops.renameColumn(session.workspace.id, before.id, newName, actor);
5844
- 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) });
6419
+ 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 });
5845
6420
  }
5846
6421
  /** `--first`, `--last`, `--before <column>` or `--after <column>` — exactly one. */
5847
6422
  function readColumnPosition(options) {
@@ -5873,7 +6448,7 @@ async function removeColumn(session, column, moveCardsTo, actor) {
5873
6448
  const result = await session.ops.removeColumn(session.workspace.id, removing.id, { moveCardsTo }, actor);
5874
6449
  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}`);
5875
6450
  return await changed(session, result.movedTo ? `Removed ${result.removed.name} and moved ${describeCount$1(result.movedCards)} to ${result.movedTo.name}.` : `Removed ${result.removed.name}.`, {
5876
- removed: projectColumn(result.removed),
6451
+ removed: result.removed,
5877
6452
  movedCards: result.movedCards
5878
6453
  });
5879
6454
  }
@@ -5917,6 +6492,14 @@ const BOARD_ERROR_TEXTS = {
5917
6492
  text: "Only a person can approve a card.",
5918
6493
  next: AS_A_PERSON
5919
6494
  }),
6495
+ board_card_not_ready: (details) => ({
6496
+ 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.`,
6497
+ next: "kanbo ready"
6498
+ }),
6499
+ board_card_run_active: (details) => ({
6500
+ text: `${named(details.issueId)} already has a run going: ${named(details.agentName)}'s, run ${named(details.runId)}.`,
6501
+ next: `kanbo run finish ${named(details.runId)} --state stopped`
6502
+ }),
5920
6503
  board_column_name_taken: (details) => ({
5921
6504
  text: `Another column, ${named(details.takenBy)}, already has that name (same slug). Pick another name.`,
5922
6505
  next: "kanbo columns list"
@@ -5947,6 +6530,14 @@ const BOARD_ERROR_TEXTS = {
5947
6530
  }),
5948
6531
  board_entry_rule_invalid: (details) => ({ text: `Unknown column rule: ${list(details.rules)}. Use any of ${list(details.allowed)}.` }),
5949
6532
  board_execution_mode_locked: (details) => ({ text: `${named(details.issueId)} has been started, so where it is worked (${named(details.executionMode)}) can't change now.` }),
6533
+ 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.` } : {
6534
+ text: `A column accepts cards only from other columns of its own board, and ${list(details.allowedFrom)} is not one.`,
6535
+ next: "kanbo columns list"
6536
+ },
6537
+ board_nothing_ready: () => ({
6538
+ text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
6539
+ next: "kanbo ready"
6540
+ }),
5950
6541
  board_pull_request_invalid: (details) => ({ text: `"${named(details.value)}" is not a GitHub pull request. Give its link or owner/repo#number.` }),
5951
6542
  board_pull_request_not_found: (details) => ({
5952
6543
  text: `${named(details.issueId)} has no pull request link "${named(details.linkId)}".`,
@@ -6002,6 +6593,7 @@ const BOARD_ERROR_TEXTS = {
6002
6593
  text: `No card "${named(details.issueId)}" here.`,
6003
6594
  next: "kanbo card list"
6004
6595
  }),
6596
+ issue_parent_cycle: () => ({ text: "A card can't be put under one of its own sub-cards." }),
6005
6597
  issue_parent_not_found: (details) => ({
6006
6598
  text: `No card "${named(details.parentIssueId)}" here to put this one under.`,
6007
6599
  next: "kanbo card list"
@@ -6011,7 +6603,7 @@ const BOARD_ERROR_TEXTS = {
6011
6603
  issue_relation_self_reference: () => ({ text: "A card can't be linked to itself." }),
6012
6604
  issue_status_name_empty: () => ({ text: "Give the column a name." }),
6013
6605
  issue_status_not_found: (details) => ({
6014
- text: `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
6606
+ 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.`,
6015
6607
  next: "kanbo columns list"
6016
6608
  }),
6017
6609
  issue_status_reference_conflict: () => ({ text: "Name the column once: by its id or by its name, not both." }),
@@ -6215,7 +6807,7 @@ async function runColumnsMenu(ui) {
6215
6807
  if (showList) say$1(ui, [
6216
6808
  "",
6217
6809
  "Columns of this board:",
6218
- describeColumns(columns.map(projectColumn))
6810
+ describeColumns(columns.map((column) => projectColumn(column, columns)))
6219
6811
  ].join("\n"));
6220
6812
  let choice;
6221
6813
  try {
@@ -6443,7 +7035,8 @@ function registerColumnsCommands(program) {
6443
7035
  const columns = program.command("columns").description("See and change the board's columns (alone: a menu)");
6444
7036
  withBoardOptions(columns.command("list").description("List the columns in order, with the short name to move cards by")).action(async (options) => {
6445
7037
  await runBoardCommand(options, "read", async (session) => {
6446
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
7038
+ const board = await session.ops.listColumns(session.workspace.id);
7039
+ const views = board.map((column) => projectColumn(column, board));
6447
7040
  return {
6448
7041
  value: views,
6449
7042
  text: describeColumns(views)
@@ -6452,7 +7045,7 @@ function registerColumnsCommands(program) {
6452
7045
  });
6453
7046
  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) => {
6454
7047
  await runBoardCommand(options, "write", async (session) => {
6455
- const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text));
7048
+ const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text), await session.ops.listColumns(session.workspace.id));
6456
7049
  return {
6457
7050
  value: view,
6458
7051
  text: describeColumns([view])
@@ -6463,10 +7056,31 @@ function registerColumnsCommands(program) {
6463
7056
  const actor = requireHumanActor("columns rules", typedWords(command));
6464
7057
  const rules = readRequestedRules(options);
6465
7058
  await runBoardCommand(options, "write", async (session) => {
6466
- const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor));
7059
+ const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor), await session.ops.listColumns(session.workspace.id));
7060
+ return {
7061
+ value: view,
7062
+ text: describeColumns([view])
7063
+ };
7064
+ });
7065
+ });
7066
+ 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) => {
7067
+ const actor = requireHumanActor("columns flow", typedWords(command));
7068
+ if (options.from === void 0 && options.wip === void 0) throw new CliError(1, "Pass --from <columns|any>, --wip <count|none>, or both.");
7069
+ const wipLimit = options.wip === void 0 ? void 0 : readWipLimit(options.wip);
7070
+ await runBoardCommand(options, "write", async (session) => {
7071
+ const workspaceId = session.workspace.id;
7072
+ const board = await session.ops.listColumns(workspaceId);
7073
+ const target = await session.ops.requireColumn(workspaceId, column);
7074
+ const current = readFlowRules(target);
7075
+ await session.ops.setColumnFlowRules(target.id, {
7076
+ allowedFrom: options.from === void 0 ? current.allowedFrom : readAllowedFrom(board, workspaceId, options.from),
7077
+ wipLimit: wipLimit === void 0 ? current.wipLimit : wipLimit
7078
+ }, actor);
7079
+ const after = await session.ops.listColumns(workspaceId);
7080
+ const views = after.map((each) => projectColumn(each, after));
6467
7081
  return {
6468
- value: view,
6469
- text: describeColumns([view])
7082
+ value: views.find((view) => view.id === target.id),
7083
+ text: describeColumns(views)
6470
7084
  };
6471
7085
  });
6472
7086
  });
@@ -6511,7 +7125,7 @@ function registerColumnsCommands(program) {
6511
7125
  mode: "add-missing",
6512
7126
  actor
6513
7127
  });
6514
- const views = statuses.map(projectColumn);
7128
+ const views = statuses.map((column) => projectColumn(column, statuses));
6515
7129
  return {
6516
7130
  value: {
6517
7131
  added,
@@ -6535,7 +7149,8 @@ function registerColumnsCommands(program) {
6535
7149
  return;
6536
7150
  }
6537
7151
  await runBoardCommand({}, "read", async (session) => {
6538
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
7152
+ const board = await session.ops.listColumns(session.workspace.id);
7153
+ const views = board.map((column) => projectColumn(column, board));
6539
7154
  return {
6540
7155
  value: views,
6541
7156
  text: describeColumns(views)
@@ -6565,6 +7180,26 @@ function readRequestedRules(options) {
6565
7180
  if (options.require === void 0) throw new CliError(1, `Pass --require ${ENTRY_RULES.join(",")} (any of them) or --clear.`);
6566
7181
  return options.require.split(",").map((rule) => rule.trim()).filter(Boolean);
6567
7182
  }
7183
+ /** `--from`: `any` for every column, else the columns named, by slug, name or id. */
7184
+ function readAllowedFrom(board, workspaceId, value) {
7185
+ if (value.trim().toLowerCase() === "any") return null;
7186
+ const names = value.split(",").map((name) => name.trim()).filter(Boolean);
7187
+ if (names.length === 0) throw new CliError(1, "Name the columns a card may come from (--from in_review,qa), or --from any.");
7188
+ return names.map((name) => {
7189
+ const found = matchColumn(board, workspaceId, name);
7190
+ if (!found) throw new BoardError("issue_status_not_found", {
7191
+ workspaceId,
7192
+ statusName: name
7193
+ });
7194
+ return found.id;
7195
+ });
7196
+ }
7197
+ /** `--wip`: `none` for no limit, else a whole number of cards, 1 or more. */
7198
+ function readWipLimit(value) {
7199
+ if (value.trim().toLowerCase() === "none") return null;
7200
+ if (!/^\d+$/.test(value.trim()) || Number(value) < 1) throw new CliError(1, "--wip takes a whole number of cards, 1 or more, or none.");
7201
+ return Number(value);
7202
+ }
6568
7203
  //#endregion
6569
7204
  //#region src/package-version.ts
6570
7205
  dirname(fileURLToPath(import.meta.url));
@@ -6574,7 +7209,7 @@ dirname(fileURLToPath(import.meta.url));
6574
7209
  * `package.version`. A build carries it as a literal; running from source
6575
7210
  * (tests, `tsx`) reads the package's own `package.json` instead.
6576
7211
  */
6577
- const KANBO_PACKAGE_VERSION = "0.3.0";
7212
+ const KANBO_PACKAGE_VERSION = "0.5.0";
6578
7213
  //#endregion
6579
7214
  //#region src/cli/setup/paths.ts
6580
7215
  /**
@@ -6923,7 +7558,7 @@ const INSTRUCTION_END_PATTERN = /<!-- KANBO_END\s*-->/g;
6923
7558
  const POINTER_KINDS = /* @__PURE__ */ new Set(["short", "global"]);
6924
7559
  /** The commands an agent uses, spelled once for the guide and the `kanbo prime` cheat sheet. */
6925
7560
  const COMMAND = {
6926
- ready: "kanbo ready",
7561
+ claim: "kanbo claim",
6927
7562
  move: "kanbo card move <id> <column>",
6928
7563
  statusLine: "kanbo card status-line <id> --text \"...\"",
6929
7564
  subtask: "kanbo card create --description \"...\" --parent <id>",
@@ -6942,7 +7577,7 @@ const AGENT_GUIDE_TEXT = [
6942
7577
  "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.",
6943
7578
  "",
6944
7579
  ...[
6945
- `- Take a card from \`${COMMAND.ready}\`.`,
7580
+ `- 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.`,
6946
7581
  `- ${MOVE_CARD_RULE} (\`${COMMAND.move}\`)`,
6947
7582
  `- ${STATUS_LINE_RULE} (\`${COMMAND.statusLine}\`)`,
6948
7583
  `- ${SUBTASK_RULE} (\`${COMMAND.subtask}\`)`,
@@ -6970,13 +7605,15 @@ const COMMAND_SHEET = [
6970
7605
  "- `kanbo card status-line TST-5 --text \"Running the tests\"` — what is happening on the card right now",
6971
7606
  "- `kanbo card comment TST-5 --content \"Added slugify with a test\"` — a finding, result, decision or question",
6972
7607
  "- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
7608
+ "- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
6973
7609
  "- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
6974
- "- `kanbo run start TST-5 --agent claude` — record a run you started yourself; it prints the finish command",
7610
+ "- `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",
7611
+ "- `kanbo run start TST-5 --agent claude` — record a run on a card you did not claim; it prints the finish command",
6975
7612
  "- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
6976
7613
  `- \`${COMMAND.capabilities}\` — everything else, rarely needed`
6977
7614
  ].join("\n");
6978
7615
  /** How to take work and who approves it — said by both blocks after their first sentence. */
6979
- const POINTER_TAIL = `Take work with \`${COMMAND.ready}\`. ${PERSON_ONLY_RULE}`;
7616
+ const POINTER_TAIL = `Take work with \`${COMMAND.claim}\`. ${PERSON_ONLY_RULE}`;
6980
7617
  /** The body of the project block: what `kanbo instructions short` prints. */
6981
7618
  const PROJECT_BODY = [
6982
7619
  "## Kanbo board",
@@ -6996,7 +7633,7 @@ const GLOBAL_BODY = [
6996
7633
  * The block, markers included, with this body's version and hash on the start
6997
7634
  * marker — and, when given, the kind of text it is (`k=orchestrator`).
6998
7635
  */
6999
- function wrapInstructionBlock(body, version = 2, kind) {
7636
+ function wrapInstructionBlock(body, version = 3, kind) {
7000
7637
  return `<!-- KANBO_START v${version}${kind ? ` k=${kind}` : ""} h=${hashBody(body)} -->\n${body}\n${INSTRUCTION_END}`;
7001
7638
  }
7002
7639
  /** The block `kanbo init --instructions …` writes into a project's own file. */
@@ -7025,7 +7662,7 @@ function inspectBlock(text, current) {
7025
7662
  kind
7026
7663
  });
7027
7664
  if (fields.trim() === "") return inspection(LEGACY_BLOCK_BODIES.includes(body) ? "legacy" : "edited");
7028
- if (version !== void 0 && Number(version) > 2) return inspection("newer");
7665
+ if (version !== void 0 && Number(version) > 3) return inspection("newer");
7029
7666
  if (kind !== null && !POINTER_KINDS.has(kind)) return inspection("chosen");
7030
7667
  if (version === void 0 || hash === void 0 || hashBody(body) !== hash) return inspection("edited");
7031
7668
  return inspection(normalized.slice(found.start, found.end) === current ? "current" : "outdated");
@@ -8055,7 +8692,7 @@ function startUpdateCheck(deps = {}) {
8055
8692
  setTimeout: globalThis.setTimeout,
8056
8693
  clearTimeout: globalThis.clearTimeout
8057
8694
  };
8058
- const current = deps.currentVersion ?? "0.3.0";
8695
+ const current = deps.currentVersion ?? "0.5.0";
8059
8696
  const controller = new AbortController();
8060
8697
  let found = null;
8061
8698
  const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
@@ -9292,6 +9929,7 @@ async function applyInitPlan(plan) {
9292
9929
  }
9293
9930
  if (plan.target.kind === "postgres" && plan.migrate) columns = await migrateSharedBoard(plan.target.url, plan.workspace.id, plan.columns);
9294
9931
  const bindingPath = writeBinding(plan.projectDir, plan.binding);
9932
+ rememberProjectQuietly(plan.projectDir);
9295
9933
  const files = plan.fileChanges.map(({ kind, change }) => ({
9296
9934
  kind,
9297
9935
  ...applyFileChange(change)
@@ -10039,7 +10677,7 @@ async function planAgents(projectDir, agents, options) {
10039
10677
  }
10040
10678
  /** The board this project is bound to, and the workspace on it. */
10041
10679
  async function resolveBoard(projectDir, options) {
10042
- if (options.file !== void 0 || namesNoBoard(projectDir, options)) return await resolveOwnFile(projectDir, options);
10680
+ if (options.file !== void 0 || namesNoBoard$1(projectDir, options)) return await resolveOwnFile(projectDir, options);
10043
10681
  const target = resolveInitTarget(projectDir, options);
10044
10682
  if (target.kind === "postgres") return {
10045
10683
  target,
@@ -10058,7 +10696,7 @@ async function resolveBoard(projectDir, options) {
10058
10696
  * does — a person on a machine with nothing else installed gets a board, not a
10059
10697
  * refusal that names a flag.
10060
10698
  */
10061
- function namesNoBoard(projectDir, options) {
10699
+ function namesNoBoard$1(projectDir, options) {
10062
10700
  return !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DB_PATH?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(projectDir) === null;
10063
10701
  }
10064
10702
  /** The workspace a host database — or the board file this project is already bound to — says this project is. */
@@ -10486,11 +11124,12 @@ const KANBO_MCP_INSTRUCTIONS = [
10486
11124
  "kanbo is a kanban board shared by people and agents; these tools read and change its cards.",
10487
11125
  "",
10488
11126
  `- At the start of a session call ${tool("kanbo_prime")} — it returns this board's columns and what each one means.`,
10489
- `- Take work from ${tool("kanbo_ready")}.`,
11127
+ `- 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.`,
10490
11128
  `- ${MOVE_CARD_RULE} (${tool("kanbo_card_move")})`,
10491
11129
  `- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
10492
11130
  `- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
10493
11131
  `- ${SUBTASK_RULE} (${tool("kanbo_card_create")} with \`parent\`)`,
11132
+ `- A card at the wrong level is moved with ${tool("kanbo_card_update")} and \`parent\` (\`"none"\` for the top level).`,
10494
11133
  `- ${WAIT_FOR_PERSON_RULE} (${tool("kanbo_wait_approval")})`,
10495
11134
  `- ${PERSON_ONLY_RULE}`,
10496
11135
  `- Every tool, rule and canonical string: the resource \`${KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI}\`.`
@@ -10576,7 +11215,7 @@ const ORCHESTRATOR_GUIDE = [
10576
11215
  "## Start of every session",
10577
11216
  "1. Run `kanbo prime` (tool: `kanbo_prime`) and read the column descriptions. On this board the description is the contract, not the name.",
10578
11217
  "2. Run `kanbo ready` (`kanbo_ready`). If nothing is ready, say so and stop.",
10579
- "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.",
11218
+ "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.",
10580
11219
  "",
10581
11220
  "## While working",
10582
11221
  "- 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.",
@@ -10603,7 +11242,7 @@ const ORCHESTRATOR_GUIDE = [
10603
11242
  "- Returned: the reason is the newest comment. The card is back in an earlier column; start a new run and address the reason first.",
10604
11243
  "",
10605
11244
  "## Never",
10606
- "- 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.",
11245
+ "- 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.",
10607
11246
  "- Never delete or edit the board's database directly."
10608
11247
  ].join("\n");
10609
11248
  //#endregion
@@ -10648,7 +11287,7 @@ function registerInstructionsCommand(program, dependencies = {}) {
10648
11287
  const found = parseKind(kind);
10649
11288
  const body = await readInstructionText(found);
10650
11289
  const pointer = found === "short" || found === "global";
10651
- const text = options.markers ? wrapInstructionBlock(body, 2, pointer ? void 0 : found) : body;
11290
+ const text = options.markers ? wrapInstructionBlock(body, 3, pointer ? void 0 : found) : body;
10652
11291
  const hints = process.stderr.isTTY === true;
10653
11292
  if (hints) process.stderr.write(`${HEADER}\n\n`);
10654
11293
  console.log(text);
@@ -10870,61 +11509,6 @@ const cardCommentTool = defineKanboTool({
10870
11509
  },
10871
11510
  run: async (transport, input) => await transport.cardComment(input)
10872
11511
  });
10873
- //#endregion
10874
- //#region src/mcp/tools/card-create.ts
10875
- /** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
10876
- const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pass `parent` = that card's id (its key, e.g. `MAN-012`).";
10877
- /** A new card, or a sub-card of the one being worked on. */
10878
- const cardCreateTool = defineKanboTool({
10879
- name: "kanbo_card_create",
10880
- title: "Create a card",
10881
- description: `Put a new card on this board. ${SUBTASK_SENTENCE} ${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("pass `parent`")}`,
10882
- inputSchema: {
10883
- title: 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."),
10884
- description: z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
10885
- column: z.string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
10886
- parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
10887
- executionMode: executionModeArgument.optional()
10888
- },
10889
- example: {
10890
- description: "Add slugify(text) with a test",
10891
- parent: "TST-1"
10892
- },
10893
- run: async (transport, input) => await transport.cardCreate(input)
10894
- });
10895
- //#endregion
10896
- //#region src/mcp/tools/card-get.ts
10897
- /** One card in full, with what an agent reads next to it. */
10898
- const cardGetTool = defineKanboTool({
10899
- name: "kanbo_card_get",
10900
- title: "Read a card",
10901
- 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"].`,
10902
- inputSchema: {
10903
- card: cardArgument,
10904
- include: z.array(z.enum(KANBO_CARD_INCLUDES)).optional().describe("What to read with the card; [\"comments\",\"subCards\"] when absent, [] for the card alone."),
10905
- commentLimit: z.number().int().positive().optional().describe(`How many of the latest comments to include; 10 when absent.`)
10906
- },
10907
- example: { card: "TST-5" },
10908
- run: async (transport, input) => await transport.cardGet(input)
10909
- });
10910
- //#endregion
10911
- //#region src/mcp/tools/card-link-pr.ts
10912
- /** A pull request the card's work went into, named on the card so its facts reach it. */
10913
- const cardLinkPrTool = defineKanboTool({
10914
- name: "kanbo_card_link_pr",
10915
- title: "Link a pull request to a card",
10916
- 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.",
10917
- inputSchema: {
10918
- card: cardArgument,
10919
- url: z.string().min(1).describe("The pull request, as https://github.com/<owner>/<repo>/pull/<number> or <owner>/<repo>#<number>.")
10920
- },
10921
- needs: { url: "the pull request" },
10922
- example: {
10923
- card: "TST-5",
10924
- url: "https://github.com/owner/repo/pull/12"
10925
- },
10926
- run: async (transport, input) => await transport.cardLinkPullRequest(input)
10927
- });
10928
11512
  /** The `detail` and `fields` arguments the list tools share. */
10929
11513
  const cardListViewArguments = {
10930
11514
  detail: 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."),
@@ -10963,6 +11547,20 @@ function compactKanboCard(card) {
10963
11547
  ...card.lastComment ? { lastComment: card.lastComment } : {}
10964
11548
  };
10965
11549
  }
11550
+ /** The `detail` argument of the tools that write a card: a list's own word, for the one card a write answers with. */
11551
+ const writtenCardDetailArgument = z.enum(["compact", "full"]).optional().describe("compact (default): the card as kanbo_card_list prints it. full: every field, description included.");
11552
+ /** What each tool that writes a card says about its answer, in its description. */
11553
+ const WRITTEN_CARD_ANSWER_SENTENCE = "Answers the card compact, as kanbo_card_list prints it; detail: \"full\" for every field.";
11554
+ /**
11555
+ * The card a write hands back, as the tool answers with it: compact and on one
11556
+ * line, as a list prints it. An agent writes its card at every step — a status
11557
+ * line, a move, an update — and reads each answer again on every later turn,
11558
+ * so a whole card, description included, would cost it again and again.
11559
+ * `detail: "full"` answers every field, as `kanbo_card_get` prints the card alone.
11560
+ */
11561
+ function formatWrittenKanboCard(card, detail) {
11562
+ return detail === "full" ? JSON.stringify(card, null, 2) : JSON.stringify(compactKanboCard(card));
11563
+ }
10966
11564
  function viewCard(card, view) {
10967
11565
  if (view.fields) return Object.fromEntries([["id", card.id], ...view.fields.map((field) => [field, card[field]])]);
10968
11566
  return view.detail === "full" ? card : compactKanboCard(card);
@@ -10986,6 +11584,62 @@ function formatKanboCardPage(page, view, returned) {
10986
11584
  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)}`;
10987
11585
  return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
10988
11586
  }
11587
+ //#endregion
11588
+ //#region src/mcp/tools/card-create.ts
11589
+ /** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
11590
+ const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pass `parent` = that card's id (its key, e.g. `MAN-012`).";
11591
+ /** A new card, or a sub-card of the one being worked on. */
11592
+ const cardCreateTool = defineKanboTool({
11593
+ name: "kanbo_card_create",
11594
+ title: "Create a card",
11595
+ 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.`,
11596
+ inputSchema: {
11597
+ title: 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."),
11598
+ description: z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
11599
+ column: z.string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
11600
+ parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
11601
+ executionMode: executionModeArgument.optional(),
11602
+ detail: writtenCardDetailArgument
11603
+ },
11604
+ example: {
11605
+ description: "Add slugify(text) with a test",
11606
+ parent: "TST-1"
11607
+ },
11608
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardCreate(input), detail)
11609
+ });
11610
+ //#endregion
11611
+ //#region src/mcp/tools/card-get.ts
11612
+ /** One card in full, with what an agent reads next to it. */
11613
+ const cardGetTool = defineKanboTool({
11614
+ name: "kanbo_card_get",
11615
+ title: "Read a card",
11616
+ 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"].`,
11617
+ inputSchema: {
11618
+ card: cardArgument,
11619
+ include: z.array(z.enum(KANBO_CARD_INCLUDES)).optional().describe("What to read with the card; [\"comments\",\"subCards\"] when absent, [] for the card alone."),
11620
+ commentLimit: z.number().int().positive().optional().describe(`How many of the latest comments to include; 10 when absent.`)
11621
+ },
11622
+ example: { card: "TST-5" },
11623
+ run: async (transport, input) => await transport.cardGet(input)
11624
+ });
11625
+ //#endregion
11626
+ //#region src/mcp/tools/card-link-pr.ts
11627
+ /** A pull request the card's work went into, named on the card so its facts reach it. */
11628
+ const cardLinkPrTool = defineKanboTool({
11629
+ name: "kanbo_card_link_pr",
11630
+ title: "Link a pull request to a card",
11631
+ 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.",
11632
+ inputSchema: {
11633
+ card: cardArgument,
11634
+ url: z.string().min(1).describe("The pull request, as https://github.com/<owner>/<repo>/pull/<number> or <owner>/<repo>#<number>.")
11635
+ },
11636
+ needs: { url: "the pull request" },
11637
+ example: {
11638
+ card: "TST-5",
11639
+ url: "https://github.com/owner/repo/pull/12"
11640
+ },
11641
+ run: async (transport, input) => await transport.cardLinkPullRequest(input)
11642
+ });
10989
11643
  /** The cards on the board a question picks, one page of them. */
10990
11644
  const cardListTool = defineKanboTool({
10991
11645
  name: "kanbo_card_list",
@@ -11040,10 +11694,11 @@ function readMoment(value) {
11040
11694
  const cardMoveTool = defineKanboTool({
11041
11695
  name: "kanbo_card_move",
11042
11696
  title: "Move a card",
11043
- 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.",
11697
+ 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}`,
11044
11698
  inputSchema: {
11045
11699
  card: cardArgument,
11046
- column: z.string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`.")
11700
+ column: z.string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`."),
11701
+ detail: writtenCardDetailArgument
11047
11702
  },
11048
11703
  aliases: { column: ["to"] },
11049
11704
  needs: { column: "the column slug, e.g. in_progress" },
@@ -11051,7 +11706,7 @@ const cardMoveTool = defineKanboTool({
11051
11706
  card: "TST-5",
11052
11707
  column: "in_progress"
11053
11708
  },
11054
- run: async (transport, input) => await transport.cardMove(input)
11709
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardMove(input), detail)
11055
11710
  });
11056
11711
  //#endregion
11057
11712
  //#region src/mcp/tools/card-pull-requests.ts
@@ -11066,11 +11721,11 @@ const cardPullRequestsTool = defineKanboTool({
11066
11721
  });
11067
11722
  //#endregion
11068
11723
  //#region src/mcp/tools/card-update.ts
11069
- /** What a card says: its title, what it is about, how urgent it is, how it is labelled. */
11724
+ /** What a card says — its title, what it is about, how urgent it is, how it is labelled — and which card it sits under. */
11070
11725
  const cardUpdateTool = defineKanboTool({
11071
11726
  name: "kanbo_card_update",
11072
11727
  title: "Update a card",
11073
- description: "Change what a card says: its title, its description, its priority, its labels or where its work happens.",
11728
+ 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}`,
11074
11729
  inputSchema: {
11075
11730
  card: cardArgument,
11076
11731
  title: z.string().min(1).optional().describe("What the card is called."),
@@ -11083,18 +11738,26 @@ const cardUpdateTool = defineKanboTool({
11083
11738
  "urgent"
11084
11739
  ]).optional(),
11085
11740
  labels: z.array(z.string()).optional().describe("The labels the card carries, replacing the ones it has."),
11086
- executionMode: executionModeArgument.optional()
11741
+ executionMode: executionModeArgument.optional(),
11742
+ 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."),
11743
+ detail: writtenCardDetailArgument
11087
11744
  },
11088
11745
  example: {
11089
11746
  card: "TST-5",
11090
11747
  title: "Add slugify"
11091
11748
  },
11092
- run: async (transport, { card, ...patch }) => {
11093
- if (Object.keys(patch).length === 0) throw new Error("Nothing to update. Pass title, description, priority, labels or executionMode.");
11094
- return await transport.cardUpdate({
11749
+ prepare: (input) => {
11750
+ const { parent, ...rest } = input;
11751
+ return parent === null ? rest : input;
11752
+ },
11753
+ run: async (transport, { card, detail, parent, ...patch }) => {
11754
+ if (Object.keys(patch).length === 0 && parent === void 0) throw new Error("Nothing to update. Pass title, description, priority, labels, executionMode or parent.");
11755
+ const placement = parent === void 0 ? {} : { parent: parent === "none" ? null : parent };
11756
+ return formatWrittenKanboCard(await transport.cardUpdate({
11095
11757
  card,
11096
- ...patch
11097
- });
11758
+ ...patch,
11759
+ ...placement
11760
+ }), detail);
11098
11761
  }
11099
11762
  });
11100
11763
  //#endregion
@@ -11103,7 +11766,7 @@ const cardUpdateTool = defineKanboTool({
11103
11766
  const columnsTool = defineKanboTool({
11104
11767
  name: "kanbo_columns",
11105
11768
  title: "Columns of the board",
11106
- 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.",
11769
+ 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).",
11107
11770
  inputSchema: {},
11108
11771
  run: async (transport) => await transport.columns()
11109
11772
  });
@@ -11117,6 +11780,38 @@ const primeTool = defineKanboTool({
11117
11780
  inputSchema: {},
11118
11781
  run: async (transport) => (await transport.prime()).text
11119
11782
  });
11783
+ //#endregion
11784
+ //#region src/mcp/tools/card-claim.ts
11785
+ /**
11786
+ * Taking a card to work on, in one step: the run starts and the card moves out
11787
+ * of To Do into the board's first work-in-progress column in the same write,
11788
+ * so two agents never get the same card. Published to every host, including
11789
+ * one that records the runs of the agents it launches: a claim is how an agent
11790
+ * in any terminal takes work.
11791
+ */
11792
+ const cardClaimTool = defineKanboTool({
11793
+ name: "kanbo_card_claim",
11794
+ title: "Claim a card",
11795
+ 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.",
11796
+ inputSchema: {
11797
+ card: cardArgument.optional(),
11798
+ agent: z.string().min(1).optional().describe("What to call whoever works the card; who this server writes as when absent.")
11799
+ },
11800
+ aliases: {
11801
+ card: [...CARD_ALIASES, "issue"],
11802
+ agent: ["agentName"]
11803
+ },
11804
+ example: { card: "TST-5" },
11805
+ run: async (transport, input) => {
11806
+ const claimed = await transport.cardClaim(input);
11807
+ return {
11808
+ card: compactKanboCard(claimed.card),
11809
+ run: claimed.run.id,
11810
+ column: claimed.column.slug,
11811
+ finishWith: `kanbo_run_finish {"run":"${claimed.run.id}","state":"finished"}`
11812
+ };
11813
+ }
11814
+ });
11120
11815
  /** The cards an agent may pick up right now. */
11121
11816
  const readyTool = defineKanboTool({
11122
11817
  name: "kanbo_ready",
@@ -11205,13 +11900,14 @@ const runStartTool = defineKanboTool({
11205
11900
  /**
11206
11901
  * Every tool the board has, in the order it declares them.
11207
11902
  *
11208
- * The list is the board's, not a server's: the same sixteen are registered
11903
+ * The list is the board's, not a server's: the same seventeen are registered
11209
11904
  * whether they run against a server or against the board directly, and the
11210
11905
  * only thing a host decides is which of them it publishes.
11211
11906
  */
11212
11907
  const KANBO_TOOLS = [
11213
11908
  primeTool,
11214
11909
  readyTool,
11910
+ cardClaimTool,
11215
11911
  columnsTool,
11216
11912
  defineKanboTool({
11217
11913
  name: "kanbo_sprints",
@@ -11231,10 +11927,11 @@ const KANBO_TOOLS = [
11231
11927
  defineKanboTool({
11232
11928
  name: "kanbo_status_line",
11233
11929
  title: "Set the status line",
11234
- 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.",
11930
+ 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,
11235
11931
  inputSchema: {
11236
11932
  card: cardArgument,
11237
- text: z.string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`.")
11933
+ text: z.string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`."),
11934
+ detail: writtenCardDetailArgument
11238
11935
  },
11239
11936
  aliases: { text: ["content"] },
11240
11937
  needs: { text: "the status line, one sentence" },
@@ -11242,22 +11939,23 @@ const KANBO_TOOLS = [
11242
11939
  card: "TST-5",
11243
11940
  text: "Running the tests"
11244
11941
  },
11245
- run: async (transport, input) => await transport.statusLine(input)
11942
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.statusLine(input), detail)
11246
11943
  }),
11247
11944
  defineKanboTool({
11248
11945
  name: "kanbo_wait_approval",
11249
11946
  title: "Hand the card to a person",
11250
- 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.",
11947
+ 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}`,
11251
11948
  inputSchema: {
11252
11949
  card: cardArgument,
11253
- text: 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`.")
11950
+ text: 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`."),
11951
+ detail: writtenCardDetailArgument
11254
11952
  },
11255
11953
  aliases: { text: ["content"] },
11256
11954
  example: {
11257
11955
  card: "TST-5",
11258
11956
  text: "Ready for review: slugify added with a test"
11259
11957
  },
11260
- run: async (transport, input) => await transport.waitApproval(input)
11958
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.waitApproval(input), detail)
11261
11959
  }),
11262
11960
  runStartTool,
11263
11961
  runFinishTool
@@ -11341,7 +12039,8 @@ async function createDbTransport(input) {
11341
12039
  const workspaceId = workspace.id;
11342
12040
  const actor = input.actor;
11343
12041
  async function readColumns() {
11344
- return (await ops.listColumns(workspaceId)).map(projectKanboColumn);
12042
+ const rows = await ops.listColumns(workspaceId);
12043
+ return rows.map((row) => projectKanboColumn(row, rows));
11345
12044
  }
11346
12045
  /** One card with what the run registry says about it, in the shape a tool prints. */
11347
12046
  async function project(card, columns) {
@@ -11391,7 +12090,7 @@ async function createDbTransport(input) {
11391
12090
  * the waiting and returned ones last said at most two more.
11392
12091
  */
11393
12092
  async function projectPage(page, { attend = true } = {}) {
11394
- const columns = page.columns.map(projectKanboColumn);
12093
+ const columns = page.columns.map((column) => projectKanboColumn(column, page.columns));
11395
12094
  const runs = await ops.readBoardProjectionForIssues(page.cards.map((card) => card.id));
11396
12095
  const attention = attend ? await ops.readCardAttention(page.cards, runs.map((run) => run.activeRun !== null)) : [];
11397
12096
  return page.cards.map((card, index) => projectKanboCard({
@@ -11458,10 +12157,14 @@ async function createDbTransport(input) {
11458
12157
  executionMode
11459
12158
  }, actor));
11460
12159
  },
11461
- cardUpdate: async ({ card, ...patch }) => {
12160
+ cardUpdate: async ({ card, parent, ...patch }) => {
11462
12161
  await session.assertWritable();
11463
12162
  const existing = await resolveCard(card);
11464
- return await project(await ops.updateCard(existing.id, patch, actor));
12163
+ const placement = parent === void 0 ? {} : { parentIssueId: parent === null ? null : (await resolveCard(parent)).id };
12164
+ return await project(await ops.updateCard(existing.id, {
12165
+ ...patch,
12166
+ ...placement
12167
+ }, actor));
11465
12168
  },
11466
12169
  cardMove: async ({ card, column }) => {
11467
12170
  await session.assertWritable();
@@ -11519,6 +12222,25 @@ async function createDbTransport(input) {
11519
12222
  state,
11520
12223
  errorText
11521
12224
  }, actor));
12225
+ },
12226
+ cardClaim: async ({ card, agent }) => {
12227
+ await session.assertWritable();
12228
+ const named = card === void 0 ? void 0 : (await resolveCard(card)).id;
12229
+ const claimed = await ops.claimCard({
12230
+ workspaceId,
12231
+ issueId: named,
12232
+ run: {
12233
+ agentName: agent ?? actor.id ?? "agent",
12234
+ externalSessionRef: sessionRefFromEnvironment() ?? void 0,
12235
+ launchedByKind: "external"
12236
+ }
12237
+ }, actor);
12238
+ const columns = await readColumns();
12239
+ return {
12240
+ card: await project(claimed.card, columns),
12241
+ run: projectKanboRun(claimed.run),
12242
+ column: columns.find((column) => column.id === claimed.column.id)
12243
+ };
11522
12244
  }
11523
12245
  };
11524
12246
  }
@@ -11560,7 +12282,8 @@ const TRANSPORT_METHODS = {
11560
12282
  statusLine: true,
11561
12283
  waitApproval: true,
11562
12284
  runStart: true,
11563
- runFinish: true
12285
+ runFinish: true,
12286
+ cardClaim: true
11564
12287
  };
11565
12288
  async function openMcpBoard(input) {
11566
12289
  try {
@@ -11615,13 +12338,40 @@ function withUpdateNote(board, peek) {
11615
12338
  }
11616
12339
  //#endregion
11617
12340
  //#region src/cli/commands/mcp.ts
12341
+ /**
12342
+ * The actor of a `kanbo mcp` session, which is always an agent's: `KANBO_ACTOR_ID`,
12343
+ * else the agent tool, else `agent` until the client says its name (see
12344
+ * `nameActorAfterClient`).
12345
+ */
12346
+ function createMcpActor() {
12347
+ const tool = readAgentTool();
12348
+ const namedByShell = Boolean(process.env.KANBO_ACTOR_ID?.trim() || tool && tool !== "agent");
12349
+ const actor = createCliActor();
12350
+ if (!namedByShell) actor.id = "agent";
12351
+ return {
12352
+ actor,
12353
+ namedByShell
12354
+ };
12355
+ }
12356
+ /**
12357
+ * Files the session's writes under the client's own name (`Test Client` ->
12358
+ * `test-client`) once it has initialized. `actor` is the object the board
12359
+ * holds, so later writes see the new id.
12360
+ */
12361
+ function nameActorAfterClient(server, actor) {
12362
+ server.server.oninitialized = () => {
12363
+ const id = (server.server.getClientVersion()?.name)?.trim().toLowerCase().replace(/\s+/g, "-").slice(0, 40);
12364
+ if (id) actor.id = id;
12365
+ };
12366
+ }
11618
12367
  function registerMcpCommand(program) {
11619
12368
  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) => {
12369
+ const { actor, namedByShell } = createMcpActor();
11620
12370
  const board = await openMcpBoard({
11621
12371
  dbPath: options.db,
11622
12372
  databaseUrl: options.databaseUrl,
11623
12373
  workspaceId: options.workspace,
11624
- actor: createCliActor()
12374
+ actor
11625
12375
  });
11626
12376
  let closing;
11627
12377
  const close = () => closing ??= board.close().catch((error) => {
@@ -11630,6 +12380,7 @@ function registerMcpCommand(program) {
11630
12380
  const check = isUpdateCheckDisabled() ? null : startUpdateCheck();
11631
12381
  const server = createKanboMcpServer(check ? withUpdateNote(board, check.peek) : board, { includeRunTools: true });
11632
12382
  server.server.onclose = () => void close();
12383
+ if (!namedByShell) nameActorAfterClient(server, actor);
11633
12384
  try {
11634
12385
  await server.connect(new StdioServerTransport());
11635
12386
  } catch (error) {
@@ -12138,7 +12889,7 @@ function sendBoardPage(response, pathname, assets) {
12138
12889
  * answering for a person — approving a card, sending it back or closing a
12139
12890
  * sprint — as anyone but one is 403, dropping a pull request link somebody else made is the same 403
12140
12891
  * for the same reason, and acting on a run that has already ended is 409,
12141
- * because the answer would have been yes a moment earlier. An agent putting a
12892
+ * 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
12142
12893
  * card in a column whose entry rules it does not meet yet is 409 for the same
12143
12894
  * reason turned around: the answer will be yes once the card meets them, and
12144
12895
  * `details.unmet` says what is missing. Deleting a column that still holds
@@ -12156,6 +12907,14 @@ const BOARD_ERROR_RESPONSES = {
12156
12907
  message: "Only a person can approve a card",
12157
12908
  code: "issue_approval_requires_user"
12158
12909
  },
12910
+ board_card_not_ready: {
12911
+ status: 409,
12912
+ message: "That card is not there to take: it is not in To Do, waits for a person, or has a run going"
12913
+ },
12914
+ board_card_run_active: {
12915
+ status: 409,
12916
+ message: "The card already has a run going"
12917
+ },
12159
12918
  board_column_name_taken: {
12160
12919
  status: 400,
12161
12920
  message: "Another column already has that name"
@@ -12195,6 +12954,14 @@ const BOARD_ERROR_RESPONSES = {
12195
12954
  status: 400,
12196
12955
  message: "Execution mode cannot change once the card has been launched"
12197
12956
  },
12957
+ board_flow_rule_invalid: {
12958
+ status: 400,
12959
+ message: "A column accepts cards from other columns of its own board, and holds at least one card"
12960
+ },
12961
+ board_nothing_ready: {
12962
+ status: 409,
12963
+ message: "No card in To Do is ready to take"
12964
+ },
12198
12965
  board_pull_request_invalid: {
12199
12966
  status: 400,
12200
12967
  message: "Not a GitHub pull request: give its URL or owner/repo#number"
@@ -12274,6 +13041,10 @@ const BOARD_ERROR_RESPONSES = {
12274
13041
  status: 404,
12275
13042
  message: "Issue not found"
12276
13043
  },
13044
+ issue_parent_cycle: {
13045
+ status: 400,
13046
+ message: "Issue cannot be put under one of its own sub-issues"
13047
+ },
12277
13048
  issue_parent_not_found: {
12278
13049
  status: 404,
12279
13050
  message: "Parent issue not found"
@@ -12606,6 +13377,14 @@ const startRunBody = z.object({
12606
13377
  ]).optional(),
12607
13378
  externalSessionRef: z.string().nullable().optional()
12608
13379
  });
13380
+ const claimBody = z.object({
13381
+ workspaceId: id,
13382
+ issueId: id.optional(),
13383
+ agentName: id.optional(),
13384
+ branch: z.string().nullable().optional(),
13385
+ executionMode: executionMode.optional(),
13386
+ externalSessionRef: z.string().nullable().optional()
13387
+ });
12609
13388
  const finishRunBody = z.object({
12610
13389
  state: z.enum([
12611
13390
  "finished",
@@ -12652,13 +13431,24 @@ async function toWrittenCardView(ops, card) {
12652
13431
  function toStatusView(status) {
12653
13432
  return {
12654
13433
  ...status,
12655
- entryRules: readEntryRules(status)
13434
+ entryRules: readEntryRules(status),
13435
+ allowedFrom: readFlowRules(status).allowedFrom
12656
13436
  };
12657
13437
  }
12658
- function toCommentView(comment) {
13438
+ function toCommentView(comment, personWriterId = null) {
13439
+ if (comment.authorKind === "system.approved" || comment.authorKind === "system.returned") return {
13440
+ ...comment,
13441
+ author: {
13442
+ kind: "user",
13443
+ id: comment.authorId ?? "__self__",
13444
+ displayName: "You",
13445
+ avatarUrl: null,
13446
+ label: null
13447
+ }
13448
+ };
12659
13449
  return {
12660
13450
  ...comment,
12661
- author: resolveActor(commentAuthorReference(comment))
13451
+ author: resolveActor(commentAuthorReference(comment), personWriterId)
12662
13452
  };
12663
13453
  }
12664
13454
  /**
@@ -12666,23 +13456,34 @@ function toCommentView(comment) {
12666
13456
  *
12667
13457
  * For the kinds that need nobody else's tables: an outside writer is shown
12668
13458
  * under the name it gave, the board writing for itself is `kanbo`, and a person
12669
- * is `You`. An agent and a provider target are rows of whatever app launched
13459
+ * is `You`. So is an outside writer that is the person who started this
13460
+ * server (`personWriterId`): their own writes carry the name their shell files
13461
+ * them under. An agent and a provider target are rows of whatever app launched
12670
13462
  * them, which this server cannot read, so they are shown by the id the board
12671
13463
  * holds for them.
12672
13464
  */
12673
- function resolveActor(actor) {
13465
+ function resolveActor(actor, personWriterId) {
12674
13466
  if (actor.kind === "system") {
12675
13467
  const provenance = {
12676
13468
  authorId: actor.id,
12677
13469
  sourceChatSessionId: actor.sourceChatSessionId
12678
13470
  };
12679
- if (isOutsideWriter(provenance)) return {
12680
- kind: "system",
12681
- id: provenance.authorId,
12682
- displayName: provenance.authorId,
12683
- avatarUrl: null,
12684
- label: "External"
12685
- };
13471
+ if (isOutsideWriter(provenance)) {
13472
+ if (personWriterId !== null && provenance.authorId === personWriterId) return {
13473
+ kind: "user",
13474
+ id: provenance.authorId,
13475
+ displayName: "You",
13476
+ avatarUrl: null,
13477
+ label: null
13478
+ };
13479
+ return {
13480
+ kind: "system",
13481
+ id: provenance.authorId,
13482
+ displayName: provenance.authorId,
13483
+ avatarUrl: null,
13484
+ label: "External"
13485
+ };
13486
+ }
12686
13487
  return {
12687
13488
  kind: "system",
12688
13489
  id: null,
@@ -12718,13 +13519,13 @@ function resolveActor(actor) {
12718
13519
  * serves, named by its identity; actors as `resolveActor` reads them; and an
12719
13520
  * agent assignee by its id.
12720
13521
  */
12721
- function createActivityResolver(workspace) {
13522
+ function createActivityResolver(workspace, personWriterId = null) {
12722
13523
  return {
12723
13524
  workspaceById: /* @__PURE__ */ new Map([[workspace.id, {
12724
13525
  id: workspace.id,
12725
13526
  name: workspace.name
12726
13527
  }]]),
12727
- resolveActor: async (actor) => resolveActor(actor),
13528
+ resolveActor: async (actor) => resolveActor(actor, personWriterId),
12728
13529
  resolveAgentName: async (agentId) => agentId
12729
13530
  };
12730
13531
  }
@@ -12745,10 +13546,15 @@ function createActivityResolver(workspace) {
12745
13546
  const OK = { ok: true };
12746
13547
  /** How many cards a search answers with when the caller names no limit. */
12747
13548
  const DEFAULT_SEARCH_LIMIT = 20;
12748
- function createIssueRoutes(session) {
13549
+ /**
13550
+ * `personWriterId` is the id the person who started the server is filed under;
13551
+ * their writes read as `You`, everyone else's by name.
13552
+ */
13553
+ function createIssueRoutes(session, options = {}) {
13554
+ const personWriterId = options.personWriterId ?? null;
12749
13555
  const { ops, store, workspace } = session;
12750
13556
  const workspaceId = workspace.id;
12751
- const activityResolver = createActivityResolver(workspace);
13557
+ const activityResolver = createActivityResolver(workspace, personWriterId);
12752
13558
  /** The served workspace, when that is the one the caller named — or named none. */
12753
13559
  function requireWorkspace(named) {
12754
13560
  if (named !== void 0 && named !== workspaceId) throw new BoardError("issue_workspace_not_found", { workspaceId: named });
@@ -13033,6 +13839,15 @@ function createIssueRoutes(session) {
13033
13839
  return updated;
13034
13840
  }
13035
13841
  }),
13842
+ route({
13843
+ method: "GET",
13844
+ path: "/issues/workspace",
13845
+ handle: async () => ({
13846
+ id: workspace.id,
13847
+ identifier: workspace.identifier,
13848
+ name: workspace.name
13849
+ })
13850
+ }),
13036
13851
  route({
13037
13852
  method: "GET",
13038
13853
  path: "/issues/:id",
@@ -13052,6 +13867,31 @@ function createIssueRoutes(session) {
13052
13867
  }, actors.placement));
13053
13868
  }
13054
13869
  }),
13870
+ route({
13871
+ method: "POST",
13872
+ path: "/issues/claim",
13873
+ body: claimBody,
13874
+ write: true,
13875
+ handle: async ({ body, actors }) => {
13876
+ const named = requireWorkspace(body.workspaceId);
13877
+ if (body.issueId !== void 0) await requireCard(body.issueId);
13878
+ const claimed = await ops.claimCard({
13879
+ workspaceId: named,
13880
+ issueId: body.issueId,
13881
+ run: {
13882
+ agentName: body.agentName ?? actors.writer.id ?? "agent",
13883
+ branch: body.branch,
13884
+ executionMode: body.executionMode,
13885
+ externalSessionRef: body.externalSessionRef
13886
+ }
13887
+ }, actors.placement);
13888
+ return {
13889
+ card: await toWrittenCardView(ops, claimed.card),
13890
+ run: await toRunView(claimed.run),
13891
+ column: toStatusView(claimed.column)
13892
+ };
13893
+ }
13894
+ }),
13055
13895
  route({
13056
13896
  method: "PATCH",
13057
13897
  path: "/issues/bulk",
@@ -13186,7 +14026,7 @@ function createIssueRoutes(session) {
13186
14026
  path: "/issues/:id/comments",
13187
14027
  handle: async ({ params }) => {
13188
14028
  await requireCard(params.id);
13189
- return (await ops.listComments(params.id)).map(toCommentView);
14029
+ return (await ops.listComments(params.id)).map((comment) => toCommentView(comment, personWriterId));
13190
14030
  }
13191
14031
  }),
13192
14032
  route({
@@ -13199,7 +14039,7 @@ function createIssueRoutes(session) {
13199
14039
  return toCommentView(await ops.addComment({
13200
14040
  issueId: params.id,
13201
14041
  content: body.content
13202
- }, actors.writer));
14042
+ }, actors.person), personWriterId);
13203
14043
  }
13204
14044
  }),
13205
14045
  route({
@@ -13328,7 +14168,7 @@ function assertServeBinding(host, token) {
13328
14168
  }
13329
14169
  async function startKanboServer(options) {
13330
14170
  assertServeBinding(options.host, options.token);
13331
- const routes = createIssueRoutes(options.session);
14171
+ const routes = createIssueRoutes(options.session, { personWriterId: options.actors.writer.id });
13332
14172
  const listenHost = stripBrackets(options.host);
13333
14173
  let boundPort = 0;
13334
14174
  const handle = createRequestHandler(routes, options, () => boundPort);
@@ -13374,6 +14214,11 @@ const SAFE_METHODS = /* @__PURE__ */ new Set([
13374
14214
  "HEAD",
13375
14215
  "OPTIONS"
13376
14216
  ]);
14217
+ /** What the board page tells the server about itself. */
14218
+ const PRESENCE_BODY = z.object({
14219
+ tabId: z.string().min(1).max(64).regex(/^[A-Za-z0-9-]+$/),
14220
+ state: z.enum(["open", "closed"])
14221
+ });
13377
14222
  function createRequestHandler(routes, options, boundPort) {
13378
14223
  const token = options.token || null;
13379
14224
  const corsOrigins = new Set(options.corsOrigins ?? []);
@@ -13434,17 +14279,41 @@ function createRequestHandler(routes, options, boundPort) {
13434
14279
  throw new ServeError(401, "unauthorized", "A valid bearer token is required");
13435
14280
  }
13436
14281
  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");
13437
- const matched = matchRoute(routes, request.method ?? "GET", url.pathname);
13438
- if (!matched) throw new ServeError(404, "not_found", "Not Found");
14282
+ if (url.pathname === "/page/presence" && request.method === "POST") {
14283
+ if (!options.presence) {
14284
+ response.writeHead(204).end();
14285
+ return;
14286
+ }
14287
+ const parsed = PRESENCE_BODY.safeParse(await readJsonBody(request));
14288
+ if (!parsed.success) throw validationError("body", parsed.error);
14289
+ if (parsed.data.state === "open") options.presence.seen(parsed.data.tabId);
14290
+ else options.presence.closed(parsed.data.tabId);
14291
+ sendJson(response, 200, { ok: true });
14292
+ return;
14293
+ }
14294
+ const projectPath = readProjectPath(url.pathname);
14295
+ if (url.pathname === "/projects" && request.method === "GET" && options.projects) {
14296
+ sendJson(response, 200, options.projects.list());
14297
+ return;
14298
+ }
14299
+ const matched = projectPath ? null : matchRoute(routes, request.method ?? "GET", url.pathname);
14300
+ if (!projectPath && !matched) throw new ServeError(404, "not_found", "Not Found");
14301
+ if (projectPath && !options.projects) throw new ServeError(404, "not_found", "Not Found");
13439
14302
  const body = await readJsonBody(request);
13440
14303
  const actors = resolveRequestActors(options.actors, {
13441
14304
  actorHeader: request.headers[ACTOR_HEADER],
13442
14305
  authenticated: token !== null
13443
14306
  });
13444
14307
  const result = queue.then(async () => {
13445
- if (matched.route.write) await options.session.assertWritable();
13446
- return await matched.route.run({
13447
- params: matched.params,
14308
+ const target = projectPath && options.projects ? await options.projects.resolve(projectPath.projectId) : {
14309
+ session: options.session,
14310
+ routes
14311
+ };
14312
+ const found = matched ?? matchRoute(target.routes, request.method ?? "GET", projectPath?.rest ?? url.pathname);
14313
+ if (!found) throw new ServeError(404, "not_found", "Not Found");
14314
+ if (found.route.write) await target.session.assertWritable();
14315
+ return await found.route.run({
14316
+ params: found.params,
13448
14317
  query: readQuery(url.searchParams),
13449
14318
  body,
13450
14319
  actors
@@ -13470,6 +14339,21 @@ function createRequestHandler(routes, options, boundPort) {
13470
14339
  }
13471
14340
  };
13472
14341
  }
14342
+ /** `/projects/<id>/issues/...` as the project's id and the `/issues/...` path under it; `null` for any other path. */
14343
+ function readProjectPath(pathname) {
14344
+ const segments = pathname.split("/").filter(Boolean);
14345
+ if (segments[0] !== "projects" || segments.length < 3 || segments[2] !== "issues") return null;
14346
+ let projectId;
14347
+ try {
14348
+ projectId = decodeURIComponent(segments[1]);
14349
+ } catch {
14350
+ return null;
14351
+ }
14352
+ return {
14353
+ projectId,
14354
+ rest: `/${segments.slice(2).join("/")}`
14355
+ };
14356
+ }
13473
14357
  function isJsonContentType(value) {
13474
14358
  return value?.split(";")[0].trim().toLowerCase() === "application/json";
13475
14359
  }
@@ -13613,6 +14497,146 @@ function readTextOrNull(path) {
13613
14497
  }
13614
14498
  }
13615
14499
  //#endregion
14500
+ //#region src/serve/page-presence.ts
14501
+ function createPagePresence(options) {
14502
+ const closedGraceMs = options.closedGraceMs ?? 5e3;
14503
+ const staleMs = options.staleMs ?? 12e4;
14504
+ const tickMs = options.tickMs ?? 1e4;
14505
+ const now = options.now ?? Date.now;
14506
+ const tabs = /* @__PURE__ */ new Map();
14507
+ let finished = false;
14508
+ let graceTimer = null;
14509
+ let lastTick = now();
14510
+ function stop() {
14511
+ finished = true;
14512
+ clearInterval(ticker);
14513
+ if (graceTimer) {
14514
+ clearTimeout(graceTimer);
14515
+ graceTimer = null;
14516
+ }
14517
+ }
14518
+ function idle() {
14519
+ if (finished) return;
14520
+ stop();
14521
+ options.onIdle();
14522
+ }
14523
+ const ticker = setInterval(() => {
14524
+ const current = now();
14525
+ const asleep = current - lastTick > 3 * tickMs;
14526
+ lastTick = current;
14527
+ if (asleep) {
14528
+ for (const id of tabs.keys()) tabs.set(id, current);
14529
+ return;
14530
+ }
14531
+ if (tabs.size === 0) return;
14532
+ for (const [id, lastSeen] of tabs) if (current - lastSeen > staleMs) tabs.delete(id);
14533
+ if (tabs.size === 0) idle();
14534
+ }, tickMs);
14535
+ ticker.unref();
14536
+ return {
14537
+ seen: (tabId) => {
14538
+ if (!finished) tabs.set(tabId, now());
14539
+ },
14540
+ closed: (tabId) => {
14541
+ if (finished || !tabs.delete(tabId) || tabs.size > 0) return;
14542
+ if (graceTimer) clearTimeout(graceTimer);
14543
+ graceTimer = setTimeout(() => {
14544
+ graceTimer = null;
14545
+ if (tabs.size === 0) idle();
14546
+ }, closedGraceMs);
14547
+ graceTimer.unref();
14548
+ },
14549
+ stop
14550
+ };
14551
+ }
14552
+ //#endregion
14553
+ //#region src/serve/projects.ts
14554
+ function createServeProjects(input) {
14555
+ const routesOptions = { personWriterId: input.personWriterId ?? null };
14556
+ const opened = /* @__PURE__ */ new Map();
14557
+ let defaultOpened = null;
14558
+ /** Every project that can be offered right now, default first, with the folder each lives in. */
14559
+ function entries() {
14560
+ const defaultDir = input.defaultProject?.dir ?? null;
14561
+ const dirs = [...defaultDir === null ? [] : [defaultDir], ...input.readRegistry().map((project) => project.path).filter((path) => path !== defaultDir)];
14562
+ const found = [];
14563
+ for (const dir of dirs) {
14564
+ const binding = input.readBinding(dir);
14565
+ if (binding === null && dir !== defaultDir) continue;
14566
+ found.push({
14567
+ view: viewOf(dir, binding, dir === defaultDir),
14568
+ dir
14569
+ });
14570
+ }
14571
+ return found;
14572
+ }
14573
+ return {
14574
+ list: () => entries().map((entry) => entry.view),
14575
+ resolve: async (projectId) => {
14576
+ const entry = entries().find((candidate) => candidate.view.id === projectId);
14577
+ if (!entry) throw new ServeError(404, "project_not_found", "No project with that id is on this computer's list");
14578
+ if (entry.view.isDefault && input.defaultProject) {
14579
+ defaultOpened ??= {
14580
+ session: input.defaultProject.session,
14581
+ routes: createIssueRoutes(input.defaultProject.session, routesOptions)
14582
+ };
14583
+ return defaultOpened;
14584
+ }
14585
+ const cached = opened.get(projectId);
14586
+ if (cached) return cached;
14587
+ let session;
14588
+ try {
14589
+ session = await input.openSession(entry.dir);
14590
+ } catch (error) {
14591
+ throw new ServeError(503, "project_unavailable", describeFailure(error).message);
14592
+ }
14593
+ const project = {
14594
+ session,
14595
+ routes: createIssueRoutes(session, routesOptions)
14596
+ };
14597
+ opened.set(projectId, project);
14598
+ return project;
14599
+ },
14600
+ close: async () => {
14601
+ const sessions = [...opened.values()].map((project) => project.session);
14602
+ opened.clear();
14603
+ await Promise.allSettled(sessions.map(async (session) => await session.close()));
14604
+ }
14605
+ };
14606
+ }
14607
+ function projectId(dir) {
14608
+ return createHash("sha256").update(dir).digest("hex").slice(0, 12);
14609
+ }
14610
+ function viewOf(dir, binding, isDefault) {
14611
+ return {
14612
+ id: projectId(dir),
14613
+ name: dir.split(/[\\/]/).filter(Boolean).pop() ?? dir,
14614
+ folder: tildify(dir),
14615
+ storage: storageOf(dir, binding),
14616
+ isDefault
14617
+ };
14618
+ }
14619
+ /** Where the board lives. The connection string is read here to name its host, and goes no further. */
14620
+ function storageOf(dir, binding) {
14621
+ if (binding?.databaseUrl) return {
14622
+ kind: "postgres",
14623
+ where: hostOf(maskDatabaseUrl(binding.databaseUrl))
14624
+ };
14625
+ return {
14626
+ kind: "file",
14627
+ path: tildify(resolve(dir, binding?.dbPath ?? ".kanbo/board.db"))
14628
+ };
14629
+ }
14630
+ /** The host of an already masked connection string; a string that is not one names no host. */
14631
+ function hostOf(maskedUrl) {
14632
+ try {
14633
+ const { host } = new URL(maskedUrl);
14634
+ return host && !host.includes("*") ? host : "a Postgres database";
14635
+ } catch {
14636
+ return "a Postgres database";
14637
+ }
14638
+ }
14639
+ //#endregion
13616
14640
  //#region src/cli/commands/serve.ts
13617
14641
  function registerServeCommand(program) {
13618
14642
  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) => {
@@ -13620,7 +14644,7 @@ function registerServeCommand(program) {
13620
14644
  status: (line) => console.error(line),
13621
14645
  page: (line) => console.log(line)
13622
14646
  });
13623
- await waitForInterrupt();
14647
+ if (await waitForStop(running.idle) === "idle") console.error("kanbo serve: the last board tab was closed — stopped.");
13624
14648
  try {
13625
14649
  await running.close();
13626
14650
  process.exit(0);
@@ -13632,26 +14656,46 @@ function registerServeCommand(program) {
13632
14656
  }
13633
14657
  /**
13634
14658
  * Open the board and serve it — everything `kanbo serve` does but wait. The
13635
- * command waits for Ctrl-C and ends the process; the home menu waits for
13636
- * Ctrl-C and comes back to its menu.
14659
+ * command waits for Ctrl-C (or the last board tab closing) and ends the
14660
+ * process; the home menu waits the same and comes back to its menu.
13637
14661
  */
13638
14662
  async function startServe(options, output) {
13639
14663
  const port = parsePort(options.port);
13640
14664
  const access = resolveServeAccess(options.host, options.token?.trim() || process.env.KANBO_SERVE_TOKEN?.trim() || null);
14665
+ const here = findBinding(process.cwd());
14666
+ const mostRecent = here === null && namesNoBoard(options) ? readProjectRegistry()[0] : void 0;
14667
+ const defaultDir = here?.projectDir ?? mostRecent?.path ?? null;
13641
14668
  const session = await openBoardSession({
13642
14669
  db: options.db,
13643
14670
  databaseUrl: options.databaseUrl,
13644
14671
  workspace: options.workspace
13645
- }, "read");
14672
+ }, "read", mostRecent?.path);
13646
14673
  const agentShell = isAgentShell();
13647
14674
  const actors = {
13648
- writer: createCliActor(),
14675
+ writer: createServeWriter(),
13649
14676
  person: agentShell ? null : createPersonActor()
13650
14677
  };
14678
+ const projects = createServeProjects({
14679
+ defaultProject: defaultDir === null ? null : {
14680
+ dir: defaultDir,
14681
+ session
14682
+ },
14683
+ readRegistry: () => readProjectRegistry(),
14684
+ readBinding: (dir) => readProjectBinding(dir),
14685
+ openSession: async (dir) => await openBoardSession({}, "read", dir),
14686
+ personWriterId: actors.writer.id
14687
+ });
14688
+ const opensBrowser = !agentShell && process.stdout.isTTY === true && options.open;
14689
+ let presence;
14690
+ const idle = new Promise((resolve) => {
14691
+ presence = opensBrowser ? createPagePresence({ onIdle: resolve }) : void 0;
14692
+ });
13651
14693
  let server;
13652
14694
  try {
13653
14695
  server = await startKanboServer({
13654
14696
  session,
14697
+ projects,
14698
+ presence,
13655
14699
  actors,
13656
14700
  host: options.host,
13657
14701
  port,
@@ -13659,10 +14703,12 @@ async function startServe(options, output) {
13659
14703
  corsOrigins: options.corsOrigin
13660
14704
  });
13661
14705
  } catch (error) {
14706
+ presence?.stop();
13662
14707
  await session.close();
13663
14708
  throw error;
13664
14709
  }
13665
- output.status(`kanbo serve: ${server.url} — workspace ${session.workspace.id}, bearer token required${access.generated ? " (generated for this run)" : ""}`);
14710
+ const projectCount = projects.list().length;
14711
+ output.status(`kanbo serve: ${server.url} — workspace ${session.workspace.id}, ${projectCount} ${projectCount === 1 ? "project" : "projects"}, bearer token required${access.generated ? " (generated for this run)" : ""}`);
13666
14712
  output.status(`kanbo serve: ${describePersonRights(actors)}`);
13667
14713
  presentServePage({
13668
14714
  url: server.url,
@@ -13676,25 +14722,40 @@ async function startServe(options, output) {
13676
14722
  });
13677
14723
  return {
13678
14724
  link: access.generated ? `${server.url}/#token=${access.token}` : `${server.url}/`,
14725
+ idle,
13679
14726
  close: async () => {
14727
+ presence?.stop();
13680
14728
  try {
13681
14729
  await server.close();
13682
14730
  } finally {
13683
- await session.close();
14731
+ try {
14732
+ await projects.close();
14733
+ } finally {
14734
+ await session.close();
14735
+ }
13684
14736
  }
13685
14737
  }
13686
14738
  };
13687
14739
  }
13688
- /** Settles on the first Ctrl-C (SIGINT) or SIGTERM, and stops listening for both. */
13689
- function waitForInterrupt() {
14740
+ /** Whether neither a flag nor the environment says which board to open. */
14741
+ function namesNoBoard(options) {
14742
+ return !options.db && !options.databaseUrl && !options.workspace && !process.env.KANBO_DB_PATH && !process.env.KANBO_DATABASE_URL && !process.env.KANBO_WORKSPACE_ID;
14743
+ }
14744
+ /**
14745
+ * Settles on the first Ctrl-C (SIGINT) or SIGTERM, or when `idle` settles, and
14746
+ * stops listening for both signals whichever it was.
14747
+ */
14748
+ function waitForStop(idle) {
13690
14749
  return new Promise((resolve) => {
13691
- const stop = () => {
13692
- process.off("SIGINT", stop);
13693
- process.off("SIGTERM", stop);
13694
- resolve();
14750
+ const finish = (why) => {
14751
+ process.off("SIGINT", onSignal);
14752
+ process.off("SIGTERM", onSignal);
14753
+ resolve(why);
13695
14754
  };
13696
- process.on("SIGINT", stop);
13697
- process.on("SIGTERM", stop);
14755
+ const onSignal = () => finish("interrupt");
14756
+ process.on("SIGINT", onSignal);
14757
+ process.on("SIGTERM", onSignal);
14758
+ idle.then(() => finish("idle"));
13698
14759
  });
13699
14760
  }
13700
14761
  function collect(value, previous) {
@@ -13965,6 +15026,7 @@ function registerKanboCommands(program, context = {}) {
13965
15026
  registerServeCommand(program);
13966
15027
  program.commandsGroup("For agents and integrations:");
13967
15028
  registerMcpCommand(program);
15029
+ registerClaimCommand(program);
13968
15030
  registerRunCommands(program);
13969
15031
  registerCapabilitiesCommand(program);
13970
15032
  program.commandsGroup("Shared Postgres boards:");
@@ -13988,6 +15050,7 @@ function registerKanboCommands(program, context = {}) {
13988
15050
  const KANBO_TOOL_NAMES = [
13989
15051
  "kanbo_prime",
13990
15052
  "kanbo_ready",
15053
+ "kanbo_card_claim",
13991
15054
  "kanbo_columns",
13992
15055
  "kanbo_sprints",
13993
15056
  "kanbo_card_get",
@@ -14013,12 +15076,13 @@ const KANBO_TOOL_NAMES = [
14013
15076
  const KANBO_TOOL_CLI_EQUIVALENTS = {
14014
15077
  kanbo_prime: { kanbo: "kanbo prime" },
14015
15078
  kanbo_ready: { kanbo: "kanbo ready" },
15079
+ kanbo_card_claim: { kanbo: "kanbo claim [card] [--agent <name>]" },
14016
15080
  kanbo_columns: { kanbo: "kanbo columns list" },
14017
15081
  kanbo_sprints: { kanbo: "kanbo sprint list" },
14018
15082
  kanbo_card_get: { kanbo: "kanbo card get <id>" },
14019
15083
  kanbo_card_list: { kanbo: "kanbo card list" },
14020
15084
  kanbo_card_create: { kanbo: "kanbo card create --description <text> [--title <title>] [--parent <id>]" },
14021
- kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>]" },
15085
+ kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>] [--parent <id>]" },
14022
15086
  kanbo_card_move: { kanbo: "kanbo card move <id> <column>" },
14023
15087
  kanbo_card_comment: { kanbo: "kanbo card comment <id> --content <text>" },
14024
15088
  kanbo_card_link_pr: { kanbo: "kanbo card pr add <id> <url>" },
@@ -14160,13 +15224,15 @@ function buildCapabilitiesManifest() {
14160
15224
  "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.",
14161
15225
  `The agent role (\`${BOARD_AGENT_ROLE}\`) may not delete a card at all — it holds select, insert and update on \`issues\` and nothing more, even though it may delete freely on every other board table.`,
14162
15226
  "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.",
15227
+ "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.",
15228
+ "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.",
14163
15229
  "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.",
14164
15230
  "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).",
14165
15231
  "`kanbo migrate` and `kanbo roles apply` refuse a host app's own database file: the app migrates it, and this package never touches it.",
14166
- "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.",
15232
+ "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.",
14167
15233
  "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."
14168
15234
  ]
14169
15235
  };
14170
15236
  }
14171
15237
  //#endregion
14172
- export { COLUMN_TEMPLATES as $, projectKanboSprint as A, createBoardOps as B, pageOf as C, nextIssueIdentity as Ct, projectKanboFieldChange as D, projectKanboColumn as E, readIssuePrefix as Et, readBinding as F, ENTRY_RULES as G, formatExternalSessionRef as H, readProjectBinding as I, evaluateEntryRules as J, ENTRY_RULE_DESCRIPTIONS as K, writeBinding as L, renderCapabilitiesMarkdown as M, BINDING_FILE_PATH as N, projectKanboPullRequest as O, ignoreBoardFile as P, COLUMN_CATALOGUE as Q, maskDatabaseUrl as R, matchesKanboCardQuery as S, formatIssueId as St, projectKanboCardComment as T, nextIssueOrder as Tt, parseExternalSessionRef as U, cardDisplayTitle as V, pullRequestFactKeys as W, readEntryRules as X, readChecklist as Y, serializeEntryRules as Z, suggestCardKey as _, parseActivityStringArray as _t, BOARD_ERROR_RESPONSES as a, TRACKED_FIELDS as at, DEFAULT_KANBO_COMMENT_LIMIT as b, normalizeCommentAuthorKind as bt, registerKanboTools as c, BOARD_ACTOR as ct, KANBO_MCP_INSTRUCTIONS as d, activityText as dt, COLUMN_TEMPLATE_IDS as et, KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI as f, activityToken as ft, isValidCardKey as g, isEmptyActivityValue as gt, CARD_KEY_PATTERN as h, formatPlainActivityValue as ht, KANBO_TOOL_NAMES as i, normalizeStatusName as it, projectKanboSubCard as j, projectKanboRun as k, KANBO_TOOLS as l, currentUnixSeconds as lt, registerKanboCapabilitiesResources as m, formatActivityIssueReference as mt, buildCapabilitiesManifest as n, runBoardWrite as nt, KANBO_MCP_SERVER_NAME as o, diffFieldChanges as ot, KANBO_CAPABILITIES_RESOURCE_URI as p, commentAuthorReference as pt, UNMET_ENTRY_RULE_REASONS as q, KANBO_TOOL_CLI_EQUIVALENTS as r, DEFAULT_STATUSES as rt, createKanboMcpServer as s, previewComment as st, CAPABILITIES_MANIFEST_VERSION as t, readChangeSeq as tt, buildKanboTools as u, activityKindOrder as ut, DEFAULT_BOARD_FILE_PATH as v, readSystemCommentKind as vt, projectKanboCard as w, nextIssueNumber as wt, KANBO_CARD_INCLUDES as x, toIssueView as xt, DEFAULT_KANBO_CARD_INCLUDES as y, isOutsideWriter as yt, maskDatabaseUrls as z };
15238
+ export { serializeEntryRules as $, projectKanboSprint as A, createBoardOps as B, pageOf as C, toIssueView as Ct, projectKanboFieldChange as D, nextIssueOrder as Dt, projectKanboColumn as E, nextIssueNumber as Et, BINDING_FILE_PATH as F, FLOW_RULES as G, formatExternalSessionRef as H, ignoreBoardFile as I, ENTRY_RULE_DESCRIPTIONS as J, readFlowRules as K, readBinding as L, renderCapabilitiesMarkdown as M, maskDatabaseUrl as N, projectKanboPullRequest as O, readIssuePrefix as Ot, maskDatabaseUrls as P, readEntryRules as Q, readProjectBinding as R, matchesKanboCardQuery as S, normalizeCommentAuthorKind as St, projectKanboCardComment as T, nextIssueIdentity as Tt, parseExternalSessionRef as U, cardDisplayTitle as V, pullRequestFactKeys as W, evaluateEntryRules as X, UNMET_ENTRY_RULE_REASONS as Y, readChecklist as Z, suggestCardKey as _, formatPlainActivityValue as _t, BOARD_ERROR_RESPONSES as a, DEFAULT_STATUSES as at, DEFAULT_KANBO_COMMENT_LIMIT as b, readSystemCommentKind as bt, registerKanboTools as c, diffFieldChanges as ct, KANBO_MCP_INSTRUCTIONS as d, currentUnixSeconds as dt, COLUMN_CATALOGUE as et, KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI as f, activityKindOrder as ft, isValidCardKey as g, formatActivityIssueReference as gt, CARD_KEY_PATTERN as h, commentAuthorReference as ht, KANBO_TOOL_NAMES as i, runBoardWrite as it, projectKanboSubCard as j, projectKanboRun as k, KANBO_TOOLS as l, previewComment as lt, registerKanboCapabilitiesResources as m, activityToken as mt, buildCapabilitiesManifest as n, COLUMN_TEMPLATE_IDS as nt, KANBO_MCP_SERVER_NAME as o, normalizeStatusName as ot, KANBO_CAPABILITIES_RESOURCE_URI as p, activityText as pt, ENTRY_RULES as q, KANBO_TOOL_CLI_EQUIVALENTS as r, readChangeSeq as rt, createKanboMcpServer as s, TRACKED_FIELDS as st, CAPABILITIES_MANIFEST_VERSION as t, COLUMN_TEMPLATES as tt, buildKanboTools as u, BOARD_ACTOR as ut, DEFAULT_BOARD_FILE_PATH as v, isEmptyActivityValue as vt, projectKanboCard as w, formatIssueId as wt, KANBO_CARD_INCLUDES as x, isOutsideWriter as xt, DEFAULT_KANBO_CARD_INCLUDES as y, parseActivityStringArray as yt, writeBinding as z };