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