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