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
package/dist/cli-main.cjs CHANGED
@@ -272,6 +272,14 @@ const BOARD_ERROR_TEXTS = {
272
272
  text: "Only a person can approve a card.",
273
273
  next: AS_A_PERSON
274
274
  }),
275
+ board_card_not_ready: (details) => ({
276
+ text: details.reason === "run_active" ? `${named(details.issueId)} is taken: it has a run going.` : details.reason === "waiting_for_person" ? `${named(details.issueId)} is waiting for a person.` : `${named(details.issueId)} is not in To Do, so it is not there to take.`,
277
+ next: "kanbo ready"
278
+ }),
279
+ board_card_run_active: (details) => ({
280
+ text: `${named(details.issueId)} already has a run going: ${named(details.agentName)}'s, run ${named(details.runId)}.`,
281
+ next: `kanbo run finish ${named(details.runId)} --state stopped`
282
+ }),
275
283
  board_column_name_taken: (details) => ({
276
284
  text: `Another column, ${named(details.takenBy)}, already has that name (same slug). Pick another name.`,
277
285
  next: "kanbo columns list"
@@ -302,6 +310,14 @@ const BOARD_ERROR_TEXTS = {
302
310
  }),
303
311
  board_entry_rule_invalid: (details) => ({ text: `Unknown column rule: ${list(details.rules)}. Use any of ${list(details.allowed)}.` }),
304
312
  board_execution_mode_locked: (details) => ({ text: `${named(details.issueId)} has been started, so where it is worked (${named(details.executionMode)}) can't change now.` }),
313
+ board_flow_rule_invalid: (details) => details.wipLimit !== void 0 ? { text: `A column holds at least 1 card; ${named(details.wipLimit)} is not a limit. Use none for no limit.` } : {
314
+ text: `A column accepts cards only from other columns of its own board, and ${list(details.allowedFrom)} is not one.`,
315
+ next: "kanbo columns list"
316
+ },
317
+ board_nothing_ready: () => ({
318
+ text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
319
+ next: "kanbo ready"
320
+ }),
305
321
  board_pull_request_invalid: (details) => ({ text: `"${named(details.value)}" is not a GitHub pull request. Give its link or owner/repo#number.` }),
306
322
  board_pull_request_not_found: (details) => ({
307
323
  text: `${named(details.issueId)} has no pull request link "${named(details.linkId)}".`,
@@ -357,6 +373,7 @@ const BOARD_ERROR_TEXTS = {
357
373
  text: `No card "${named(details.issueId)}" here.`,
358
374
  next: "kanbo card list"
359
375
  }),
376
+ issue_parent_cycle: () => ({ text: "A card can't be put under one of its own sub-cards." }),
360
377
  issue_parent_not_found: (details) => ({
361
378
  text: `No card "${named(details.parentIssueId)}" here to put this one under.`,
362
379
  next: "kanbo card list"
@@ -366,7 +383,7 @@ const BOARD_ERROR_TEXTS = {
366
383
  issue_relation_self_reference: () => ({ text: "A card can't be linked to itself." }),
367
384
  issue_status_name_empty: () => ({ text: "Give the column a name." }),
368
385
  issue_status_not_found: (details) => ({
369
- text: `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
386
+ text: details.category !== void 0 ? `This board has no ${named(details.category)} column for a claimed card to go to.` : `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
370
387
  next: "kanbo columns list"
371
388
  }),
372
389
  issue_status_reference_conflict: () => ({ text: "Name the column once: by its id or by its name, not both." }),
@@ -3560,7 +3577,7 @@ new Command();
3560
3577
  * `package.version`. A build carries it as a literal; running from source
3561
3578
  * (tests, `tsx`) reads the package's own `package.json` instead.
3562
3579
  */
3563
- const KANBO_PACKAGE_VERSION = "0.3.0";
3580
+ const KANBO_PACKAGE_VERSION = "0.5.0";
3564
3581
  //#endregion
3565
3582
  //#region src/cli/agent-shell.ts
3566
3583
  /**
@@ -3595,22 +3612,26 @@ const AGENT_SHELL_MARKERS = [
3595
3612
  {
3596
3613
  variable: "CLAUDECODE",
3597
3614
  matches: (value) => value === "1",
3598
- label: "CLAUDECODE=1"
3615
+ label: "CLAUDECODE=1",
3616
+ tool: "claude"
3599
3617
  },
3600
3618
  {
3601
3619
  variable: "GEMINI_CLI",
3602
3620
  matches: (value) => value === "1",
3603
- label: "GEMINI_CLI=1"
3621
+ label: "GEMINI_CLI=1",
3622
+ tool: "gemini"
3604
3623
  },
3605
3624
  {
3606
3625
  variable: "CURSOR_AGENT",
3607
3626
  matches: (value) => value !== "",
3608
- label: "CURSOR_AGENT"
3627
+ label: "CURSOR_AGENT",
3628
+ tool: "cursor"
3609
3629
  },
3610
3630
  {
3611
3631
  variable: "CODEX_THREAD_ID",
3612
3632
  matches: (value) => value !== "",
3613
- label: "CODEX_THREAD_ID"
3633
+ label: "CODEX_THREAD_ID",
3634
+ tool: "codex"
3614
3635
  }
3615
3636
  ];
3616
3637
  /**
@@ -3627,6 +3648,15 @@ function readAgentShellMarker(env = process.env) {
3627
3648
  if (kind === "person") return null;
3628
3649
  return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.label ?? null;
3629
3650
  }
3651
+ /**
3652
+ * The name of the agent tool this shell belongs to (`claude`, `gemini`,
3653
+ * `cursor`, `codex`), `agent` when only `KANBO_ACTOR_KIND=agent` says so, or
3654
+ * `null` for a person's shell. Same precedence as `readAgentShellMarker`.
3655
+ */
3656
+ function readAgentTool(env = process.env) {
3657
+ if (readAgentShellMarker(env) === null) return null;
3658
+ return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.tool ?? "agent";
3659
+ }
3630
3660
  //#endregion
3631
3661
  //#region src/cli/actor.ts
3632
3662
  /**
@@ -3686,6 +3716,7 @@ const HUMAN_ONLY_ACTIONS = [
3686
3716
  "return",
3687
3717
  "sprint close",
3688
3718
  "columns rules",
3719
+ "columns flow",
3689
3720
  "columns add",
3690
3721
  "columns rename",
3691
3722
  "columns move",
@@ -3699,6 +3730,7 @@ const HUMAN_ONLY_MESSAGES = {
3699
3730
  "return": describeReturnRefusal,
3700
3731
  "sprint close": describeSprintCloseRefusal,
3701
3732
  "columns rules": describeColumnRulesRefusal,
3733
+ "columns flow": describeColumnRulesRefusal,
3702
3734
  "columns add": describeColumnChangesRefusal,
3703
3735
  "columns rename": describeColumnChangesRefusal,
3704
3736
  "columns move": describeColumnChangesRefusal,
@@ -3708,11 +3740,15 @@ const HUMAN_ONLY_MESSAGES = {
3708
3740
  "run clear-session": describeRunSessionRefRefusal,
3709
3741
  "run attach-session --replace": describeRunSessionRefRefusal
3710
3742
  };
3711
- /** The actor every board write from this tool is filed under. */
3743
+ /**
3744
+ * The actor every board write from this tool is filed under: `KANBO_ACTOR_ID`,
3745
+ * else the agent tool of an agent's shell (`claude`, `codex`, ...), else the
3746
+ * operating system's user.
3747
+ */
3712
3748
  function createCliActor() {
3713
3749
  return {
3714
3750
  kind: "external",
3715
- id: process.env.KANBO_ACTOR_ID?.trim() || safeUsername(),
3751
+ id: process.env.KANBO_ACTOR_ID?.trim() || readAgentTool() || safeUsername(),
3716
3752
  name: (0, node_os.hostname)()
3717
3753
  };
3718
3754
  }
@@ -3750,6 +3786,14 @@ function createPlacementActor() {
3750
3786
  placesForPerson: true
3751
3787
  };
3752
3788
  }
3789
+ /** 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. */
3790
+ function createServeWriter() {
3791
+ return {
3792
+ kind: "external",
3793
+ id: (isAgentShell() ? "" : process.env.KANBO_ACTOR_ID?.trim() ?? "") || safeUsername(),
3794
+ name: (0, node_os.hostname)()
3795
+ };
3796
+ }
3753
3797
  /**
3754
3798
  * The person who owns this shell, as the board files a person. Only for a
3755
3799
  * caller that has already asked `isAgentShell` — `requireHumanActor` for a
@@ -9988,6 +10032,14 @@ const HIDDEN_ACTIVITY_FIELDS = /* @__PURE__ */ new Set([
9988
10032
  "order",
9989
10033
  "waitingFor"
9990
10034
  ]);
10035
+ /**
10036
+ * The column a card was created in, recorded as a change from no column at the
10037
+ * card's own creation time. The feed's `created` item already stands for it,
10038
+ * and the field history keeps it.
10039
+ */
10040
+ function isStartingColumn(change, issue) {
10041
+ return change.field === "statusId" && change.fromValue === null && change.createdAt === issue.createdAt;
10042
+ }
9991
10043
  const ACTIVITY_FIELD_BY_RAW_FIELD = {
9992
10044
  assigneeId: "assignee",
9993
10045
  description: "description",
@@ -10028,7 +10080,7 @@ async function listActivity(store, issueId, resolver) {
10028
10080
  workspaceById: resolver.workspaceById
10029
10081
  };
10030
10082
  const commentItems = await Promise.all((await store.comments.listByIssue(issueId)).map(async (comment) => await toCommentActivityItem(comment, resolver)));
10031
- const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
10083
+ const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).filter((change) => !isStartingColumn(change, issue)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
10032
10084
  return [
10033
10085
  {
10034
10086
  id: `${issue.id}:created`,
@@ -10652,6 +10704,63 @@ function describeColumnRefusal(details) {
10652
10704
  return [`board_column_rules_unmet: ${details.issueId ? `${details.issueId} cannot enter` : "A new card cannot be created in"} "${details.column}" yet:`, ...details.unmet.map((item) => `- ${item.rule}: ${item.detail}`)].join("\n");
10653
10705
  }
10654
10706
  //#endregion
10707
+ //#region src/domain/flow-rules.ts
10708
+ /** The column the standard flow guards, and the columns it lets cards in from, by slug. */
10709
+ const STANDARD_FLOW_TARGET = "done";
10710
+ const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
10711
+ /** A column's flow rules, out of what the row stores. */
10712
+ function readFlowRules(column) {
10713
+ return {
10714
+ allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
10715
+ wipLimit: column.wipLimit
10716
+ };
10717
+ }
10718
+ /** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
10719
+ function serializeAllowedFrom(allowedFrom) {
10720
+ return allowedFrom === null ? null : JSON.stringify(allowedFrom);
10721
+ }
10722
+ /**
10723
+ * The flow rules a card entering `column` does not meet, where it may come from
10724
+ * first. Pure: what the board holds arrives in `facts`. A card being created
10725
+ * comes from no column, so a column that names where cards come from does not
10726
+ * take it.
10727
+ */
10728
+ function evaluateFlowRules(card, column, rules, facts) {
10729
+ const unmet = [];
10730
+ if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
10731
+ rule: "allowed_from",
10732
+ reason: "not_allowed_from",
10733
+ detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
10734
+ });
10735
+ if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
10736
+ rule: "wip_limit",
10737
+ reason: "column_full",
10738
+ detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
10739
+ });
10740
+ return unmet;
10741
+ }
10742
+ function describeAllowedFrom(name, allowedFrom, columnNames) {
10743
+ const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
10744
+ return names.length === 0 ? `${name} accepts cards from no column; only a person may put a card here` : `${name} accepts cards from ${joinWords(names)} only`;
10745
+ }
10746
+ /** `A`, `A and B`, `A, B and C`. */
10747
+ function joinWords(words) {
10748
+ return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
10749
+ }
10750
+ /**
10751
+ * The standard flow on these columns: Done, and the columns it takes cards from
10752
+ * — In Review, and QA when there is one — in board order. `null` when the board
10753
+ * has no Done, or neither of the two.
10754
+ */
10755
+ function standardFlowOf(columns) {
10756
+ const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
10757
+ const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
10758
+ return target && sources.length > 0 ? {
10759
+ target,
10760
+ sources
10761
+ } : null;
10762
+ }
10763
+ //#endregion
10655
10764
  //#region src/ops/columns.ts
10656
10765
  /** The columns of one workspace, in board order. */
10657
10766
  async function listColumns(store, workspaceId) {
@@ -10721,6 +10830,7 @@ async function applyColumnTemplate(store, workspaceId, columns, options, scope)
10721
10830
  workspaceId,
10722
10831
  ...column
10723
10832
  }, order);
10833
+ await writeStandardFlow(tx, await listColumns(tx, workspaceId));
10724
10834
  return {
10725
10835
  statuses: await listColumns(tx, workspaceId),
10726
10836
  added: columns.map((column) => column.name)
@@ -10830,6 +10940,77 @@ async function setColumnEntryRules(store, workspaceId, nameOrId, entryRules, act
10830
10940
  return await updateColumn(tx, (await requireColumn(tx, workspaceId, nameOrId)).id, { entryRules }, actor, { tx });
10831
10941
  }, scope);
10832
10942
  }
10943
+ /** The largest WIP limit: what a Postgres `integer` column holds. */
10944
+ const MAX_WIP_LIMIT = 2147483647;
10945
+ /**
10946
+ * Set where a card may enter the column from and how many cards it holds:
10947
+ * `allowedFrom` the ids of other columns of its board (`null` for any, `[]` for
10948
+ * none), kept once each in board order; `wipLimit` a whole number from 1 to 2147483647, or `null`. A
10949
+ * person's to set, like the entry rules: an agent that could open a column
10950
+ * could walk any card into it.
10951
+ */
10952
+ async function setColumnFlowRules(store, columnId, rules, actor, scope) {
10953
+ assertPersonSetsEntryRules(actor, { statusId: columnId });
10954
+ if (rules.wipLimit !== null && !(Number.isSafeInteger(rules.wipLimit) && rules.wipLimit >= 1 && rules.wipLimit <= MAX_WIP_LIMIT)) throw new BoardError("board_flow_rule_invalid", {
10955
+ statusId: columnId,
10956
+ wipLimit: rules.wipLimit
10957
+ });
10958
+ return await runBoardWrite(store, async ({ tx }) => {
10959
+ const column = await tx.statuses.findById(columnId);
10960
+ if (!column) throw new BoardError("issue_status_not_found", { statusId: columnId });
10961
+ const allowedFrom = rules.allowedFrom === null ? null : allowedFromInBoardOrder(await listColumns(tx, column.workspaceId), column, rules.allowedFrom);
10962
+ await tx.statuses.update(columnId, {
10963
+ allowedFrom: serializeAllowedFrom(allowedFrom),
10964
+ wipLimit: rules.wipLimit
10965
+ });
10966
+ return await tx.statuses.findById(columnId);
10967
+ }, scope);
10968
+ }
10969
+ /** The ids asked for, once each and in board order — or the refusal naming those that are not another column of this board. */
10970
+ function allowedFromInBoardOrder(columns, column, requested) {
10971
+ const others = columns.filter((other) => other.id !== column.id);
10972
+ const invalid = requested.filter((id) => !others.some((other) => other.id === id));
10973
+ if (invalid.length > 0) throw new BoardError("board_flow_rule_invalid", {
10974
+ statusId: column.id,
10975
+ allowedFrom: invalid
10976
+ });
10977
+ return others.filter((other) => requested.includes(other.id)).map((other) => other.id);
10978
+ }
10979
+ /**
10980
+ * Put the standard flow on a board: Done accepts cards from In Review only —
10981
+ * and from QA, when the board has it — so nothing reaches Done but through
10982
+ * review. Done's WIP limit and every other column stay as they are. A board
10983
+ * without Done, or with neither In Review nor QA, has nothing to apply it to.
10984
+ */
10985
+ async function applyStandardFlow(store, workspaceId, actor, scope) {
10986
+ assertPersonSetsEntryRules(actor, { workspaceId });
10987
+ return await runBoardWrite(store, async ({ tx }) => {
10988
+ const columns = await listColumns(tx, workspaceId);
10989
+ if (!await writeStandardFlow(tx, columns)) throw new BoardError("issue_status_not_found", {
10990
+ workspaceId,
10991
+ statusName: columns.some((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET) ? "In Review" : "Done"
10992
+ });
10993
+ return await listColumns(tx, workspaceId);
10994
+ }, scope);
10995
+ }
10996
+ /** Done takes cards from the board's In Review and QA; `false`, with nothing written, when the board has no such columns. */
10997
+ async function writeStandardFlow(store, columns) {
10998
+ const flow = standardFlowOf(columns);
10999
+ if (!flow) return false;
11000
+ await store.statuses.update(flow.target.id, { allowedFrom: serializeAllowedFrom(flow.sources.map((column) => column.id)) });
11001
+ return true;
11002
+ }
11003
+ /**
11004
+ * Take a deleted column out of every list of where cards may come from. A list
11005
+ * it empties stays empty: a column that took cards only from it does not open
11006
+ * to every column.
11007
+ */
11008
+ async function dropFromAllowedFrom(store, workspaceId, statusId) {
11009
+ for (const other of await listColumns(store, workspaceId)) {
11010
+ const { allowedFrom } = readFlowRules(other);
11011
+ if (allowedFrom?.includes(statusId)) await store.statuses.update(other.id, { allowedFrom: serializeAllowedFrom(allowedFrom.filter((id) => id !== statusId)) });
11012
+ }
11013
+ }
10833
11014
  /**
10834
11015
  * Take an empty column off the board, and number the columns left 0..n-1 in
10835
11016
  * the same write.
@@ -10856,6 +11037,7 @@ async function deleteColumn(store, statusId, scope, actor) {
10856
11037
  hint: "Move its cards to another column first, or remove it with removeColumn and moveCardsTo."
10857
11038
  });
10858
11039
  await tx.statuses.delete(statusId);
11040
+ await dropFromAllowedFrom(tx, column.workspaceId, statusId);
10859
11041
  await renumberColumns(tx, column.workspaceId);
10860
11042
  }, scope);
10861
11043
  }
@@ -11135,24 +11317,46 @@ function pullRequestFactKeys({ owner, repo, number }) {
11135
11317
  //#endregion
11136
11318
  //#region src/ops/entry-rules.ts
11137
11319
  /**
11138
- * The column's entry rules the card does not meet (ruling 6-2). A column with
11139
- * no rules answers at once, without reading anything; otherwise only the facts
11140
- * the column's rules need are read.
11320
+ * The column's entry rules and flow rules the card does not meet, entry rules
11321
+ * first (ruling 6-2; board-workflow spec, Part 2). A column with no rules
11322
+ * answers at once, without reading anything; otherwise only the facts its
11323
+ * rules need are read. `entering` counts the cards this same write already let
11324
+ * into the column, so a bulk move is held to the WIP limit as a whole.
11141
11325
  */
11142
- async function checkColumnEntry(store, card, column) {
11326
+ async function checkColumnEntry(store, card, column, entering) {
11143
11327
  const rules = readEntryRules(column);
11144
- if (rules.length === 0) return [];
11145
- return evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
11328
+ const unmet = rules.length === 0 ? [] : evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
11329
+ const flow = readFlowRules(column);
11330
+ if (flow.allowedFrom === null && flow.wipLimit === null) return unmet;
11331
+ return [...unmet, ...evaluateFlowRules(card, column, flow, await readFlowRuleFacts(store, column, flow, entering))];
11332
+ }
11333
+ /** What the flow rules need: the board's column names when the column names where cards come from, and its card count when it has a limit. */
11334
+ async function readFlowRuleFacts(store, column, flow, entering) {
11335
+ const columnNames = flow.allowedFrom === null ? /* @__PURE__ */ new Map() : new Map((await store.statuses.listByWorkspace(column.workspaceId)).map((other) => [other.id, other.name]));
11336
+ if (flow.wipLimit === null) return {
11337
+ columnNames,
11338
+ cardsInColumn: 0
11339
+ };
11340
+ await store.statuses.lockById(column.id);
11341
+ return {
11342
+ columnNames,
11343
+ cardsInColumn: (await store.issues.listPage({
11344
+ workspaceId: column.workspaceId,
11345
+ statusIds: [column.id],
11346
+ limit: 1
11347
+ })).total + entering
11348
+ };
11146
11349
  }
11147
11350
  /**
11148
- * Hold a card at the door of a column that asks for more than it has (ruling
11149
- * 6-3). Anyone but a person — or a person's own terminal, `placesForPerson`
11150
- * (ruling 6-6) — is refused with `board_column_rules_unmet`, which
11151
- * names the column and every unmet rule; a person is let through, and the
11152
- * unmet rules come back so the caller can warn them.
11351
+ * Hold a card at the door of a column that asks for more than it has — entry
11352
+ * rules (ruling 6-3), or flow rules: a column it may not come from, or a column
11353
+ * that is full. Anyone but a person — or a person's own terminal,
11354
+ * `placesForPerson` (ruling 6-6) — is refused with `board_column_rules_unmet`,
11355
+ * which names the column and everything unmet; a person is let through, and
11356
+ * the unmet list comes back so the caller can warn them.
11153
11357
  */
11154
- async function enforceColumnEntry(store, card, column, actor) {
11155
- const unmet = await checkColumnEntry(store, card, column);
11358
+ async function enforceColumnEntry(store, card, column, actor, entering = 0) {
11359
+ const unmet = await checkColumnEntry(store, card, column, entering);
11156
11360
  if (unmet.length > 0 && actor.kind !== "user" && !actor.placesForPerson) throw new BoardError("board_column_rules_unmet", {
11157
11361
  issueId: card.id,
11158
11362
  column: column.name,
@@ -11165,14 +11369,17 @@ async function enforceColumnEntry(store, card, column, actor) {
11165
11369
  * The same door for several cards entering one column at once — a bulk status
11166
11370
  * change. All or nothing: the first card an agent may not move refuses the
11167
11371
  * whole change. A person gets back the cards that entered with rules unmet.
11372
+ * Each card counts against the column's WIP limit as the cards before it in the same change entered it.
11168
11373
  */
11169
11374
  async function enforceColumnEntryForCards(store, issueIds, statusId, actor) {
11170
11375
  const column = await store.statuses.findById(statusId);
11171
11376
  if (!column) throw new BoardError("issue_status_not_found", { statusId });
11172
11377
  const entered = [];
11378
+ let entering = 0;
11173
11379
  for (const card of await store.issues.listByIds(issueIds)) {
11174
11380
  if (card.statusId === column.id) continue;
11175
- const unmet = await enforceColumnEntry(store, card, column, actor);
11381
+ const unmet = await enforceColumnEntry(store, card, column, actor, entering);
11382
+ entering++;
11176
11383
  if (unmet.length > 0) entered.push({
11177
11384
  issueId: card.id,
11178
11385
  unmet
@@ -11327,11 +11534,24 @@ const STOPPED_BY_YOU_STATUS_LINE = "stopped by you";
11327
11534
  * the board reads the same whether the agent was launched by an app or from
11328
11535
  * somewhere else entirely. The attempt number is simply how many runs the card
11329
11536
  * has had — a card whose runs were all launched elsewhere still counts them.
11537
+ *
11538
+ * A card has one run going at a time. A start on a card whose run is still going
11539
+ * is refused with `board_card_run_active`, naming that run — whichever path it
11540
+ * came by, an app's launch, `kanbo run start`, MCP, or a second app on a shared
11541
+ * Postgres board. The card's row is locked first, so two starts racing for it
11542
+ * are settled one after the other.
11330
11543
  */
11331
11544
  async function startRun(store, issueId, input, actor, scope) {
11332
11545
  const externalSessionRef = readExternalSessionRef(input.externalSessionRef) ?? null;
11333
11546
  return await runBoardWrite(store, async ({ tx }) => {
11334
- const card = await requireCard$1(tx, issueId);
11547
+ const card = await tx.issues.lockById(issueId);
11548
+ if (!card) throw new BoardError("issue_not_found", { issueId });
11549
+ const [active] = await tx.runs.listRunning(issueId);
11550
+ if (active) throw new BoardError("board_card_run_active", {
11551
+ issueId,
11552
+ runId: active.id,
11553
+ agentName: active.agentName
11554
+ });
11335
11555
  const attempt = await tx.runs.countByIssue(issueId) + 1;
11336
11556
  const executionMode = input.executionMode ?? card.executionMode;
11337
11557
  const run = await tx.runs.create({
@@ -11597,7 +11817,8 @@ async function createCard(store, input, actor, scope) {
11597
11817
  const column = statusId === null ? null : await tx.statuses.findById(statusId);
11598
11818
  const unmet = column ? await enforceColumnEntry(tx, {
11599
11819
  id: null,
11600
- description: input.description ?? null
11820
+ description: input.description ?? null,
11821
+ statusId: null
11601
11822
  }, column, actor) : [];
11602
11823
  const milestoneId = await requireMilestoneInWorkspace(tx, workspaceId, input.milestoneId ?? null);
11603
11824
  const parentIssueId = await requireParentInWorkspace(tx, workspaceId, input.parentIssueId ?? null);
@@ -11605,8 +11826,9 @@ async function createCard(store, input, actor, scope) {
11605
11826
  const now = currentUnixSeconds();
11606
11827
  for (let attempt = 1;; attempt++) {
11607
11828
  const identity = await nextIssueIdentity(input.workspace, tx);
11829
+ let created;
11608
11830
  try {
11609
- return withUnmetRules(await tx.issues.create({
11831
+ created = await tx.issues.create({
11610
11832
  id: identity.id,
11611
11833
  workspaceId,
11612
11834
  title: input.title?.trim() || identity.id,
@@ -11630,12 +11852,25 @@ async function createCard(store, input, actor, scope) {
11630
11852
  order,
11631
11853
  createdAt: now,
11632
11854
  updatedAt: now
11633
- }), unmet);
11855
+ });
11634
11856
  } catch (error) {
11635
11857
  const numberTaken = await tx.issues.findByNumber(workspaceId, identity.number);
11636
11858
  const keyTaken = await tx.issues.findById(identity.id);
11637
11859
  if (attempt >= NUMBERING_ATTEMPTS || !numberTaken && !keyTaken) throw error;
11860
+ continue;
11638
11861
  }
11862
+ if (statusId !== null) await tx.fieldChanges.create({
11863
+ id: (0, node_crypto.randomUUID)(),
11864
+ issueId: created.id,
11865
+ field: "statusId",
11866
+ fromValue: null,
11867
+ toValue: statusId,
11868
+ actorKind: toStoredActorKind(actor),
11869
+ actorId: actor.id ?? null,
11870
+ sourceChatSessionId: actor.chatSessionId ?? null,
11871
+ createdAt: now
11872
+ });
11873
+ return withUnmetRules(created, unmet);
11639
11874
  }
11640
11875
  }, scope);
11641
11876
  }
@@ -11691,7 +11926,8 @@ async function updateCard(store, issueId, input, actor, scope) {
11691
11926
  const description = "description" in updates ? updates.description ?? null : card.description;
11692
11927
  unmet = await enforceColumnEntry(tx, {
11693
11928
  id: card.id,
11694
- description
11929
+ description,
11930
+ statusId: card.statusId
11695
11931
  }, column, actor);
11696
11932
  }
11697
11933
  if ("assigneeKind" in input) updates.assigneeKind = input.assigneeKind ?? null;
@@ -11994,12 +12230,33 @@ async function requireParentInWorkspace(store, workspaceId, parentIssueId, issue
11994
12230
  issueId,
11995
12231
  parentIssueId
11996
12232
  });
11997
- if (!await store.issues.findInWorkspace(workspaceId, parentIssueId)) throw new BoardError("issue_parent_not_found", {
12233
+ const parent = await store.issues.findInWorkspace(workspaceId, parentIssueId);
12234
+ if (!parent) throw new BoardError("issue_parent_not_found", {
11998
12235
  workspaceId,
11999
12236
  parentIssueId
12000
12237
  });
12238
+ if (issueId !== void 0 && await isUnder(store, parent, issueId)) throw new BoardError("issue_parent_cycle", {
12239
+ issueId,
12240
+ parentIssueId
12241
+ });
12001
12242
  return parentIssueId;
12002
12243
  }
12244
+ /**
12245
+ * Is the card somewhere under `ancestorId` — its parent, its parent's parent,
12246
+ * and so on up? A card put under one of its own sub-cards would make a loop no
12247
+ * reader of the board can walk to the top of. The cards already seen end the
12248
+ * walk, so a loop the data holds from before cannot hang it.
12249
+ */
12250
+ async function isUnder(store, card, ancestorId) {
12251
+ const seen = /* @__PURE__ */ new Set();
12252
+ let id = card.parentIssueId;
12253
+ while (id !== null && !seen.has(id)) {
12254
+ if (id === ancestorId) return true;
12255
+ seen.add(id);
12256
+ id = (await store.issues.findById(id))?.parentIssueId ?? null;
12257
+ }
12258
+ return false;
12259
+ }
12003
12260
  /** Is the card's own number already taken by another card in the target workspace? */
12004
12261
  async function numberTakenByAnother(store, workspaceId, card) {
12005
12262
  const holder = await store.issues.findByNumber(workspaceId, card.number);
@@ -12069,6 +12326,160 @@ async function reorderCards(store, workspaceId, orderedIds, scope) {
12069
12326
  }, scope);
12070
12327
  }
12071
12328
  //#endregion
12329
+ //#region src/ops/ready.ts
12330
+ /** The column an agent takes its next card from. */
12331
+ const READY_COLUMN_SLUG = "to_do";
12332
+ /**
12333
+ * The cards an agent may pick up right now: spelled out, nobody working on them,
12334
+ * nobody's turn but the agent's.
12335
+ *
12336
+ * "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
12337
+ * run is taken, and a card waiting for a person belongs to that person until
12338
+ * they answer. What is left is the queue — in board order, so the card someone
12339
+ * dragged to the top is the one an agent asking for work gets.
12340
+ */
12341
+ async function listReady(store, input) {
12342
+ return (await queryReady(store, input)).cards;
12343
+ }
12344
+ /**
12345
+ * One page of the ready cards, how many are ready in all, and the columns read
12346
+ * on the way — the rule above, asked of the database in one statement rather
12347
+ * than of every card on the board.
12348
+ */
12349
+ async function queryReady(store, input) {
12350
+ const columns = await listColumns(store, input.workspaceId);
12351
+ const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
12352
+ if (!column) return {
12353
+ cards: [],
12354
+ total: 0,
12355
+ columns
12356
+ };
12357
+ return {
12358
+ ...await store.issues.listPage({
12359
+ workspaceId: input.workspaceId,
12360
+ statusIds: [column.id],
12361
+ waitingForPerson: false,
12362
+ hasActiveRun: false,
12363
+ limit: input.limit,
12364
+ offset: input.offset
12365
+ }),
12366
+ columns
12367
+ };
12368
+ }
12369
+ //#endregion
12370
+ //#region src/ops/claim.ts
12371
+ /**
12372
+ * Take a card to work on, in one board write: pick it, start its run, and move
12373
+ * it into the board's first `started` column (In Progress on a standard
12374
+ * board), its entry and flow rules checked on the way in. Anything refused
12375
+ * leaves nothing written.
12376
+ *
12377
+ * Two claimers never get the same card. On SQLite the write holds the file
12378
+ * from its first read (`begin immediate`), so the second claimer reads the
12379
+ * board after the first one's commit. On Postgres the picked card's row is
12380
+ * locked (`select … for update`): a claimer that waited on the lock finds the
12381
+ * card taken, and moves on to the next ready one. The target's row is locked
12382
+ * first, always, in the order a move into it locks (column, then card), so a
12383
+ * claim and a move never wait on each other in a circle.
12384
+ */
12385
+ async function claimCard(store, input, actor, scope) {
12386
+ return await runBoardWrite(store, async ({ tx }) => {
12387
+ const columns = await listColumns(tx, input.workspaceId);
12388
+ const target = columns.find((column) => column.category === "started");
12389
+ if (!target) throw new BoardError("issue_status_not_found", {
12390
+ workspaceId: input.workspaceId,
12391
+ category: "started"
12392
+ });
12393
+ await tx.statuses.lockById(target.id);
12394
+ const toDo = columns.find((column) => normalizeStatusName(column.name) === "to_do") ?? null;
12395
+ const card = input.issueId === void 0 ? await takeFirstReady(tx, input.workspaceId, toDo) : await takeNamed(tx, input.workspaceId, input.issueId, toDo);
12396
+ const run = await startRun(tx, card.id, input.run, actor, { tx });
12397
+ return {
12398
+ card: await updateCard(tx, card.id, { statusId: target.id }, actor, { tx }),
12399
+ run,
12400
+ column: target
12401
+ };
12402
+ }, scope);
12403
+ }
12404
+ /**
12405
+ * The first ready card, locked. A card another claimer took while this one
12406
+ * waited for its lock is passed over, and the next read no longer lists it; the
12407
+ * cards passed over are counted so the loop ends with the list.
12408
+ */
12409
+ async function takeFirstReady(store, workspaceId, toDo) {
12410
+ const passed = /* @__PURE__ */ new Set();
12411
+ for (;;) {
12412
+ const { cards } = await queryReady(store, {
12413
+ workspaceId,
12414
+ limit: passed.size + 1
12415
+ });
12416
+ const candidate = cards.find((card) => !passed.has(card.id));
12417
+ if (!candidate) throw new BoardError("board_nothing_ready", { workspaceId });
12418
+ const locked = await store.issues.lockById(candidate.id);
12419
+ if (locked && await notReadyReason(store, locked, toDo) === null) return locked;
12420
+ passed.add(candidate.id);
12421
+ }
12422
+ }
12423
+ /** The named card, locked, when it is on this board and ready — or why not. */
12424
+ async function takeNamed(store, workspaceId, issueId, toDo) {
12425
+ const card = await store.issues.lockById(issueId);
12426
+ if (!card || card.workspaceId !== workspaceId) throw new BoardError("issue_not_found", { issueId });
12427
+ const reason = await notReadyReason(store, card, toDo);
12428
+ if (reason) throw new BoardError("board_card_not_ready", {
12429
+ issueId,
12430
+ reason
12431
+ });
12432
+ return card;
12433
+ }
12434
+ /** `queryReady`'s rule for one card already read: nobody on it, nobody waited for, in To Do. */
12435
+ async function notReadyReason(store, card, toDo) {
12436
+ if ((await store.runs.listRunning(card.id)).length > 0) return "run_active";
12437
+ if (card.waitingFor === "human") return "waiting_for_person";
12438
+ return toDo !== null && card.statusId === toDo.id ? null : "not_in_to_do";
12439
+ }
12440
+ //#endregion
12441
+ //#region src/ops/column-entries.ts
12442
+ /**
12443
+ * Every entry into a column on the named boards at or after `since`, in order
12444
+ * of `(at, changeId)` — the one signal an app dispatching work on column
12445
+ * changes needs. It sees every writer, an outside `kanbo` on the same database
12446
+ * included, because it reads the cards' own history.
12447
+ *
12448
+ * A page never ends inside a second: when `limit` cuts into one, the rest of
12449
+ * that second comes too, so a page may be longer than `limit`. A reader keeps
12450
+ * its cursor at the last `at` it read after a short page — a write in that
12451
+ * second may still commit — and drops the `changeId`s it has seen; after a page
12452
+ * of `limit` or more it moves on to the last `at` + 1, which a second holding
12453
+ * more entries than `limit` would otherwise never let it pass.
12454
+ */
12455
+ async function listColumnEntries(store, input) {
12456
+ const page = await store.fieldChanges.listByFieldSince("statusId", input);
12457
+ return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
12458
+ }
12459
+ /** The page with every change of its last second, not only those `limit` let in. */
12460
+ async function withWholeLastSecond(store, input, page) {
12461
+ const last = page.at(-1).createdAt;
12462
+ const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
12463
+ workspaceIds: input.workspaceIds,
12464
+ since: last,
12465
+ until: last
12466
+ });
12467
+ return [...page.filter((change) => change.createdAt < last), ...lastSecond];
12468
+ }
12469
+ /** A status change as an entry; a change that took the card out of every column enters none. */
12470
+ function toColumnEntry(change) {
12471
+ return change.toValue === null ? [] : [{
12472
+ changeId: change.id,
12473
+ issueId: change.issueId,
12474
+ workspaceId: change.workspaceId,
12475
+ fromStatusId: change.fromValue,
12476
+ toStatusId: change.toValue,
12477
+ actorKind: change.actorKind,
12478
+ actorId: change.actorId,
12479
+ at: change.createdAt
12480
+ }];
12481
+ }
12482
+ //#endregion
12072
12483
  //#region src/ops/context-refs.ts
12073
12484
  const ContextRefsJsonSchema = string().transform((raw) => JSON.parse(raw)).pipe(array(string()));
12074
12485
  /** Add a context ref at the end of the card's list, recording the change in its history. */
@@ -12253,6 +12664,7 @@ function subtaskSplitSentence(parentReference) {
12253
12664
  }
12254
12665
  const SUBTASK_RULE = `${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("the parent card's id")}`;
12255
12666
  const PULL_REQUEST_RULE = "Opened a pull request? Link it to the card. Its state and CI checks then reach the card as comments on their own — nothing else tells the board a pull request exists.";
12667
+ const CLAIM_RULE = "Taking a card from To Do yourself? Claim it instead of starting a run and moving it: the claim does both in one step, and no other agent gets the same card. Finish the run when you stop.";
12256
12668
  const OWN_SESSION_RULE = "Started the work yourself, not launched by an app that tracks the run for you? Record the run when you start and finish it when you stop. kanbo fills in the Claude Code or Codex session you are in; never invent a session id.";
12257
12669
  /** Said by the `kanbo init` block and the MCP instructions, which name the comment command or tool beside it. */
12258
12670
  const COMMENT_RULE = "Findings, results, decisions and questions go on the card as comments.";
@@ -12276,6 +12688,7 @@ const BOARD_RULES = [
12276
12688
  EXECUTION_MODE_RULE,
12277
12689
  SUBTASK_RULE,
12278
12690
  PULL_REQUEST_RULE,
12691
+ CLAIM_RULE,
12279
12692
  OWN_SESSION_RULE,
12280
12693
  "Read the column descriptions above before moving a card, and do all of this through the board tools or commands you have — a statement in your reply changes nothing."
12281
12694
  ];
@@ -12291,12 +12704,14 @@ const BOARD_RULES = [
12291
12704
  async function buildPrimeText(store, workspaceId) {
12292
12705
  const columns = await listColumns(store, workspaceId);
12293
12706
  const ruled = columns.some((column) => readEntryRules(column).length > 0);
12707
+ const flowing = columns.some((column) => hasFlowRules(column));
12294
12708
  return [
12295
12709
  ...await describeReturnedCards(store, workspaceId, columns),
12296
12710
  "Columns of this board, in board order:",
12297
12711
  "",
12298
- ...columns.length > 0 ? columns.map(describeColumnLine) : ["- (this board has no columns yet)"],
12712
+ ...columns.length > 0 ? columns.map((column) => describeColumnLine(column, columns)) : ["- (this board has no columns yet)"],
12299
12713
  ...ruled ? ["", ENTRY_RULES_NOTE] : [],
12714
+ ...flowing ? ["", FLOW_RULES_NOTE] : [],
12300
12715
  "",
12301
12716
  "How this board works:",
12302
12717
  "",
@@ -12338,11 +12753,22 @@ async function describeReturnedCards(store, workspaceId, columns) {
12338
12753
  * without any reads exactly as it did before they existed.
12339
12754
  */
12340
12755
  const ENTRY_RULES_NOTE = "A column that lists requirements refuses a card that does not meet them yet, with the list of what is missing: do that work first, then move the card. A person may still move it.";
12341
- function describeColumnLine(column) {
12756
+ /** Said under the columns only when one of them has flow rules. */
12757
+ const FLOW_RULES_NOTE = "A column that says which columns it accepts cards from, or how many cards it holds, refuses a move that breaks that and says why: move the card through a column it accepts, or wait until a card leaves. A person may still move it.";
12758
+ function hasFlowRules(column) {
12759
+ const { allowedFrom, wipLimit } = readFlowRules(column);
12760
+ return allowedFrom !== null || wipLimit !== null;
12761
+ }
12762
+ function describeColumnLine(column, columns) {
12342
12763
  const slug = normalizeStatusName(column.name);
12343
12764
  const rules = readEntryRules(column);
12344
- const requires = rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : "";
12345
- return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${requires}` : `- ${column.name} (\`${slug}\`)${requires ? ` —${requires}` : ""}`;
12765
+ const said = `${rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : ""}${describeFlow(column, columns)}`;
12766
+ return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${said}` : `- ${column.name} (\`${slug}\`)${said ? ` —${said}` : ""}`;
12767
+ }
12768
+ /** Where a column takes cards from and how many it holds, as the prime text says it; nothing for a column without flow rules. */
12769
+ function describeFlow(column, columns) {
12770
+ const { allowedFrom, wipLimit } = readFlowRules(column);
12771
+ return `${allowedFrom === null ? "" : allowedFrom.length === 0 ? " Accepts no card from another column: only a person puts a card here." : ` Accepts cards from ${joinWords(columns.filter((other) => allowedFrom.includes(other.id)).map((other) => `\`${normalizeStatusName(other.name)}\``))} only.`}${wipLimit === null ? "" : ` Holds at most ${wipLimit} ${wipLimit === 1 ? "card" : "cards"}.`}`;
12346
12772
  }
12347
12773
  //#endregion
12348
12774
  //#region src/domain/pull-request-ref.ts
@@ -12512,47 +12938,6 @@ async function queryCards(store, input) {
12512
12938
  };
12513
12939
  }
12514
12940
  //#endregion
12515
- //#region src/ops/ready.ts
12516
- /** The column an agent takes its next card from. */
12517
- const READY_COLUMN_SLUG = "to_do";
12518
- /**
12519
- * The cards an agent may pick up right now: spelled out, nobody working on them,
12520
- * nobody's turn but the agent's.
12521
- *
12522
- * "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
12523
- * run is taken, and a card waiting for a person belongs to that person until
12524
- * they answer. What is left is the queue — in board order, so the card someone
12525
- * dragged to the top is the one an agent asking for work gets.
12526
- */
12527
- async function listReady(store, input) {
12528
- return (await queryReady(store, input)).cards;
12529
- }
12530
- /**
12531
- * One page of the ready cards, how many are ready in all, and the columns read
12532
- * on the way — the rule above, asked of the database in one statement rather
12533
- * than of every card on the board.
12534
- */
12535
- async function queryReady(store, input) {
12536
- const columns = await listColumns(store, input.workspaceId);
12537
- const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
12538
- if (!column) return {
12539
- cards: [],
12540
- total: 0,
12541
- columns
12542
- };
12543
- return {
12544
- ...await store.issues.listPage({
12545
- workspaceId: input.workspaceId,
12546
- statusIds: [column.id],
12547
- waitingForPerson: false,
12548
- hasActiveRun: false,
12549
- limit: input.limit,
12550
- offset: input.offset
12551
- }),
12552
- columns
12553
- };
12554
- }
12555
- //#endregion
12556
12941
  //#region src/ops/relations.ts
12557
12942
  /**
12558
12943
  * Every edge touching the card, in either direction, each with the card on its
@@ -12842,6 +13227,8 @@ function createBoardOps(store) {
12842
13227
  updateColumn: async (statusId, patch, actor, scope) => await updateColumn(store, statusId, patch, actor, scope),
12843
13228
  describeColumn: async (workspaceId, nameOrId, description, scope) => await describeColumn(store, workspaceId, nameOrId, description, scope),
12844
13229
  setColumnEntryRules: async (workspaceId, nameOrId, entryRules, actor, scope) => await setColumnEntryRules(store, workspaceId, nameOrId, entryRules, actor, scope),
13230
+ setColumnFlowRules: async (columnId, rules, actor, scope) => await setColumnFlowRules(store, columnId, rules, actor, scope),
13231
+ applyStandardFlow: async (workspaceId, actor, scope) => await applyStandardFlow(store, workspaceId, actor, scope),
12845
13232
  enforceColumnEntryForCards: async (issueIds, statusId, actor) => await enforceColumnEntryForCards(store, issueIds, statusId, actor),
12846
13233
  deleteColumn: async (statusId, scope, actor) => await deleteColumn(store, statusId, scope, actor),
12847
13234
  reorderColumns: async (workspaceId, orderedIds, scope, actor) => await reorderColumns(store, workspaceId, orderedIds, scope, actor),
@@ -12852,6 +13239,7 @@ function createBoardOps(store) {
12852
13239
  moveColumn: async (workspaceId, nameOrId, position, actor, scope) => await moveColumn$1(store, workspaceId, nameOrId, position, actor, scope),
12853
13240
  removeColumn: async (workspaceId, nameOrId, options, actor, scope) => await removeColumn$1(store, workspaceId, nameOrId, options, actor, scope),
12854
13241
  startRun: async (issueId, input, actor, scope) => await startRun(store, issueId, input, actor, scope),
13242
+ claimCard: async (input, actor, scope) => await claimCard(store, input, actor, scope),
12855
13243
  attachRunExecution: async (runId, input, scope) => await attachRunExecution(store, runId, input, scope),
12856
13244
  recordRunSessionRef: async (runId, ref, scope) => await recordRunSessionRef(store, runId, ref, scope),
12857
13245
  clearRunSessionRef: async (runId, actor, options, scope) => await clearRunSessionRef(store, runId, actor, options, scope),
@@ -12864,6 +13252,7 @@ function createBoardOps(store) {
12864
13252
  readBoardProjectionForIssue: async (issueId) => await readBoardProjectionForIssue(store, issueId),
12865
13253
  listReady: async (input) => await listReady(store, input),
12866
13254
  queryReady: async (input) => await queryReady(store, input),
13255
+ listColumnEntries: async (input) => await listColumnEntries(store, input),
12867
13256
  buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId)
12868
13257
  };
12869
13258
  }
@@ -18730,6 +19119,10 @@ const issueStatuses$1 = pgTable("issue_statuses", {
18730
19119
  order: integer$1("order").notNull().default(0),
18731
19120
  /** A JSON array of `ENTRY_RULES`, or `null` for none — see the SQLite schema. */
18732
19121
  entryRules: text$1("entry_rules"),
19122
+ /** A JSON array of the status ids a card may enter from, or `null` for any — see the SQLite schema. */
19123
+ allowedFrom: text$1("allowed_from"),
19124
+ /** The most cards the column holds, or `null` — see the SQLite schema. */
19125
+ wipLimit: integer$1("wip_limit"),
18733
19126
  ...createdAt$1()
18734
19127
  }, (table) => [index$1("issue_statuses_workspace_id_idx").on(table.workspaceId), uniqueIndex$1("issue_statuses_workspace_name_unique").on(table.workspaceId, table.name)]);
18735
19128
  const issueMilestones$1 = pgTable("issue_milestones", {
@@ -18877,7 +19270,7 @@ const issueFieldChanges$1 = pgTable("issue_field_changes", {
18877
19270
  ...createdAt$1(),
18878
19271
  /** Insertion order, standing in for the `rowid` the SQLite board orders changes by. */
18879
19272
  seq: bigserial("seq", { mode: "number" }).notNull()
18880
- }, (table) => [index$1("issue_field_changes_issue_id_idx").on(table.issueId)]);
19273
+ }, (table) => [index$1("issue_field_changes_issue_id_idx").on(table.issueId), index$1("issue_field_changes_field_created_at_idx").on(table.field, table.createdAt)]);
18881
19274
  /** A pull request someone said belongs to a card — see the SQLite twin for what the row is and is not. */
18882
19275
  const issuePullRequests$1 = pgTable("issue_pull_requests", {
18883
19276
  id: textPk$1(),
@@ -18953,6 +19346,10 @@ function toIssueComment({ seq: _seq, ...comment }) {
18953
19346
  function toIssueFieldChange({ seq: _seq, ...change }) {
18954
19347
  return change;
18955
19348
  }
19349
+ /** A field change with its card's workspace, without `seq`, for the same reason. */
19350
+ function toWorkspaceFieldChange({ seq: _seq, ...change }) {
19351
+ return change;
19352
+ }
18956
19353
  /** The pull-request link without `seq`, for the same reason. */
18957
19354
  function toIssuePullRequest({ seq: _seq, ...link }) {
18958
19355
  return link;
@@ -19159,6 +19556,10 @@ function createStoreOver(database) {
19159
19556
  const [row] = await database.select().from(issues$1).where(eq(issues$1.id, issueId)).limit(1);
19160
19557
  return row ?? null;
19161
19558
  },
19559
+ lockById: async (issueId) => {
19560
+ const [row] = await database.select().from(issues$1).where(eq(issues$1.id, issueId)).limit(1).for("update");
19561
+ return row ?? null;
19562
+ },
19162
19563
  findInWorkspace: async (workspaceId, issueId) => {
19163
19564
  const [row] = await database.select().from(issues$1).where(and(eq(issues$1.id, issueId), eq(issues$1.workspaceId, workspaceId))).limit(1);
19164
19565
  return row ?? null;
@@ -19310,6 +19711,10 @@ function createStoreOver(database) {
19310
19711
  fieldChanges: {
19311
19712
  listByIssue: async (issueId) => (await database.select().from(issueFieldChanges$1).where(eq(issueFieldChanges$1.issueId, issueId)).orderBy(asc(issueFieldChanges$1.createdAt), asc(issueFieldChanges$1.seq))).map(toIssueFieldChange),
19312
19713
  listByIssueField: async (issueId, field) => (await database.select().from(issueFieldChanges$1).where(and(eq(issueFieldChanges$1.issueId, issueId), eq(issueFieldChanges$1.field, field))).orderBy(asc(issueFieldChanges$1.createdAt), asc(issueFieldChanges$1.seq))).map(toIssueFieldChange),
19714
+ listByFieldSince: async (field, query) => query.workspaceIds.length === 0 ? [] : (await database.select({
19715
+ ...getTableColumns(issueFieldChanges$1),
19716
+ workspaceId: issues$1.workspaceId
19717
+ }).from(issueFieldChanges$1).innerJoin(issues$1, eq(issues$1.id, issueFieldChanges$1.issueId)).where(and(eq(issueFieldChanges$1.field, field), isNotNull(issueFieldChanges$1.toValue), gte(issueFieldChanges$1.createdAt, query.since), query.until === void 0 ? void 0 : lte(issueFieldChanges$1.createdAt, query.until), inArray(issues$1.workspaceId, [...query.workspaceIds]))).orderBy(asc(issueFieldChanges$1.createdAt), asc(issueFieldChanges$1.id)).limit(query.limit ?? Number.MAX_SAFE_INTEGER)).map(toWorkspaceFieldChange),
19313
19718
  create: async (values) => {
19314
19719
  await database.insert(issueFieldChanges$1).values(values);
19315
19720
  }
@@ -20127,6 +20532,14 @@ const issueStatuses = sqliteTable("issue_statuses", {
20127
20532
  * through `readEntryRules`, never by hand.
20128
20533
  */
20129
20534
  entryRules: text("entry_rules"),
20535
+ /**
20536
+ * The columns a card may enter this one from — a JSON array of status ids;
20537
+ * `null` for any column, `[]` for none (only a person puts a card here). Read
20538
+ * it through `readFlowRules`, never by hand.
20539
+ */
20540
+ allowedFrom: text("allowed_from"),
20541
+ /** The most cards this column holds, 1 or more; `null` for no limit. */
20542
+ wipLimit: int("wip_limit"),
20130
20543
  ...createdAt()
20131
20544
  }, (table) => ({
20132
20545
  byWorkspace: index("issue_statuses_workspace_id_idx").on(table.workspaceId),
@@ -20274,7 +20687,11 @@ const issueFieldChanges = sqliteTable("issue_field_changes", {
20274
20687
  actorId: text("actor_id"),
20275
20688
  sourceChatSessionId: text("source_chat_session_id"),
20276
20689
  ...createdAt()
20277
- }, (table) => ({ byIssue: index("issue_field_changes_issue_id_idx").on(table.issueId) }));
20690
+ }, (table) => ({
20691
+ byIssue: index("issue_field_changes_issue_id_idx").on(table.issueId),
20692
+ /** The column-entry feed reads one field across a board by time. */
20693
+ byFieldCreatedAt: index("issue_field_changes_field_created_at_idx").on(table.field, table.createdAt)
20694
+ }));
20278
20695
  /**
20279
20696
  * A pull request someone said belongs to a card.
20280
20697
  *
@@ -20617,6 +21034,7 @@ function createSqliteBoardStoreCore(resolveDatabase) {
20617
21034
  }).from(issues).where(and(eq(issues.workspaceId, workspaceId), isNotNull(issues.milestoneId))).groupBy(issues.milestoneId, issues.statusId).all(),
20618
21035
  listByIds: (issueIds) => resolveDatabase().select().from(issues).where(inArray(issues.id, issueIds)).all(),
20619
21036
  findById: (issueId) => resolveDatabase().select().from(issues).where(eq(issues.id, issueId)).get() ?? null,
21037
+ lockById: (issueId) => resolveDatabase().select().from(issues).where(eq(issues.id, issueId)).get() ?? null,
20620
21038
  findInWorkspace: (workspaceId, issueId) => resolveDatabase().select().from(issues).where(and(eq(issues.id, issueId), eq(issues.workspaceId, workspaceId))).get() ?? null,
20621
21039
  findByNumber: (workspaceId, number) => resolveDatabase().select().from(issues).where(and(eq(issues.workspaceId, workspaceId), eq(issues.number, number))).get() ?? null,
20622
21040
  maxNumber: (workspaceId) => {
@@ -20714,6 +21132,10 @@ function createSqliteBoardStoreCore(resolveDatabase) {
20714
21132
  fieldChanges: {
20715
21133
  listByIssue: (issueId) => resolveDatabase().select().from(issueFieldChanges).where(eq(issueFieldChanges.issueId, issueId)).orderBy(asc(issueFieldChanges.createdAt), FIELD_CHANGE_INSERTION_ORDER).all(),
20716
21134
  listByIssueField: (issueId, field) => resolveDatabase().select().from(issueFieldChanges).where(and(eq(issueFieldChanges.issueId, issueId), eq(issueFieldChanges.field, field))).orderBy(asc(issueFieldChanges.createdAt), FIELD_CHANGE_INSERTION_ORDER).all(),
21135
+ listByFieldSince: (field, query) => query.workspaceIds.length === 0 ? [] : resolveDatabase().select({
21136
+ ...getTableColumns(issueFieldChanges),
21137
+ workspaceId: issues.workspaceId
21138
+ }).from(issueFieldChanges).innerJoin(issues, eq(issues.id, issueFieldChanges.issueId)).where(and(eq(issueFieldChanges.field, field), isNotNull(issueFieldChanges.toValue), gte(issueFieldChanges.createdAt, query.since), query.until === void 0 ? void 0 : lte(issueFieldChanges.createdAt, query.until), inArray(issues.workspaceId, [...query.workspaceIds]))).orderBy(asc(issueFieldChanges.createdAt), asc(issueFieldChanges.id)).limit(query.limit ?? Number.MAX_SAFE_INTEGER).all(),
20717
21139
  create: (values) => {
20718
21140
  resolveDatabase().insert(issueFieldChanges).values(values).run();
20719
21141
  }
@@ -20794,6 +21216,7 @@ function createSqliteBoardStore(options) {
20794
21216
  countByMilestone: async (workspaceId) => core.issues.countByMilestone(workspaceId),
20795
21217
  listByIds: async (issueIds) => core.issues.listByIds(issueIds),
20796
21218
  findById: async (issueId) => core.issues.findById(issueId),
21219
+ lockById: async (issueId) => core.issues.lockById(issueId),
20797
21220
  findInWorkspace: async (workspaceId, issueId) => core.issues.findInWorkspace(workspaceId, issueId),
20798
21221
  findByNumber: async (workspaceId, number) => core.issues.findByNumber(workspaceId, number),
20799
21222
  maxNumber: async (workspaceId) => core.issues.maxNumber(workspaceId),
@@ -20839,6 +21262,7 @@ function createSqliteBoardStore(options) {
20839
21262
  fieldChanges: {
20840
21263
  listByIssue: async (issueId) => core.fieldChanges.listByIssue(issueId),
20841
21264
  listByIssueField: async (issueId, field) => core.fieldChanges.listByIssueField(issueId, field),
21265
+ listByFieldSince: async (field, query) => core.fieldChanges.listByFieldSince(field, query),
20842
21266
  create: async (values) => core.fieldChanges.create(values)
20843
21267
  },
20844
21268
  pullRequests: {
@@ -23776,6 +24200,111 @@ async function openWithPostgresJs(url) {
23776
24200
  };
23777
24201
  }
23778
24202
  //#endregion
24203
+ //#region src/cli/project-registry.ts
24204
+ /**
24205
+ * Which project folders kanbo has opened a board in.
24206
+ *
24207
+ * Every project keeps its board in its own folder, so nothing else knows which
24208
+ * other projects exist. This list, in the user's home, is what lets one board
24209
+ * page offer them all. It holds a folder and a time — never a connection
24210
+ * string, never board data — and deleting it is safe: it fills again as the
24211
+ * projects are used.
24212
+ */
24213
+ /** The file's name inside kanbo's per-user folder. */
24214
+ const REGISTRY_FILE_NAME = "projects.json";
24215
+ /** The file is private to the user, as a binding is. */
24216
+ const REGISTRY_FILE_MODE = 384;
24217
+ /** The folder holding it is private to the user too. */
24218
+ const REGISTRY_DIRECTORY_MODE = 448;
24219
+ /**
24220
+ * A project noted within this many seconds is not noted again, so an ordinary
24221
+ * command does not rewrite the file: a day is fresh enough for "recently used".
24222
+ */
24223
+ const RECENTLY_NOTED_SECONDS = 86400;
24224
+ const RegisteredProjectSchema = object$1({
24225
+ path: string().min(1),
24226
+ lastSeenAt: number().int().nonnegative()
24227
+ });
24228
+ const RegistrySchema = object$1({
24229
+ schemaVersion: literal(1),
24230
+ projects: array(RegisteredProjectSchema)
24231
+ });
24232
+ /** `$KANBO_HOME/projects.json`, else `~/.kanbo/projects.json`. */
24233
+ function projectRegistryPath(env = process.env) {
24234
+ const home = env.KANBO_HOME?.trim();
24235
+ return (0, node_path.join)(home || (0, node_path.join)((0, node_os.homedir)(), ".kanbo"), REGISTRY_FILE_NAME);
24236
+ }
24237
+ /** The projects, most recently seen first; `[]` when the file is missing or does not parse. */
24238
+ function readProjectRegistry(path = projectRegistryPath()) {
24239
+ return readEntries(path).sort((a, b) => b.lastSeenAt - a.lastSeenAt);
24240
+ }
24241
+ /**
24242
+ * Note that `projectDir` was just used. Writes only when the folder is new or
24243
+ * was last noted more than a day ago, so ordinary commands do not rewrite the file.
24244
+ * Drops entries whose `.kanbo/binding.json` is gone.
24245
+ */
24246
+ function rememberProject(projectDir, options = {}) {
24247
+ const now = options.now ?? Math.floor(Date.now() / 1e3);
24248
+ const path = options.path ?? projectRegistryPath();
24249
+ const canonical = canonicalPath$1(projectDir);
24250
+ const entries = readEntries(path);
24251
+ const known = entries.find((entry) => entry.path === canonical);
24252
+ if (known && now - known.lastSeenAt <= RECENTLY_NOTED_SECONDS) return;
24253
+ const kept = entries.filter((entry) => entry.path !== canonical && (0, node_fs.existsSync)((0, node_path.join)(entry.path, BINDING_FILE_PATH)));
24254
+ kept.push({
24255
+ path: canonical,
24256
+ lastSeenAt: now
24257
+ });
24258
+ writeEntries(path, kept);
24259
+ }
24260
+ /**
24261
+ * `rememberProject`, for the places that must not fail because of it: a home
24262
+ * directory that cannot be written is not a reason for `kanbo card list` to fail.
24263
+ * With `KANBO_DEBUG=1` the reason is printed on stderr.
24264
+ */
24265
+ function rememberProjectQuietly(projectDir) {
24266
+ const path = projectRegistryPath();
24267
+ try {
24268
+ rememberProject(projectDir, { path });
24269
+ } catch (error) {
24270
+ if (process.env.KANBO_DEBUG === "1") console.error(`kanbo: could not note this project in ${path}: ${error instanceof Error ? error.message : String(error)}`);
24271
+ }
24272
+ }
24273
+ function canonicalPath$1(projectDir) {
24274
+ const absolute = (0, node_path.resolve)(projectDir);
24275
+ try {
24276
+ return (0, node_fs.realpathSync)(absolute);
24277
+ } catch {
24278
+ return absolute;
24279
+ }
24280
+ }
24281
+ function readEntries(path) {
24282
+ try {
24283
+ const parsed = RegistrySchema.safeParse(JSON.parse((0, node_fs.readFileSync)(path, "utf8")));
24284
+ return parsed.success ? parsed.data.projects : [];
24285
+ } catch {
24286
+ return [];
24287
+ }
24288
+ }
24289
+ function writeEntries(path, projects) {
24290
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(path), {
24291
+ recursive: true,
24292
+ mode: REGISTRY_DIRECTORY_MODE
24293
+ });
24294
+ const temporaryPath = `${path}.${process.pid}.tmp`;
24295
+ try {
24296
+ (0, node_fs.writeFileSync)(temporaryPath, `${JSON.stringify(RegistrySchema.parse({
24297
+ schemaVersion: 1,
24298
+ projects
24299
+ }), null, 2)}\n`, { mode: REGISTRY_FILE_MODE });
24300
+ (0, node_fs.chmodSync)(temporaryPath, REGISTRY_FILE_MODE);
24301
+ renameWithRetry(temporaryPath, path);
24302
+ } catch (error) {
24303
+ (0, node_fs.rmSync)(temporaryPath, { force: true });
24304
+ throw error;
24305
+ }
24306
+ }
24307
+ //#endregion
23779
24308
  //#region src/postgres/open-database.ts
23780
24309
  /** `information_schema.tables`, the first of the two views the guard reads. */
23781
24310
  const informationSchemaTables = pgSchema("information_schema").table("tables", {
@@ -23855,9 +24384,10 @@ const SCHEMA_NOT_INSTALLED_MESSAGE = "This Postgres database has no kanbo board
23855
24384
  * caller's own risk but never written to — a write shaped by this build's idea
23856
24385
  * of the schema is how a person's board gets a column full of nothing. A file
23857
24386
  * of this package's own is this package's to migrate, so the same refusal
23858
- * points at `kanbo migrate` instead of at the app. Reads stay open either way,
23859
- * because a person who cannot migrate right now should still be able to look at
23860
- * their cards.
24387
+ * points at `kanbo migrate` instead of at the app. Reads are held to the lesser
24388
+ * bar of `assertReadableBoard`: a file only a stamp behind is still read, so a
24389
+ * person who cannot migrate right now can look at their cards, but one missing
24390
+ * a table or column this build reads is refused with the same sentence.
23861
24391
  */
23862
24392
  function assertWritableBoard(database, owner) {
23863
24393
  try {
@@ -23868,6 +24398,26 @@ function assertWritableBoard(database, owner) {
23868
24398
  }
23869
24399
  }
23870
24400
  /**
24401
+ * Refuse a read of a board file that lacks a table or a column this build
24402
+ * reads — a file a migration behind — with the sentence a write gets, instead
24403
+ * of letting the first statement fail on what is missing. A file whose only
24404
+ * difference is its schema stamp is still read: every statement this build
24405
+ * makes works on it, and a person who cannot migrate now can still look at
24406
+ * their cards.
24407
+ */
24408
+ function assertReadableBoard(database, owner) {
24409
+ try {
24410
+ assertBoardSchema$1(database);
24411
+ } catch (error) {
24412
+ if (!(error instanceof BoardError && error.code === "board_schema_outdated")) throw error;
24413
+ if (lacksWhatThisBuildReads(error.details)) throw new CliError(3, owner === "kanbo" ? FILE_SCHEMA_OUTDATED_MESSAGE : SCHEMA_OUTDATED_MESSAGE);
24414
+ }
24415
+ }
24416
+ /** Whether `assertBoardSchema` found a table or a column missing, rather than only another stamp. */
24417
+ function lacksWhatThisBuildReads(details) {
24418
+ return details !== void 0 && ("missingTables" in details || "missingAddedTables" in details || "missingColumns" in details);
24419
+ }
24420
+ /**
23871
24421
  * Refuse an external board this build cannot speak for — before reading it as
23872
24422
  * well as before writing to it.
23873
24423
  *
@@ -24087,15 +24637,17 @@ function withBoardOptions(command) {
24087
24637
  * as a client is talking to it. A failure on the way out closes the board first
24088
24638
  * — a caller that never received a session has nothing to close.
24089
24639
  */
24090
- async function openBoardSession(options, mode) {
24091
- const cwd = process.cwd();
24640
+ async function openBoardSession(options, mode, cwd = process.cwd()) {
24092
24641
  const target = resolveDbTarget({
24093
24642
  explicitPath: options.db,
24094
24643
  explicitUrl: options.databaseUrl,
24095
24644
  asAgent: options.asAgent,
24096
24645
  cwd
24097
24646
  });
24098
- return target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
24647
+ const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
24648
+ const found = findBinding(cwd);
24649
+ if (found) rememberProjectQuietly(found.projectDir);
24650
+ return session;
24099
24651
  }
24100
24652
  async function openFileSession(target, options, mode, cwd) {
24101
24653
  const board = await openBoardDatabase(target.path);
@@ -24104,6 +24656,7 @@ async function openFileSession(target, options, mode, cwd) {
24104
24656
  assertWritableBoard(board.database, target.owner);
24105
24657
  };
24106
24658
  if (mode === "write") assertWritableBoard(board.database, target.owner);
24659
+ else assertReadableBoard(board.database, target.owner);
24107
24660
  const workspace = resolveWorkspace(target.owner === "kanbo" ? { kind: "file" } : {
24108
24661
  kind: "sqlite",
24109
24662
  database: board.database
@@ -24127,8 +24680,9 @@ async function openFileSession(target, options, mode, cwd) {
24127
24680
  /**
24128
24681
  * The same, on a board that is a database of its own.
24129
24682
  *
24130
- * The guard runs whatever the command came to do. A board file that this build
24131
- * cannot write to is still a board worth reading; a connection string pointing
24683
+ * The guard runs whatever the command came to do. A board file whose only
24684
+ * difference from this build is its schema stamp is still a board worth
24685
+ * reading; a connection string pointing
24132
24686
  * at a database with no board in it is nothing to read at all, and every
24133
24687
  * command would otherwise fail one statement later with the driver's own words.
24134
24688
  */
@@ -24426,7 +24980,9 @@ function projectCardActiveRun(run) {
24426
24980
  startedAt: run.startedAt
24427
24981
  };
24428
24982
  }
24429
- function projectColumn(column) {
24983
+ /** A column as the command line prints it; `board` is every column, to name the ones it accepts cards from. */
24984
+ function projectColumn(column, board) {
24985
+ const { allowedFrom, wipLimit } = readFlowRules(column);
24430
24986
  return {
24431
24987
  id: column.id,
24432
24988
  name: column.name,
@@ -24434,7 +24990,9 @@ function projectColumn(column) {
24434
24990
  description: column.description,
24435
24991
  category: column.category,
24436
24992
  order: column.order,
24437
- entryRules: readEntryRules(column)
24993
+ entryRules: readEntryRules(column),
24994
+ allowedFrom: allowedFrom === null ? null : board.filter((other) => allowedFrom.includes(other.id)).map((other) => normalizeStatusName(other.name)),
24995
+ wipLimit
24438
24996
  };
24439
24997
  }
24440
24998
  function projectSprint(sprint) {
@@ -24506,7 +25064,9 @@ function describeColumns(columns) {
24506
25064
  return columns.map((column) => [
24507
25065
  `${padToWidth(sanitizeTerminalText(column.slug), slugWidth)} ${sanitizeTerminalText(column.name)}`,
24508
25066
  column.description ? ` — ${sanitizeTerminalText(column.description)}` : "",
24509
- column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : ""
25067
+ column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : "",
25068
+ column.allowedFrom === null ? "" : ` [from: ${column.allowedFrom.length > 0 ? column.allowedFrom.join(", ") : "no column"}]`,
25069
+ column.wipLimit === null ? "" : ` [wip: ${column.wipLimit}]`
24510
25070
  ].join("")).join("\n");
24511
25071
  }
24512
25072
  /** A unix-seconds date as the UTC day it falls on, `—` for none. */
@@ -24700,12 +25260,13 @@ function colorsWanted() {
24700
25260
  const KANBO_TOOL_CLI_EQUIVALENTS = {
24701
25261
  kanbo_prime: { kanbo: "kanbo prime" },
24702
25262
  kanbo_ready: { kanbo: "kanbo ready" },
25263
+ kanbo_card_claim: { kanbo: "kanbo claim [card] [--agent <name>]" },
24703
25264
  kanbo_columns: { kanbo: "kanbo columns list" },
24704
25265
  kanbo_sprints: { kanbo: "kanbo sprint list" },
24705
25266
  kanbo_card_get: { kanbo: "kanbo card get <id>" },
24706
25267
  kanbo_card_list: { kanbo: "kanbo card list" },
24707
25268
  kanbo_card_create: { kanbo: "kanbo card create --description <text> [--title <title>] [--parent <id>]" },
24708
- kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>]" },
25269
+ kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>] [--parent <id>]" },
24709
25270
  kanbo_card_move: { kanbo: "kanbo card move <id> <column>" },
24710
25271
  kanbo_card_comment: { kanbo: "kanbo card comment <id> --content <text>" },
24711
25272
  kanbo_card_link_pr: { kanbo: "kanbo card pr add <id> <url>" },
@@ -24903,6 +25464,81 @@ const cardCommentTool = defineKanboTool({
24903
25464
  },
24904
25465
  run: async (transport, input) => await transport.cardComment(input)
24905
25466
  });
25467
+ /** The `detail` and `fields` arguments the list tools share. */
25468
+ const cardListViewArguments = {
25469
+ detail: _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."),
25470
+ fields: array(_enum([
25471
+ "number",
25472
+ "title",
25473
+ "description",
25474
+ "column",
25475
+ "columnSlug",
25476
+ "statusLine",
25477
+ "waitingFor",
25478
+ "priority",
25479
+ "labels",
25480
+ "executionMode",
25481
+ "parentIssueId",
25482
+ "attemptCount",
25483
+ "activeRun",
25484
+ "updatedAt"
25485
+ ])).optional().describe("Print the id and only these fields of each card, e.g. [\"description\"].")
25486
+ };
25487
+ function compactKanboCard(card) {
25488
+ return {
25489
+ id: card.id,
25490
+ title: cardDisplayTitle(card),
25491
+ column: card.columnSlug,
25492
+ ...card.statusLine ? { statusLine: card.statusLine } : {},
25493
+ ...card.waitingFor ? { waitingFor: card.waitingFor } : {},
25494
+ ...card.parentIssueId ? { parentId: card.parentIssueId } : {},
25495
+ ...card.attemptCount > 0 ? { attempt: card.attemptCount } : {},
25496
+ ...card.activeRun ? { activeRun: {
25497
+ agentName: card.activeRun.agentName,
25498
+ startedAt: card.activeRun.startedAt
25499
+ } } : {},
25500
+ updatedAt: card.updatedAt,
25501
+ ...card.returned ? { returned: card.returned } : {},
25502
+ ...card.lastComment ? { lastComment: card.lastComment } : {}
25503
+ };
25504
+ }
25505
+ /** The `detail` argument of the tools that write a card: a list's own word, for the one card a write answers with. */
25506
+ const writtenCardDetailArgument = _enum(["compact", "full"]).optional().describe("compact (default): the card as kanbo_card_list prints it. full: every field, description included.");
25507
+ /** What each tool that writes a card says about its answer, in its description. */
25508
+ const WRITTEN_CARD_ANSWER_SENTENCE = "Answers the card compact, as kanbo_card_list prints it; detail: \"full\" for every field.";
25509
+ /**
25510
+ * The card a write hands back, as the tool answers with it: compact and on one
25511
+ * line, as a list prints it. An agent writes its card at every step — a status
25512
+ * line, a move, an update — and reads each answer again on every later turn,
25513
+ * so a whole card, description included, would cost it again and again.
25514
+ * `detail: "full"` answers every field, as `kanbo_card_get` prints the card alone.
25515
+ */
25516
+ function formatWrittenKanboCard(card, detail) {
25517
+ return detail === "full" ? JSON.stringify(card, null, 2) : JSON.stringify(compactKanboCard(card));
25518
+ }
25519
+ function viewCard(card, view) {
25520
+ if (view.fields) return Object.fromEntries([["id", card.id], ...view.fields.map((field) => [field, card[field]])]);
25521
+ return view.detail === "full" ? card : compactKanboCard(card);
25522
+ }
25523
+ /**
25524
+ * The page as the tool's answer: how many cards the question picks, where the
25525
+ * next page starts when there is one, and one card per line.
25526
+ */
25527
+ function formatKanboCardPage(page, view, returned) {
25528
+ const shownUntil = page.offset + page.cards.length;
25529
+ const more = Math.max(0, page.total - shownUntil);
25530
+ const head = {
25531
+ total: page.total,
25532
+ ...page.offset > 0 ? { offset: page.offset } : {},
25533
+ ...more > 0 ? {
25534
+ more,
25535
+ nextOffset: shownUntil
25536
+ } : {}
25537
+ };
25538
+ const cards = page.cards.map((card) => JSON.stringify(viewCard(card, view)));
25539
+ 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)}`;
25540
+ return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
25541
+ }
24906
25542
  //#endregion
24907
25543
  //#region src/mcp/tools/card-create.ts
24908
25544
  /** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
@@ -24911,19 +25547,20 @@ const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pas
24911
25547
  const cardCreateTool = defineKanboTool({
24912
25548
  name: "kanbo_card_create",
24913
25549
  title: "Create a card",
24914
- description: `Put a new card on this board. ${SUBTASK_SENTENCE} ${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("pass `parent`")}`,
25550
+ 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.`,
24915
25551
  inputSchema: {
24916
25552
  title: 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."),
24917
25553
  description: string().optional().describe("What the card is about, spelled out for whoever picks it up."),
24918
25554
  column: string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
24919
25555
  parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
24920
- executionMode: executionModeArgument.optional()
25556
+ executionMode: executionModeArgument.optional(),
25557
+ detail: writtenCardDetailArgument
24921
25558
  },
24922
25559
  example: {
24923
25560
  description: "Add slugify(text) with a test",
24924
25561
  parent: "TST-1"
24925
25562
  },
24926
- run: async (transport, input) => await transport.cardCreate(input)
25563
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardCreate(input), detail)
24927
25564
  });
24928
25565
  //#endregion
24929
25566
  //#region src/mcp/transport.ts
@@ -25040,23 +25677,34 @@ function projectKanboSprint(sprint) {
25040
25677
  doneCards: sprint.cards.done
25041
25678
  };
25042
25679
  }
25043
- /** A column of the board, from the row either transport holds. */
25044
- function projectKanboColumn(column) {
25680
+ /** A column of the board, from the row either transport holds; `board` is every column, to name the ones it accepts cards from. */
25681
+ function projectKanboColumn(column, board) {
25045
25682
  const stored = column.entryRules;
25683
+ const allowedIds = typeof column.allowedFrom === "string" ? readFlowRules({
25684
+ allowedFrom: column.allowedFrom,
25685
+ wipLimit: null
25686
+ }).allowedFrom : column.allowedFrom ?? null;
25046
25687
  return {
25047
25688
  id: column.id,
25048
25689
  name: column.name,
25049
25690
  slug: normalizeStatusName(column.name),
25050
25691
  description: column.description,
25051
25692
  order: column.order,
25052
- entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null })
25693
+ entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null }),
25694
+ allowedFrom: allowedIds === null ? null : board.filter((other) => allowedIds.includes(other.id)).map((other) => normalizeStatusName(other.name)),
25695
+ wipLimit: column.wipLimit ?? null
25053
25696
  };
25054
25697
  }
25055
25698
  /** A comment as a card read lists it, from the row either transport holds. */
25056
25699
  function projectKanboCardComment(comment) {
25700
+ const decision = comment.authorKind === "system.approved" || comment.authorKind === "system.returned";
25701
+ const author = decision ? "user" : normalizeCommentAuthorKind(comment.authorKind);
25702
+ const named = author !== "system" || decision;
25703
+ const outside = author === "system" && isOutsideWriter(comment) && comment.authorKind === "system";
25057
25704
  return {
25058
25705
  id: comment.id,
25059
- author: normalizeCommentAuthorKind(comment.authorKind),
25706
+ author,
25707
+ by: named || outside ? comment.authorId ?? author : "kanbo",
25060
25708
  content: comment.content,
25061
25709
  createdAt: comment.createdAt
25062
25710
  };
@@ -25104,67 +25752,6 @@ const cardLinkPrTool = defineKanboTool({
25104
25752
  },
25105
25753
  run: async (transport, input) => await transport.cardLinkPullRequest(input)
25106
25754
  });
25107
- /** The `detail` and `fields` arguments the list tools share. */
25108
- const cardListViewArguments = {
25109
- detail: _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."),
25110
- fields: array(_enum([
25111
- "number",
25112
- "title",
25113
- "description",
25114
- "column",
25115
- "columnSlug",
25116
- "statusLine",
25117
- "waitingFor",
25118
- "priority",
25119
- "labels",
25120
- "executionMode",
25121
- "parentIssueId",
25122
- "attemptCount",
25123
- "activeRun",
25124
- "updatedAt"
25125
- ])).optional().describe("Print the id and only these fields of each card, e.g. [\"description\"].")
25126
- };
25127
- function compactKanboCard(card) {
25128
- return {
25129
- id: card.id,
25130
- title: cardDisplayTitle(card),
25131
- column: card.columnSlug,
25132
- ...card.statusLine ? { statusLine: card.statusLine } : {},
25133
- ...card.waitingFor ? { waitingFor: card.waitingFor } : {},
25134
- ...card.parentIssueId ? { parentId: card.parentIssueId } : {},
25135
- ...card.attemptCount > 0 ? { attempt: card.attemptCount } : {},
25136
- ...card.activeRun ? { activeRun: {
25137
- agentName: card.activeRun.agentName,
25138
- startedAt: card.activeRun.startedAt
25139
- } } : {},
25140
- updatedAt: card.updatedAt,
25141
- ...card.returned ? { returned: card.returned } : {},
25142
- ...card.lastComment ? { lastComment: card.lastComment } : {}
25143
- };
25144
- }
25145
- function viewCard(card, view) {
25146
- if (view.fields) return Object.fromEntries([["id", card.id], ...view.fields.map((field) => [field, card[field]])]);
25147
- return view.detail === "full" ? card : compactKanboCard(card);
25148
- }
25149
- /**
25150
- * The page as the tool's answer: how many cards the question picks, where the
25151
- * next page starts when there is one, and one card per line.
25152
- */
25153
- function formatKanboCardPage(page, view, returned) {
25154
- const shownUntil = page.offset + page.cards.length;
25155
- const more = Math.max(0, page.total - shownUntil);
25156
- const head = {
25157
- total: page.total,
25158
- ...page.offset > 0 ? { offset: page.offset } : {},
25159
- ...more > 0 ? {
25160
- more,
25161
- nextOffset: shownUntil
25162
- } : {}
25163
- };
25164
- const cards = page.cards.map((card) => JSON.stringify(viewCard(card, view)));
25165
- 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)}`;
25166
- return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
25167
- }
25168
25755
  /** The cards on the board a question picks, one page of them. */
25169
25756
  const cardListTool = defineKanboTool({
25170
25757
  name: "kanbo_card_list",
@@ -25219,10 +25806,11 @@ function readMoment(value) {
25219
25806
  const cardMoveTool = defineKanboTool({
25220
25807
  name: "kanbo_card_move",
25221
25808
  title: "Move a card",
25222
- 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.",
25809
+ description: `Move a card to another column the moment its real state changes. Read kanbo_columns first: a card in the wrong column is a lie, and a column refuses a card that does not meet its entryRules yet, that comes from a column it does not accept (allowedFrom), or that would overfill it (wipLimit), listing what is missing. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
25223
25810
  inputSchema: {
25224
25811
  card: cardArgument,
25225
- column: string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`.")
25812
+ column: string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`."),
25813
+ detail: writtenCardDetailArgument
25226
25814
  },
25227
25815
  aliases: { column: ["to"] },
25228
25816
  needs: { column: "the column slug, e.g. in_progress" },
@@ -25230,7 +25818,7 @@ const cardMoveTool = defineKanboTool({
25230
25818
  card: "TST-5",
25231
25819
  column: "in_progress"
25232
25820
  },
25233
- run: async (transport, input) => await transport.cardMove(input)
25821
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardMove(input), detail)
25234
25822
  });
25235
25823
  //#endregion
25236
25824
  //#region src/mcp/tools/card-pull-requests.ts
@@ -25245,11 +25833,11 @@ const cardPullRequestsTool = defineKanboTool({
25245
25833
  });
25246
25834
  //#endregion
25247
25835
  //#region src/mcp/tools/card-update.ts
25248
- /** What a card says: its title, what it is about, how urgent it is, how it is labelled. */
25836
+ /** What a card says — its title, what it is about, how urgent it is, how it is labelled — and which card it sits under. */
25249
25837
  const cardUpdateTool = defineKanboTool({
25250
25838
  name: "kanbo_card_update",
25251
25839
  title: "Update a card",
25252
- description: "Change what a card says: its title, its description, its priority, its labels or where its work happens.",
25840
+ 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}`,
25253
25841
  inputSchema: {
25254
25842
  card: cardArgument,
25255
25843
  title: string().min(1).optional().describe("What the card is called."),
@@ -25262,18 +25850,26 @@ const cardUpdateTool = defineKanboTool({
25262
25850
  "urgent"
25263
25851
  ]).optional(),
25264
25852
  labels: array(string()).optional().describe("The labels the card carries, replacing the ones it has."),
25265
- executionMode: executionModeArgument.optional()
25853
+ executionMode: executionModeArgument.optional(),
25854
+ 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."),
25855
+ detail: writtenCardDetailArgument
25266
25856
  },
25267
25857
  example: {
25268
25858
  card: "TST-5",
25269
25859
  title: "Add slugify"
25270
25860
  },
25271
- run: async (transport, { card, ...patch }) => {
25272
- if (Object.keys(patch).length === 0) throw new Error("Nothing to update. Pass title, description, priority, labels or executionMode.");
25273
- return await transport.cardUpdate({
25861
+ prepare: (input) => {
25862
+ const { parent, ...rest } = input;
25863
+ return parent === null ? rest : input;
25864
+ },
25865
+ run: async (transport, { card, detail, parent, ...patch }) => {
25866
+ if (Object.keys(patch).length === 0 && parent === void 0) throw new Error("Nothing to update. Pass title, description, priority, labels, executionMode or parent.");
25867
+ const placement = parent === void 0 ? {} : { parent: parent === "none" ? null : parent };
25868
+ return formatWrittenKanboCard(await transport.cardUpdate({
25274
25869
  card,
25275
- ...patch
25276
- });
25870
+ ...patch,
25871
+ ...placement
25872
+ }), detail);
25277
25873
  }
25278
25874
  });
25279
25875
  //#endregion
@@ -25282,7 +25878,7 @@ const cardUpdateTool = defineKanboTool({
25282
25878
  const columnsTool = defineKanboTool({
25283
25879
  name: "kanbo_columns",
25284
25880
  title: "Columns of the board",
25285
- description: "List this board's columns in board order, each with the slug kanbo_card_move takes, what the column means here, and the entryRules a card must meet before you move it in.",
25881
+ description: "List this board's columns in board order, each with the slug kanbo_card_move takes, what the column means here, the entryRules a card must meet before you move it in, the columns it accepts cards from (allowedFrom, slugs; null for any) and the most cards it holds (wipLimit).",
25286
25882
  inputSchema: {},
25287
25883
  run: async (transport) => await transport.columns()
25288
25884
  });
@@ -25296,6 +25892,38 @@ const primeTool = defineKanboTool({
25296
25892
  inputSchema: {},
25297
25893
  run: async (transport) => (await transport.prime()).text
25298
25894
  });
25895
+ //#endregion
25896
+ //#region src/mcp/tools/card-claim.ts
25897
+ /**
25898
+ * Taking a card to work on, in one step: the run starts and the card moves out
25899
+ * of To Do into the board's first work-in-progress column in the same write,
25900
+ * so two agents never get the same card. Published to every host, including
25901
+ * one that records the runs of the agents it launches: a claim is how an agent
25902
+ * in any terminal takes work.
25903
+ */
25904
+ const cardClaimTool = defineKanboTool({
25905
+ name: "kanbo_card_claim",
25906
+ title: "Claim a card",
25907
+ description: "Take a card to work on, in one step: it starts your run and moves the card from To Do into the board's first work-in-progress column. Without `card`, takes the first ready card. Two agents never get the same card: the one that loses is told board_nothing_ready or board_card_not_ready. Answers the card compact, the run id, the column, and the kanbo_run_finish call that ends the run.",
25908
+ inputSchema: {
25909
+ card: cardArgument.optional(),
25910
+ agent: string().min(1).optional().describe("What to call whoever works the card; who this server writes as when absent.")
25911
+ },
25912
+ aliases: {
25913
+ card: [...CARD_ALIASES, "issue"],
25914
+ agent: ["agentName"]
25915
+ },
25916
+ example: { card: "TST-5" },
25917
+ run: async (transport, input) => {
25918
+ const claimed = await transport.cardClaim(input);
25919
+ return {
25920
+ card: compactKanboCard(claimed.card),
25921
+ run: claimed.run.id,
25922
+ column: claimed.column.slug,
25923
+ finishWith: `kanbo_run_finish {"run":"${claimed.run.id}","state":"finished"}`
25924
+ };
25925
+ }
25926
+ });
25299
25927
  /** The cards an agent may pick up right now. */
25300
25928
  const readyTool = defineKanboTool({
25301
25929
  name: "kanbo_ready",
@@ -25384,13 +26012,14 @@ const runStartTool = defineKanboTool({
25384
26012
  /**
25385
26013
  * Every tool the board has, in the order it declares them.
25386
26014
  *
25387
- * The list is the board's, not a server's: the same sixteen are registered
26015
+ * The list is the board's, not a server's: the same seventeen are registered
25388
26016
  * whether they run against a server or against the board directly, and the
25389
26017
  * only thing a host decides is which of them it publishes.
25390
26018
  */
25391
26019
  const KANBO_TOOLS = [
25392
26020
  primeTool,
25393
26021
  readyTool,
26022
+ cardClaimTool,
25394
26023
  columnsTool,
25395
26024
  defineKanboTool({
25396
26025
  name: "kanbo_sprints",
@@ -25410,10 +26039,11 @@ const KANBO_TOOLS = [
25410
26039
  defineKanboTool({
25411
26040
  name: "kanbo_status_line",
25412
26041
  title: "Set the status line",
25413
- 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.",
26042
+ 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,
25414
26043
  inputSchema: {
25415
26044
  card: cardArgument,
25416
- text: string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`.")
26045
+ text: string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`."),
26046
+ detail: writtenCardDetailArgument
25417
26047
  },
25418
26048
  aliases: { text: ["content"] },
25419
26049
  needs: { text: "the status line, one sentence" },
@@ -25421,22 +26051,23 @@ const KANBO_TOOLS = [
25421
26051
  card: "TST-5",
25422
26052
  text: "Running the tests"
25423
26053
  },
25424
- run: async (transport, input) => await transport.statusLine(input)
26054
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.statusLine(input), detail)
25425
26055
  }),
25426
26056
  defineKanboTool({
25427
26057
  name: "kanbo_wait_approval",
25428
26058
  title: "Hand the card to a person",
25429
- 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.",
26059
+ 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}`,
25430
26060
  inputSchema: {
25431
26061
  card: cardArgument,
25432
- text: string().min(1).optional().describe("The status line to leave, saying what the person is being asked to look at. Also accepted as `content`.")
26062
+ text: string().min(1).optional().describe("The status line to leave, saying what the person is being asked to look at. Also accepted as `content`."),
26063
+ detail: writtenCardDetailArgument
25433
26064
  },
25434
26065
  aliases: { text: ["content"] },
25435
26066
  example: {
25436
26067
  card: "TST-5",
25437
26068
  text: "Ready for review: slugify added with a test"
25438
26069
  },
25439
- run: async (transport, input) => await transport.waitApproval(input)
26070
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.waitApproval(input), detail)
25440
26071
  }),
25441
26072
  runStartTool,
25442
26073
  runFinishTool
@@ -25778,10 +26409,12 @@ function buildCapabilitiesManifest() {
25778
26409
  "Only a person may close a sprint. `kanbo sprint close` refuses an agent's shell (exit 4), and the operation refuses any actor but a person; there is no MCP tool that closes one — `kanbo_sprints` only reads. Unlike approval, the database role does not enforce this.",
25779
26410
  `The agent role (\`${BOARD_AGENT_ROLE}\`) may not delete a card at all — it holds select, insert and update on \`issues\` and nothing more, even though it may delete freely on every other board table.`,
25780
26411
  "A column may ask a card to meet entry rules before it enters (`entryRules`). Anyone but a person moving, creating or bulk-moving a card into it early is refused with `board_column_rules_unmet` and one line per unmet rule (CLI exit 1); a person goes through and is told what is missing. Only a person sets the rules — `kanbo columns rules` refuses an agent's shell (exit 4). The database role does not enforce them.",
26412
+ "A column may name the columns it accepts cards from (`allowedFrom`) and the most cards it holds (`wipLimit`). Anyone but a person moving, creating, bulk-moving or claiming a card against them is refused with `board_column_rules_unmet` (`allowed_from`, `wip_limit`); a person goes through and is told. A new board's Done accepts cards from In Review only. Only a person sets them — `kanbo columns flow` refuses an agent's shell (exit 4). The database role does not enforce them.",
26413
+ "A claim (`kanbo claim`, `kanbo_card_claim`, `POST /issues/claim`) takes a ready card from To Do, starts its run and moves it into the board's first `started` column in one write; two claimers never get the same card, and the one that loses is told `board_nothing_ready` or `board_card_not_ready`. A card has one run going at a time: starting a second is refused with `board_card_run_active`, on every path.",
25781
26414
  "Only a person changes the board's columns: `kanbo columns add`, `rename`, `move`, `remove`, `template` and `add-standard` refuse an agent's shell (exit 4), and the rename, move, remove and add-missing operations refuse any actor but a person (`board_column_structure_requires_user`); no MCP tool changes columns. To Do can be neither removed nor renamed away from its slug `to_do`: `kanbo ready` takes work from it.",
25782
26415
  "No board tool or command deletes a card, agent role or not. Cancelling one is a move to another column. The one exception is `kanbo serve`'s `DELETE /issues/:id`, which it answers only for a request presenting its token, on a server a person started, that has not sent `x-kanbo-actor: agent` (403 `issue_delete_requires_user` otherwise).",
25783
26416
  "`kanbo migrate` and `kanbo roles apply` refuse a host app's own database file: the app migrates it, and this package never touches it.",
25784
- "A write to a board older than this build's schema epoch is refused (exit 3) until `kanbo migrate` runs (on a host app's database, until the app migrates it) — reads stay open either way.",
26417
+ "A write to a board older than this build's schema epoch is refused (exit 3) until `kanbo migrate` runs (on a host app's database, until the app migrates it), and so is a read of a board file that lacks a table or a column this build reads; a board whose only difference is its schema stamp stays readable.",
25785
26418
  "There is no MCP tool that approves a card, and there never will be: `kanbo_wait_approval` hands the card to a person and ends the turn; only a person takes it back."
25786
26419
  ]
25787
26420
  };
@@ -25926,6 +26559,52 @@ function registerCapabilitiesCommand(program) {
25926
26559
  });
25927
26560
  }
25928
26561
  //#endregion
26562
+ //#region src/cli/commands/claim.ts
26563
+ /**
26564
+ * `kanbo claim [card]` — take a card to work on, in one step.
26565
+ *
26566
+ * The run starts and the card moves out of To Do into the board's first
26567
+ * work-in-progress column in one write, so two agents in two terminals never
26568
+ * take the same card. Filed as a placement: in a person's own terminal the
26569
+ * column's rules warn, in an agent's shell they refuse.
26570
+ */
26571
+ function registerClaimCommand(program) {
26572
+ withBoardOptions(program.command("claim").description("Take a card to work on: start your run and move it out of To Do, in one step").argument("[card]", "the card, as MAN-012, MAN-12 or 12 (default: the first ready card)").option("--agent <name>", "your name, e.g. claude (default: the name this shell writes as)").option("--session <ref>", "your session, to find it later: claude:<session id> or codex:<session id> (default: the one Claude Code or Codex names in the environment)")).action(async (reference, options) => {
26573
+ await runBoardCommand(options, "write", async (session) => {
26574
+ const actor = createPlacementActor();
26575
+ const named = reference === void 0 ? void 0 : (await requireCard(session, reference)).id;
26576
+ const claimed = await session.ops.claimCard({
26577
+ workspaceId: session.workspace.id,
26578
+ issueId: named,
26579
+ run: {
26580
+ agentName: options.agent ?? actor.id ?? "agent",
26581
+ externalSessionRef: options.session ?? sessionRefFromEnvironment() ?? void 0,
26582
+ launchedByKind: "external"
26583
+ }
26584
+ }, actor);
26585
+ const [columns, projection, runs] = await Promise.all([
26586
+ session.ops.listColumns(session.workspace.id),
26587
+ session.ops.readBoardProjectionForIssue(claimed.card.id),
26588
+ session.ops.listRuns(claimed.card.id)
26589
+ ]);
26590
+ const card = projectCard(claimed.card, columns, projection);
26591
+ for (const item of claimed.card.unmetRules ?? []) console.error(`kanbo: warning: ${card.id} entered "${claimed.column.name}" without ${item.rule}: ${item.detail}`);
26592
+ const run = projectRun({
26593
+ ...claimed.run,
26594
+ attempt: runs.find((each) => each.id === claimed.run.id)?.attempt ?? runs.length
26595
+ });
26596
+ return {
26597
+ value: {
26598
+ card,
26599
+ run,
26600
+ column: projectColumn(claimed.column, columns)
26601
+ },
26602
+ text: `Claimed ${card.id}: run ${run.id} started, card moved to ${claimed.column.name}\nWhen you are done: kanbo run finish ${run.id} --state finished`
26603
+ };
26604
+ });
26605
+ });
26606
+ }
26607
+ //#endregion
25929
26608
  //#region src/cli/card-extras.ts
25930
26609
  async function readCardExtras(session, card, columns, include, commentLimit) {
25931
26610
  const wants = new Set(include);
@@ -25981,7 +26660,7 @@ function describeCardExtras(extras) {
25981
26660
  })))].join("\n"));
25982
26661
  if (extras.comments && extras.comments.length > 0) {
25983
26662
  const heading = extras.commentCount === extras.comments.length ? "Comments:" : `Comments (last ${extras.comments.length} of ${extras.commentCount}):`;
25984
- sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${comment.author}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
26663
+ sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${sanitizeTerminalText(comment.by)}${comment.author === "user" ? " (person)" : ""}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
25985
26664
  }
25986
26665
  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"));
25987
26666
  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"));
@@ -26079,10 +26758,11 @@ function registerCardCommands(program) {
26079
26758
  }, createPlacementActor()));
26080
26759
  });
26081
26760
  });
26082
- 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.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) => {
26761
+ 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.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) => {
26083
26762
  await runBoardCommand(options, "write", async (session) => {
26084
26763
  const input = readUpdateInput(options);
26085
26764
  const existing = await requireCard(session, reference);
26765
+ if (options.parent !== void 0) input.parentIssueId = options.parent === "none" ? null : (await requireCard(session, options.parent)).id;
26086
26766
  return await presentCard(session, await session.ops.updateCard(existing.id, input, createPlacementActor()));
26087
26767
  });
26088
26768
  });
@@ -26223,9 +26903,10 @@ async function presentCard(session, card) {
26223
26903
  };
26224
26904
  }
26225
26905
  /**
26226
- * The fields `card update` was asked to change. A command that names none of
26227
- * them is refused rather than obeyed: an empty update still bumps the board
26228
- * version, and every reader would be told the board changed when it did not.
26906
+ * The fields `card update` was asked to change — all but the parent, a card
26907
+ * of the board, looked up there once the card itself is. A command that names
26908
+ * none of them is refused rather than obeyed: an empty update still bumps the
26909
+ * board version, and every reader would be told the board changed when it did not.
26229
26910
  */
26230
26911
  function readUpdateInput(options) {
26231
26912
  const input = {};
@@ -26234,7 +26915,7 @@ function readUpdateInput(options) {
26234
26915
  if (options.priority !== void 0) input.priority = options.priority;
26235
26916
  if (options.labels !== void 0) input.labels = options.labels;
26236
26917
  if (options.executionMode !== void 0) input.executionMode = options.executionMode;
26237
- if (Object.keys(input).length === 0) throw new CliError(1, "Nothing to update. Pass --title, --description, --priority, --labels or --execution-mode.");
26918
+ 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.");
26238
26919
  return input;
26239
26920
  }
26240
26921
  function parsePriority(value) {
@@ -26304,10 +26985,11 @@ const COLUMN_CATEGORIES = [
26304
26985
  ];
26305
26986
  /** The whole board's columns after a change, and the sentence that says what changed. */
26306
26987
  async function changed(session, message, extra = {}) {
26307
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
26988
+ const board = await session.ops.listColumns(session.workspace.id);
26989
+ const views = board.map((column) => projectColumn(column, board));
26308
26990
  return {
26309
26991
  value: {
26310
- ...extra,
26992
+ ...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, typeof value === "number" || Array.isArray(value) ? value : projectColumn(value, board)])),
26311
26993
  columns: views
26312
26994
  },
26313
26995
  text: `${message}\n\n${describeColumns(views)}`
@@ -26336,14 +27018,14 @@ async function addColumn(session, request, actor) {
26336
27018
  category: request.category ?? spec.category,
26337
27019
  position: anchor ? request.after !== void 0 ? { after: anchor.id } : { before: anchor.id } : void 0
26338
27020
  }, actor);
26339
- return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: projectColumn(created) });
27021
+ return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: created });
26340
27022
  }
26341
27023
  /** Said after a rename: agents' instructions name no column but To Do, and `kanbo prime` gives them the rest. */
26342
27024
  const RENAME_NOTE = "Agents read column names from kanbo prime; rename is picked up there.";
26343
27025
  async function renameColumn(session, column, newName, actor) {
26344
27026
  const before = await requireNamedColumn(session, column);
26345
27027
  const renamed = await session.ops.renameColumn(session.workspace.id, before.id, newName, actor);
26346
- return await changed(session, before.name === renamed.name ? `${renamed.name} already has that name.` : `Renamed ${before.name} to ${renamed.name} (slug ${normalizeStatusName(renamed.name)}).\n${RENAME_NOTE}`, { renamed: projectColumn(renamed) });
27028
+ return await changed(session, before.name === renamed.name ? `${renamed.name} already has that name.` : `Renamed ${before.name} to ${renamed.name} (slug ${normalizeStatusName(renamed.name)}).\n${RENAME_NOTE}`, { renamed });
26347
27029
  }
26348
27030
  /** `--first`, `--last`, `--before <column>` or `--after <column>` — exactly one. */
26349
27031
  function readColumnPosition(options) {
@@ -26375,7 +27057,7 @@ async function removeColumn(session, column, moveCardsTo, actor) {
26375
27057
  const result = await session.ops.removeColumn(session.workspace.id, removing.id, { moveCardsTo }, actor);
26376
27058
  for (const card of result.unmetRules ?? []) for (const item of card.unmet) console.error(`kanbo: warning: ${card.issueId} entered "${result.movedTo?.name ?? "—"}" without ${item.rule}: ${item.detail}`);
26377
27059
  return await changed(session, result.movedTo ? `Removed ${result.removed.name} and moved ${describeCount(result.movedCards)} to ${result.movedTo.name}.` : `Removed ${result.removed.name}.`, {
26378
- removed: projectColumn(result.removed),
27060
+ removed: result.removed,
26379
27061
  movedCards: result.movedCards
26380
27062
  });
26381
27063
  }
@@ -26499,7 +27181,7 @@ async function runColumnsMenu(ui) {
26499
27181
  if (showList) say$2(ui, [
26500
27182
  "",
26501
27183
  "Columns of this board:",
26502
- describeColumns(columns.map(projectColumn))
27184
+ describeColumns(columns.map((column) => projectColumn(column, columns)))
26503
27185
  ].join("\n"));
26504
27186
  let choice;
26505
27187
  try {
@@ -26727,7 +27409,8 @@ function registerColumnsCommands(program) {
26727
27409
  const columns = program.command("columns").description("See and change the board's columns (alone: a menu)");
26728
27410
  withBoardOptions(columns.command("list").description("List the columns in order, with the short name to move cards by")).action(async (options) => {
26729
27411
  await runBoardCommand(options, "read", async (session) => {
26730
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
27412
+ const board = await session.ops.listColumns(session.workspace.id);
27413
+ const views = board.map((column) => projectColumn(column, board));
26731
27414
  return {
26732
27415
  value: views,
26733
27416
  text: describeColumns(views)
@@ -26736,7 +27419,7 @@ function registerColumnsCommands(program) {
26736
27419
  });
26737
27420
  withBoardOptions(columns.command("describe").description("Say in one line when a card belongs in a column").argument("<column>", "the column, by slug, name or id").requiredOption("--text <text>", "what the column means")).action(async (column, options) => {
26738
27421
  await runBoardCommand(options, "write", async (session) => {
26739
- const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text));
27422
+ const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text), await session.ops.listColumns(session.workspace.id));
26740
27423
  return {
26741
27424
  value: view,
26742
27425
  text: describeColumns([view])
@@ -26747,13 +27430,34 @@ function registerColumnsCommands(program) {
26747
27430
  const actor = requireHumanActor("columns rules", typedWords(command));
26748
27431
  const rules = readRequestedRules(options);
26749
27432
  await runBoardCommand(options, "write", async (session) => {
26750
- const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor));
27433
+ const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor), await session.ops.listColumns(session.workspace.id));
26751
27434
  return {
26752
27435
  value: view,
26753
27436
  text: describeColumns([view])
26754
27437
  };
26755
27438
  });
26756
27439
  });
27440
+ withBoardOptions(columns.command("flow").description("Set which columns a card may come from, and how many cards a column holds").argument("<column>", "the column, by slug, name or id").option("--from <columns>", "comma-separated columns a card may enter it from, or any").option("--wip <count>", "the most cards it holds, or none")).action(async (column, options, command) => {
27441
+ const actor = requireHumanActor("columns flow", typedWords(command));
27442
+ if (options.from === void 0 && options.wip === void 0) throw new CliError(1, "Pass --from <columns|any>, --wip <count|none>, or both.");
27443
+ const wipLimit = options.wip === void 0 ? void 0 : readWipLimit(options.wip);
27444
+ await runBoardCommand(options, "write", async (session) => {
27445
+ const workspaceId = session.workspace.id;
27446
+ const board = await session.ops.listColumns(workspaceId);
27447
+ const target = await session.ops.requireColumn(workspaceId, column);
27448
+ const current = readFlowRules(target);
27449
+ await session.ops.setColumnFlowRules(target.id, {
27450
+ allowedFrom: options.from === void 0 ? current.allowedFrom : readAllowedFrom(board, workspaceId, options.from),
27451
+ wipLimit: wipLimit === void 0 ? current.wipLimit : wipLimit
27452
+ }, actor);
27453
+ const after = await session.ops.listColumns(workspaceId);
27454
+ const views = after.map((each) => projectColumn(each, after));
27455
+ return {
27456
+ value: views.find((view) => view.id === target.id),
27457
+ text: describeColumns(views)
27458
+ };
27459
+ });
27460
+ });
26757
27461
  withBoardOptions(columns.command("add").description("Add a column (before Done unless you say where)").argument("<name>", "what the column is called").option("--after <column>", "put it right after this column").option("--before <column>", "put it right before this column").option("--description <text>", "when a card belongs in it, in one line agents read").addOption(new Option("--category <category>", "what kind of column it is").choices(COLUMN_CATEGORIES))).action(async (name, options, command) => {
26758
27462
  const actor = requireHumanActor("columns add", typedWords(command));
26759
27463
  await runBoardCommand(options, "write", async (session) => await addColumn(session, {
@@ -26795,7 +27499,7 @@ function registerColumnsCommands(program) {
26795
27499
  mode: "add-missing",
26796
27500
  actor
26797
27501
  });
26798
- const views = statuses.map(projectColumn);
27502
+ const views = statuses.map((column) => projectColumn(column, statuses));
26799
27503
  return {
26800
27504
  value: {
26801
27505
  added,
@@ -26819,7 +27523,8 @@ function registerColumnsCommands(program) {
26819
27523
  return;
26820
27524
  }
26821
27525
  await runBoardCommand({}, "read", async (session) => {
26822
- const views = (await session.ops.listColumns(session.workspace.id)).map(projectColumn);
27526
+ const board = await session.ops.listColumns(session.workspace.id);
27527
+ const views = board.map((column) => projectColumn(column, board));
26823
27528
  return {
26824
27529
  value: views,
26825
27530
  text: describeColumns(views)
@@ -26849,6 +27554,26 @@ function readRequestedRules(options) {
26849
27554
  if (options.require === void 0) throw new CliError(1, `Pass --require ${ENTRY_RULES.join(",")} (any of them) or --clear.`);
26850
27555
  return options.require.split(",").map((rule) => rule.trim()).filter(Boolean);
26851
27556
  }
27557
+ /** `--from`: `any` for every column, else the columns named, by slug, name or id. */
27558
+ function readAllowedFrom(board, workspaceId, value) {
27559
+ if (value.trim().toLowerCase() === "any") return null;
27560
+ const names = value.split(",").map((name) => name.trim()).filter(Boolean);
27561
+ if (names.length === 0) throw new CliError(1, "Name the columns a card may come from (--from in_review,qa), or --from any.");
27562
+ return names.map((name) => {
27563
+ const found = matchColumn(board, workspaceId, name);
27564
+ if (!found) throw new BoardError("issue_status_not_found", {
27565
+ workspaceId,
27566
+ statusName: name
27567
+ });
27568
+ return found.id;
27569
+ });
27570
+ }
27571
+ /** `--wip`: `none` for no limit, else a whole number of cards, 1 or more. */
27572
+ function readWipLimit(value) {
27573
+ if (value.trim().toLowerCase() === "none") return null;
27574
+ if (!/^\d+$/.test(value.trim()) || Number(value) < 1) throw new CliError(1, "--wip takes a whole number of cards, 1 or more, or none.");
27575
+ return Number(value);
27576
+ }
26852
27577
  //#endregion
26853
27578
  //#region node_modules/zod/v3/helpers/util.js
26854
27579
  var util;
@@ -42791,7 +43516,7 @@ const INSTRUCTION_END_PATTERN = /<!-- KANBO_END\s*-->/g;
42791
43516
  const POINTER_KINDS = /* @__PURE__ */ new Set(["short", "global"]);
42792
43517
  /** The commands an agent uses, spelled once for the guide and the `kanbo prime` cheat sheet. */
42793
43518
  const COMMAND = {
42794
- ready: "kanbo ready",
43519
+ claim: "kanbo claim",
42795
43520
  move: "kanbo card move <id> <column>",
42796
43521
  statusLine: "kanbo card status-line <id> --text \"...\"",
42797
43522
  subtask: "kanbo card create --description \"...\" --parent <id>",
@@ -42810,7 +43535,7 @@ const AGENT_GUIDE_TEXT = [
42810
43535
  "Work on this project is tracked on a kanbo board. Before starting a task run `kanbo prime` — it prints the board's columns and what each one means.",
42811
43536
  "",
42812
43537
  ...[
42813
- `- Take a card from \`${COMMAND.ready}\`.`,
43538
+ `- Take a card with \`${COMMAND.claim}\`: it starts your run and moves the card into work in one step, and no other agent gets the same card.`,
42814
43539
  `- ${MOVE_CARD_RULE} (\`${COMMAND.move}\`)`,
42815
43540
  `- ${STATUS_LINE_RULE} (\`${COMMAND.statusLine}\`)`,
42816
43541
  `- ${SUBTASK_RULE} (\`${COMMAND.subtask}\`)`,
@@ -42838,13 +43563,15 @@ const COMMAND_SHEET = [
42838
43563
  "- `kanbo card status-line TST-5 --text \"Running the tests\"` — what is happening on the card right now",
42839
43564
  "- `kanbo card comment TST-5 --content \"Added slugify with a test\"` — a finding, result, decision or question",
42840
43565
  "- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
43566
+ "- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
42841
43567
  "- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
42842
- "- `kanbo run start TST-5 --agent claude` — record a run you started yourself; it prints the finish command",
43568
+ "- `kanbo claim` — take the first ready card: starts your run and moves it into work in one step (`kanbo claim TST-5` for that card); it prints the finish command",
43569
+ "- `kanbo run start TST-5 --agent claude` — record a run on a card you did not claim; it prints the finish command",
42843
43570
  "- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
42844
43571
  `- \`${COMMAND.capabilities}\` — everything else, rarely needed`
42845
43572
  ].join("\n");
42846
43573
  /** How to take work and who approves it — said by both blocks after their first sentence. */
42847
- const POINTER_TAIL = `Take work with \`${COMMAND.ready}\`. ${PERSON_ONLY_RULE}`;
43574
+ const POINTER_TAIL = `Take work with \`${COMMAND.claim}\`. ${PERSON_ONLY_RULE}`;
42848
43575
  /** The body of the project block: what `kanbo instructions short` prints. */
42849
43576
  const PROJECT_BODY = [
42850
43577
  "## Kanbo board",
@@ -42864,7 +43591,7 @@ const GLOBAL_BODY = [
42864
43591
  * The block, markers included, with this body's version and hash on the start
42865
43592
  * marker — and, when given, the kind of text it is (`k=orchestrator`).
42866
43593
  */
42867
- function wrapInstructionBlock(body, version = 2, kind) {
43594
+ function wrapInstructionBlock(body, version = 3, kind) {
42868
43595
  return `<!-- KANBO_START v${version}${kind ? ` k=${kind}` : ""} h=${hashBody(body)} -->\n${body}\n${INSTRUCTION_END}`;
42869
43596
  }
42870
43597
  /** The block `kanbo init --instructions …` writes into a project's own file. */
@@ -42893,7 +43620,7 @@ function inspectBlock(text, current) {
42893
43620
  kind
42894
43621
  });
42895
43622
  if (fields.trim() === "") return inspection(LEGACY_BLOCK_BODIES.includes(body) ? "legacy" : "edited");
42896
- if (version !== void 0 && Number(version) > 2) return inspection("newer");
43623
+ if (version !== void 0 && Number(version) > 3) return inspection("newer");
42897
43624
  if (kind !== null && !POINTER_KINDS.has(kind)) return inspection("chosen");
42898
43625
  if (version === void 0 || hash === void 0 || hashBody(body) !== hash) return inspection("edited");
42899
43626
  return inspection(normalized.slice(found.start, found.end) === current ? "current" : "outdated");
@@ -43965,7 +44692,7 @@ function startUpdateCheck(deps = {}) {
43965
44692
  setTimeout: globalThis.setTimeout,
43966
44693
  clearTimeout: globalThis.clearTimeout
43967
44694
  };
43968
- const current = deps.currentVersion ?? "0.3.0";
44695
+ const current = deps.currentVersion ?? "0.5.0";
43969
44696
  const controller = new AbortController();
43970
44697
  let found = null;
43971
44698
  const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
@@ -45287,6 +46014,7 @@ async function applyInitPlan(plan) {
45287
46014
  }
45288
46015
  if (plan.target.kind === "postgres" && plan.migrate) columns = await migrateSharedBoard(plan.target.url, plan.workspace.id, plan.columns);
45289
46016
  const bindingPath = writeBinding(plan.projectDir, plan.binding);
46017
+ rememberProjectQuietly(plan.projectDir);
45290
46018
  const files = plan.fileChanges.map(({ kind, change }) => ({
45291
46019
  kind,
45292
46020
  ...applyFileChange(change)
@@ -46034,7 +46762,7 @@ async function planAgents(projectDir, agents, options) {
46034
46762
  }
46035
46763
  /** The board this project is bound to, and the workspace on it. */
46036
46764
  async function resolveBoard(projectDir, options) {
46037
- if (options.file !== void 0 || namesNoBoard(projectDir, options)) return await resolveOwnFile(projectDir, options);
46765
+ if (options.file !== void 0 || namesNoBoard$1(projectDir, options)) return await resolveOwnFile(projectDir, options);
46038
46766
  const target = resolveInitTarget(projectDir, options);
46039
46767
  if (target.kind === "postgres") return {
46040
46768
  target,
@@ -46053,7 +46781,7 @@ async function resolveBoard(projectDir, options) {
46053
46781
  * does — a person on a machine with nothing else installed gets a board, not a
46054
46782
  * refusal that names a flag.
46055
46783
  */
46056
- function namesNoBoard(projectDir, options) {
46784
+ function namesNoBoard$1(projectDir, options) {
46057
46785
  return !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DB_PATH?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(projectDir) === null;
46058
46786
  }
46059
46787
  /** The workspace a host database — or the board file this project is already bound to — says this project is. */
@@ -46481,11 +47209,12 @@ const KANBO_MCP_INSTRUCTIONS = [
46481
47209
  "kanbo is a kanban board shared by people and agents; these tools read and change its cards.",
46482
47210
  "",
46483
47211
  `- At the start of a session call ${tool("kanbo_prime")} — it returns this board's columns and what each one means.`,
46484
- `- Take work from ${tool("kanbo_ready")}.`,
47212
+ `- Take work from ${tool("kanbo_ready")}, and claim the card with ${tool("kanbo_card_claim")} before you start: it starts your run and moves the card into work in one step, so no other agent takes it.`,
46485
47213
  `- ${MOVE_CARD_RULE} (${tool("kanbo_card_move")})`,
46486
47214
  `- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
46487
47215
  `- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
46488
47216
  `- ${SUBTASK_RULE} (${tool("kanbo_card_create")} with \`parent\`)`,
47217
+ `- A card at the wrong level is moved with ${tool("kanbo_card_update")} and \`parent\` (\`"none"\` for the top level).`,
46489
47218
  `- ${WAIT_FOR_PERSON_RULE} (${tool("kanbo_wait_approval")})`,
46490
47219
  `- ${PERSON_ONLY_RULE}`,
46491
47220
  `- Every tool, rule and canonical string: the resource \`${KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI}\`.`
@@ -46571,7 +47300,7 @@ const ORCHESTRATOR_GUIDE = [
46571
47300
  "## Start of every session",
46572
47301
  "1. Run `kanbo prime` (tool: `kanbo_prime`) and read the column descriptions. On this board the description is the contract, not the name.",
46573
47302
  "2. Run `kanbo ready` (`kanbo_ready`). If nothing is ready, say so and stop.",
46574
- "3. Take the first card. Run `kanbo run start <card> --agent orchestrator` (`kanbo_run_start`) and move it to the column this board uses for work in progress — `kanbo prime` lists the columns and what each is for — (`kanbo_card_move`) before doing anything else.",
47303
+ "3. Take the first card with `kanbo claim --agent orchestrator` (`kanbo_card_claim`): it starts your run and moves the card out of To Do into the board's first work-in-progress column in one step, so no other agent takes it, and it prints the run id you finish later. If another agent got there first, run `kanbo ready` again.",
46575
47304
  "",
46576
47305
  "## While working",
46577
47306
  "- Write a status line (`kanbo card status-line <card> --text \"…\"`, `kanbo_status_line`) at every step and before and after anything that takes more than a minute. One sentence, present tense.",
@@ -46598,7 +47327,7 @@ const ORCHESTRATOR_GUIDE = [
46598
47327
  "- Returned: the reason is the newest comment. The card is back in an earlier column; start a new run and address the reason first.",
46599
47328
  "",
46600
47329
  "## Never",
46601
- "- Never run `kanbo approve`, `kanbo return`, `kanbo sprint close`, `kanbo run clear-session`, or anything that changes the columns: `kanbo columns add`, `kanbo columns rename`, `kanbo columns move`, `kanbo columns remove`, `kanbo columns template`, `kanbo columns add-standard`, `kanbo columns rules`. They are for a person and will fail in your shell anyway.",
47330
+ "- Never run `kanbo approve`, `kanbo return`, `kanbo sprint close`, `kanbo run clear-session`, or anything that changes the columns: `kanbo columns add`, `kanbo columns rename`, `kanbo columns move`, `kanbo columns remove`, `kanbo columns template`, `kanbo columns add-standard`, `kanbo columns rules`, `kanbo columns flow`. They are for a person and will fail in your shell anyway.",
46602
47331
  "- Never delete or edit the board's database directly."
46603
47332
  ].join("\n");
46604
47333
  //#endregion
@@ -46643,7 +47372,7 @@ function registerInstructionsCommand(program, dependencies = {}) {
46643
47372
  const found = parseKind(kind);
46644
47373
  const body = await readInstructionText(found);
46645
47374
  const pointer = found === "short" || found === "global";
46646
- const text = options.markers ? wrapInstructionBlock(body, 2, pointer ? void 0 : found) : body;
47375
+ const text = options.markers ? wrapInstructionBlock(body, 3, pointer ? void 0 : found) : body;
46647
47376
  const hints = process.stderr.isTTY === true;
46648
47377
  if (hints) process.stderr.write(`${HEADER}\n\n`);
46649
47378
  console.log(text);
@@ -48076,7 +48805,8 @@ async function createDbTransport(input) {
48076
48805
  const workspaceId = workspace.id;
48077
48806
  const actor = input.actor;
48078
48807
  async function readColumns() {
48079
- return (await ops.listColumns(workspaceId)).map(projectKanboColumn);
48808
+ const rows = await ops.listColumns(workspaceId);
48809
+ return rows.map((row) => projectKanboColumn(row, rows));
48080
48810
  }
48081
48811
  /** One card with what the run registry says about it, in the shape a tool prints. */
48082
48812
  async function project(card, columns) {
@@ -48126,7 +48856,7 @@ async function createDbTransport(input) {
48126
48856
  * the waiting and returned ones last said at most two more.
48127
48857
  */
48128
48858
  async function projectPage(page, { attend = true } = {}) {
48129
- const columns = page.columns.map(projectKanboColumn);
48859
+ const columns = page.columns.map((column) => projectKanboColumn(column, page.columns));
48130
48860
  const runs = await ops.readBoardProjectionForIssues(page.cards.map((card) => card.id));
48131
48861
  const attention = attend ? await ops.readCardAttention(page.cards, runs.map((run) => run.activeRun !== null)) : [];
48132
48862
  return page.cards.map((card, index) => projectKanboCard({
@@ -48193,10 +48923,14 @@ async function createDbTransport(input) {
48193
48923
  executionMode
48194
48924
  }, actor));
48195
48925
  },
48196
- cardUpdate: async ({ card, ...patch }) => {
48926
+ cardUpdate: async ({ card, parent, ...patch }) => {
48197
48927
  await session.assertWritable();
48198
48928
  const existing = await resolveCard(card);
48199
- return await project(await ops.updateCard(existing.id, patch, actor));
48929
+ const placement = parent === void 0 ? {} : { parentIssueId: parent === null ? null : (await resolveCard(parent)).id };
48930
+ return await project(await ops.updateCard(existing.id, {
48931
+ ...patch,
48932
+ ...placement
48933
+ }, actor));
48200
48934
  },
48201
48935
  cardMove: async ({ card, column }) => {
48202
48936
  await session.assertWritable();
@@ -48254,6 +48988,25 @@ async function createDbTransport(input) {
48254
48988
  state,
48255
48989
  errorText
48256
48990
  }, actor));
48991
+ },
48992
+ cardClaim: async ({ card, agent }) => {
48993
+ await session.assertWritable();
48994
+ const named = card === void 0 ? void 0 : (await resolveCard(card)).id;
48995
+ const claimed = await ops.claimCard({
48996
+ workspaceId,
48997
+ issueId: named,
48998
+ run: {
48999
+ agentName: agent ?? actor.id ?? "agent",
49000
+ externalSessionRef: sessionRefFromEnvironment() ?? void 0,
49001
+ launchedByKind: "external"
49002
+ }
49003
+ }, actor);
49004
+ const columns = await readColumns();
49005
+ return {
49006
+ card: await project(claimed.card, columns),
49007
+ run: projectKanboRun(claimed.run),
49008
+ column: columns.find((column) => column.id === claimed.column.id)
49009
+ };
48257
49010
  }
48258
49011
  };
48259
49012
  }
@@ -48295,7 +49048,8 @@ const TRANSPORT_METHODS = {
48295
49048
  statusLine: true,
48296
49049
  waitApproval: true,
48297
49050
  runStart: true,
48298
- runFinish: true
49051
+ runFinish: true,
49052
+ cardClaim: true
48299
49053
  };
48300
49054
  async function openMcpBoard(input) {
48301
49055
  try {
@@ -48350,13 +49104,40 @@ function withUpdateNote(board, peek) {
48350
49104
  }
48351
49105
  //#endregion
48352
49106
  //#region src/cli/commands/mcp.ts
49107
+ /**
49108
+ * The actor of a `kanbo mcp` session, which is always an agent's: `KANBO_ACTOR_ID`,
49109
+ * else the agent tool, else `agent` until the client says its name (see
49110
+ * `nameActorAfterClient`).
49111
+ */
49112
+ function createMcpActor() {
49113
+ const tool = readAgentTool();
49114
+ const namedByShell = Boolean(process.env.KANBO_ACTOR_ID?.trim() || tool && tool !== "agent");
49115
+ const actor = createCliActor();
49116
+ if (!namedByShell) actor.id = "agent";
49117
+ return {
49118
+ actor,
49119
+ namedByShell
49120
+ };
49121
+ }
49122
+ /**
49123
+ * Files the session's writes under the client's own name (`Test Client` ->
49124
+ * `test-client`) once it has initialized. `actor` is the object the board
49125
+ * holds, so later writes see the new id.
49126
+ */
49127
+ function nameActorAfterClient(server, actor) {
49128
+ server.server.oninitialized = () => {
49129
+ const id = (server.server.getClientVersion()?.name)?.trim().toLowerCase().replace(/\s+/g, "-").slice(0, 40);
49130
+ if (id) actor.id = id;
49131
+ };
49132
+ }
48353
49133
  function registerMcpCommand(program) {
48354
49134
  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) => {
49135
+ const { actor, namedByShell } = createMcpActor();
48355
49136
  const board = await openMcpBoard({
48356
49137
  dbPath: options.db,
48357
49138
  databaseUrl: options.databaseUrl,
48358
49139
  workspaceId: options.workspace,
48359
- actor: createCliActor()
49140
+ actor
48360
49141
  });
48361
49142
  let closing;
48362
49143
  const close = () => closing ??= board.close().catch((error) => {
@@ -48365,6 +49146,7 @@ function registerMcpCommand(program) {
48365
49146
  const check = isUpdateCheckDisabled() ? null : startUpdateCheck();
48366
49147
  const server = createKanboMcpServer(check ? withUpdateNote(board, check.peek) : board, { includeRunTools: true });
48367
49148
  server.server.onclose = () => void close();
49149
+ if (!namedByShell) nameActorAfterClient(server, actor);
48368
49150
  try {
48369
49151
  await server.connect(new StdioServerTransport());
48370
49152
  } catch (error) {
@@ -48873,7 +49655,7 @@ function sendBoardPage(response, pathname, assets) {
48873
49655
  * answering for a person — approving a card, sending it back or closing a
48874
49656
  * sprint — as anyone but one is 403, dropping a pull request link somebody else made is the same 403
48875
49657
  * for the same reason, and acting on a run that has already ended is 409,
48876
- * because the answer would have been yes a moment earlier. An agent putting a
49658
+ * because the answer would have been yes a moment earlier. Starting a run on a card that has one going, and claiming a card nobody can take right now, are 409 for the same reason. An agent putting a
48877
49659
  * card in a column whose entry rules it does not meet yet is 409 for the same
48878
49660
  * reason turned around: the answer will be yes once the card meets them, and
48879
49661
  * `details.unmet` says what is missing. Deleting a column that still holds
@@ -48891,6 +49673,14 @@ const BOARD_ERROR_RESPONSES = {
48891
49673
  message: "Only a person can approve a card",
48892
49674
  code: "issue_approval_requires_user"
48893
49675
  },
49676
+ board_card_not_ready: {
49677
+ status: 409,
49678
+ message: "That card is not there to take: it is not in To Do, waits for a person, or has a run going"
49679
+ },
49680
+ board_card_run_active: {
49681
+ status: 409,
49682
+ message: "The card already has a run going"
49683
+ },
48894
49684
  board_column_name_taken: {
48895
49685
  status: 400,
48896
49686
  message: "Another column already has that name"
@@ -48930,6 +49720,14 @@ const BOARD_ERROR_RESPONSES = {
48930
49720
  status: 400,
48931
49721
  message: "Execution mode cannot change once the card has been launched"
48932
49722
  },
49723
+ board_flow_rule_invalid: {
49724
+ status: 400,
49725
+ message: "A column accepts cards from other columns of its own board, and holds at least one card"
49726
+ },
49727
+ board_nothing_ready: {
49728
+ status: 409,
49729
+ message: "No card in To Do is ready to take"
49730
+ },
48933
49731
  board_pull_request_invalid: {
48934
49732
  status: 400,
48935
49733
  message: "Not a GitHub pull request: give its URL or owner/repo#number"
@@ -49009,6 +49807,10 @@ const BOARD_ERROR_RESPONSES = {
49009
49807
  status: 404,
49010
49808
  message: "Issue not found"
49011
49809
  },
49810
+ issue_parent_cycle: {
49811
+ status: 400,
49812
+ message: "Issue cannot be put under one of its own sub-issues"
49813
+ },
49012
49814
  issue_parent_not_found: {
49013
49815
  status: 404,
49014
49816
  message: "Parent issue not found"
@@ -49341,6 +50143,14 @@ const startRunBody = object$1({
49341
50143
  ]).optional(),
49342
50144
  externalSessionRef: string().nullable().optional()
49343
50145
  });
50146
+ const claimBody = object$1({
50147
+ workspaceId: id,
50148
+ issueId: id.optional(),
50149
+ agentName: id.optional(),
50150
+ branch: string().nullable().optional(),
50151
+ executionMode: executionMode.optional(),
50152
+ externalSessionRef: string().nullable().optional()
50153
+ });
49344
50154
  const finishRunBody = object$1({
49345
50155
  state: _enum([
49346
50156
  "finished",
@@ -49387,13 +50197,24 @@ async function toWrittenCardView(ops, card) {
49387
50197
  function toStatusView(status) {
49388
50198
  return {
49389
50199
  ...status,
49390
- entryRules: readEntryRules(status)
50200
+ entryRules: readEntryRules(status),
50201
+ allowedFrom: readFlowRules(status).allowedFrom
49391
50202
  };
49392
50203
  }
49393
- function toCommentView(comment) {
50204
+ function toCommentView(comment, personWriterId = null) {
50205
+ if (comment.authorKind === "system.approved" || comment.authorKind === "system.returned") return {
50206
+ ...comment,
50207
+ author: {
50208
+ kind: "user",
50209
+ id: comment.authorId ?? "__self__",
50210
+ displayName: "You",
50211
+ avatarUrl: null,
50212
+ label: null
50213
+ }
50214
+ };
49394
50215
  return {
49395
50216
  ...comment,
49396
- author: resolveActor(commentAuthorReference(comment))
50217
+ author: resolveActor(commentAuthorReference(comment), personWriterId)
49397
50218
  };
49398
50219
  }
49399
50220
  /**
@@ -49401,23 +50222,34 @@ function toCommentView(comment) {
49401
50222
  *
49402
50223
  * For the kinds that need nobody else's tables: an outside writer is shown
49403
50224
  * under the name it gave, the board writing for itself is `kanbo`, and a person
49404
- * is `You`. An agent and a provider target are rows of whatever app launched
50225
+ * is `You`. So is an outside writer that is the person who started this
50226
+ * server (`personWriterId`): their own writes carry the name their shell files
50227
+ * them under. An agent and a provider target are rows of whatever app launched
49405
50228
  * them, which this server cannot read, so they are shown by the id the board
49406
50229
  * holds for them.
49407
50230
  */
49408
- function resolveActor(actor) {
50231
+ function resolveActor(actor, personWriterId) {
49409
50232
  if (actor.kind === "system") {
49410
50233
  const provenance = {
49411
50234
  authorId: actor.id,
49412
50235
  sourceChatSessionId: actor.sourceChatSessionId
49413
50236
  };
49414
- if (isOutsideWriter(provenance)) return {
49415
- kind: "system",
49416
- id: provenance.authorId,
49417
- displayName: provenance.authorId,
49418
- avatarUrl: null,
49419
- label: "External"
49420
- };
50237
+ if (isOutsideWriter(provenance)) {
50238
+ if (personWriterId !== null && provenance.authorId === personWriterId) return {
50239
+ kind: "user",
50240
+ id: provenance.authorId,
50241
+ displayName: "You",
50242
+ avatarUrl: null,
50243
+ label: null
50244
+ };
50245
+ return {
50246
+ kind: "system",
50247
+ id: provenance.authorId,
50248
+ displayName: provenance.authorId,
50249
+ avatarUrl: null,
50250
+ label: "External"
50251
+ };
50252
+ }
49421
50253
  return {
49422
50254
  kind: "system",
49423
50255
  id: null,
@@ -49453,13 +50285,13 @@ function resolveActor(actor) {
49453
50285
  * serves, named by its identity; actors as `resolveActor` reads them; and an
49454
50286
  * agent assignee by its id.
49455
50287
  */
49456
- function createActivityResolver(workspace) {
50288
+ function createActivityResolver(workspace, personWriterId = null) {
49457
50289
  return {
49458
50290
  workspaceById: /* @__PURE__ */ new Map([[workspace.id, {
49459
50291
  id: workspace.id,
49460
50292
  name: workspace.name
49461
50293
  }]]),
49462
- resolveActor: async (actor) => resolveActor(actor),
50294
+ resolveActor: async (actor) => resolveActor(actor, personWriterId),
49463
50295
  resolveAgentName: async (agentId) => agentId
49464
50296
  };
49465
50297
  }
@@ -49480,10 +50312,15 @@ function createActivityResolver(workspace) {
49480
50312
  const OK = { ok: true };
49481
50313
  /** How many cards a search answers with when the caller names no limit. */
49482
50314
  const DEFAULT_SEARCH_LIMIT = 20;
49483
- function createIssueRoutes(session) {
50315
+ /**
50316
+ * `personWriterId` is the id the person who started the server is filed under;
50317
+ * their writes read as `You`, everyone else's by name.
50318
+ */
50319
+ function createIssueRoutes(session, options = {}) {
50320
+ const personWriterId = options.personWriterId ?? null;
49484
50321
  const { ops, store, workspace } = session;
49485
50322
  const workspaceId = workspace.id;
49486
- const activityResolver = createActivityResolver(workspace);
50323
+ const activityResolver = createActivityResolver(workspace, personWriterId);
49487
50324
  /** The served workspace, when that is the one the caller named — or named none. */
49488
50325
  function requireWorkspace(named) {
49489
50326
  if (named !== void 0 && named !== workspaceId) throw new BoardError("issue_workspace_not_found", { workspaceId: named });
@@ -49768,6 +50605,15 @@ function createIssueRoutes(session) {
49768
50605
  return updated;
49769
50606
  }
49770
50607
  }),
50608
+ route({
50609
+ method: "GET",
50610
+ path: "/issues/workspace",
50611
+ handle: async () => ({
50612
+ id: workspace.id,
50613
+ identifier: workspace.identifier,
50614
+ name: workspace.name
50615
+ })
50616
+ }),
49771
50617
  route({
49772
50618
  method: "GET",
49773
50619
  path: "/issues/:id",
@@ -49787,6 +50633,31 @@ function createIssueRoutes(session) {
49787
50633
  }, actors.placement));
49788
50634
  }
49789
50635
  }),
50636
+ route({
50637
+ method: "POST",
50638
+ path: "/issues/claim",
50639
+ body: claimBody,
50640
+ write: true,
50641
+ handle: async ({ body, actors }) => {
50642
+ const named = requireWorkspace(body.workspaceId);
50643
+ if (body.issueId !== void 0) await requireCard(body.issueId);
50644
+ const claimed = await ops.claimCard({
50645
+ workspaceId: named,
50646
+ issueId: body.issueId,
50647
+ run: {
50648
+ agentName: body.agentName ?? actors.writer.id ?? "agent",
50649
+ branch: body.branch,
50650
+ executionMode: body.executionMode,
50651
+ externalSessionRef: body.externalSessionRef
50652
+ }
50653
+ }, actors.placement);
50654
+ return {
50655
+ card: await toWrittenCardView(ops, claimed.card),
50656
+ run: await toRunView(claimed.run),
50657
+ column: toStatusView(claimed.column)
50658
+ };
50659
+ }
50660
+ }),
49790
50661
  route({
49791
50662
  method: "PATCH",
49792
50663
  path: "/issues/bulk",
@@ -49921,7 +50792,7 @@ function createIssueRoutes(session) {
49921
50792
  path: "/issues/:id/comments",
49922
50793
  handle: async ({ params }) => {
49923
50794
  await requireCard(params.id);
49924
- return (await ops.listComments(params.id)).map(toCommentView);
50795
+ return (await ops.listComments(params.id)).map((comment) => toCommentView(comment, personWriterId));
49925
50796
  }
49926
50797
  }),
49927
50798
  route({
@@ -49934,7 +50805,7 @@ function createIssueRoutes(session) {
49934
50805
  return toCommentView(await ops.addComment({
49935
50806
  issueId: params.id,
49936
50807
  content: body.content
49937
- }, actors.writer));
50808
+ }, actors.person), personWriterId);
49938
50809
  }
49939
50810
  }),
49940
50811
  route({
@@ -50063,7 +50934,7 @@ function assertServeBinding(host, token) {
50063
50934
  }
50064
50935
  async function startKanboServer(options) {
50065
50936
  assertServeBinding(options.host, options.token);
50066
- const routes = createIssueRoutes(options.session);
50937
+ const routes = createIssueRoutes(options.session, { personWriterId: options.actors.writer.id });
50067
50938
  const listenHost = stripBrackets(options.host);
50068
50939
  let boundPort = 0;
50069
50940
  const handle = createRequestHandler(routes, options, () => boundPort);
@@ -50109,6 +50980,11 @@ const SAFE_METHODS = /* @__PURE__ */ new Set([
50109
50980
  "HEAD",
50110
50981
  "OPTIONS"
50111
50982
  ]);
50983
+ /** What the board page tells the server about itself. */
50984
+ const PRESENCE_BODY = object$1({
50985
+ tabId: string().min(1).max(64).regex(/^[A-Za-z0-9-]+$/),
50986
+ state: _enum(["open", "closed"])
50987
+ });
50112
50988
  function createRequestHandler(routes, options, boundPort) {
50113
50989
  const token = options.token || null;
50114
50990
  const corsOrigins = new Set(options.corsOrigins ?? []);
@@ -50169,17 +51045,41 @@ function createRequestHandler(routes, options, boundPort) {
50169
51045
  throw new ServeError(401, "unauthorized", "A valid bearer token is required");
50170
51046
  }
50171
51047
  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");
50172
- const matched = matchRoute(routes, request.method ?? "GET", url.pathname);
50173
- if (!matched) throw new ServeError(404, "not_found", "Not Found");
51048
+ if (url.pathname === "/page/presence" && request.method === "POST") {
51049
+ if (!options.presence) {
51050
+ response.writeHead(204).end();
51051
+ return;
51052
+ }
51053
+ const parsed = PRESENCE_BODY.safeParse(await readJsonBody(request));
51054
+ if (!parsed.success) throw validationError("body", parsed.error);
51055
+ if (parsed.data.state === "open") options.presence.seen(parsed.data.tabId);
51056
+ else options.presence.closed(parsed.data.tabId);
51057
+ sendJson(response, 200, { ok: true });
51058
+ return;
51059
+ }
51060
+ const projectPath = readProjectPath(url.pathname);
51061
+ if (url.pathname === "/projects" && request.method === "GET" && options.projects) {
51062
+ sendJson(response, 200, options.projects.list());
51063
+ return;
51064
+ }
51065
+ const matched = projectPath ? null : matchRoute(routes, request.method ?? "GET", url.pathname);
51066
+ if (!projectPath && !matched) throw new ServeError(404, "not_found", "Not Found");
51067
+ if (projectPath && !options.projects) throw new ServeError(404, "not_found", "Not Found");
50174
51068
  const body = await readJsonBody(request);
50175
51069
  const actors = resolveRequestActors(options.actors, {
50176
51070
  actorHeader: request.headers[ACTOR_HEADER],
50177
51071
  authenticated: token !== null
50178
51072
  });
50179
51073
  const result = queue.then(async () => {
50180
- if (matched.route.write) await options.session.assertWritable();
50181
- return await matched.route.run({
50182
- params: matched.params,
51074
+ const target = projectPath && options.projects ? await options.projects.resolve(projectPath.projectId) : {
51075
+ session: options.session,
51076
+ routes
51077
+ };
51078
+ const found = matched ?? matchRoute(target.routes, request.method ?? "GET", projectPath?.rest ?? url.pathname);
51079
+ if (!found) throw new ServeError(404, "not_found", "Not Found");
51080
+ if (found.route.write) await target.session.assertWritable();
51081
+ return await found.route.run({
51082
+ params: found.params,
50183
51083
  query: readQuery(url.searchParams),
50184
51084
  body,
50185
51085
  actors
@@ -50205,6 +51105,21 @@ function createRequestHandler(routes, options, boundPort) {
50205
51105
  }
50206
51106
  };
50207
51107
  }
51108
+ /** `/projects/<id>/issues/...` as the project's id and the `/issues/...` path under it; `null` for any other path. */
51109
+ function readProjectPath(pathname) {
51110
+ const segments = pathname.split("/").filter(Boolean);
51111
+ if (segments[0] !== "projects" || segments.length < 3 || segments[2] !== "issues") return null;
51112
+ let projectId;
51113
+ try {
51114
+ projectId = decodeURIComponent(segments[1]);
51115
+ } catch {
51116
+ return null;
51117
+ }
51118
+ return {
51119
+ projectId,
51120
+ rest: `/${segments.slice(2).join("/")}`
51121
+ };
51122
+ }
50208
51123
  function isJsonContentType(value) {
50209
51124
  return value?.split(";")[0].trim().toLowerCase() === "application/json";
50210
51125
  }
@@ -50348,6 +51263,146 @@ function readTextOrNull(path) {
50348
51263
  }
50349
51264
  }
50350
51265
  //#endregion
51266
+ //#region src/serve/page-presence.ts
51267
+ function createPagePresence(options) {
51268
+ const closedGraceMs = options.closedGraceMs ?? 5e3;
51269
+ const staleMs = options.staleMs ?? 12e4;
51270
+ const tickMs = options.tickMs ?? 1e4;
51271
+ const now = options.now ?? Date.now;
51272
+ const tabs = /* @__PURE__ */ new Map();
51273
+ let finished = false;
51274
+ let graceTimer = null;
51275
+ let lastTick = now();
51276
+ function stop() {
51277
+ finished = true;
51278
+ clearInterval(ticker);
51279
+ if (graceTimer) {
51280
+ clearTimeout(graceTimer);
51281
+ graceTimer = null;
51282
+ }
51283
+ }
51284
+ function idle() {
51285
+ if (finished) return;
51286
+ stop();
51287
+ options.onIdle();
51288
+ }
51289
+ const ticker = setInterval(() => {
51290
+ const current = now();
51291
+ const asleep = current - lastTick > 3 * tickMs;
51292
+ lastTick = current;
51293
+ if (asleep) {
51294
+ for (const id of tabs.keys()) tabs.set(id, current);
51295
+ return;
51296
+ }
51297
+ if (tabs.size === 0) return;
51298
+ for (const [id, lastSeen] of tabs) if (current - lastSeen > staleMs) tabs.delete(id);
51299
+ if (tabs.size === 0) idle();
51300
+ }, tickMs);
51301
+ ticker.unref();
51302
+ return {
51303
+ seen: (tabId) => {
51304
+ if (!finished) tabs.set(tabId, now());
51305
+ },
51306
+ closed: (tabId) => {
51307
+ if (finished || !tabs.delete(tabId) || tabs.size > 0) return;
51308
+ if (graceTimer) clearTimeout(graceTimer);
51309
+ graceTimer = setTimeout(() => {
51310
+ graceTimer = null;
51311
+ if (tabs.size === 0) idle();
51312
+ }, closedGraceMs);
51313
+ graceTimer.unref();
51314
+ },
51315
+ stop
51316
+ };
51317
+ }
51318
+ //#endregion
51319
+ //#region src/serve/projects.ts
51320
+ function createServeProjects(input) {
51321
+ const routesOptions = { personWriterId: input.personWriterId ?? null };
51322
+ const opened = /* @__PURE__ */ new Map();
51323
+ let defaultOpened = null;
51324
+ /** Every project that can be offered right now, default first, with the folder each lives in. */
51325
+ function entries() {
51326
+ const defaultDir = input.defaultProject?.dir ?? null;
51327
+ const dirs = [...defaultDir === null ? [] : [defaultDir], ...input.readRegistry().map((project) => project.path).filter((path) => path !== defaultDir)];
51328
+ const found = [];
51329
+ for (const dir of dirs) {
51330
+ const binding = input.readBinding(dir);
51331
+ if (binding === null && dir !== defaultDir) continue;
51332
+ found.push({
51333
+ view: viewOf(dir, binding, dir === defaultDir),
51334
+ dir
51335
+ });
51336
+ }
51337
+ return found;
51338
+ }
51339
+ return {
51340
+ list: () => entries().map((entry) => entry.view),
51341
+ resolve: async (projectId) => {
51342
+ const entry = entries().find((candidate) => candidate.view.id === projectId);
51343
+ if (!entry) throw new ServeError(404, "project_not_found", "No project with that id is on this computer's list");
51344
+ if (entry.view.isDefault && input.defaultProject) {
51345
+ defaultOpened ??= {
51346
+ session: input.defaultProject.session,
51347
+ routes: createIssueRoutes(input.defaultProject.session, routesOptions)
51348
+ };
51349
+ return defaultOpened;
51350
+ }
51351
+ const cached = opened.get(projectId);
51352
+ if (cached) return cached;
51353
+ let session;
51354
+ try {
51355
+ session = await input.openSession(entry.dir);
51356
+ } catch (error) {
51357
+ throw new ServeError(503, "project_unavailable", describeFailure(error).message);
51358
+ }
51359
+ const project = {
51360
+ session,
51361
+ routes: createIssueRoutes(session, routesOptions)
51362
+ };
51363
+ opened.set(projectId, project);
51364
+ return project;
51365
+ },
51366
+ close: async () => {
51367
+ const sessions = [...opened.values()].map((project) => project.session);
51368
+ opened.clear();
51369
+ await Promise.allSettled(sessions.map(async (session) => await session.close()));
51370
+ }
51371
+ };
51372
+ }
51373
+ function projectId(dir) {
51374
+ return (0, node_crypto.createHash)("sha256").update(dir).digest("hex").slice(0, 12);
51375
+ }
51376
+ function viewOf(dir, binding, isDefault) {
51377
+ return {
51378
+ id: projectId(dir),
51379
+ name: dir.split(/[\\/]/).filter(Boolean).pop() ?? dir,
51380
+ folder: tildify(dir),
51381
+ storage: storageOf(dir, binding),
51382
+ isDefault
51383
+ };
51384
+ }
51385
+ /** Where the board lives. The connection string is read here to name its host, and goes no further. */
51386
+ function storageOf(dir, binding) {
51387
+ if (binding?.databaseUrl) return {
51388
+ kind: "postgres",
51389
+ where: hostOf(maskDatabaseUrl(binding.databaseUrl))
51390
+ };
51391
+ return {
51392
+ kind: "file",
51393
+ path: tildify((0, node_path.resolve)(dir, binding?.dbPath ?? ".kanbo/board.db"))
51394
+ };
51395
+ }
51396
+ /** The host of an already masked connection string; a string that is not one names no host. */
51397
+ function hostOf(maskedUrl) {
51398
+ try {
51399
+ const { host } = new URL(maskedUrl);
51400
+ return host && !host.includes("*") ? host : "a Postgres database";
51401
+ } catch {
51402
+ return "a Postgres database";
51403
+ }
51404
+ }
51405
+ //#endregion
50351
51406
  //#region src/cli/commands/serve.ts
50352
51407
  function registerServeCommand(program) {
50353
51408
  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) => {
@@ -50355,7 +51410,7 @@ function registerServeCommand(program) {
50355
51410
  status: (line) => console.error(line),
50356
51411
  page: (line) => console.log(line)
50357
51412
  });
50358
- await waitForInterrupt();
51413
+ if (await waitForStop(running.idle) === "idle") console.error("kanbo serve: the last board tab was closed — stopped.");
50359
51414
  try {
50360
51415
  await running.close();
50361
51416
  process.exit(0);
@@ -50367,26 +51422,46 @@ function registerServeCommand(program) {
50367
51422
  }
50368
51423
  /**
50369
51424
  * Open the board and serve it — everything `kanbo serve` does but wait. The
50370
- * command waits for Ctrl-C and ends the process; the home menu waits for
50371
- * Ctrl-C and comes back to its menu.
51425
+ * command waits for Ctrl-C (or the last board tab closing) and ends the
51426
+ * process; the home menu waits the same and comes back to its menu.
50372
51427
  */
50373
51428
  async function startServe(options, output) {
50374
51429
  const port = parsePort(options.port);
50375
51430
  const access = resolveServeAccess(options.host, options.token?.trim() || process.env.KANBO_SERVE_TOKEN?.trim() || null);
51431
+ const here = findBinding(process.cwd());
51432
+ const mostRecent = here === null && namesNoBoard(options) ? readProjectRegistry()[0] : void 0;
51433
+ const defaultDir = here?.projectDir ?? mostRecent?.path ?? null;
50376
51434
  const session = await openBoardSession({
50377
51435
  db: options.db,
50378
51436
  databaseUrl: options.databaseUrl,
50379
51437
  workspace: options.workspace
50380
- }, "read");
51438
+ }, "read", mostRecent?.path);
50381
51439
  const agentShell = isAgentShell();
50382
51440
  const actors = {
50383
- writer: createCliActor(),
51441
+ writer: createServeWriter(),
50384
51442
  person: agentShell ? null : createPersonActor()
50385
51443
  };
51444
+ const projects = createServeProjects({
51445
+ defaultProject: defaultDir === null ? null : {
51446
+ dir: defaultDir,
51447
+ session
51448
+ },
51449
+ readRegistry: () => readProjectRegistry(),
51450
+ readBinding: (dir) => readProjectBinding(dir),
51451
+ openSession: async (dir) => await openBoardSession({}, "read", dir),
51452
+ personWriterId: actors.writer.id
51453
+ });
51454
+ const opensBrowser = !agentShell && process.stdout.isTTY === true && options.open;
51455
+ let presence;
51456
+ const idle = new Promise((resolve) => {
51457
+ presence = opensBrowser ? createPagePresence({ onIdle: resolve }) : void 0;
51458
+ });
50386
51459
  let server;
50387
51460
  try {
50388
51461
  server = await startKanboServer({
50389
51462
  session,
51463
+ projects,
51464
+ presence,
50390
51465
  actors,
50391
51466
  host: options.host,
50392
51467
  port,
@@ -50394,10 +51469,12 @@ async function startServe(options, output) {
50394
51469
  corsOrigins: options.corsOrigin
50395
51470
  });
50396
51471
  } catch (error) {
51472
+ presence?.stop();
50397
51473
  await session.close();
50398
51474
  throw error;
50399
51475
  }
50400
- output.status(`kanbo serve: ${server.url} — workspace ${session.workspace.id}, bearer token required${access.generated ? " (generated for this run)" : ""}`);
51476
+ const projectCount = projects.list().length;
51477
+ output.status(`kanbo serve: ${server.url} — workspace ${session.workspace.id}, ${projectCount} ${projectCount === 1 ? "project" : "projects"}, bearer token required${access.generated ? " (generated for this run)" : ""}`);
50401
51478
  output.status(`kanbo serve: ${describePersonRights(actors)}`);
50402
51479
  presentServePage({
50403
51480
  url: server.url,
@@ -50411,15 +51488,25 @@ async function startServe(options, output) {
50411
51488
  });
50412
51489
  return {
50413
51490
  link: access.generated ? `${server.url}/#token=${access.token}` : `${server.url}/`,
51491
+ idle,
50414
51492
  close: async () => {
51493
+ presence?.stop();
50415
51494
  try {
50416
51495
  await server.close();
50417
51496
  } finally {
50418
- await session.close();
51497
+ try {
51498
+ await projects.close();
51499
+ } finally {
51500
+ await session.close();
51501
+ }
50419
51502
  }
50420
51503
  }
50421
51504
  };
50422
51505
  }
51506
+ /** Whether neither a flag nor the environment says which board to open. */
51507
+ function namesNoBoard(options) {
51508
+ return !options.db && !options.databaseUrl && !options.workspace && !process.env.KANBO_DB_PATH && !process.env.KANBO_DATABASE_URL && !process.env.KANBO_WORKSPACE_ID;
51509
+ }
50423
51510
  /** What `kanbo serve` with no options is told — the home menu serves the board this way. */
50424
51511
  const DEFAULT_SERVE_OPTIONS = {
50425
51512
  port: String(DEFAULT_SERVE_PORT),
@@ -50427,16 +51514,21 @@ const DEFAULT_SERVE_OPTIONS = {
50427
51514
  corsOrigin: [],
50428
51515
  open: true
50429
51516
  };
50430
- /** Settles on the first Ctrl-C (SIGINT) or SIGTERM, and stops listening for both. */
50431
- function waitForInterrupt() {
51517
+ /**
51518
+ * Settles on the first Ctrl-C (SIGINT) or SIGTERM, or when `idle` settles, and
51519
+ * stops listening for both signals whichever it was.
51520
+ */
51521
+ function waitForStop(idle) {
50432
51522
  return new Promise((resolve) => {
50433
- const stop = () => {
50434
- process.off("SIGINT", stop);
50435
- process.off("SIGTERM", stop);
50436
- resolve();
51523
+ const finish = (why) => {
51524
+ process.off("SIGINT", onSignal);
51525
+ process.off("SIGTERM", onSignal);
51526
+ resolve(why);
50437
51527
  };
50438
- process.on("SIGINT", stop);
50439
- process.on("SIGTERM", stop);
51528
+ const onSignal = () => finish("interrupt");
51529
+ process.on("SIGINT", onSignal);
51530
+ process.on("SIGTERM", onSignal);
51531
+ idle.then(() => finish("idle"));
50440
51532
  });
50441
51533
  }
50442
51534
  function collect(value, previous) {
@@ -50707,6 +51799,7 @@ function registerKanboCommands(program, context = {}) {
50707
51799
  registerServeCommand(program);
50708
51800
  program.commandsGroup("For agents and integrations:");
50709
51801
  registerMcpCommand(program);
51802
+ registerClaimCommand(program);
50710
51803
  registerRunCommands(program);
50711
51804
  registerCapabilitiesCommand(program);
50712
51805
  program.commandsGroup("Shared Postgres boards:");
@@ -50720,7 +51813,7 @@ const SERVE_FROM_MENU = {
50720
51813
  status: (line) => console.error(line),
50721
51814
  page: () => {}
50722
51815
  }),
50723
- waitForStop: waitForInterrupt
51816
+ waitForStop: (running) => waitForStop(running.idle)
50724
51817
  };
50725
51818
  /** The `--check` instruction cells that mean a kanbo section is there and readable. */
50726
51819
  const INSTRUCTION_BLOCK_PRESENT = /* @__PURE__ */ new Set([
@@ -50882,7 +51975,7 @@ async function serveUntilStopped(context) {
50882
51975
  const running = await start();
50883
51976
  try {
50884
51977
  say(context.ui, `Board open at ${running.link} — press Ctrl-C to stop and return to the menu`);
50885
- await waitForStop();
51978
+ await waitForStop(running);
50886
51979
  } finally {
50887
51980
  await running.close();
50888
51981
  }