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.
@@ -282,14 +282,9 @@ const describeSprintCloseRefusal = refusal("close a sprint", "close it");
282
282
  const describeColumnRulesRefusal = refusal("set what a column asks of a card", "set it");
283
283
  /**
284
284
  * The same, for changing the board's columns: every agent reads its work from
285
- * them, and `kanbo ready` from To Do.
285
+ * them, and `kanbo_ready` from To Do.
286
286
  */
287
287
  const describeColumnChangesRefusal = refusal("change the board's columns", "change them");
288
- /**
289
- * The same, for taking back the log a run names: an agent that could clear or
290
- * swap it could point a card's history at any log it liked.
291
- */
292
- const describeRunSessionRefRefusal = refusal("change or clear the log a run names", "do it");
293
288
  /** The commands only a person may run, as typed after `kanbo` — the capabilities manifest reads this rather than naming them again. */
294
289
  const HUMAN_ONLY_ACTIONS = [
295
290
  "approve",
@@ -302,8 +297,7 @@ const HUMAN_ONLY_ACTIONS = [
302
297
  "columns move",
303
298
  "columns remove",
304
299
  "columns template",
305
- "columns add-standard",
306
- "run clear-session"
300
+ "columns add-standard"
307
301
  ];
308
302
  const HUMAN_ONLY_MESSAGES = {
309
303
  "approve": describeApprovalRefusal,
@@ -316,9 +310,7 @@ const HUMAN_ONLY_MESSAGES = {
316
310
  "columns move": describeColumnChangesRefusal,
317
311
  "columns remove": describeColumnChangesRefusal,
318
312
  "columns template": describeColumnChangesRefusal,
319
- "columns add-standard": describeColumnChangesRefusal,
320
- "run clear-session": describeRunSessionRefRefusal,
321
- "run attach-session --replace": describeRunSessionRefRefusal
313
+ "columns add-standard": describeColumnChangesRefusal
322
314
  };
323
315
  /**
324
316
  * The actor every board write from this tool is filed under: `KANBO_ACTOR_ID`,
@@ -337,7 +329,7 @@ function createCliActor() {
337
329
  * is none.
338
330
  *
339
331
  * Approving, returning, closing a sprint, setting a column's entry rules,
340
- * changing the board's columns and taking back the log a run names are the
332
+ * changing the board's columns are the
341
333
  * things only a person may do, and a shell
342
334
  * that says it belongs to an agent is not one. `KANBO_ACTOR_KIND=person` overrides
343
335
  * the marks — for a person's own terminal that an agent tool marked — so this
@@ -5465,8 +5457,6 @@ function canonicalPath(path) {
5465
5457
  }
5466
5458
  //#endregion
5467
5459
  //#region src/cli/command.ts
5468
- /** The two modes a card can be worked in, as `issues.execution_mode` spells them. */
5469
- const EXECUTION_MODES = ["worktree", "main"];
5470
5460
  /** Add the options that name a board and shape the answer, wherever the command sits in the tree. */
5471
5461
  function withTargetOptions(command) {
5472
5462
  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)");
@@ -5490,11 +5480,28 @@ async function openBoardSession(options, mode, cwd = process.cwd()) {
5490
5480
  asAgent: options.asAgent,
5491
5481
  cwd
5492
5482
  });
5493
- const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
5483
+ const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd).catch((error) => {
5484
+ throw describeEnvironmentOnlyFailure(error, target, options, cwd);
5485
+ });
5494
5486
  const found = findBinding(cwd);
5495
5487
  if (found) rememberProjectQuietly(found.projectDir);
5496
5488
  return session;
5497
5489
  }
5490
+ /**
5491
+ * A folder with no board of its own that only `KANBO_DB_PATH` points at an
5492
+ * app's database: when that database is older than this kanbo, or knows no
5493
+ * project for the folder, the folder is told it has no board, not that "the
5494
+ * app's database" needs updating — the person never chose that database. A
5495
+ * flag or a binding that names the database keeps the app-centred sentence.
5496
+ */
5497
+ function describeEnvironmentOnlyFailure(error, target, options, cwd) {
5498
+ if (!(target.owner !== "kanbo" && !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(cwd) === null) || !(error instanceof CliError)) return error;
5499
+ let reason;
5500
+ 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";
5501
+ 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";
5502
+ else return error;
5503
+ 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.`);
5504
+ }
5498
5505
  async function openFileSession(target, options, mode, cwd) {
5499
5506
  const board = await require_migrate.openBoardDatabase(target.path);
5500
5507
  try {
@@ -5572,18 +5579,6 @@ async function runBoardCommand(options, mode, body) {
5572
5579
  await session.close();
5573
5580
  }
5574
5581
  }
5575
- /** Where a card's work happens. A caller naming anything else is refused, not corrected. */
5576
- function parseExecutionMode(value) {
5577
- const mode = EXECUTION_MODES.find((candidate) => candidate === value);
5578
- if (!mode) throw new CliError(1, `Unknown execution mode "${value}". Use ${EXECUTION_MODES.join(" or ")}.`);
5579
- return mode;
5580
- }
5581
- /** A count a caller typed, refused rather than quietly rounded when it is not one. */
5582
- function parseCount(value) {
5583
- const count = Number(value);
5584
- if (!Number.isInteger(count) || count <= 0) throw new CliError(1, `Expected a positive whole number, got "${value}".`);
5585
- return count;
5586
- }
5587
5582
  /**
5588
5583
  * A date as the command line takes it: `YYYY-MM-DD`, read as a UTC day, or a
5589
5584
  * unix-seconds number taken as it is. `end` turns a day into its last second,
@@ -5614,7 +5609,7 @@ async function requireCard(session, reference) {
5614
5609
  if (byId) return byId;
5615
5610
  const number = readCardNumber(session, trimmed);
5616
5611
  const byNumber = number === null ? null : await session.store.issues.findByNumber(session.workspace.id, number);
5617
- if (!byNumber) throw new CliError(2, `No card "${reference}" on this board.\n Next: kanbo card list`);
5612
+ if (!byNumber) throw new CliError(2, `No card "${reference}" on this board.\n Next: kanbo board`);
5618
5613
  return byNumber;
5619
5614
  }
5620
5615
  /**
@@ -5742,7 +5737,7 @@ function isWide(codePoint) {
5742
5737
  }
5743
5738
  //#endregion
5744
5739
  //#region src/cli/view.ts
5745
- /** Every field `kanbo card list|get --json` may ask for, in listing order. */
5740
+ /** Every field `--json` may ask of a card, in listing order. */
5746
5741
  const CARD_VIEW_FIELDS = [
5747
5742
  "id",
5748
5743
  "number",
@@ -5762,16 +5757,6 @@ const CARD_VIEW_FIELDS = [
5762
5757
  "returned",
5763
5758
  "lastComment"
5764
5759
  ];
5765
- /** Every field `kanbo card get --json` may ask for: a card's, and the parts `--include` reads with it. */
5766
- const CARD_DETAIL_VIEW_FIELDS = [
5767
- ...CARD_VIEW_FIELDS,
5768
- "subCards",
5769
- "comments",
5770
- "commentCount",
5771
- "runs",
5772
- "history",
5773
- "pullRequests"
5774
- ];
5775
5760
  /** Every field `kanbo sprint list --json` may ask for, in listing order. */
5776
5761
  const SPRINT_VIEW_FIELDS = [
5777
5762
  "id",
@@ -5783,22 +5768,6 @@ const SPRINT_VIEW_FIELDS = [
5783
5768
  "openCards",
5784
5769
  "doneCards"
5785
5770
  ];
5786
- /** Every field `kanbo run start|finish|attach-session --json` may ask for, in listing order. */
5787
- const RUN_VIEW_FIELDS = [
5788
- "id",
5789
- "issueId",
5790
- "agentName",
5791
- "state",
5792
- "executionMode",
5793
- "branch",
5794
- "worktreePath",
5795
- "startedAt",
5796
- "endedAt",
5797
- "attempt",
5798
- "chatSessionId",
5799
- "externalSessionRef",
5800
- "launchedByKind"
5801
- ];
5802
5771
  function projectCard(card, columns, runs) {
5803
5772
  const view = toIssueView(card);
5804
5773
  const column = columns.find((candidate) => candidate.id === card.statusId) ?? null;
@@ -5820,14 +5789,6 @@ function projectCard(card, columns, runs) {
5820
5789
  updatedAt: view.updatedAt
5821
5790
  };
5822
5791
  }
5823
- /** List cards with what `readCardAttention` says beside them — `returned` and `lastComment` only where there is something. */
5824
- function withCardAttention(cards, attention) {
5825
- return cards.map((card, index) => ({
5826
- ...card,
5827
- ...attention[index]?.returned ? { returned: true } : {},
5828
- ...attention[index]?.lastComment ? { lastComment: attention[index].lastComment } : {}
5829
- }));
5830
- }
5831
5792
  /** The run going on right now, trimmed to what a card line needs — not the agent id, the branch or the session behind it. */
5832
5793
  function projectCardActiveRun(run) {
5833
5794
  return run === null ? null : {
@@ -5864,42 +5825,6 @@ function projectSprint(sprint) {
5864
5825
  doneCards: sprint.cards.done
5865
5826
  };
5866
5827
  }
5867
- function projectRun(run) {
5868
- return {
5869
- id: run.id,
5870
- issueId: run.issueId,
5871
- agentName: run.agentName,
5872
- state: run.state,
5873
- executionMode: run.executionMode,
5874
- branch: run.branch,
5875
- worktreePath: run.worktreePath,
5876
- startedAt: run.startedAt,
5877
- endedAt: run.endedAt,
5878
- attempt: run.attempt,
5879
- chatSessionId: run.chatSessionId,
5880
- externalSessionRef: run.externalSessionRef,
5881
- launchedByKind: run.launchedByKind
5882
- };
5883
- }
5884
- /** One card per line: the key, the column it is in, and what it is called (`cardDisplayTitle`). */
5885
- function describeCards(cards) {
5886
- if (cards.length === 0) return "No cards";
5887
- const keyWidth = Math.max(...cards.map((card) => card.id.length));
5888
- const indent = " ".repeat(keyWidth + 2);
5889
- const column = (card) => sanitizeTerminalText(card.column ?? "—");
5890
- const columnWidth = Math.max(...cards.map((card) => displayWidth(column(card))));
5891
- return cards.map((card) => {
5892
- return [`${card.id.padEnd(keyWidth)} ${padToWidth(column(card), columnWidth)} ${sanitizeTerminalText(cardDisplayTitle(card))}`, ...[
5893
- card.waitingFor === "human" ? `waiting for a person${card.statusLine ? ` — ${sanitizeTerminalText(card.statusLine)}` : ""}` : null,
5894
- card.returned && !card.lastComment ? "returned" : null,
5895
- card.lastComment ? `${card.returned ? "returned — " : "last comment, "}${describeCommentAuthor(card.lastComment.author)}: ${sanitizeTerminalText(card.lastComment.text)}` : null
5896
- ].filter((text) => text !== null).map((text) => `${indent}${text}`)].join("\n");
5897
- }).join("\n");
5898
- }
5899
- /** Who wrote a comment, as a list line says it. */
5900
- function describeCommentAuthor(author) {
5901
- return author === "user" ? "person" : author;
5902
- }
5903
5828
  /** The whole card, for a person who asked about exactly one. */
5904
5829
  function describeCard(card) {
5905
5830
  const lines = [`${card.id} ${sanitizeTerminalText(card.column ?? "—")} ${card.executionMode}`, sanitizeTerminalText(cardDisplayTitle(card))];
@@ -6106,858 +6031,131 @@ function colorsWanted() {
6106
6031
  return process.stdout.isTTY === true && process.env.TERM !== "dumb";
6107
6032
  }
6108
6033
  //#endregion
6109
- //#region src/capabilities/markdown.ts
6034
+ //#region src/cli/column-changes.ts
6110
6035
  /**
6111
- * The same manifest `buildCapabilitiesManifest` returns, as Markdown a person
6112
- * can read and an agent can paste into its own `CLAUDE.md` or `AGENTS.md`.
6036
+ * What `kanbo columns add|rename|move|remove|template` do to a board, once
6037
+ * the command or the `kanbo columns` menu knows what the person asked for.
6038
+ * Both run these, so a change made from the menu reads exactly like the
6039
+ * command it names.
6113
6040
  *
6114
- * Nothing here decides what the board can do — it only lays the manifest's own
6115
- * fields out under headings. `kanbo capabilities` prints this by default, and
6116
- * `kanbo mcp` serves it as `kanbo://capabilities.md` beside the JSON.
6041
+ * A refusal of the board's is said as a sentence with the command to type
6042
+ * instead, rather than as its code: these are a person's commands.
6117
6043
  */
6044
+ const COLUMN_CATEGORIES = [
6045
+ "backlog",
6046
+ "unstarted",
6047
+ "started",
6048
+ "completed",
6049
+ "canceled"
6050
+ ];
6051
+ /** The whole board's columns after a change, and the sentence that says what changed. */
6052
+ async function changed(session, message, extra = {}) {
6053
+ const board = await session.ops.listColumns(session.workspace.id);
6054
+ const views = board.map((column) => projectColumn(column, board));
6055
+ return {
6056
+ value: {
6057
+ ...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, typeof value === "number" || Array.isArray(value) ? value : projectColumn(value, board)])),
6058
+ columns: views
6059
+ },
6060
+ text: `${message}\n\n${describeColumns(views)}`
6061
+ };
6062
+ }
6118
6063
  /**
6119
- * A table cell, safe from a pipe inside the text breaking the row it sits in —
6120
- * the same escape the package README's own tables use for a command that
6121
- * spells `\|` between two states.
6064
+ * A new column, placed in the same write that creates it. A ready-made name
6065
+ * (`QA`, `Blocked`) brings its own line and category; any other name is work
6066
+ * in progress. With no place given it goes before the board's first completed
6067
+ * column (Done), or Canceled, so work still to do stays on the left.
6122
6068
  */
6123
- function cell(text) {
6124
- return text.replace(/\|/g, "\\|").replace(/\n/g, " ").trim();
6125
- }
6126
- function renderStorages(manifest) {
6127
- return [
6128
- "## Storages",
6129
- "",
6130
- "| Kind | Label | Description | Schema epoch |",
6131
- "| --- | --- | --- | --- |",
6132
- ...manifest.storages.map((storage) => `| \`${storage.kind}\` | ${cell(storage.label)} | ${cell(storage.description)} | ${storage.schemaEpoch} |`)
6133
- ];
6134
- }
6135
- function renderTool(tool) {
6136
- return `| \`${tool.name}\` | \`${cell(tool.cli.kanbo)}\` | ${cell(tool.description)} |`;
6137
- }
6138
- function renderTools(manifest) {
6139
- return [
6140
- "## Tools",
6141
- "",
6142
- "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.",
6143
- "",
6144
- "| Tool | `kanbo` | Does |",
6145
- "| --- | --- | --- |",
6146
- ...manifest.tools.map(renderTool)
6147
- ];
6069
+ async function addColumn(session, request, actor) {
6070
+ const workspaceId = session.workspace.id;
6071
+ const name = request.name.trim();
6072
+ if (!normalizeStatusName(name)) throw new CliError(1, "Give the column a name.");
6073
+ if (request.after !== void 0 && request.before !== void 0) throw new CliError(1, "Pass --after or --before, not both.");
6074
+ const existing = await session.ops.findColumn(workspaceId, name);
6075
+ if (existing) throw new CliError(1, `This board already has a column called ${existing.name} (slug ${normalizeStatusName(existing.name)}).`);
6076
+ const anchorName = request.after ?? request.before;
6077
+ const anchor = anchorName === void 0 ? null : await requireNamedColumn(session, anchorName);
6078
+ const spec = findCatalogueColumn(name) ?? ownColumn(name, null);
6079
+ const created = await session.ops.addColumn(workspaceId, {
6080
+ name,
6081
+ description: request.description?.trim() || spec.description,
6082
+ color: spec.color,
6083
+ category: request.category ?? spec.category,
6084
+ position: anchor ? request.after !== void 0 ? { after: anchor.id } : { before: anchor.id } : void 0
6085
+ }, actor);
6086
+ return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: created });
6148
6087
  }
6149
- function renderCommand(command) {
6150
- const options = command.options.map((option) => `\`${option.flags}\`${option.mandatory ? " (required)" : ""}`).join(", ");
6151
- const humanOnly = command.humanOnly ? " **Person only.**" : "";
6152
- return `| \`kanbo ${command.path}\` | ${cell(command.description)}${humanOnly} | ${options ? cell(options) : "—"} |`;
6088
+ /** Said after a rename: agents' instructions name no column but To Do, and `kanbo_prime` gives them the rest. */
6089
+ const RENAME_NOTE = "Agents read column names from kanbo_prime; rename is picked up there.";
6090
+ async function renameColumn(session, column, newName, actor) {
6091
+ const before = await requireNamedColumn(session, column);
6092
+ const renamed = await session.ops.renameColumn(session.workspace.id, before.id, newName, actor);
6093
+ 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 });
6153
6094
  }
6154
- function renderCommands(manifest) {
6155
- return [
6156
- "## Commands",
6157
- "",
6158
- "| Command | Does | Options |",
6159
- "| --- | --- | --- |",
6160
- ...manifest.commands.map(renderCommand)
6161
- ];
6095
+ /** `--first`, `--last`, `--before <column>` or `--after <column>` — exactly one. */
6096
+ function readColumnPosition(options) {
6097
+ const given = [
6098
+ options.first ? "first" : null,
6099
+ options.last ? "last" : null,
6100
+ options.before !== void 0 ? { before: options.before } : null,
6101
+ options.after !== void 0 ? { after: options.after } : null
6102
+ ].filter((position) => position !== null);
6103
+ if (given.length !== 1) throw new CliError(1, "Say where the column goes: --first, --last, --before <column> or --after <column> (one of them).");
6104
+ return given[0];
6162
6105
  }
6163
- function renderRules(manifest) {
6164
- return [
6165
- "## Rules",
6166
- "",
6167
- ...manifest.rules.map((rule) => `- ${rule}`)
6168
- ];
6106
+ async function moveColumn(session, column, position, actor) {
6107
+ const moving = await requireNamedColumn(session, column);
6108
+ const anchorName = typeof position === "string" ? null : "before" in position ? position.before : position.after;
6109
+ const anchor = anchorName === null ? null : await requireNamedColumn(session, anchorName);
6110
+ const before = await session.ops.listColumns(session.workspace.id);
6111
+ const after = await session.ops.moveColumn(session.workspace.id, moving.id, position, actor);
6112
+ const where = position === "first" ? "to the start" : position === "last" ? "to the end" : `${"before" in position ? "before" : "after"} ${anchor.name}`;
6113
+ 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}.`);
6169
6114
  }
6170
- function renderEntryRules(manifest) {
6171
- return [
6172
- "## Column entry rules",
6173
- "",
6174
- "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`.",
6175
- "",
6176
- ...manifest.entryRules.map((entry) => `- \`${entry.rule}\` — ${entry.description}`)
6177
- ];
6115
+ /** How many cards sit in the column right now. */
6116
+ async function countCardsIn(session, column) {
6117
+ return (await session.store.issues.listInBoardOrder(session.workspace.id)).filter((card) => card.statusId === column.id).length;
6178
6118
  }
6179
- function renderCanonicalStrings(manifest) {
6180
- return [
6181
- "## Canonical strings",
6182
- "",
6183
- "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.",
6184
- "",
6185
- "| Name | Value |",
6186
- "| --- | --- |",
6187
- ...Object.entries(manifest.canonicalStrings).map(([name, value]) => `| ${cell(name)} | \`${cell(value)}\` |`)
6188
- ];
6119
+ async function removeColumn(session, column, moveCardsTo, actor) {
6120
+ const removing = await requireNamedColumn(session, column);
6121
+ if (moveCardsTo !== null) await requireNamedColumn(session, moveCardsTo);
6122
+ const result = await session.ops.removeColumn(session.workspace.id, removing.id, { moveCardsTo }, actor);
6123
+ 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}`);
6124
+ return await changed(session, result.movedTo ? `Removed ${result.removed.name} and moved ${describeCount$1(result.movedCards)} to ${result.movedTo.name}.` : `Removed ${result.removed.name}.`, {
6125
+ removed: result.removed,
6126
+ movedCards: result.movedCards
6127
+ });
6189
6128
  }
6190
- function renderRoles(manifest) {
6191
- return [
6192
- "## Roles",
6193
- "",
6194
- `- Person: \`${manifest.roles.person}\``,
6195
- `- Agent: \`${manifest.roles.agent}\``,
6196
- "",
6197
- "What the database itself refuses the agent role, on an external board, whichever client sends the write:",
6198
- "",
6199
- ...manifest.roles.refusals.map((refusal) => `- ${refusal}`)
6200
- ];
6129
+ /** The template a person named, or the error that lists the ones there are. */
6130
+ function readTemplateId(value) {
6131
+ const id = COLUMN_TEMPLATE_IDS.find((candidate) => candidate === value.trim().toLowerCase());
6132
+ if (!id) throw new CliError(1, `Unknown template "${value}". Use ${COLUMN_TEMPLATE_IDS.join(", ")}.`);
6133
+ return id;
6201
6134
  }
6202
- function renderLimits(manifest) {
6203
- return [
6204
- "## Limits",
6205
- "",
6206
- ...manifest.limits.map((limit) => `- ${limit}`)
6207
- ];
6135
+ /**
6136
+ * A template's columns on this board. An empty board gets the template as it
6137
+ * is; a board with columns only gets the ones it lacks, and only when asked
6138
+ * with `--add-missing` — a template never takes a column away.
6139
+ */
6140
+ async function applyTemplate(session, id, addMissing, actor) {
6141
+ const workspaceId = session.workspace.id;
6142
+ const label = COLUMN_TEMPLATE_LABELS[id];
6143
+ const present = await session.ops.listColumns(workspaceId);
6144
+ 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`);
6145
+ const { added } = present.length === 0 ? await session.ops.applyColumnTemplate(workspaceId, COLUMN_TEMPLATES[id], { mode: "seed" }) : await session.ops.applyColumnTemplate(workspaceId, COLUMN_TEMPLATES[id], {
6146
+ mode: "add-missing",
6147
+ actor
6148
+ });
6149
+ return await changed(session, added.length === 0 ? `This board already has every ${label} column.` : `Added ${added.join(", ")}.`, { added });
6208
6150
  }
6209
- /** The manifest, laid out under one heading per field — stable and diffable, never a snapshot to update by hand. */
6210
- function renderCapabilitiesMarkdown(manifest) {
6211
- return [
6212
- "# Kanbo capabilities",
6213
- "",
6214
- `\`${manifest.package.name}\` ${manifest.package.version} — capabilities manifest version ${manifest.version}.`,
6215
- "",
6216
- ...renderStorages(manifest),
6217
- "",
6218
- ...renderTools(manifest),
6219
- "",
6220
- ...renderCommands(manifest),
6221
- "",
6222
- ...renderRules(manifest),
6223
- "",
6224
- ...renderEntryRules(manifest),
6225
- "",
6226
- ...renderCanonicalStrings(manifest),
6227
- "",
6228
- ...renderRoles(manifest),
6229
- "",
6230
- ...renderLimits(manifest),
6231
- ""
6232
- ].join("\n");
6151
+ /** The column a person named, or the error that says the board has none by that name. */
6152
+ async function requireNamedColumn(session, nameOrId) {
6153
+ const column = normalizeStatusName(nameOrId) ? await session.ops.findColumn(session.workspace.id, nameOrId) : null;
6154
+ if (!column) throw new CliError(1, `This board has no column "${nameOrId}". See kanbo columns list.`);
6155
+ return column;
6233
6156
  }
6234
- //#endregion
6235
- //#region src/cli/commands/capabilities.ts
6236
- function registerCapabilitiesCommand(program) {
6237
- 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) => {
6238
- if (options.json !== void 0 && options.markdown) throw new CliError(1, "Pass --json or --markdown, not both.");
6239
- const manifest = buildCapabilitiesManifest();
6240
- if (options.json !== void 0) {
6241
- printResult({ value: manifest }, { json: options.json });
6242
- return;
6243
- }
6244
- console.log(renderCapabilitiesMarkdown(manifest));
6245
- });
6246
- }
6247
- //#endregion
6248
- //#region src/cli/commands/claim.ts
6249
- /**
6250
- * `kanbo claim [card]` — take a card to work on, in one step.
6251
- *
6252
- * The run starts and the card moves out of To Do into the board's first
6253
- * work-in-progress column in one write, so two agents in two terminals never
6254
- * take the same card. Filed as a placement: in a person's own terminal the
6255
- * column's rules warn, in an agent's shell they refuse.
6256
- */
6257
- function registerClaimCommand(program) {
6258
- 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) => {
6259
- await runBoardCommand(options, "write", async (session) => {
6260
- const actor = createPlacementActor();
6261
- const named = reference === void 0 ? void 0 : (await requireCard(session, reference)).id;
6262
- const claimed = await session.ops.claimCard({
6263
- workspaceId: session.workspace.id,
6264
- issueId: named,
6265
- run: {
6266
- agentName: options.agent ?? actor.id ?? "agent",
6267
- externalSessionRef: options.session ?? sessionRefFromEnvironment() ?? void 0,
6268
- launchedByKind: "external"
6269
- }
6270
- }, actor);
6271
- const [columns, projection, runs] = await Promise.all([
6272
- session.ops.listColumns(session.workspace.id),
6273
- session.ops.readBoardProjectionForIssue(claimed.card.id),
6274
- session.ops.listRuns(claimed.card.id)
6275
- ]);
6276
- const card = projectCard(claimed.card, columns, projection);
6277
- for (const item of claimed.card.unmetRules ?? []) console.error(`kanbo: warning: ${card.id} entered "${claimed.column.name}" without ${item.rule}: ${item.detail}`);
6278
- const run = projectRun({
6279
- ...claimed.run,
6280
- attempt: runs.find((each) => each.id === claimed.run.id)?.attempt ?? runs.length
6281
- });
6282
- return {
6283
- value: {
6284
- card,
6285
- run,
6286
- column: projectColumn(claimed.column, columns)
6287
- },
6288
- 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`
6289
- };
6290
- });
6291
- });
6292
- }
6293
- //#endregion
6294
- //#region src/mcp/transport.ts
6295
- /** What `kanbo_card_get` may bring along with the card. */
6296
- const KANBO_CARD_INCLUDES = [
6297
- "comments",
6298
- "subCards",
6299
- "runs",
6300
- "history",
6301
- "prs"
6302
- ];
6303
- /** What a card read brings along when the caller does not say: its latest comments and its sub-cards. */
6304
- const DEFAULT_KANBO_CARD_INCLUDES = ["comments", "subCards"];
6305
- /** How many of its latest comments a card read brings along when the caller does not say. */
6306
- const DEFAULT_KANBO_COMMENT_LIMIT = 10;
6307
- /**
6308
- * The card a tool prints, from the card a transport holds.
6309
- *
6310
- * Both transports project here so the JSON an agent reads is the same whether
6311
- * it came from the server or from the board file — including the column, which
6312
- * neither of them carries on the card itself: the row keeps a foreign key, and
6313
- * an agent moving a card needs the slug.
6314
- *
6315
- * Every field is named rather than spread. A card the board file answers with
6316
- * carries more than this — the agent and session ids behind a run among them —
6317
- * and passing an object through by reference would print whatever it happened
6318
- * to hold, which is both a different shape from the server's answer and host
6319
- * facts the agent was never meant to see.
6320
- */
6321
- function projectKanboCard(card, columns) {
6322
- const column = columns.find((candidate) => candidate.id === card.statusId) ?? null;
6323
- return {
6324
- id: card.id,
6325
- number: card.number,
6326
- title: card.title,
6327
- description: card.description,
6328
- column: column?.name ?? null,
6329
- columnSlug: column?.slug ?? null,
6330
- statusLine: card.statusLine,
6331
- waitingFor: card.waitingFor,
6332
- priority: card.priority,
6333
- labels: card.labels,
6334
- executionMode: card.executionMode,
6335
- parentIssueId: card.parentIssueId,
6336
- attemptCount: card.attemptCount,
6337
- activeRun: projectKanboActiveRun(card.activeRun),
6338
- updatedAt: card.updatedAt,
6339
- ...card.attention?.returned ? { returned: true } : {},
6340
- ...card.attention?.lastComment ? { lastComment: card.attention.lastComment } : {}
6341
- };
6342
- }
6343
- /** A sub-card, as the card above it lists it: enough to name it and read it next. */
6344
- function projectKanboSubCard(card, columns) {
6345
- return {
6346
- id: card.id,
6347
- title: card.title,
6348
- columnSlug: columns.find((candidate) => candidate.id === card.statusId)?.slug ?? null
6349
- };
6350
- }
6351
- /** The run going on right now, as much of it as the agent working the card needs. */
6352
- function projectKanboActiveRun(run) {
6353
- return run === null ? null : {
6354
- id: run.id,
6355
- agentName: run.agentName,
6356
- state: run.state,
6357
- executionMode: run.executionMode,
6358
- branch: run.branch,
6359
- startedAt: run.startedAt
6360
- };
6361
- }
6362
- /**
6363
- * One launch of a card, from the run row either transport holds.
6364
- *
6365
- * What the row carries beyond this — the worktree path, the chat session
6366
- * driving it, who launched it — belongs to the host that launched the run, not
6367
- * to the agent that asked for one.
6368
- */
6369
- function projectKanboRun(run) {
6370
- return {
6371
- id: run.id,
6372
- issueId: run.issueId,
6373
- agentName: run.agentName,
6374
- state: run.state,
6375
- executionMode: run.executionMode,
6376
- branch: run.branch,
6377
- startedAt: run.startedAt,
6378
- endedAt: run.endedAt,
6379
- externalSessionRef: run.externalSessionRef ?? null
6380
- };
6381
- }
6382
- /**
6383
- * A pull-request link, from the row either transport holds — without who made
6384
- * it, which is the board's record rather than anything the agent acts on.
6385
- */
6386
- function projectKanboPullRequest(link) {
6387
- return {
6388
- id: link.id,
6389
- issueId: link.issueId,
6390
- owner: link.owner,
6391
- repo: link.repo,
6392
- number: link.number,
6393
- url: link.url,
6394
- createdAt: link.createdAt
6395
- };
6396
- }
6397
- /** A sprint, from the one `listSprints` answers with on either end — the description and timestamps left behind. */
6398
- function projectKanboSprint(sprint) {
6399
- return {
6400
- id: sprint.id,
6401
- title: sprint.title,
6402
- status: sprint.status,
6403
- startDate: sprint.startDate,
6404
- dueDate: sprint.dueDate,
6405
- current: sprint.current,
6406
- openCards: sprint.cards.open,
6407
- doneCards: sprint.cards.done
6408
- };
6409
- }
6410
- /** A column of the board, from the row either transport holds; `board` is every column, to name the ones it accepts cards from. */
6411
- function projectKanboColumn(column, board) {
6412
- const stored = column.entryRules;
6413
- const allowedIds = typeof column.allowedFrom === "string" ? readFlowRules({
6414
- allowedFrom: column.allowedFrom,
6415
- wipLimit: null
6416
- }).allowedFrom : column.allowedFrom ?? null;
6417
- return {
6418
- id: column.id,
6419
- name: column.name,
6420
- slug: normalizeStatusName(column.name),
6421
- description: column.description,
6422
- order: column.order,
6423
- entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null }),
6424
- allowedFrom: allowedIds === null ? null : board.filter((other) => allowedIds.includes(other.id)).map((other) => normalizeStatusName(other.name)),
6425
- wipLimit: column.wipLimit ?? null
6426
- };
6427
- }
6428
- /**
6429
- * Does the card answer the query? The filters of `KanboCardQuery`, applied to
6430
- * a card already read — for a transport whose server filters on less than the
6431
- * query names. `statusIds` are the query's columns, already resolved.
6432
- */
6433
- function matchesKanboCardQuery(card, query, statusIds) {
6434
- const text = query.text === void 0 ? void 0 : require_migrate.foldCase(query.text);
6435
- 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 || [
6436
- card.id,
6437
- card.title,
6438
- card.description ?? ""
6439
- ].some((value) => require_migrate.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));
6440
- }
6441
- /** A page cut out of cards already picked, in the order they were picked. */
6442
- function pageOf(cards, input) {
6443
- const offset = input.offset ?? 0;
6444
- return {
6445
- cards: cards.slice(offset, input.limit === void 0 ? void 0 : offset + input.limit),
6446
- total: cards.length,
6447
- offset
6448
- };
6449
- }
6450
- /** A comment as a card read lists it, from the row either transport holds. */
6451
- function projectKanboCardComment(comment) {
6452
- const decision = comment.authorKind === "system.approved" || comment.authorKind === "system.returned";
6453
- const author = decision ? "user" : normalizeCommentAuthorKind(comment.authorKind);
6454
- const named = author !== "system" || decision;
6455
- const outside = author === "system" && isOutsideWriter(comment) && comment.authorKind === "system";
6456
- return {
6457
- id: comment.id,
6458
- author,
6459
- by: named || outside ? comment.authorId ?? author : "kanbo",
6460
- content: comment.content,
6461
- createdAt: comment.createdAt
6462
- };
6463
- }
6464
- /** A field change as a card read lists it — who by kind only: the id behind it is the host's. */
6465
- function projectKanboFieldChange(change) {
6466
- return {
6467
- field: change.field,
6468
- from: change.fromValue,
6469
- to: change.toValue,
6470
- actor: change.actorKind,
6471
- createdAt: change.createdAt
6472
- };
6473
- }
6474
- //#endregion
6475
- //#region src/cli/card-extras.ts
6476
- async function readCardExtras(session, card, columns, include, commentLimit) {
6477
- const wants = new Set(include);
6478
- const [subCards, comments, runs, history, pullRequests] = await Promise.all([
6479
- wants.has("subCards") ? session.store.issues.listPage({
6480
- workspaceId: card.workspaceId,
6481
- parentIssueId: card.id
6482
- }) : null,
6483
- wants.has("comments") ? session.store.comments.listByIssue(card.id) : null,
6484
- wants.has("runs") ? session.ops.listRuns(card.id) : null,
6485
- wants.has("history") ? session.store.fieldChanges.listByIssue(card.id) : null,
6486
- wants.has("prs") ? session.store.pullRequests.listByIssue(card.id) : null
6487
- ]);
6488
- return {
6489
- ...subCards ? { subCards: subCards.cards.map((row) => {
6490
- const view = projectCard(row, columns, {
6491
- issueId: row.id,
6492
- attemptCount: 0,
6493
- activeRun: null
6494
- });
6495
- return {
6496
- id: view.id,
6497
- title: view.title,
6498
- columnSlug: view.columnSlug
6499
- };
6500
- }) } : {},
6501
- ...comments ? {
6502
- comments: comments.slice(-commentLimit).map(projectKanboCardComment),
6503
- commentCount: comments.length
6504
- } : {},
6505
- ...runs ? { runs: runs.map(projectRun) } : {},
6506
- ...history ? { history: history.map(projectKanboFieldChange) } : {},
6507
- ...pullRequests ? { pullRequests: pullRequests.map((link) => ({
6508
- id: link.id,
6509
- owner: link.owner,
6510
- repo: link.repo,
6511
- number: link.number,
6512
- url: link.url
6513
- })) } : {}
6514
- };
6515
- }
6516
- /** A unix-seconds moment as `YYYY-MM-DD HH:MM` UTC. */
6517
- function formatMoment(seconds) {
6518
- return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 16).replace("T", " ");
6519
- }
6520
- /** The parts under the card, each under a heading; nothing for a part that was not asked for. */
6521
- function describeCardExtras(extras) {
6522
- const sections = [];
6523
- if (extras.subCards && extras.subCards.length > 0) sections.push(["Sub-cards:", describeCards(extras.subCards.map((sub) => ({
6524
- ...sub,
6525
- description: null,
6526
- column: sub.columnSlug
6527
- })))].join("\n"));
6528
- if (extras.comments && extras.comments.length > 0) {
6529
- const heading = extras.commentCount === extras.comments.length ? "Comments:" : `Comments (last ${extras.comments.length} of ${extras.commentCount}):`;
6530
- sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${sanitizeTerminalText(comment.by)}${comment.author === "user" ? " (person)" : ""}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
6531
- }
6532
- 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"));
6533
- 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"));
6534
- 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"));
6535
- return sections.join("\n\n");
6536
- }
6537
- //#endregion
6538
- //#region src/cli/commands/card.ts
6539
- /**
6540
- * `kanbo card …` — the cards themselves.
6541
- *
6542
- * Every write here is filed under the shell that made it and goes through the
6543
- * board's own operations, so a card an agent moved from a terminal reads on the
6544
- * board exactly like one moved in the app: the same field history, the same
6545
- * status line, the same board version bump for whoever is watching.
6546
- */
6547
- const PRIORITIES$1 = [
6548
- "none",
6549
- "low",
6550
- "medium",
6551
- "high",
6552
- "urgent"
6553
- ];
6554
- /**
6555
- * Where `card create` puts a card no `--column` placed. A person's card goes
6556
- * to To Do, as the home screen and the wizard put it: they wrote it down to
6557
- * have it done. An agent's goes where the board's operations put any card with
6558
- * no column — the first one (Backlog on the standard board) — as it did in
6559
- * 0.2: an agent writing down follow-up work should not hand it straight to the
6560
- * next agent that runs `kanbo ready`. A board with no To Do gets the default
6561
- * for a person too.
6562
- */
6563
- async function readDefaultColumn(session) {
6564
- if (isAgentShell()) return;
6565
- return (await session.ops.findColumn(session.workspace.id, READY_COLUMN_SLUG$1))?.name;
6566
- }
6567
- /** How many cards `kanbo card list` prints when the caller names no limit. */
6568
- const DEFAULT_LIST_LIMIT = 50;
6569
- function registerCardCommands(program) {
6570
- const card = program.command("card").description("Add, change, move and read cards");
6571
- 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) => {
6572
- await runBoardCommand(options, "read", async (session) => {
6573
- const parent = options.parent ? await requireCard(session, options.parent) : null;
6574
- const page = await session.ops.queryCards({
6575
- workspaceId: session.workspace.id,
6576
- columns: options.column,
6577
- waitingForPerson: options.waiting ? true : void 0,
6578
- parentIssueId: parent?.id,
6579
- hasActiveRun: options.active ? true : void 0,
6580
- returned: options.returned ? true : void 0,
6581
- text: options.text,
6582
- updatedSince: options.updatedSince,
6583
- labels: options.label,
6584
- priorities: options.priority,
6585
- offset: options.offset,
6586
- limit: options.all ? void 0 : options.limit ?? DEFAULT_LIST_LIMIT
6587
- }).catch(rethrowUnknownColumn(options.column));
6588
- const runs = await session.ops.readBoardProjectionForIssues(page.cards.map((row) => row.id));
6589
- const attention = await session.ops.readCardAttention(page.cards, runs.map((run) => run.activeRun !== null));
6590
- const cards = withCardAttention(page.cards.map((row, index) => projectCard(row, page.columns, runs[index])), attention);
6591
- noteMoreCards(page.total, options.offset ?? 0, cards.length);
6592
- return {
6593
- value: cards,
6594
- text: describeCards(cards),
6595
- fields: CARD_VIEW_FIELDS
6596
- };
6597
- });
6598
- });
6599
- 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) => {
6600
- await runBoardCommand(options, "read", async (session) => {
6601
- const found = await requireCard(session, reference);
6602
- const [columns, runs] = await Promise.all([session.ops.listColumns(session.workspace.id), session.ops.readBoardProjectionForIssue(found.id)]);
6603
- const view = projectCard(found, columns, runs);
6604
- const extras = await readCardExtras(session, found, columns, options.include ?? DEFAULT_KANBO_CARD_INCLUDES, options.comments ?? 10);
6605
- return {
6606
- value: {
6607
- ...view,
6608
- ...extras
6609
- },
6610
- text: [describeCard(view), describeCardExtras(extras)].filter(Boolean).join("\n\n"),
6611
- fields: CARD_DETAIL_VIEW_FIELDS
6612
- };
6613
- });
6614
- });
6615
- 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) => {
6616
- await runBoardCommand(options, "write", async (session) => {
6617
- const parent = options.parent ? await requireCard(session, options.parent) : null;
6618
- return await presentCard(session, await session.ops.createCard({
6619
- workspace: session.workspace,
6620
- title: options.title,
6621
- description: options.description,
6622
- statusName: options.column ?? await readDefaultColumn(session),
6623
- parentIssueId: parent?.id,
6624
- executionMode: options.executionMode
6625
- }, createPlacementActor()));
6626
- });
6627
- });
6628
- 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) => {
6629
- await runBoardCommand(options, "write", async (session) => {
6630
- const input = readUpdateInput(options);
6631
- const existing = await requireCard(session, reference);
6632
- if (options.parent !== void 0) input.parentIssueId = options.parent === "none" ? null : (await requireCard(session, options.parent)).id;
6633
- return await presentCard(session, await session.ops.updateCard(existing.id, input, createPlacementActor()));
6634
- });
6635
- });
6636
- 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) => {
6637
- await runBoardCommand(options, "write", async (session) => {
6638
- const existing = await requireCard(session, reference);
6639
- return await presentCard(session, await session.ops.moveCard(existing.id, column, createPlacementActor()));
6640
- });
6641
- });
6642
- 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 commander.Option("--content <text>", "the same as --text").hideHelp())).action(async (reference, options) => {
6643
- const text = readAliasedText("status-line", options, "text", "content");
6644
- 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\"");
6645
- await runBoardCommand(options, "write", async (session) => {
6646
- const existing = await requireCard(session, reference);
6647
- return await presentCard(session, await session.ops.setStatusLine(existing.id, text, createCliActor()));
6648
- });
6649
- });
6650
- 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 commander.Option("--text <text>", "the same as --content").hideHelp())).action(async (reference, options) => {
6651
- const content = readAliasedText("comment", options, "content", "text");
6652
- 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\"");
6653
- await runBoardCommand(options, "write", async (session) => {
6654
- const existing = await requireCard(session, reference);
6655
- const comment = await session.ops.addComment({
6656
- issueId: existing.id,
6657
- content
6658
- }, createCliActor());
6659
- return {
6660
- value: {
6661
- id: comment.id,
6662
- issueId: comment.issueId,
6663
- content: comment.content,
6664
- createdAt: comment.createdAt
6665
- },
6666
- text: `Commented on ${existing.id}`
6667
- };
6668
- });
6669
- });
6670
- 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 commander.Option("--content <text>", "the same as --text").hideHelp())).action(async (reference, options) => {
6671
- const text = readAliasedText("wait-approval", options, "text", "content");
6672
- await runBoardCommand(options, "write", async (session) => {
6673
- const existing = await requireCard(session, reference);
6674
- return await presentCard(session, await session.ops.waitApproval(existing.id, { statusLine: text }, createCliActor()));
6675
- });
6676
- });
6677
- registerPullRequestCommands(card);
6678
- }
6679
- /**
6680
- * The text a command was given under its own option or the other one agents
6681
- * reach for (`--text` for a comment, `--content` for a status line): the MCP
6682
- * tools take both names too. Both, saying different things, is refused.
6683
- */
6684
- function readAliasedText(command, options, canonical, alias) {
6685
- const own = options[canonical];
6686
- const other = options[alias];
6687
- 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}.`);
6688
- return own ?? other;
6689
- }
6690
- /** The fields a pull-request link prints, and the ones `--json` may name. */
6691
- const PULL_REQUEST_FIELDS = [
6692
- "id",
6693
- "issueId",
6694
- "owner",
6695
- "repo",
6696
- "number",
6697
- "url",
6698
- "createdAt"
6699
- ];
6700
- function projectPullRequest(link) {
6701
- return {
6702
- id: link.id,
6703
- issueId: link.issueId,
6704
- owner: link.owner,
6705
- repo: link.repo,
6706
- number: link.number,
6707
- url: link.url,
6708
- createdAt: link.createdAt
6709
- };
6710
- }
6711
- function describePullRequest(link) {
6712
- return `${link.id} ${link.owner}/${link.repo}#${link.number} ${link.url}`;
6713
- }
6714
- /**
6715
- * `kanbo card pr …` — the pull requests a card names (ruling 4-1).
6716
- *
6717
- * Linking is all an agent does: what the pull request is — open, merged, green
6718
- * — reaches the card as comments from whoever watches GitHub, so nothing here
6719
- * writes one.
6720
- */
6721
- function registerPullRequestCommands(card) {
6722
- const pr = card.command("pr").description("Link pull requests to a card");
6723
- 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) => {
6724
- await runBoardCommand(options, "write", async (session) => {
6725
- const existing = await requireCard(session, reference);
6726
- const link = projectPullRequest(await session.ops.linkPullRequest(existing.id, url, createCliActor()));
6727
- return {
6728
- value: link,
6729
- text: `Linked ${link.owner}/${link.repo}#${link.number} to ${existing.id} (${link.id})`,
6730
- fields: PULL_REQUEST_FIELDS
6731
- };
6732
- });
6733
- });
6734
- 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) => {
6735
- await runBoardCommand(options, "read", async (session) => {
6736
- const existing = await requireCard(session, reference);
6737
- const links = (await session.ops.listPullRequests(existing.id)).map(projectPullRequest);
6738
- return {
6739
- value: links,
6740
- text: links.length === 0 ? `${existing.id} names no pull requests.` : links.map(describePullRequest).join("\n"),
6741
- fields: PULL_REQUEST_FIELDS
6742
- };
6743
- });
6744
- });
6745
- 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) => {
6746
- await runBoardCommand(options, "write", async (session) => {
6747
- const existing = await requireCard(session, reference);
6748
- const link = projectPullRequest(await session.ops.unlinkPullRequest(existing.id, linkId, createCliActor()));
6749
- return {
6750
- value: link,
6751
- text: `Unlinked ${link.owner}/${link.repo}#${link.number} from ${existing.id}`,
6752
- fields: PULL_REQUEST_FIELDS
6753
- };
6754
- });
6755
- });
6756
- }
6757
- /**
6758
- * The card as both shapes the output contract offers. A card a person placed
6759
- * in a column whose entry rules it does not meet (ruling 6-5) also leaves one
6760
- * warning line per unmet rule on stderr, so the card prints as usual.
6761
- */
6762
- async function presentCard(session, card) {
6763
- const [columns, runs] = await Promise.all([session.ops.listColumns(session.workspace.id), session.ops.readBoardProjectionForIssue(card.id)]);
6764
- const view = projectCard(card, columns, runs);
6765
- for (const item of card.unmetRules ?? []) console.error(`kanbo: warning: ${view.id} entered "${view.column ?? "—"}" without ${item.rule}: ${item.detail}`);
6766
- return {
6767
- value: view,
6768
- text: describeCard(view),
6769
- fields: CARD_VIEW_FIELDS
6770
- };
6771
- }
6772
- /**
6773
- * The fields `card update` was asked to change — all but the parent, a card
6774
- * of the board, looked up there once the card itself is. A command that names
6775
- * none of them is refused rather than obeyed: an empty update still bumps the
6776
- * board version, and every reader would be told the board changed when it did not.
6777
- */
6778
- function readUpdateInput(options) {
6779
- const input = {};
6780
- if (options.title !== void 0) input.title = options.title;
6781
- if (options.description !== void 0) input.description = options.description;
6782
- if (options.priority !== void 0) input.priority = options.priority;
6783
- if (options.labels !== void 0) input.labels = options.labels;
6784
- if (options.executionMode !== void 0) input.executionMode = options.executionMode;
6785
- 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.");
6786
- return input;
6787
- }
6788
- function parsePriority(value) {
6789
- const priority = PRIORITIES$1.find((candidate) => candidate === value);
6790
- if (!priority) throw new CliError(1, `Unknown priority "${value}". Use one of ${PRIORITIES$1.join(", ")}.`);
6791
- return priority;
6792
- }
6793
- function parseLabels(value) {
6794
- return value.split(",").map((label) => label.trim()).filter((label) => label.length > 0);
6795
- }
6796
- /** A list option given comma-separated, repeated, or both. */
6797
- function collectList(value, previous) {
6798
- return [...previous ?? [], ...value.split(",").map((item) => item.trim()).filter((item) => item.length > 0)];
6799
- }
6800
- function parsePriorities(value) {
6801
- return collectList(value, void 0).map(parsePriority);
6802
- }
6803
- function parseOffset(value) {
6804
- const count = Number(value);
6805
- if (!Number.isInteger(count) || count < 0) throw new CliError(1, `Expected a whole number of cards to skip, got "${value}".`);
6806
- return count;
6807
- }
6808
- function parseMoment(value) {
6809
- try {
6810
- return readUnixSeconds(value);
6811
- } catch (error) {
6812
- throw new CliError(2, `--updated-since: ${error.message}`);
6813
- }
6814
- }
6815
- function parseIncludes(value) {
6816
- if (value.trim() === "none") return [];
6817
- return collectList(value, void 0).map((part) => {
6818
- const include = KANBO_CARD_INCLUDES.find((candidate) => candidate.toLowerCase() === part.toLowerCase());
6819
- if (!include) throw new CliError(1, `Unknown part "${part}". Use ${KANBO_CARD_INCLUDES.join(", ")} or none.`);
6820
- return include;
6821
- });
6822
- }
6823
- /** A column the board does not have, said the way the rest of `kanbo card` says it. */
6824
- function rethrowUnknownColumn(named) {
6825
- return (error) => {
6826
- if (error instanceof require_migrations_path.BoardError && error.code === "issue_status_not_found" && named) throw new CliError(1, `This board has no column "${String(error.details?.statusName)}".`);
6827
- throw error;
6828
- };
6829
- }
6830
- /** When the page is not every picked card, say so on stderr — where the next page starts, and how to get all of them. */
6831
- function noteMoreCards(total, offset, shown) {
6832
- const more = total - offset - shown;
6833
- if (more > 0) console.error(`kanbo: ${more} more of ${total}; next page: --offset ${offset + shown}, or --all for every card`);
6834
- }
6835
- //#endregion
6836
- //#region src/cli/column-changes.ts
6837
- /**
6838
- * What `kanbo columns add|rename|move|remove|template` do to a board, once
6839
- * the command or the `kanbo columns` menu knows what the person asked for.
6840
- * Both run these, so a change made from the menu reads exactly like the
6841
- * command it names.
6842
- *
6843
- * A refusal of the board's is said as a sentence with the command to type
6844
- * instead, rather than as its code: these are a person's commands.
6845
- */
6846
- const COLUMN_CATEGORIES = [
6847
- "backlog",
6848
- "unstarted",
6849
- "started",
6850
- "completed",
6851
- "canceled"
6852
- ];
6853
- /** The whole board's columns after a change, and the sentence that says what changed. */
6854
- async function changed(session, message, extra = {}) {
6855
- const board = await session.ops.listColumns(session.workspace.id);
6856
- const views = board.map((column) => projectColumn(column, board));
6857
- return {
6858
- value: {
6859
- ...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, typeof value === "number" || Array.isArray(value) ? value : projectColumn(value, board)])),
6860
- columns: views
6861
- },
6862
- text: `${message}\n\n${describeColumns(views)}`
6863
- };
6864
- }
6865
- /**
6866
- * A new column, placed in the same write that creates it. A ready-made name
6867
- * (`QA`, `Blocked`) brings its own line and category; any other name is work
6868
- * in progress. With no place given it goes before the board's first completed
6869
- * column (Done), or Canceled, so work still to do stays on the left.
6870
- */
6871
- async function addColumn(session, request, actor) {
6872
- const workspaceId = session.workspace.id;
6873
- const name = request.name.trim();
6874
- if (!normalizeStatusName(name)) throw new CliError(1, "Give the column a name.");
6875
- if (request.after !== void 0 && request.before !== void 0) throw new CliError(1, "Pass --after or --before, not both.");
6876
- const existing = await session.ops.findColumn(workspaceId, name);
6877
- if (existing) throw new CliError(1, `This board already has a column called ${existing.name} (slug ${normalizeStatusName(existing.name)}).`);
6878
- const anchorName = request.after ?? request.before;
6879
- const anchor = anchorName === void 0 ? null : await requireNamedColumn(session, anchorName);
6880
- const spec = findCatalogueColumn(name) ?? ownColumn(name, null);
6881
- const created = await session.ops.addColumn(workspaceId, {
6882
- name,
6883
- description: request.description?.trim() || spec.description,
6884
- color: spec.color,
6885
- category: request.category ?? spec.category,
6886
- position: anchor ? request.after !== void 0 ? { after: anchor.id } : { before: anchor.id } : void 0
6887
- }, actor);
6888
- return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: created });
6889
- }
6890
- /** Said after a rename: agents' instructions name no column but To Do, and `kanbo prime` gives them the rest. */
6891
- const RENAME_NOTE = "Agents read column names from kanbo prime; rename is picked up there.";
6892
- async function renameColumn(session, column, newName, actor) {
6893
- const before = await requireNamedColumn(session, column);
6894
- const renamed = await session.ops.renameColumn(session.workspace.id, before.id, newName, actor);
6895
- 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 });
6896
- }
6897
- /** `--first`, `--last`, `--before <column>` or `--after <column>` — exactly one. */
6898
- function readColumnPosition(options) {
6899
- const given = [
6900
- options.first ? "first" : null,
6901
- options.last ? "last" : null,
6902
- options.before !== void 0 ? { before: options.before } : null,
6903
- options.after !== void 0 ? { after: options.after } : null
6904
- ].filter((position) => position !== null);
6905
- if (given.length !== 1) throw new CliError(1, "Say where the column goes: --first, --last, --before <column> or --after <column> (one of them).");
6906
- return given[0];
6907
- }
6908
- async function moveColumn(session, column, position, actor) {
6909
- const moving = await requireNamedColumn(session, column);
6910
- const anchorName = typeof position === "string" ? null : "before" in position ? position.before : position.after;
6911
- const anchor = anchorName === null ? null : await requireNamedColumn(session, anchorName);
6912
- const before = await session.ops.listColumns(session.workspace.id);
6913
- const after = await session.ops.moveColumn(session.workspace.id, moving.id, position, actor);
6914
- const where = position === "first" ? "to the start" : position === "last" ? "to the end" : `${"before" in position ? "before" : "after"} ${anchor.name}`;
6915
- 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}.`);
6916
- }
6917
- /** How many cards sit in the column right now. */
6918
- async function countCardsIn(session, column) {
6919
- return (await session.store.issues.listInBoardOrder(session.workspace.id)).filter((card) => card.statusId === column.id).length;
6920
- }
6921
- async function removeColumn(session, column, moveCardsTo, actor) {
6922
- const removing = await requireNamedColumn(session, column);
6923
- if (moveCardsTo !== null) await requireNamedColumn(session, moveCardsTo);
6924
- const result = await session.ops.removeColumn(session.workspace.id, removing.id, { moveCardsTo }, actor);
6925
- 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}`);
6926
- return await changed(session, result.movedTo ? `Removed ${result.removed.name} and moved ${describeCount$1(result.movedCards)} to ${result.movedTo.name}.` : `Removed ${result.removed.name}.`, {
6927
- removed: result.removed,
6928
- movedCards: result.movedCards
6929
- });
6930
- }
6931
- /** The template a person named, or the error that lists the ones there are. */
6932
- function readTemplateId(value) {
6933
- const id = COLUMN_TEMPLATE_IDS.find((candidate) => candidate === value.trim().toLowerCase());
6934
- if (!id) throw new CliError(1, `Unknown template "${value}". Use ${COLUMN_TEMPLATE_IDS.join(", ")}.`);
6935
- return id;
6936
- }
6937
- /**
6938
- * A template's columns on this board. An empty board gets the template as it
6939
- * is; a board with columns only gets the ones it lacks, and only when asked
6940
- * with `--add-missing` — a template never takes a column away.
6941
- */
6942
- async function applyTemplate(session, id, addMissing, actor) {
6943
- const workspaceId = session.workspace.id;
6944
- const label = COLUMN_TEMPLATE_LABELS[id];
6945
- const present = await session.ops.listColumns(workspaceId);
6946
- 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`);
6947
- const { added } = present.length === 0 ? await session.ops.applyColumnTemplate(workspaceId, COLUMN_TEMPLATES[id], { mode: "seed" }) : await session.ops.applyColumnTemplate(workspaceId, COLUMN_TEMPLATES[id], {
6948
- mode: "add-missing",
6949
- actor
6950
- });
6951
- return await changed(session, added.length === 0 ? `This board already has every ${label} column.` : `Added ${added.join(", ")}.`, { added });
6952
- }
6953
- /** The column a person named, or the error that says the board has none by that name. */
6954
- async function requireNamedColumn(session, nameOrId) {
6955
- const column = normalizeStatusName(nameOrId) ? await session.ops.findColumn(session.workspace.id, nameOrId) : null;
6956
- if (!column) throw new CliError(1, `This board has no column "${nameOrId}". See kanbo columns list.`);
6957
- return column;
6958
- }
6959
- function describeCount$1(count) {
6960
- return `${count} ${count === 1 ? "card" : "cards"}`;
6157
+ function describeCount$1(count) {
6158
+ return `${count} ${count === 1 ? "card" : "cards"}`;
6961
6159
  }
6962
6160
  //#endregion
6963
6161
  //#region src/cli/humanize.ts
@@ -6968,14 +6166,8 @@ const BOARD_ERROR_TEXTS = {
6968
6166
  text: "Only a person can approve a card.",
6969
6167
  next: AS_A_PERSON
6970
6168
  }),
6971
- board_card_not_ready: (details) => ({
6972
- 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.`,
6973
- next: "kanbo ready"
6974
- }),
6975
- board_card_run_active: (details) => ({
6976
- text: `${named(details.issueId)} already has a run going: ${named(details.agentName)}'s, run ${named(details.runId)}.`,
6977
- next: `kanbo run finish ${named(details.runId)} --state stopped`
6978
- }),
6169
+ 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.` }),
6170
+ board_card_run_active: (details) => ({ text: `${named(details.issueId)} already has a run going: ${named(details.agentName)}'s, run ${named(details.runId)}.` }),
6979
6171
  board_column_name_taken: (details) => ({
6980
6172
  text: `Another column, ${named(details.takenBy)}, already has that name (same slug). Pick another name.`,
6981
6173
  next: "kanbo columns list"
@@ -6984,7 +6176,7 @@ const BOARD_ERROR_TEXTS = {
6984
6176
  text: `${named(details.statusName)} holds ${describeCount(Number(details.cardCount))}. Say where they go.`,
6985
6177
  next: `kanbo columns remove ${normalizeStatusName(named(details.statusName))} --move-cards-to <column>`
6986
6178
  }),
6987
- 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.` }),
6179
+ 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.` }),
6988
6180
  board_column_remove_target_invalid: (details) => ({
6989
6181
  text: `Cards can't move into ${named(details.statusName)}: it is the column being removed.`,
6990
6182
  next: "kanbo columns list"
@@ -7011,15 +6203,9 @@ const BOARD_ERROR_TEXTS = {
7011
6203
  next: "kanbo columns list"
7012
6204
  },
7013
6205
  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." },
7014
- board_nothing_ready: () => ({
7015
- text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
7016
- next: "kanbo ready"
7017
- }),
6206
+ 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." }),
7018
6207
  board_pull_request_invalid: (details) => ({ text: `"${named(details.value)}" is not a GitHub pull request. Give its link or owner/repo#number.` }),
7019
- board_pull_request_not_found: (details) => ({
7020
- text: `${named(details.issueId)} has no pull request link "${named(details.linkId)}".`,
7021
- next: `kanbo card pr list ${named(details.issueId)}`
7022
- }),
6208
+ board_pull_request_not_found: (details) => ({ text: `${named(details.issueId)} has no pull request link "${named(details.linkId)}".` }),
7023
6209
  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.` }),
7024
6210
  board_return_no_previous_column: (details) => ({
7025
6211
  text: `${named(details.issueId)} is already in the first column: there is no column before it to send it back to.`,
@@ -7031,10 +6217,7 @@ const BOARD_ERROR_TEXTS = {
7031
6217
  }),
7032
6218
  board_run_already_finished: (details) => ({ text: `Run ${named(details.runId)} has already finished.` }),
7033
6219
  board_run_not_found: (details) => ({ text: `No run "${named(details.runId)}" on this board.` }),
7034
- board_run_session_ref_conflict: (details) => ({
7035
- text: `Run ${named(details.runId)} already names another session log (${describeSessionRef(details.externalSessionRef)}).`,
7036
- next: `a person can replace it: kanbo run attach-session ${named(details.runId)} <ref> --replace`
7037
- }),
6220
+ board_run_session_ref_conflict: (details) => ({ text: `Run ${named(details.runId)} already names another session log (${describeSessionRef(details.externalSessionRef)}).` }),
7038
6221
  board_run_session_ref_invalid: (details) => ({ text: `"${named(details.value)}" is not a session log. Give it as claude:<id> or codex:<id>.` }),
7039
6222
  board_run_session_ref_requires_user: () => ({
7040
6223
  text: "Only a person can change or clear the session log a run names.",
@@ -7058,22 +6241,19 @@ const BOARD_ERROR_TEXTS = {
7058
6241
  next: AS_A_PERSON
7059
6242
  }),
7060
6243
  issue_comment_not_found: (details) => ({ text: `No comment "${named(details.commentId)}" on this board.` }),
7061
- issue_context_ref_invalid_index: (details) => ({
7062
- text: `${named(details.issueId)} has no context link number ${named(details.index)}.`,
7063
- next: `kanbo card get ${named(details.issueId)}`
7064
- }),
6244
+ issue_context_ref_invalid_index: (details) => ({ text: `${named(details.issueId)} has no context link number ${named(details.index)}.` }),
7065
6245
  issue_milestone_not_found: (details) => ({
7066
6246
  text: `No sprint "${named(details.milestoneId)}" on this board.`,
7067
6247
  next: "kanbo sprint list"
7068
6248
  }),
7069
6249
  issue_not_found: (details) => ({
7070
6250
  text: `No card "${named(details.issueId)}" here.`,
7071
- next: "kanbo card list"
6251
+ next: "kanbo board"
7072
6252
  }),
7073
6253
  issue_parent_cycle: () => ({ text: "A card can't be put under one of its own sub-cards." }),
7074
6254
  issue_parent_not_found: (details) => ({
7075
6255
  text: `No card "${named(details.parentIssueId)}" here to put this one under.`,
7076
- next: "kanbo card list"
6256
+ next: "kanbo board"
7077
6257
  }),
7078
6258
  issue_parent_self_reference: () => ({ text: "A card can't be put under itself." }),
7079
6259
  issue_relation_not_found: (details) => ({ text: `No link "${named(details.relationId)}" between cards on this board.` }),
@@ -7686,29 +6866,9 @@ function readWipLimit(value) {
7686
6866
  * `package.version`. A build carries it as a literal; running from source
7687
6867
  * (tests, `tsx`) reads the package's own `package.json` instead.
7688
6868
  */
7689
- const KANBO_PACKAGE_VERSION = "0.6.0";
6869
+ const KANBO_PACKAGE_VERSION = "0.8.0";
7690
6870
  //#endregion
7691
6871
  //#region src/cli/setup/paths.ts
7692
- /**
7693
- * Where each agent tool keeps the files kanbo writes into, for one project and
7694
- * for the person's whole machine.
7695
- *
7696
- * Resolved on every call rather than at import, so `HOME` and `CODEX_HOME` set
7697
- * by a test (or a person) are the ones obeyed. Each path was checked against
7698
- * the tool's own documentation; `docs/agents.md` lists the pages.
7699
- */
7700
- /** The tools whose user-level instruction file `kanbo init --global` can write. */
7701
- const GLOBAL_INSTRUCTION_CLIENTS = [
7702
- "claude",
7703
- "codex",
7704
- "gemini"
7705
- ];
7706
- /** The tools that can be told about the board's MCP server. */
7707
- const MCP_CLIENTS = [
7708
- "claude",
7709
- "codex",
7710
- "cursor"
7711
- ];
7712
6872
  /** Where Codex keeps its global files: `$CODEX_HOME`, else `~/.codex`. */
7713
6873
  function codexHome() {
7714
6874
  return process.env.CODEX_HOME?.trim() || (0, node_path.join)((0, node_os.homedir)(), ".codex");
@@ -7716,7 +6876,9 @@ function codexHome() {
7716
6876
  /**
7717
6877
  * Where Claude Code keeps its user files: `$CLAUDE_CONFIG_DIR`, else
7718
6878
  * `~/.claude`. Its documentation says every `~/.claude` path moves there; it
7719
- * does not say `~/.claude.json` does, so that one is not moved here.
6879
+ * does not say `~/.claude.json` does; the MCP file follows the variable
6880
+ * all the same, as `claude mcp add --scope user` writes it there
6881
+ * (`globalMcpConfigPath`).
7720
6882
  */
7721
6883
  function claudeConfigDir() {
7722
6884
  return process.env.CLAUDE_CONFIG_DIR?.trim() || (0, node_path.join)((0, node_os.homedir)(), ".claude");
@@ -7725,7 +6887,7 @@ function claudeConfigDir() {
7725
6887
  function geminiDir() {
7726
6888
  return (0, node_path.join)(process.env.GEMINI_CLI_HOME?.trim() || (0, node_os.homedir)(), ".gemini");
7727
6889
  }
7728
- /** The user-level instruction file of each tool. */
6890
+ /** The user-level instruction file of each tool — where an older kanbo wrote its block, which `kanbo connect` takes out. */
7729
6891
  function globalInstructionPath(client) {
7730
6892
  switch (client) {
7731
6893
  case "claude": return (0, node_path.join)(claudeConfigDir(), "CLAUDE.md");
@@ -7735,12 +6897,13 @@ function globalInstructionPath(client) {
7735
6897
  }
7736
6898
  /**
7737
6899
  * The user-level MCP configuration of each tool. Claude Code's is
7738
- * `~/.claude.json`, its own state file: kanbo reads it but never writes it —
6900
+ * `.claude.json` in `$CLAUDE_CONFIG_DIR` (else in the home directory), its own
6901
+ * state file: resolved on every call, and kanbo reads it but never writes it —
7739
6902
  * `claude mcp add` and `claude mcp remove` do.
7740
6903
  */
7741
6904
  function globalMcpConfigPath(client) {
7742
6905
  switch (client) {
7743
- case "claude": return (0, node_path.join)((0, node_os.homedir)(), ".claude.json");
6906
+ case "claude": return (0, node_path.join)(process.env.CLAUDE_CONFIG_DIR?.trim() || (0, node_os.homedir)(), ".claude.json");
7744
6907
  case "codex": return (0, node_path.join)(codexHome(), "config.toml");
7745
6908
  case "cursor": return (0, node_path.join)((0, node_os.homedir)(), ".cursor", "mcp.json");
7746
6909
  }
@@ -7811,8 +6974,9 @@ function isExecutableFile(path) {
7811
6974
  //#region src/cli/setup/agents.ts
7812
6975
  /**
7813
6976
  * The agent tools kanbo can connect a project — or a person — to, and for
7814
- * each one: how to tell it is here, which file it reads its standing
7815
- * instructions from, and where it keeps its MCP servers.
6977
+ * each one: how to tell it is here, which file an older kanbo wrote
6978
+ * its instruction block into (kept to take that block out again), and where it
6979
+ * keeps its MCP servers.
7816
6980
  *
7817
6981
  * `kanbo connect`, `kanbo init`, `kanbo uninstall` and `kanbo doctor` all read
7818
6982
  * this one table, so a file one of them writes is a file the others find.
@@ -7846,8 +7010,7 @@ const AGENTS = {
7846
7010
  } : {
7847
7011
  format: "claude-cli",
7848
7012
  path: globalMcpConfigPath("claude")
7849
- },
7850
- mcpScope: "project"
7013
+ }
7851
7014
  },
7852
7015
  codex: {
7853
7016
  id: "codex",
@@ -7857,8 +7020,7 @@ const AGENTS = {
7857
7020
  mcpTarget: (scope, projectDir) => ({
7858
7021
  format: "toml",
7859
7022
  path: scope === "project" ? (0, node_path.join)(projectDir, ".codex", "config.toml") : globalMcpConfigPath("codex")
7860
- }),
7861
- mcpScope: "user"
7023
+ })
7862
7024
  },
7863
7025
  cursor: {
7864
7026
  id: "cursor",
@@ -7869,9 +7031,7 @@ const AGENTS = {
7869
7031
  format: "json",
7870
7032
  path: scope === "project" ? (0, node_path.join)(projectDir, ".cursor", "mcp.json") : globalMcpConfigPath("cursor"),
7871
7033
  withType: true
7872
- }),
7873
- mcpScope: "project",
7874
- 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."
7034
+ })
7875
7035
  },
7876
7036
  gemini: {
7877
7037
  id: "gemini",
@@ -7882,13 +7042,16 @@ const AGENTS = {
7882
7042
  format: "json",
7883
7043
  path: scope === "project" ? (0, node_path.join)(projectDir, ".gemini", "settings.json") : (0, node_path.join)(geminiDir(), "settings.json"),
7884
7044
  withType: false
7885
- }),
7886
- mcpScope: "project"
7045
+ })
7887
7046
  }
7888
7047
  };
7889
- /** Where the MCP server goes when no scope is named: on Windows always the person's own configuration. */
7890
- function defaultMcpScope(agent, platform = process.platform) {
7891
- return platform === "win32" ? "user" : AGENTS[agent].mcpScope;
7048
+ /**
7049
+ * Where the MCP server goes when no scope is named: the person's own
7050
+ * configuration, for every agent. One registration serves every project —
7051
+ * `kanbo mcp` finds the board from the folder the client starts it in.
7052
+ */
7053
+ function defaultMcpScope() {
7054
+ return "user";
7892
7055
  }
7893
7056
  /** The agent's name as typed on the command line, or `null` when it is none of them. */
7894
7057
  function parseAgentId(name) {
@@ -8031,66 +7194,10 @@ const INSTRUCTION_END = "<!-- KANBO_END -->";
8031
7194
  */
8032
7195
  const INSTRUCTION_START_PATTERN = /<!-- KANBO_START\b([^>]*?)\s*-->/g;
8033
7196
  const INSTRUCTION_END_PATTERN = /<!-- KANBO_END\s*-->/g;
8034
- /** The kinds of block that are kanbo's pointer — the one `kanbo connect` writes. */
7197
+ /** The kinds of block that are kanbo's pointer — the one kanbo 0.5–0.6 wrote. */
8035
7198
  const POINTER_KINDS = /* @__PURE__ */ new Set(["short", "global"]);
8036
- /** The commands an agent uses, spelled once for the guide and the `kanbo prime` cheat sheet. */
8037
- const COMMAND = {
8038
- claim: "kanbo claim",
8039
- move: "kanbo card move <id> <column>",
8040
- statusLine: "kanbo card status-line <id> --text \"...\"",
8041
- subtask: "kanbo card create --description \"...\" --parent <id>",
8042
- comment: "kanbo card comment <id> --content \"...\"",
8043
- waitApproval: "kanbo card wait-approval <id>",
8044
- runStart: "kanbo run start <id> --agent <name>",
8045
- capabilities: "kanbo capabilities"
8046
- };
8047
- /**
8048
- * The whole guide for an agent working a board from the command line — what
8049
- * the project block said in kanbo 0.1–0.2, before it became a pointer.
8050
- */
8051
- const AGENT_GUIDE_TEXT = [
8052
- "## Kanbo board",
8053
- "",
8054
- "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.",
8055
- "",
8056
- ...[
8057
- `- 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.`,
8058
- `- ${MOVE_CARD_RULE} (\`${COMMAND.move}\`)`,
8059
- `- ${STATUS_LINE_RULE} (\`${COMMAND.statusLine}\`)`,
8060
- `- ${SUBTASK_RULE} (\`${COMMAND.subtask}\`)`,
8061
- `- ${COMMENT_RULE} (\`${COMMAND.comment}\`)`,
8062
- `- ${WAIT_FOR_PERSON_RULE} (\`${COMMAND.waitApproval}\`)`,
8063
- `- ${PERSON_ONLY_RULE}`,
8064
- `- ${OWN_SESSION_RULE} (\`${COMMAND.runStart}\`)`,
8065
- `- Run \`${COMMAND.capabilities}\` for the full list of tools, commands and rules.`
8066
- ]
8067
- ].join("\n");
8068
- /**
8069
- * The commands, for an agent with no MCP tools: `kanbo prime` prints this under
8070
- * the board's rules, which say when to use each. Each is written out with its
8071
- * arguments, as a call that works — agents that had only the placeholders read
8072
- * `kanbo capabilities` first, or guessed an option and ran it again
8073
- * (docs/performance.md).
8074
- */
8075
- const COMMAND_SHEET = [
8076
- "Commands (a card is named by its id, e.g. TST-5):",
8077
- "",
8078
- "- `kanbo ready` — the cards you may take next; cards a person sent back to you come first",
8079
- "- `kanbo card get TST-5` — one card, with its latest comments",
8080
- "- `kanbo card list --waiting` (or `--returned`) — cards waiting for a person (or sent back), each with its last comment",
8081
- "- `kanbo card move TST-5 in_progress` — move a card; the column by its slug above",
8082
- "- `kanbo card status-line TST-5 --text \"Running the tests\"` — what is happening on the card right now",
8083
- "- `kanbo card comment TST-5 --content \"Added slugify with a test\"` — a finding, result, decision or question",
8084
- "- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
8085
- "- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
8086
- "- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
8087
- "- `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",
8088
- "- `kanbo run start TST-5 --agent claude` — record a run on a card you did not claim; it prints the finish command",
8089
- "- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
8090
- `- \`${COMMAND.capabilities}\` — everything else, rarely needed`
8091
- ].join("\n");
8092
7199
  /** How to take work and who approves it — said by both blocks after their first sentence. */
8093
- const POINTER_TAIL = `Take work with \`${COMMAND.claim}\`. ${PERSON_ONLY_RULE}`;
7200
+ const POINTER_TAIL = `Take work with \`kanbo claim\`. ${PERSON_ONLY_RULE}`;
8094
7201
  /** The body of the project block: what `kanbo instructions short` prints. */
8095
7202
  const PROJECT_BODY = [
8096
7203
  "## Kanbo board",
@@ -8113,9 +7220,9 @@ const GLOBAL_BODY = [
8113
7220
  function wrapInstructionBlock(body, version = 3, kind) {
8114
7221
  return `<!-- KANBO_START v${version}${kind ? ` k=${kind}` : ""} h=${hashBody(body)} -->\n${body}\n${INSTRUCTION_END}`;
8115
7222
  }
8116
- /** The block `kanbo init --instructions …` writes into a project's own file. */
7223
+ /** The project block kanbo 0.5–0.6 wrote into a project's own file. */
8117
7224
  const INSTRUCTION_BLOCK = wrapInstructionBlock(PROJECT_BODY);
8118
- /** The block `kanbo init --global` writes into a person's user-level file. */
7225
+ /** The global block kanbo 0.5–0.6 wrote into a person's user-level file. */
8119
7226
  const GLOBAL_INSTRUCTION_BLOCK = wrapInstructionBlock(GLOBAL_BODY);
8120
7227
  /** The same, with what the block's marker said. */
8121
7228
  function inspectBlock(text, current) {
@@ -8140,56 +7247,13 @@ function inspectBlock(text, current) {
8140
7247
  });
8141
7248
  if (fields.trim() === "") return inspection(LEGACY_BLOCK_BODIES.includes(body) ? "legacy" : "edited");
8142
7249
  if (version !== void 0 && Number(version) > 3) return inspection("newer");
8143
- if (kind !== null && !POINTER_KINDS.has(kind)) return inspection("chosen");
8144
- if (version === void 0 || hash === void 0 || hashBody(body) !== hash) return inspection("edited");
8145
- return inspection(normalized.slice(found.start, found.end) === current ? "current" : "outdated");
8146
- }
8147
- /** What the person is told about a file whose markers do not pair up; `label` names the file. */
8148
- function damagedBlockMessage(label, damage) {
8149
- return `${label} has ${damage} — fix it by hand, then run again.`;
8150
- }
8151
- /** What the person is told about a block a newer kanbo wrote. */
8152
- function newerBlockMessage(label, version) {
8153
- return `${label}: the kanbo section was written by a newer kanbo (v${version ?? "?"}) — kept. Update kanbo: npm install -g kanbo-cli@latest`;
8154
- }
8155
- /**
8156
- * The block in that file, once.
8157
- *
8158
- * A file that already carries the markers has its block replaced rather than
8159
- * appended to, so a current block changes nothing at all and an outdated one is
8160
- * brought forward. Everything outside the markers is the person's own writing
8161
- * and is never touched.
8162
- */
8163
- function planInstructionBlock(path, block) {
8164
- return planTextFile(path, (existing) => withBlock(existing, block));
8165
- }
8166
- /**
8167
- * The same, minding a block that is not kanbo's to replace: one the person
8168
- * changed is replaced only when they say so at a terminal, and the default
8169
- * answer is no. `--yes` is not that answer — it agrees to kanbo's own changes,
8170
- * not to losing theirs. A block of a text the person chose, one a newer kanbo
8171
- * wrote, and a file whose markers do not pair up are left as they are.
8172
- */
8173
- async function planInstructionBlockAsking(path, block, options) {
8174
- const inspection = readInstructionBlockInspection(path, block);
8175
- const label = options.label ?? path;
8176
- const keep = (note) => ({
8177
- change: {
8178
- path,
8179
- next: null
8180
- },
8181
- note
8182
- });
8183
- switch (inspection?.state) {
8184
- case "damaged": return keep(damagedBlockMessage(label, inspection.damage));
8185
- case "newer": return keep(newerBlockMessage(label, inspection.version));
8186
- case "chosen": return keep(`${label}: you chose the ${inspection.kind} text for the kanbo section — kept.`);
8187
- case "edited": return !options.yes && canPrompt() && await getUi().confirm({
8188
- message: `You changed the kanbo section in ${label}. Replace it with the current one?`,
8189
- initialValue: false
8190
- }) ? { change: planInstructionBlock(path, block) } : keep(`${label}: you changed the kanbo section, so it was left as it is.`);
8191
- default: return { change: planInstructionBlock(path, block) };
8192
- }
7250
+ if (kind !== null && !POINTER_KINDS.has(kind)) return inspection("chosen");
7251
+ if (version === void 0 || hash === void 0 || hashBody(body) !== hash) return inspection("edited");
7252
+ return inspection(normalized.slice(found.start, found.end) === current ? "current" : "outdated");
7253
+ }
7254
+ /** What the person is told about a file whose markers do not pair up; `label` names the file. */
7255
+ function damagedBlockMessage(label, damage) {
7256
+ return `${label} has ${damage} — fix it by hand, then run again.`;
8193
7257
  }
8194
7258
  /**
8195
7259
  * The file without the block, or no change when it carries none. The file
@@ -8245,13 +7309,6 @@ function readInstructionBlockInspection(path, current) {
8245
7309
  const text = readTextFile(path)?.text;
8246
7310
  return text === void 0 ? null : inspectBlock(text, current);
8247
7311
  }
8248
- function withBlock(existing, block) {
8249
- if (existing === null || !existing.trim()) return `${block}\n`;
8250
- const bounds = findBlock(existing);
8251
- if (bounds && "damage" in bounds) return null;
8252
- if (bounds) return existing.slice(0, bounds.start) + block + existing.slice(bounds.end);
8253
- return `${existing.replace(/\s*$/, "")}\n\n${block}\n`;
8254
- }
8255
7312
  /**
8256
7313
  * The first block in the text; `null` when there is none; `{ damage }` when
8257
7314
  * the markers do not pair up — every start followed by its end before the
@@ -8380,48 +7437,6 @@ function spawnDetached(command, args) {
8380
7437
  child.unref();
8381
7438
  } catch {}
8382
7439
  }
8383
- /**
8384
- * Run a program with this input on its stdin and say whether it exited `0`.
8385
- * Never throws: a program that is not installed, fails or hangs past the
8386
- * timeout is `false`, and the caller tries the next thing. Its output is
8387
- * dropped — nothing it prints reaches kanbo's own stdout.
8388
- */
8389
- function runWithInput(command, args, input, options) {
8390
- return new Promise((resolve) => {
8391
- let settled = false;
8392
- const settle = (ok) => {
8393
- if (!settled) {
8394
- settled = true;
8395
- clearTimeout(timer);
8396
- resolve(ok);
8397
- }
8398
- };
8399
- let child;
8400
- try {
8401
- child = (0, cross_spawn.default)(command, [...args], {
8402
- detached: options.detached ?? false,
8403
- stdio: [
8404
- "pipe",
8405
- "ignore",
8406
- "ignore"
8407
- ],
8408
- windowsHide: true
8409
- });
8410
- } catch {
8411
- resolve(false);
8412
- return;
8413
- }
8414
- const timer = setTimeout(() => {
8415
- child.kill();
8416
- settle(false);
8417
- }, options.timeoutMs);
8418
- child.on("error", () => settle(false));
8419
- child.on("exit", (code) => settle(code === 0));
8420
- child.stdin?.on("error", () => {});
8421
- child.stdin?.end(input);
8422
- if (options.detached) child.unref();
8423
- });
8424
- }
8425
7440
  //#endregion
8426
7441
  //#region src/cli/setup/mcp-config.ts
8427
7442
  /**
@@ -8552,7 +7567,7 @@ function readJsonMcpEntry(path) {
8552
7567
  return {
8553
7568
  command: typeof command === "string" && command.trim() ? command.trim() : null,
8554
7569
  args: Array.isArray(args) ? args.filter((arg) => typeof arg === "string") : [],
8555
- otherFields: Object.keys(entry).filter((key) => key !== "command" && key !== "args" && !(key === "type" && entry.type === "stdio"))
7570
+ 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))
8556
7571
  };
8557
7572
  }
8558
7573
  /**
@@ -8585,7 +7600,7 @@ function planCodexMcpServer(path, launch = PORTABLE_MCP_LAUNCH, options = {}) {
8585
7600
  * Is this entry one kanbo wrote, in exactly the shape it writes it? `kanbo mcp`
8586
7601
  * (with `"type": "stdio"` or without), or a Node given by full path running
8587
7602
  * kanbo's own script (`…/kanbo-cli/dist/cli.cjs mcp`) — and nothing else: an
8588
- * entry with an `env`, a `cwd` or any other field, or one that starts kanbo
7603
+ * entry with a non-empty `env`, a `cwd` or any other field, or one that starts kanbo
8589
7604
  * some other way (`cmd /c kanbo mcp`, `npx -y kanbo-cli mcp`), is the
8590
7605
  * person's, and kanbo never rewrites or removes it without asking.
8591
7606
  */
@@ -8748,10 +7763,10 @@ function readJsonConfig(path, text) {
8748
7763
  try {
8749
7764
  parsed = JSON.parse(text);
8750
7765
  } catch {
8751
- throw new CliError(1, `${path} is not valid JSON. Fix or remove it, then run kanbo init again.`);
7766
+ throw new CliError(1, `${path} is not valid JSON. Fix or remove it, then run kanbo connect again.`);
8752
7767
  }
8753
- if (!isPlainObject$1(parsed)) throw new CliError(1, `${path} does not hold a JSON object. Fix or remove it, then run kanbo init again.`);
8754
- 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.`);
7768
+ if (!isPlainObject$1(parsed)) throw new CliError(1, `${path} does not hold a JSON object. Fix or remove it, then run kanbo connect again.`);
7769
+ 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.`);
8755
7770
  return parsed;
8756
7771
  }
8757
7772
  /** The file's text as a JSON object, or `null` when it is missing or anything else — for reading and removal, which never refuse. */
@@ -8774,8 +7789,9 @@ function isPlainObject$1(value) {
8774
7789
  * worked out in full before anything is written.
8775
7790
  *
8776
7791
  * `kanbo connect` builds one of these from the agents and scopes it was given,
8777
- * `kanbo init --instructions/--mcp` and `kanbo init --global` build theirs the
8778
- * same way, and all of them show it, ask, and apply it here. Each item is one
7792
+ * `kanbo init --connect` builds its the same way, and both show it, ask, and
7793
+ * apply it here. kanbo writes only the MCP server into an agent's settings; the
7794
+ * `instructions` items take out the section older versions wrote. Each item is one
8779
7795
  * file (or one `claude mcp` command) with what changes in it; an item whose
8780
7796
  * file already says it has `next: null` and is reported `unchanged`.
8781
7797
  */
@@ -8791,8 +7807,10 @@ function ownMcpEntryNote(label, path) {
8791
7807
  return `${label}: ${path} has your own kanbo entry — left as is.`;
8792
7808
  }
8793
7809
  /**
8794
- * The plan that connects these agents. Asks only one question, and only at a
8795
- * terminal: whether to replace an instruction block the person changed.
7810
+ * The plan that connects these agents: their MCP server registered, and the
7811
+ * kanbo section older versions wrote into their instruction files taken out.
7812
+ * Asks nothing: a section a person changed (or chose, or a newer kanbo wrote)
7813
+ * is kept and named in a note, never removed without their say.
8796
7814
  */
8797
7815
  async function planConnect(requests, context) {
8798
7816
  const plan = {
@@ -8801,29 +7819,36 @@ async function planConnect(requests, context) {
8801
7819
  notes: []
8802
7820
  };
8803
7821
  const planned = /* @__PURE__ */ new Set();
8804
- for (const { agent, instructions: scope } of requests) {
8805
- if (scope === null) continue;
8806
- const path = AGENTS[agent].instructionPath(scope, context.projectDir);
8807
- if (path === null) {
8808
- plan.notes.push(AGENTS[agent].noInstructionFile ?? `${AGENTS[agent].label} has no instruction file for this scope.`);
8809
- continue;
8810
- }
8811
- const existing = plan.items.find((item) => item.kind === "instructions" && item.change.path === path);
8812
- if (existing?.kind === "instructions") {
8813
- existing.agents.push(agent);
8814
- continue;
7822
+ for (const { agent, instructions } of requests) {
7823
+ if (instructions === null) continue;
7824
+ for (const scope of instructions === "both" ? ["project", "user"] : [instructions]) {
7825
+ const path = AGENTS[agent].instructionPath(scope, context.projectDir);
7826
+ if (path === null) continue;
7827
+ const existing = plan.items.find((item) => item.kind === "instructions" && item.change.path === path);
7828
+ if (existing?.kind === "instructions") {
7829
+ existing.agents.push(agent);
7830
+ continue;
7831
+ }
7832
+ const label = displayPath(plan, path);
7833
+ const inspection = readInstructionBlockInspection(path, INSTRUCTION_BLOCK);
7834
+ switch (inspection?.state) {
7835
+ case void 0: break;
7836
+ case "damaged":
7837
+ plan.notes.push(damagedBlockMessage(label, inspection.damage));
7838
+ break;
7839
+ case "edited":
7840
+ case "chosen":
7841
+ case "newer":
7842
+ plan.notes.push(`${label}: an old kanbo section is still there; kanbo no longer uses it. Delete it yourself.`);
7843
+ break;
7844
+ default: plan.items.push({
7845
+ kind: "instructions",
7846
+ agents: [agent],
7847
+ scope,
7848
+ change: planInstructionBlockRemoval(path)
7849
+ });
7850
+ }
8815
7851
  }
8816
- const asked = await planInstructionBlockAsking(path, scope === "project" ? INSTRUCTION_BLOCK : GLOBAL_INSTRUCTION_BLOCK, {
8817
- yes: context.yes,
8818
- label: displayPath(plan, path)
8819
- });
8820
- if (asked.note) plan.notes.push(asked.note);
8821
- plan.items.push({
8822
- kind: "instructions",
8823
- agents: [agent],
8824
- scope,
8825
- change: asked.change
8826
- });
8827
7852
  }
8828
7853
  for (const { agent, mcp: scope } of requests) {
8829
7854
  if (scope === null) continue;
@@ -8994,11 +8019,10 @@ function readMcpState(agent, scope, projectDir, platform) {
8994
8019
  if (!isKanboEntry(entry, scope, platform)) return "own";
8995
8020
  return isStaleMcpEntry(entry, scope, platform) ? "stale" : "ok";
8996
8021
  }
8997
- /** What the instruction block for one agent in one scope is; `null` when that scope has no file for it. */
8998
- function readInstructionState(agent, scope, projectDir) {
8022
+ /** The file that still carries a kanbo section from an older version, in one agent's scope; none when it carries none. */
8023
+ function readOldSectionPaths(agent, scope, projectDir) {
8999
8024
  const path = AGENTS[agent].instructionPath(scope, projectDir);
9000
- if (path === null) return null;
9001
- return readInstructionBlockState(path, scope === "project" ? INSTRUCTION_BLOCK : GLOBAL_INSTRUCTION_BLOCK) ?? "missing";
8025
+ return path !== null && readInstructionBlockState(path, INSTRUCTION_BLOCK) !== null ? [path] : [];
9002
8026
  }
9003
8027
  /** The items that change something. */
9004
8028
  function pendingItems(plan) {
@@ -9014,7 +8038,7 @@ function explainItem(item, removal = false) {
9014
8038
  case "instructions": {
9015
8039
  const readers = item.agents.map((agent) => AGENTS[agent].label).join(" and ");
9016
8040
  const one = item.agents.length === 1;
9017
- 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`;
8041
+ return removal ? `the kanbo section ${readers} read${one ? "s" : ""} ${where}` : "the old kanbo section: agents now learn the board from the MCP server";
9018
8042
  }
9019
8043
  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}`;
9020
8044
  case "claude-cli": return item.argvs.map((argv) => displayCommand(argv)).join(", then ");
@@ -9029,10 +8053,10 @@ function describeConnectPlan(plan, title, removal = false) {
9029
8053
  "Nothing else is changed."
9030
8054
  ].join("\n");
9031
8055
  }
9032
- /** One line per change the plan makes: ` write CLAUDE.md (what it is for)`. */
8056
+ /** One line per change the plan makes: ` write .mcp.json (what it is for)`. */
9033
8057
  function describeConnectItems(plan, removal = false) {
9034
8058
  return pendingItems(plan).map((item) => {
9035
- const verb = item.kind === "claude-cli" ? "run " : removal ? "edit " : "write";
8059
+ const verb = item.kind === "claude-cli" ? "run " : item.kind === "instructions" ? "remove" : removal ? "edit " : "write";
9036
8060
  return item.kind === "claude-cli" ? ` ${verb} ${explainItem(item, removal)}` : ` ${verb} ${displayPath(plan, itemPath(item))} (${explainItem(item, removal)})`;
9037
8061
  });
9038
8062
  }
@@ -9082,6 +8106,17 @@ function applyConnectPlan(plan) {
9082
8106
  });
9083
8107
  }
9084
8108
  /**
8109
+ * What happened to one item, as a person reads it in the parentheses of an
8110
+ * outcome line: an old kanbo section taken out of a file is "removed", not
8111
+ * "written" (kanbo writes no section any more); a `claude mcp` command is
8112
+ * "ran …". `removal` is `kanbo connect --remove`, where every change is a removal.
8113
+ */
8114
+ function describeOutcomeState(outcome, removal = false) {
8115
+ if (outcome.kind === "instructions" && outcome.state === "written") return "old kanbo section removed";
8116
+ if (outcome.state === "ran") return `ran ${outcome.command}`;
8117
+ return outcome.state === "written" && removal ? "removed" : outcome.state;
8118
+ }
8119
+ /**
9085
8120
  * What the person reads about `claude mcp` commands left to them: each on a
9086
8121
  * line of its own, so each can be pasted into any shell — PowerShell has no `&&`.
9087
8122
  */
@@ -9169,7 +8204,7 @@ function startUpdateCheck(deps = {}) {
9169
8204
  setTimeout: globalThis.setTimeout,
9170
8205
  clearTimeout: globalThis.clearTimeout
9171
8206
  };
9172
- const current = deps.currentVersion ?? "0.6.0";
8207
+ const current = deps.currentVersion ?? "0.8.0";
9173
8208
  const controller = new AbortController();
9174
8209
  let found = null;
9175
8210
  const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
@@ -9448,8 +8483,8 @@ async function checkBoard(target, workspaceId, root) {
9448
8483
  /** The Standard columns, as the plan names them. */
9449
8484
  const STANDARD_COLUMN_NAMES = COLUMN_TEMPLATES.standard.map((column) => column.name).join(", ");
9450
8485
  /**
9451
- * A board with no columns gives agents nothing to take: `kanbo prime` lists
9452
- * none and `kanbo ready` has no To Do. Boards from kanbo 0.1–0.2 got their
8486
+ * A board with no columns gives agents nothing to take: `kanbo_prime` lists
8487
+ * none and `kanbo_ready` has no To Do. Boards from kanbo 0.1–0.2 got their
9453
8488
  * columns only with the first card.
9454
8489
  */
9455
8490
  async function checkColumns(store, workspaceId, open) {
@@ -9457,7 +8492,7 @@ async function checkColumns(store, workspaceId, open) {
9457
8492
  return [{
9458
8493
  check: "columns",
9459
8494
  status: "warn",
9460
- detail: "This board has no columns: kanbo prime shows agents none, and kanbo ready has no To Do to take cards from.",
8495
+ detail: "This board has no columns: kanbo_prime shows agents none, and kanbo_ready has no To Do to take cards from.",
9461
8496
  fix: "Run kanbo doctor --fix, or kanbo columns template standard.",
9462
8497
  repair: {
9463
8498
  plan: [` add the Standard columns to the board: ${STANDARD_COLUMN_NAMES}`],
@@ -9511,9 +8546,11 @@ async function checkPostgresBoard(url, workspaceId) {
9511
8546
  }
9512
8547
  }
9513
8548
  /**
9514
- * Every instruction block kanbo could have written, compared with the one this
9515
- * build writes. Only files that carry a block are reported: a file without one
9516
- * is not a fault, but having no block anywhere is worth a warning.
8549
+ * Every instruction block an older kanbo could have written, in the project's
8550
+ * and the person's own agent files. kanbo writes none any more, so a file
8551
+ * without one is as it should be, and a file with one is worth a warning: the
8552
+ * agent learns the board from the MCP server and the block is dead text. Only
8553
+ * files that carry a block are reported.
9517
8554
  */
9518
8555
  async function checkInstructions(root) {
9519
8556
  const findings = [];
@@ -9529,9 +8566,8 @@ async function checkInstructions(root) {
9529
8566
  for (const { scope, root: folder, block } of scopes) for (const { path, agents } of instructionPaths(scope, folder)) {
9530
8567
  const inspection = readInstructionBlockInspection(path, block);
9531
8568
  if (inspection !== null) {
9532
- const command = `kanbo connect ${agents[0]} ${scope === "project" ? "--project" : "--global"} --no-mcp`;
9533
- const finding = blockFinding(path, inspection, scope === "project" ? `cd ${folder} && ${command}` : command);
9534
- if (inspection.state === "outdated" || inspection.state === "legacy") finding.repair = connectRepair(await planConnect([{
8569
+ const finding = blockFinding(path, inspection, agents[0]);
8570
+ if (inspection.state === "current" || inspection.state === "outdated" || inspection.state === "legacy") finding.repair = connectRepair(await planConnect([{
9535
8571
  agent: agents[0],
9536
8572
  instructions: scope,
9537
8573
  mcp: null
@@ -9542,38 +8578,17 @@ async function checkInstructions(root) {
9542
8578
  findings.push(finding);
9543
8579
  }
9544
8580
  }
9545
- if (findings.length === 0) findings.push({
9546
- check: "instructions",
9547
- status: "warn",
9548
- detail: "No kanbo instruction block in this project's or your own agent files: agents learn the rules only from the MCP server.",
9549
- fix: "Run kanbo connect in the project (kanbo connect claude --yes, say), or kanbo connect claude --global."
9550
- });
9551
8581
  return findings;
9552
8582
  }
9553
8583
  /**
9554
- * A block kanbo wrote and nobody changed is only out of date: it still sends
9555
- * the agent to `kanbo prime`, so it is a warning, and `--yes` rewrites it. A
9556
- * block the person changed is theirs, and only they can say to replace it; a
9557
- * text they chose, or a block a newer kanbo wrote, is fine as it is. A file
9558
- * whose markers do not pair up is one kanbo will not touch until it is fixed.
8584
+ * A block kanbo wrote and nobody changed is only dead text: it is a warning,
8585
+ * and `--fix` removes it. A block the person changed or chose, or one a newer
8586
+ * kanbo wrote, is theirs to delete. A file whose markers do not pair up is one
8587
+ * kanbo will not touch until it is fixed.
9559
8588
  */
9560
- function blockFinding(path, inspection, command) {
8589
+ function blockFinding(path, inspection, agent) {
8590
+ const detail = `${path}: an old kanbo section — agents now learn the board from the MCP server.`;
9561
8591
  switch (inspection.state) {
9562
- case "current": return {
9563
- check: "instructions",
9564
- status: "ok",
9565
- detail: `${path}: block is current.`
9566
- };
9567
- case "chosen": return {
9568
- check: "instructions",
9569
- status: "ok",
9570
- detail: `${path}: you chose the ${inspection.kind} text for the kanbo section; kanbo leaves it alone.`
9571
- };
9572
- case "newer": return {
9573
- check: "instructions",
9574
- status: "ok",
9575
- detail: `${path}: written by a newer kanbo (v${inspection.version}) — update kanbo: npm install -g kanbo-cli@latest`
9576
- };
9577
8592
  case "damaged": {
9578
8593
  const message = damagedBlockMessage(path, inspection.damage);
9579
8594
  return {
@@ -9583,17 +8598,19 @@ function blockFinding(path, inspection, command) {
9583
8598
  fix: message
9584
8599
  };
9585
8600
  }
9586
- case "edited": return {
8601
+ case "edited":
8602
+ case "chosen":
8603
+ case "newer": return {
9587
8604
  check: "instructions",
9588
8605
  status: "warn",
9589
- detail: `${path}: you edited the kanbo block, so kanbo leaves it alone.`,
9590
- fix: `Keep it, or run ${command} in a terminal and answer yes to replace it with the current one.`
8606
+ detail,
8607
+ fix: "Delete it yourself."
9591
8608
  };
9592
8609
  default: return {
9593
8610
  check: "instructions",
9594
8611
  status: "warn",
9595
- detail: `${path}: block was written by an older kanbo.`,
9596
- fix: `Run kanbo doctor --fix, or ${command} --yes to rewrite it.`
8612
+ detail,
8613
+ fix: `Run kanbo doctor --fix (or kanbo connect ${agent} --yes) to remove it.`
9597
8614
  };
9598
8615
  }
9599
8616
  }
@@ -9619,8 +8636,8 @@ async function checkRegistrations(registrations, platform, projectDir) {
9619
8636
  if (registrations.length === 0) return [{
9620
8637
  check: "mcp",
9621
8638
  status: "warn",
9622
- detail: "No MCP client has the kanbo server registered.",
9623
- fix: "Run kanbo connect in the project (kanbo connect claude --yes, say), or kanbo connect claude --global."
8639
+ detail: "No coding agent reaches kanbo: its MCP server is registered nowhere.",
8640
+ fix: "kanbo connect claude --yes (or codex, cursor, gemini)"
9624
8641
  }];
9625
8642
  const findings = [];
9626
8643
  for (const registration of registrations) {
@@ -9693,7 +8710,7 @@ function connectRepair(plan) {
9693
8710
  switch (outcome.state) {
9694
8711
  case "manual": return describeManualOutcome(AGENTS[outcome.agents[0]].label, outcome);
9695
8712
  case "ran": return `ran ${outcome.command}`;
9696
- default: return `${outcome.state} ${displayPath(plan, outcome.path)}`;
8713
+ default: return outcome.kind === "instructions" && outcome.state === "written" ? `removed the old kanbo section from ${displayPath(plan, outcome.path)}` : `${outcome.state} ${displayPath(plan, outcome.path)}`;
9697
8714
  }
9698
8715
  })
9699
8716
  };
@@ -9707,7 +8724,7 @@ function isAbsoluteOn(platform, path) {
9707
8724
  * alone would register kanbo for the person and leave the project's entry
9708
8725
  * there, so the fix is two commands, each on its own line.
9709
8726
  */
9710
- 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";
8727
+ 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";
9711
8728
  /**
9712
8729
  * A registration that names its program by full path — on Windows, a Node and
9713
8730
  * the kanbo script it runs. Both have to still be there: a Node upgrade or a
@@ -9875,7 +8892,7 @@ async function confirmPlan(input) {
9875
8892
  //#region src/cli/commands/connect.ts
9876
8893
  const AGENT_LIST = `${AGENT_IDS.join(", ")} (or all)`;
9877
8894
  function registerConnectCommand(program) {
9878
- 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) => {
8895
+ 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) => {
9879
8896
  await connect(names, options);
9880
8897
  });
9881
8898
  }
@@ -9900,7 +8917,7 @@ async function connect(names, options) {
9900
8917
  await disconnect(agents, scope, projectDir, options);
9901
8918
  return;
9902
8919
  }
9903
- const plan = await planConnect(agents.map((agent) => request(agent, scope, options)), {
8920
+ const plan = await planConnect(agents.map((agent) => request(agent, scope)), {
9904
8921
  projectDir,
9905
8922
  yes: options.yes || options.dryRun
9906
8923
  });
@@ -9923,19 +8940,23 @@ async function connect(names, options) {
9923
8940
  }
9924
8941
  printResult(describeOutcomes("connect", agents, applyConnectPlan(plan), plan, false, previewed), outputOptions(options));
9925
8942
  }
9926
- /** What one agent gets: its instructions where the scope says, and its MCP server where the scope — or the agent — says. */
9927
- function request(agent, scope, options) {
8943
+ /**
8944
+ * What one agent gets: its MCP server where the scope says (the person's own
8945
+ * configuration by default), and the old kanbo section taken out of its
8946
+ * instruction file in the same scope — in both files when none is named.
8947
+ */
8948
+ function request(agent, scope) {
9928
8949
  return {
9929
8950
  agent,
9930
- instructions: options.instructions === false ? null : scope ?? "project",
9931
- mcp: options.mcp === false ? null : scope ?? defaultMcpScope(agent)
8951
+ instructions: scope ?? "both",
8952
+ mcp: scope ?? defaultMcpScope()
9932
8953
  };
9933
8954
  }
9934
8955
  async function disconnect(agents, scope, projectDir, options) {
9935
8956
  const plan = await planDisconnect((scope ? [scope] : ["project", "user"]).flatMap((each) => agents.map((agent) => ({
9936
8957
  agent,
9937
- instructions: options.instructions === false ? null : each,
9938
- mcp: options.mcp === false ? null : each
8958
+ instructions: each,
8959
+ mcp: each
9939
8960
  }))), {
9940
8961
  projectDir,
9941
8962
  yes: options.yes,
@@ -9971,14 +8992,12 @@ async function askAgents$1(projectDir) {
9971
8992
  });
9972
8993
  }
9973
8994
  /**
9974
- * Show the section the agents get, let the person untick any file, then show
9975
- * what is left and ask once. Unticked files are dropped from the plan.
8995
+ * Let the person untick any change, then show what is left and ask once.
8996
+ * Unticked changes are dropped from the plan.
9976
8997
  */
9977
8998
  async function chooseAndConfirm(plan) {
9978
8999
  const ui = getUi();
9979
9000
  const pending = pendingItems(plan);
9980
- const section = pending.find((item) => item.kind === "instructions");
9981
- if (section) console.log(`The kanbo section your agents get:\n\n${section.scope === "project" ? INSTRUCTION_BLOCK : GLOBAL_INSTRUCTION_BLOCK}\n`);
9982
9001
  const keys = pending.map((_, index) => `${index}`);
9983
9002
  const kept = new Set(await ui.multiselect({
9984
9003
  message: "Which of these should kanbo change?",
@@ -10009,10 +9028,7 @@ function describeOutcomes(action, agents, outcomes, plan, dryRun, notesShown = f
10009
9028
  const who = outcome.agents.map((agent) => AGENTS[agent].label).join(", ");
10010
9029
  const where = displayPath(plan, outcome.path);
10011
9030
  if (outcome.state === "manual") lines.push(describeManualOutcome(who, outcome));
10012
- else {
10013
- const state = outcome.state === "written" && action === "remove" ? "removed" : outcome.state;
10014
- lines.push(`${who}: ${where} (${state === "ran" ? `ran ${outcome.command}` : state})`);
10015
- }
9031
+ else lines.push(`${who}: ${where} (${describeOutcomeState(outcome, action === "remove")})`);
10016
9032
  }
10017
9033
  if (!dryRun && !notesShown) lines.push(...plan.notes.map((note) => `Note: ${note}`));
10018
9034
  if (action === "connect" && !dryRun) lines.push("Check it with kanbo connect --check.");
@@ -10039,73 +9055,61 @@ function describeOutcomes(action, agents, outcomes, plan, dryRun, notesShown = f
10039
9055
  };
10040
9056
  }
10041
9057
  function runCheck(agents, named, flag, projectDir, options) {
10042
- const checked = agents.map((agent) => checkAgent(agent, flag, projectDir, options));
9058
+ const checked = agents.map((agent) => checkAgent(agent, flag, projectDir));
10043
9059
  const rows = checked.map(({ row }) => row);
10044
9060
  const text = [[
10045
9061
  "agent",
10046
- "instructions",
10047
9062
  "MCP",
10048
9063
  "scope"
10049
9064
  ], ...rows.map((row) => [
10050
9065
  row.agent,
10051
- row.instructions,
10052
9066
  row.mcp,
10053
9067
  row.scope
10054
9068
  ])];
10055
9069
  const widths = text[0].map((_, column) => Math.max(...text.map((line) => line[column].length)));
10056
9070
  const notes = checked.flatMap(({ notes }) => notes);
9071
+ const oldSections = [...new Set(rows.flatMap((row) => row.oldSections))];
10057
9072
  printResult({
10058
9073
  value: rows,
10059
- text: [...text.map((line) => line.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd()), ...notes.map((note) => `Note: ${note}`)].join("\n")
9074
+ text: [
9075
+ ...text.map((line) => line.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd()),
9076
+ ...oldSections.map((path) => `old kanbo section in ${path} — kanbo connect removes it`),
9077
+ ...notes.map((note) => `Note: ${note}`)
9078
+ ].join("\n")
10060
9079
  }, outputOptions(options));
10061
9080
  const missing = rows.filter((row) => !row.connected);
10062
9081
  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(" ")}.`);
10063
9082
  }
10064
9083
  /**
10065
- * Where an agent stands: its kanbo section and its MCP entry, looked for in the
10066
- * scope named — or, when none was, in the project first and then the person's
10067
- * own files. Connected means both are there (or were not asked about) and the
10068
- * MCP entry still starts. An MCP entry of the person's own is theirs: it is
10069
- * reported, left as it is, and counts as connected when it can start.
9084
+ * Where an agent stands: its MCP entry, looked for in the scope named — or,
9085
+ * when none was, in the project first and then the person's own files.
9086
+ * Connected means the entry is there and still starts. An MCP entry of the
9087
+ * person's own is theirs: it is reported, left as it is, and counts as
9088
+ * connected when it can start. Files that still carry a kanbo section from an
9089
+ * older version are listed, for `kanbo connect` to take out.
10070
9090
  */
10071
- function checkAgent(agent, flag, projectDir, options) {
9091
+ function checkAgent(agent, flag, projectDir) {
10072
9092
  const scopes = flag ? [flag] : ["project", "user"];
10073
- const found = [];
10074
9093
  const notes = [];
10075
- let instructions = "skipped";
10076
- if (options.instructions !== false) {
10077
- const states = scopes.map((scope) => ({
10078
- scope,
10079
- state: readInstructionState(agent, scope, projectDir)
10080
- }));
10081
- const present = states.find(({ state }) => state !== null && state !== "missing");
10082
- instructions = present?.state ?? (states.every(({ state }) => state === null) ? "no file" : "missing");
10083
- if (present) found.push(present.scope);
10084
- }
10085
- let mcp = "skipped";
10086
- if (options.mcp !== false) {
10087
- const states = scopes.map((scope) => ({
10088
- scope,
10089
- state: readMcpState(agent, scope, projectDir)
10090
- }));
10091
- const present = states.find(({ state }) => state === "ok") ?? states.find(({ state }) => state === "own") ?? states.find(({ state }) => state === "stale");
10092
- mcp = present ? MCP_CELLS[present.state] : "missing";
10093
- if (present?.state === "own") {
10094
- const target = AGENTS[agent].mcpTarget(present.scope, projectDir);
10095
- const startable = canStartMcpEntry(readMcpEntry(target));
10096
- mcp = startable ? "yours" : "yours, won't start";
10097
- notes.push(`${ownMcpEntryNote(AGENTS[agent].label, target.path)}${startable ? "" : " It can't start here: its command is not found."}`);
10098
- }
10099
- if (present) found.push(present.scope);
9094
+ const states = scopes.map((scope) => ({
9095
+ scope,
9096
+ state: readMcpState(agent, scope, projectDir)
9097
+ }));
9098
+ const present = states.find(({ state }) => state === "ok") ?? states.find(({ state }) => state === "own") ?? states.find(({ state }) => state === "stale");
9099
+ let mcp = present ? MCP_CELLS[present.state] : "missing";
9100
+ if (present?.state === "own") {
9101
+ const target = AGENTS[agent].mcpTarget(present.scope, projectDir);
9102
+ const startable = canStartMcpEntry(readMcpEntry(target));
9103
+ mcp = startable ? "yours" : "yours, won't start";
9104
+ notes.push(`${ownMcpEntryNote(AGENTS[agent].label, target.path)}${startable ? "" : " It can't start here: its command is not found."}`);
10100
9105
  }
10101
- const where = [...new Set(found)];
10102
9106
  return {
10103
9107
  row: {
10104
9108
  agent,
10105
- instructions,
10106
9109
  mcp,
10107
- scope: where.length > 0 ? where.join("+") : flag ?? defaultMcpScope(agent),
10108
- connected: instructions !== "missing" && instructions !== "damaged" && (mcp === "skipped" || mcp === "ok" || mcp === "yours")
9110
+ scope: present ? present.scope : flag ?? defaultMcpScope(),
9111
+ connected: mcp === "ok" || mcp === "yours",
9112
+ oldSections: scopes.flatMap((scope) => readOldSectionPaths(agent, scope, projectDir))
10109
9113
  },
10110
9114
  notes
10111
9115
  };
@@ -10369,31 +9373,10 @@ function assertOwnBoardFile(absolutePath) {
10369
9373
  function notABoardFileMessage(absolutePath) {
10370
9374
  return `${absolutePath} already holds a database of its own. Name another path with --file, or move that file out of the way.`;
10371
9375
  }
10372
- /** The plan as a person reads it before saying yes. */
10373
- function describeInitPlan(plan) {
10374
- const lines = ["kanbo init will:"];
10375
- const columns = plan.columns ? `, with the columns ${plan.columns.map((column) => column.name).join(", ")}` : "";
10376
- if (plan.boardFile) {
10377
- const what = plan.boardFile.exists ? "the board file of this project, brought up to date" : `a board file of this project's own${columns}`;
10378
- lines.push(` ${plan.boardFile.exists ? "update" : "create"} ${relativeTo(plan, plan.boardFile.path)} (${what})`);
10379
- }
10380
- if (plan.target.kind === "postgres" && plan.migrate) lines.push(` create the board's tables in ${maskDatabaseUrl(plan.target.url)}${columns}`);
10381
- lines.push(` write ${BINDING_FILE_PATH} (this project uses board ${plan.workspace.name})`);
10382
- 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"})`);
10383
- 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)})`);
10384
- if (plan.firstCard) lines.push(` add a first card to To Do: ${plan.firstCard}`);
10385
- for (const note of plan.connect?.notes ?? []) lines.push(`Note: ${note}`);
10386
- lines.push("Nothing else is changed.");
10387
- return lines.join("\n");
10388
- }
10389
- function relativeTo(plan, path) {
10390
- const inside = (0, node_path.relative)(plan.projectDir, path);
10391
- return inside.startsWith("..") ? path : inside;
10392
- }
10393
9376
  /**
10394
9377
  * Carry the plan out: the board first — the file, or the shared database's
10395
9378
  * tables — so a migration that fails leaves no binding pointing at nothing;
10396
- * then the binding; then the agent files; then the first card.
9379
+ * then the binding; then the agents' settings; then the first card.
10397
9380
  */
10398
9381
  async function applyInitPlan(plan) {
10399
9382
  let columns = [];
@@ -10407,15 +9390,10 @@ async function applyInitPlan(plan) {
10407
9390
  if (plan.target.kind === "postgres" && plan.migrate) columns = await migrateSharedBoard(plan.target.url, plan.workspace.id, plan.columns);
10408
9391
  const bindingPath = writeBinding(plan.projectDir, plan.binding);
10409
9392
  rememberProjectQuietly(plan.projectDir);
10410
- const files = plan.fileChanges.map(({ kind, change }) => ({
10411
- kind,
10412
- ...applyFileChange(change)
10413
- }));
10414
9393
  const connect = plan.connect ? applyConnectPlan(plan.connect) : [];
10415
9394
  const firstCard = plan.firstCard ? await addFirstCard(plan, plan.firstCard) : null;
10416
9395
  return {
10417
9396
  bindingPath,
10418
- files,
10419
9397
  connect,
10420
9398
  columns,
10421
9399
  firstCard
@@ -10476,7 +9454,7 @@ async function withBoardOps(target, body) {
10476
9454
  * `kanbo init --file` a no-op.
10477
9455
  *
10478
9456
  * The chosen columns go into a new board before it is moved into place, for
10479
- * `seed`'s workspace: `kanbo prime` right after `init` then has columns to show.
9457
+ * `seed`'s workspace: `kanbo_prime` right after `init` then has columns to show.
10480
9458
  * Returns the names of the columns put there.
10481
9459
  */
10482
9460
  async function createOwnBoardFile(absolutePath, seed) {
@@ -10549,7 +9527,7 @@ async function runInitWizard(context) {
10549
9527
  const bound = findBinding(context.cwd);
10550
9528
  say(ui, "kanbo — set up a board for this project");
10551
9529
  let projectDir;
10552
- const missing = findMissingBoardFile(context.cwd);
9530
+ const missing = bound ? findMissingBoardFile(context.cwd) : null;
10553
9531
  if (missing) {
10554
9532
  projectDir = bound?.projectDir ?? await askProjectDir(ui, context.cwd);
10555
9533
  await replaceMissingBoard(ui, missing, projectDir, bound, options);
@@ -10561,38 +9539,26 @@ async function runInitWizard(context) {
10561
9539
  if (namesPostgres(options)) await askBoard(context, projectDir, options, "postgres");
10562
9540
  else if (!namesBoard(options)) await askBoard(context, projectDir, options);
10563
9541
  }
10564
- let pasted = false;
10565
- if (options.connect === void 0 && options.instructions === void 0 && options.mcp === void 0) {
10566
- const agents = await askAgents(ui, context.detect(projectDir));
10567
- const choice = agents.length > 0 ? await askConnect(ui) : "not-now";
10568
- options.connect = choice === "yes" ? agents : [];
10569
- if (choice === "paste") {
10570
- pasted = true;
10571
- say(ui, `Paste this into your agent's instructions (or run kanbo instructions --copy):\n\n${INSTRUCTION_BLOCK}\n`);
9542
+ const alreadyConnected = [];
9543
+ if (options.connect === void 0) {
9544
+ const detections = context.detect(projectDir);
9545
+ const reaches = (agent) => ["project", "user"].some((scope) => readMcpState(agent, scope, projectDir) !== "missing");
9546
+ const detected = detections.filter((detection) => detection.detected);
9547
+ alreadyConnected.push(...detected.map((detection) => detection.agent).filter(reaches));
9548
+ if (detected.length > 0 && alreadyConnected.length === detected.length) {
9549
+ say(ui, "Your agents already reach kanbo through its MCP server.");
9550
+ options.connect = [];
9551
+ } else {
9552
+ const agents = await askAgents(ui, detections, reaches);
9553
+ options.connect = (agents.length > 0 ? await askConnect(ui) : "not-now") === "yes" ? agents : [];
10572
9554
  }
10573
9555
  }
10574
9556
  const plan = await context.plan(projectDir, options);
10575
- if (plan.connect && pendingItems(plan.connect).length > 0) await chooseAgentFiles(ui, plan);
10576
- if (options.firstCard === void 0 && canAddCard(plan)) {
10577
- const title = (await ui.text({
10578
- message: "Add a first card? (optional — Enter to skip)",
10579
- placeholder: "e.g. Add a README"
10580
- })).trim();
10581
- if (title) {
10582
- options.firstCard = title;
10583
- plan.firstCard = title;
10584
- }
10585
- }
10586
- say(ui, describeInitPlan(plan));
10587
- if (!await ui.confirm({
10588
- message: "Write these changes?",
10589
- initialValue: true
10590
- })) throw new CliError(1, "Nothing was written.");
10591
9557
  return {
10592
9558
  projectDir,
10593
9559
  options,
10594
9560
  plan,
10595
- pasted,
9561
+ alreadyConnected,
10596
9562
  launchWarning: context.launchWarning
10597
9563
  };
10598
9564
  }
@@ -10604,9 +9570,14 @@ function say(ui, text) {
10604
9570
  function namesPostgres(options) {
10605
9571
  return Boolean(options.databaseUrl?.trim() || !options.db?.trim() && options.file === void 0 && process.env.KANBO_DATABASE_URL?.trim());
10606
9572
  }
10607
- /** Does anything besides the wizard already say which board this is? */
9573
+ /**
9574
+ * Does anything besides the wizard already say which board this is? `--db` or
9575
+ * a connection string does. `KANBO_DB_PATH` does not: it names an app's own
9576
+ * database for the commands the app starts, and where a new board goes is
9577
+ * still the person's question (see `namesNoBoard` in `commands/init.ts`).
9578
+ */
10608
9579
  function namesBoard(options) {
10609
- return Boolean(options.db?.trim() || options.databaseUrl?.trim() || process.env.KANBO_DB_PATH?.trim() || process.env.KANBO_DATABASE_URL?.trim());
9580
+ return Boolean(options.db?.trim() || options.databaseUrl?.trim() || process.env.KANBO_DATABASE_URL?.trim());
10610
9581
  }
10611
9582
  function describeExistingBoard(bound, cwd) {
10612
9583
  const { binding } = bound;
@@ -10620,7 +9591,7 @@ function describeExistingBoard(bound, cwd) {
10620
9591
  return `${bound.projectDir === (0, node_path.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.`;
10621
9592
  }
10622
9593
  /**
10623
- * The board file this project is bound to (or `KANBO_DB_PATH` names) is gone.
9594
+ * The board file this project is bound to is gone.
10624
9595
  * The one way on from here is a new, empty board — where the old one was when
10625
9596
  * the project is bound to it — or nothing at all. `--file` already said "a new
10626
9597
  * one"; the columns are asked, and the card key stays the one it was bound to.
@@ -10804,58 +9775,30 @@ async function askCustomColumns(ui) {
10804
9775
  }
10805
9776
  return insertOwnColumns(COLUMN_CATALOGUE.filter((column) => picked.has(column.name)), own);
10806
9777
  }
10807
- /** (5) The agents this person uses; the ones found here are ticked already. */
10808
- async function askAgents(ui, detections) {
9778
+ /** (5) The agents this person uses; the ones found here that kanbo does not reach yet are ticked already. */
9779
+ async function askAgents(ui, detections, reaches) {
10809
9780
  return await ui.multiselect({
10810
9781
  message: "Which coding agents do you use here?",
10811
9782
  options: detections.map(({ agent, detected, evidence }) => ({
10812
9783
  value: agent,
10813
9784
  label: detected && evidence[0] ? `${AGENTS[agent].label} (found ${tildify(evidence[0])})` : AGENTS[agent].label
10814
9785
  })),
10815
- initialValues: detections.filter((detection) => detection.detected).map((detection) => detection.agent)
9786
+ initialValues: detections.filter((detection) => detection.detected && !reaches(detection.agent)).map((detection) => detection.agent)
10816
9787
  });
10817
9788
  }
10818
9789
  /** (6) Whether to connect them now. */
10819
9790
  async function askConnect(ui) {
10820
9791
  return await ui.select({
10821
9792
  message: "Connect them now?",
10822
- options: [
10823
- {
10824
- value: "yes",
10825
- label: "Yes — show exactly which files change, then ask"
10826
- },
10827
- {
10828
- value: "not-now",
10829
- label: "Not now — set up the board only; no other file is touched (later: kanbo connect)"
10830
- },
10831
- {
10832
- value: "paste",
10833
- label: "I'll paste the instructions myself (prints them; nothing is written)"
10834
- }
10835
- ]
9793
+ options: [{
9794
+ value: "yes",
9795
+ label: "Yes — register kanbo's MCP server for them, for every project"
9796
+ }, {
9797
+ value: "not-now",
9798
+ label: "Not now — set up the board only (later: kanbo connect)"
9799
+ }]
10836
9800
  });
10837
9801
  }
10838
- /** (7) The files connecting changes, each explained; an unticked one is left out of the plan. */
10839
- async function chooseAgentFiles(ui, plan) {
10840
- const connect = plan.connect;
10841
- const pending = pendingItems(connect);
10842
- const keys = pending.map((_, index) => `${index}`);
10843
- const kept = new Set(await ui.multiselect({
10844
- message: "These files change so your agents know the board. Keep all of them?",
10845
- options: pending.map((item, index) => ({
10846
- value: keys[index],
10847
- label: item.kind === "claude-cli" ? "Claude Code user settings" : displayPath(connect, itemPath(item)),
10848
- hint: explainItem(item)
10849
- })),
10850
- initialValues: keys
10851
- }));
10852
- connect.items = connect.items.filter((item) => !pending.includes(item) || kept.has(`${pending.indexOf(item)}`));
10853
- if (pendingItems(connect).some((item) => item.kind === "instructions")) say(ui, `The kanbo section your agents get:\n\n${INSTRUCTION_BLOCK}\n`);
10854
- }
10855
- /** A shared board whose tables were not created here may have none yet, so there is nowhere to put a card. */
10856
- function canAddCard(plan) {
10857
- return plan.target.kind !== "postgres" || plan.migrate;
10858
- }
10859
9802
  /** (9) What was done, and what to do next. */
10860
9803
  function printInitOutro(ui, result, applied) {
10861
9804
  const { plan } = result;
@@ -10879,134 +9822,29 @@ function printInitOutro(ui, result, applied) {
10879
9822
  continue;
10880
9823
  }
10881
9824
  connected.push(...outcome.agents);
10882
- lines.push(`✔ ${who}: ${where} (${outcome.state === "ran" ? `ran ${outcome.command}` : outcome.state})`);
9825
+ lines.push(`✔ ${who}: ${where} (${describeOutcomeState(outcome)})`);
10883
9826
  }
10884
- for (const file of applied.files) lines.push(`✔ ${displayPath(plan, file.path).replaceAll("\\", "/")} (${file.state})`);
10885
9827
  for (const note of plan.connect?.notes ?? []) lines.push(`Note: ${note}`);
10886
9828
  if (applied.firstCard) lines.push(`✔ First card: ${applied.firstCard.id} ${applied.firstCard.title}`);
10887
- const anyAgent = connected.length > 0 || applied.files.length > 0;
10888
- if (anyAgent && result.launchWarning) lines.push(`! ${result.launchWarning}`);
9829
+ if (connected.length > 0 && result.launchWarning) lines.push(`! ${result.launchWarning}`);
10889
9830
  lines.push("");
10890
- const first = result.options.connect?.find((agent) => connected.includes(agent));
9831
+ const first = [...connected, ...result.alreadyConnected][0];
10891
9832
  if (first) lines.push(`Next: start ${AGENTS[first].label} in this folder and say "take the next card from kanbo".`);
10892
- else if (result.pasted) lines.push("Next: paste the instructions into your agent, then say \"take the next card from kanbo\".");
10893
- else if (!anyAgent) lines.push("Next: connect your agent with kanbo connect <agent>, or paste kanbo instructions --copy into it.");
9833
+ else lines.push("Next: connect your agent once: kanbo connect claude (or codex, cursor, gemini).");
10894
9834
  lines.push("See the board: kanbo board · in your browser: kanbo serve");
10895
9835
  lines.push("Run kanbo any time to come back here.");
10896
9836
  say(ui, lines.join("\n"));
10897
9837
  }
10898
9838
  //#endregion
10899
- //#region src/cli/commands/init-global.ts
10900
- /**
10901
- * `kanbo init --global` — this person's own agent tools, told about kanbo once
10902
- * for every project.
10903
- *
10904
- * It binds no board and creates none: the block it writes says "a project with
10905
- * `.kanbo/` is on a board", and a project becomes one with a plain `kanbo init`.
10906
- * The MCP registrations start the same `kanbo mcp` every project registration
10907
- * starts, which resolves the board from the folder the client starts it in —
10908
- * on Windows spelled as this Node and this kanbo's script (`mcp-launch.ts`).
10909
- *
10910
- * It plans what `kanbo connect <agents> --global` plans (`setup/connect-plan.ts`),
10911
- * for the agents its flags name. Every change is worked out first and shown; nothing is written until the
10912
- * person says yes (or said `--yes`), and a file that already says it is
10913
- * reported `unchanged`.
10914
- */
10915
- /** Written when `--instructions` is not given. Gemini is asked for by name. */
10916
- const DEFAULT_INSTRUCTION_CLIENTS = ["claude", "codex"];
10917
- /** The flags that are about binding a project, which `--global` does not do. */
10918
- const PROJECT_ONLY_FLAGS = [
10919
- ["file", "--file"],
10920
- ["db", "--db"],
10921
- ["databaseUrl", "--database-url"],
10922
- ["workspace", "--workspace"],
10923
- ["board", "--board"],
10924
- ["identifier", "--identifier"],
10925
- ["agentUrl", "--agent-url"]
10926
- ];
10927
- async function initGlobal(options) {
10928
- const format = readFormat(options);
10929
- const given = PROJECT_ONLY_FLAGS.filter(([key]) => options[key] !== void 0).map(([, flag]) => flag);
10930
- 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.`);
10931
- const instructionClients = parseGlobalInstructions(options.instructions);
10932
- const mcpClients = options.mcp ?? [...MCP_CLIENTS];
10933
- const plan = await planConnect([...instructionClients.map((agent) => ({
10934
- agent,
10935
- instructions: "user",
10936
- mcp: null
10937
- })), ...mcpClients.map((agent) => ({
10938
- agent,
10939
- instructions: null,
10940
- mcp: "user"
10941
- }))], {
10942
- projectDir: process.cwd(),
10943
- yes: options.yes
10944
- });
10945
- for (const note of plan.notes) console.error(note);
10946
- if (pendingItems(plan).length > 0) {
10947
- if (!await confirmPlan({
10948
- 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."),
10949
- question: "Make these changes?",
10950
- yes: options.yes,
10951
- machineOutput: Boolean(options.json || format),
10952
- command: "kanbo init --global"
10953
- })) throw new CliError(1, "Nothing was changed.");
10954
- }
10955
- const outcomes = applyConnectPlan(plan);
10956
- printResult(describeResult(outcomes.filter((outcome) => outcome.kind === "instructions").map(({ agents, path, state }) => ({
10957
- client: agents[0],
10958
- path,
10959
- state
10960
- })), outcomes.filter((outcome) => outcome.kind !== "instructions").map(({ agents, path, state, command, reason, removed }) => ({
10961
- client: agents[0],
10962
- path,
10963
- state: state === "ran" ? "written" : state,
10964
- ...command ? { command } : {},
10965
- ...reason ? { reason } : {},
10966
- ...removed ? { removed } : {}
10967
- }))), options);
10968
- }
10969
- function describeResult(instructions, mcp) {
10970
- const lines = [];
10971
- for (const outcome of instructions) lines.push(`Instructions: ${outcome.path} (${outcome.state})`);
10972
- for (const outcome of mcp) if (outcome.state === "manual") lines.push(`MCP: ${describeManualOutcome(outcome.client, outcome)}`);
10973
- else lines.push(`MCP: ${outcome.path} (${outcome.state})`);
10974
- lines.push("A project uses the board once it has run kanbo init. Check everything with kanbo doctor.");
10975
- return {
10976
- value: {
10977
- scope: "global",
10978
- instructions,
10979
- mcp
10980
- },
10981
- text: lines.join("\n"),
10982
- fields: [
10983
- "scope",
10984
- "instructions",
10985
- "mcp"
10986
- ]
10987
- };
10988
- }
10989
- function parseGlobalInstructions(value) {
10990
- if (value === void 0) return DEFAULT_INSTRUCTION_CLIENTS;
10991
- const names = value.split(",").map((name) => name.trim()).filter((name) => name.length > 0);
10992
- if (names.length === 1 && names[0] === "none") return [];
10993
- return names.map((name) => {
10994
- const client = GLOBAL_INSTRUCTION_CLIENTS.find((candidate) => candidate === name);
10995
- if (!client) throw new CliError(1, `Unknown instructions target "${name}" for --global. Use one or more of ${GLOBAL_INSTRUCTION_CLIENTS.join(", ")}, or none.`);
10996
- return client;
10997
- });
10998
- }
10999
- //#endregion
11000
9839
  //#region src/cli/commands/init.ts
11001
9840
  /**
11002
- * `kanbo init` — tying a project folder to a board, and telling the agents
11003
- * that work in it what the board expects.
9841
+ * `kanbo init` — tying a project folder to a board.
11004
9842
  *
11005
- * Three things come out of it, and each is optional except the first: the
11006
- * binding, so no later command has to be told which workspace this is; a block
11007
- * in the project's agent instructions, so an agent reads the board's rules
11008
- * without being handed them; and a registration of the board's MCP server with
11009
- * whichever tools the person actually uses. Nothing is written to a file the
9843
+ * Two things come out of it, and only the first is always there: the binding,
9844
+ * so no later command has to be told which workspace this is, and — when the
9845
+ * person asks for it with `--connect` or in the wizard — the board's MCP
9846
+ * server registered with the agents they use, as `kanbo connect` does. kanbo
9847
+ * writes no instruction file into the project. Nothing is written to a file the
11010
9848
  * person did not ask for, and nothing is written twice.
11011
9849
  *
11012
9850
  * Every question comes first. The answers become a plan (`setup/init-plan.ts`)
@@ -11030,15 +9868,10 @@ const AGENT_URL_MISSING_WARNING = "kanbo: agents in this project will reach the
11030
9868
  /** What a person is told when the setup stopped at a question. */
11031
9869
  const INIT_CANCELLED_MESSAGE = "Cancelled — nothing was written.";
11032
9870
  /** What a board set up without its agents ends with. */
11033
- const CONNECT_LATER_TIP = "Agents don't know about this board yet: kanbo connect <agent>, or kanbo instructions --copy and paste it.";
9871
+ const CONNECT_LATER_TIP = "Agents can't reach this board yet: kanbo connect <agent>.";
11034
9872
  const CONNECT_VALUES = `${AGENT_IDS.join(", ")}, all or none`;
11035
9873
  function registerInitCommand(program) {
11036
- 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) => {
11037
- if (options.global) {
11038
- await initGlobal(options);
11039
- return;
11040
- }
11041
- if (options.instructions !== void 0) parseInstructions(options.instructions);
9874
+ 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) => {
11042
9875
  readFormat(options);
11043
9876
  const request = normalizeInitOptions(options);
11044
9877
  if (!request.yes && !request.json && !request.format && canPrompt()) {
@@ -11073,25 +9906,21 @@ async function initWithWizard(options) {
11073
9906
  printInitOutro(ui, answered, await applyInitPlan(answered.plan));
11074
9907
  }
11075
9908
  /**
11076
- * Agent files — instructions, MCP settings, `claude mcp` — change only on a
11077
- * yes: the wizard's, or `--yes`. Without either (an agent's shell, a script,
11078
- * machine output) the board is still set up and the agent files are left as
11079
- * they are, and the person is told how to connect them, as `kanbo connect` would.
9909
+ * Agent settings — the MCP registration, `claude mcp` — change only on a yes:
9910
+ * the wizard's, or `--yes`. Without either (an agent's shell, a script,
9911
+ * machine output) the board is still set up and the agents are left as they
9912
+ * are, and the person is told how to connect them, as `kanbo connect` would.
11080
9913
  */
11081
9914
  function withoutUnconfirmedAgentFiles(plan, options) {
11082
- const pending = plan.fileChanges.some(({ change }) => change.next !== null) || plan.connect !== null && pendingItems(plan.connect).length > 0;
11083
- if (options.yes || !pending) return plan;
11084
- const agents = options.connect?.length ? options.connect : [.../* @__PURE__ */ new Set([...options.instructions === "claude" ? ["claude"] : options.instructions === "agents" ? ["codex"] : [], ...options.mcp ?? []])];
11085
- console.error(`Not changing agent files without a yes: run kanbo connect ${agents.join(" ") || "<agent>"} --yes`);
9915
+ if (options.yes || plan.connect === null || pendingItems(plan.connect).length === 0) return plan;
9916
+ console.error(`Not connecting agents without a yes: run kanbo connect ${options.connect?.join(" ") || "<agent>"} --yes`);
11086
9917
  return {
11087
9918
  ...plan,
11088
- fileChanges: [],
11089
9919
  connect: null
11090
9920
  };
11091
9921
  }
11092
- /** The flags as `planInit` takes them: `--key` is `--identifier`, and `--connect` does not mix with the older flags. */
9922
+ /** The flags as `planInit` takes them: `--key` is `--identifier`. */
11093
9923
  function normalizeInitOptions(options) {
11094
- if (options.connect && (options.instructions !== void 0 || options.mcp)) throw new CliError(1, "Use --connect, or the older --instructions and --mcp, not both.");
11095
9924
  if (options.key === void 0) return options;
11096
9925
  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.");
11097
9926
  return {
@@ -11120,7 +9949,6 @@ async function planInit(projectDir, options) {
11120
9949
  boardFile,
11121
9950
  columns: newBoard ? options.columns ?? COLUMN_TEMPLATES.standard : null,
11122
9951
  migrate,
11123
- fileChanges: [...await planInstructions(projectDir, options), ...await planMcpServers(projectDir, options.mcp ?? [])],
11124
9952
  connect: options.connect?.length ? await planAgents(projectDir, options.connect, options) : null,
11125
9953
  firstCard: options.firstCard?.trim() || null
11126
9954
  };
@@ -11141,12 +9969,12 @@ async function describeKept(target, workspace, options, newBoard) {
11141
9969
  if (key && key !== workspace.identifier) kept.push(`Kept the existing key (${workspace.identifier}); --key names the key of a new board only`);
11142
9970
  return kept;
11143
9971
  }
11144
- /** Connect these agents the way `kanbo connect <agents>` does: the kanbo section in the project, the MCP server where each agent reads it. */
9972
+ /** 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. */
11145
9973
  async function planAgents(projectDir, agents, options) {
11146
9974
  return await planConnect(agents.map((agent) => ({
11147
9975
  agent,
11148
- instructions: "project",
11149
- mcp: defaultMcpScope(agent)
9976
+ instructions: "both",
9977
+ mcp: defaultMcpScope()
11150
9978
  })), {
11151
9979
  projectDir,
11152
9980
  yes: options.yes
@@ -11168,13 +9996,20 @@ async function resolveBoard(projectDir, options) {
11168
9996
  };
11169
9997
  }
11170
9998
  /**
11171
- * Nothing says where a board is: no flag, no environment, no binding at or
11172
- * above this folder. Then a plain `kanbo init` does what `kanbo init --file`
11173
- * does — a person on a machine with nothing else installed gets a board, not a
11174
- * refusal that names a flag.
9999
+ * Nothing says where this project's board is: no `--db`, no connection string
10000
+ * (typed or exported), no binding at or above this folder. Then a plain
10001
+ * `kanbo init` does what `kanbo init --file` does — a person on a machine with
10002
+ * nothing else installed gets a board, not a refusal that names a flag.
10003
+ *
10004
+ * `KANBO_DB_PATH` is not on that list on purpose. It is how an app that embeds
10005
+ * kanbo names its own database for the commands it starts, and a new board
10006
+ * must not land in it, or fail on it, because the shell `init` runs in happens
10007
+ * to carry that variable. Only `kanbo init --db <path>` binds a project to an
10008
+ * app's database. A Postgres connection string in the environment is the
10009
+ * person's own choice, so it still counts.
11175
10010
  */
11176
10011
  function namesNoBoard$1(projectDir, options) {
11177
- return !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DB_PATH?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(projectDir) === null;
10012
+ return !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(projectDir) === null;
11178
10013
  }
11179
10014
  /** The workspace a host database — or the board file this project is already bound to — says this project is. */
11180
10015
  async function readSessionWorkspace(options) {
@@ -11412,84 +10247,33 @@ async function openHostDatabase(options) {
11412
10247
  function warnAboutOwnerAccess(target, options) {
11413
10248
  if (target.kind === "postgres" && !options.agentUrl?.trim()) console.error(AGENT_URL_MISSING_WARNING);
11414
10249
  }
11415
- /**
11416
- * The block in the file `--instructions` named. Without the flag nothing is
11417
- * written into a project's instructions: that is `--connect`, the wizard, or
11418
- * `kanbo connect` later.
11419
- */
11420
- async function planInstructions(projectDir, options) {
11421
- const target = options.instructions === void 0 ? "none" : parseInstructions(options.instructions);
11422
- if (target === "none") return [];
11423
- const plan = await planConnect([{
11424
- agent: target === "claude" ? "claude" : "codex",
11425
- instructions: "project",
11426
- mcp: null
11427
- }], {
11428
- projectDir,
11429
- yes: options.yes
11430
- });
11431
- for (const note of plan.notes) console.error(note);
11432
- return plan.items.flatMap((item) => item.kind === "instructions" ? [{
11433
- kind: "instructions",
11434
- change: item.change
11435
- }] : []);
11436
- }
11437
- /**
11438
- * Tell the tools the person uses about the board's MCP server, in the project's
11439
- * own files — what `kanbo connect <clients> --project --no-instructions` does.
11440
- *
11441
- * Only the ones they named, and only when the tool does not already know: a
11442
- * `kanbo` entry that is already there belongs to them — it may point at
11443
- * a different binary or carry arguments of its own — and is rewritten only
11444
- * when it is kanbo's own and no longer starts.
11445
- *
11446
- * A project file is shared across machines, so it gets the portable form on every system.
11447
- */
11448
- async function planMcpServers(projectDir, clients) {
11449
- return (await planConnect(clients.map((agent) => ({
11450
- agent,
11451
- instructions: null,
11452
- mcp: "project"
11453
- })), { projectDir })).items.flatMap((item) => item.kind === "mcp" ? [{
11454
- kind: "mcp",
11455
- change: item.change
11456
- }] : []);
10250
+ /** 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. */
10251
+ function someAgentReachesKanbo(projectDir) {
10252
+ return AGENT_IDS.some((agent) => ["project", "user"].some((scope) => readMcpState(agent, scope, projectDir) !== "missing"));
11457
10253
  }
11458
10254
  function describeInit(plan, applied, options) {
11459
10255
  const { workspace, target } = plan;
11460
- const outcome = ({ path, state }) => ({
11461
- path,
11462
- state
11463
- });
11464
- const instructions = applied.files.filter((file) => file.kind === "instructions").map(outcome)[0] ?? null;
11465
- const mcp = applied.files.filter((file) => file.kind === "mcp").map(outcome);
11466
10256
  const lines = [`This project uses board ${workspace.name} — ${applied.bindingPath}`];
11467
10257
  if (target.kind === "postgres") lines.push(`Board: ${maskDatabaseUrl(target.url)}`);
11468
10258
  else if (target.owner === "kanbo") lines.push(`Board file: ${target.path}`);
11469
10259
  if (applied.columns.length > 0) lines.push(`Columns: ${applied.columns.join(", ")}`);
11470
10260
  lines.push(...plan.kept);
11471
- if (instructions) lines.push(`Instructions: ${instructions.path} (${instructions.state})`);
11472
- for (const entry of mcp) lines.push(`MCP: ${entry.path} (${entry.state})`);
11473
10261
  for (const entry of applied.connect) {
11474
10262
  const who = entry.agents.map((agent) => AGENTS[agent].label).join(", ");
11475
- lines.push(entry.state === "manual" ? describeManualOutcome(who, entry) : `${who}: ${entry.path} (${entry.state === "ran" ? `ran ${entry.command}` : entry.state})`);
10263
+ lines.push(entry.state === "manual" ? describeManualOutcome(who, entry) : `${who}: ${entry.path} (${describeOutcomeState(entry)})`);
11476
10264
  }
11477
10265
  if (applied.firstCard) lines.push(`First card: ${applied.firstCard.id} ${applied.firstCard.title}`);
11478
- const warning = mcp.length > 0 ? mcpLaunchSpec({ scope: "project" }).warning : void 0;
11479
- if (warning) lines.push(`Note: ${warning}`);
11480
10266
  for (const note of plan.connect?.notes ?? []) lines.push(`Note: ${note}`);
11481
- const connected = instructions !== null || mcp.length > 0 || applied.connect.length > 0;
10267
+ const connected = applied.connect.length > 0;
11482
10268
  const launchWarning = connected ? readLaunchWarning() : null;
11483
10269
  if (launchWarning) lines.push(`Note: ${launchWarning}`);
11484
- if (!connected) lines.push(CONNECT_LATER_TIP);
10270
+ if (!connected && !someAgentReachesKanbo(plan.projectDir)) lines.push(CONNECT_LATER_TIP);
11485
10271
  return {
11486
10272
  value: {
11487
10273
  binding: applied.bindingPath,
11488
10274
  workspaceId: workspace.id,
11489
10275
  boardId: options.board ?? null,
11490
10276
  database: describeTarget(target),
11491
- instructions,
11492
- mcp,
11493
10277
  columns: applied.columns,
11494
10278
  kept: plan.kept,
11495
10279
  connect: applied.connect,
@@ -11498,17 +10282,6 @@ function describeInit(plan, applied, options) {
11498
10282
  text: lines.join("\n")
11499
10283
  };
11500
10284
  }
11501
- function parseInstructions(value) {
11502
- if (value !== "claude" && value !== "agents" && value !== "none") throw new CliError(1, `Unknown instructions target "${value}". Use claude, agents or none.`);
11503
- return value;
11504
- }
11505
- function parseMcpClients(value) {
11506
- return value.split(",").map((name) => name.trim()).filter((name) => name.length > 0).map((name) => {
11507
- const client = MCP_CLIENTS.find((candidate) => candidate === name);
11508
- if (!client) throw new CliError(1, `Unknown MCP client "${name}". Use one or more of ${MCP_CLIENTS.join(", ")}.`);
11509
- return client;
11510
- });
11511
- }
11512
10285
  /** `--key`: a letter and two letters or digits, upper-cased. */
11513
10286
  function parseKey(value) {
11514
10287
  if (!isValidCardKey(value)) throw new CliError(1, `"${value}" can't be a card key: use a letter and two letters or digits, like MYA.`);
@@ -11542,6 +10315,132 @@ function parseConnect(value) {
11542
10315
  return [...new Set(agents)];
11543
10316
  }
11544
10317
  //#endregion
10318
+ //#region src/capabilities/markdown.ts
10319
+ /**
10320
+ * The same manifest `buildCapabilitiesManifest` returns, as Markdown a person
10321
+ * can read and an agent can paste into its own `CLAUDE.md` or `AGENTS.md`.
10322
+ *
10323
+ * Nothing here decides what the board can do — it only lays the manifest's own
10324
+ * fields out under headings. `kanbo mcp` serves it as
10325
+ * `kanbo://capabilities.md` beside the JSON.
10326
+ */
10327
+ /**
10328
+ * A table cell, safe from a pipe inside the text breaking the row it sits in —
10329
+ * the same escape the package README's own tables use for a command that
10330
+ * spells `\|` between two states.
10331
+ */
10332
+ function cell(text) {
10333
+ return text.replace(/\|/g, "\\|").replace(/\n/g, " ").trim();
10334
+ }
10335
+ function renderStorages(manifest) {
10336
+ return [
10337
+ "## Storages",
10338
+ "",
10339
+ "| Kind | Label | Description | Schema epoch |",
10340
+ "| --- | --- | --- | --- |",
10341
+ ...manifest.storages.map((storage) => `| \`${storage.kind}\` | ${cell(storage.label)} | ${cell(storage.description)} | ${storage.schemaEpoch} |`)
10342
+ ];
10343
+ }
10344
+ function renderTool(tool) {
10345
+ return `| \`${tool.name}\` | ${cell(tool.description)} |`;
10346
+ }
10347
+ function renderTools(manifest) {
10348
+ return [
10349
+ "## Tools",
10350
+ "",
10351
+ "Full JSON Schema for each tool's input is in the JSON manifest (`kanbo://capabilities`) — this table is the tools themselves.",
10352
+ "",
10353
+ "| Tool | Does |",
10354
+ "| --- | --- |",
10355
+ ...manifest.tools.map(renderTool)
10356
+ ];
10357
+ }
10358
+ function renderCommand(command) {
10359
+ const options = command.options.map((option) => `\`${option.flags}\`${option.mandatory ? " (required)" : ""}`).join(", ");
10360
+ const humanOnly = command.humanOnly ? " **Person only.**" : "";
10361
+ return `| \`kanbo ${command.path}\` | ${cell(command.description)}${humanOnly} | ${options ? cell(options) : "—"} |`;
10362
+ }
10363
+ function renderCommands(manifest) {
10364
+ return [
10365
+ "## Commands",
10366
+ "",
10367
+ "| Command | Does | Options |",
10368
+ "| --- | --- | --- |",
10369
+ ...manifest.commands.map(renderCommand)
10370
+ ];
10371
+ }
10372
+ function renderRules(manifest) {
10373
+ return [
10374
+ "## Rules",
10375
+ "",
10376
+ ...manifest.rules.map((rule) => `- ${rule}`)
10377
+ ];
10378
+ }
10379
+ function renderEntryRules(manifest) {
10380
+ return [
10381
+ "## Column entry rules",
10382
+ "",
10383
+ "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`.",
10384
+ "",
10385
+ ...manifest.entryRules.map((entry) => `- \`${entry.rule}\` — ${entry.description}`)
10386
+ ];
10387
+ }
10388
+ function renderCanonicalStrings(manifest) {
10389
+ return [
10390
+ "## Canonical strings",
10391
+ "",
10392
+ "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.",
10393
+ "",
10394
+ "| Name | Value |",
10395
+ "| --- | --- |",
10396
+ ...Object.entries(manifest.canonicalStrings).map(([name, value]) => `| ${cell(name)} | \`${cell(value)}\` |`)
10397
+ ];
10398
+ }
10399
+ function renderRoles(manifest) {
10400
+ return [
10401
+ "## Roles",
10402
+ "",
10403
+ `- Person: \`${manifest.roles.person}\``,
10404
+ `- Agent: \`${manifest.roles.agent}\``,
10405
+ "",
10406
+ "What the database itself refuses the agent role, on an external board, whichever client sends the write:",
10407
+ "",
10408
+ ...manifest.roles.refusals.map((refusal) => `- ${refusal}`)
10409
+ ];
10410
+ }
10411
+ function renderLimits(manifest) {
10412
+ return [
10413
+ "## Limits",
10414
+ "",
10415
+ ...manifest.limits.map((limit) => `- ${limit}`)
10416
+ ];
10417
+ }
10418
+ /** The manifest, laid out under one heading per field — stable and diffable, never a snapshot to update by hand. */
10419
+ function renderCapabilitiesMarkdown(manifest) {
10420
+ return [
10421
+ "# Kanbo capabilities",
10422
+ "",
10423
+ `\`${manifest.package.name}\` ${manifest.package.version} — capabilities manifest version ${manifest.version}.`,
10424
+ "",
10425
+ ...renderStorages(manifest),
10426
+ "",
10427
+ ...renderTools(manifest),
10428
+ "",
10429
+ ...renderCommands(manifest),
10430
+ "",
10431
+ ...renderRules(manifest),
10432
+ "",
10433
+ ...renderEntryRules(manifest),
10434
+ "",
10435
+ ...renderCanonicalStrings(manifest),
10436
+ "",
10437
+ ...renderRoles(manifest),
10438
+ "",
10439
+ ...renderLimits(manifest),
10440
+ ""
10441
+ ].join("\n");
10442
+ }
10443
+ //#endregion
11545
10444
  //#region src/mcp/resources.ts
11546
10445
  /** The URI a client reads the manifest as JSON from. */
11547
10446
  const KANBO_CAPABILITIES_RESOURCE_URI = "kanbo://capabilities";
@@ -11590,7 +10489,7 @@ function tool(name) {
11590
10489
  * What `kanbo mcp` tells a client when it connects — the `instructions` of the
11591
10490
  * MCP initialize result, which a client hands its model before any tool is
11592
10491
  * called. An agent with nothing but this server connected learns from it how
11593
- * to work the board, with no instruction file in the project.
10492
+ * to work the board; kanbo writes no instruction file.
11594
10493
  *
11595
10494
  * Static on purpose: it is sent before any board is resolved, so it says
11596
10495
  * nothing about columns — `kanbo_prime` returns those. The rules are the
@@ -11601,7 +10500,7 @@ const KANBO_MCP_INSTRUCTIONS = [
11601
10500
  "kanbo is a kanban board shared by people and agents; these tools read and change its cards.",
11602
10501
  "",
11603
10502
  `- At the start of a session call ${tool("kanbo_prime")} — it returns this board's columns and what each one means.`,
11604
- `- 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.`,
10503
+ `- 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")}.`,
11605
10504
  `- ${MOVE_CARD_RULE} (${tool("kanbo_card_move")})`,
11606
10505
  `- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
11607
10506
  `- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
@@ -11609,194 +10508,9 @@ const KANBO_MCP_INSTRUCTIONS = [
11609
10508
  `- A card at the wrong level is moved with ${tool("kanbo_card_update")} and \`parent\` (\`"none"\` for the top level).`,
11610
10509
  `- ${WAIT_FOR_PERSON_RULE} (${tool("kanbo_wait_approval")})`,
11611
10510
  `- ${PERSON_ONLY_RULE}`,
10511
+ "- If a tool answers that this folder has no kanbo board, leave the board alone for this session: only a person sets one up.",
11612
10512
  `- Every tool, rule and canonical string: the resource \`${KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI}\`.`
11613
10513
  ].join("\n");
11614
- /** Copy the text, trying each program this machine should have in turn. */
11615
- async function copyToClipboard(text, environment = {}) {
11616
- const env = environment.env ?? process.env;
11617
- const run = environment.run ?? runWithInput;
11618
- const stderr = environment.stderr ?? process.stderr;
11619
- for (const program of chooseClipboardPrograms(text, environment)) if (await run(program.command, program.args, program.input, {
11620
- timeoutMs: 3e3,
11621
- detached: program.detached
11622
- })) return "copied";
11623
- if ((env.SSH_TTY || env.SSH_CONNECTION) && stderr.isTTY === true) {
11624
- stderr.write(osc52(text));
11625
- return "osc52";
11626
- }
11627
- return "unavailable";
11628
- }
11629
- /** The programs to try here, in order; empty when there is nothing to try. */
11630
- function chooseClipboardPrograms(text, environment) {
11631
- const platform = environment.platform ?? process.platform;
11632
- const env = environment.env ?? process.env;
11633
- const readFile = environment.readFile ?? readTextOrNull$1;
11634
- const utf8 = Buffer.from(text, "utf8");
11635
- if (platform === "darwin") return [{
11636
- command: "pbcopy",
11637
- args: [],
11638
- input: utf8
11639
- }];
11640
- if (platform === "win32" || platform === "linux" && /microsoft/i.test(readFile("/proc/version") ?? "")) return [{
11641
- command: "clip.exe",
11642
- args: [],
11643
- input: utf16leWithBom(text)
11644
- }];
11645
- const programs = [];
11646
- if (env.WAYLAND_DISPLAY) programs.push({
11647
- command: "wl-copy",
11648
- args: [],
11649
- input: utf8
11650
- });
11651
- if (env.DISPLAY) programs.push({
11652
- command: "xclip",
11653
- args: ["-selection", "clipboard"],
11654
- input: utf8,
11655
- detached: true
11656
- }, {
11657
- command: "xsel",
11658
- args: ["--clipboard", "--input"],
11659
- input: utf8
11660
- });
11661
- return programs;
11662
- }
11663
- /** The text as `clip.exe` reads it without guessing: UTF-16LE after a byte-order mark. */
11664
- function utf16leWithBom(text) {
11665
- return Buffer.concat([Buffer.from([255, 254]), Buffer.from(text, "utf16le")]);
11666
- }
11667
- /** The OSC 52 "set the clipboard" escape for this text. */
11668
- function osc52(text) {
11669
- return `\u001B]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`;
11670
- }
11671
- function readTextOrNull$1(path) {
11672
- try {
11673
- return (0, node_fs.readFileSync)(path, "utf8");
11674
- } catch {
11675
- return null;
11676
- }
11677
- }
11678
- //#endregion
11679
- //#region src/cli/setup/orchestrator-guide.ts
11680
- /**
11681
- * The instruction file for an orchestrator: an agent that pulls ready cards,
11682
- * splits them when it should, hands subtasks to sub-agents by card key and
11683
- * waits for a person's approval. `kanbo instructions orchestrator` prints it,
11684
- * and `docs/orchestrator.md` shows it word for word — a test holds the two
11685
- * together, so the page never drifts from what the command hands out.
11686
- */
11687
- const ORCHESTRATOR_GUIDE = [
11688
- "# Orchestrator",
11689
- "",
11690
- "You coordinate work on this project's kanbo board. You do not approve work; a person does.",
11691
- "",
11692
- "## Start of every session",
11693
- "1. Run `kanbo prime` (tool: `kanbo_prime`) and read the column descriptions. On this board the description is the contract, not the name.",
11694
- "2. Run `kanbo ready` (`kanbo_ready`). If nothing is ready, say so and stop.",
11695
- "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.",
11696
- "",
11697
- "## While working",
11698
- "- 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.",
11699
- "- Put decisions, numbers and open questions on the card as comments (`kanbo_card_comment`). Your transcript is not the card.",
11700
- "",
11701
- "## Splitting",
11702
- "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.",
11703
- "- A subtask is `kanbo card create --parent <card> --column to_do --title \"…\" --description \"…\"` (`kanbo_card_create` with `parent`).",
11704
- "- 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.",
11705
- "",
11706
- "## Delegating",
11707
- "- 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.",
11708
- "- 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.",
11709
- "- Link every pull request to the card it belongs to (`kanbo card pr add <card> <url>`, `kanbo_card_link_pr`).",
11710
- "",
11711
- "## Handing back",
11712
- "1. When the card's work is complete, move it to the column this board uses for review (see `kanbo prime`).",
11713
- "2. Run `kanbo card wait-approval <card> --text \"<what the person should look at>\"` (`kanbo_wait_approval`).",
11714
- "3. Finish your run: `kanbo run finish <runId> --state finished` (`kanbo_run_finish`).",
11715
- "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.",
11716
- "",
11717
- "## When the card comes back",
11718
- "- Approved: move it to the board's finished column (see `kanbo prime`).",
11719
- "- Returned: the reason is the newest comment. The card is back in an earlier column; start a new run and address the reason first.",
11720
- "",
11721
- "## Never",
11722
- "- 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.",
11723
- "- Never delete or edit the board's database directly."
11724
- ].join("\n");
11725
- //#endregion
11726
- //#region src/cli/commands/instructions.ts
11727
- /**
11728
- * `kanbo instructions` — the text to give an agent, without a trip to GitHub.
11729
- *
11730
- * The text goes to stdout and nothing else does, so `kanbo instructions >>
11731
- * AGENTS.md` writes exactly the text. The two lines around it that say what to
11732
- * do with it go to stderr, and only when stderr is a terminal a person reads.
11733
- */
11734
- /** The versions of the text, as typed after `kanbo instructions`. */
11735
- const INSTRUCTION_KINDS = [
11736
- "agent",
11737
- "short",
11738
- "global",
11739
- "orchestrator",
11740
- "mcp",
11741
- "board"
11742
- ];
11743
- /** What `board` says in a folder with no board. */
11744
- 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.";
11745
- const HEADER = "Paste this into CLAUDE.md or AGENTS.md — or run `kanbo connect <agent>` to have kanbo do it.";
11746
- const FOOTER = "Copy it: kanbo instructions --copy · Other versions: short | global | orchestrator | mcp | board";
11747
- /**
11748
- * The text of each version that needs no board. A function, not a table: the
11749
- * MCP instructions sit on an import cycle back to this command list (through
11750
- * the capabilities manifest), and a table built at load time would hold
11751
- * `undefined` for them.
11752
- */
11753
- function staticText(kind) {
11754
- switch (kind) {
11755
- case "agent": return AGENT_GUIDE_TEXT;
11756
- case "short": return PROJECT_BODY;
11757
- case "global": return GLOBAL_BODY;
11758
- case "orchestrator": return ORCHESTRATOR_GUIDE;
11759
- case "mcp": return KANBO_MCP_INSTRUCTIONS;
11760
- }
11761
- }
11762
- function registerInstructionsCommand(program, dependencies = {}) {
11763
- 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) => {
11764
- const found = parseKind(kind);
11765
- const body = await readInstructionText(found);
11766
- const pointer = found === "short" || found === "global";
11767
- const text = options.markers ? wrapInstructionBlock(body, 3, pointer ? void 0 : found) : body;
11768
- const hints = process.stderr.isTTY === true;
11769
- if (hints) process.stderr.write(`${HEADER}\n\n`);
11770
- console.log(text);
11771
- if (hints) process.stderr.write(`\n${FOOTER}\n`);
11772
- if (options.copy) process.stderr.write(`${describeCopy(await copyToClipboard(text, dependencies.clipboard))}\n`);
11773
- });
11774
- }
11775
- function parseKind(kind) {
11776
- const found = INSTRUCTION_KINDS.find((candidate) => candidate === kind);
11777
- if (!found) throw new CliError(1, `Unknown version "${kind}". Use one of: ${INSTRUCTION_KINDS.join(", ")}.`);
11778
- return found;
11779
- }
11780
- /** The text of one version; `board` reads this folder's board, as `kanbo prime` prints it. */
11781
- async function readInstructionText(kind) {
11782
- if (kind !== "board") return staticText(kind);
11783
- const session = await openBoardSession({}, "read").catch((error) => {
11784
- if (error instanceof BoardNotFoundError) throw new CliError(2, NO_BOARD_FOR_INSTRUCTIONS_MESSAGE);
11785
- throw error;
11786
- });
11787
- try {
11788
- return `${await session.ops.buildPrimeText(session.workspace.id)}\n\n${COMMAND_SHEET}`;
11789
- } finally {
11790
- await session.close();
11791
- }
11792
- }
11793
- function describeCopy(outcome) {
11794
- switch (outcome) {
11795
- case "copied": return "Copied to the clipboard.";
11796
- case "osc52": return "Sent to your terminal's clipboard (OSC 52).";
11797
- case "unavailable": return "Couldn't reach a clipboard — select the text above.";
11798
- }
11799
- }
11800
10514
  //#endregion
11801
10515
  //#region src/mcp/tools/tool.ts
11802
10516
  /** What every tool that takes `card` also takes it as. */
@@ -12024,66 +10738,247 @@ function compactKanboCard(card) {
12024
10738
  ...card.lastComment ? { lastComment: card.lastComment } : {}
12025
10739
  };
12026
10740
  }
12027
- /** The `detail` argument of the tools that write a card: a list's own word, for the one card a write answers with. */
12028
- const writtenCardDetailArgument = zod.z.enum(["compact", "full"]).optional().describe("compact (default): the card as kanbo_card_list prints it. full: every field, description included.");
12029
- /** What each tool that writes a card says about its answer, in its description. */
12030
- const WRITTEN_CARD_ANSWER_SENTENCE = "Answers the card compact, as kanbo_card_list prints it; detail: \"full\" for every field.";
10741
+ /** The `detail` argument of the tools that write a card: a list's own word, for the one card a write answers with. */
10742
+ const writtenCardDetailArgument = zod.z.enum(["compact", "full"]).optional().describe("compact (default): the card as kanbo_card_list prints it. full: every field, description included.");
10743
+ /** What each tool that writes a card says about its answer, in its description. */
10744
+ const WRITTEN_CARD_ANSWER_SENTENCE = "Answers the card compact, as kanbo_card_list prints it; detail: \"full\" for every field.";
10745
+ /**
10746
+ * The card a write hands back, as the tool answers with it: compact and on one
10747
+ * line, as a list prints it. An agent writes its card at every step — a status
10748
+ * line, a move, an update — and reads each answer again on every later turn,
10749
+ * so a whole card, description included, would cost it again and again.
10750
+ * `detail: "full"` answers every field, as `kanbo_card_get` prints the card alone.
10751
+ */
10752
+ function formatWrittenKanboCard(card, detail) {
10753
+ return detail === "full" ? JSON.stringify(card, null, 2) : JSON.stringify(compactKanboCard(card));
10754
+ }
10755
+ function viewCard(card, view) {
10756
+ if (view.fields) return Object.fromEntries([["id", card.id], ...view.fields.map((field) => [field, card[field]])]);
10757
+ return view.detail === "full" ? card : compactKanboCard(card);
10758
+ }
10759
+ /**
10760
+ * The page as the tool's answer: how many cards the question picks, where the
10761
+ * next page starts when there is one, and one card per line.
10762
+ */
10763
+ function formatKanboCardPage(page, view, returned) {
10764
+ const shownUntil = page.offset + page.cards.length;
10765
+ const more = Math.max(0, page.total - shownUntil);
10766
+ const head = {
10767
+ total: page.total,
10768
+ ...page.offset > 0 ? { offset: page.offset } : {},
10769
+ ...more > 0 ? {
10770
+ more,
10771
+ nextOffset: shownUntil
10772
+ } : {}
10773
+ };
10774
+ const cards = page.cards.map((card) => JSON.stringify(viewCard(card, view)));
10775
+ 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)}`;
10776
+ return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
10777
+ }
10778
+ //#endregion
10779
+ //#region src/mcp/tools/card-create.ts
10780
+ /** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
10781
+ const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pass `parent` = that card's id (its key, e.g. `MAN-012`).";
10782
+ /** A new card, or a sub-card of the one being worked on. */
10783
+ const cardCreateTool = defineKanboTool({
10784
+ name: "kanbo_card_create",
10785
+ title: "Create a card",
10786
+ 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.`,
10787
+ inputSchema: {
10788
+ title: zod.z.string().optional().describe("What the card is called. Leave it out and the card is titled with its own key; the description then says what the task is."),
10789
+ description: zod.z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
10790
+ column: zod.z.string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
10791
+ parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
10792
+ executionMode: executionModeArgument.optional(),
10793
+ detail: writtenCardDetailArgument
10794
+ },
10795
+ example: {
10796
+ description: "Add slugify(text) with a test",
10797
+ parent: "TST-1"
10798
+ },
10799
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardCreate(input), detail)
10800
+ });
10801
+ //#endregion
10802
+ //#region src/mcp/transport.ts
10803
+ /** What `kanbo_card_get` may bring along with the card. */
10804
+ const KANBO_CARD_INCLUDES = [
10805
+ "comments",
10806
+ "subCards",
10807
+ "runs",
10808
+ "history",
10809
+ "prs"
10810
+ ];
10811
+ /** What a card read brings along when the caller does not say: its latest comments and its sub-cards. */
10812
+ const DEFAULT_KANBO_CARD_INCLUDES = ["comments", "subCards"];
10813
+ /** How many of its latest comments a card read brings along when the caller does not say. */
10814
+ const DEFAULT_KANBO_COMMENT_LIMIT = 10;
10815
+ /**
10816
+ * The card a tool prints, from the card a transport holds.
10817
+ *
10818
+ * Both transports project here so the JSON an agent reads is the same whether
10819
+ * it came from the server or from the board file — including the column, which
10820
+ * neither of them carries on the card itself: the row keeps a foreign key, and
10821
+ * an agent moving a card needs the slug.
10822
+ *
10823
+ * Every field is named rather than spread. A card the board file answers with
10824
+ * carries more than this — the agent and session ids behind a run among them —
10825
+ * and passing an object through by reference would print whatever it happened
10826
+ * to hold, which is both a different shape from the server's answer and host
10827
+ * facts the agent was never meant to see.
10828
+ */
10829
+ function projectKanboCard(card, columns) {
10830
+ const column = columns.find((candidate) => candidate.id === card.statusId) ?? null;
10831
+ return {
10832
+ id: card.id,
10833
+ number: card.number,
10834
+ title: card.title,
10835
+ description: card.description,
10836
+ column: column?.name ?? null,
10837
+ columnSlug: column?.slug ?? null,
10838
+ statusLine: card.statusLine,
10839
+ waitingFor: card.waitingFor,
10840
+ priority: card.priority,
10841
+ labels: card.labels,
10842
+ executionMode: card.executionMode,
10843
+ parentIssueId: card.parentIssueId,
10844
+ attemptCount: card.attemptCount,
10845
+ activeRun: projectKanboActiveRun(card.activeRun),
10846
+ updatedAt: card.updatedAt,
10847
+ ...card.attention?.returned ? { returned: true } : {},
10848
+ ...card.attention?.lastComment ? { lastComment: card.attention.lastComment } : {}
10849
+ };
10850
+ }
10851
+ /** A sub-card, as the card above it lists it: enough to name it and read it next. */
10852
+ function projectKanboSubCard(card, columns) {
10853
+ return {
10854
+ id: card.id,
10855
+ title: card.title,
10856
+ columnSlug: columns.find((candidate) => candidate.id === card.statusId)?.slug ?? null
10857
+ };
10858
+ }
10859
+ /** The run going on right now, as much of it as the agent working the card needs. */
10860
+ function projectKanboActiveRun(run) {
10861
+ return run === null ? null : {
10862
+ id: run.id,
10863
+ agentName: run.agentName,
10864
+ state: run.state,
10865
+ executionMode: run.executionMode,
10866
+ branch: run.branch,
10867
+ startedAt: run.startedAt
10868
+ };
10869
+ }
10870
+ /**
10871
+ * One launch of a card, from the run row either transport holds.
10872
+ *
10873
+ * What the row carries beyond this — the worktree path, the chat session
10874
+ * driving it, who launched it — belongs to the host that launched the run, not
10875
+ * to the agent that asked for one.
10876
+ */
10877
+ function projectKanboRun(run) {
10878
+ return {
10879
+ id: run.id,
10880
+ issueId: run.issueId,
10881
+ agentName: run.agentName,
10882
+ state: run.state,
10883
+ executionMode: run.executionMode,
10884
+ branch: run.branch,
10885
+ startedAt: run.startedAt,
10886
+ endedAt: run.endedAt,
10887
+ externalSessionRef: run.externalSessionRef ?? null
10888
+ };
10889
+ }
10890
+ /**
10891
+ * A pull-request link, from the row either transport holds — without who made
10892
+ * it, which is the board's record rather than anything the agent acts on.
10893
+ */
10894
+ function projectKanboPullRequest(link) {
10895
+ return {
10896
+ id: link.id,
10897
+ issueId: link.issueId,
10898
+ owner: link.owner,
10899
+ repo: link.repo,
10900
+ number: link.number,
10901
+ url: link.url,
10902
+ createdAt: link.createdAt
10903
+ };
10904
+ }
10905
+ /** A sprint, from the one `listSprints` answers with on either end — the description and timestamps left behind. */
10906
+ function projectKanboSprint(sprint) {
10907
+ return {
10908
+ id: sprint.id,
10909
+ title: sprint.title,
10910
+ status: sprint.status,
10911
+ startDate: sprint.startDate,
10912
+ dueDate: sprint.dueDate,
10913
+ current: sprint.current,
10914
+ openCards: sprint.cards.open,
10915
+ doneCards: sprint.cards.done
10916
+ };
10917
+ }
10918
+ /** A column of the board, from the row either transport holds; `board` is every column, to name the ones it accepts cards from. */
10919
+ function projectKanboColumn(column, board) {
10920
+ const stored = column.entryRules;
10921
+ const allowedIds = typeof column.allowedFrom === "string" ? readFlowRules({
10922
+ allowedFrom: column.allowedFrom,
10923
+ wipLimit: null
10924
+ }).allowedFrom : column.allowedFrom ?? null;
10925
+ return {
10926
+ id: column.id,
10927
+ name: column.name,
10928
+ slug: normalizeStatusName(column.name),
10929
+ description: column.description,
10930
+ order: column.order,
10931
+ entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null }),
10932
+ allowedFrom: allowedIds === null ? null : board.filter((other) => allowedIds.includes(other.id)).map((other) => normalizeStatusName(other.name)),
10933
+ wipLimit: column.wipLimit ?? null
10934
+ };
10935
+ }
12031
10936
  /**
12032
- * The card a write hands back, as the tool answers with it: compact and on one
12033
- * line, as a list prints it. An agent writes its card at every step — a status
12034
- * line, a move, an update — and reads each answer again on every later turn,
12035
- * so a whole card, description included, would cost it again and again.
12036
- * `detail: "full"` answers every field, as `kanbo_card_get` prints the card alone.
10937
+ * Does the card answer the query? The filters of `KanboCardQuery`, applied to
10938
+ * a card already read — for a transport whose server filters on less than the
10939
+ * query names. `statusIds` are the query's columns, already resolved.
12037
10940
  */
12038
- function formatWrittenKanboCard(card, detail) {
12039
- return detail === "full" ? JSON.stringify(card, null, 2) : JSON.stringify(compactKanboCard(card));
10941
+ function matchesKanboCardQuery(card, query, statusIds) {
10942
+ const text = query.text === void 0 ? void 0 : require_migrate.foldCase(query.text);
10943
+ 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 || [
10944
+ card.id,
10945
+ card.title,
10946
+ card.description ?? ""
10947
+ ].some((value) => require_migrate.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));
12040
10948
  }
12041
- function viewCard(card, view) {
12042
- if (view.fields) return Object.fromEntries([["id", card.id], ...view.fields.map((field) => [field, card[field]])]);
12043
- return view.detail === "full" ? card : compactKanboCard(card);
10949
+ /** A page cut out of cards already picked, in the order they were picked. */
10950
+ function pageOf(cards, input) {
10951
+ const offset = input.offset ?? 0;
10952
+ return {
10953
+ cards: cards.slice(offset, input.limit === void 0 ? void 0 : offset + input.limit),
10954
+ total: cards.length,
10955
+ offset
10956
+ };
12044
10957
  }
12045
- /**
12046
- * The page as the tool's answer: how many cards the question picks, where the
12047
- * next page starts when there is one, and one card per line.
12048
- */
12049
- function formatKanboCardPage(page, view, returned) {
12050
- const shownUntil = page.offset + page.cards.length;
12051
- const more = Math.max(0, page.total - shownUntil);
12052
- const head = {
12053
- total: page.total,
12054
- ...page.offset > 0 ? { offset: page.offset } : {},
12055
- ...more > 0 ? {
12056
- more,
12057
- nextOffset: shownUntil
12058
- } : {}
10958
+ /** A comment as a card read lists it, from the row either transport holds. */
10959
+ function projectKanboCardComment(comment) {
10960
+ const decision = comment.authorKind === "system.approved" || comment.authorKind === "system.returned";
10961
+ const author = decision ? "user" : normalizeCommentAuthorKind(comment.authorKind);
10962
+ const named = author !== "system" || decision;
10963
+ const outside = author === "system" && isOutsideWriter(comment) && comment.authorKind === "system";
10964
+ return {
10965
+ id: comment.id,
10966
+ author,
10967
+ by: named || outside ? comment.authorId ?? author : "kanbo",
10968
+ content: comment.content,
10969
+ createdAt: comment.createdAt
10970
+ };
10971
+ }
10972
+ /** A field change as a card read lists it — who by kind only: the id behind it is the host's. */
10973
+ function projectKanboFieldChange(change) {
10974
+ return {
10975
+ field: change.field,
10976
+ from: change.fromValue,
10977
+ to: change.toValue,
10978
+ actor: change.actorKind,
10979
+ createdAt: change.createdAt
12059
10980
  };
12060
- const cards = page.cards.map((card) => JSON.stringify(viewCard(card, view)));
12061
- 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)}`;
12062
- return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
12063
10981
  }
12064
- //#endregion
12065
- //#region src/mcp/tools/card-create.ts
12066
- /** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
12067
- const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pass `parent` = that card's id (its key, e.g. `MAN-012`).";
12068
- /** A new card, or a sub-card of the one being worked on. */
12069
- const cardCreateTool = defineKanboTool({
12070
- name: "kanbo_card_create",
12071
- title: "Create a card",
12072
- 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.`,
12073
- inputSchema: {
12074
- title: zod.z.string().optional().describe("What the card is called. Leave it out and the card is titled with its own key; the description then says what the task is."),
12075
- description: zod.z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
12076
- column: zod.z.string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
12077
- parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
12078
- executionMode: executionModeArgument.optional(),
12079
- detail: writtenCardDetailArgument
12080
- },
12081
- example: {
12082
- description: "Add slugify(text) with a test",
12083
- parent: "TST-1"
12084
- },
12085
- run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardCreate(input), detail)
12086
- });
12087
10982
  //#endregion
12088
10983
  //#region src/mcp/tools/card-get.ts
12089
10984
  /** One card in full, with what an agent reads next to it. */
@@ -12727,16 +11622,20 @@ async function createDbTransport(input) {
12727
11622
  * The board `kanbo mcp` serves — or, in a folder with none yet, a stand-in
12728
11623
  * that finds it later.
12729
11624
  *
12730
- * A person who registers kanbo in their own agent settings has it started in
12731
- * every folder they open, and most of those have no board. A server that
12732
- * refused to start there would show up in the agent as a broken connection. So
12733
- * it starts anyway: the client connects and reads the instructions, and every
12734
- * tool answers that there is no board here. Each call looks again, so a board
12735
- * a person creates in this folder while the agent is running is picked up on
12736
- * the next call, and held from then on.
12737
- *
12738
- * Any other failure to open the board — a file that is not a board, a database
12739
- * that does not answer — is not "no board" and still stops the server at once.
11625
+ * The server is registered once, in the person's own agent settings, so every
11626
+ * project they open starts it — and most of those folders cannot give it a
11627
+ * board: there is none yet, the database `KANBO_DB_PATH` names is older than
11628
+ * this kanbo, or it knows no project for the folder. A server that refused to
11629
+ * start there would show up in the agent's client as a broken connection. So
11630
+ * it starts anyway: the client connects and reads the instructions, and each
11631
+ * tool says why there is no board here. Each call looks again, so a board a
11632
+ * person creates in this folder while the agent is running is picked up on the
11633
+ * next call, and held from then on.
11634
+ *
11635
+ * "No board" answers with one fixed message; the two other reasons answer with
11636
+ * the error's own message, unchanged. Any other failure to open the board — a
11637
+ * file that is not a board, a database that does not answer — still stops the
11638
+ * server at once.
12740
11639
  */
12741
11640
  /** What every tool answers in a folder with no board. */
12742
11641
  const NO_BOARD_FOR_AGENT_MESSAGE = DATABASE_NOT_FOUND_AGENT_MESSAGE;
@@ -12780,7 +11679,7 @@ function createDeferredBoard(input) {
12780
11679
  return opened;
12781
11680
  }, (error) => {
12782
11681
  opening = null;
12783
- throw isNoBoard(error) ? new CliError(2, NO_BOARD_FOR_AGENT_MESSAGE) : error;
11682
+ throw error instanceof BoardNotFoundError ? new CliError(2, NO_BOARD_FOR_AGENT_MESSAGE) : error;
12784
11683
  });
12785
11684
  return await opening;
12786
11685
  };
@@ -12794,9 +11693,13 @@ function createDeferredBoard(input) {
12794
11693
  }
12795
11694
  };
12796
11695
  }
12797
- /** The failure that means nothing names a board here — not a board that failed to open. */
11696
+ /**
11697
+ * The failures that mean this folder has no usable board for now: nothing
11698
+ * names one, the host database is older than this kanbo, or no project of it
11699
+ * matches the folder. Not a board that failed to open.
11700
+ */
12798
11701
  function isNoBoard(error) {
12799
- return error instanceof BoardNotFoundError;
11702
+ return error instanceof BoardNotFoundError || error instanceof CliError && (error.exitCode === 3 || error.exitCode === 2);
12800
11703
  }
12801
11704
  /**
12802
11705
  * The same board, with `kanbo_prime` ending in a line about a newer kanbo once
@@ -12931,84 +11834,6 @@ async function migrateOwnFile(target, options) {
12931
11834
  }, options);
12932
11835
  }
12933
11836
  //#endregion
12934
- //#region src/cli/commands/prime.ts
12935
- /**
12936
- * `kanbo prime` — what the board tells an agent about itself.
12937
- *
12938
- * The first thing an agent should run, and the reason the instruction block
12939
- * `kanbo init` writes says so: the columns of this board with their meaning,
12940
- * and the rules a card travels by. The text belongs to the board, not to this
12941
- * tool, so the command prints what `buildPrimeText` says — and then the
12942
- * commands, because an agent reading this from a shell may have no MCP tools
12943
- * and nothing else to learn them from. The `kanbo_prime` tool leaves them out.
12944
- */
12945
- function registerPrimeCommand(program) {
12946
- withBoardOptions(program.command("prime").description("Print the board's columns and rules for an agent to read first")).action(async (options) => {
12947
- await runBoardCommand(options, "read", async (session) => {
12948
- const text = `${await session.ops.buildPrimeText(session.workspace.id)}\n\n${COMMAND_SHEET}`;
12949
- return {
12950
- value: { text },
12951
- text
12952
- };
12953
- });
12954
- });
12955
- }
12956
- //#endregion
12957
- //#region src/cli/commands/ready.ts
12958
- /** How many cards `kanbo ready` prints when the caller names no limit. */
12959
- const DEFAULT_READY_LIMIT = 10;
12960
- /** How many returned cards `kanbo ready` prints ahead of the ready ones, at most. */
12961
- const READY_RETURNED_LIMIT = 5;
12962
- /**
12963
- * The ready cards, and ahead of them the cards a person sent back — an agent
12964
- * continues those before it takes anything new. `--json` stays the ready
12965
- * cards alone; `kanbo card list --returned --json` lists the others.
12966
- */
12967
- async function describeReady(session, cards, returned) {
12968
- if (returned.cards.length === 0) return describeCards(cards);
12969
- const runs = await session.ops.readBoardProjectionForIssues(returned.cards.map((card) => card.id));
12970
- const attention = await session.ops.readCardAttention(returned.cards, runs.map(() => false));
12971
- const views = withCardAttention(returned.cards.map((card, index) => projectCard(card, returned.columns, runs[index])), attention);
12972
- const more = returned.total - views.length;
12973
- return [
12974
- "Returned to you — continue these first, then hand them back to a person:",
12975
- describeCards(views),
12976
- ...more > 0 ? [`… and ${more} more: kanbo card list --returned`] : [],
12977
- "",
12978
- "Ready:",
12979
- describeCards(cards)
12980
- ].join("\n");
12981
- }
12982
- /**
12983
- * `kanbo ready` — the cards an agent may pick up right now.
12984
- *
12985
- * What counts as ready is the board's rule, not this tool's: spelled out,
12986
- * nobody working on it, nobody's turn but the agent's. An agent asking for work
12987
- * takes the first line and starts there.
12988
- */
12989
- function registerReadyCommand(program) {
12990
- 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) => {
12991
- await runBoardCommand(options, "read", async (session) => {
12992
- const page = await session.ops.queryReady({
12993
- workspaceId: session.workspace.id,
12994
- limit: options.all ? void 0 : options.limit ?? DEFAULT_READY_LIMIT
12995
- });
12996
- const [runs, returned] = await Promise.all([session.ops.readBoardProjectionForIssues(page.cards.map((card) => card.id)), session.ops.queryCards({
12997
- workspaceId: session.workspace.id,
12998
- returned: true,
12999
- limit: READY_RETURNED_LIMIT
13000
- })]);
13001
- const cards = page.cards.map((card, index) => projectCard(card, page.columns, runs[index]));
13002
- if (page.total > cards.length) console.error(`kanbo: ${page.total - cards.length} more ready; --limit <count> or --all for more`);
13003
- return {
13004
- value: cards,
13005
- text: await describeReady(session, cards, returned),
13006
- fields: CARD_VIEW_FIELDS
13007
- };
13008
- });
13009
- });
13010
- }
13011
- //#endregion
13012
11837
  //#region src/cli/commands/return.ts
13013
11838
  /**
13014
11839
  * `kanbo return` — a person sending a card back with a reason.
@@ -13115,104 +11940,6 @@ function describeApplied(database) {
13115
11940
  ].join("\n");
13116
11941
  }
13117
11942
  //#endregion
13118
- //#region src/cli/commands/run.ts
13119
- /**
13120
- * `kanbo run …` — saying that a card is being worked on, and how that ended.
13121
- *
13122
- * A run launched from a terminal is recorded exactly like one an app launched:
13123
- * the card gets a line saying who is on it and a comment naming the attempt, so
13124
- * `kanbo ready` stops offering the card to the next agent that asks. Its
13125
- * launch is filed as `external` — no app started this, and a board that
13126
- * claimed otherwise would send someone looking for a session that never existed.
13127
- */
13128
- function registerRunCommands(program) {
13129
- const run = program.command("run").description("Record when an agent starts and finishes work on a card");
13130
- 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) => {
13131
- await runBoardCommand(options, "write", async (session) => {
13132
- const card = await requireCard(session, reference);
13133
- const started = await session.ops.startRun(card.id, {
13134
- agentName: options.agent,
13135
- branch: options.branch,
13136
- executionMode: options.executionMode,
13137
- externalSessionRef: options.session ?? sessionRefFromEnvironment() ?? void 0,
13138
- launchedByKind: "external"
13139
- }, createCliActor());
13140
- const view = projectRun({
13141
- ...started,
13142
- attempt: await attemptFor(session, started)
13143
- });
13144
- return {
13145
- value: view,
13146
- text: `Run ${view.id} started on ${card.id}\nWhen you are done: kanbo run finish ${view.id} --state finished`,
13147
- fields: RUN_VIEW_FIELDS
13148
- };
13149
- });
13150
- });
13151
- 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) => {
13152
- const actor = options.replace ? requireHumanActor("run attach-session --replace", typedWords(command)) : null;
13153
- await runBoardCommand(options, "write", async (session) => {
13154
- const found = await requireRun(session, runId);
13155
- const attached = actor ? await session.ops.clearRunSessionRef(found.id, actor, { replaceWith: ref }) : await session.ops.recordRunSessionRef(found.id, ref);
13156
- const view = projectRun({
13157
- ...attached,
13158
- attempt: await attemptFor(session, attached)
13159
- });
13160
- return {
13161
- value: view,
13162
- text: `Run ${view.id} carries ${attached.externalSessionRef}`,
13163
- fields: RUN_VIEW_FIELDS
13164
- };
13165
- });
13166
- });
13167
- 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) => {
13168
- const actor = requireHumanActor("run clear-session", typedWords(command));
13169
- await runBoardCommand(options, "write", async (session) => {
13170
- const found = await requireRun(session, runId);
13171
- const cleared = await session.ops.clearRunSessionRef(found.id, actor);
13172
- const view = projectRun({
13173
- ...cleared,
13174
- attempt: await attemptFor(session, cleared)
13175
- });
13176
- return {
13177
- value: view,
13178
- text: `Run ${view.id} names no log`,
13179
- fields: RUN_VIEW_FIELDS
13180
- };
13181
- });
13182
- });
13183
- 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) => {
13184
- await runBoardCommand(options, "write", async (session) => {
13185
- const run = await requireRun(session, runId);
13186
- const finished = await session.ops.finishRun(run.id, {
13187
- state: options.state,
13188
- errorText: options.errorText
13189
- }, createCliActor());
13190
- const view = projectRun({
13191
- ...finished,
13192
- attempt: await attemptFor(session, finished)
13193
- });
13194
- return {
13195
- value: view,
13196
- text: `Run ${view.id} ${view.state}`,
13197
- fields: RUN_VIEW_FIELDS
13198
- };
13199
- });
13200
- });
13201
- }
13202
- /**
13203
- * Which attempt a run is. `startRun` and `finishRun` hand back the row
13204
- * itself; the count only exists once every run of the card is lined up.
13205
- */
13206
- async function attemptFor(session, run) {
13207
- const runs = await session.ops.listRuns(run.issueId);
13208
- return runs.find((candidate) => candidate.id === run.id)?.attempt ?? runs.length;
13209
- }
13210
- function parseRunState(value) {
13211
- const state = readRunFinishState(value);
13212
- if (!state) throw new CliError(1, `Unknown run state "${value}". Use one of ${RUN_FINISH_STATES.join(", ")}.`);
13213
- return state;
13214
- }
13215
- //#endregion
13216
11943
  //#region src/serve/actors.ts
13217
11944
  /**
13218
11945
  * Who a request writes as.
@@ -13276,12 +12003,14 @@ const BOARD_PAGE_CSP = [
13276
12003
  "style-src 'self'",
13277
12004
  "connect-src 'self'",
13278
12005
  "img-src 'self' data:",
12006
+ "font-src 'self'",
13279
12007
  "base-uri 'none'",
13280
12008
  "form-action 'none'",
13281
12009
  "frame-ancestors 'none'"
13282
12010
  ].join("; ");
13283
12011
  const SCRIPT_PATH = "/assets/board.js";
13284
12012
  const STYLESHEET_PATH = "/assets/board.css";
12013
+ const FONT_PATH = /^\/assets\/fonts\/([a-z0-9-]+\.woff2)$/;
13285
12014
  /** The document itself. No data in it; the `data:` icon keeps the browser from asking for `/favicon.ico`. */
13286
12015
  const BOARD_PAGE_HTML = `<!doctype html>
13287
12016
  <html lang="en">
@@ -13308,19 +12037,23 @@ function resolveBoardPageDirectory() {
13308
12037
  const candidates = [(0, node_path.join)(here, "page"), (0, node_path.join)(here, "..", "..", "dist", "page")];
13309
12038
  return candidates.find((directory) => (0, node_fs.existsSync)((0, node_path.join)(directory, "board.js"))) ?? candidates[1];
13310
12039
  }
13311
- /** The page's two files from `directory`, or `null` when the page has not been built there. */
12040
+ /** The page's files from `directory`, or `null` when the page has not been built there. */
13312
12041
  function readBoardPageAssets(directory) {
13313
12042
  const script = (0, node_path.join)(directory, "board.js");
13314
12043
  const stylesheet = (0, node_path.join)(directory, "board.css");
13315
12044
  if (!(0, node_fs.existsSync)(script) || !(0, node_fs.existsSync)(stylesheet)) return null;
12045
+ const fontDirectory = (0, node_path.join)(directory, "fonts");
12046
+ const fonts = /* @__PURE__ */ new Map();
12047
+ if ((0, node_fs.existsSync)(fontDirectory)) for (const name of (0, node_fs.readdirSync)(fontDirectory).filter((file) => FONT_PATH.test(`/assets/fonts/${file}`))) fonts.set(name, (0, node_fs.readFileSync)((0, node_path.join)(fontDirectory, name)));
13316
12048
  return {
13317
12049
  script: (0, node_fs.readFileSync)(script, "utf8"),
13318
- stylesheet: (0, node_fs.readFileSync)(stylesheet, "utf8")
12050
+ stylesheet: (0, node_fs.readFileSync)(stylesheet, "utf8"),
12051
+ fonts
13319
12052
  };
13320
12053
  }
13321
12054
  /** Whether a path is one of the page's own. */
13322
12055
  function isBoardPagePath(pathname) {
13323
- return pathname === "/" || pathname === SCRIPT_PATH || pathname === STYLESHEET_PATH;
12056
+ return pathname === "/" || pathname === SCRIPT_PATH || pathname === STYLESHEET_PATH || FONT_PATH.test(pathname);
13324
12057
  }
13325
12058
  /**
13326
12059
  * Answer a page path. With no built page, `/` says how to build it rather than
@@ -13339,6 +12072,31 @@ function sendBoardPage(response, pathname, assets) {
13339
12072
  response.end(body);
13340
12073
  return;
13341
12074
  }
12075
+ const fontName = FONT_PATH.exec(pathname)?.[1];
12076
+ if (fontName !== void 0) {
12077
+ const font = assets.fonts.get(fontName);
12078
+ if (!font) {
12079
+ const body = JSON.stringify({
12080
+ code: "not_found",
12081
+ message: "No such font"
12082
+ });
12083
+ response.writeHead(404, {
12084
+ "content-type": "application/json",
12085
+ "content-length": Buffer.byteLength(body)
12086
+ });
12087
+ response.end(body);
12088
+ return;
12089
+ }
12090
+ response.writeHead(200, {
12091
+ "content-type": "font/woff2",
12092
+ "content-length": font.length,
12093
+ "cache-control": "no-cache",
12094
+ "x-content-type-options": "nosniff",
12095
+ "referrer-policy": "no-referrer"
12096
+ });
12097
+ response.end(font);
12098
+ return;
12099
+ }
13342
12100
  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];
13343
12101
  response.writeHead(200, {
13344
12102
  "content-type": contentType,
@@ -13650,37 +12408,144 @@ function route(spec) {
13650
12408
  }
13651
12409
  };
13652
12410
  }
13653
- /** The route a request names and its path parameters, or `null` when no route does. */
13654
- function matchRoute(routes, method, path) {
13655
- const segments = splitPath(path);
13656
- for (const candidate of routes) {
13657
- if (candidate.method !== method || candidate.segments.length !== segments.length) continue;
13658
- const params = {};
13659
- if (candidate.segments.every((segment, index) => {
13660
- if (segment.startsWith(":")) {
13661
- params[segment.slice(1)] = segments[index];
13662
- return segments[index].length > 0;
13663
- }
13664
- return segment === segments[index];
13665
- })) return {
13666
- route: candidate,
13667
- params
13668
- };
13669
- }
13670
- return null;
12411
+ /** The route a request names and its path parameters, or `null` when no route does. */
12412
+ function matchRoute(routes, method, path) {
12413
+ const segments = splitPath(path);
12414
+ for (const candidate of routes) {
12415
+ if (candidate.method !== method || candidate.segments.length !== segments.length) continue;
12416
+ const params = {};
12417
+ if (candidate.segments.every((segment, index) => {
12418
+ if (segment.startsWith(":")) {
12419
+ params[segment.slice(1)] = segments[index];
12420
+ return segments[index].length > 0;
12421
+ }
12422
+ return segment === segments[index];
12423
+ })) return {
12424
+ route: candidate,
12425
+ params
12426
+ };
12427
+ }
12428
+ return null;
12429
+ }
12430
+ /**
12431
+ * A path as its segments, decoded, with a trailing slash ignored: `/issues`
12432
+ * and `/issues/` are the same list route. A segment that is not valid
12433
+ * percent-encoding is kept as it came, and names nothing on the board.
12434
+ */
12435
+ function splitPath(path) {
12436
+ return path.split("/").filter(Boolean).map((segment) => {
12437
+ try {
12438
+ return decodeURIComponent(segment);
12439
+ } catch {
12440
+ return segment;
12441
+ }
12442
+ });
12443
+ }
12444
+ //#endregion
12445
+ //#region src/cli/metrics-view.ts
12446
+ /** Every field `kanbo stats --json` may name, in the order the value carries them. */
12447
+ const BOARD_METRICS_FIELDS = [
12448
+ "range",
12449
+ "now",
12450
+ "milestoneId",
12451
+ "columns",
12452
+ "cycleTime",
12453
+ "leadTime",
12454
+ "throughput",
12455
+ "wip",
12456
+ "aging",
12457
+ "timeInColumn",
12458
+ "cumulativeFlow",
12459
+ "finished",
12460
+ "returns",
12461
+ "attempts",
12462
+ "canceled",
12463
+ "agents",
12464
+ "people"
12465
+ ];
12466
+ const LABEL_WIDTH = 16;
12467
+ /** 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. */
12468
+ function formatDuration(seconds) {
12469
+ if (seconds === null) return "—";
12470
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
12471
+ if (seconds < 86400) return `${Number((seconds / 3600).toFixed(1))}h`;
12472
+ return `${Number((seconds / 86400).toFixed(1))}d`;
12473
+ }
12474
+ function percent(share) {
12475
+ return share === null ? "—" : `${Math.round(share * 100)}%`;
12476
+ }
12477
+ function decimal(value) {
12478
+ return value === null ? "—" : String(Number(value.toFixed(1)));
12479
+ }
12480
+ function count(n, noun) {
12481
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
12482
+ }
12483
+ function day(seconds) {
12484
+ return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
12485
+ }
12486
+ /** A labelled block: the first item beside the label, the rest under it; a dash when there is nothing. */
12487
+ function block(label, items) {
12488
+ const [first = "—", ...rest] = items;
12489
+ return [`${label.padEnd(LABEL_WIDTH)}${first}`, ...rest.map((item) => `${" ".repeat(LABEL_WIDTH)}${item}`)];
12490
+ }
12491
+ /** Items joined on one line, or none at all. */
12492
+ function oneLine(items) {
12493
+ return items.length === 0 ? [] : [items.join(" · ")];
13671
12494
  }
13672
12495
  /**
13673
- * A path as its segments, decoded, with a trailing slash ignored: `/issues`
13674
- * and `/issues/` are the same list route. A segment that is not valid
13675
- * percent-encoding is kept as it came, and names nothing on the board.
12496
+ * The metrics as a person reads them in a terminal: the range, then one block
12497
+ * per figure. The cumulative flow is a series per day and has no one-screen
12498
+ * form, so the last line says how to get it.
13676
12499
  */
13677
- function splitPath(path) {
13678
- return path.split("/").filter(Boolean).map((segment) => {
13679
- try {
13680
- return decodeURIComponent(segment);
13681
- } catch {
13682
- return segment;
13683
- }
12500
+ function describeMetrics(metrics, sprint) {
12501
+ const column = (slug) => sanitizeTerminalText(metrics.columns.find((candidate) => candidate.slug === slug)?.name ?? slug);
12502
+ const by = metrics.returns.byActorKind;
12503
+ const lines = [`${day(metrics.range.from)} → ${day(metrics.range.to)}`];
12504
+ if (sprint) lines.push(`${sanitizeTerminalText(sprint.title)}: ${sprint.cards.open + sprint.cards.done} planned · ${sprint.cards.done} done`);
12505
+ if (metrics.range.from > metrics.range.to) return [...lines, "Nothing measured yet: the range starts after now."].join("\n");
12506
+ 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");
12507
+ return lines.join("\n");
12508
+ }
12509
+ function describeAgent(agent) {
12510
+ const { runs } = agent;
12511
+ 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`;
12512
+ }
12513
+ function describePerson(person) {
12514
+ return `${sanitizeTerminalText(person.id ?? "unnamed")}: ${person.created} created · ${person.approvals} approved · ${person.returns} returned · ${person.moves} moved`;
12515
+ }
12516
+ const WEEK_SECONDS = 604800;
12517
+ /** The Monday that makes the range `DEFAULT_STATS_WEEKS` ISO weeks long, ending with the week of `now`. */
12518
+ function defaultStatsFrom(now) {
12519
+ return isoWeekOf(now).start - 11 * WEEK_SECONDS;
12520
+ }
12521
+ /**
12522
+ * `kanbo stats` — how work flows on this board (spec 2026-11-05, Part 4).
12523
+ *
12524
+ * Reading only, so it is anybody's, an agent's shell included. A sprint sets
12525
+ * the range to its own dates and keeps its cards; `--from` and `--to` still
12526
+ * win over either end.
12527
+ */
12528
+ function registerStatsCommand(program) {
12529
+ 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) => {
12530
+ await runBoardCommand(options, "read", async (session) => {
12531
+ const now = currentUnixSeconds();
12532
+ const sprint = options.sprint === void 0 ? null : (await session.ops.listSprints(session.workspace.id, now)).find((candidate) => candidate.id === options.sprint) ?? null;
12533
+ if (options.sprint !== void 0 && sprint === null) throw new require_migrations_path.BoardError("issue_milestone_not_found", {
12534
+ milestoneId: options.sprint,
12535
+ workspaceId: session.workspace.id
12536
+ });
12537
+ const metrics = await session.ops.boardMetrics({
12538
+ workspaceIds: [session.workspace.id],
12539
+ from: options.from ?? sprint?.startDate ?? defaultStatsFrom(now),
12540
+ to: options.to ?? sprint?.dueDate ?? now,
12541
+ milestoneId: sprint?.id ?? null
12542
+ }, now);
12543
+ return {
12544
+ value: metrics,
12545
+ text: describeMetrics(metrics, sprint),
12546
+ fields: BOARD_METRICS_FIELDS
12547
+ };
12548
+ });
13684
12549
  });
13685
12550
  }
13686
12551
  //#endregion
@@ -14025,6 +12890,14 @@ function createActivityResolver(workspace, personWriterId = null) {
14025
12890
  * a server started for one of them must not reach into the rest.
14026
12891
  */
14027
12892
  const OK = { ok: true };
12893
+ /** Unix seconds in a query string, where everything arrives as text. */
12894
+ const unixSecondsQuery = zod.z.string().regex(/^\d+$/).transform(Number);
12895
+ /** `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. */
12896
+ const metricsQuery = zod.z.object({
12897
+ from: unixSecondsQuery.optional(),
12898
+ to: unixSecondsQuery.optional(),
12899
+ sprint: zod.z.string().min(1).optional()
12900
+ });
14028
12901
  /** How many cards a search answers with when the caller names no limit. */
14029
12902
  const DEFAULT_SEARCH_LIMIT = 20;
14030
12903
  /**
@@ -14320,6 +13193,25 @@ function createIssueRoutes(session, options = {}) {
14320
13193
  return updated;
14321
13194
  }
14322
13195
  }),
13196
+ route({
13197
+ method: "GET",
13198
+ path: "/issues/metrics",
13199
+ query: metricsQuery,
13200
+ handle: async ({ query }) => {
13201
+ const now = currentUnixSeconds();
13202
+ const sprint = query.sprint === void 0 ? null : (await ops.listSprints(workspaceId, now)).find((candidate) => candidate.id === query.sprint) ?? null;
13203
+ if (query.sprint !== void 0 && sprint === null) throw new require_migrations_path.BoardError("issue_milestone_not_found", {
13204
+ milestoneId: query.sprint,
13205
+ workspaceId
13206
+ });
13207
+ return await ops.boardMetrics({
13208
+ workspaceIds: [workspaceId],
13209
+ from: query.from ?? sprint?.startDate ?? defaultStatsFrom(now),
13210
+ to: query.to ?? sprint?.dueDate ?? now,
13211
+ milestoneId: sprint?.id ?? null
13212
+ }, now);
13213
+ }
13214
+ }),
14323
13215
  route({
14324
13216
  method: "GET",
14325
13217
  path: "/issues/workspace",
@@ -15313,113 +14205,6 @@ function registerSprintCommands(program) {
15313
14205
  });
15314
14206
  }
15315
14207
  //#endregion
15316
- //#region src/cli/metrics-view.ts
15317
- /** Every field `kanbo stats --json` may name, in the order the value carries them. */
15318
- const BOARD_METRICS_FIELDS = [
15319
- "range",
15320
- "now",
15321
- "milestoneId",
15322
- "columns",
15323
- "cycleTime",
15324
- "leadTime",
15325
- "throughput",
15326
- "wip",
15327
- "aging",
15328
- "timeInColumn",
15329
- "cumulativeFlow",
15330
- "finished",
15331
- "returns",
15332
- "attempts",
15333
- "canceled",
15334
- "agents",
15335
- "people"
15336
- ];
15337
- const LABEL_WIDTH = 16;
15338
- /** 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. */
15339
- function formatDuration(seconds) {
15340
- if (seconds === null) return "—";
15341
- if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
15342
- if (seconds < 86400) return `${Number((seconds / 3600).toFixed(1))}h`;
15343
- return `${Number((seconds / 86400).toFixed(1))}d`;
15344
- }
15345
- function percent(share) {
15346
- return share === null ? "—" : `${Math.round(share * 100)}%`;
15347
- }
15348
- function decimal(value) {
15349
- return value === null ? "—" : String(Number(value.toFixed(1)));
15350
- }
15351
- function count(n, noun) {
15352
- return `${n} ${noun}${n === 1 ? "" : "s"}`;
15353
- }
15354
- function day(seconds) {
15355
- return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
15356
- }
15357
- /** A labelled block: the first item beside the label, the rest under it; a dash when there is nothing. */
15358
- function block(label, items) {
15359
- const [first = "—", ...rest] = items;
15360
- return [`${label.padEnd(LABEL_WIDTH)}${first}`, ...rest.map((item) => `${" ".repeat(LABEL_WIDTH)}${item}`)];
15361
- }
15362
- /** Items joined on one line, or none at all. */
15363
- function oneLine(items) {
15364
- return items.length === 0 ? [] : [items.join(" · ")];
15365
- }
15366
- /**
15367
- * The metrics as a person reads them in a terminal: the range, then one block
15368
- * per figure. The cumulative flow is a series per day and has no one-screen
15369
- * form, so the last line says how to get it.
15370
- */
15371
- function describeMetrics(metrics, sprint) {
15372
- const column = (slug) => sanitizeTerminalText(metrics.columns.find((candidate) => candidate.slug === slug)?.name ?? slug);
15373
- const by = metrics.returns.byActorKind;
15374
- const lines = [`${day(metrics.range.from)} → ${day(metrics.range.to)}`];
15375
- if (sprint) lines.push(`${sanitizeTerminalText(sprint.title)}: ${sprint.cards.open + sprint.cards.done} planned · ${sprint.cards.done} done`);
15376
- if (metrics.range.from > metrics.range.to) return [...lines, "Nothing measured yet: the range starts after now."].join("\n");
15377
- 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");
15378
- return lines.join("\n");
15379
- }
15380
- function describeAgent(agent) {
15381
- const { runs } = agent;
15382
- 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`;
15383
- }
15384
- function describePerson(person) {
15385
- return `${sanitizeTerminalText(person.id ?? "unnamed")}: ${person.created} created · ${person.approvals} approved · ${person.returns} returned · ${person.moves} moved`;
15386
- }
15387
- const WEEK_SECONDS = 604800;
15388
- /** The Monday that makes the range `DEFAULT_STATS_WEEKS` ISO weeks long, ending with the week of `now`. */
15389
- function defaultStatsFrom(now) {
15390
- return isoWeekOf(now).start - 11 * WEEK_SECONDS;
15391
- }
15392
- /**
15393
- * `kanbo stats` — how work flows on this board (spec 2026-11-05, Part 4).
15394
- *
15395
- * Reading only, so it is anybody's, an agent's shell included. A sprint sets
15396
- * the range to its own dates and keeps its cards; `--from` and `--to` still
15397
- * win over either end.
15398
- */
15399
- function registerStatsCommand(program) {
15400
- 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) => {
15401
- await runBoardCommand(options, "read", async (session) => {
15402
- const now = currentUnixSeconds();
15403
- const sprint = options.sprint === void 0 ? null : (await session.ops.listSprints(session.workspace.id, now)).find((candidate) => candidate.id === options.sprint) ?? null;
15404
- if (options.sprint !== void 0 && sprint === null) throw new require_migrations_path.BoardError("issue_milestone_not_found", {
15405
- milestoneId: options.sprint,
15406
- workspaceId: session.workspace.id
15407
- });
15408
- const metrics = await session.ops.boardMetrics({
15409
- workspaceIds: [session.workspace.id],
15410
- from: options.from ?? sprint?.startDate ?? defaultStatsFrom(now),
15411
- to: options.to ?? sprint?.dueDate ?? now,
15412
- milestoneId: sprint?.id ?? null
15413
- }, now);
15414
- return {
15415
- value: metrics,
15416
- text: describeMetrics(metrics, sprint),
15417
- fields: BOARD_METRICS_FIELDS
15418
- };
15419
- });
15420
- });
15421
- }
15422
- //#endregion
15423
14208
  //#region src/cli/commands/uninstall.ts
15424
14209
  function registerUninstallCommand(program) {
15425
14210
  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) => {
@@ -15582,95 +14367,30 @@ function printActions(actions, notes, options) {
15582
14367
  * this one.
15583
14368
  *
15584
14369
  * The order is the order `kanbo --help` lists them in, under the heading of
15585
- * the group each belongs to.
14370
+ * the group each belongs to. Agents reach the board through the MCP server
14371
+ * (`kanbo mcp`) alone; every other command is a person's.
15586
14372
  */
15587
14373
  function registerKanboCommands(program, context = {}) {
15588
14374
  program.commandsGroup("Get started:");
15589
14375
  registerInitCommand(program);
15590
14376
  registerConnectCommand(program);
15591
- registerInstructionsCommand(program);
15592
14377
  registerDoctorCommand(program, context.update ?? null);
15593
14378
  registerUninstallCommand(program);
15594
14379
  program.commandsGroup("Your board:");
15595
14380
  registerBoardCommand(program);
15596
- registerCardCommands(program);
15597
14381
  registerColumnsCommands(program);
15598
14382
  registerApproveCommand(program);
15599
14383
  registerReturnCommand(program);
15600
- registerReadyCommand(program);
15601
- registerPrimeCommand(program);
15602
14384
  registerSprintCommands(program);
15603
14385
  registerStatsCommand(program);
15604
14386
  registerServeCommand(program);
15605
- program.commandsGroup("For agents and integrations:");
14387
+ program.commandsGroup("For agents:");
15606
14388
  registerMcpCommand(program);
15607
- registerClaimCommand(program);
15608
- registerRunCommands(program);
15609
- registerCapabilitiesCommand(program);
15610
14389
  program.commandsGroup("Shared Postgres boards:");
15611
14390
  registerMigrateCommand(program);
15612
14391
  registerRolesCommands(program);
15613
14392
  }
15614
14393
  //#endregion
15615
- //#region src/mcp/tool-names.ts
15616
- /**
15617
- * The board's tools, named once.
15618
- *
15619
- * An agent reaches this board through an MCP server, through a command line, or
15620
- * through an HTTP API (`kanbo serve`, or an app that serves the same routes),
15621
- * and the three must agree on what the board can be asked to do. The names live
15622
- * here rather than in the MCP server so that a prompt built for an agent and the
15623
- * tools that agent actually holds cannot drift apart: both read this list.
15624
- *
15625
- * Nothing here executes anything. These are names and the commands that mean
15626
- * the same thing, for a prompt and for documentation.
15627
- */
15628
- const KANBO_TOOL_NAMES = [
15629
- "kanbo_prime",
15630
- "kanbo_ready",
15631
- "kanbo_card_claim",
15632
- "kanbo_columns",
15633
- "kanbo_sprints",
15634
- "kanbo_card_get",
15635
- "kanbo_card_list",
15636
- "kanbo_card_create",
15637
- "kanbo_card_update",
15638
- "kanbo_card_move",
15639
- "kanbo_card_comment",
15640
- "kanbo_card_link_pr",
15641
- "kanbo_card_pull_requests",
15642
- "kanbo_status_line",
15643
- "kanbo_wait_approval",
15644
- "kanbo_run_start",
15645
- "kanbo_run_finish"
15646
- ];
15647
- /**
15648
- * What to type instead of calling the tool.
15649
- *
15650
- * An agent without MCP tools is not shut out of the board: every tool has a
15651
- * command that does the same thing, and the card ends up saying the same
15652
- * either way, because both call the same operations.
15653
- */
15654
- const KANBO_TOOL_CLI_EQUIVALENTS = {
15655
- kanbo_prime: { kanbo: "kanbo prime" },
15656
- kanbo_ready: { kanbo: "kanbo ready" },
15657
- kanbo_card_claim: { kanbo: "kanbo claim [card] [--agent <name>]" },
15658
- kanbo_columns: { kanbo: "kanbo columns list" },
15659
- kanbo_sprints: { kanbo: "kanbo sprint list" },
15660
- kanbo_card_get: { kanbo: "kanbo card get <id>" },
15661
- kanbo_card_list: { kanbo: "kanbo card list" },
15662
- kanbo_card_create: { kanbo: "kanbo card create --description <text> [--title <title>] [--parent <id>]" },
15663
- kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>] [--parent <id>]" },
15664
- kanbo_card_move: { kanbo: "kanbo card move <id> <column>" },
15665
- kanbo_card_comment: { kanbo: "kanbo card comment <id> --content <text>" },
15666
- kanbo_card_link_pr: { kanbo: "kanbo card pr add <id> <url>" },
15667
- kanbo_card_pull_requests: { kanbo: "kanbo card pr list <id>" },
15668
- kanbo_status_line: { kanbo: "kanbo card status-line <id> --text <text>" },
15669
- kanbo_wait_approval: { kanbo: "kanbo card wait-approval <id> [--text <text>]" },
15670
- kanbo_run_start: { kanbo: "kanbo run start <id> --agent <name> [--session <ref>]" },
15671
- kanbo_run_finish: { kanbo: "kanbo run finish <runId> --state <state>" }
15672
- };
15673
- //#endregion
15674
14394
  //#region src/capabilities/manifest.ts
15675
14395
  /**
15676
14396
  * A machine-readable description of this board, for an agent that has never
@@ -15686,12 +14406,11 @@ const KANBO_TOOL_CLI_EQUIVALENTS = {
15686
14406
  * sources gains something this file was not told about.
15687
14407
  *
15688
14408
  * `buildCapabilitiesManifest` and `renderCapabilitiesMarkdown` are the one
15689
- * source two servings come from: `kanbo capabilities` prints either shape
15690
- * from a terminal, and `kanbo mcp` serves both as resources
14409
+ * source two servings come from: `kanbo mcp` serves both as resources
15691
14410
  * (`kanbo://capabilities`, `kanbo://capabilities.md`).
15692
14411
  */
15693
14412
  /** The manifest's own shape version, bumped when a field is added or removed — not the package's version. */
15694
- const CAPABILITIES_MANIFEST_VERSION = "3";
14413
+ const CAPABILITIES_MANIFEST_VERSION = "4";
15695
14414
  /** The package name and version this build reports — the same literal `kanbo mcp` and `kanbo --version` already use. */
15696
14415
  const KANBO_PACKAGE = {
15697
14416
  name: "kanbo",
@@ -15739,14 +14458,13 @@ function buildCanonicalStrings() {
15739
14458
  approved: APPROVED_COMMENT
15740
14459
  };
15741
14460
  }
15742
- /** Every board tool, with its schema converted and its CLI equivalent attached. */
14461
+ /** Every board tool, with its schema converted. */
15743
14462
  function buildTools() {
15744
14463
  return KANBO_TOOLS.map((tool) => ({
15745
14464
  name: tool.name,
15746
14465
  title: tool.title,
15747
14466
  description: tool.description,
15748
- inputSchema: zod.z.toJSONSchema(zod.z.object(tool.inputSchema)),
15749
- cli: KANBO_TOOL_CLI_EQUIVALENTS[tool.name]
14467
+ inputSchema: zod.z.toJSONSchema(zod.z.object(tool.inputSchema))
15750
14468
  }));
15751
14469
  }
15752
14470
  /** Every leaf command the `kanbo` binary answers to, walked off its own registration. */
@@ -15780,7 +14498,7 @@ function buildCommands() {
15780
14498
  /** Everything this build of the board can tell an orchestrator about itself. */
15781
14499
  function buildCapabilitiesManifest() {
15782
14500
  return {
15783
- version: "3",
14501
+ version: "4",
15784
14502
  package: KANBO_PACKAGE,
15785
14503
  storages: [...KANBO_STORAGES],
15786
14504
  tools: buildTools(),
@@ -15803,8 +14521,8 @@ function buildCapabilitiesManifest() {
15803
14521
  `The agent role (\`${require_roles_sql.BOARD_AGENT_ROLE}\`) may not delete a card at all — it holds select, insert and update on \`issues\` and nothing more, even though it may delete freely on every other board table.`,
15804
14522
  "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.",
15805
14523
  "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.",
15806
- "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.",
15807
- "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.",
14524
+ "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.",
14525
+ "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.",
15808
14526
  "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).",
15809
14527
  "`kanbo migrate` and `kanbo roles apply` refuse a host app's own database file: the app migrates it, and this package never touches it.",
15810
14528
  "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.",
@@ -15813,6 +14531,41 @@ function buildCapabilitiesManifest() {
15813
14531
  };
15814
14532
  }
15815
14533
  //#endregion
14534
+ //#region src/mcp/tool-names.ts
14535
+ /**
14536
+ * The board's tools, named once.
14537
+ *
14538
+ * An agent reaches this board through an MCP server, or through an HTTP API
14539
+ * (`kanbo serve`, or an app that serves the same routes), and both must agree on
14540
+ * what the board can be asked to do. The names are read by the MCP server, by
14541
+ * the prompts built for an agent and by the HTTP API — there is no command line
14542
+ * for an agent. They live here rather than in the MCP server so that a prompt
14543
+ * built for an agent and the tools that agent actually holds cannot drift
14544
+ * apart: both read this list.
14545
+ *
14546
+ * Nothing here executes anything. These are names, for a prompt and for
14547
+ * documentation.
14548
+ */
14549
+ const KANBO_TOOL_NAMES = [
14550
+ "kanbo_prime",
14551
+ "kanbo_ready",
14552
+ "kanbo_card_claim",
14553
+ "kanbo_columns",
14554
+ "kanbo_sprints",
14555
+ "kanbo_card_get",
14556
+ "kanbo_card_list",
14557
+ "kanbo_card_create",
14558
+ "kanbo_card_update",
14559
+ "kanbo_card_move",
14560
+ "kanbo_card_comment",
14561
+ "kanbo_card_link_pr",
14562
+ "kanbo_card_pull_requests",
14563
+ "kanbo_status_line",
14564
+ "kanbo_wait_approval",
14565
+ "kanbo_run_start",
14566
+ "kanbo_run_finish"
14567
+ ];
14568
+ //#endregion
15816
14569
  Object.defineProperty(exports, "BINDING_FILE_PATH", {
15817
14570
  enumerable: true,
15818
14571
  get: function() {
@@ -15939,12 +14692,6 @@ Object.defineProperty(exports, "KANBO_TOOLS", {
15939
14692
  return KANBO_TOOLS;
15940
14693
  }
15941
14694
  });
15942
- Object.defineProperty(exports, "KANBO_TOOL_CLI_EQUIVALENTS", {
15943
- enumerable: true,
15944
- get: function() {
15945
- return KANBO_TOOL_CLI_EQUIVALENTS;
15946
- }
15947
- });
15948
14695
  Object.defineProperty(exports, "KANBO_TOOL_NAMES", {
15949
14696
  enumerable: true,
15950
14697
  get: function() {