kanbo-cli 0.6.0 → 0.8.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.
@@ -6,7 +6,7 @@ import { z } from "zod";
6
6
  import { homedir, hostname, userInfo } from "node:os";
7
7
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
8
8
  import { sql } from "drizzle-orm";
9
- import { accessSync, chmodSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
9
+ import { accessSync, chmodSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
10
10
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
11
  import { drizzle } from "drizzle-orm/postgres-js";
12
12
  import { migrate } from "drizzle-orm/postgres-js/migrator";
@@ -257,14 +257,9 @@ const describeSprintCloseRefusal = refusal("close a sprint", "close it");
257
257
  const describeColumnRulesRefusal = refusal("set what a column asks of a card", "set it");
258
258
  /**
259
259
  * The same, for changing the board's columns: every agent reads its work from
260
- * them, and `kanbo ready` from To Do.
260
+ * them, and `kanbo_ready` from To Do.
261
261
  */
262
262
  const describeColumnChangesRefusal = refusal("change the board's columns", "change them");
263
- /**
264
- * The same, for taking back the log a run names: an agent that could clear or
265
- * swap it could point a card's history at any log it liked.
266
- */
267
- const describeRunSessionRefRefusal = refusal("change or clear the log a run names", "do it");
268
263
  /** The commands only a person may run, as typed after `kanbo` — the capabilities manifest reads this rather than naming them again. */
269
264
  const HUMAN_ONLY_ACTIONS = [
270
265
  "approve",
@@ -277,8 +272,7 @@ const HUMAN_ONLY_ACTIONS = [
277
272
  "columns move",
278
273
  "columns remove",
279
274
  "columns template",
280
- "columns add-standard",
281
- "run clear-session"
275
+ "columns add-standard"
282
276
  ];
283
277
  const HUMAN_ONLY_MESSAGES = {
284
278
  "approve": describeApprovalRefusal,
@@ -291,9 +285,7 @@ const HUMAN_ONLY_MESSAGES = {
291
285
  "columns move": describeColumnChangesRefusal,
292
286
  "columns remove": describeColumnChangesRefusal,
293
287
  "columns template": describeColumnChangesRefusal,
294
- "columns add-standard": describeColumnChangesRefusal,
295
- "run clear-session": describeRunSessionRefRefusal,
296
- "run attach-session --replace": describeRunSessionRefRefusal
288
+ "columns add-standard": describeColumnChangesRefusal
297
289
  };
298
290
  /**
299
291
  * The actor every board write from this tool is filed under: `KANBO_ACTOR_ID`,
@@ -312,7 +304,7 @@ function createCliActor() {
312
304
  * is none.
313
305
  *
314
306
  * Approving, returning, closing a sprint, setting a column's entry rules,
315
- * changing the board's columns and taking back the log a run names are the
307
+ * changing the board's columns are the
316
308
  * things only a person may do, and a shell
317
309
  * that says it belongs to an agent is not one. `KANBO_ACTOR_KIND=person` overrides
318
310
  * the marks — for a person's own terminal that an agent tool marked — so this
@@ -5440,8 +5432,6 @@ function canonicalPath(path) {
5440
5432
  }
5441
5433
  //#endregion
5442
5434
  //#region src/cli/command.ts
5443
- /** The two modes a card can be worked in, as `issues.execution_mode` spells them. */
5444
- const EXECUTION_MODES = ["worktree", "main"];
5445
5435
  /** Add the options that name a board and shape the answer, wherever the command sits in the tree. */
5446
5436
  function withTargetOptions(command) {
5447
5437
  return command.option("--db <path>", "the board file to open").option("--database-url <url>", "a shared Postgres board to use instead of a board file").option("--json [fields]", "print the result as JSON; name comma-separated fields to print only those").option("--format <format>", "print JSON: json (one line) or pretty (indented)");
@@ -5465,11 +5455,28 @@ async function openBoardSession(options, mode, cwd = process.cwd()) {
5465
5455
  asAgent: options.asAgent,
5466
5456
  cwd
5467
5457
  });
5468
- const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
5458
+ const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd).catch((error) => {
5459
+ throw describeEnvironmentOnlyFailure(error, target, options, cwd);
5460
+ });
5469
5461
  const found = findBinding(cwd);
5470
5462
  if (found) rememberProjectQuietly(found.projectDir);
5471
5463
  return session;
5472
5464
  }
5465
+ /**
5466
+ * A folder with no board of its own that only `KANBO_DB_PATH` points at an
5467
+ * app's database: when that database is older than this kanbo, or knows no
5468
+ * project for the folder, the folder is told it has no board, not that "the
5469
+ * app's database" needs updating — the person never chose that database. A
5470
+ * flag or a binding that names the database keeps the app-centred sentence.
5471
+ */
5472
+ function describeEnvironmentOnlyFailure(error, target, options, cwd) {
5473
+ if (!(target.owner !== "kanbo" && !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(cwd) === null) || !(error instanceof CliError)) return error;
5474
+ let reason;
5475
+ if (error.exitCode === 3 && error.message === "The app's database is older than this kanbo. Let the app it belongs to update it first.") reason = "is older than this kanbo";
5476
+ else if (error.exitCode === 2 && error.message === "Could not tell which project this is. Pass --workspace <name|id>, or run \"kanbo init\" in the project folder.") reason = "has no project for this folder";
5477
+ else return error;
5478
+ return new CliError(error.exitCode, `This folder has no kanbo board of its own. KANBO_DB_PATH points at another app's database (${target.path}), which ${reason}. To give this folder its own board, run kanbo init here.`);
5479
+ }
5473
5480
  async function openFileSession(target, options, mode, cwd) {
5474
5481
  const board = await openBoardDatabase(target.path);
5475
5482
  try {
@@ -5547,18 +5554,6 @@ async function runBoardCommand(options, mode, body) {
5547
5554
  await session.close();
5548
5555
  }
5549
5556
  }
5550
- /** Where a card's work happens. A caller naming anything else is refused, not corrected. */
5551
- function parseExecutionMode(value) {
5552
- const mode = EXECUTION_MODES.find((candidate) => candidate === value);
5553
- if (!mode) throw new CliError(1, `Unknown execution mode "${value}". Use ${EXECUTION_MODES.join(" or ")}.`);
5554
- return mode;
5555
- }
5556
- /** A count a caller typed, refused rather than quietly rounded when it is not one. */
5557
- function parseCount(value) {
5558
- const count = Number(value);
5559
- if (!Number.isInteger(count) || count <= 0) throw new CliError(1, `Expected a positive whole number, got "${value}".`);
5560
- return count;
5561
- }
5562
5557
  /**
5563
5558
  * A date as the command line takes it: `YYYY-MM-DD`, read as a UTC day, or a
5564
5559
  * unix-seconds number taken as it is. `end` turns a day into its last second,
@@ -5589,7 +5584,7 @@ async function requireCard(session, reference) {
5589
5584
  if (byId) return byId;
5590
5585
  const number = readCardNumber(session, trimmed);
5591
5586
  const byNumber = number === null ? null : await session.store.issues.findByNumber(session.workspace.id, number);
5592
- if (!byNumber) throw new CliError(2, `No card "${reference}" on this board.\n Next: kanbo card list`);
5587
+ if (!byNumber) throw new CliError(2, `No card "${reference}" on this board.\n Next: kanbo board`);
5593
5588
  return byNumber;
5594
5589
  }
5595
5590
  /**
@@ -5717,7 +5712,7 @@ function isWide(codePoint) {
5717
5712
  }
5718
5713
  //#endregion
5719
5714
  //#region src/cli/view.ts
5720
- /** Every field `kanbo card list|get --json` may ask for, in listing order. */
5715
+ /** Every field `--json` may ask of a card, in listing order. */
5721
5716
  const CARD_VIEW_FIELDS = [
5722
5717
  "id",
5723
5718
  "number",
@@ -5737,16 +5732,6 @@ const CARD_VIEW_FIELDS = [
5737
5732
  "returned",
5738
5733
  "lastComment"
5739
5734
  ];
5740
- /** Every field `kanbo card get --json` may ask for: a card's, and the parts `--include` reads with it. */
5741
- const CARD_DETAIL_VIEW_FIELDS = [
5742
- ...CARD_VIEW_FIELDS,
5743
- "subCards",
5744
- "comments",
5745
- "commentCount",
5746
- "runs",
5747
- "history",
5748
- "pullRequests"
5749
- ];
5750
5735
  /** Every field `kanbo sprint list --json` may ask for, in listing order. */
5751
5736
  const SPRINT_VIEW_FIELDS = [
5752
5737
  "id",
@@ -5758,22 +5743,6 @@ const SPRINT_VIEW_FIELDS = [
5758
5743
  "openCards",
5759
5744
  "doneCards"
5760
5745
  ];
5761
- /** Every field `kanbo run start|finish|attach-session --json` may ask for, in listing order. */
5762
- const RUN_VIEW_FIELDS = [
5763
- "id",
5764
- "issueId",
5765
- "agentName",
5766
- "state",
5767
- "executionMode",
5768
- "branch",
5769
- "worktreePath",
5770
- "startedAt",
5771
- "endedAt",
5772
- "attempt",
5773
- "chatSessionId",
5774
- "externalSessionRef",
5775
- "launchedByKind"
5776
- ];
5777
5746
  function projectCard(card, columns, runs) {
5778
5747
  const view = toIssueView(card);
5779
5748
  const column = columns.find((candidate) => candidate.id === card.statusId) ?? null;
@@ -5795,14 +5764,6 @@ function projectCard(card, columns, runs) {
5795
5764
  updatedAt: view.updatedAt
5796
5765
  };
5797
5766
  }
5798
- /** List cards with what `readCardAttention` says beside them — `returned` and `lastComment` only where there is something. */
5799
- function withCardAttention(cards, attention) {
5800
- return cards.map((card, index) => ({
5801
- ...card,
5802
- ...attention[index]?.returned ? { returned: true } : {},
5803
- ...attention[index]?.lastComment ? { lastComment: attention[index].lastComment } : {}
5804
- }));
5805
- }
5806
5767
  /** The run going on right now, trimmed to what a card line needs — not the agent id, the branch or the session behind it. */
5807
5768
  function projectCardActiveRun(run) {
5808
5769
  return run === null ? null : {
@@ -5839,42 +5800,6 @@ function projectSprint(sprint) {
5839
5800
  doneCards: sprint.cards.done
5840
5801
  };
5841
5802
  }
5842
- function projectRun(run) {
5843
- return {
5844
- id: run.id,
5845
- issueId: run.issueId,
5846
- agentName: run.agentName,
5847
- state: run.state,
5848
- executionMode: run.executionMode,
5849
- branch: run.branch,
5850
- worktreePath: run.worktreePath,
5851
- startedAt: run.startedAt,
5852
- endedAt: run.endedAt,
5853
- attempt: run.attempt,
5854
- chatSessionId: run.chatSessionId,
5855
- externalSessionRef: run.externalSessionRef,
5856
- launchedByKind: run.launchedByKind
5857
- };
5858
- }
5859
- /** One card per line: the key, the column it is in, and what it is called (`cardDisplayTitle`). */
5860
- function describeCards(cards) {
5861
- if (cards.length === 0) return "No cards";
5862
- const keyWidth = Math.max(...cards.map((card) => card.id.length));
5863
- const indent = " ".repeat(keyWidth + 2);
5864
- const column = (card) => sanitizeTerminalText(card.column ?? "—");
5865
- const columnWidth = Math.max(...cards.map((card) => displayWidth(column(card))));
5866
- return cards.map((card) => {
5867
- return [`${card.id.padEnd(keyWidth)} ${padToWidth(column(card), columnWidth)} ${sanitizeTerminalText(cardDisplayTitle(card))}`, ...[
5868
- card.waitingFor === "human" ? `waiting for a person${card.statusLine ? ` — ${sanitizeTerminalText(card.statusLine)}` : ""}` : null,
5869
- card.returned && !card.lastComment ? "returned" : null,
5870
- card.lastComment ? `${card.returned ? "returned — " : "last comment, "}${describeCommentAuthor(card.lastComment.author)}: ${sanitizeTerminalText(card.lastComment.text)}` : null
5871
- ].filter((text) => text !== null).map((text) => `${indent}${text}`)].join("\n");
5872
- }).join("\n");
5873
- }
5874
- /** Who wrote a comment, as a list line says it. */
5875
- function describeCommentAuthor(author) {
5876
- return author === "user" ? "person" : author;
5877
- }
5878
5803
  /** The whole card, for a person who asked about exactly one. */
5879
5804
  function describeCard(card) {
5880
5805
  const lines = [`${card.id} ${sanitizeTerminalText(card.column ?? "—")} ${card.executionMode}`, sanitizeTerminalText(cardDisplayTitle(card))];
@@ -6081,858 +6006,131 @@ function colorsWanted() {
6081
6006
  return process.stdout.isTTY === true && process.env.TERM !== "dumb";
6082
6007
  }
6083
6008
  //#endregion
6084
- //#region src/capabilities/markdown.ts
6009
+ //#region src/cli/column-changes.ts
6085
6010
  /**
6086
- * The same manifest `buildCapabilitiesManifest` returns, as Markdown a person
6087
- * can read and an agent can paste into its own `CLAUDE.md` or `AGENTS.md`.
6011
+ * What `kanbo columns add|rename|move|remove|template` do to a board, once
6012
+ * the command or the `kanbo columns` menu knows what the person asked for.
6013
+ * Both run these, so a change made from the menu reads exactly like the
6014
+ * command it names.
6088
6015
  *
6089
- * Nothing here decides what the board can do — it only lays the manifest's own
6090
- * fields out under headings. `kanbo capabilities` prints this by default, and
6091
- * `kanbo mcp` serves it as `kanbo://capabilities.md` beside the JSON.
6016
+ * A refusal of the board's is said as a sentence with the command to type
6017
+ * instead, rather than as its code: these are a person's commands.
6092
6018
  */
6019
+ const COLUMN_CATEGORIES = [
6020
+ "backlog",
6021
+ "unstarted",
6022
+ "started",
6023
+ "completed",
6024
+ "canceled"
6025
+ ];
6026
+ /** The whole board's columns after a change, and the sentence that says what changed. */
6027
+ async function changed(session, message, extra = {}) {
6028
+ const board = await session.ops.listColumns(session.workspace.id);
6029
+ const views = board.map((column) => projectColumn(column, board));
6030
+ return {
6031
+ value: {
6032
+ ...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, typeof value === "number" || Array.isArray(value) ? value : projectColumn(value, board)])),
6033
+ columns: views
6034
+ },
6035
+ text: `${message}\n\n${describeColumns(views)}`
6036
+ };
6037
+ }
6093
6038
  /**
6094
- * A table cell, safe from a pipe inside the text breaking the row it sits in —
6095
- * the same escape the package README's own tables use for a command that
6096
- * spells `\|` between two states.
6039
+ * A new column, placed in the same write that creates it. A ready-made name
6040
+ * (`QA`, `Blocked`) brings its own line and category; any other name is work
6041
+ * in progress. With no place given it goes before the board's first completed
6042
+ * column (Done), or Canceled, so work still to do stays on the left.
6097
6043
  */
6098
- function cell(text) {
6099
- return text.replace(/\|/g, "\\|").replace(/\n/g, " ").trim();
6100
- }
6101
- function renderStorages(manifest) {
6102
- return [
6103
- "## Storages",
6104
- "",
6105
- "| Kind | Label | Description | Schema epoch |",
6106
- "| --- | --- | --- | --- |",
6107
- ...manifest.storages.map((storage) => `| \`${storage.kind}\` | ${cell(storage.label)} | ${cell(storage.description)} | ${storage.schemaEpoch} |`)
6108
- ];
6109
- }
6110
- function renderTool(tool) {
6111
- return `| \`${tool.name}\` | \`${cell(tool.cli.kanbo)}\` | ${cell(tool.description)} |`;
6112
- }
6113
- function renderTools(manifest) {
6114
- return [
6115
- "## Tools",
6116
- "",
6117
- "Full JSON Schema for each tool's input is in the JSON manifest (`kanbo capabilities --json` or `kanbo://capabilities`) — this table is the tools themselves and what to type instead of calling one.",
6118
- "",
6119
- "| Tool | `kanbo` | Does |",
6120
- "| --- | --- | --- |",
6121
- ...manifest.tools.map(renderTool)
6122
- ];
6044
+ async function addColumn(session, request, actor) {
6045
+ const workspaceId = session.workspace.id;
6046
+ const name = request.name.trim();
6047
+ if (!normalizeStatusName(name)) throw new CliError(1, "Give the column a name.");
6048
+ if (request.after !== void 0 && request.before !== void 0) throw new CliError(1, "Pass --after or --before, not both.");
6049
+ const existing = await session.ops.findColumn(workspaceId, name);
6050
+ if (existing) throw new CliError(1, `This board already has a column called ${existing.name} (slug ${normalizeStatusName(existing.name)}).`);
6051
+ const anchorName = request.after ?? request.before;
6052
+ const anchor = anchorName === void 0 ? null : await requireNamedColumn(session, anchorName);
6053
+ const spec = findCatalogueColumn(name) ?? ownColumn(name, null);
6054
+ const created = await session.ops.addColumn(workspaceId, {
6055
+ name,
6056
+ description: request.description?.trim() || spec.description,
6057
+ color: spec.color,
6058
+ category: request.category ?? spec.category,
6059
+ position: anchor ? request.after !== void 0 ? { after: anchor.id } : { before: anchor.id } : void 0
6060
+ }, actor);
6061
+ return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: created });
6123
6062
  }
6124
- function renderCommand(command) {
6125
- const options = command.options.map((option) => `\`${option.flags}\`${option.mandatory ? " (required)" : ""}`).join(", ");
6126
- const humanOnly = command.humanOnly ? " **Person only.**" : "";
6127
- return `| \`kanbo ${command.path}\` | ${cell(command.description)}${humanOnly} | ${options ? cell(options) : "—"} |`;
6063
+ /** Said after a rename: agents' instructions name no column but To Do, and `kanbo_prime` gives them the rest. */
6064
+ const RENAME_NOTE = "Agents read column names from kanbo_prime; rename is picked up there.";
6065
+ async function renameColumn(session, column, newName, actor) {
6066
+ const before = await requireNamedColumn(session, column);
6067
+ const renamed = await session.ops.renameColumn(session.workspace.id, before.id, newName, actor);
6068
+ 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 });
6128
6069
  }
6129
- function renderCommands(manifest) {
6130
- return [
6131
- "## Commands",
6132
- "",
6133
- "| Command | Does | Options |",
6134
- "| --- | --- | --- |",
6135
- ...manifest.commands.map(renderCommand)
6136
- ];
6070
+ /** `--first`, `--last`, `--before <column>` or `--after <column>` — exactly one. */
6071
+ function readColumnPosition(options) {
6072
+ const given = [
6073
+ options.first ? "first" : null,
6074
+ options.last ? "last" : null,
6075
+ options.before !== void 0 ? { before: options.before } : null,
6076
+ options.after !== void 0 ? { after: options.after } : null
6077
+ ].filter((position) => position !== null);
6078
+ if (given.length !== 1) throw new CliError(1, "Say where the column goes: --first, --last, --before <column> or --after <column> (one of them).");
6079
+ return given[0];
6137
6080
  }
6138
- function renderRules(manifest) {
6139
- return [
6140
- "## Rules",
6141
- "",
6142
- ...manifest.rules.map((rule) => `- ${rule}`)
6143
- ];
6081
+ async function moveColumn(session, column, position, actor) {
6082
+ const moving = await requireNamedColumn(session, column);
6083
+ const anchorName = typeof position === "string" ? null : "before" in position ? position.before : position.after;
6084
+ const anchor = anchorName === null ? null : await requireNamedColumn(session, anchorName);
6085
+ const before = await session.ops.listColumns(session.workspace.id);
6086
+ const after = await session.ops.moveColumn(session.workspace.id, moving.id, position, actor);
6087
+ const where = position === "first" ? "to the start" : position === "last" ? "to the end" : `${"before" in position ? "before" : "after"} ${anchor.name}`;
6088
+ return await changed(session, before.map((candidate) => candidate.id).join() === after.map((candidate) => candidate.id).join() ? `${moving.name} is already there.` : `Moved ${moving.name} ${where}.`);
6144
6089
  }
6145
- function renderEntryRules(manifest) {
6146
- return [
6147
- "## Column entry rules",
6148
- "",
6149
- "What a column may ask of a card before an agent moves it in. Which columns ask for which is per board: `kanbo columns list` or `kanbo_columns`.",
6150
- "",
6151
- ...manifest.entryRules.map((entry) => `- \`${entry.rule}\` — ${entry.description}`)
6152
- ];
6090
+ /** How many cards sit in the column right now. */
6091
+ async function countCardsIn(session, column) {
6092
+ return (await session.store.issues.listInBoardOrder(session.workspace.id)).filter((card) => card.statusId === column.id).length;
6153
6093
  }
6154
- function renderCanonicalStrings(manifest) {
6155
- return [
6156
- "## Canonical strings",
6157
- "",
6158
- "What the board writes for you, on a status line or in a system comment. A value ending in a space or `: ` is a prefix — the rest of the line is whatever the write was about.",
6159
- "",
6160
- "| Name | Value |",
6161
- "| --- | --- |",
6162
- ...Object.entries(manifest.canonicalStrings).map(([name, value]) => `| ${cell(name)} | \`${cell(value)}\` |`)
6163
- ];
6094
+ async function removeColumn(session, column, moveCardsTo, actor) {
6095
+ const removing = await requireNamedColumn(session, column);
6096
+ if (moveCardsTo !== null) await requireNamedColumn(session, moveCardsTo);
6097
+ const result = await session.ops.removeColumn(session.workspace.id, removing.id, { moveCardsTo }, actor);
6098
+ 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}`);
6099
+ return await changed(session, result.movedTo ? `Removed ${result.removed.name} and moved ${describeCount$1(result.movedCards)} to ${result.movedTo.name}.` : `Removed ${result.removed.name}.`, {
6100
+ removed: result.removed,
6101
+ movedCards: result.movedCards
6102
+ });
6164
6103
  }
6165
- function renderRoles(manifest) {
6166
- return [
6167
- "## Roles",
6168
- "",
6169
- `- Person: \`${manifest.roles.person}\``,
6170
- `- Agent: \`${manifest.roles.agent}\``,
6171
- "",
6172
- "What the database itself refuses the agent role, on an external board, whichever client sends the write:",
6173
- "",
6174
- ...manifest.roles.refusals.map((refusal) => `- ${refusal}`)
6175
- ];
6104
+ /** The template a person named, or the error that lists the ones there are. */
6105
+ function readTemplateId(value) {
6106
+ const id = COLUMN_TEMPLATE_IDS.find((candidate) => candidate === value.trim().toLowerCase());
6107
+ if (!id) throw new CliError(1, `Unknown template "${value}". Use ${COLUMN_TEMPLATE_IDS.join(", ")}.`);
6108
+ return id;
6176
6109
  }
6177
- function renderLimits(manifest) {
6178
- return [
6179
- "## Limits",
6180
- "",
6181
- ...manifest.limits.map((limit) => `- ${limit}`)
6182
- ];
6110
+ /**
6111
+ * A template's columns on this board. An empty board gets the template as it
6112
+ * is; a board with columns only gets the ones it lacks, and only when asked
6113
+ * with `--add-missing` — a template never takes a column away.
6114
+ */
6115
+ async function applyTemplate(session, id, addMissing, actor) {
6116
+ const workspaceId = session.workspace.id;
6117
+ const label = COLUMN_TEMPLATE_LABELS[id];
6118
+ const present = await session.ops.listColumns(workspaceId);
6119
+ if (present.length > 0 && !addMissing) throw new CliError(1, `This board already has columns. Add the ${label} columns it lacks with: kanbo columns template ${id} --add-missing`);
6120
+ const { added } = present.length === 0 ? await session.ops.applyColumnTemplate(workspaceId, COLUMN_TEMPLATES[id], { mode: "seed" }) : await session.ops.applyColumnTemplate(workspaceId, COLUMN_TEMPLATES[id], {
6121
+ mode: "add-missing",
6122
+ actor
6123
+ });
6124
+ return await changed(session, added.length === 0 ? `This board already has every ${label} column.` : `Added ${added.join(", ")}.`, { added });
6183
6125
  }
6184
- /** The manifest, laid out under one heading per field — stable and diffable, never a snapshot to update by hand. */
6185
- function renderCapabilitiesMarkdown(manifest) {
6186
- return [
6187
- "# Kanbo capabilities",
6188
- "",
6189
- `\`${manifest.package.name}\` ${manifest.package.version} — capabilities manifest version ${manifest.version}.`,
6190
- "",
6191
- ...renderStorages(manifest),
6192
- "",
6193
- ...renderTools(manifest),
6194
- "",
6195
- ...renderCommands(manifest),
6196
- "",
6197
- ...renderRules(manifest),
6198
- "",
6199
- ...renderEntryRules(manifest),
6200
- "",
6201
- ...renderCanonicalStrings(manifest),
6202
- "",
6203
- ...renderRoles(manifest),
6204
- "",
6205
- ...renderLimits(manifest),
6206
- ""
6207
- ].join("\n");
6126
+ /** The column a person named, or the error that says the board has none by that name. */
6127
+ async function requireNamedColumn(session, nameOrId) {
6128
+ const column = normalizeStatusName(nameOrId) ? await session.ops.findColumn(session.workspace.id, nameOrId) : null;
6129
+ if (!column) throw new CliError(1, `This board has no column "${nameOrId}". See kanbo columns list.`);
6130
+ return column;
6208
6131
  }
6209
- //#endregion
6210
- //#region src/cli/commands/capabilities.ts
6211
- function registerCapabilitiesCommand(program) {
6212
- program.command("capabilities").description("List what kanbo offers agents: tools, commands, rules and limits (no board needed)").option("--json [fields]", "print it as JSON; name comma-separated fields to print only those").option("--markdown", "print it as Markdown (the default)").action((options) => {
6213
- if (options.json !== void 0 && options.markdown) throw new CliError(1, "Pass --json or --markdown, not both.");
6214
- const manifest = buildCapabilitiesManifest();
6215
- if (options.json !== void 0) {
6216
- printResult({ value: manifest }, { json: options.json });
6217
- return;
6218
- }
6219
- console.log(renderCapabilitiesMarkdown(manifest));
6220
- });
6221
- }
6222
- //#endregion
6223
- //#region src/cli/commands/claim.ts
6224
- /**
6225
- * `kanbo claim [card]` — take a card to work on, in one step.
6226
- *
6227
- * The run starts and the card moves out of To Do into the board's first
6228
- * work-in-progress column in one write, so two agents in two terminals never
6229
- * take the same card. Filed as a placement: in a person's own terminal the
6230
- * column's rules warn, in an agent's shell they refuse.
6231
- */
6232
- function registerClaimCommand(program) {
6233
- 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) => {
6234
- await runBoardCommand(options, "write", async (session) => {
6235
- const actor = createPlacementActor();
6236
- const named = reference === void 0 ? void 0 : (await requireCard(session, reference)).id;
6237
- const claimed = await session.ops.claimCard({
6238
- workspaceId: session.workspace.id,
6239
- issueId: named,
6240
- run: {
6241
- agentName: options.agent ?? actor.id ?? "agent",
6242
- externalSessionRef: options.session ?? sessionRefFromEnvironment() ?? void 0,
6243
- launchedByKind: "external"
6244
- }
6245
- }, actor);
6246
- const [columns, projection, runs] = await Promise.all([
6247
- session.ops.listColumns(session.workspace.id),
6248
- session.ops.readBoardProjectionForIssue(claimed.card.id),
6249
- session.ops.listRuns(claimed.card.id)
6250
- ]);
6251
- const card = projectCard(claimed.card, columns, projection);
6252
- for (const item of claimed.card.unmetRules ?? []) console.error(`kanbo: warning: ${card.id} entered "${claimed.column.name}" without ${item.rule}: ${item.detail}`);
6253
- const run = projectRun({
6254
- ...claimed.run,
6255
- attempt: runs.find((each) => each.id === claimed.run.id)?.attempt ?? runs.length
6256
- });
6257
- return {
6258
- value: {
6259
- card,
6260
- run,
6261
- column: projectColumn(claimed.column, columns)
6262
- },
6263
- 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`
6264
- };
6265
- });
6266
- });
6267
- }
6268
- //#endregion
6269
- //#region src/mcp/transport.ts
6270
- /** What `kanbo_card_get` may bring along with the card. */
6271
- const KANBO_CARD_INCLUDES = [
6272
- "comments",
6273
- "subCards",
6274
- "runs",
6275
- "history",
6276
- "prs"
6277
- ];
6278
- /** What a card read brings along when the caller does not say: its latest comments and its sub-cards. */
6279
- const DEFAULT_KANBO_CARD_INCLUDES = ["comments", "subCards"];
6280
- /** How many of its latest comments a card read brings along when the caller does not say. */
6281
- const DEFAULT_KANBO_COMMENT_LIMIT = 10;
6282
- /**
6283
- * The card a tool prints, from the card a transport holds.
6284
- *
6285
- * Both transports project here so the JSON an agent reads is the same whether
6286
- * it came from the server or from the board file — including the column, which
6287
- * neither of them carries on the card itself: the row keeps a foreign key, and
6288
- * an agent moving a card needs the slug.
6289
- *
6290
- * Every field is named rather than spread. A card the board file answers with
6291
- * carries more than this — the agent and session ids behind a run among them —
6292
- * and passing an object through by reference would print whatever it happened
6293
- * to hold, which is both a different shape from the server's answer and host
6294
- * facts the agent was never meant to see.
6295
- */
6296
- function projectKanboCard(card, columns) {
6297
- const column = columns.find((candidate) => candidate.id === card.statusId) ?? null;
6298
- return {
6299
- id: card.id,
6300
- number: card.number,
6301
- title: card.title,
6302
- description: card.description,
6303
- column: column?.name ?? null,
6304
- columnSlug: column?.slug ?? null,
6305
- statusLine: card.statusLine,
6306
- waitingFor: card.waitingFor,
6307
- priority: card.priority,
6308
- labels: card.labels,
6309
- executionMode: card.executionMode,
6310
- parentIssueId: card.parentIssueId,
6311
- attemptCount: card.attemptCount,
6312
- activeRun: projectKanboActiveRun(card.activeRun),
6313
- updatedAt: card.updatedAt,
6314
- ...card.attention?.returned ? { returned: true } : {},
6315
- ...card.attention?.lastComment ? { lastComment: card.attention.lastComment } : {}
6316
- };
6317
- }
6318
- /** A sub-card, as the card above it lists it: enough to name it and read it next. */
6319
- function projectKanboSubCard(card, columns) {
6320
- return {
6321
- id: card.id,
6322
- title: card.title,
6323
- columnSlug: columns.find((candidate) => candidate.id === card.statusId)?.slug ?? null
6324
- };
6325
- }
6326
- /** The run going on right now, as much of it as the agent working the card needs. */
6327
- function projectKanboActiveRun(run) {
6328
- return run === null ? null : {
6329
- id: run.id,
6330
- agentName: run.agentName,
6331
- state: run.state,
6332
- executionMode: run.executionMode,
6333
- branch: run.branch,
6334
- startedAt: run.startedAt
6335
- };
6336
- }
6337
- /**
6338
- * One launch of a card, from the run row either transport holds.
6339
- *
6340
- * What the row carries beyond this — the worktree path, the chat session
6341
- * driving it, who launched it — belongs to the host that launched the run, not
6342
- * to the agent that asked for one.
6343
- */
6344
- function projectKanboRun(run) {
6345
- return {
6346
- id: run.id,
6347
- issueId: run.issueId,
6348
- agentName: run.agentName,
6349
- state: run.state,
6350
- executionMode: run.executionMode,
6351
- branch: run.branch,
6352
- startedAt: run.startedAt,
6353
- endedAt: run.endedAt,
6354
- externalSessionRef: run.externalSessionRef ?? null
6355
- };
6356
- }
6357
- /**
6358
- * A pull-request link, from the row either transport holds — without who made
6359
- * it, which is the board's record rather than anything the agent acts on.
6360
- */
6361
- function projectKanboPullRequest(link) {
6362
- return {
6363
- id: link.id,
6364
- issueId: link.issueId,
6365
- owner: link.owner,
6366
- repo: link.repo,
6367
- number: link.number,
6368
- url: link.url,
6369
- createdAt: link.createdAt
6370
- };
6371
- }
6372
- /** A sprint, from the one `listSprints` answers with on either end — the description and timestamps left behind. */
6373
- function projectKanboSprint(sprint) {
6374
- return {
6375
- id: sprint.id,
6376
- title: sprint.title,
6377
- status: sprint.status,
6378
- startDate: sprint.startDate,
6379
- dueDate: sprint.dueDate,
6380
- current: sprint.current,
6381
- openCards: sprint.cards.open,
6382
- doneCards: sprint.cards.done
6383
- };
6384
- }
6385
- /** A column of the board, from the row either transport holds; `board` is every column, to name the ones it accepts cards from. */
6386
- function projectKanboColumn(column, board) {
6387
- const stored = column.entryRules;
6388
- const allowedIds = typeof column.allowedFrom === "string" ? readFlowRules({
6389
- allowedFrom: column.allowedFrom,
6390
- wipLimit: null
6391
- }).allowedFrom : column.allowedFrom ?? null;
6392
- return {
6393
- id: column.id,
6394
- name: column.name,
6395
- slug: normalizeStatusName(column.name),
6396
- description: column.description,
6397
- order: column.order,
6398
- entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null }),
6399
- allowedFrom: allowedIds === null ? null : board.filter((other) => allowedIds.includes(other.id)).map((other) => normalizeStatusName(other.name)),
6400
- wipLimit: column.wipLimit ?? null
6401
- };
6402
- }
6403
- /**
6404
- * Does the card answer the query? The filters of `KanboCardQuery`, applied to
6405
- * a card already read — for a transport whose server filters on less than the
6406
- * query names. `statusIds` are the query's columns, already resolved.
6407
- */
6408
- function matchesKanboCardQuery(card, query, statusIds) {
6409
- const text = query.text === void 0 ? void 0 : foldCase(query.text);
6410
- return (statusIds === null || card.statusId !== null && statusIds.has(card.statusId)) && (query.waitingForPerson === void 0 || card.waitingFor === "human" === query.waitingForPerson) && (query.parent === void 0 || card.parentIssueId === query.parent) && (query.hasActiveRun === void 0 || card.activeRun !== null === query.hasActiveRun) && (query.returned === void 0 || (card.attention?.returned ?? false) === query.returned) && (text === void 0 || [
6411
- card.id,
6412
- card.title,
6413
- card.description ?? ""
6414
- ].some((value) => foldCase(value).includes(text))) && (query.updatedSince === void 0 || card.updatedAt >= query.updatedSince) && (query.labels ?? []).every((label) => card.labels.includes(label)) && (query.priority === void 0 || query.priority.includes(card.priority));
6415
- }
6416
- /** A page cut out of cards already picked, in the order they were picked. */
6417
- function pageOf(cards, input) {
6418
- const offset = input.offset ?? 0;
6419
- return {
6420
- cards: cards.slice(offset, input.limit === void 0 ? void 0 : offset + input.limit),
6421
- total: cards.length,
6422
- offset
6423
- };
6424
- }
6425
- /** A comment as a card read lists it, from the row either transport holds. */
6426
- function projectKanboCardComment(comment) {
6427
- const decision = comment.authorKind === "system.approved" || comment.authorKind === "system.returned";
6428
- const author = decision ? "user" : normalizeCommentAuthorKind(comment.authorKind);
6429
- const named = author !== "system" || decision;
6430
- const outside = author === "system" && isOutsideWriter(comment) && comment.authorKind === "system";
6431
- return {
6432
- id: comment.id,
6433
- author,
6434
- by: named || outside ? comment.authorId ?? author : "kanbo",
6435
- content: comment.content,
6436
- createdAt: comment.createdAt
6437
- };
6438
- }
6439
- /** A field change as a card read lists it — who by kind only: the id behind it is the host's. */
6440
- function projectKanboFieldChange(change) {
6441
- return {
6442
- field: change.field,
6443
- from: change.fromValue,
6444
- to: change.toValue,
6445
- actor: change.actorKind,
6446
- createdAt: change.createdAt
6447
- };
6448
- }
6449
- //#endregion
6450
- //#region src/cli/card-extras.ts
6451
- async function readCardExtras(session, card, columns, include, commentLimit) {
6452
- const wants = new Set(include);
6453
- const [subCards, comments, runs, history, pullRequests] = await Promise.all([
6454
- wants.has("subCards") ? session.store.issues.listPage({
6455
- workspaceId: card.workspaceId,
6456
- parentIssueId: card.id
6457
- }) : null,
6458
- wants.has("comments") ? session.store.comments.listByIssue(card.id) : null,
6459
- wants.has("runs") ? session.ops.listRuns(card.id) : null,
6460
- wants.has("history") ? session.store.fieldChanges.listByIssue(card.id) : null,
6461
- wants.has("prs") ? session.store.pullRequests.listByIssue(card.id) : null
6462
- ]);
6463
- return {
6464
- ...subCards ? { subCards: subCards.cards.map((row) => {
6465
- const view = projectCard(row, columns, {
6466
- issueId: row.id,
6467
- attemptCount: 0,
6468
- activeRun: null
6469
- });
6470
- return {
6471
- id: view.id,
6472
- title: view.title,
6473
- columnSlug: view.columnSlug
6474
- };
6475
- }) } : {},
6476
- ...comments ? {
6477
- comments: comments.slice(-commentLimit).map(projectKanboCardComment),
6478
- commentCount: comments.length
6479
- } : {},
6480
- ...runs ? { runs: runs.map(projectRun) } : {},
6481
- ...history ? { history: history.map(projectKanboFieldChange) } : {},
6482
- ...pullRequests ? { pullRequests: pullRequests.map((link) => ({
6483
- id: link.id,
6484
- owner: link.owner,
6485
- repo: link.repo,
6486
- number: link.number,
6487
- url: link.url
6488
- })) } : {}
6489
- };
6490
- }
6491
- /** A unix-seconds moment as `YYYY-MM-DD HH:MM` UTC. */
6492
- function formatMoment(seconds) {
6493
- return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 16).replace("T", " ");
6494
- }
6495
- /** The parts under the card, each under a heading; nothing for a part that was not asked for. */
6496
- function describeCardExtras(extras) {
6497
- const sections = [];
6498
- if (extras.subCards && extras.subCards.length > 0) sections.push(["Sub-cards:", describeCards(extras.subCards.map((sub) => ({
6499
- ...sub,
6500
- description: null,
6501
- column: sub.columnSlug
6502
- })))].join("\n"));
6503
- if (extras.comments && extras.comments.length > 0) {
6504
- const heading = extras.commentCount === extras.comments.length ? "Comments:" : `Comments (last ${extras.comments.length} of ${extras.commentCount}):`;
6505
- sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${sanitizeTerminalText(comment.by)}${comment.author === "user" ? " (person)" : ""}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
6506
- }
6507
- 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"));
6508
- 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"));
6509
- if (extras.pullRequests && extras.pullRequests.length > 0) sections.push(["Pull requests:", ...extras.pullRequests.map((link) => `${link.owner}/${link.repo}#${link.number} ${link.url}`)].join("\n"));
6510
- return sections.join("\n\n");
6511
- }
6512
- //#endregion
6513
- //#region src/cli/commands/card.ts
6514
- /**
6515
- * `kanbo card …` — the cards themselves.
6516
- *
6517
- * Every write here is filed under the shell that made it and goes through the
6518
- * board's own operations, so a card an agent moved from a terminal reads on the
6519
- * board exactly like one moved in the app: the same field history, the same
6520
- * status line, the same board version bump for whoever is watching.
6521
- */
6522
- const PRIORITIES$1 = [
6523
- "none",
6524
- "low",
6525
- "medium",
6526
- "high",
6527
- "urgent"
6528
- ];
6529
- /**
6530
- * Where `card create` puts a card no `--column` placed. A person's card goes
6531
- * to To Do, as the home screen and the wizard put it: they wrote it down to
6532
- * have it done. An agent's goes where the board's operations put any card with
6533
- * no column — the first one (Backlog on the standard board) — as it did in
6534
- * 0.2: an agent writing down follow-up work should not hand it straight to the
6535
- * next agent that runs `kanbo ready`. A board with no To Do gets the default
6536
- * for a person too.
6537
- */
6538
- async function readDefaultColumn(session) {
6539
- if (isAgentShell()) return;
6540
- return (await session.ops.findColumn(session.workspace.id, READY_COLUMN_SLUG$1))?.name;
6541
- }
6542
- /** How many cards `kanbo card list` prints when the caller names no limit. */
6543
- const DEFAULT_LIST_LIMIT = 50;
6544
- function registerCardCommands(program) {
6545
- const card = program.command("card").description("Add, change, move and read cards");
6546
- withBoardOptions(card.command("list").description(`List cards in board order, ${DEFAULT_LIST_LIMIT} at a time; filters combine`).option("--column <columns>", "only cards in these columns, by slug, name or id (comma-separated, or repeat)", collectList, void 0).option("--waiting", "only cards waiting for a person").option("--parent <card>", "only the sub-cards of this card").option("--active", "only cards an agent is working on now").option("--returned", "only cards a person sent back that nobody has picked up again").option("--text <text>", "only cards whose key, title or description contains this, ignoring case in any script (über finds Über)").option("--updated-since <time>", "only cards changed since then: unix seconds, 2026-09-28T10:00:00Z (a zone is required) or 2026-09-28 (UTC midnight)", parseMoment).option("--label <labels>", "only cards carrying every one of these labels (comma-separated, or repeat)", collectList, void 0).option("--priority <priorities>", `only cards of these priorities: ${PRIORITIES$1.join(", ")} (comma-separated)`, parsePriorities).option("--limit <count>", `how many cards to print (${DEFAULT_LIST_LIMIT} when left out)`, parseCount).option("--offset <count>", "how many picked cards to skip first", parseOffset).option("--all", "print every picked card")).action(async (options) => {
6547
- await runBoardCommand(options, "read", async (session) => {
6548
- const parent = options.parent ? await requireCard(session, options.parent) : null;
6549
- const page = await session.ops.queryCards({
6550
- workspaceId: session.workspace.id,
6551
- columns: options.column,
6552
- waitingForPerson: options.waiting ? true : void 0,
6553
- parentIssueId: parent?.id,
6554
- hasActiveRun: options.active ? true : void 0,
6555
- returned: options.returned ? true : void 0,
6556
- text: options.text,
6557
- updatedSince: options.updatedSince,
6558
- labels: options.label,
6559
- priorities: options.priority,
6560
- offset: options.offset,
6561
- limit: options.all ? void 0 : options.limit ?? DEFAULT_LIST_LIMIT
6562
- }).catch(rethrowUnknownColumn(options.column));
6563
- const runs = await session.ops.readBoardProjectionForIssues(page.cards.map((row) => row.id));
6564
- const attention = await session.ops.readCardAttention(page.cards, runs.map((run) => run.activeRun !== null));
6565
- const cards = withCardAttention(page.cards.map((row, index) => projectCard(row, page.columns, runs[index])), attention);
6566
- noteMoreCards(page.total, options.offset ?? 0, cards.length);
6567
- return {
6568
- value: cards,
6569
- text: describeCards(cards),
6570
- fields: CARD_VIEW_FIELDS
6571
- };
6572
- });
6573
- });
6574
- withBoardOptions(card.command("get").description("Show one card, with its last comments and its sub-cards").argument("<card>", "the card, as MAN-012, MAN-12 or 12").option("--include <parts>", `what to show with the card: ${KANBO_CARD_INCLUDES.join(", ")} (comma-separated; ${DEFAULT_KANBO_CARD_INCLUDES.join(",")} when left out, none for the card alone)`, parseIncludes).option("--comments <count>", `how many of the latest comments to show (10 when left out)`, parseCount)).action(async (reference, options) => {
6575
- await runBoardCommand(options, "read", async (session) => {
6576
- const found = await requireCard(session, reference);
6577
- const [columns, runs] = await Promise.all([session.ops.listColumns(session.workspace.id), session.ops.readBoardProjectionForIssue(found.id)]);
6578
- const view = projectCard(found, columns, runs);
6579
- const extras = await readCardExtras(session, found, columns, options.include ?? DEFAULT_KANBO_CARD_INCLUDES, options.comments ?? 10);
6580
- return {
6581
- value: {
6582
- ...view,
6583
- ...extras
6584
- },
6585
- text: [describeCard(view), describeCardExtras(extras)].filter(Boolean).join("\n\n"),
6586
- fields: CARD_DETAIL_VIEW_FIELDS
6587
- };
6588
- });
6589
- });
6590
- withBoardOptions(card.command("create").description("Add a card to the board").option("--title <title>", "what the card is called (its number when left out)").option("--description <text>", "what the card is about").option("--column <column>", "the column to put it in, by slug, name or id (default: To Do for a person, the board's first column for an agent)").option("--parent <card>", "the card this one is part of").option("--execution-mode <mode>", "where the work happens: worktree (its own checkout) or main", parseExecutionMode)).action(async (options) => {
6591
- await runBoardCommand(options, "write", async (session) => {
6592
- const parent = options.parent ? await requireCard(session, options.parent) : null;
6593
- return await presentCard(session, await session.ops.createCard({
6594
- workspace: session.workspace,
6595
- title: options.title,
6596
- description: options.description,
6597
- statusName: options.column ?? await readDefaultColumn(session),
6598
- parentIssueId: parent?.id,
6599
- executionMode: options.executionMode
6600
- }, createPlacementActor()));
6601
- });
6602
- });
6603
- withBoardOptions(card.command("update").description("Change a card's title, description, priority, labels or parent").argument("<card>", "the card, as MAN-012, MAN-12 or 12").option("--title <title>", "what the card is called").option("--description <text>", "what the card is about").option("--priority <priority>", `one of ${PRIORITIES$1.join(", ")}`, parsePriority).option("--labels <labels>", "comma-separated labels, replacing the ones the card has", parseLabels).option("--execution-mode <mode>", "where the work happens: worktree (its own checkout) or main", parseExecutionMode).option("--parent <card>", "the card to put it under; none puts it on the top level")).action(async (reference, options) => {
6604
- await runBoardCommand(options, "write", async (session) => {
6605
- const input = readUpdateInput(options);
6606
- const existing = await requireCard(session, reference);
6607
- if (options.parent !== void 0) input.parentIssueId = options.parent === "none" ? null : (await requireCard(session, options.parent)).id;
6608
- return await presentCard(session, await session.ops.updateCard(existing.id, input, createPlacementActor()));
6609
- });
6610
- });
6611
- withBoardOptions(card.command("move").description("Move a card to another column").argument("<card>", "the card, as MAN-012, MAN-12 or 12").argument("<column>", "the column, by slug, name or id")).action(async (reference, column, options) => {
6612
- await runBoardCommand(options, "write", async (session) => {
6613
- const existing = await requireCard(session, reference);
6614
- return await presentCard(session, await session.ops.moveCard(existing.id, column, createPlacementActor()));
6615
- });
6616
- });
6617
- withBoardOptions(card.command("status-line").description("Say in one line what is happening on a card right now").argument("<card>", "the card, as MAN-012, MAN-12 or 12").option("--text <text>", "one sentence, present tense (required)").addOption(new Option("--content <text>", "the same as --text").hideHelp())).action(async (reference, options) => {
6618
- const text = readAliasedText("status-line", options, "text", "content");
6619
- if (text === void 0) throw new CliError(1, "kanbo card status-line needs --text (one sentence). Example: kanbo card status-line TST-5 --text \"Running the tests\"");
6620
- await runBoardCommand(options, "write", async (session) => {
6621
- const existing = await requireCard(session, reference);
6622
- return await presentCard(session, await session.ops.setStatusLine(existing.id, text, createCliActor()));
6623
- });
6624
- });
6625
- withBoardOptions(card.command("comment").description("Leave a comment on a card: a finding, a decision or a question").argument("<card>", "the card, as MAN-012, MAN-12 or 12").option("--content <text>", "what to say (required)").addOption(new Option("--text <text>", "the same as --content").hideHelp())).action(async (reference, options) => {
6626
- const content = readAliasedText("comment", options, "content", "text");
6627
- if (content === void 0) throw new CliError(1, "kanbo card comment needs --content (the comment text). Example: kanbo card comment TST-5 --content \"Added slugify with a test\"");
6628
- await runBoardCommand(options, "write", async (session) => {
6629
- const existing = await requireCard(session, reference);
6630
- const comment = await session.ops.addComment({
6631
- issueId: existing.id,
6632
- content
6633
- }, createCliActor());
6634
- return {
6635
- value: {
6636
- id: comment.id,
6637
- issueId: comment.issueId,
6638
- content: comment.content,
6639
- createdAt: comment.createdAt
6640
- },
6641
- text: `Commented on ${existing.id}`
6642
- };
6643
- });
6644
- });
6645
- withBoardOptions(card.command("wait-approval").description("Ask a person to review a card, and stop working on it").argument("<card>", "the card, as MAN-012, MAN-12 or 12").option("--text <text>", "the status line to leave, saying what you need").addOption(new Option("--content <text>", "the same as --text").hideHelp())).action(async (reference, options) => {
6646
- const text = readAliasedText("wait-approval", options, "text", "content");
6647
- await runBoardCommand(options, "write", async (session) => {
6648
- const existing = await requireCard(session, reference);
6649
- return await presentCard(session, await session.ops.waitApproval(existing.id, { statusLine: text }, createCliActor()));
6650
- });
6651
- });
6652
- registerPullRequestCommands(card);
6653
- }
6654
- /**
6655
- * The text a command was given under its own option or the other one agents
6656
- * reach for (`--text` for a comment, `--content` for a status line): the MCP
6657
- * tools take both names too. Both, saying different things, is refused.
6658
- */
6659
- function readAliasedText(command, options, canonical, alias) {
6660
- const own = options[canonical];
6661
- const other = options[alias];
6662
- if (own !== void 0 && other !== void 0 && own !== other) throw new CliError(1, `kanbo card ${command} got --${canonical} and --${alias} with different text; pass only --${canonical}.`);
6663
- return own ?? other;
6664
- }
6665
- /** The fields a pull-request link prints, and the ones `--json` may name. */
6666
- const PULL_REQUEST_FIELDS = [
6667
- "id",
6668
- "issueId",
6669
- "owner",
6670
- "repo",
6671
- "number",
6672
- "url",
6673
- "createdAt"
6674
- ];
6675
- function projectPullRequest(link) {
6676
- return {
6677
- id: link.id,
6678
- issueId: link.issueId,
6679
- owner: link.owner,
6680
- repo: link.repo,
6681
- number: link.number,
6682
- url: link.url,
6683
- createdAt: link.createdAt
6684
- };
6685
- }
6686
- function describePullRequest(link) {
6687
- return `${link.id} ${link.owner}/${link.repo}#${link.number} ${link.url}`;
6688
- }
6689
- /**
6690
- * `kanbo card pr …` — the pull requests a card names (ruling 4-1).
6691
- *
6692
- * Linking is all an agent does: what the pull request is — open, merged, green
6693
- * — reaches the card as comments from whoever watches GitHub, so nothing here
6694
- * writes one.
6695
- */
6696
- function registerPullRequestCommands(card) {
6697
- const pr = card.command("pr").description("Link pull requests to a card");
6698
- withBoardOptions(pr.command("add").description("Link a pull request to a card (linking it twice is fine)").argument("<card>", "the card, as MAN-012, MAN-12 or 12").argument("<url>", "the pull request, as https://github.com/<owner>/<repo>/pull/<n> or <owner>/<repo>#<n>")).action(async (reference, url, options) => {
6699
- await runBoardCommand(options, "write", async (session) => {
6700
- const existing = await requireCard(session, reference);
6701
- const link = projectPullRequest(await session.ops.linkPullRequest(existing.id, url, createCliActor()));
6702
- return {
6703
- value: link,
6704
- text: `Linked ${link.owner}/${link.repo}#${link.number} to ${existing.id} (${link.id})`,
6705
- fields: PULL_REQUEST_FIELDS
6706
- };
6707
- });
6708
- });
6709
- withBoardOptions(pr.command("list").description("List the pull requests linked to a card").argument("<card>", "the card, as MAN-012, MAN-12 or 12")).action(async (reference, options) => {
6710
- await runBoardCommand(options, "read", async (session) => {
6711
- const existing = await requireCard(session, reference);
6712
- const links = (await session.ops.listPullRequests(existing.id)).map(projectPullRequest);
6713
- return {
6714
- value: links,
6715
- text: links.length === 0 ? `${existing.id} names no pull requests.` : links.map(describePullRequest).join("\n"),
6716
- fields: PULL_REQUEST_FIELDS
6717
- };
6718
- });
6719
- });
6720
- withBoardOptions(pr.command("remove").description("Unlink a pull request from a card").argument("<card>", "the card, as MAN-012, MAN-12 or 12").argument("<linkId>", "the link id, as kanbo card pr list prints it")).action(async (reference, linkId, options) => {
6721
- await runBoardCommand(options, "write", async (session) => {
6722
- const existing = await requireCard(session, reference);
6723
- const link = projectPullRequest(await session.ops.unlinkPullRequest(existing.id, linkId, createCliActor()));
6724
- return {
6725
- value: link,
6726
- text: `Unlinked ${link.owner}/${link.repo}#${link.number} from ${existing.id}`,
6727
- fields: PULL_REQUEST_FIELDS
6728
- };
6729
- });
6730
- });
6731
- }
6732
- /**
6733
- * The card as both shapes the output contract offers. A card a person placed
6734
- * in a column whose entry rules it does not meet (ruling 6-5) also leaves one
6735
- * warning line per unmet rule on stderr, so the card prints as usual.
6736
- */
6737
- async function presentCard(session, card) {
6738
- const [columns, runs] = await Promise.all([session.ops.listColumns(session.workspace.id), session.ops.readBoardProjectionForIssue(card.id)]);
6739
- const view = projectCard(card, columns, runs);
6740
- for (const item of card.unmetRules ?? []) console.error(`kanbo: warning: ${view.id} entered "${view.column ?? "—"}" without ${item.rule}: ${item.detail}`);
6741
- return {
6742
- value: view,
6743
- text: describeCard(view),
6744
- fields: CARD_VIEW_FIELDS
6745
- };
6746
- }
6747
- /**
6748
- * The fields `card update` was asked to change — all but the parent, a card
6749
- * of the board, looked up there once the card itself is. A command that names
6750
- * none of them is refused rather than obeyed: an empty update still bumps the
6751
- * board version, and every reader would be told the board changed when it did not.
6752
- */
6753
- function readUpdateInput(options) {
6754
- const input = {};
6755
- if (options.title !== void 0) input.title = options.title;
6756
- if (options.description !== void 0) input.description = options.description;
6757
- if (options.priority !== void 0) input.priority = options.priority;
6758
- if (options.labels !== void 0) input.labels = options.labels;
6759
- if (options.executionMode !== void 0) input.executionMode = options.executionMode;
6760
- 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.");
6761
- return input;
6762
- }
6763
- function parsePriority(value) {
6764
- const priority = PRIORITIES$1.find((candidate) => candidate === value);
6765
- if (!priority) throw new CliError(1, `Unknown priority "${value}". Use one of ${PRIORITIES$1.join(", ")}.`);
6766
- return priority;
6767
- }
6768
- function parseLabels(value) {
6769
- return value.split(",").map((label) => label.trim()).filter((label) => label.length > 0);
6770
- }
6771
- /** A list option given comma-separated, repeated, or both. */
6772
- function collectList(value, previous) {
6773
- return [...previous ?? [], ...value.split(",").map((item) => item.trim()).filter((item) => item.length > 0)];
6774
- }
6775
- function parsePriorities(value) {
6776
- return collectList(value, void 0).map(parsePriority);
6777
- }
6778
- function parseOffset(value) {
6779
- const count = Number(value);
6780
- if (!Number.isInteger(count) || count < 0) throw new CliError(1, `Expected a whole number of cards to skip, got "${value}".`);
6781
- return count;
6782
- }
6783
- function parseMoment(value) {
6784
- try {
6785
- return readUnixSeconds(value);
6786
- } catch (error) {
6787
- throw new CliError(2, `--updated-since: ${error.message}`);
6788
- }
6789
- }
6790
- function parseIncludes(value) {
6791
- if (value.trim() === "none") return [];
6792
- return collectList(value, void 0).map((part) => {
6793
- const include = KANBO_CARD_INCLUDES.find((candidate) => candidate.toLowerCase() === part.toLowerCase());
6794
- if (!include) throw new CliError(1, `Unknown part "${part}". Use ${KANBO_CARD_INCLUDES.join(", ")} or none.`);
6795
- return include;
6796
- });
6797
- }
6798
- /** A column the board does not have, said the way the rest of `kanbo card` says it. */
6799
- function rethrowUnknownColumn(named) {
6800
- return (error) => {
6801
- if (error instanceof BoardError && error.code === "issue_status_not_found" && named) throw new CliError(1, `This board has no column "${String(error.details?.statusName)}".`);
6802
- throw error;
6803
- };
6804
- }
6805
- /** When the page is not every picked card, say so on stderr — where the next page starts, and how to get all of them. */
6806
- function noteMoreCards(total, offset, shown) {
6807
- const more = total - offset - shown;
6808
- if (more > 0) console.error(`kanbo: ${more} more of ${total}; next page: --offset ${offset + shown}, or --all for every card`);
6809
- }
6810
- //#endregion
6811
- //#region src/cli/column-changes.ts
6812
- /**
6813
- * What `kanbo columns add|rename|move|remove|template` do to a board, once
6814
- * the command or the `kanbo columns` menu knows what the person asked for.
6815
- * Both run these, so a change made from the menu reads exactly like the
6816
- * command it names.
6817
- *
6818
- * A refusal of the board's is said as a sentence with the command to type
6819
- * instead, rather than as its code: these are a person's commands.
6820
- */
6821
- const COLUMN_CATEGORIES = [
6822
- "backlog",
6823
- "unstarted",
6824
- "started",
6825
- "completed",
6826
- "canceled"
6827
- ];
6828
- /** The whole board's columns after a change, and the sentence that says what changed. */
6829
- async function changed(session, message, extra = {}) {
6830
- const board = await session.ops.listColumns(session.workspace.id);
6831
- const views = board.map((column) => projectColumn(column, board));
6832
- return {
6833
- value: {
6834
- ...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, typeof value === "number" || Array.isArray(value) ? value : projectColumn(value, board)])),
6835
- columns: views
6836
- },
6837
- text: `${message}\n\n${describeColumns(views)}`
6838
- };
6839
- }
6840
- /**
6841
- * A new column, placed in the same write that creates it. A ready-made name
6842
- * (`QA`, `Blocked`) brings its own line and category; any other name is work
6843
- * in progress. With no place given it goes before the board's first completed
6844
- * column (Done), or Canceled, so work still to do stays on the left.
6845
- */
6846
- async function addColumn(session, request, actor) {
6847
- const workspaceId = session.workspace.id;
6848
- const name = request.name.trim();
6849
- if (!normalizeStatusName(name)) throw new CliError(1, "Give the column a name.");
6850
- if (request.after !== void 0 && request.before !== void 0) throw new CliError(1, "Pass --after or --before, not both.");
6851
- const existing = await session.ops.findColumn(workspaceId, name);
6852
- if (existing) throw new CliError(1, `This board already has a column called ${existing.name} (slug ${normalizeStatusName(existing.name)}).`);
6853
- const anchorName = request.after ?? request.before;
6854
- const anchor = anchorName === void 0 ? null : await requireNamedColumn(session, anchorName);
6855
- const spec = findCatalogueColumn(name) ?? ownColumn(name, null);
6856
- const created = await session.ops.addColumn(workspaceId, {
6857
- name,
6858
- description: request.description?.trim() || spec.description,
6859
- color: spec.color,
6860
- category: request.category ?? spec.category,
6861
- position: anchor ? request.after !== void 0 ? { after: anchor.id } : { before: anchor.id } : void 0
6862
- }, actor);
6863
- return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: created });
6864
- }
6865
- /** Said after a rename: agents' instructions name no column but To Do, and `kanbo prime` gives them the rest. */
6866
- const RENAME_NOTE = "Agents read column names from kanbo prime; rename is picked up there.";
6867
- async function renameColumn(session, column, newName, actor) {
6868
- const before = await requireNamedColumn(session, column);
6869
- const renamed = await session.ops.renameColumn(session.workspace.id, before.id, newName, actor);
6870
- 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 });
6871
- }
6872
- /** `--first`, `--last`, `--before <column>` or `--after <column>` — exactly one. */
6873
- function readColumnPosition(options) {
6874
- const given = [
6875
- options.first ? "first" : null,
6876
- options.last ? "last" : null,
6877
- options.before !== void 0 ? { before: options.before } : null,
6878
- options.after !== void 0 ? { after: options.after } : null
6879
- ].filter((position) => position !== null);
6880
- if (given.length !== 1) throw new CliError(1, "Say where the column goes: --first, --last, --before <column> or --after <column> (one of them).");
6881
- return given[0];
6882
- }
6883
- async function moveColumn(session, column, position, actor) {
6884
- const moving = await requireNamedColumn(session, column);
6885
- const anchorName = typeof position === "string" ? null : "before" in position ? position.before : position.after;
6886
- const anchor = anchorName === null ? null : await requireNamedColumn(session, anchorName);
6887
- const before = await session.ops.listColumns(session.workspace.id);
6888
- const after = await session.ops.moveColumn(session.workspace.id, moving.id, position, actor);
6889
- const where = position === "first" ? "to the start" : position === "last" ? "to the end" : `${"before" in position ? "before" : "after"} ${anchor.name}`;
6890
- return await changed(session, before.map((candidate) => candidate.id).join() === after.map((candidate) => candidate.id).join() ? `${moving.name} is already there.` : `Moved ${moving.name} ${where}.`);
6891
- }
6892
- /** How many cards sit in the column right now. */
6893
- async function countCardsIn(session, column) {
6894
- return (await session.store.issues.listInBoardOrder(session.workspace.id)).filter((card) => card.statusId === column.id).length;
6895
- }
6896
- async function removeColumn(session, column, moveCardsTo, actor) {
6897
- const removing = await requireNamedColumn(session, column);
6898
- if (moveCardsTo !== null) await requireNamedColumn(session, moveCardsTo);
6899
- const result = await session.ops.removeColumn(session.workspace.id, removing.id, { moveCardsTo }, actor);
6900
- 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}`);
6901
- return await changed(session, result.movedTo ? `Removed ${result.removed.name} and moved ${describeCount$1(result.movedCards)} to ${result.movedTo.name}.` : `Removed ${result.removed.name}.`, {
6902
- removed: result.removed,
6903
- movedCards: result.movedCards
6904
- });
6905
- }
6906
- /** The template a person named, or the error that lists the ones there are. */
6907
- function readTemplateId(value) {
6908
- const id = COLUMN_TEMPLATE_IDS.find((candidate) => candidate === value.trim().toLowerCase());
6909
- if (!id) throw new CliError(1, `Unknown template "${value}". Use ${COLUMN_TEMPLATE_IDS.join(", ")}.`);
6910
- return id;
6911
- }
6912
- /**
6913
- * A template's columns on this board. An empty board gets the template as it
6914
- * is; a board with columns only gets the ones it lacks, and only when asked
6915
- * with `--add-missing` — a template never takes a column away.
6916
- */
6917
- async function applyTemplate(session, id, addMissing, actor) {
6918
- const workspaceId = session.workspace.id;
6919
- const label = COLUMN_TEMPLATE_LABELS[id];
6920
- const present = await session.ops.listColumns(workspaceId);
6921
- if (present.length > 0 && !addMissing) throw new CliError(1, `This board already has columns. Add the ${label} columns it lacks with: kanbo columns template ${id} --add-missing`);
6922
- const { added } = present.length === 0 ? await session.ops.applyColumnTemplate(workspaceId, COLUMN_TEMPLATES[id], { mode: "seed" }) : await session.ops.applyColumnTemplate(workspaceId, COLUMN_TEMPLATES[id], {
6923
- mode: "add-missing",
6924
- actor
6925
- });
6926
- return await changed(session, added.length === 0 ? `This board already has every ${label} column.` : `Added ${added.join(", ")}.`, { added });
6927
- }
6928
- /** The column a person named, or the error that says the board has none by that name. */
6929
- async function requireNamedColumn(session, nameOrId) {
6930
- const column = normalizeStatusName(nameOrId) ? await session.ops.findColumn(session.workspace.id, nameOrId) : null;
6931
- if (!column) throw new CliError(1, `This board has no column "${nameOrId}". See kanbo columns list.`);
6932
- return column;
6933
- }
6934
- function describeCount$1(count) {
6935
- return `${count} ${count === 1 ? "card" : "cards"}`;
6132
+ function describeCount$1(count) {
6133
+ return `${count} ${count === 1 ? "card" : "cards"}`;
6936
6134
  }
6937
6135
  //#endregion
6938
6136
  //#region src/cli/humanize.ts
@@ -6943,14 +6141,8 @@ const BOARD_ERROR_TEXTS = {
6943
6141
  text: "Only a person can approve a card.",
6944
6142
  next: AS_A_PERSON
6945
6143
  }),
6946
- board_card_not_ready: (details) => ({
6947
- 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.`,
6948
- next: "kanbo ready"
6949
- }),
6950
- board_card_run_active: (details) => ({
6951
- text: `${named(details.issueId)} already has a run going: ${named(details.agentName)}'s, run ${named(details.runId)}.`,
6952
- next: `kanbo run finish ${named(details.runId)} --state stopped`
6953
- }),
6144
+ board_card_not_ready: (details) => ({ 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.` }),
6145
+ board_card_run_active: (details) => ({ text: `${named(details.issueId)} already has a run going: ${named(details.agentName)}'s, run ${named(details.runId)}.` }),
6954
6146
  board_column_name_taken: (details) => ({
6955
6147
  text: `Another column, ${named(details.takenBy)}, already has that name (same slug). Pick another name.`,
6956
6148
  next: "kanbo columns list"
@@ -6959,7 +6151,7 @@ const BOARD_ERROR_TEXTS = {
6959
6151
  text: `${named(details.statusName)} holds ${describeCount(Number(details.cardCount))}. Say where they go.`,
6960
6152
  next: `kanbo columns remove ${normalizeStatusName(named(details.statusName))} --move-cards-to <column>`
6961
6153
  }),
6962
- board_column_ready_protected: (details) => ({ text: details.newName === void 0 ? `${named(details.statusName)} can't be removed: kanbo ready takes work from To Do, and agents would find no cards to take.` : `${named(details.statusName)} can't be renamed to "${named(details.newName)}": kanbo ready takes work from To Do, and agents would find no cards to take. Another spelling of To Do (To-do, TO DO) is fine.` }),
6154
+ board_column_ready_protected: (details) => ({ text: details.newName === void 0 ? `${named(details.statusName)} can't be removed: agents take work from To Do and would find no cards to take.` : `${named(details.statusName)} can't be renamed to "${named(details.newName)}": agents take work from To Do and would find no cards to take. Another spelling of To Do (To-do, TO DO) is fine.` }),
6963
6155
  board_column_remove_target_invalid: (details) => ({
6964
6156
  text: `Cards can't move into ${named(details.statusName)}: it is the column being removed.`,
6965
6157
  next: "kanbo columns list"
@@ -6986,15 +6178,9 @@ const BOARD_ERROR_TEXTS = {
6986
6178
  next: "kanbo columns list"
6987
6179
  },
6988
6180
  board_metrics_range_invalid: (details) => typeof details.from === "number" && typeof details.to === "number" ? { text: `The range starts after it ends: it starts on ${utcDayLabel(details.from)} and ends on ${utcDayLabel(details.to)}.` } : { text: "The range starts after it ends." },
6989
- board_nothing_ready: () => ({
6990
- text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
6991
- next: "kanbo ready"
6992
- }),
6181
+ board_nothing_ready: () => ({ text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none." }),
6993
6182
  board_pull_request_invalid: (details) => ({ text: `"${named(details.value)}" is not a GitHub pull request. Give its link or owner/repo#number.` }),
6994
- board_pull_request_not_found: (details) => ({
6995
- text: `${named(details.issueId)} has no pull request link "${named(details.linkId)}".`,
6996
- next: `kanbo card pr list ${named(details.issueId)}`
6997
- }),
6183
+ board_pull_request_not_found: (details) => ({ text: `${named(details.issueId)} has no pull request link "${named(details.linkId)}".` }),
6998
6184
  board_pull_request_not_yours: (details) => ({ text: `Someone else linked that pull request to ${named(details.issueId)}; only they or a person can unlink it.` }),
6999
6185
  board_return_no_previous_column: (details) => ({
7000
6186
  text: `${named(details.issueId)} is already in the first column: there is no column before it to send it back to.`,
@@ -7006,10 +6192,7 @@ const BOARD_ERROR_TEXTS = {
7006
6192
  }),
7007
6193
  board_run_already_finished: (details) => ({ text: `Run ${named(details.runId)} has already finished.` }),
7008
6194
  board_run_not_found: (details) => ({ text: `No run "${named(details.runId)}" on this board.` }),
7009
- board_run_session_ref_conflict: (details) => ({
7010
- text: `Run ${named(details.runId)} already names another session log (${describeSessionRef(details.externalSessionRef)}).`,
7011
- next: `a person can replace it: kanbo run attach-session ${named(details.runId)} <ref> --replace`
7012
- }),
6195
+ board_run_session_ref_conflict: (details) => ({ text: `Run ${named(details.runId)} already names another session log (${describeSessionRef(details.externalSessionRef)}).` }),
7013
6196
  board_run_session_ref_invalid: (details) => ({ text: `"${named(details.value)}" is not a session log. Give it as claude:<id> or codex:<id>.` }),
7014
6197
  board_run_session_ref_requires_user: () => ({
7015
6198
  text: "Only a person can change or clear the session log a run names.",
@@ -7033,22 +6216,19 @@ const BOARD_ERROR_TEXTS = {
7033
6216
  next: AS_A_PERSON
7034
6217
  }),
7035
6218
  issue_comment_not_found: (details) => ({ text: `No comment "${named(details.commentId)}" on this board.` }),
7036
- issue_context_ref_invalid_index: (details) => ({
7037
- text: `${named(details.issueId)} has no context link number ${named(details.index)}.`,
7038
- next: `kanbo card get ${named(details.issueId)}`
7039
- }),
6219
+ issue_context_ref_invalid_index: (details) => ({ text: `${named(details.issueId)} has no context link number ${named(details.index)}.` }),
7040
6220
  issue_milestone_not_found: (details) => ({
7041
6221
  text: `No sprint "${named(details.milestoneId)}" on this board.`,
7042
6222
  next: "kanbo sprint list"
7043
6223
  }),
7044
6224
  issue_not_found: (details) => ({
7045
6225
  text: `No card "${named(details.issueId)}" here.`,
7046
- next: "kanbo card list"
6226
+ next: "kanbo board"
7047
6227
  }),
7048
6228
  issue_parent_cycle: () => ({ text: "A card can't be put under one of its own sub-cards." }),
7049
6229
  issue_parent_not_found: (details) => ({
7050
6230
  text: `No card "${named(details.parentIssueId)}" here to put this one under.`,
7051
- next: "kanbo card list"
6231
+ next: "kanbo board"
7052
6232
  }),
7053
6233
  issue_parent_self_reference: () => ({ text: "A card can't be put under itself." }),
7054
6234
  issue_relation_not_found: (details) => ({ text: `No link "${named(details.relationId)}" between cards on this board.` }),
@@ -7661,29 +6841,9 @@ dirname(fileURLToPath(import.meta.url));
7661
6841
  * `package.version`. A build carries it as a literal; running from source
7662
6842
  * (tests, `tsx`) reads the package's own `package.json` instead.
7663
6843
  */
7664
- const KANBO_PACKAGE_VERSION = "0.6.0";
6844
+ const KANBO_PACKAGE_VERSION = "0.8.0";
7665
6845
  //#endregion
7666
6846
  //#region src/cli/setup/paths.ts
7667
- /**
7668
- * Where each agent tool keeps the files kanbo writes into, for one project and
7669
- * for the person's whole machine.
7670
- *
7671
- * Resolved on every call rather than at import, so `HOME` and `CODEX_HOME` set
7672
- * by a test (or a person) are the ones obeyed. Each path was checked against
7673
- * the tool's own documentation; `docs/agents.md` lists the pages.
7674
- */
7675
- /** The tools whose user-level instruction file `kanbo init --global` can write. */
7676
- const GLOBAL_INSTRUCTION_CLIENTS = [
7677
- "claude",
7678
- "codex",
7679
- "gemini"
7680
- ];
7681
- /** The tools that can be told about the board's MCP server. */
7682
- const MCP_CLIENTS = [
7683
- "claude",
7684
- "codex",
7685
- "cursor"
7686
- ];
7687
6847
  /** Where Codex keeps its global files: `$CODEX_HOME`, else `~/.codex`. */
7688
6848
  function codexHome() {
7689
6849
  return process.env.CODEX_HOME?.trim() || join(homedir(), ".codex");
@@ -7691,7 +6851,9 @@ function codexHome() {
7691
6851
  /**
7692
6852
  * Where Claude Code keeps its user files: `$CLAUDE_CONFIG_DIR`, else
7693
6853
  * `~/.claude`. Its documentation says every `~/.claude` path moves there; it
7694
- * does not say `~/.claude.json` does, so that one is not moved here.
6854
+ * does not say `~/.claude.json` does; the MCP file follows the variable
6855
+ * all the same, as `claude mcp add --scope user` writes it there
6856
+ * (`globalMcpConfigPath`).
7695
6857
  */
7696
6858
  function claudeConfigDir() {
7697
6859
  return process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude");
@@ -7700,7 +6862,7 @@ function claudeConfigDir() {
7700
6862
  function geminiDir() {
7701
6863
  return join(process.env.GEMINI_CLI_HOME?.trim() || homedir(), ".gemini");
7702
6864
  }
7703
- /** The user-level instruction file of each tool. */
6865
+ /** The user-level instruction file of each tool — where an older kanbo wrote its block, which `kanbo connect` takes out. */
7704
6866
  function globalInstructionPath(client) {
7705
6867
  switch (client) {
7706
6868
  case "claude": return join(claudeConfigDir(), "CLAUDE.md");
@@ -7710,12 +6872,13 @@ function globalInstructionPath(client) {
7710
6872
  }
7711
6873
  /**
7712
6874
  * The user-level MCP configuration of each tool. Claude Code's is
7713
- * `~/.claude.json`, its own state file: kanbo reads it but never writes it —
6875
+ * `.claude.json` in `$CLAUDE_CONFIG_DIR` (else in the home directory), its own
6876
+ * state file: resolved on every call, and kanbo reads it but never writes it —
7714
6877
  * `claude mcp add` and `claude mcp remove` do.
7715
6878
  */
7716
6879
  function globalMcpConfigPath(client) {
7717
6880
  switch (client) {
7718
- case "claude": return join(homedir(), ".claude.json");
6881
+ case "claude": return join(process.env.CLAUDE_CONFIG_DIR?.trim() || homedir(), ".claude.json");
7719
6882
  case "codex": return join(codexHome(), "config.toml");
7720
6883
  case "cursor": return join(homedir(), ".cursor", "mcp.json");
7721
6884
  }
@@ -7786,8 +6949,9 @@ function isExecutableFile(path) {
7786
6949
  //#region src/cli/setup/agents.ts
7787
6950
  /**
7788
6951
  * The agent tools kanbo can connect a project — or a person — to, and for
7789
- * each one: how to tell it is here, which file it reads its standing
7790
- * instructions from, and where it keeps its MCP servers.
6952
+ * each one: how to tell it is here, which file an older kanbo wrote
6953
+ * its instruction block into (kept to take that block out again), and where it
6954
+ * keeps its MCP servers.
7791
6955
  *
7792
6956
  * `kanbo connect`, `kanbo init`, `kanbo uninstall` and `kanbo doctor` all read
7793
6957
  * this one table, so a file one of them writes is a file the others find.
@@ -7821,8 +6985,7 @@ const AGENTS = {
7821
6985
  } : {
7822
6986
  format: "claude-cli",
7823
6987
  path: globalMcpConfigPath("claude")
7824
- },
7825
- mcpScope: "project"
6988
+ }
7826
6989
  },
7827
6990
  codex: {
7828
6991
  id: "codex",
@@ -7832,8 +6995,7 @@ const AGENTS = {
7832
6995
  mcpTarget: (scope, projectDir) => ({
7833
6996
  format: "toml",
7834
6997
  path: scope === "project" ? join(projectDir, ".codex", "config.toml") : globalMcpConfigPath("codex")
7835
- }),
7836
- mcpScope: "user"
6998
+ })
7837
6999
  },
7838
7000
  cursor: {
7839
7001
  id: "cursor",
@@ -7844,9 +7006,7 @@ const AGENTS = {
7844
7006
  format: "json",
7845
7007
  path: scope === "project" ? join(projectDir, ".cursor", "mcp.json") : globalMcpConfigPath("cursor"),
7846
7008
  withType: true
7847
- }),
7848
- mcpScope: "project",
7849
- noInstructionFile: "Cursor keeps its rules for every project in its settings, not in a file: paste the kanbo block into Cursor Settings → Rules → User Rules yourself."
7009
+ })
7850
7010
  },
7851
7011
  gemini: {
7852
7012
  id: "gemini",
@@ -7857,13 +7017,16 @@ const AGENTS = {
7857
7017
  format: "json",
7858
7018
  path: scope === "project" ? join(projectDir, ".gemini", "settings.json") : join(geminiDir(), "settings.json"),
7859
7019
  withType: false
7860
- }),
7861
- mcpScope: "project"
7020
+ })
7862
7021
  }
7863
7022
  };
7864
- /** Where the MCP server goes when no scope is named: on Windows always the person's own configuration. */
7865
- function defaultMcpScope(agent, platform = process.platform) {
7866
- return platform === "win32" ? "user" : AGENTS[agent].mcpScope;
7023
+ /**
7024
+ * Where the MCP server goes when no scope is named: the person's own
7025
+ * configuration, for every agent. One registration serves every project —
7026
+ * `kanbo mcp` finds the board from the folder the client starts it in.
7027
+ */
7028
+ function defaultMcpScope() {
7029
+ return "user";
7867
7030
  }
7868
7031
  /** The agent's name as typed on the command line, or `null` when it is none of them. */
7869
7032
  function parseAgentId(name) {
@@ -8006,66 +7169,10 @@ const INSTRUCTION_END = "<!-- KANBO_END -->";
8006
7169
  */
8007
7170
  const INSTRUCTION_START_PATTERN = /<!-- KANBO_START\b([^>]*?)\s*-->/g;
8008
7171
  const INSTRUCTION_END_PATTERN = /<!-- KANBO_END\s*-->/g;
8009
- /** The kinds of block that are kanbo's pointer — the one `kanbo connect` writes. */
7172
+ /** The kinds of block that are kanbo's pointer — the one kanbo 0.5–0.6 wrote. */
8010
7173
  const POINTER_KINDS = /* @__PURE__ */ new Set(["short", "global"]);
8011
- /** The commands an agent uses, spelled once for the guide and the `kanbo prime` cheat sheet. */
8012
- const COMMAND = {
8013
- claim: "kanbo claim",
8014
- move: "kanbo card move <id> <column>",
8015
- statusLine: "kanbo card status-line <id> --text \"...\"",
8016
- subtask: "kanbo card create --description \"...\" --parent <id>",
8017
- comment: "kanbo card comment <id> --content \"...\"",
8018
- waitApproval: "kanbo card wait-approval <id>",
8019
- runStart: "kanbo run start <id> --agent <name>",
8020
- capabilities: "kanbo capabilities"
8021
- };
8022
- /**
8023
- * The whole guide for an agent working a board from the command line — what
8024
- * the project block said in kanbo 0.1–0.2, before it became a pointer.
8025
- */
8026
- const AGENT_GUIDE_TEXT = [
8027
- "## Kanbo board",
8028
- "",
8029
- "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.",
8030
- "",
8031
- ...[
8032
- `- 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.`,
8033
- `- ${MOVE_CARD_RULE} (\`${COMMAND.move}\`)`,
8034
- `- ${STATUS_LINE_RULE} (\`${COMMAND.statusLine}\`)`,
8035
- `- ${SUBTASK_RULE} (\`${COMMAND.subtask}\`)`,
8036
- `- ${COMMENT_RULE} (\`${COMMAND.comment}\`)`,
8037
- `- ${WAIT_FOR_PERSON_RULE} (\`${COMMAND.waitApproval}\`)`,
8038
- `- ${PERSON_ONLY_RULE}`,
8039
- `- ${OWN_SESSION_RULE} (\`${COMMAND.runStart}\`)`,
8040
- `- Run \`${COMMAND.capabilities}\` for the full list of tools, commands and rules.`
8041
- ]
8042
- ].join("\n");
8043
- /**
8044
- * The commands, for an agent with no MCP tools: `kanbo prime` prints this under
8045
- * the board's rules, which say when to use each. Each is written out with its
8046
- * arguments, as a call that works — agents that had only the placeholders read
8047
- * `kanbo capabilities` first, or guessed an option and ran it again
8048
- * (docs/performance.md).
8049
- */
8050
- const COMMAND_SHEET = [
8051
- "Commands (a card is named by its id, e.g. TST-5):",
8052
- "",
8053
- "- `kanbo ready` — the cards you may take next; cards a person sent back to you come first",
8054
- "- `kanbo card get TST-5` — one card, with its latest comments",
8055
- "- `kanbo card list --waiting` (or `--returned`) — cards waiting for a person (or sent back), each with its last comment",
8056
- "- `kanbo card move TST-5 in_progress` — move a card; the column by its slug above",
8057
- "- `kanbo card status-line TST-5 --text \"Running the tests\"` — what is happening on the card right now",
8058
- "- `kanbo card comment TST-5 --content \"Added slugify with a test\"` — a finding, result, decision or question",
8059
- "- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
8060
- "- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
8061
- "- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
8062
- "- `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",
8063
- "- `kanbo run start TST-5 --agent claude` — record a run on a card you did not claim; it prints the finish command",
8064
- "- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
8065
- `- \`${COMMAND.capabilities}\` — everything else, rarely needed`
8066
- ].join("\n");
8067
7174
  /** How to take work and who approves it — said by both blocks after their first sentence. */
8068
- const POINTER_TAIL = `Take work with \`${COMMAND.claim}\`. ${PERSON_ONLY_RULE}`;
7175
+ const POINTER_TAIL = `Take work with \`kanbo claim\`. ${PERSON_ONLY_RULE}`;
8069
7176
  /** The body of the project block: what `kanbo instructions short` prints. */
8070
7177
  const PROJECT_BODY = [
8071
7178
  "## Kanbo board",
@@ -8088,9 +7195,9 @@ const GLOBAL_BODY = [
8088
7195
  function wrapInstructionBlock(body, version = 3, kind) {
8089
7196
  return `<!-- KANBO_START v${version}${kind ? ` k=${kind}` : ""} h=${hashBody(body)} -->\n${body}\n${INSTRUCTION_END}`;
8090
7197
  }
8091
- /** The block `kanbo init --instructions …` writes into a project's own file. */
7198
+ /** The project block kanbo 0.5–0.6 wrote into a project's own file. */
8092
7199
  const INSTRUCTION_BLOCK = wrapInstructionBlock(PROJECT_BODY);
8093
- /** The block `kanbo init --global` writes into a person's user-level file. */
7200
+ /** The global block kanbo 0.5–0.6 wrote into a person's user-level file. */
8094
7201
  const GLOBAL_INSTRUCTION_BLOCK = wrapInstructionBlock(GLOBAL_BODY);
8095
7202
  /** The same, with what the block's marker said. */
8096
7203
  function inspectBlock(text, current) {
@@ -8113,58 +7220,15 @@ function inspectBlock(text, current) {
8113
7220
  version: version === void 0 ? null : Number(version),
8114
7221
  kind
8115
7222
  });
8116
- if (fields.trim() === "") return inspection(LEGACY_BLOCK_BODIES.includes(body) ? "legacy" : "edited");
8117
- if (version !== void 0 && Number(version) > 3) return inspection("newer");
8118
- if (kind !== null && !POINTER_KINDS.has(kind)) return inspection("chosen");
8119
- if (version === void 0 || hash === void 0 || hashBody(body) !== hash) return inspection("edited");
8120
- return inspection(normalized.slice(found.start, found.end) === current ? "current" : "outdated");
8121
- }
8122
- /** What the person is told about a file whose markers do not pair up; `label` names the file. */
8123
- function damagedBlockMessage(label, damage) {
8124
- return `${label} has ${damage} — fix it by hand, then run again.`;
8125
- }
8126
- /** What the person is told about a block a newer kanbo wrote. */
8127
- function newerBlockMessage(label, version) {
8128
- return `${label}: the kanbo section was written by a newer kanbo (v${version ?? "?"}) — kept. Update kanbo: npm install -g kanbo-cli@latest`;
8129
- }
8130
- /**
8131
- * The block in that file, once.
8132
- *
8133
- * A file that already carries the markers has its block replaced rather than
8134
- * appended to, so a current block changes nothing at all and an outdated one is
8135
- * brought forward. Everything outside the markers is the person's own writing
8136
- * and is never touched.
8137
- */
8138
- function planInstructionBlock(path, block) {
8139
- return planTextFile(path, (existing) => withBlock(existing, block));
8140
- }
8141
- /**
8142
- * The same, minding a block that is not kanbo's to replace: one the person
8143
- * changed is replaced only when they say so at a terminal, and the default
8144
- * answer is no. `--yes` is not that answer — it agrees to kanbo's own changes,
8145
- * not to losing theirs. A block of a text the person chose, one a newer kanbo
8146
- * wrote, and a file whose markers do not pair up are left as they are.
8147
- */
8148
- async function planInstructionBlockAsking(path, block, options) {
8149
- const inspection = readInstructionBlockInspection(path, block);
8150
- const label = options.label ?? path;
8151
- const keep = (note) => ({
8152
- change: {
8153
- path,
8154
- next: null
8155
- },
8156
- note
8157
- });
8158
- switch (inspection?.state) {
8159
- case "damaged": return keep(damagedBlockMessage(label, inspection.damage));
8160
- case "newer": return keep(newerBlockMessage(label, inspection.version));
8161
- case "chosen": return keep(`${label}: you chose the ${inspection.kind} text for the kanbo section — kept.`);
8162
- case "edited": return !options.yes && canPrompt() && await getUi().confirm({
8163
- message: `You changed the kanbo section in ${label}. Replace it with the current one?`,
8164
- initialValue: false
8165
- }) ? { change: planInstructionBlock(path, block) } : keep(`${label}: you changed the kanbo section, so it was left as it is.`);
8166
- default: return { change: planInstructionBlock(path, block) };
8167
- }
7223
+ if (fields.trim() === "") return inspection(LEGACY_BLOCK_BODIES.includes(body) ? "legacy" : "edited");
7224
+ if (version !== void 0 && Number(version) > 3) return inspection("newer");
7225
+ if (kind !== null && !POINTER_KINDS.has(kind)) return inspection("chosen");
7226
+ if (version === void 0 || hash === void 0 || hashBody(body) !== hash) return inspection("edited");
7227
+ return inspection(normalized.slice(found.start, found.end) === current ? "current" : "outdated");
7228
+ }
7229
+ /** What the person is told about a file whose markers do not pair up; `label` names the file. */
7230
+ function damagedBlockMessage(label, damage) {
7231
+ return `${label} has ${damage} — fix it by hand, then run again.`;
8168
7232
  }
8169
7233
  /**
8170
7234
  * The file without the block, or no change when it carries none. The file
@@ -8220,13 +7284,6 @@ function readInstructionBlockInspection(path, current) {
8220
7284
  const text = readTextFile(path)?.text;
8221
7285
  return text === void 0 ? null : inspectBlock(text, current);
8222
7286
  }
8223
- function withBlock(existing, block) {
8224
- if (existing === null || !existing.trim()) return `${block}\n`;
8225
- const bounds = findBlock(existing);
8226
- if (bounds && "damage" in bounds) return null;
8227
- if (bounds) return existing.slice(0, bounds.start) + block + existing.slice(bounds.end);
8228
- return `${existing.replace(/\s*$/, "")}\n\n${block}\n`;
8229
- }
8230
7287
  /**
8231
7288
  * The first block in the text; `null` when there is none; `{ damage }` when
8232
7289
  * the markers do not pair up — every start followed by its end before the
@@ -8355,48 +7412,6 @@ function spawnDetached(command, args) {
8355
7412
  child.unref();
8356
7413
  } catch {}
8357
7414
  }
8358
- /**
8359
- * Run a program with this input on its stdin and say whether it exited `0`.
8360
- * Never throws: a program that is not installed, fails or hangs past the
8361
- * timeout is `false`, and the caller tries the next thing. Its output is
8362
- * dropped — nothing it prints reaches kanbo's own stdout.
8363
- */
8364
- function runWithInput(command, args, input, options) {
8365
- return new Promise((resolve) => {
8366
- let settled = false;
8367
- const settle = (ok) => {
8368
- if (!settled) {
8369
- settled = true;
8370
- clearTimeout(timer);
8371
- resolve(ok);
8372
- }
8373
- };
8374
- let child;
8375
- try {
8376
- child = crossSpawn(command, [...args], {
8377
- detached: options.detached ?? false,
8378
- stdio: [
8379
- "pipe",
8380
- "ignore",
8381
- "ignore"
8382
- ],
8383
- windowsHide: true
8384
- });
8385
- } catch {
8386
- resolve(false);
8387
- return;
8388
- }
8389
- const timer = setTimeout(() => {
8390
- child.kill();
8391
- settle(false);
8392
- }, options.timeoutMs);
8393
- child.on("error", () => settle(false));
8394
- child.on("exit", (code) => settle(code === 0));
8395
- child.stdin?.on("error", () => {});
8396
- child.stdin?.end(input);
8397
- if (options.detached) child.unref();
8398
- });
8399
- }
8400
7415
  //#endregion
8401
7416
  //#region src/cli/setup/mcp-config.ts
8402
7417
  /**
@@ -8527,7 +7542,7 @@ function readJsonMcpEntry(path) {
8527
7542
  return {
8528
7543
  command: typeof command === "string" && command.trim() ? command.trim() : null,
8529
7544
  args: Array.isArray(args) ? args.filter((arg) => typeof arg === "string") : [],
8530
- otherFields: Object.keys(entry).filter((key) => key !== "command" && key !== "args" && !(key === "type" && entry.type === "stdio"))
7545
+ otherFields: Object.keys(entry).filter((key) => key !== "command" && key !== "args" && !(key === "type" && entry.type === "stdio") && !(key === "env" && isPlainObject$1(entry.env) && Object.keys(entry.env).length === 0))
8531
7546
  };
8532
7547
  }
8533
7548
  /**
@@ -8560,7 +7575,7 @@ function planCodexMcpServer(path, launch = PORTABLE_MCP_LAUNCH, options = {}) {
8560
7575
  * Is this entry one kanbo wrote, in exactly the shape it writes it? `kanbo mcp`
8561
7576
  * (with `"type": "stdio"` or without), or a Node given by full path running
8562
7577
  * kanbo's own script (`…/kanbo-cli/dist/cli.cjs mcp`) — and nothing else: an
8563
- * entry with an `env`, a `cwd` or any other field, or one that starts kanbo
7578
+ * entry with a non-empty `env`, a `cwd` or any other field, or one that starts kanbo
8564
7579
  * some other way (`cmd /c kanbo mcp`, `npx -y kanbo-cli mcp`), is the
8565
7580
  * person's, and kanbo never rewrites or removes it without asking.
8566
7581
  */
@@ -8723,10 +7738,10 @@ function readJsonConfig(path, text) {
8723
7738
  try {
8724
7739
  parsed = JSON.parse(text);
8725
7740
  } catch {
8726
- throw new CliError(1, `${path} is not valid JSON. Fix or remove it, then run kanbo init again.`);
7741
+ throw new CliError(1, `${path} is not valid JSON. Fix or remove it, then run kanbo connect again.`);
8727
7742
  }
8728
- if (!isPlainObject$1(parsed)) throw new CliError(1, `${path} does not hold a JSON object. Fix or remove it, then run kanbo init again.`);
8729
- if (parsed.mcpServers !== void 0 && !isPlainObject$1(parsed.mcpServers)) throw new CliError(1, `${path} has an "mcpServers" that is not an object. Fix it, then run kanbo init again.`);
7743
+ if (!isPlainObject$1(parsed)) throw new CliError(1, `${path} does not hold a JSON object. Fix or remove it, then run kanbo connect again.`);
7744
+ if (parsed.mcpServers !== void 0 && !isPlainObject$1(parsed.mcpServers)) throw new CliError(1, `${path} has an "mcpServers" that is not an object. Fix it, then run kanbo connect again.`);
8730
7745
  return parsed;
8731
7746
  }
8732
7747
  /** The file's text as a JSON object, or `null` when it is missing or anything else — for reading and removal, which never refuse. */
@@ -8749,8 +7764,9 @@ function isPlainObject$1(value) {
8749
7764
  * worked out in full before anything is written.
8750
7765
  *
8751
7766
  * `kanbo connect` builds one of these from the agents and scopes it was given,
8752
- * `kanbo init --instructions/--mcp` and `kanbo init --global` build theirs the
8753
- * same way, and all of them show it, ask, and apply it here. Each item is one
7767
+ * `kanbo init --connect` builds its the same way, and both show it, ask, and
7768
+ * apply it here. kanbo writes only the MCP server into an agent's settings; the
7769
+ * `instructions` items take out the section older versions wrote. Each item is one
8754
7770
  * file (or one `claude mcp` command) with what changes in it; an item whose
8755
7771
  * file already says it has `next: null` and is reported `unchanged`.
8756
7772
  */
@@ -8766,8 +7782,10 @@ function ownMcpEntryNote(label, path) {
8766
7782
  return `${label}: ${path} has your own kanbo entry — left as is.`;
8767
7783
  }
8768
7784
  /**
8769
- * The plan that connects these agents. Asks only one question, and only at a
8770
- * terminal: whether to replace an instruction block the person changed.
7785
+ * The plan that connects these agents: their MCP server registered, and the
7786
+ * kanbo section older versions wrote into their instruction files taken out.
7787
+ * Asks nothing: a section a person changed (or chose, or a newer kanbo wrote)
7788
+ * is kept and named in a note, never removed without their say.
8771
7789
  */
8772
7790
  async function planConnect(requests, context) {
8773
7791
  const plan = {
@@ -8776,29 +7794,36 @@ async function planConnect(requests, context) {
8776
7794
  notes: []
8777
7795
  };
8778
7796
  const planned = /* @__PURE__ */ new Set();
8779
- for (const { agent, instructions: scope } of requests) {
8780
- if (scope === null) continue;
8781
- const path = AGENTS[agent].instructionPath(scope, context.projectDir);
8782
- if (path === null) {
8783
- plan.notes.push(AGENTS[agent].noInstructionFile ?? `${AGENTS[agent].label} has no instruction file for this scope.`);
8784
- continue;
8785
- }
8786
- const existing = plan.items.find((item) => item.kind === "instructions" && item.change.path === path);
8787
- if (existing?.kind === "instructions") {
8788
- existing.agents.push(agent);
8789
- continue;
7797
+ for (const { agent, instructions } of requests) {
7798
+ if (instructions === null) continue;
7799
+ for (const scope of instructions === "both" ? ["project", "user"] : [instructions]) {
7800
+ const path = AGENTS[agent].instructionPath(scope, context.projectDir);
7801
+ if (path === null) continue;
7802
+ const existing = plan.items.find((item) => item.kind === "instructions" && item.change.path === path);
7803
+ if (existing?.kind === "instructions") {
7804
+ existing.agents.push(agent);
7805
+ continue;
7806
+ }
7807
+ const label = displayPath(plan, path);
7808
+ const inspection = readInstructionBlockInspection(path, INSTRUCTION_BLOCK);
7809
+ switch (inspection?.state) {
7810
+ case void 0: break;
7811
+ case "damaged":
7812
+ plan.notes.push(damagedBlockMessage(label, inspection.damage));
7813
+ break;
7814
+ case "edited":
7815
+ case "chosen":
7816
+ case "newer":
7817
+ plan.notes.push(`${label}: an old kanbo section is still there; kanbo no longer uses it. Delete it yourself.`);
7818
+ break;
7819
+ default: plan.items.push({
7820
+ kind: "instructions",
7821
+ agents: [agent],
7822
+ scope,
7823
+ change: planInstructionBlockRemoval(path)
7824
+ });
7825
+ }
8790
7826
  }
8791
- const asked = await planInstructionBlockAsking(path, scope === "project" ? INSTRUCTION_BLOCK : GLOBAL_INSTRUCTION_BLOCK, {
8792
- yes: context.yes,
8793
- label: displayPath(plan, path)
8794
- });
8795
- if (asked.note) plan.notes.push(asked.note);
8796
- plan.items.push({
8797
- kind: "instructions",
8798
- agents: [agent],
8799
- scope,
8800
- change: asked.change
8801
- });
8802
7827
  }
8803
7828
  for (const { agent, mcp: scope } of requests) {
8804
7829
  if (scope === null) continue;
@@ -8969,11 +7994,10 @@ function readMcpState(agent, scope, projectDir, platform) {
8969
7994
  if (!isKanboEntry(entry, scope, platform)) return "own";
8970
7995
  return isStaleMcpEntry(entry, scope, platform) ? "stale" : "ok";
8971
7996
  }
8972
- /** What the instruction block for one agent in one scope is; `null` when that scope has no file for it. */
8973
- function readInstructionState(agent, scope, projectDir) {
7997
+ /** The file that still carries a kanbo section from an older version, in one agent's scope; none when it carries none. */
7998
+ function readOldSectionPaths(agent, scope, projectDir) {
8974
7999
  const path = AGENTS[agent].instructionPath(scope, projectDir);
8975
- if (path === null) return null;
8976
- return readInstructionBlockState(path, scope === "project" ? INSTRUCTION_BLOCK : GLOBAL_INSTRUCTION_BLOCK) ?? "missing";
8000
+ return path !== null && readInstructionBlockState(path, INSTRUCTION_BLOCK) !== null ? [path] : [];
8977
8001
  }
8978
8002
  /** The items that change something. */
8979
8003
  function pendingItems(plan) {
@@ -8989,7 +8013,7 @@ function explainItem(item, removal = false) {
8989
8013
  case "instructions": {
8990
8014
  const readers = item.agents.map((agent) => AGENTS[agent].label).join(" and ");
8991
8015
  const one = item.agents.length === 1;
8992
- return removal ? `the kanbo section ${readers} read${one ? "s" : ""} ${where}` : `the kanbo section: ${readers} read${one ? "s" : ""} it ${where} and run${one ? "s" : ""} kanbo prime before a task`;
8016
+ return removal ? `the kanbo section ${readers} read${one ? "s" : ""} ${where}` : "the old kanbo section: agents now learn the board from the MCP server";
8993
8017
  }
8994
8018
  case "mcp": return removal ? `the kanbo MCP server ${AGENTS[item.agent].label} starts ${where}` : `${item.stale ? "rewrites a kanbo MCP entry that no longer starts: " : ""}lets ${AGENTS[item.agent].label} call the board's tools ${where}`;
8995
8019
  case "claude-cli": return item.argvs.map((argv) => displayCommand(argv)).join(", then ");
@@ -9004,10 +8028,10 @@ function describeConnectPlan(plan, title, removal = false) {
9004
8028
  "Nothing else is changed."
9005
8029
  ].join("\n");
9006
8030
  }
9007
- /** One line per change the plan makes: ` write CLAUDE.md (what it is for)`. */
8031
+ /** One line per change the plan makes: ` write .mcp.json (what it is for)`. */
9008
8032
  function describeConnectItems(plan, removal = false) {
9009
8033
  return pendingItems(plan).map((item) => {
9010
- const verb = item.kind === "claude-cli" ? "run " : removal ? "edit " : "write";
8034
+ const verb = item.kind === "claude-cli" ? "run " : item.kind === "instructions" ? "remove" : removal ? "edit " : "write";
9011
8035
  return item.kind === "claude-cli" ? ` ${verb} ${explainItem(item, removal)}` : ` ${verb} ${displayPath(plan, itemPath(item))} (${explainItem(item, removal)})`;
9012
8036
  });
9013
8037
  }
@@ -9057,6 +8081,17 @@ function applyConnectPlan(plan) {
9057
8081
  });
9058
8082
  }
9059
8083
  /**
8084
+ * What happened to one item, as a person reads it in the parentheses of an
8085
+ * outcome line: an old kanbo section taken out of a file is "removed", not
8086
+ * "written" (kanbo writes no section any more); a `claude mcp` command is
8087
+ * "ran …". `removal` is `kanbo connect --remove`, where every change is a removal.
8088
+ */
8089
+ function describeOutcomeState(outcome, removal = false) {
8090
+ if (outcome.kind === "instructions" && outcome.state === "written") return "old kanbo section removed";
8091
+ if (outcome.state === "ran") return `ran ${outcome.command}`;
8092
+ return outcome.state === "written" && removal ? "removed" : outcome.state;
8093
+ }
8094
+ /**
9060
8095
  * What the person reads about `claude mcp` commands left to them: each on a
9061
8096
  * line of its own, so each can be pasted into any shell — PowerShell has no `&&`.
9062
8097
  */
@@ -9144,7 +8179,7 @@ function startUpdateCheck(deps = {}) {
9144
8179
  setTimeout: globalThis.setTimeout,
9145
8180
  clearTimeout: globalThis.clearTimeout
9146
8181
  };
9147
- const current = deps.currentVersion ?? "0.6.0";
8182
+ const current = deps.currentVersion ?? "0.8.0";
9148
8183
  const controller = new AbortController();
9149
8184
  let found = null;
9150
8185
  const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
@@ -9423,8 +8458,8 @@ async function checkBoard(target, workspaceId, root) {
9423
8458
  /** The Standard columns, as the plan names them. */
9424
8459
  const STANDARD_COLUMN_NAMES = COLUMN_TEMPLATES.standard.map((column) => column.name).join(", ");
9425
8460
  /**
9426
- * A board with no columns gives agents nothing to take: `kanbo prime` lists
9427
- * none and `kanbo ready` has no To Do. Boards from kanbo 0.1–0.2 got their
8461
+ * A board with no columns gives agents nothing to take: `kanbo_prime` lists
8462
+ * none and `kanbo_ready` has no To Do. Boards from kanbo 0.1–0.2 got their
9428
8463
  * columns only with the first card.
9429
8464
  */
9430
8465
  async function checkColumns(store, workspaceId, open) {
@@ -9432,7 +8467,7 @@ async function checkColumns(store, workspaceId, open) {
9432
8467
  return [{
9433
8468
  check: "columns",
9434
8469
  status: "warn",
9435
- detail: "This board has no columns: kanbo prime shows agents none, and kanbo ready has no To Do to take cards from.",
8470
+ detail: "This board has no columns: kanbo_prime shows agents none, and kanbo_ready has no To Do to take cards from.",
9436
8471
  fix: "Run kanbo doctor --fix, or kanbo columns template standard.",
9437
8472
  repair: {
9438
8473
  plan: [` add the Standard columns to the board: ${STANDARD_COLUMN_NAMES}`],
@@ -9486,9 +8521,11 @@ async function checkPostgresBoard(url, workspaceId) {
9486
8521
  }
9487
8522
  }
9488
8523
  /**
9489
- * Every instruction block kanbo could have written, compared with the one this
9490
- * build writes. Only files that carry a block are reported: a file without one
9491
- * is not a fault, but having no block anywhere is worth a warning.
8524
+ * Every instruction block an older kanbo could have written, in the project's
8525
+ * and the person's own agent files. kanbo writes none any more, so a file
8526
+ * without one is as it should be, and a file with one is worth a warning: the
8527
+ * agent learns the board from the MCP server and the block is dead text. Only
8528
+ * files that carry a block are reported.
9492
8529
  */
9493
8530
  async function checkInstructions(root) {
9494
8531
  const findings = [];
@@ -9504,9 +8541,8 @@ async function checkInstructions(root) {
9504
8541
  for (const { scope, root: folder, block } of scopes) for (const { path, agents } of instructionPaths(scope, folder)) {
9505
8542
  const inspection = readInstructionBlockInspection(path, block);
9506
8543
  if (inspection !== null) {
9507
- const command = `kanbo connect ${agents[0]} ${scope === "project" ? "--project" : "--global"} --no-mcp`;
9508
- const finding = blockFinding(path, inspection, scope === "project" ? `cd ${folder} && ${command}` : command);
9509
- if (inspection.state === "outdated" || inspection.state === "legacy") finding.repair = connectRepair(await planConnect([{
8544
+ const finding = blockFinding(path, inspection, agents[0]);
8545
+ if (inspection.state === "current" || inspection.state === "outdated" || inspection.state === "legacy") finding.repair = connectRepair(await planConnect([{
9510
8546
  agent: agents[0],
9511
8547
  instructions: scope,
9512
8548
  mcp: null
@@ -9517,38 +8553,17 @@ async function checkInstructions(root) {
9517
8553
  findings.push(finding);
9518
8554
  }
9519
8555
  }
9520
- if (findings.length === 0) findings.push({
9521
- check: "instructions",
9522
- status: "warn",
9523
- detail: "No kanbo instruction block in this project's or your own agent files: agents learn the rules only from the MCP server.",
9524
- fix: "Run kanbo connect in the project (kanbo connect claude --yes, say), or kanbo connect claude --global."
9525
- });
9526
8556
  return findings;
9527
8557
  }
9528
8558
  /**
9529
- * A block kanbo wrote and nobody changed is only out of date: it still sends
9530
- * the agent to `kanbo prime`, so it is a warning, and `--yes` rewrites it. A
9531
- * block the person changed is theirs, and only they can say to replace it; a
9532
- * text they chose, or a block a newer kanbo wrote, is fine as it is. A file
9533
- * whose markers do not pair up is one kanbo will not touch until it is fixed.
8559
+ * A block kanbo wrote and nobody changed is only dead text: it is a warning,
8560
+ * and `--fix` removes it. A block the person changed or chose, or one a newer
8561
+ * kanbo wrote, is theirs to delete. A file whose markers do not pair up is one
8562
+ * kanbo will not touch until it is fixed.
9534
8563
  */
9535
- function blockFinding(path, inspection, command) {
8564
+ function blockFinding(path, inspection, agent) {
8565
+ const detail = `${path}: an old kanbo section — agents now learn the board from the MCP server.`;
9536
8566
  switch (inspection.state) {
9537
- case "current": return {
9538
- check: "instructions",
9539
- status: "ok",
9540
- detail: `${path}: block is current.`
9541
- };
9542
- case "chosen": return {
9543
- check: "instructions",
9544
- status: "ok",
9545
- detail: `${path}: you chose the ${inspection.kind} text for the kanbo section; kanbo leaves it alone.`
9546
- };
9547
- case "newer": return {
9548
- check: "instructions",
9549
- status: "ok",
9550
- detail: `${path}: written by a newer kanbo (v${inspection.version}) — update kanbo: npm install -g kanbo-cli@latest`
9551
- };
9552
8567
  case "damaged": {
9553
8568
  const message = damagedBlockMessage(path, inspection.damage);
9554
8569
  return {
@@ -9558,17 +8573,19 @@ function blockFinding(path, inspection, command) {
9558
8573
  fix: message
9559
8574
  };
9560
8575
  }
9561
- case "edited": return {
8576
+ case "edited":
8577
+ case "chosen":
8578
+ case "newer": return {
9562
8579
  check: "instructions",
9563
8580
  status: "warn",
9564
- detail: `${path}: you edited the kanbo block, so kanbo leaves it alone.`,
9565
- fix: `Keep it, or run ${command} in a terminal and answer yes to replace it with the current one.`
8581
+ detail,
8582
+ fix: "Delete it yourself."
9566
8583
  };
9567
8584
  default: return {
9568
8585
  check: "instructions",
9569
8586
  status: "warn",
9570
- detail: `${path}: block was written by an older kanbo.`,
9571
- fix: `Run kanbo doctor --fix, or ${command} --yes to rewrite it.`
8587
+ detail,
8588
+ fix: `Run kanbo doctor --fix (or kanbo connect ${agent} --yes) to remove it.`
9572
8589
  };
9573
8590
  }
9574
8591
  }
@@ -9594,8 +8611,8 @@ async function checkRegistrations(registrations, platform, projectDir) {
9594
8611
  if (registrations.length === 0) return [{
9595
8612
  check: "mcp",
9596
8613
  status: "warn",
9597
- detail: "No MCP client has the kanbo server registered.",
9598
- fix: "Run kanbo connect in the project (kanbo connect claude --yes, say), or kanbo connect claude --global."
8614
+ detail: "No coding agent reaches kanbo: its MCP server is registered nowhere.",
8615
+ fix: "kanbo connect claude --yes (or codex, cursor, gemini)"
9599
8616
  }];
9600
8617
  const findings = [];
9601
8618
  for (const registration of registrations) {
@@ -9668,7 +8685,7 @@ function connectRepair(plan) {
9668
8685
  switch (outcome.state) {
9669
8686
  case "manual": return describeManualOutcome(AGENTS[outcome.agents[0]].label, outcome);
9670
8687
  case "ran": return `ran ${outcome.command}`;
9671
- default: return `${outcome.state} ${displayPath(plan, outcome.path)}`;
8688
+ default: return outcome.kind === "instructions" && outcome.state === "written" ? `removed the old kanbo section from ${displayPath(plan, outcome.path)}` : `${outcome.state} ${displayPath(plan, outcome.path)}`;
9672
8689
  }
9673
8690
  })
9674
8691
  };
@@ -9682,7 +8699,7 @@ function isAbsoluteOn(platform, path) {
9682
8699
  * alone would register kanbo for the person and leave the project's entry
9683
8700
  * there, so the fix is two commands, each on its own line.
9684
8701
  */
9685
- const CODEX_PROJECT_CMD_FIX = "Codex on Windows can't start a .cmd. Remove the project entry, then register kanbo for your user:\n kanbo connect codex --project --remove --no-instructions\n kanbo connect codex";
8702
+ const CODEX_PROJECT_CMD_FIX = "Codex on Windows can't start a .cmd. Remove the project entry, then register kanbo for your user:\n kanbo connect codex --project --remove\n kanbo connect codex";
9686
8703
  /**
9687
8704
  * A registration that names its program by full path — on Windows, a Node and
9688
8705
  * the kanbo script it runs. Both have to still be there: a Node upgrade or a
@@ -9850,7 +8867,7 @@ async function confirmPlan(input) {
9850
8867
  //#region src/cli/commands/connect.ts
9851
8868
  const AGENT_LIST = `${AGENT_IDS.join(", ")} (or all)`;
9852
8869
  function registerConnectCommand(program) {
9853
- program.command("connect").alias("setup").argument("[agents...]", `the agents to connect: ${AGENT_LIST}`).description("Connect your coding agents to the board: instructions and MCP server").option("--project", "write into this project's files only").option("--global", "write into your own files, for every project").option("--no-instructions", "leave the agents' instruction files alone").option("--no-mcp", "leave the agents' MCP settings alone").option("--check", "show what is connected; exits 1 when a named agent is not").option("--remove", "disconnect: take kanbo out of their instructions and MCP settings").option("--dry-run", "show what would change, and change nothing").option("--yes", "make the changes without asking").option("--json [fields]", "print the result as JSON; name comma-separated fields to print only those").action(async (names, options) => {
8870
+ program.command("connect").alias("setup").argument("[agents...]", `the agents to connect: ${AGENT_LIST}`).description("Connect your coding agents to the board: its MCP server, for every project (--project: this project only)").option("--project", "write into this project's files only").option("--global", "write into your own files, for every project (the default)").option("--check", "show what is connected; exits 1 when a named agent is not").option("--remove", "disconnect: take kanbo out of their MCP settings and instruction files").option("--dry-run", "show what would change, and change nothing").option("--yes", "make the changes without asking").option("--json [fields]", "print the result as JSON; name comma-separated fields to print only those").action(async (names, options) => {
9854
8871
  await connect(names, options);
9855
8872
  });
9856
8873
  }
@@ -9875,7 +8892,7 @@ async function connect(names, options) {
9875
8892
  await disconnect(agents, scope, projectDir, options);
9876
8893
  return;
9877
8894
  }
9878
- const plan = await planConnect(agents.map((agent) => request(agent, scope, options)), {
8895
+ const plan = await planConnect(agents.map((agent) => request(agent, scope)), {
9879
8896
  projectDir,
9880
8897
  yes: options.yes || options.dryRun
9881
8898
  });
@@ -9898,19 +8915,23 @@ async function connect(names, options) {
9898
8915
  }
9899
8916
  printResult(describeOutcomes("connect", agents, applyConnectPlan(plan), plan, false, previewed), outputOptions(options));
9900
8917
  }
9901
- /** What one agent gets: its instructions where the scope says, and its MCP server where the scope — or the agent — says. */
9902
- function request(agent, scope, options) {
8918
+ /**
8919
+ * What one agent gets: its MCP server where the scope says (the person's own
8920
+ * configuration by default), and the old kanbo section taken out of its
8921
+ * instruction file in the same scope — in both files when none is named.
8922
+ */
8923
+ function request(agent, scope) {
9903
8924
  return {
9904
8925
  agent,
9905
- instructions: options.instructions === false ? null : scope ?? "project",
9906
- mcp: options.mcp === false ? null : scope ?? defaultMcpScope(agent)
8926
+ instructions: scope ?? "both",
8927
+ mcp: scope ?? defaultMcpScope()
9907
8928
  };
9908
8929
  }
9909
8930
  async function disconnect(agents, scope, projectDir, options) {
9910
8931
  const plan = await planDisconnect((scope ? [scope] : ["project", "user"]).flatMap((each) => agents.map((agent) => ({
9911
8932
  agent,
9912
- instructions: options.instructions === false ? null : each,
9913
- mcp: options.mcp === false ? null : each
8933
+ instructions: each,
8934
+ mcp: each
9914
8935
  }))), {
9915
8936
  projectDir,
9916
8937
  yes: options.yes,
@@ -9946,14 +8967,12 @@ async function askAgents$1(projectDir) {
9946
8967
  });
9947
8968
  }
9948
8969
  /**
9949
- * Show the section the agents get, let the person untick any file, then show
9950
- * what is left and ask once. Unticked files are dropped from the plan.
8970
+ * Let the person untick any change, then show what is left and ask once.
8971
+ * Unticked changes are dropped from the plan.
9951
8972
  */
9952
8973
  async function chooseAndConfirm(plan) {
9953
8974
  const ui = getUi();
9954
8975
  const pending = pendingItems(plan);
9955
- const section = pending.find((item) => item.kind === "instructions");
9956
- if (section) console.log(`The kanbo section your agents get:\n\n${section.scope === "project" ? INSTRUCTION_BLOCK : GLOBAL_INSTRUCTION_BLOCK}\n`);
9957
8976
  const keys = pending.map((_, index) => `${index}`);
9958
8977
  const kept = new Set(await ui.multiselect({
9959
8978
  message: "Which of these should kanbo change?",
@@ -9984,10 +9003,7 @@ function describeOutcomes(action, agents, outcomes, plan, dryRun, notesShown = f
9984
9003
  const who = outcome.agents.map((agent) => AGENTS[agent].label).join(", ");
9985
9004
  const where = displayPath(plan, outcome.path);
9986
9005
  if (outcome.state === "manual") lines.push(describeManualOutcome(who, outcome));
9987
- else {
9988
- const state = outcome.state === "written" && action === "remove" ? "removed" : outcome.state;
9989
- lines.push(`${who}: ${where} (${state === "ran" ? `ran ${outcome.command}` : state})`);
9990
- }
9006
+ else lines.push(`${who}: ${where} (${describeOutcomeState(outcome, action === "remove")})`);
9991
9007
  }
9992
9008
  if (!dryRun && !notesShown) lines.push(...plan.notes.map((note) => `Note: ${note}`));
9993
9009
  if (action === "connect" && !dryRun) lines.push("Check it with kanbo connect --check.");
@@ -10014,73 +9030,61 @@ function describeOutcomes(action, agents, outcomes, plan, dryRun, notesShown = f
10014
9030
  };
10015
9031
  }
10016
9032
  function runCheck(agents, named, flag, projectDir, options) {
10017
- const checked = agents.map((agent) => checkAgent(agent, flag, projectDir, options));
9033
+ const checked = agents.map((agent) => checkAgent(agent, flag, projectDir));
10018
9034
  const rows = checked.map(({ row }) => row);
10019
9035
  const text = [[
10020
9036
  "agent",
10021
- "instructions",
10022
9037
  "MCP",
10023
9038
  "scope"
10024
9039
  ], ...rows.map((row) => [
10025
9040
  row.agent,
10026
- row.instructions,
10027
9041
  row.mcp,
10028
9042
  row.scope
10029
9043
  ])];
10030
9044
  const widths = text[0].map((_, column) => Math.max(...text.map((line) => line[column].length)));
10031
9045
  const notes = checked.flatMap(({ notes }) => notes);
9046
+ const oldSections = [...new Set(rows.flatMap((row) => row.oldSections))];
10032
9047
  printResult({
10033
9048
  value: rows,
10034
- text: [...text.map((line) => line.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd()), ...notes.map((note) => `Note: ${note}`)].join("\n")
9049
+ text: [
9050
+ ...text.map((line) => line.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd()),
9051
+ ...oldSections.map((path) => `old kanbo section in ${path} — kanbo connect removes it`),
9052
+ ...notes.map((note) => `Note: ${note}`)
9053
+ ].join("\n")
10035
9054
  }, outputOptions(options));
10036
9055
  const missing = rows.filter((row) => !row.connected);
10037
9056
  if (named && missing.length > 0) throw new CliError(1, `Not connected: ${missing.map((row) => row.agent).join(", ")}. Run kanbo connect ${missing.map((row) => row.agent).join(" ")}.`);
10038
9057
  }
10039
9058
  /**
10040
- * Where an agent stands: its kanbo section and its MCP entry, looked for in the
10041
- * scope named — or, when none was, in the project first and then the person's
10042
- * own files. Connected means both are there (or were not asked about) and the
10043
- * MCP entry still starts. An MCP entry of the person's own is theirs: it is
10044
- * reported, left as it is, and counts as connected when it can start.
9059
+ * Where an agent stands: its MCP entry, looked for in the scope named — or,
9060
+ * when none was, in the project first and then the person's own files.
9061
+ * Connected means the entry is there and still starts. An MCP entry of the
9062
+ * person's own is theirs: it is reported, left as it is, and counts as
9063
+ * connected when it can start. Files that still carry a kanbo section from an
9064
+ * older version are listed, for `kanbo connect` to take out.
10045
9065
  */
10046
- function checkAgent(agent, flag, projectDir, options) {
9066
+ function checkAgent(agent, flag, projectDir) {
10047
9067
  const scopes = flag ? [flag] : ["project", "user"];
10048
- const found = [];
10049
9068
  const notes = [];
10050
- let instructions = "skipped";
10051
- if (options.instructions !== false) {
10052
- const states = scopes.map((scope) => ({
10053
- scope,
10054
- state: readInstructionState(agent, scope, projectDir)
10055
- }));
10056
- const present = states.find(({ state }) => state !== null && state !== "missing");
10057
- instructions = present?.state ?? (states.every(({ state }) => state === null) ? "no file" : "missing");
10058
- if (present) found.push(present.scope);
10059
- }
10060
- let mcp = "skipped";
10061
- if (options.mcp !== false) {
10062
- const states = scopes.map((scope) => ({
10063
- scope,
10064
- state: readMcpState(agent, scope, projectDir)
10065
- }));
10066
- const present = states.find(({ state }) => state === "ok") ?? states.find(({ state }) => state === "own") ?? states.find(({ state }) => state === "stale");
10067
- mcp = present ? MCP_CELLS[present.state] : "missing";
10068
- if (present?.state === "own") {
10069
- const target = AGENTS[agent].mcpTarget(present.scope, projectDir);
10070
- const startable = canStartMcpEntry(readMcpEntry(target));
10071
- mcp = startable ? "yours" : "yours, won't start";
10072
- notes.push(`${ownMcpEntryNote(AGENTS[agent].label, target.path)}${startable ? "" : " It can't start here: its command is not found."}`);
10073
- }
10074
- if (present) found.push(present.scope);
9069
+ const states = scopes.map((scope) => ({
9070
+ scope,
9071
+ state: readMcpState(agent, scope, projectDir)
9072
+ }));
9073
+ const present = states.find(({ state }) => state === "ok") ?? states.find(({ state }) => state === "own") ?? states.find(({ state }) => state === "stale");
9074
+ let mcp = present ? MCP_CELLS[present.state] : "missing";
9075
+ if (present?.state === "own") {
9076
+ const target = AGENTS[agent].mcpTarget(present.scope, projectDir);
9077
+ const startable = canStartMcpEntry(readMcpEntry(target));
9078
+ mcp = startable ? "yours" : "yours, won't start";
9079
+ notes.push(`${ownMcpEntryNote(AGENTS[agent].label, target.path)}${startable ? "" : " It can't start here: its command is not found."}`);
10075
9080
  }
10076
- const where = [...new Set(found)];
10077
9081
  return {
10078
9082
  row: {
10079
9083
  agent,
10080
- instructions,
10081
9084
  mcp,
10082
- scope: where.length > 0 ? where.join("+") : flag ?? defaultMcpScope(agent),
10083
- connected: instructions !== "missing" && instructions !== "damaged" && (mcp === "skipped" || mcp === "ok" || mcp === "yours")
9085
+ scope: present ? present.scope : flag ?? defaultMcpScope(),
9086
+ connected: mcp === "ok" || mcp === "yours",
9087
+ oldSections: scopes.flatMap((scope) => readOldSectionPaths(agent, scope, projectDir))
10084
9088
  },
10085
9089
  notes
10086
9090
  };
@@ -10344,31 +9348,10 @@ function assertOwnBoardFile(absolutePath) {
10344
9348
  function notABoardFileMessage(absolutePath) {
10345
9349
  return `${absolutePath} already holds a database of its own. Name another path with --file, or move that file out of the way.`;
10346
9350
  }
10347
- /** The plan as a person reads it before saying yes. */
10348
- function describeInitPlan(plan) {
10349
- const lines = ["kanbo init will:"];
10350
- const columns = plan.columns ? `, with the columns ${plan.columns.map((column) => column.name).join(", ")}` : "";
10351
- if (plan.boardFile) {
10352
- const what = plan.boardFile.exists ? "the board file of this project, brought up to date" : `a board file of this project's own${columns}`;
10353
- lines.push(` ${plan.boardFile.exists ? "update" : "create"} ${relativeTo(plan, plan.boardFile.path)} (${what})`);
10354
- }
10355
- if (plan.target.kind === "postgres" && plan.migrate) lines.push(` create the board's tables in ${maskDatabaseUrl(plan.target.url)}${columns}`);
10356
- lines.push(` write ${BINDING_FILE_PATH} (this project uses board ${plan.workspace.name})`);
10357
- for (const { kind, change } of plan.fileChanges) if (change.next !== null) lines.push(` write ${relativeTo(plan, change.path)} (${kind === "instructions" ? "the kanbo section for agents" : "the kanbo MCP server"})`);
10358
- for (const item of plan.connect ? pendingItems(plan.connect) : []) lines.push(item.kind === "claude-cli" ? ` run ${explainItem(item)}` : ` write ${displayPath(plan, itemPath(item))} (${explainItem(item)})`);
10359
- if (plan.firstCard) lines.push(` add a first card to To Do: ${plan.firstCard}`);
10360
- for (const note of plan.connect?.notes ?? []) lines.push(`Note: ${note}`);
10361
- lines.push("Nothing else is changed.");
10362
- return lines.join("\n");
10363
- }
10364
- function relativeTo(plan, path) {
10365
- const inside = relative(plan.projectDir, path);
10366
- return inside.startsWith("..") ? path : inside;
10367
- }
10368
9351
  /**
10369
9352
  * Carry the plan out: the board first — the file, or the shared database's
10370
9353
  * tables — so a migration that fails leaves no binding pointing at nothing;
10371
- * then the binding; then the agent files; then the first card.
9354
+ * then the binding; then the agents' settings; then the first card.
10372
9355
  */
10373
9356
  async function applyInitPlan(plan) {
10374
9357
  let columns = [];
@@ -10382,15 +9365,10 @@ async function applyInitPlan(plan) {
10382
9365
  if (plan.target.kind === "postgres" && plan.migrate) columns = await migrateSharedBoard(plan.target.url, plan.workspace.id, plan.columns);
10383
9366
  const bindingPath = writeBinding(plan.projectDir, plan.binding);
10384
9367
  rememberProjectQuietly(plan.projectDir);
10385
- const files = plan.fileChanges.map(({ kind, change }) => ({
10386
- kind,
10387
- ...applyFileChange(change)
10388
- }));
10389
9368
  const connect = plan.connect ? applyConnectPlan(plan.connect) : [];
10390
9369
  const firstCard = plan.firstCard ? await addFirstCard(plan, plan.firstCard) : null;
10391
9370
  return {
10392
9371
  bindingPath,
10393
- files,
10394
9372
  connect,
10395
9373
  columns,
10396
9374
  firstCard
@@ -10451,7 +9429,7 @@ async function withBoardOps(target, body) {
10451
9429
  * `kanbo init --file` a no-op.
10452
9430
  *
10453
9431
  * The chosen columns go into a new board before it is moved into place, for
10454
- * `seed`'s workspace: `kanbo prime` right after `init` then has columns to show.
9432
+ * `seed`'s workspace: `kanbo_prime` right after `init` then has columns to show.
10455
9433
  * Returns the names of the columns put there.
10456
9434
  */
10457
9435
  async function createOwnBoardFile(absolutePath, seed) {
@@ -10524,7 +9502,7 @@ async function runInitWizard(context) {
10524
9502
  const bound = findBinding(context.cwd);
10525
9503
  say(ui, "kanbo — set up a board for this project");
10526
9504
  let projectDir;
10527
- const missing = findMissingBoardFile(context.cwd);
9505
+ const missing = bound ? findMissingBoardFile(context.cwd) : null;
10528
9506
  if (missing) {
10529
9507
  projectDir = bound?.projectDir ?? await askProjectDir(ui, context.cwd);
10530
9508
  await replaceMissingBoard(ui, missing, projectDir, bound, options);
@@ -10536,38 +9514,26 @@ async function runInitWizard(context) {
10536
9514
  if (namesPostgres(options)) await askBoard(context, projectDir, options, "postgres");
10537
9515
  else if (!namesBoard(options)) await askBoard(context, projectDir, options);
10538
9516
  }
10539
- let pasted = false;
10540
- if (options.connect === void 0 && options.instructions === void 0 && options.mcp === void 0) {
10541
- const agents = await askAgents(ui, context.detect(projectDir));
10542
- const choice = agents.length > 0 ? await askConnect(ui) : "not-now";
10543
- options.connect = choice === "yes" ? agents : [];
10544
- if (choice === "paste") {
10545
- pasted = true;
10546
- say(ui, `Paste this into your agent's instructions (or run kanbo instructions --copy):\n\n${INSTRUCTION_BLOCK}\n`);
9517
+ const alreadyConnected = [];
9518
+ if (options.connect === void 0) {
9519
+ const detections = context.detect(projectDir);
9520
+ const reaches = (agent) => ["project", "user"].some((scope) => readMcpState(agent, scope, projectDir) !== "missing");
9521
+ const detected = detections.filter((detection) => detection.detected);
9522
+ alreadyConnected.push(...detected.map((detection) => detection.agent).filter(reaches));
9523
+ if (detected.length > 0 && alreadyConnected.length === detected.length) {
9524
+ say(ui, "Your agents already reach kanbo through its MCP server.");
9525
+ options.connect = [];
9526
+ } else {
9527
+ const agents = await askAgents(ui, detections, reaches);
9528
+ options.connect = (agents.length > 0 ? await askConnect(ui) : "not-now") === "yes" ? agents : [];
10547
9529
  }
10548
9530
  }
10549
9531
  const plan = await context.plan(projectDir, options);
10550
- if (plan.connect && pendingItems(plan.connect).length > 0) await chooseAgentFiles(ui, plan);
10551
- if (options.firstCard === void 0 && canAddCard(plan)) {
10552
- const title = (await ui.text({
10553
- message: "Add a first card? (optional — Enter to skip)",
10554
- placeholder: "e.g. Add a README"
10555
- })).trim();
10556
- if (title) {
10557
- options.firstCard = title;
10558
- plan.firstCard = title;
10559
- }
10560
- }
10561
- say(ui, describeInitPlan(plan));
10562
- if (!await ui.confirm({
10563
- message: "Write these changes?",
10564
- initialValue: true
10565
- })) throw new CliError(1, "Nothing was written.");
10566
9532
  return {
10567
9533
  projectDir,
10568
9534
  options,
10569
9535
  plan,
10570
- pasted,
9536
+ alreadyConnected,
10571
9537
  launchWarning: context.launchWarning
10572
9538
  };
10573
9539
  }
@@ -10579,9 +9545,14 @@ function say(ui, text) {
10579
9545
  function namesPostgres(options) {
10580
9546
  return Boolean(options.databaseUrl?.trim() || !options.db?.trim() && options.file === void 0 && process.env.KANBO_DATABASE_URL?.trim());
10581
9547
  }
10582
- /** Does anything besides the wizard already say which board this is? */
9548
+ /**
9549
+ * Does anything besides the wizard already say which board this is? `--db` or
9550
+ * a connection string does. `KANBO_DB_PATH` does not: it names an app's own
9551
+ * database for the commands the app starts, and where a new board goes is
9552
+ * still the person's question (see `namesNoBoard` in `commands/init.ts`).
9553
+ */
10583
9554
  function namesBoard(options) {
10584
- return Boolean(options.db?.trim() || options.databaseUrl?.trim() || process.env.KANBO_DB_PATH?.trim() || process.env.KANBO_DATABASE_URL?.trim());
9555
+ return Boolean(options.db?.trim() || options.databaseUrl?.trim() || process.env.KANBO_DATABASE_URL?.trim());
10585
9556
  }
10586
9557
  function describeExistingBoard(bound, cwd) {
10587
9558
  const { binding } = bound;
@@ -10595,7 +9566,7 @@ function describeExistingBoard(bound, cwd) {
10595
9566
  return `${bound.projectDir === resolve(cwd) ? "This project" : `The project at ${bound.projectDir}`} already has a board: ${where}${cards}. It stays as it is; this sets up your agents.`;
10596
9567
  }
10597
9568
  /**
10598
- * The board file this project is bound to (or `KANBO_DB_PATH` names) is gone.
9569
+ * The board file this project is bound to is gone.
10599
9570
  * The one way on from here is a new, empty board — where the old one was when
10600
9571
  * the project is bound to it — or nothing at all. `--file` already said "a new
10601
9572
  * one"; the columns are asked, and the card key stays the one it was bound to.
@@ -10779,58 +9750,30 @@ async function askCustomColumns(ui) {
10779
9750
  }
10780
9751
  return insertOwnColumns(COLUMN_CATALOGUE.filter((column) => picked.has(column.name)), own);
10781
9752
  }
10782
- /** (5) The agents this person uses; the ones found here are ticked already. */
10783
- async function askAgents(ui, detections) {
9753
+ /** (5) The agents this person uses; the ones found here that kanbo does not reach yet are ticked already. */
9754
+ async function askAgents(ui, detections, reaches) {
10784
9755
  return await ui.multiselect({
10785
9756
  message: "Which coding agents do you use here?",
10786
9757
  options: detections.map(({ agent, detected, evidence }) => ({
10787
9758
  value: agent,
10788
9759
  label: detected && evidence[0] ? `${AGENTS[agent].label} (found ${tildify(evidence[0])})` : AGENTS[agent].label
10789
9760
  })),
10790
- initialValues: detections.filter((detection) => detection.detected).map((detection) => detection.agent)
9761
+ initialValues: detections.filter((detection) => detection.detected && !reaches(detection.agent)).map((detection) => detection.agent)
10791
9762
  });
10792
9763
  }
10793
9764
  /** (6) Whether to connect them now. */
10794
9765
  async function askConnect(ui) {
10795
9766
  return await ui.select({
10796
9767
  message: "Connect them now?",
10797
- options: [
10798
- {
10799
- value: "yes",
10800
- label: "Yes — show exactly which files change, then ask"
10801
- },
10802
- {
10803
- value: "not-now",
10804
- label: "Not now — set up the board only; no other file is touched (later: kanbo connect)"
10805
- },
10806
- {
10807
- value: "paste",
10808
- label: "I'll paste the instructions myself (prints them; nothing is written)"
10809
- }
10810
- ]
9768
+ options: [{
9769
+ value: "yes",
9770
+ label: "Yes — register kanbo's MCP server for them, for every project"
9771
+ }, {
9772
+ value: "not-now",
9773
+ label: "Not now — set up the board only (later: kanbo connect)"
9774
+ }]
10811
9775
  });
10812
9776
  }
10813
- /** (7) The files connecting changes, each explained; an unticked one is left out of the plan. */
10814
- async function chooseAgentFiles(ui, plan) {
10815
- const connect = plan.connect;
10816
- const pending = pendingItems(connect);
10817
- const keys = pending.map((_, index) => `${index}`);
10818
- const kept = new Set(await ui.multiselect({
10819
- message: "These files change so your agents know the board. Keep all of them?",
10820
- options: pending.map((item, index) => ({
10821
- value: keys[index],
10822
- label: item.kind === "claude-cli" ? "Claude Code user settings" : displayPath(connect, itemPath(item)),
10823
- hint: explainItem(item)
10824
- })),
10825
- initialValues: keys
10826
- }));
10827
- connect.items = connect.items.filter((item) => !pending.includes(item) || kept.has(`${pending.indexOf(item)}`));
10828
- if (pendingItems(connect).some((item) => item.kind === "instructions")) say(ui, `The kanbo section your agents get:\n\n${INSTRUCTION_BLOCK}\n`);
10829
- }
10830
- /** A shared board whose tables were not created here may have none yet, so there is nowhere to put a card. */
10831
- function canAddCard(plan) {
10832
- return plan.target.kind !== "postgres" || plan.migrate;
10833
- }
10834
9777
  /** (9) What was done, and what to do next. */
10835
9778
  function printInitOutro(ui, result, applied) {
10836
9779
  const { plan } = result;
@@ -10854,134 +9797,29 @@ function printInitOutro(ui, result, applied) {
10854
9797
  continue;
10855
9798
  }
10856
9799
  connected.push(...outcome.agents);
10857
- lines.push(`✔ ${who}: ${where} (${outcome.state === "ran" ? `ran ${outcome.command}` : outcome.state})`);
9800
+ lines.push(`✔ ${who}: ${where} (${describeOutcomeState(outcome)})`);
10858
9801
  }
10859
- for (const file of applied.files) lines.push(`✔ ${displayPath(plan, file.path).replaceAll("\\", "/")} (${file.state})`);
10860
9802
  for (const note of plan.connect?.notes ?? []) lines.push(`Note: ${note}`);
10861
9803
  if (applied.firstCard) lines.push(`✔ First card: ${applied.firstCard.id} ${applied.firstCard.title}`);
10862
- const anyAgent = connected.length > 0 || applied.files.length > 0;
10863
- if (anyAgent && result.launchWarning) lines.push(`! ${result.launchWarning}`);
9804
+ if (connected.length > 0 && result.launchWarning) lines.push(`! ${result.launchWarning}`);
10864
9805
  lines.push("");
10865
- const first = result.options.connect?.find((agent) => connected.includes(agent));
9806
+ const first = [...connected, ...result.alreadyConnected][0];
10866
9807
  if (first) lines.push(`Next: start ${AGENTS[first].label} in this folder and say "take the next card from kanbo".`);
10867
- else if (result.pasted) lines.push("Next: paste the instructions into your agent, then say \"take the next card from kanbo\".");
10868
- else if (!anyAgent) lines.push("Next: connect your agent with kanbo connect <agent>, or paste kanbo instructions --copy into it.");
9808
+ else lines.push("Next: connect your agent once: kanbo connect claude (or codex, cursor, gemini).");
10869
9809
  lines.push("See the board: kanbo board · in your browser: kanbo serve");
10870
9810
  lines.push("Run kanbo any time to come back here.");
10871
9811
  say(ui, lines.join("\n"));
10872
9812
  }
10873
9813
  //#endregion
10874
- //#region src/cli/commands/init-global.ts
10875
- /**
10876
- * `kanbo init --global` — this person's own agent tools, told about kanbo once
10877
- * for every project.
10878
- *
10879
- * It binds no board and creates none: the block it writes says "a project with
10880
- * `.kanbo/` is on a board", and a project becomes one with a plain `kanbo init`.
10881
- * The MCP registrations start the same `kanbo mcp` every project registration
10882
- * starts, which resolves the board from the folder the client starts it in —
10883
- * on Windows spelled as this Node and this kanbo's script (`mcp-launch.ts`).
10884
- *
10885
- * It plans what `kanbo connect <agents> --global` plans (`setup/connect-plan.ts`),
10886
- * for the agents its flags name. Every change is worked out first and shown; nothing is written until the
10887
- * person says yes (or said `--yes`), and a file that already says it is
10888
- * reported `unchanged`.
10889
- */
10890
- /** Written when `--instructions` is not given. Gemini is asked for by name. */
10891
- const DEFAULT_INSTRUCTION_CLIENTS = ["claude", "codex"];
10892
- /** The flags that are about binding a project, which `--global` does not do. */
10893
- const PROJECT_ONLY_FLAGS = [
10894
- ["file", "--file"],
10895
- ["db", "--db"],
10896
- ["databaseUrl", "--database-url"],
10897
- ["workspace", "--workspace"],
10898
- ["board", "--board"],
10899
- ["identifier", "--identifier"],
10900
- ["agentUrl", "--agent-url"]
10901
- ];
10902
- async function initGlobal(options) {
10903
- const format = readFormat(options);
10904
- const given = PROJECT_ONLY_FLAGS.filter(([key]) => options[key] !== void 0).map(([, flag]) => flag);
10905
- if (given.length > 0) throw new CliError(1, `--global connects no board; ${given.join(", ")} ${given.length === 1 ? "is" : "are"} for a project. Run kanbo init in the project for that.`);
10906
- const instructionClients = parseGlobalInstructions(options.instructions);
10907
- const mcpClients = options.mcp ?? [...MCP_CLIENTS];
10908
- const plan = await planConnect([...instructionClients.map((agent) => ({
10909
- agent,
10910
- instructions: "user",
10911
- mcp: null
10912
- })), ...mcpClients.map((agent) => ({
10913
- agent,
10914
- instructions: null,
10915
- mcp: "user"
10916
- }))], {
10917
- projectDir: process.cwd(),
10918
- yes: options.yes
10919
- });
10920
- for (const note of plan.notes) console.error(note);
10921
- if (pendingItems(plan).length > 0) {
10922
- if (!await confirmPlan({
10923
- preview: describeConnectPlan(plan, "kanbo init --global will:").replace("Nothing else is changed.", "No board is created. Narrow this with --instructions and --mcp, or use kanbo connect <agents> --global."),
10924
- question: "Make these changes?",
10925
- yes: options.yes,
10926
- machineOutput: Boolean(options.json || format),
10927
- command: "kanbo init --global"
10928
- })) throw new CliError(1, "Nothing was changed.");
10929
- }
10930
- const outcomes = applyConnectPlan(plan);
10931
- printResult(describeResult(outcomes.filter((outcome) => outcome.kind === "instructions").map(({ agents, path, state }) => ({
10932
- client: agents[0],
10933
- path,
10934
- state
10935
- })), outcomes.filter((outcome) => outcome.kind !== "instructions").map(({ agents, path, state, command, reason, removed }) => ({
10936
- client: agents[0],
10937
- path,
10938
- state: state === "ran" ? "written" : state,
10939
- ...command ? { command } : {},
10940
- ...reason ? { reason } : {},
10941
- ...removed ? { removed } : {}
10942
- }))), options);
10943
- }
10944
- function describeResult(instructions, mcp) {
10945
- const lines = [];
10946
- for (const outcome of instructions) lines.push(`Instructions: ${outcome.path} (${outcome.state})`);
10947
- for (const outcome of mcp) if (outcome.state === "manual") lines.push(`MCP: ${describeManualOutcome(outcome.client, outcome)}`);
10948
- else lines.push(`MCP: ${outcome.path} (${outcome.state})`);
10949
- lines.push("A project uses the board once it has run kanbo init. Check everything with kanbo doctor.");
10950
- return {
10951
- value: {
10952
- scope: "global",
10953
- instructions,
10954
- mcp
10955
- },
10956
- text: lines.join("\n"),
10957
- fields: [
10958
- "scope",
10959
- "instructions",
10960
- "mcp"
10961
- ]
10962
- };
10963
- }
10964
- function parseGlobalInstructions(value) {
10965
- if (value === void 0) return DEFAULT_INSTRUCTION_CLIENTS;
10966
- const names = value.split(",").map((name) => name.trim()).filter((name) => name.length > 0);
10967
- if (names.length === 1 && names[0] === "none") return [];
10968
- return names.map((name) => {
10969
- const client = GLOBAL_INSTRUCTION_CLIENTS.find((candidate) => candidate === name);
10970
- if (!client) throw new CliError(1, `Unknown instructions target "${name}" for --global. Use one or more of ${GLOBAL_INSTRUCTION_CLIENTS.join(", ")}, or none.`);
10971
- return client;
10972
- });
10973
- }
10974
- //#endregion
10975
9814
  //#region src/cli/commands/init.ts
10976
9815
  /**
10977
- * `kanbo init` — tying a project folder to a board, and telling the agents
10978
- * that work in it what the board expects.
9816
+ * `kanbo init` — tying a project folder to a board.
10979
9817
  *
10980
- * Three things come out of it, and each is optional except the first: the
10981
- * binding, so no later command has to be told which workspace this is; a block
10982
- * in the project's agent instructions, so an agent reads the board's rules
10983
- * without being handed them; and a registration of the board's MCP server with
10984
- * whichever tools the person actually uses. Nothing is written to a file the
9818
+ * Two things come out of it, and only the first is always there: the binding,
9819
+ * so no later command has to be told which workspace this is, and — when the
9820
+ * person asks for it with `--connect` or in the wizard — the board's MCP
9821
+ * server registered with the agents they use, as `kanbo connect` does. kanbo
9822
+ * writes no instruction file into the project. Nothing is written to a file the
10985
9823
  * person did not ask for, and nothing is written twice.
10986
9824
  *
10987
9825
  * Every question comes first. The answers become a plan (`setup/init-plan.ts`)
@@ -11005,15 +9843,10 @@ const AGENT_URL_MISSING_WARNING = "kanbo: agents in this project will reach the
11005
9843
  /** What a person is told when the setup stopped at a question. */
11006
9844
  const INIT_CANCELLED_MESSAGE = "Cancelled — nothing was written.";
11007
9845
  /** What a board set up without its agents ends with. */
11008
- const CONNECT_LATER_TIP = "Agents don't know about this board yet: kanbo connect <agent>, or kanbo instructions --copy and paste it.";
9846
+ const CONNECT_LATER_TIP = "Agents can't reach this board yet: kanbo connect <agent>.";
11009
9847
  const CONNECT_VALUES = `${AGENT_IDS.join(", ")}, all or none`;
11010
9848
  function registerInitCommand(program) {
11011
- program.command("init").description("Set up a board for this project and connect your agents (asks step by step)").option("--db <path>", "the app's database file to find this project in").option("--database-url <url>", "use a shared Postgres board for this project").option("--file [path]", `keep the board in a file in this project (default ${DEFAULT_BOARD_FILE_PATH})`).option("--workspace <nameOrId>", "the project's id on a shared board").option("--json [fields]", "print the result as JSON; name comma-separated fields to print only those").option("--format <format>", "print JSON: json (one line) or pretty (indented)").option("--board <id>", "which board, when the project has more than one").option("--identifier <key>", "the same as --key").option("--key <KEY>", "what card numbers start with, e.g. MYA for MYA-001", parseKey).option("--columns <columns>", `the columns a new board starts with: ${COLUMN_TEMPLATE_IDS.join(", ")}, or a comma-separated list that includes To Do`, parseColumns).option("--connect <agents>", `connect these agents, as kanbo connect does: ${CONNECT_VALUES}`, parseConnect).option("--first-card <title>", "put a first card in To Do").option("--migrate", "create the board's tables in the Postgres database now").option("--agent-url <url>", "a separate connection string for agents (with fewer rights)").option("--instructions <agent>", "where to write the agent instructions: claude, agents or none; with --global, a comma-separated list of claude, codex, gemini, or none").option("--mcp <clients>", `add the board's MCP server to ${MCP_CLIENTS.join(", ")}`, parseMcpClients).option("--global", "set up your agents for every project instead of this one; creates no board").option("--yes", "take the defaults instead of asking").action(async (options) => {
11012
- if (options.global) {
11013
- await initGlobal(options);
11014
- return;
11015
- }
11016
- if (options.instructions !== void 0) parseInstructions(options.instructions);
9849
+ program.command("init").description("Set up a board for this project (asks step by step)").option("--db <path>", "the app's database file to find this project in").option("--database-url <url>", "use a shared Postgres board for this project").option("--file [path]", `keep the board in a file in this project (default ${DEFAULT_BOARD_FILE_PATH})`).option("--workspace <nameOrId>", "the project's id on a shared board").option("--json [fields]", "print the result as JSON; name comma-separated fields to print only those").option("--format <format>", "print JSON: json (one line) or pretty (indented)").option("--board <id>", "which board, when the project has more than one").option("--identifier <key>", "the same as --key").option("--key <KEY>", "what card numbers start with, e.g. MYA for MYA-001", parseKey).option("--columns <columns>", `the columns a new board starts with: ${COLUMN_TEMPLATE_IDS.join(", ")}, or a comma-separated list that includes To Do`, parseColumns).option("--connect <agents>", `connect these agents, as kanbo connect does: ${CONNECT_VALUES}`, parseConnect).option("--first-card <title>", "put a first card in To Do").option("--migrate", "create the board's tables in the Postgres database now").option("--agent-url <url>", "a separate connection string for agents (with fewer rights)").option("--yes", "take the defaults instead of asking").action(async (options) => {
11017
9850
  readFormat(options);
11018
9851
  const request = normalizeInitOptions(options);
11019
9852
  if (!request.yes && !request.json && !request.format && canPrompt()) {
@@ -11048,25 +9881,21 @@ async function initWithWizard(options) {
11048
9881
  printInitOutro(ui, answered, await applyInitPlan(answered.plan));
11049
9882
  }
11050
9883
  /**
11051
- * Agent files — instructions, MCP settings, `claude mcp` — change only on a
11052
- * yes: the wizard's, or `--yes`. Without either (an agent's shell, a script,
11053
- * machine output) the board is still set up and the agent files are left as
11054
- * they are, and the person is told how to connect them, as `kanbo connect` would.
9884
+ * Agent settings — the MCP registration, `claude mcp` — change only on a yes:
9885
+ * the wizard's, or `--yes`. Without either (an agent's shell, a script,
9886
+ * machine output) the board is still set up and the agents are left as they
9887
+ * are, and the person is told how to connect them, as `kanbo connect` would.
11055
9888
  */
11056
9889
  function withoutUnconfirmedAgentFiles(plan, options) {
11057
- const pending = plan.fileChanges.some(({ change }) => change.next !== null) || plan.connect !== null && pendingItems(plan.connect).length > 0;
11058
- if (options.yes || !pending) return plan;
11059
- const agents = options.connect?.length ? options.connect : [.../* @__PURE__ */ new Set([...options.instructions === "claude" ? ["claude"] : options.instructions === "agents" ? ["codex"] : [], ...options.mcp ?? []])];
11060
- console.error(`Not changing agent files without a yes: run kanbo connect ${agents.join(" ") || "<agent>"} --yes`);
9890
+ if (options.yes || plan.connect === null || pendingItems(plan.connect).length === 0) return plan;
9891
+ console.error(`Not connecting agents without a yes: run kanbo connect ${options.connect?.join(" ") || "<agent>"} --yes`);
11061
9892
  return {
11062
9893
  ...plan,
11063
- fileChanges: [],
11064
9894
  connect: null
11065
9895
  };
11066
9896
  }
11067
- /** The flags as `planInit` takes them: `--key` is `--identifier`, and `--connect` does not mix with the older flags. */
9897
+ /** The flags as `planInit` takes them: `--key` is `--identifier`. */
11068
9898
  function normalizeInitOptions(options) {
11069
- if (options.connect && (options.instructions !== void 0 || options.mcp)) throw new CliError(1, "Use --connect, or the older --instructions and --mcp, not both.");
11070
9899
  if (options.key === void 0) return options;
11071
9900
  if (options.identifier !== void 0 && options.identifier.trim().toUpperCase() !== options.key) throw new CliError(1, "--key and --identifier are the same thing; pass one of them.");
11072
9901
  return {
@@ -11095,7 +9924,6 @@ async function planInit(projectDir, options) {
11095
9924
  boardFile,
11096
9925
  columns: newBoard ? options.columns ?? COLUMN_TEMPLATES.standard : null,
11097
9926
  migrate,
11098
- fileChanges: [...await planInstructions(projectDir, options), ...await planMcpServers(projectDir, options.mcp ?? [])],
11099
9927
  connect: options.connect?.length ? await planAgents(projectDir, options.connect, options) : null,
11100
9928
  firstCard: options.firstCard?.trim() || null
11101
9929
  };
@@ -11116,12 +9944,12 @@ async function describeKept(target, workspace, options, newBoard) {
11116
9944
  if (key && key !== workspace.identifier) kept.push(`Kept the existing key (${workspace.identifier}); --key names the key of a new board only`);
11117
9945
  return kept;
11118
9946
  }
11119
- /** Connect these agents the way `kanbo connect <agents>` does: the kanbo section in the project, the MCP server where each agent reads it. */
9947
+ /** Connect these agents the way `kanbo connect <agents>` does: the MCP server in each agent's own settings, and an old kanbo section taken out. */
11120
9948
  async function planAgents(projectDir, agents, options) {
11121
9949
  return await planConnect(agents.map((agent) => ({
11122
9950
  agent,
11123
- instructions: "project",
11124
- mcp: defaultMcpScope(agent)
9951
+ instructions: "both",
9952
+ mcp: defaultMcpScope()
11125
9953
  })), {
11126
9954
  projectDir,
11127
9955
  yes: options.yes
@@ -11143,13 +9971,20 @@ async function resolveBoard(projectDir, options) {
11143
9971
  };
11144
9972
  }
11145
9973
  /**
11146
- * Nothing says where a board is: no flag, no environment, no binding at or
11147
- * above this folder. Then a plain `kanbo init` does what `kanbo init --file`
11148
- * does — a person on a machine with nothing else installed gets a board, not a
11149
- * refusal that names a flag.
9974
+ * Nothing says where this project's board is: no `--db`, no connection string
9975
+ * (typed or exported), no binding at or above this folder. Then a plain
9976
+ * `kanbo init` does what `kanbo init --file` does — a person on a machine with
9977
+ * nothing else installed gets a board, not a refusal that names a flag.
9978
+ *
9979
+ * `KANBO_DB_PATH` is not on that list on purpose. It is how an app that embeds
9980
+ * kanbo names its own database for the commands it starts, and a new board
9981
+ * must not land in it, or fail on it, because the shell `init` runs in happens
9982
+ * to carry that variable. Only `kanbo init --db <path>` binds a project to an
9983
+ * app's database. A Postgres connection string in the environment is the
9984
+ * person's own choice, so it still counts.
11150
9985
  */
11151
9986
  function namesNoBoard$1(projectDir, options) {
11152
- return !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DB_PATH?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(projectDir) === null;
9987
+ return !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(projectDir) === null;
11153
9988
  }
11154
9989
  /** The workspace a host database — or the board file this project is already bound to — says this project is. */
11155
9990
  async function readSessionWorkspace(options) {
@@ -11387,84 +10222,33 @@ async function openHostDatabase(options) {
11387
10222
  function warnAboutOwnerAccess(target, options) {
11388
10223
  if (target.kind === "postgres" && !options.agentUrl?.trim()) console.error(AGENT_URL_MISSING_WARNING);
11389
10224
  }
11390
- /**
11391
- * The block in the file `--instructions` named. Without the flag nothing is
11392
- * written into a project's instructions: that is `--connect`, the wizard, or
11393
- * `kanbo connect` later.
11394
- */
11395
- async function planInstructions(projectDir, options) {
11396
- const target = options.instructions === void 0 ? "none" : parseInstructions(options.instructions);
11397
- if (target === "none") return [];
11398
- const plan = await planConnect([{
11399
- agent: target === "claude" ? "claude" : "codex",
11400
- instructions: "project",
11401
- mcp: null
11402
- }], {
11403
- projectDir,
11404
- yes: options.yes
11405
- });
11406
- for (const note of plan.notes) console.error(note);
11407
- return plan.items.flatMap((item) => item.kind === "instructions" ? [{
11408
- kind: "instructions",
11409
- change: item.change
11410
- }] : []);
11411
- }
11412
- /**
11413
- * Tell the tools the person uses about the board's MCP server, in the project's
11414
- * own files — what `kanbo connect <clients> --project --no-instructions` does.
11415
- *
11416
- * Only the ones they named, and only when the tool does not already know: a
11417
- * `kanbo` entry that is already there belongs to them — it may point at
11418
- * a different binary or carry arguments of its own — and is rewritten only
11419
- * when it is kanbo's own and no longer starts.
11420
- *
11421
- * A project file is shared across machines, so it gets the portable form on every system.
11422
- */
11423
- async function planMcpServers(projectDir, clients) {
11424
- return (await planConnect(clients.map((agent) => ({
11425
- agent,
11426
- instructions: null,
11427
- mcp: "project"
11428
- })), { projectDir })).items.flatMap((item) => item.kind === "mcp" ? [{
11429
- kind: "mcp",
11430
- change: item.change
11431
- }] : []);
10225
+ /** Has any agent a kanbo MCP registration already, in the project's files or its own settings? Then the tip to connect one is not needed. */
10226
+ function someAgentReachesKanbo(projectDir) {
10227
+ return AGENT_IDS.some((agent) => ["project", "user"].some((scope) => readMcpState(agent, scope, projectDir) !== "missing"));
11432
10228
  }
11433
10229
  function describeInit(plan, applied, options) {
11434
10230
  const { workspace, target } = plan;
11435
- const outcome = ({ path, state }) => ({
11436
- path,
11437
- state
11438
- });
11439
- const instructions = applied.files.filter((file) => file.kind === "instructions").map(outcome)[0] ?? null;
11440
- const mcp = applied.files.filter((file) => file.kind === "mcp").map(outcome);
11441
10231
  const lines = [`This project uses board ${workspace.name} — ${applied.bindingPath}`];
11442
10232
  if (target.kind === "postgres") lines.push(`Board: ${maskDatabaseUrl(target.url)}`);
11443
10233
  else if (target.owner === "kanbo") lines.push(`Board file: ${target.path}`);
11444
10234
  if (applied.columns.length > 0) lines.push(`Columns: ${applied.columns.join(", ")}`);
11445
10235
  lines.push(...plan.kept);
11446
- if (instructions) lines.push(`Instructions: ${instructions.path} (${instructions.state})`);
11447
- for (const entry of mcp) lines.push(`MCP: ${entry.path} (${entry.state})`);
11448
10236
  for (const entry of applied.connect) {
11449
10237
  const who = entry.agents.map((agent) => AGENTS[agent].label).join(", ");
11450
- lines.push(entry.state === "manual" ? describeManualOutcome(who, entry) : `${who}: ${entry.path} (${entry.state === "ran" ? `ran ${entry.command}` : entry.state})`);
10238
+ lines.push(entry.state === "manual" ? describeManualOutcome(who, entry) : `${who}: ${entry.path} (${describeOutcomeState(entry)})`);
11451
10239
  }
11452
10240
  if (applied.firstCard) lines.push(`First card: ${applied.firstCard.id} ${applied.firstCard.title}`);
11453
- const warning = mcp.length > 0 ? mcpLaunchSpec({ scope: "project" }).warning : void 0;
11454
- if (warning) lines.push(`Note: ${warning}`);
11455
10241
  for (const note of plan.connect?.notes ?? []) lines.push(`Note: ${note}`);
11456
- const connected = instructions !== null || mcp.length > 0 || applied.connect.length > 0;
10242
+ const connected = applied.connect.length > 0;
11457
10243
  const launchWarning = connected ? readLaunchWarning() : null;
11458
10244
  if (launchWarning) lines.push(`Note: ${launchWarning}`);
11459
- if (!connected) lines.push(CONNECT_LATER_TIP);
10245
+ if (!connected && !someAgentReachesKanbo(plan.projectDir)) lines.push(CONNECT_LATER_TIP);
11460
10246
  return {
11461
10247
  value: {
11462
10248
  binding: applied.bindingPath,
11463
10249
  workspaceId: workspace.id,
11464
10250
  boardId: options.board ?? null,
11465
10251
  database: describeTarget(target),
11466
- instructions,
11467
- mcp,
11468
10252
  columns: applied.columns,
11469
10253
  kept: plan.kept,
11470
10254
  connect: applied.connect,
@@ -11473,17 +10257,6 @@ function describeInit(plan, applied, options) {
11473
10257
  text: lines.join("\n")
11474
10258
  };
11475
10259
  }
11476
- function parseInstructions(value) {
11477
- if (value !== "claude" && value !== "agents" && value !== "none") throw new CliError(1, `Unknown instructions target "${value}". Use claude, agents or none.`);
11478
- return value;
11479
- }
11480
- function parseMcpClients(value) {
11481
- return value.split(",").map((name) => name.trim()).filter((name) => name.length > 0).map((name) => {
11482
- const client = MCP_CLIENTS.find((candidate) => candidate === name);
11483
- if (!client) throw new CliError(1, `Unknown MCP client "${name}". Use one or more of ${MCP_CLIENTS.join(", ")}.`);
11484
- return client;
11485
- });
11486
- }
11487
10260
  /** `--key`: a letter and two letters or digits, upper-cased. */
11488
10261
  function parseKey(value) {
11489
10262
  if (!isValidCardKey(value)) throw new CliError(1, `"${value}" can't be a card key: use a letter and two letters or digits, like MYA.`);
@@ -11517,6 +10290,132 @@ function parseConnect(value) {
11517
10290
  return [...new Set(agents)];
11518
10291
  }
11519
10292
  //#endregion
10293
+ //#region src/capabilities/markdown.ts
10294
+ /**
10295
+ * The same manifest `buildCapabilitiesManifest` returns, as Markdown a person
10296
+ * can read and an agent can paste into its own `CLAUDE.md` or `AGENTS.md`.
10297
+ *
10298
+ * Nothing here decides what the board can do — it only lays the manifest's own
10299
+ * fields out under headings. `kanbo mcp` serves it as
10300
+ * `kanbo://capabilities.md` beside the JSON.
10301
+ */
10302
+ /**
10303
+ * A table cell, safe from a pipe inside the text breaking the row it sits in —
10304
+ * the same escape the package README's own tables use for a command that
10305
+ * spells `\|` between two states.
10306
+ */
10307
+ function cell(text) {
10308
+ return text.replace(/\|/g, "\\|").replace(/\n/g, " ").trim();
10309
+ }
10310
+ function renderStorages(manifest) {
10311
+ return [
10312
+ "## Storages",
10313
+ "",
10314
+ "| Kind | Label | Description | Schema epoch |",
10315
+ "| --- | --- | --- | --- |",
10316
+ ...manifest.storages.map((storage) => `| \`${storage.kind}\` | ${cell(storage.label)} | ${cell(storage.description)} | ${storage.schemaEpoch} |`)
10317
+ ];
10318
+ }
10319
+ function renderTool(tool) {
10320
+ return `| \`${tool.name}\` | ${cell(tool.description)} |`;
10321
+ }
10322
+ function renderTools(manifest) {
10323
+ return [
10324
+ "## Tools",
10325
+ "",
10326
+ "Full JSON Schema for each tool's input is in the JSON manifest (`kanbo://capabilities`) — this table is the tools themselves.",
10327
+ "",
10328
+ "| Tool | Does |",
10329
+ "| --- | --- |",
10330
+ ...manifest.tools.map(renderTool)
10331
+ ];
10332
+ }
10333
+ function renderCommand(command) {
10334
+ const options = command.options.map((option) => `\`${option.flags}\`${option.mandatory ? " (required)" : ""}`).join(", ");
10335
+ const humanOnly = command.humanOnly ? " **Person only.**" : "";
10336
+ return `| \`kanbo ${command.path}\` | ${cell(command.description)}${humanOnly} | ${options ? cell(options) : "—"} |`;
10337
+ }
10338
+ function renderCommands(manifest) {
10339
+ return [
10340
+ "## Commands",
10341
+ "",
10342
+ "| Command | Does | Options |",
10343
+ "| --- | --- | --- |",
10344
+ ...manifest.commands.map(renderCommand)
10345
+ ];
10346
+ }
10347
+ function renderRules(manifest) {
10348
+ return [
10349
+ "## Rules",
10350
+ "",
10351
+ ...manifest.rules.map((rule) => `- ${rule}`)
10352
+ ];
10353
+ }
10354
+ function renderEntryRules(manifest) {
10355
+ return [
10356
+ "## Column entry rules",
10357
+ "",
10358
+ "What a column may ask of a card before an agent moves it in. Which columns ask for which is per board: `kanbo columns list` or `kanbo_columns`.",
10359
+ "",
10360
+ ...manifest.entryRules.map((entry) => `- \`${entry.rule}\` — ${entry.description}`)
10361
+ ];
10362
+ }
10363
+ function renderCanonicalStrings(manifest) {
10364
+ return [
10365
+ "## Canonical strings",
10366
+ "",
10367
+ "What the board writes for you, on a status line or in a system comment. A value ending in a space or `: ` is a prefix — the rest of the line is whatever the write was about.",
10368
+ "",
10369
+ "| Name | Value |",
10370
+ "| --- | --- |",
10371
+ ...Object.entries(manifest.canonicalStrings).map(([name, value]) => `| ${cell(name)} | \`${cell(value)}\` |`)
10372
+ ];
10373
+ }
10374
+ function renderRoles(manifest) {
10375
+ return [
10376
+ "## Roles",
10377
+ "",
10378
+ `- Person: \`${manifest.roles.person}\``,
10379
+ `- Agent: \`${manifest.roles.agent}\``,
10380
+ "",
10381
+ "What the database itself refuses the agent role, on an external board, whichever client sends the write:",
10382
+ "",
10383
+ ...manifest.roles.refusals.map((refusal) => `- ${refusal}`)
10384
+ ];
10385
+ }
10386
+ function renderLimits(manifest) {
10387
+ return [
10388
+ "## Limits",
10389
+ "",
10390
+ ...manifest.limits.map((limit) => `- ${limit}`)
10391
+ ];
10392
+ }
10393
+ /** The manifest, laid out under one heading per field — stable and diffable, never a snapshot to update by hand. */
10394
+ function renderCapabilitiesMarkdown(manifest) {
10395
+ return [
10396
+ "# Kanbo capabilities",
10397
+ "",
10398
+ `\`${manifest.package.name}\` ${manifest.package.version} — capabilities manifest version ${manifest.version}.`,
10399
+ "",
10400
+ ...renderStorages(manifest),
10401
+ "",
10402
+ ...renderTools(manifest),
10403
+ "",
10404
+ ...renderCommands(manifest),
10405
+ "",
10406
+ ...renderRules(manifest),
10407
+ "",
10408
+ ...renderEntryRules(manifest),
10409
+ "",
10410
+ ...renderCanonicalStrings(manifest),
10411
+ "",
10412
+ ...renderRoles(manifest),
10413
+ "",
10414
+ ...renderLimits(manifest),
10415
+ ""
10416
+ ].join("\n");
10417
+ }
10418
+ //#endregion
11520
10419
  //#region src/mcp/resources.ts
11521
10420
  /** The URI a client reads the manifest as JSON from. */
11522
10421
  const KANBO_CAPABILITIES_RESOURCE_URI = "kanbo://capabilities";
@@ -11565,7 +10464,7 @@ function tool(name) {
11565
10464
  * What `kanbo mcp` tells a client when it connects — the `instructions` of the
11566
10465
  * MCP initialize result, which a client hands its model before any tool is
11567
10466
  * called. An agent with nothing but this server connected learns from it how
11568
- * to work the board, with no instruction file in the project.
10467
+ * to work the board; kanbo writes no instruction file.
11569
10468
  *
11570
10469
  * Static on purpose: it is sent before any board is resolved, so it says
11571
10470
  * nothing about columns — `kanbo_prime` returns those. The rules are the
@@ -11576,7 +10475,7 @@ const KANBO_MCP_INSTRUCTIONS = [
11576
10475
  "kanbo is a kanban board shared by people and agents; these tools read and change its cards.",
11577
10476
  "",
11578
10477
  `- At the start of a session call ${tool("kanbo_prime")} — it returns this board's columns and what each one means.`,
11579
- `- 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.`,
10478
+ `- 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. When you stop, finish the run with ${tool("kanbo_run_finish")}.`,
11580
10479
  `- ${MOVE_CARD_RULE} (${tool("kanbo_card_move")})`,
11581
10480
  `- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
11582
10481
  `- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
@@ -11584,194 +10483,9 @@ const KANBO_MCP_INSTRUCTIONS = [
11584
10483
  `- A card at the wrong level is moved with ${tool("kanbo_card_update")} and \`parent\` (\`"none"\` for the top level).`,
11585
10484
  `- ${WAIT_FOR_PERSON_RULE} (${tool("kanbo_wait_approval")})`,
11586
10485
  `- ${PERSON_ONLY_RULE}`,
10486
+ "- If a tool answers that this folder has no kanbo board, leave the board alone for this session: only a person sets one up.",
11587
10487
  `- Every tool, rule and canonical string: the resource \`${KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI}\`.`
11588
10488
  ].join("\n");
11589
- /** Copy the text, trying each program this machine should have in turn. */
11590
- async function copyToClipboard(text, environment = {}) {
11591
- const env = environment.env ?? process.env;
11592
- const run = environment.run ?? runWithInput;
11593
- const stderr = environment.stderr ?? process.stderr;
11594
- for (const program of chooseClipboardPrograms(text, environment)) if (await run(program.command, program.args, program.input, {
11595
- timeoutMs: 3e3,
11596
- detached: program.detached
11597
- })) return "copied";
11598
- if ((env.SSH_TTY || env.SSH_CONNECTION) && stderr.isTTY === true) {
11599
- stderr.write(osc52(text));
11600
- return "osc52";
11601
- }
11602
- return "unavailable";
11603
- }
11604
- /** The programs to try here, in order; empty when there is nothing to try. */
11605
- function chooseClipboardPrograms(text, environment) {
11606
- const platform = environment.platform ?? process.platform;
11607
- const env = environment.env ?? process.env;
11608
- const readFile = environment.readFile ?? readTextOrNull$1;
11609
- const utf8 = Buffer.from(text, "utf8");
11610
- if (platform === "darwin") return [{
11611
- command: "pbcopy",
11612
- args: [],
11613
- input: utf8
11614
- }];
11615
- if (platform === "win32" || platform === "linux" && /microsoft/i.test(readFile("/proc/version") ?? "")) return [{
11616
- command: "clip.exe",
11617
- args: [],
11618
- input: utf16leWithBom(text)
11619
- }];
11620
- const programs = [];
11621
- if (env.WAYLAND_DISPLAY) programs.push({
11622
- command: "wl-copy",
11623
- args: [],
11624
- input: utf8
11625
- });
11626
- if (env.DISPLAY) programs.push({
11627
- command: "xclip",
11628
- args: ["-selection", "clipboard"],
11629
- input: utf8,
11630
- detached: true
11631
- }, {
11632
- command: "xsel",
11633
- args: ["--clipboard", "--input"],
11634
- input: utf8
11635
- });
11636
- return programs;
11637
- }
11638
- /** The text as `clip.exe` reads it without guessing: UTF-16LE after a byte-order mark. */
11639
- function utf16leWithBom(text) {
11640
- return Buffer.concat([Buffer.from([255, 254]), Buffer.from(text, "utf16le")]);
11641
- }
11642
- /** The OSC 52 "set the clipboard" escape for this text. */
11643
- function osc52(text) {
11644
- return `\u001B]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`;
11645
- }
11646
- function readTextOrNull$1(path) {
11647
- try {
11648
- return readFileSync(path, "utf8");
11649
- } catch {
11650
- return null;
11651
- }
11652
- }
11653
- //#endregion
11654
- //#region src/cli/setup/orchestrator-guide.ts
11655
- /**
11656
- * The instruction file for an orchestrator: an agent that pulls ready cards,
11657
- * splits them when it should, hands subtasks to sub-agents by card key and
11658
- * waits for a person's approval. `kanbo instructions orchestrator` prints it,
11659
- * and `docs/orchestrator.md` shows it word for word — a test holds the two
11660
- * together, so the page never drifts from what the command hands out.
11661
- */
11662
- const ORCHESTRATOR_GUIDE = [
11663
- "# Orchestrator",
11664
- "",
11665
- "You coordinate work on this project's kanbo board. You do not approve work; a person does.",
11666
- "",
11667
- "## Start of every session",
11668
- "1. Run `kanbo prime` (tool: `kanbo_prime`) and read the column descriptions. On this board the description is the contract, not the name.",
11669
- "2. Run `kanbo ready` (`kanbo_ready`). If nothing is ready, say so and stop.",
11670
- "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.",
11671
- "",
11672
- "## While working",
11673
- "- 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.",
11674
- "- Put decisions, numbers and open questions on the card as comments (`kanbo_card_comment`). Your transcript is not the card.",
11675
- "",
11676
- "## Splitting",
11677
- "Split a card into subtasks only when the person asked for it, or when it has parts that can be done and checked separately — different stages, owners or pull requests. Do not split work you would finish in one go.",
11678
- "- A subtask is `kanbo card create --parent <card> --column to_do --title \"…\" --description \"…\"` (`kanbo_card_create` with `parent`).",
11679
- "- Give each subtask a description a sub-agent can act on without asking you: what to change, how to check it, a `- [ ]` checklist when there are several steps.",
11680
- "",
11681
- "## Delegating",
11682
- "- Hand each subtask to a sub-agent by its key only: \"Work APP-014 on the kanbo board.\" The sub-agent reads the card itself.",
11683
- "- Wait for the sub-agent to finish. Then read the subtask (`kanbo_card_get`): it should be in the board's review column or a finished one (by the descriptions in `kanbo prime`) with a comment saying what changed. If it is not, write why on the parent card and decide: retry, fix it yourself, or ask the person.",
11684
- "- Link every pull request to the card it belongs to (`kanbo card pr add <card> <url>`, `kanbo_card_link_pr`).",
11685
- "",
11686
- "## Handing back",
11687
- "1. When the card's work is complete, move it to the column this board uses for review (see `kanbo prime`).",
11688
- "2. Run `kanbo card wait-approval <card> --text \"<what the person should look at>\"` (`kanbo_wait_approval`).",
11689
- "3. Finish your run: `kanbo run finish <runId> --state finished` (`kanbo_run_finish`).",
11690
- "4. End your turn. Do not approve, do not move the card out of review, do not start the same card again. The person approves with `kanbo approve` or returns it with a reason.",
11691
- "",
11692
- "## When the card comes back",
11693
- "- Approved: move it to the board's finished column (see `kanbo prime`).",
11694
- "- Returned: the reason is the newest comment. The card is back in an earlier column; start a new run and address the reason first.",
11695
- "",
11696
- "## Never",
11697
- "- 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.",
11698
- "- Never delete or edit the board's database directly."
11699
- ].join("\n");
11700
- //#endregion
11701
- //#region src/cli/commands/instructions.ts
11702
- /**
11703
- * `kanbo instructions` — the text to give an agent, without a trip to GitHub.
11704
- *
11705
- * The text goes to stdout and nothing else does, so `kanbo instructions >>
11706
- * AGENTS.md` writes exactly the text. The two lines around it that say what to
11707
- * do with it go to stderr, and only when stderr is a terminal a person reads.
11708
- */
11709
- /** The versions of the text, as typed after `kanbo instructions`. */
11710
- const INSTRUCTION_KINDS = [
11711
- "agent",
11712
- "short",
11713
- "global",
11714
- "orchestrator",
11715
- "mcp",
11716
- "board"
11717
- ];
11718
- /** What `board` says in a folder with no board. */
11719
- const NO_BOARD_FOR_INSTRUCTIONS_MESSAGE = "This folder has no kanbo board, so there are no board rules to print. Run kanbo here to set one up, or ask for a version that needs no board: agent | short | global | orchestrator | mcp.";
11720
- const HEADER = "Paste this into CLAUDE.md or AGENTS.md — or run `kanbo connect <agent>` to have kanbo do it.";
11721
- const FOOTER = "Copy it: kanbo instructions --copy · Other versions: short | global | orchestrator | mcp | board";
11722
- /**
11723
- * The text of each version that needs no board. A function, not a table: the
11724
- * MCP instructions sit on an import cycle back to this command list (through
11725
- * the capabilities manifest), and a table built at load time would hold
11726
- * `undefined` for them.
11727
- */
11728
- function staticText(kind) {
11729
- switch (kind) {
11730
- case "agent": return AGENT_GUIDE_TEXT;
11731
- case "short": return PROJECT_BODY;
11732
- case "global": return GLOBAL_BODY;
11733
- case "orchestrator": return ORCHESTRATOR_GUIDE;
11734
- case "mcp": return KANBO_MCP_INSTRUCTIONS;
11735
- }
11736
- }
11737
- function registerInstructionsCommand(program, dependencies = {}) {
11738
- program.command("instructions").description("Print the instructions to give your agent (to paste yourself)").argument("[kind]", "which version: agent, short, global, orchestrator, mcp or board", "agent").option("--copy", "also put the text on the clipboard").option("--markers", "wrap the text in kanbo's start and end markers, so kanbo connect can update it later").action(async (kind, options) => {
11739
- const found = parseKind(kind);
11740
- const body = await readInstructionText(found);
11741
- const pointer = found === "short" || found === "global";
11742
- const text = options.markers ? wrapInstructionBlock(body, 3, pointer ? void 0 : found) : body;
11743
- const hints = process.stderr.isTTY === true;
11744
- if (hints) process.stderr.write(`${HEADER}\n\n`);
11745
- console.log(text);
11746
- if (hints) process.stderr.write(`\n${FOOTER}\n`);
11747
- if (options.copy) process.stderr.write(`${describeCopy(await copyToClipboard(text, dependencies.clipboard))}\n`);
11748
- });
11749
- }
11750
- function parseKind(kind) {
11751
- const found = INSTRUCTION_KINDS.find((candidate) => candidate === kind);
11752
- if (!found) throw new CliError(1, `Unknown version "${kind}". Use one of: ${INSTRUCTION_KINDS.join(", ")}.`);
11753
- return found;
11754
- }
11755
- /** The text of one version; `board` reads this folder's board, as `kanbo prime` prints it. */
11756
- async function readInstructionText(kind) {
11757
- if (kind !== "board") return staticText(kind);
11758
- const session = await openBoardSession({}, "read").catch((error) => {
11759
- if (error instanceof BoardNotFoundError) throw new CliError(2, NO_BOARD_FOR_INSTRUCTIONS_MESSAGE);
11760
- throw error;
11761
- });
11762
- try {
11763
- return `${await session.ops.buildPrimeText(session.workspace.id)}\n\n${COMMAND_SHEET}`;
11764
- } finally {
11765
- await session.close();
11766
- }
11767
- }
11768
- function describeCopy(outcome) {
11769
- switch (outcome) {
11770
- case "copied": return "Copied to the clipboard.";
11771
- case "osc52": return "Sent to your terminal's clipboard (OSC 52).";
11772
- case "unavailable": return "Couldn't reach a clipboard — select the text above.";
11773
- }
11774
- }
11775
10489
  //#endregion
11776
10490
  //#region src/mcp/tools/tool.ts
11777
10491
  /** What every tool that takes `card` also takes it as. */
@@ -12032,33 +10746,214 @@ function formatKanboCardPage(page, view, returned) {
12032
10746
  nextOffset: shownUntil
12033
10747
  } : {}
12034
10748
  };
12035
- const cards = page.cards.map((card) => JSON.stringify(viewCard(card, view)));
12036
- 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)}`;
12037
- return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
10749
+ const cards = page.cards.map((card) => JSON.stringify(viewCard(card, view)));
10750
+ 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)}`;
10751
+ return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
10752
+ }
10753
+ //#endregion
10754
+ //#region src/mcp/tools/card-create.ts
10755
+ /** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
10756
+ const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pass `parent` = that card's id (its key, e.g. `MAN-012`).";
10757
+ /** A new card, or a sub-card of the one being worked on. */
10758
+ const cardCreateTool = defineKanboTool({
10759
+ name: "kanbo_card_create",
10760
+ title: "Create a card",
10761
+ 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.`,
10762
+ inputSchema: {
10763
+ title: z.string().optional().describe("What the card is called. Leave it out and the card is titled with its own key; the description then says what the task is."),
10764
+ description: z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
10765
+ column: z.string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
10766
+ parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
10767
+ executionMode: executionModeArgument.optional(),
10768
+ detail: writtenCardDetailArgument
10769
+ },
10770
+ example: {
10771
+ description: "Add slugify(text) with a test",
10772
+ parent: "TST-1"
10773
+ },
10774
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardCreate(input), detail)
10775
+ });
10776
+ //#endregion
10777
+ //#region src/mcp/transport.ts
10778
+ /** What `kanbo_card_get` may bring along with the card. */
10779
+ const KANBO_CARD_INCLUDES = [
10780
+ "comments",
10781
+ "subCards",
10782
+ "runs",
10783
+ "history",
10784
+ "prs"
10785
+ ];
10786
+ /** What a card read brings along when the caller does not say: its latest comments and its sub-cards. */
10787
+ const DEFAULT_KANBO_CARD_INCLUDES = ["comments", "subCards"];
10788
+ /** How many of its latest comments a card read brings along when the caller does not say. */
10789
+ const DEFAULT_KANBO_COMMENT_LIMIT = 10;
10790
+ /**
10791
+ * The card a tool prints, from the card a transport holds.
10792
+ *
10793
+ * Both transports project here so the JSON an agent reads is the same whether
10794
+ * it came from the server or from the board file — including the column, which
10795
+ * neither of them carries on the card itself: the row keeps a foreign key, and
10796
+ * an agent moving a card needs the slug.
10797
+ *
10798
+ * Every field is named rather than spread. A card the board file answers with
10799
+ * carries more than this — the agent and session ids behind a run among them —
10800
+ * and passing an object through by reference would print whatever it happened
10801
+ * to hold, which is both a different shape from the server's answer and host
10802
+ * facts the agent was never meant to see.
10803
+ */
10804
+ function projectKanboCard(card, columns) {
10805
+ const column = columns.find((candidate) => candidate.id === card.statusId) ?? null;
10806
+ return {
10807
+ id: card.id,
10808
+ number: card.number,
10809
+ title: card.title,
10810
+ description: card.description,
10811
+ column: column?.name ?? null,
10812
+ columnSlug: column?.slug ?? null,
10813
+ statusLine: card.statusLine,
10814
+ waitingFor: card.waitingFor,
10815
+ priority: card.priority,
10816
+ labels: card.labels,
10817
+ executionMode: card.executionMode,
10818
+ parentIssueId: card.parentIssueId,
10819
+ attemptCount: card.attemptCount,
10820
+ activeRun: projectKanboActiveRun(card.activeRun),
10821
+ updatedAt: card.updatedAt,
10822
+ ...card.attention?.returned ? { returned: true } : {},
10823
+ ...card.attention?.lastComment ? { lastComment: card.attention.lastComment } : {}
10824
+ };
10825
+ }
10826
+ /** A sub-card, as the card above it lists it: enough to name it and read it next. */
10827
+ function projectKanboSubCard(card, columns) {
10828
+ return {
10829
+ id: card.id,
10830
+ title: card.title,
10831
+ columnSlug: columns.find((candidate) => candidate.id === card.statusId)?.slug ?? null
10832
+ };
10833
+ }
10834
+ /** The run going on right now, as much of it as the agent working the card needs. */
10835
+ function projectKanboActiveRun(run) {
10836
+ return run === null ? null : {
10837
+ id: run.id,
10838
+ agentName: run.agentName,
10839
+ state: run.state,
10840
+ executionMode: run.executionMode,
10841
+ branch: run.branch,
10842
+ startedAt: run.startedAt
10843
+ };
10844
+ }
10845
+ /**
10846
+ * One launch of a card, from the run row either transport holds.
10847
+ *
10848
+ * What the row carries beyond this — the worktree path, the chat session
10849
+ * driving it, who launched it — belongs to the host that launched the run, not
10850
+ * to the agent that asked for one.
10851
+ */
10852
+ function projectKanboRun(run) {
10853
+ return {
10854
+ id: run.id,
10855
+ issueId: run.issueId,
10856
+ agentName: run.agentName,
10857
+ state: run.state,
10858
+ executionMode: run.executionMode,
10859
+ branch: run.branch,
10860
+ startedAt: run.startedAt,
10861
+ endedAt: run.endedAt,
10862
+ externalSessionRef: run.externalSessionRef ?? null
10863
+ };
10864
+ }
10865
+ /**
10866
+ * A pull-request link, from the row either transport holds — without who made
10867
+ * it, which is the board's record rather than anything the agent acts on.
10868
+ */
10869
+ function projectKanboPullRequest(link) {
10870
+ return {
10871
+ id: link.id,
10872
+ issueId: link.issueId,
10873
+ owner: link.owner,
10874
+ repo: link.repo,
10875
+ number: link.number,
10876
+ url: link.url,
10877
+ createdAt: link.createdAt
10878
+ };
10879
+ }
10880
+ /** A sprint, from the one `listSprints` answers with on either end — the description and timestamps left behind. */
10881
+ function projectKanboSprint(sprint) {
10882
+ return {
10883
+ id: sprint.id,
10884
+ title: sprint.title,
10885
+ status: sprint.status,
10886
+ startDate: sprint.startDate,
10887
+ dueDate: sprint.dueDate,
10888
+ current: sprint.current,
10889
+ openCards: sprint.cards.open,
10890
+ doneCards: sprint.cards.done
10891
+ };
10892
+ }
10893
+ /** A column of the board, from the row either transport holds; `board` is every column, to name the ones it accepts cards from. */
10894
+ function projectKanboColumn(column, board) {
10895
+ const stored = column.entryRules;
10896
+ const allowedIds = typeof column.allowedFrom === "string" ? readFlowRules({
10897
+ allowedFrom: column.allowedFrom,
10898
+ wipLimit: null
10899
+ }).allowedFrom : column.allowedFrom ?? null;
10900
+ return {
10901
+ id: column.id,
10902
+ name: column.name,
10903
+ slug: normalizeStatusName(column.name),
10904
+ description: column.description,
10905
+ order: column.order,
10906
+ entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null }),
10907
+ allowedFrom: allowedIds === null ? null : board.filter((other) => allowedIds.includes(other.id)).map((other) => normalizeStatusName(other.name)),
10908
+ wipLimit: column.wipLimit ?? null
10909
+ };
10910
+ }
10911
+ /**
10912
+ * Does the card answer the query? The filters of `KanboCardQuery`, applied to
10913
+ * a card already read — for a transport whose server filters on less than the
10914
+ * query names. `statusIds` are the query's columns, already resolved.
10915
+ */
10916
+ function matchesKanboCardQuery(card, query, statusIds) {
10917
+ const text = query.text === void 0 ? void 0 : foldCase(query.text);
10918
+ return (statusIds === null || card.statusId !== null && statusIds.has(card.statusId)) && (query.waitingForPerson === void 0 || card.waitingFor === "human" === query.waitingForPerson) && (query.parent === void 0 || card.parentIssueId === query.parent) && (query.hasActiveRun === void 0 || card.activeRun !== null === query.hasActiveRun) && (query.returned === void 0 || (card.attention?.returned ?? false) === query.returned) && (text === void 0 || [
10919
+ card.id,
10920
+ card.title,
10921
+ card.description ?? ""
10922
+ ].some((value) => foldCase(value).includes(text))) && (query.updatedSince === void 0 || card.updatedAt >= query.updatedSince) && (query.labels ?? []).every((label) => card.labels.includes(label)) && (query.priority === void 0 || query.priority.includes(card.priority));
10923
+ }
10924
+ /** A page cut out of cards already picked, in the order they were picked. */
10925
+ function pageOf(cards, input) {
10926
+ const offset = input.offset ?? 0;
10927
+ return {
10928
+ cards: cards.slice(offset, input.limit === void 0 ? void 0 : offset + input.limit),
10929
+ total: cards.length,
10930
+ offset
10931
+ };
10932
+ }
10933
+ /** A comment as a card read lists it, from the row either transport holds. */
10934
+ function projectKanboCardComment(comment) {
10935
+ const decision = comment.authorKind === "system.approved" || comment.authorKind === "system.returned";
10936
+ const author = decision ? "user" : normalizeCommentAuthorKind(comment.authorKind);
10937
+ const named = author !== "system" || decision;
10938
+ const outside = author === "system" && isOutsideWriter(comment) && comment.authorKind === "system";
10939
+ return {
10940
+ id: comment.id,
10941
+ author,
10942
+ by: named || outside ? comment.authorId ?? author : "kanbo",
10943
+ content: comment.content,
10944
+ createdAt: comment.createdAt
10945
+ };
10946
+ }
10947
+ /** A field change as a card read lists it — who by kind only: the id behind it is the host's. */
10948
+ function projectKanboFieldChange(change) {
10949
+ return {
10950
+ field: change.field,
10951
+ from: change.fromValue,
10952
+ to: change.toValue,
10953
+ actor: change.actorKind,
10954
+ createdAt: change.createdAt
10955
+ };
12038
10956
  }
12039
- //#endregion
12040
- //#region src/mcp/tools/card-create.ts
12041
- /** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
12042
- const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pass `parent` = that card's id (its key, e.g. `MAN-012`).";
12043
- /** A new card, or a sub-card of the one being worked on. */
12044
- const cardCreateTool = defineKanboTool({
12045
- name: "kanbo_card_create",
12046
- title: "Create a card",
12047
- 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.`,
12048
- inputSchema: {
12049
- title: z.string().optional().describe("What the card is called. Leave it out and the card is titled with its own key; the description then says what the task is."),
12050
- description: z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
12051
- column: z.string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
12052
- parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
12053
- executionMode: executionModeArgument.optional(),
12054
- detail: writtenCardDetailArgument
12055
- },
12056
- example: {
12057
- description: "Add slugify(text) with a test",
12058
- parent: "TST-1"
12059
- },
12060
- run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardCreate(input), detail)
12061
- });
12062
10957
  //#endregion
12063
10958
  //#region src/mcp/tools/card-get.ts
12064
10959
  /** One card in full, with what an agent reads next to it. */
@@ -12702,16 +11597,20 @@ async function createDbTransport(input) {
12702
11597
  * The board `kanbo mcp` serves — or, in a folder with none yet, a stand-in
12703
11598
  * that finds it later.
12704
11599
  *
12705
- * A person who registers kanbo in their own agent settings has it started in
12706
- * every folder they open, and most of those have no board. A server that
12707
- * refused to start there would show up in the agent as a broken connection. So
12708
- * it starts anyway: the client connects and reads the instructions, and every
12709
- * tool answers that there is no board here. Each call looks again, so a board
12710
- * a person creates in this folder while the agent is running is picked up on
12711
- * the next call, and held from then on.
12712
- *
12713
- * Any other failure to open the board — a file that is not a board, a database
12714
- * that does not answer — is not "no board" and still stops the server at once.
11600
+ * The server is registered once, in the person's own agent settings, so every
11601
+ * project they open starts it — and most of those folders cannot give it a
11602
+ * board: there is none yet, the database `KANBO_DB_PATH` names is older than
11603
+ * this kanbo, or it knows no project for the folder. A server that refused to
11604
+ * start there would show up in the agent's client as a broken connection. So
11605
+ * it starts anyway: the client connects and reads the instructions, and each
11606
+ * tool says why there is no board here. Each call looks again, so a board a
11607
+ * person creates in this folder while the agent is running is picked up on the
11608
+ * next call, and held from then on.
11609
+ *
11610
+ * "No board" answers with one fixed message; the two other reasons answer with
11611
+ * the error's own message, unchanged. Any other failure to open the board — a
11612
+ * file that is not a board, a database that does not answer — still stops the
11613
+ * server at once.
12715
11614
  */
12716
11615
  /** What every tool answers in a folder with no board. */
12717
11616
  const NO_BOARD_FOR_AGENT_MESSAGE = DATABASE_NOT_FOUND_AGENT_MESSAGE;
@@ -12755,7 +11654,7 @@ function createDeferredBoard(input) {
12755
11654
  return opened;
12756
11655
  }, (error) => {
12757
11656
  opening = null;
12758
- throw isNoBoard(error) ? new CliError(2, NO_BOARD_FOR_AGENT_MESSAGE) : error;
11657
+ throw error instanceof BoardNotFoundError ? new CliError(2, NO_BOARD_FOR_AGENT_MESSAGE) : error;
12759
11658
  });
12760
11659
  return await opening;
12761
11660
  };
@@ -12769,9 +11668,13 @@ function createDeferredBoard(input) {
12769
11668
  }
12770
11669
  };
12771
11670
  }
12772
- /** The failure that means nothing names a board here — not a board that failed to open. */
11671
+ /**
11672
+ * The failures that mean this folder has no usable board for now: nothing
11673
+ * names one, the host database is older than this kanbo, or no project of it
11674
+ * matches the folder. Not a board that failed to open.
11675
+ */
12773
11676
  function isNoBoard(error) {
12774
- return error instanceof BoardNotFoundError;
11677
+ return error instanceof BoardNotFoundError || error instanceof CliError && (error.exitCode === 3 || error.exitCode === 2);
12775
11678
  }
12776
11679
  /**
12777
11680
  * The same board, with `kanbo_prime` ending in a line about a newer kanbo once
@@ -12906,84 +11809,6 @@ async function migrateOwnFile(target, options) {
12906
11809
  }, options);
12907
11810
  }
12908
11811
  //#endregion
12909
- //#region src/cli/commands/prime.ts
12910
- /**
12911
- * `kanbo prime` — what the board tells an agent about itself.
12912
- *
12913
- * The first thing an agent should run, and the reason the instruction block
12914
- * `kanbo init` writes says so: the columns of this board with their meaning,
12915
- * and the rules a card travels by. The text belongs to the board, not to this
12916
- * tool, so the command prints what `buildPrimeText` says — and then the
12917
- * commands, because an agent reading this from a shell may have no MCP tools
12918
- * and nothing else to learn them from. The `kanbo_prime` tool leaves them out.
12919
- */
12920
- function registerPrimeCommand(program) {
12921
- withBoardOptions(program.command("prime").description("Print the board's columns and rules for an agent to read first")).action(async (options) => {
12922
- await runBoardCommand(options, "read", async (session) => {
12923
- const text = `${await session.ops.buildPrimeText(session.workspace.id)}\n\n${COMMAND_SHEET}`;
12924
- return {
12925
- value: { text },
12926
- text
12927
- };
12928
- });
12929
- });
12930
- }
12931
- //#endregion
12932
- //#region src/cli/commands/ready.ts
12933
- /** How many cards `kanbo ready` prints when the caller names no limit. */
12934
- const DEFAULT_READY_LIMIT = 10;
12935
- /** How many returned cards `kanbo ready` prints ahead of the ready ones, at most. */
12936
- const READY_RETURNED_LIMIT = 5;
12937
- /**
12938
- * The ready cards, and ahead of them the cards a person sent back — an agent
12939
- * continues those before it takes anything new. `--json` stays the ready
12940
- * cards alone; `kanbo card list --returned --json` lists the others.
12941
- */
12942
- async function describeReady(session, cards, returned) {
12943
- if (returned.cards.length === 0) return describeCards(cards);
12944
- const runs = await session.ops.readBoardProjectionForIssues(returned.cards.map((card) => card.id));
12945
- const attention = await session.ops.readCardAttention(returned.cards, runs.map(() => false));
12946
- const views = withCardAttention(returned.cards.map((card, index) => projectCard(card, returned.columns, runs[index])), attention);
12947
- const more = returned.total - views.length;
12948
- return [
12949
- "Returned to you — continue these first, then hand them back to a person:",
12950
- describeCards(views),
12951
- ...more > 0 ? [`… and ${more} more: kanbo card list --returned`] : [],
12952
- "",
12953
- "Ready:",
12954
- describeCards(cards)
12955
- ].join("\n");
12956
- }
12957
- /**
12958
- * `kanbo ready` — the cards an agent may pick up right now.
12959
- *
12960
- * What counts as ready is the board's rule, not this tool's: spelled out,
12961
- * nobody working on it, nobody's turn but the agent's. An agent asking for work
12962
- * takes the first line and starts there.
12963
- */
12964
- function registerReadyCommand(program) {
12965
- withBoardOptions(program.command("ready").description("Cards ready to start: in To Do, not taken, not waiting for a person").option("--limit <count>", `how many cards to print (${DEFAULT_READY_LIMIT} when left out)`, parseCount).option("--all", "print every ready card")).action(async (options) => {
12966
- await runBoardCommand(options, "read", async (session) => {
12967
- const page = await session.ops.queryReady({
12968
- workspaceId: session.workspace.id,
12969
- limit: options.all ? void 0 : options.limit ?? DEFAULT_READY_LIMIT
12970
- });
12971
- const [runs, returned] = await Promise.all([session.ops.readBoardProjectionForIssues(page.cards.map((card) => card.id)), session.ops.queryCards({
12972
- workspaceId: session.workspace.id,
12973
- returned: true,
12974
- limit: READY_RETURNED_LIMIT
12975
- })]);
12976
- const cards = page.cards.map((card, index) => projectCard(card, page.columns, runs[index]));
12977
- if (page.total > cards.length) console.error(`kanbo: ${page.total - cards.length} more ready; --limit <count> or --all for more`);
12978
- return {
12979
- value: cards,
12980
- text: await describeReady(session, cards, returned),
12981
- fields: CARD_VIEW_FIELDS
12982
- };
12983
- });
12984
- });
12985
- }
12986
- //#endregion
12987
11812
  //#region src/cli/commands/return.ts
12988
11813
  /**
12989
11814
  * `kanbo return` — a person sending a card back with a reason.
@@ -13090,104 +11915,6 @@ function describeApplied(database) {
13090
11915
  ].join("\n");
13091
11916
  }
13092
11917
  //#endregion
13093
- //#region src/cli/commands/run.ts
13094
- /**
13095
- * `kanbo run …` — saying that a card is being worked on, and how that ended.
13096
- *
13097
- * A run launched from a terminal is recorded exactly like one an app launched:
13098
- * the card gets a line saying who is on it and a comment naming the attempt, so
13099
- * `kanbo ready` stops offering the card to the next agent that asks. Its
13100
- * launch is filed as `external` — no app started this, and a board that
13101
- * claimed otherwise would send someone looking for a session that never existed.
13102
- */
13103
- function registerRunCommands(program) {
13104
- const run = program.command("run").description("Record when an agent starts and finishes work on a card");
13105
- withBoardOptions(run.command("start").description("Say that you started working on a card").argument("<card>", "the card, as MAN-012, MAN-12 or 12").requiredOption("--agent <name>", "your name, e.g. claude").option("--branch <branch>", "the branch the work happens on").option("--execution-mode <mode>", "where the work happens: worktree or main (default: what the card says)", parseExecutionMode).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) => {
13106
- await runBoardCommand(options, "write", async (session) => {
13107
- const card = await requireCard(session, reference);
13108
- const started = await session.ops.startRun(card.id, {
13109
- agentName: options.agent,
13110
- branch: options.branch,
13111
- executionMode: options.executionMode,
13112
- externalSessionRef: options.session ?? sessionRefFromEnvironment() ?? void 0,
13113
- launchedByKind: "external"
13114
- }, createCliActor());
13115
- const view = projectRun({
13116
- ...started,
13117
- attempt: await attemptFor(session, started)
13118
- });
13119
- return {
13120
- value: view,
13121
- text: `Run ${view.id} started on ${card.id}\nWhen you are done: kanbo run finish ${view.id} --state finished`,
13122
- fields: RUN_VIEW_FIELDS
13123
- };
13124
- });
13125
- });
13126
- withBoardOptions(run.command("attach-session").description("Say which agent session did this work (the first one given stays)").argument("<runId>", "the run id kanbo run start printed").argument("<ref>", "claude:<session id> or codex:<session id>").option("--replace", "replace the session already given (a person only)")).action(async (runId, ref, options, command) => {
13127
- const actor = options.replace ? requireHumanActor("run attach-session --replace", typedWords(command)) : null;
13128
- await runBoardCommand(options, "write", async (session) => {
13129
- const found = await requireRun(session, runId);
13130
- const attached = actor ? await session.ops.clearRunSessionRef(found.id, actor, { replaceWith: ref }) : await session.ops.recordRunSessionRef(found.id, ref);
13131
- const view = projectRun({
13132
- ...attached,
13133
- attempt: await attemptFor(session, attached)
13134
- });
13135
- return {
13136
- value: view,
13137
- text: `Run ${view.id} carries ${attached.externalSessionRef}`,
13138
- fields: RUN_VIEW_FIELDS
13139
- };
13140
- });
13141
- });
13142
- withBoardOptions(run.command("clear-session").description("Remove the session given for this work (a person only)").argument("<runId>", "the run, as `kanbo run start` printed it")).action(async (runId, options, command) => {
13143
- const actor = requireHumanActor("run clear-session", typedWords(command));
13144
- await runBoardCommand(options, "write", async (session) => {
13145
- const found = await requireRun(session, runId);
13146
- const cleared = await session.ops.clearRunSessionRef(found.id, actor);
13147
- const view = projectRun({
13148
- ...cleared,
13149
- attempt: await attemptFor(session, cleared)
13150
- });
13151
- return {
13152
- value: view,
13153
- text: `Run ${view.id} names no log`,
13154
- fields: RUN_VIEW_FIELDS
13155
- };
13156
- });
13157
- });
13158
- withBoardOptions(run.command("finish").description("Say how the work ended").argument("<runId>", "the run, as `kanbo run start` printed it").requiredOption("--state <state>", `one of ${RUN_FINISH_STATES.join(", ")} (completed, succeeded, done: finished; error: failed; cancelled, aborted: stopped)`, parseRunState).option("--error-text <text>", "what went wrong, for a failed run")).action(async (runId, options) => {
13159
- await runBoardCommand(options, "write", async (session) => {
13160
- const run = await requireRun(session, runId);
13161
- const finished = await session.ops.finishRun(run.id, {
13162
- state: options.state,
13163
- errorText: options.errorText
13164
- }, createCliActor());
13165
- const view = projectRun({
13166
- ...finished,
13167
- attempt: await attemptFor(session, finished)
13168
- });
13169
- return {
13170
- value: view,
13171
- text: `Run ${view.id} ${view.state}`,
13172
- fields: RUN_VIEW_FIELDS
13173
- };
13174
- });
13175
- });
13176
- }
13177
- /**
13178
- * Which attempt a run is. `startRun` and `finishRun` hand back the row
13179
- * itself; the count only exists once every run of the card is lined up.
13180
- */
13181
- async function attemptFor(session, run) {
13182
- const runs = await session.ops.listRuns(run.issueId);
13183
- return runs.find((candidate) => candidate.id === run.id)?.attempt ?? runs.length;
13184
- }
13185
- function parseRunState(value) {
13186
- const state = readRunFinishState(value);
13187
- if (!state) throw new CliError(1, `Unknown run state "${value}". Use one of ${RUN_FINISH_STATES.join(", ")}.`);
13188
- return state;
13189
- }
13190
- //#endregion
13191
11918
  //#region src/serve/actors.ts
13192
11919
  /**
13193
11920
  * Who a request writes as.
@@ -13251,12 +11978,14 @@ const BOARD_PAGE_CSP = [
13251
11978
  "style-src 'self'",
13252
11979
  "connect-src 'self'",
13253
11980
  "img-src 'self' data:",
11981
+ "font-src 'self'",
13254
11982
  "base-uri 'none'",
13255
11983
  "form-action 'none'",
13256
11984
  "frame-ancestors 'none'"
13257
11985
  ].join("; ");
13258
11986
  const SCRIPT_PATH = "/assets/board.js";
13259
11987
  const STYLESHEET_PATH = "/assets/board.css";
11988
+ const FONT_PATH = /^\/assets\/fonts\/([a-z0-9-]+\.woff2)$/;
13260
11989
  /** The document itself. No data in it; the `data:` icon keeps the browser from asking for `/favicon.ico`. */
13261
11990
  const BOARD_PAGE_HTML = `<!doctype html>
13262
11991
  <html lang="en">
@@ -13283,19 +12012,23 @@ function resolveBoardPageDirectory() {
13283
12012
  const candidates = [join(here, "page"), join(here, "..", "..", "dist", "page")];
13284
12013
  return candidates.find((directory) => existsSync(join(directory, "board.js"))) ?? candidates[1];
13285
12014
  }
13286
- /** The page's two files from `directory`, or `null` when the page has not been built there. */
12015
+ /** The page's files from `directory`, or `null` when the page has not been built there. */
13287
12016
  function readBoardPageAssets(directory) {
13288
12017
  const script = join(directory, "board.js");
13289
12018
  const stylesheet = join(directory, "board.css");
13290
12019
  if (!existsSync(script) || !existsSync(stylesheet)) return null;
12020
+ const fontDirectory = join(directory, "fonts");
12021
+ const fonts = /* @__PURE__ */ new Map();
12022
+ if (existsSync(fontDirectory)) for (const name of readdirSync(fontDirectory).filter((file) => FONT_PATH.test(`/assets/fonts/${file}`))) fonts.set(name, readFileSync(join(fontDirectory, name)));
13291
12023
  return {
13292
12024
  script: readFileSync(script, "utf8"),
13293
- stylesheet: readFileSync(stylesheet, "utf8")
12025
+ stylesheet: readFileSync(stylesheet, "utf8"),
12026
+ fonts
13294
12027
  };
13295
12028
  }
13296
12029
  /** Whether a path is one of the page's own. */
13297
12030
  function isBoardPagePath(pathname) {
13298
- return pathname === "/" || pathname === SCRIPT_PATH || pathname === STYLESHEET_PATH;
12031
+ return pathname === "/" || pathname === SCRIPT_PATH || pathname === STYLESHEET_PATH || FONT_PATH.test(pathname);
13299
12032
  }
13300
12033
  /**
13301
12034
  * Answer a page path. With no built page, `/` says how to build it rather than
@@ -13314,6 +12047,31 @@ function sendBoardPage(response, pathname, assets) {
13314
12047
  response.end(body);
13315
12048
  return;
13316
12049
  }
12050
+ const fontName = FONT_PATH.exec(pathname)?.[1];
12051
+ if (fontName !== void 0) {
12052
+ const font = assets.fonts.get(fontName);
12053
+ if (!font) {
12054
+ const body = JSON.stringify({
12055
+ code: "not_found",
12056
+ message: "No such font"
12057
+ });
12058
+ response.writeHead(404, {
12059
+ "content-type": "application/json",
12060
+ "content-length": Buffer.byteLength(body)
12061
+ });
12062
+ response.end(body);
12063
+ return;
12064
+ }
12065
+ response.writeHead(200, {
12066
+ "content-type": "font/woff2",
12067
+ "content-length": font.length,
12068
+ "cache-control": "no-cache",
12069
+ "x-content-type-options": "nosniff",
12070
+ "referrer-policy": "no-referrer"
12071
+ });
12072
+ response.end(font);
12073
+ return;
12074
+ }
13317
12075
  const [contentType, body] = pathname === SCRIPT_PATH ? ["text/javascript; charset=utf-8", assets.script] : pathname === STYLESHEET_PATH ? ["text/css; charset=utf-8", assets.stylesheet] : ["text/html; charset=utf-8", BOARD_PAGE_HTML];
13318
12076
  response.writeHead(200, {
13319
12077
  "content-type": contentType,
@@ -13597,65 +12355,172 @@ function withDetails(body, details) {
13597
12355
  details
13598
12356
  };
13599
12357
  }
13600
- //#endregion
13601
- //#region src/serve/router.ts
13602
- /** Declare a route; the query and body reach the handler parsed, or the request is a 400. */
13603
- function route(spec) {
13604
- return {
13605
- method: spec.method,
13606
- segments: splitPath(spec.path),
13607
- write: spec.write ?? false,
13608
- run: async ({ params, query, body, actors }) => {
13609
- const parsedQuery = spec.query ? spec.query.safeParse(query) : {
13610
- success: true,
13611
- data: query
13612
- };
13613
- if (!parsedQuery.success) throw validationError("query", parsedQuery.error);
13614
- const parsedBody = spec.body ? spec.body.safeParse(body ?? {}) : {
13615
- success: true,
13616
- data: void 0
13617
- };
13618
- if (!parsedBody.success) throw validationError("body", parsedBody.error);
13619
- return await spec.handle({
13620
- params,
13621
- query: parsedQuery.data,
13622
- body: parsedBody.data,
13623
- actors
13624
- });
13625
- }
13626
- };
12358
+ //#endregion
12359
+ //#region src/serve/router.ts
12360
+ /** Declare a route; the query and body reach the handler parsed, or the request is a 400. */
12361
+ function route(spec) {
12362
+ return {
12363
+ method: spec.method,
12364
+ segments: splitPath(spec.path),
12365
+ write: spec.write ?? false,
12366
+ run: async ({ params, query, body, actors }) => {
12367
+ const parsedQuery = spec.query ? spec.query.safeParse(query) : {
12368
+ success: true,
12369
+ data: query
12370
+ };
12371
+ if (!parsedQuery.success) throw validationError("query", parsedQuery.error);
12372
+ const parsedBody = spec.body ? spec.body.safeParse(body ?? {}) : {
12373
+ success: true,
12374
+ data: void 0
12375
+ };
12376
+ if (!parsedBody.success) throw validationError("body", parsedBody.error);
12377
+ return await spec.handle({
12378
+ params,
12379
+ query: parsedQuery.data,
12380
+ body: parsedBody.data,
12381
+ actors
12382
+ });
12383
+ }
12384
+ };
12385
+ }
12386
+ /** The route a request names and its path parameters, or `null` when no route does. */
12387
+ function matchRoute(routes, method, path) {
12388
+ const segments = splitPath(path);
12389
+ for (const candidate of routes) {
12390
+ if (candidate.method !== method || candidate.segments.length !== segments.length) continue;
12391
+ const params = {};
12392
+ if (candidate.segments.every((segment, index) => {
12393
+ if (segment.startsWith(":")) {
12394
+ params[segment.slice(1)] = segments[index];
12395
+ return segments[index].length > 0;
12396
+ }
12397
+ return segment === segments[index];
12398
+ })) return {
12399
+ route: candidate,
12400
+ params
12401
+ };
12402
+ }
12403
+ return null;
12404
+ }
12405
+ /**
12406
+ * A path as its segments, decoded, with a trailing slash ignored: `/issues`
12407
+ * and `/issues/` are the same list route. A segment that is not valid
12408
+ * percent-encoding is kept as it came, and names nothing on the board.
12409
+ */
12410
+ function splitPath(path) {
12411
+ return path.split("/").filter(Boolean).map((segment) => {
12412
+ try {
12413
+ return decodeURIComponent(segment);
12414
+ } catch {
12415
+ return segment;
12416
+ }
12417
+ });
12418
+ }
12419
+ //#endregion
12420
+ //#region src/cli/metrics-view.ts
12421
+ /** Every field `kanbo stats --json` may name, in the order the value carries them. */
12422
+ const BOARD_METRICS_FIELDS = [
12423
+ "range",
12424
+ "now",
12425
+ "milestoneId",
12426
+ "columns",
12427
+ "cycleTime",
12428
+ "leadTime",
12429
+ "throughput",
12430
+ "wip",
12431
+ "aging",
12432
+ "timeInColumn",
12433
+ "cumulativeFlow",
12434
+ "finished",
12435
+ "returns",
12436
+ "attempts",
12437
+ "canceled",
12438
+ "agents",
12439
+ "people"
12440
+ ];
12441
+ const LABEL_WIDTH = 16;
12442
+ /** A duration as the command line prints it: minutes under an hour, hours under a day, then days, to one decimal; a dash for none. */
12443
+ function formatDuration(seconds) {
12444
+ if (seconds === null) return "—";
12445
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
12446
+ if (seconds < 86400) return `${Number((seconds / 3600).toFixed(1))}h`;
12447
+ return `${Number((seconds / 86400).toFixed(1))}d`;
12448
+ }
12449
+ function percent(share) {
12450
+ return share === null ? "—" : `${Math.round(share * 100)}%`;
12451
+ }
12452
+ function decimal(value) {
12453
+ return value === null ? "—" : String(Number(value.toFixed(1)));
12454
+ }
12455
+ function count(n, noun) {
12456
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
12457
+ }
12458
+ function day(seconds) {
12459
+ return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
12460
+ }
12461
+ /** A labelled block: the first item beside the label, the rest under it; a dash when there is nothing. */
12462
+ function block(label, items) {
12463
+ const [first = "—", ...rest] = items;
12464
+ return [`${label.padEnd(LABEL_WIDTH)}${first}`, ...rest.map((item) => `${" ".repeat(LABEL_WIDTH)}${item}`)];
12465
+ }
12466
+ /** Items joined on one line, or none at all. */
12467
+ function oneLine(items) {
12468
+ return items.length === 0 ? [] : [items.join(" · ")];
12469
+ }
12470
+ /**
12471
+ * The metrics as a person reads them in a terminal: the range, then one block
12472
+ * per figure. The cumulative flow is a series per day and has no one-screen
12473
+ * form, so the last line says how to get it.
12474
+ */
12475
+ function describeMetrics(metrics, sprint) {
12476
+ const column = (slug) => sanitizeTerminalText(metrics.columns.find((candidate) => candidate.slug === slug)?.name ?? slug);
12477
+ const by = metrics.returns.byActorKind;
12478
+ const lines = [`${day(metrics.range.from)} → ${day(metrics.range.to)}`];
12479
+ if (sprint) lines.push(`${sanitizeTerminalText(sprint.title)}: ${sprint.cards.open + sprint.cards.done} planned · ${sprint.cards.done} done`);
12480
+ if (metrics.range.from > metrics.range.to) return [...lines, "Nothing measured yet: the range starts after now."].join("\n");
12481
+ lines.push(...block("Cycle time", [`p50 ${formatDuration(metrics.cycleTime.p50)} · p85 ${formatDuration(metrics.cycleTime.p85)} (${count(metrics.cycleTime.cards, "card")})`]), ...block("Lead time", [`p50 ${formatDuration(metrics.leadTime.p50)} · p85 ${formatDuration(metrics.leadTime.p85)}`]), ...block("Throughput", oneLine(metrics.throughput.map((week) => `${week.week} ${week.finished}`))), ...block("In progress", oneLine(metrics.wip.map((wip) => `${column(wip.slug)} ${wip.cards}${wip.limit === null ? "" : `/${wip.limit}`}`))), ...block("First pass", [`${percent(metrics.returns.firstPassRate)} · attempts ${decimal(metrics.attempts.perFinishedCard)} per card`]), ...block("Returns", [`${metrics.returns.total} (people ${by.user} · agents ${by.agent} · outside ${by.external}) · ${decimal(metrics.returns.perFinishedCard)} per card`]), ...block("Canceled", [String(metrics.canceled)]), ...block("Aging", metrics.aging.map((card) => `${card.issueId} ${column(card.slug)} ${formatDuration(card.ageSeconds)}${card.overCycleP85 ? " (older than p85)" : ""}`)), ...block("Time in column", oneLine(metrics.timeInColumn.filter((time) => time.seconds > 0).map((time) => `${column(time.slug)} ${formatDuration(time.seconds)}`))), ...block("Agents", metrics.agents.map(describeAgent)), ...block("People", metrics.people.map(describePerson)), "Cumulative flow by day: kanbo stats --json cumulativeFlow");
12482
+ return lines.join("\n");
12483
+ }
12484
+ function describeAgent(agent) {
12485
+ const { runs } = agent;
12486
+ return `${sanitizeTerminalText(agent.agentName)}: ${count(runs.total, "run")} (${runs.finished} finished · ${runs.failed} failed · ${runs.stopped} stopped · ${runs.running} running) · median ${formatDuration(agent.medianRunSeconds)} · ${count(agent.cardsFinished, "card")} finished · ${percent(agent.returnRate)} returned`;
12487
+ }
12488
+ function describePerson(person) {
12489
+ return `${sanitizeTerminalText(person.id ?? "unnamed")}: ${person.created} created · ${person.approvals} approved · ${person.returns} returned · ${person.moves} moved`;
13627
12490
  }
13628
- /** The route a request names and its path parameters, or `null` when no route does. */
13629
- function matchRoute(routes, method, path) {
13630
- const segments = splitPath(path);
13631
- for (const candidate of routes) {
13632
- if (candidate.method !== method || candidate.segments.length !== segments.length) continue;
13633
- const params = {};
13634
- if (candidate.segments.every((segment, index) => {
13635
- if (segment.startsWith(":")) {
13636
- params[segment.slice(1)] = segments[index];
13637
- return segments[index].length > 0;
13638
- }
13639
- return segment === segments[index];
13640
- })) return {
13641
- route: candidate,
13642
- params
13643
- };
13644
- }
13645
- return null;
12491
+ const WEEK_SECONDS = 604800;
12492
+ /** The Monday that makes the range `DEFAULT_STATS_WEEKS` ISO weeks long, ending with the week of `now`. */
12493
+ function defaultStatsFrom(now) {
12494
+ return isoWeekOf(now).start - 11 * WEEK_SECONDS;
13646
12495
  }
13647
12496
  /**
13648
- * A path as its segments, decoded, with a trailing slash ignored: `/issues`
13649
- * and `/issues/` are the same list route. A segment that is not valid
13650
- * percent-encoding is kept as it came, and names nothing on the board.
12497
+ * `kanbo stats` — how work flows on this board (spec 2026-11-05, Part 4).
12498
+ *
12499
+ * Reading only, so it is anybody's, an agent's shell included. A sprint sets
12500
+ * the range to its own dates and keeps its cards; `--from` and `--to` still
12501
+ * win over either end.
13651
12502
  */
13652
- function splitPath(path) {
13653
- return path.split("/").filter(Boolean).map((segment) => {
13654
- try {
13655
- return decodeURIComponent(segment);
13656
- } catch {
13657
- return segment;
13658
- }
12503
+ function registerStatsCommand(program) {
12504
+ withBoardOptions(program.command("stats").description("How work flows on this board: cycle time, throughput, work in progress, returns, agents").option("--from <date>", `first day, YYYY-MM-DD (UTC) or unix seconds (12 weeks back when left out)`, (value) => parseCalendarDay(value, false)).option("--to <date>", "last day, YYYY-MM-DD (UTC, through its end) or unix seconds (now when left out)", (value) => parseCalendarDay(value, true)).option("--sprint <id>", "only the cards of this sprint, over its dates")).action(async (options) => {
12505
+ await runBoardCommand(options, "read", async (session) => {
12506
+ const now = currentUnixSeconds();
12507
+ const sprint = options.sprint === void 0 ? null : (await session.ops.listSprints(session.workspace.id, now)).find((candidate) => candidate.id === options.sprint) ?? null;
12508
+ if (options.sprint !== void 0 && sprint === null) throw new BoardError("issue_milestone_not_found", {
12509
+ milestoneId: options.sprint,
12510
+ workspaceId: session.workspace.id
12511
+ });
12512
+ const metrics = await session.ops.boardMetrics({
12513
+ workspaceIds: [session.workspace.id],
12514
+ from: options.from ?? sprint?.startDate ?? defaultStatsFrom(now),
12515
+ to: options.to ?? sprint?.dueDate ?? now,
12516
+ milestoneId: sprint?.id ?? null
12517
+ }, now);
12518
+ return {
12519
+ value: metrics,
12520
+ text: describeMetrics(metrics, sprint),
12521
+ fields: BOARD_METRICS_FIELDS
12522
+ };
12523
+ });
13659
12524
  });
13660
12525
  }
13661
12526
  //#endregion
@@ -14000,6 +12865,14 @@ function createActivityResolver(workspace, personWriterId = null) {
14000
12865
  * a server started for one of them must not reach into the rest.
14001
12866
  */
14002
12867
  const OK = { ok: true };
12868
+ /** Unix seconds in a query string, where everything arrives as text. */
12869
+ const unixSecondsQuery = z.string().regex(/^\d+$/).transform(Number);
12870
+ /** `GET /issues/metrics`: the range as `kanbo stats` takes it, `from` and `to` in unix seconds, or a sprint to read over its own dates. */
12871
+ const metricsQuery = z.object({
12872
+ from: unixSecondsQuery.optional(),
12873
+ to: unixSecondsQuery.optional(),
12874
+ sprint: z.string().min(1).optional()
12875
+ });
14003
12876
  /** How many cards a search answers with when the caller names no limit. */
14004
12877
  const DEFAULT_SEARCH_LIMIT = 20;
14005
12878
  /**
@@ -14295,6 +13168,25 @@ function createIssueRoutes(session, options = {}) {
14295
13168
  return updated;
14296
13169
  }
14297
13170
  }),
13171
+ route({
13172
+ method: "GET",
13173
+ path: "/issues/metrics",
13174
+ query: metricsQuery,
13175
+ handle: async ({ query }) => {
13176
+ const now = currentUnixSeconds();
13177
+ const sprint = query.sprint === void 0 ? null : (await ops.listSprints(workspaceId, now)).find((candidate) => candidate.id === query.sprint) ?? null;
13178
+ if (query.sprint !== void 0 && sprint === null) throw new BoardError("issue_milestone_not_found", {
13179
+ milestoneId: query.sprint,
13180
+ workspaceId
13181
+ });
13182
+ return await ops.boardMetrics({
13183
+ workspaceIds: [workspaceId],
13184
+ from: query.from ?? sprint?.startDate ?? defaultStatsFrom(now),
13185
+ to: query.to ?? sprint?.dueDate ?? now,
13186
+ milestoneId: sprint?.id ?? null
13187
+ }, now);
13188
+ }
13189
+ }),
14298
13190
  route({
14299
13191
  method: "GET",
14300
13192
  path: "/issues/workspace",
@@ -15288,113 +14180,6 @@ function registerSprintCommands(program) {
15288
14180
  });
15289
14181
  }
15290
14182
  //#endregion
15291
- //#region src/cli/metrics-view.ts
15292
- /** Every field `kanbo stats --json` may name, in the order the value carries them. */
15293
- const BOARD_METRICS_FIELDS = [
15294
- "range",
15295
- "now",
15296
- "milestoneId",
15297
- "columns",
15298
- "cycleTime",
15299
- "leadTime",
15300
- "throughput",
15301
- "wip",
15302
- "aging",
15303
- "timeInColumn",
15304
- "cumulativeFlow",
15305
- "finished",
15306
- "returns",
15307
- "attempts",
15308
- "canceled",
15309
- "agents",
15310
- "people"
15311
- ];
15312
- const LABEL_WIDTH = 16;
15313
- /** A duration as the command line prints it: minutes under an hour, hours under a day, then days, to one decimal; a dash for none. */
15314
- function formatDuration(seconds) {
15315
- if (seconds === null) return "—";
15316
- if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
15317
- if (seconds < 86400) return `${Number((seconds / 3600).toFixed(1))}h`;
15318
- return `${Number((seconds / 86400).toFixed(1))}d`;
15319
- }
15320
- function percent(share) {
15321
- return share === null ? "—" : `${Math.round(share * 100)}%`;
15322
- }
15323
- function decimal(value) {
15324
- return value === null ? "—" : String(Number(value.toFixed(1)));
15325
- }
15326
- function count(n, noun) {
15327
- return `${n} ${noun}${n === 1 ? "" : "s"}`;
15328
- }
15329
- function day(seconds) {
15330
- return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
15331
- }
15332
- /** A labelled block: the first item beside the label, the rest under it; a dash when there is nothing. */
15333
- function block(label, items) {
15334
- const [first = "—", ...rest] = items;
15335
- return [`${label.padEnd(LABEL_WIDTH)}${first}`, ...rest.map((item) => `${" ".repeat(LABEL_WIDTH)}${item}`)];
15336
- }
15337
- /** Items joined on one line, or none at all. */
15338
- function oneLine(items) {
15339
- return items.length === 0 ? [] : [items.join(" · ")];
15340
- }
15341
- /**
15342
- * The metrics as a person reads them in a terminal: the range, then one block
15343
- * per figure. The cumulative flow is a series per day and has no one-screen
15344
- * form, so the last line says how to get it.
15345
- */
15346
- function describeMetrics(metrics, sprint) {
15347
- const column = (slug) => sanitizeTerminalText(metrics.columns.find((candidate) => candidate.slug === slug)?.name ?? slug);
15348
- const by = metrics.returns.byActorKind;
15349
- const lines = [`${day(metrics.range.from)} → ${day(metrics.range.to)}`];
15350
- if (sprint) lines.push(`${sanitizeTerminalText(sprint.title)}: ${sprint.cards.open + sprint.cards.done} planned · ${sprint.cards.done} done`);
15351
- if (metrics.range.from > metrics.range.to) return [...lines, "Nothing measured yet: the range starts after now."].join("\n");
15352
- lines.push(...block("Cycle time", [`p50 ${formatDuration(metrics.cycleTime.p50)} · p85 ${formatDuration(metrics.cycleTime.p85)} (${count(metrics.cycleTime.cards, "card")})`]), ...block("Lead time", [`p50 ${formatDuration(metrics.leadTime.p50)} · p85 ${formatDuration(metrics.leadTime.p85)}`]), ...block("Throughput", oneLine(metrics.throughput.map((week) => `${week.week} ${week.finished}`))), ...block("In progress", oneLine(metrics.wip.map((wip) => `${column(wip.slug)} ${wip.cards}${wip.limit === null ? "" : `/${wip.limit}`}`))), ...block("First pass", [`${percent(metrics.returns.firstPassRate)} · attempts ${decimal(metrics.attempts.perFinishedCard)} per card`]), ...block("Returns", [`${metrics.returns.total} (people ${by.user} · agents ${by.agent} · outside ${by.external}) · ${decimal(metrics.returns.perFinishedCard)} per card`]), ...block("Canceled", [String(metrics.canceled)]), ...block("Aging", metrics.aging.map((card) => `${card.issueId} ${column(card.slug)} ${formatDuration(card.ageSeconds)}${card.overCycleP85 ? " (older than p85)" : ""}`)), ...block("Time in column", oneLine(metrics.timeInColumn.filter((time) => time.seconds > 0).map((time) => `${column(time.slug)} ${formatDuration(time.seconds)}`))), ...block("Agents", metrics.agents.map(describeAgent)), ...block("People", metrics.people.map(describePerson)), "Cumulative flow by day: kanbo stats --json cumulativeFlow");
15353
- return lines.join("\n");
15354
- }
15355
- function describeAgent(agent) {
15356
- const { runs } = agent;
15357
- return `${sanitizeTerminalText(agent.agentName)}: ${count(runs.total, "run")} (${runs.finished} finished · ${runs.failed} failed · ${runs.stopped} stopped · ${runs.running} running) · median ${formatDuration(agent.medianRunSeconds)} · ${count(agent.cardsFinished, "card")} finished · ${percent(agent.returnRate)} returned`;
15358
- }
15359
- function describePerson(person) {
15360
- return `${sanitizeTerminalText(person.id ?? "unnamed")}: ${person.created} created · ${person.approvals} approved · ${person.returns} returned · ${person.moves} moved`;
15361
- }
15362
- const WEEK_SECONDS = 604800;
15363
- /** The Monday that makes the range `DEFAULT_STATS_WEEKS` ISO weeks long, ending with the week of `now`. */
15364
- function defaultStatsFrom(now) {
15365
- return isoWeekOf(now).start - 11 * WEEK_SECONDS;
15366
- }
15367
- /**
15368
- * `kanbo stats` — how work flows on this board (spec 2026-11-05, Part 4).
15369
- *
15370
- * Reading only, so it is anybody's, an agent's shell included. A sprint sets
15371
- * the range to its own dates and keeps its cards; `--from` and `--to` still
15372
- * win over either end.
15373
- */
15374
- function registerStatsCommand(program) {
15375
- withBoardOptions(program.command("stats").description("How work flows on this board: cycle time, throughput, work in progress, returns, agents").option("--from <date>", `first day, YYYY-MM-DD (UTC) or unix seconds (12 weeks back when left out)`, (value) => parseCalendarDay(value, false)).option("--to <date>", "last day, YYYY-MM-DD (UTC, through its end) or unix seconds (now when left out)", (value) => parseCalendarDay(value, true)).option("--sprint <id>", "only the cards of this sprint, over its dates")).action(async (options) => {
15376
- await runBoardCommand(options, "read", async (session) => {
15377
- const now = currentUnixSeconds();
15378
- const sprint = options.sprint === void 0 ? null : (await session.ops.listSprints(session.workspace.id, now)).find((candidate) => candidate.id === options.sprint) ?? null;
15379
- if (options.sprint !== void 0 && sprint === null) throw new BoardError("issue_milestone_not_found", {
15380
- milestoneId: options.sprint,
15381
- workspaceId: session.workspace.id
15382
- });
15383
- const metrics = await session.ops.boardMetrics({
15384
- workspaceIds: [session.workspace.id],
15385
- from: options.from ?? sprint?.startDate ?? defaultStatsFrom(now),
15386
- to: options.to ?? sprint?.dueDate ?? now,
15387
- milestoneId: sprint?.id ?? null
15388
- }, now);
15389
- return {
15390
- value: metrics,
15391
- text: describeMetrics(metrics, sprint),
15392
- fields: BOARD_METRICS_FIELDS
15393
- };
15394
- });
15395
- });
15396
- }
15397
- //#endregion
15398
14183
  //#region src/cli/commands/uninstall.ts
15399
14184
  function registerUninstallCommand(program) {
15400
14185
  program.command("uninstall").description("Disconnect kanbo from your agents; the board stays unless --purge").option("--project", "only this project's files").option("--global", "only your own files, for every project").option("--purge", "also remove this project's board settings, and its board file after you confirm").option("--yes", "do it without asking (never deletes a board file)").option("--json [fields]", "print what was done as JSON; name comma-separated fields to print only those").action(async (options) => {
@@ -15557,95 +14342,30 @@ function printActions(actions, notes, options) {
15557
14342
  * this one.
15558
14343
  *
15559
14344
  * The order is the order `kanbo --help` lists them in, under the heading of
15560
- * the group each belongs to.
14345
+ * the group each belongs to. Agents reach the board through the MCP server
14346
+ * (`kanbo mcp`) alone; every other command is a person's.
15561
14347
  */
15562
14348
  function registerKanboCommands(program, context = {}) {
15563
14349
  program.commandsGroup("Get started:");
15564
14350
  registerInitCommand(program);
15565
14351
  registerConnectCommand(program);
15566
- registerInstructionsCommand(program);
15567
14352
  registerDoctorCommand(program, context.update ?? null);
15568
14353
  registerUninstallCommand(program);
15569
14354
  program.commandsGroup("Your board:");
15570
14355
  registerBoardCommand(program);
15571
- registerCardCommands(program);
15572
14356
  registerColumnsCommands(program);
15573
14357
  registerApproveCommand(program);
15574
14358
  registerReturnCommand(program);
15575
- registerReadyCommand(program);
15576
- registerPrimeCommand(program);
15577
14359
  registerSprintCommands(program);
15578
14360
  registerStatsCommand(program);
15579
14361
  registerServeCommand(program);
15580
- program.commandsGroup("For agents and integrations:");
14362
+ program.commandsGroup("For agents:");
15581
14363
  registerMcpCommand(program);
15582
- registerClaimCommand(program);
15583
- registerRunCommands(program);
15584
- registerCapabilitiesCommand(program);
15585
14364
  program.commandsGroup("Shared Postgres boards:");
15586
14365
  registerMigrateCommand(program);
15587
14366
  registerRolesCommands(program);
15588
14367
  }
15589
14368
  //#endregion
15590
- //#region src/mcp/tool-names.ts
15591
- /**
15592
- * The board's tools, named once.
15593
- *
15594
- * An agent reaches this board through an MCP server, through a command line, or
15595
- * through an HTTP API (`kanbo serve`, or an app that serves the same routes),
15596
- * and the three must agree on what the board can be asked to do. The names live
15597
- * here rather than in the MCP server so that a prompt built for an agent and the
15598
- * tools that agent actually holds cannot drift apart: both read this list.
15599
- *
15600
- * Nothing here executes anything. These are names and the commands that mean
15601
- * the same thing, for a prompt and for documentation.
15602
- */
15603
- const KANBO_TOOL_NAMES = [
15604
- "kanbo_prime",
15605
- "kanbo_ready",
15606
- "kanbo_card_claim",
15607
- "kanbo_columns",
15608
- "kanbo_sprints",
15609
- "kanbo_card_get",
15610
- "kanbo_card_list",
15611
- "kanbo_card_create",
15612
- "kanbo_card_update",
15613
- "kanbo_card_move",
15614
- "kanbo_card_comment",
15615
- "kanbo_card_link_pr",
15616
- "kanbo_card_pull_requests",
15617
- "kanbo_status_line",
15618
- "kanbo_wait_approval",
15619
- "kanbo_run_start",
15620
- "kanbo_run_finish"
15621
- ];
15622
- /**
15623
- * What to type instead of calling the tool.
15624
- *
15625
- * An agent without MCP tools is not shut out of the board: every tool has a
15626
- * command that does the same thing, and the card ends up saying the same
15627
- * either way, because both call the same operations.
15628
- */
15629
- const KANBO_TOOL_CLI_EQUIVALENTS = {
15630
- kanbo_prime: { kanbo: "kanbo prime" },
15631
- kanbo_ready: { kanbo: "kanbo ready" },
15632
- kanbo_card_claim: { kanbo: "kanbo claim [card] [--agent <name>]" },
15633
- kanbo_columns: { kanbo: "kanbo columns list" },
15634
- kanbo_sprints: { kanbo: "kanbo sprint list" },
15635
- kanbo_card_get: { kanbo: "kanbo card get <id>" },
15636
- kanbo_card_list: { kanbo: "kanbo card list" },
15637
- kanbo_card_create: { kanbo: "kanbo card create --description <text> [--title <title>] [--parent <id>]" },
15638
- kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>] [--parent <id>]" },
15639
- kanbo_card_move: { kanbo: "kanbo card move <id> <column>" },
15640
- kanbo_card_comment: { kanbo: "kanbo card comment <id> --content <text>" },
15641
- kanbo_card_link_pr: { kanbo: "kanbo card pr add <id> <url>" },
15642
- kanbo_card_pull_requests: { kanbo: "kanbo card pr list <id>" },
15643
- kanbo_status_line: { kanbo: "kanbo card status-line <id> --text <text>" },
15644
- kanbo_wait_approval: { kanbo: "kanbo card wait-approval <id> [--text <text>]" },
15645
- kanbo_run_start: { kanbo: "kanbo run start <id> --agent <name> [--session <ref>]" },
15646
- kanbo_run_finish: { kanbo: "kanbo run finish <runId> --state <state>" }
15647
- };
15648
- //#endregion
15649
14369
  //#region src/capabilities/manifest.ts
15650
14370
  /**
15651
14371
  * A machine-readable description of this board, for an agent that has never
@@ -15661,12 +14381,11 @@ const KANBO_TOOL_CLI_EQUIVALENTS = {
15661
14381
  * sources gains something this file was not told about.
15662
14382
  *
15663
14383
  * `buildCapabilitiesManifest` and `renderCapabilitiesMarkdown` are the one
15664
- * source two servings come from: `kanbo capabilities` prints either shape
15665
- * from a terminal, and `kanbo mcp` serves both as resources
14384
+ * source two servings come from: `kanbo mcp` serves both as resources
15666
14385
  * (`kanbo://capabilities`, `kanbo://capabilities.md`).
15667
14386
  */
15668
14387
  /** The manifest's own shape version, bumped when a field is added or removed — not the package's version. */
15669
- const CAPABILITIES_MANIFEST_VERSION = "3";
14388
+ const CAPABILITIES_MANIFEST_VERSION = "4";
15670
14389
  /** The package name and version this build reports — the same literal `kanbo mcp` and `kanbo --version` already use. */
15671
14390
  const KANBO_PACKAGE = {
15672
14391
  name: "kanbo",
@@ -15714,14 +14433,13 @@ function buildCanonicalStrings() {
15714
14433
  approved: APPROVED_COMMENT
15715
14434
  };
15716
14435
  }
15717
- /** Every board tool, with its schema converted and its CLI equivalent attached. */
14436
+ /** Every board tool, with its schema converted. */
15718
14437
  function buildTools() {
15719
14438
  return KANBO_TOOLS.map((tool) => ({
15720
14439
  name: tool.name,
15721
14440
  title: tool.title,
15722
14441
  description: tool.description,
15723
- inputSchema: z.toJSONSchema(z.object(tool.inputSchema)),
15724
- cli: KANBO_TOOL_CLI_EQUIVALENTS[tool.name]
14442
+ inputSchema: z.toJSONSchema(z.object(tool.inputSchema))
15725
14443
  }));
15726
14444
  }
15727
14445
  /** Every leaf command the `kanbo` binary answers to, walked off its own registration. */
@@ -15755,7 +14473,7 @@ function buildCommands() {
15755
14473
  /** Everything this build of the board can tell an orchestrator about itself. */
15756
14474
  function buildCapabilitiesManifest() {
15757
14475
  return {
15758
- version: "3",
14476
+ version: "4",
15759
14477
  package: KANBO_PACKAGE,
15760
14478
  storages: [...KANBO_STORAGES],
15761
14479
  tools: buildTools(),
@@ -15778,8 +14496,8 @@ function buildCapabilitiesManifest() {
15778
14496
  `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.`,
15779
14497
  "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.",
15780
14498
  "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.",
15781
- "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.",
15782
- "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.",
14499
+ "A 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.",
14500
+ "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.",
15783
14501
  "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).",
15784
14502
  "`kanbo migrate` and `kanbo roles apply` refuse a host app's own database file: the app migrates it, and this package never touches it.",
15785
14503
  "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.",
@@ -15788,4 +14506,39 @@ function buildCapabilitiesManifest() {
15788
14506
  };
15789
14507
  }
15790
14508
  //#endregion
15791
- export { COLUMN_TEMPLATE_IDS as $, projectKanboSprint as A, formatIssueId as At, createBoardOps as B, pageOf as C, formatPlainActivityValue as Ct, projectKanboFieldChange as D, isOutsideWriter as Dt, projectKanboColumn as E, readSystemCommentKind as Et, BINDING_FILE_PATH as F, ENTRY_RULE_DESCRIPTIONS as G, parseExternalSessionRef as H, ignoreBoardFile as I, readChecklist as J, UNMET_ENTRY_RULE_REASONS as K, readBinding as L, renderCapabilitiesMarkdown as M, nextIssueNumber as Mt, maskDatabaseUrl as N, nextIssueOrder as Nt, projectKanboPullRequest as O, normalizeCommentAuthorKind as Ot, maskDatabaseUrls as P, readIssuePrefix as Pt, COLUMN_TEMPLATES as Q, readProjectBinding as R, matchesKanboCardQuery as S, formatActivityIssueReference as St, projectKanboCardComment as T, parseActivityStringArray as Tt, pullRequestFactKeys as U, formatExternalSessionRef as V, ENTRY_RULES as W, serializeEntryRules as X, readEntryRules as Y, COLUMN_CATALOGUE as Z, suggestCardKey as _, currentUnixSeconds as _t, BOARD_ERROR_RESPONSES as a, boardMetricsRuns as at, DEFAULT_KANBO_COMMENT_LIMIT as b, activityToken as bt, registerKanboTools as c, isoWeekOf as ct, KANBO_MCP_INSTRUCTIONS as d, readFlowRules as dt, readChangeSeq as et, KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI as f, DEFAULT_STATUSES as ft, isValidCardKey as g, BOARD_ACTOR as gt, CARD_KEY_PATTERN as h, previewComment as ht, KANBO_TOOL_NAMES as i, boardMetricsAgentKey as it, projectKanboSubCard as j, nextIssueIdentity as jt, projectKanboRun as k, toIssueView as kt, KANBO_TOOLS as l, utcDayLabel as lt, registerKanboCapabilitiesResources as m, cardDisplayTitle as mt, buildCapabilitiesManifest as n, TRACKED_FIELDS as nt, KANBO_MCP_SERVER_NAME as o, computeBoardMetrics as ot, KANBO_CAPABILITIES_RESOURCE_URI as p, normalizeStatusName as pt, evaluateEntryRules as q, KANBO_TOOL_CLI_EQUIVALENTS as r, diffFieldChanges as rt, createKanboMcpServer as s, nearestRank as st, CAPABILITIES_MANIFEST_VERSION as t, runBoardWrite as tt, buildKanboTools as u, FLOW_RULES as ut, DEFAULT_BOARD_FILE_PATH as v, activityKindOrder as vt, projectKanboCard as w, isEmptyActivityValue as wt, KANBO_CARD_INCLUDES as x, commentAuthorReference as xt, DEFAULT_KANBO_CARD_INCLUDES as y, activityText as yt, writeBinding as z };
14509
+ //#region src/mcp/tool-names.ts
14510
+ /**
14511
+ * The board's tools, named once.
14512
+ *
14513
+ * An agent reaches this board through an MCP server, or through an HTTP API
14514
+ * (`kanbo serve`, or an app that serves the same routes), and both must agree on
14515
+ * what the board can be asked to do. The names are read by the MCP server, by
14516
+ * the prompts built for an agent and by the HTTP API — there is no command line
14517
+ * for an agent. They live here rather than in the MCP server so that a prompt
14518
+ * built for an agent and the tools that agent actually holds cannot drift
14519
+ * apart: both read this list.
14520
+ *
14521
+ * Nothing here executes anything. These are names, for a prompt and for
14522
+ * documentation.
14523
+ */
14524
+ const KANBO_TOOL_NAMES = [
14525
+ "kanbo_prime",
14526
+ "kanbo_ready",
14527
+ "kanbo_card_claim",
14528
+ "kanbo_columns",
14529
+ "kanbo_sprints",
14530
+ "kanbo_card_get",
14531
+ "kanbo_card_list",
14532
+ "kanbo_card_create",
14533
+ "kanbo_card_update",
14534
+ "kanbo_card_move",
14535
+ "kanbo_card_comment",
14536
+ "kanbo_card_link_pr",
14537
+ "kanbo_card_pull_requests",
14538
+ "kanbo_status_line",
14539
+ "kanbo_wait_approval",
14540
+ "kanbo_run_start",
14541
+ "kanbo_run_finish"
14542
+ ];
14543
+ //#endregion
14544
+ export { readChangeSeq as $, suggestCardKey as A, nextIssueIdentity as At, formatExternalSessionRef as B, KANBO_MCP_INSTRUCTIONS as C, isEmptyActivityValue as Ct, renderCapabilitiesMarkdown as D, normalizeCommentAuthorKind as Dt, registerKanboCapabilitiesResources as E, isOutsideWriter as Et, ignoreBoardFile as F, UNMET_ENTRY_RULE_REASONS as G, pullRequestFactKeys as H, readBinding as I, readEntryRules as J, evaluateEntryRules as K, readProjectBinding as L, maskDatabaseUrl as M, nextIssueOrder as Mt, maskDatabaseUrls as N, readIssuePrefix as Nt, CARD_KEY_PATTERN as O, toIssueView as Ot, BINDING_FILE_PATH as P, COLUMN_TEMPLATE_IDS as Q, writeBinding as R, projectKanboSubCard as S, formatPlainActivityValue as St, KANBO_CAPABILITIES_RESOURCE_URI as T, readSystemCommentKind as Tt, ENTRY_RULES as U, parseExternalSessionRef as V, ENTRY_RULE_DESCRIPTIONS as W, COLUMN_CATALOGUE as X, serializeEntryRules as Y, COLUMN_TEMPLATES as Z, projectKanboColumn as _, activityKindOrder as _t, KANBO_MCP_SERVER_NAME as a, computeBoardMetrics as at, projectKanboRun as b, commentAuthorReference as bt, KANBO_TOOLS as c, utcDayLabel as ct, DEFAULT_KANBO_COMMENT_LIMIT as d, DEFAULT_STATUSES as dt, runBoardWrite as et, KANBO_CARD_INCLUDES as f, normalizeStatusName as ft, projectKanboCardComment as g, currentUnixSeconds as gt, projectKanboCard as h, BOARD_ACTOR as ht, BOARD_ERROR_RESPONSES as i, boardMetricsRuns as it, DEFAULT_BOARD_FILE_PATH as j, nextIssueNumber as jt, isValidCardKey as k, formatIssueId as kt, buildKanboTools as l, FLOW_RULES as lt, pageOf as m, previewComment as mt, CAPABILITIES_MANIFEST_VERSION as n, diffFieldChanges as nt, createKanboMcpServer as o, nearestRank as ot, matchesKanboCardQuery as p, cardDisplayTitle as pt, readChecklist as q, buildCapabilitiesManifest as r, boardMetricsAgentKey as rt, registerKanboTools as s, isoWeekOf as st, KANBO_TOOL_NAMES as t, TRACKED_FIELDS as tt, DEFAULT_KANBO_CARD_INCLUDES as u, readFlowRules as ut, projectKanboFieldChange as v, activityText as vt, KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI as w, parseActivityStringArray as wt, projectKanboSprint as x, formatActivityIssueReference as xt, projectKanboPullRequest as y, activityToken as yt, createBoardOps as z };