kanbo-cli 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +2 -0
- package/dist/cli-main.cjs +1347 -254
- package/dist/drizzle-postgres/0006_flow_rules_and_column_entries.sql +3 -0
- package/dist/drizzle-postgres/README.md +8 -0
- package/dist/drizzle-postgres/meta/0006_snapshot.json +1290 -0
- package/dist/drizzle-postgres/meta/_journal.json +7 -0
- package/dist/drizzle-sqlite/0005_flow_rules_and_column_entries.sql +3 -0
- package/dist/drizzle-sqlite/README.md +8 -0
- package/dist/drizzle-sqlite/meta/0005_snapshot.json +1097 -0
- package/dist/drizzle-sqlite/meta/_journal.json +7 -0
- package/dist/lib/{approval-BsBk2xVr.d.cts → approval-CnJJfwfi.d.mts} +30 -6
- package/dist/lib/{approval-LRZ6ontC.d.mts → approval-DNfUhYD5.d.cts} +30 -6
- package/dist/lib/{board-store-D_Mpfb84.d.mts → board-store-D2x2w1wG.d.cts} +26 -2
- package/dist/lib/{board-store-Dx-mw2I6.d.cts → board-store-DN5Y2U2y.d.mts} +26 -2
- package/dist/lib/index.cjs +3 -1
- package/dist/lib/index.d.cts +96 -6
- package/dist/lib/index.d.mts +96 -6
- package/dist/lib/index.mjs +2 -2
- package/dist/lib/{manifest-QPoQl8tn.cjs → manifest-DUGRyC7A.cjs} +1378 -300
- package/dist/lib/{manifest-v2e9x_C8.mjs → manifest-t4LvFgzH.mjs} +1369 -303
- package/dist/lib/mcp/index.cjs +35 -6
- package/dist/lib/mcp/index.d.cts +23 -4
- package/dist/lib/mcp/index.d.mts +23 -4
- package/dist/lib/mcp/index.mjs +35 -6
- package/dist/lib/{migrate-DtBeW3Fs.mjs → migrate-BUsaTJlj.mjs} +8 -1
- package/dist/lib/{migrate-Dca5WdYd.cjs → migrate-BoGrqK9m.cjs} +7 -0
- package/dist/lib/postgres/index.cjs +1 -1
- package/dist/lib/postgres/index.d.cts +69 -1
- package/dist/lib/postgres/index.d.mts +69 -1
- package/dist/lib/postgres/index.mjs +1 -1
- package/dist/lib/{roles.sql-BoOZSkBp.mjs → roles.sql-BDmmNUdx.mjs} +18 -2
- package/dist/lib/{roles.sql-E58x7mLS.cjs → roles.sql-CBFxSHy7.cjs} +17 -1
- package/dist/lib/{schema-D0MyXcej.d.cts → schema-Cwx3HosC.d.cts} +72 -0
- package/dist/lib/{schema-D0MyXcej.d.mts → schema-Cwx3HosC.d.mts} +72 -0
- package/dist/lib/sqlite/index.cjs +1 -1
- package/dist/lib/sqlite/index.d.cts +2 -2
- package/dist/lib/sqlite/index.d.mts +2 -2
- package/dist/lib/sqlite/index.mjs +1 -1
- package/dist/lib/sqlite/schema.cjs +13 -1
- package/dist/lib/sqlite/schema.d.cts +1 -1
- package/dist/lib/sqlite/schema.d.mts +1 -1
- package/dist/lib/sqlite/schema.mjs +13 -1
- package/dist/page/board.css +1622 -327
- package/dist/page/board.js +2 -2
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { a as BoardError } from "./migrations-path-ClaYRNwk.mjs";
|
|
2
|
-
import { c as getBoardMigrationsPath, i as BOARD_PERSON_ROLE, l as createPostgresBoardStore, n as AGENT_WAITING_REFUSAL, o as BOARD_ROLE_SQL, r as BOARD_AGENT_ROLE, s as assertBoardSchema, t as AGENT_APPROVAL_COMMENT_REFUSAL } from "./roles.sql-
|
|
3
|
-
import { a as openBoardDatabase, c as boardQueryLogger, d as foldCase, i as markBoardFileOwnedByKanbo, l as createSqliteBoardStore, n as migrateBoardFile, o as readBoardFileContents, r as assertBoardSchema$1 } from "./migrate-
|
|
2
|
+
import { c as getBoardMigrationsPath, i as BOARD_PERSON_ROLE, l as createPostgresBoardStore, n as AGENT_WAITING_REFUSAL, o as BOARD_ROLE_SQL, r as BOARD_AGENT_ROLE, s as assertBoardSchema, t as AGENT_APPROVAL_COMMENT_REFUSAL } from "./roles.sql-BDmmNUdx.mjs";
|
|
3
|
+
import { a as openBoardDatabase, c as boardQueryLogger, d as foldCase, i as markBoardFileOwnedByKanbo, l as createSqliteBoardStore, n as migrateBoardFile, o as readBoardFileContents, r as assertBoardSchema$1 } from "./migrate-BUsaTJlj.mjs";
|
|
4
4
|
import { Command, Option } from "commander";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { homedir, hostname, userInfo } from "node:os";
|
|
@@ -52,22 +52,26 @@ const AGENT_SHELL_MARKERS = [
|
|
|
52
52
|
{
|
|
53
53
|
variable: "CLAUDECODE",
|
|
54
54
|
matches: (value) => value === "1",
|
|
55
|
-
label: "CLAUDECODE=1"
|
|
55
|
+
label: "CLAUDECODE=1",
|
|
56
|
+
tool: "claude"
|
|
56
57
|
},
|
|
57
58
|
{
|
|
58
59
|
variable: "GEMINI_CLI",
|
|
59
60
|
matches: (value) => value === "1",
|
|
60
|
-
label: "GEMINI_CLI=1"
|
|
61
|
+
label: "GEMINI_CLI=1",
|
|
62
|
+
tool: "gemini"
|
|
61
63
|
},
|
|
62
64
|
{
|
|
63
65
|
variable: "CURSOR_AGENT",
|
|
64
66
|
matches: (value) => value !== "",
|
|
65
|
-
label: "CURSOR_AGENT"
|
|
67
|
+
label: "CURSOR_AGENT",
|
|
68
|
+
tool: "cursor"
|
|
66
69
|
},
|
|
67
70
|
{
|
|
68
71
|
variable: "CODEX_THREAD_ID",
|
|
69
72
|
matches: (value) => value !== "",
|
|
70
|
-
label: "CODEX_THREAD_ID"
|
|
73
|
+
label: "CODEX_THREAD_ID",
|
|
74
|
+
tool: "codex"
|
|
71
75
|
}
|
|
72
76
|
];
|
|
73
77
|
/**
|
|
@@ -85,6 +89,15 @@ function readAgentShellMarker(env = process.env) {
|
|
|
85
89
|
return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.label ?? null;
|
|
86
90
|
}
|
|
87
91
|
/**
|
|
92
|
+
* The name of the agent tool this shell belongs to (`claude`, `gemini`,
|
|
93
|
+
* `cursor`, `codex`), `agent` when only `KANBO_ACTOR_KIND=agent` says so, or
|
|
94
|
+
* `null` for a person's shell. Same precedence as `readAgentShellMarker`.
|
|
95
|
+
*/
|
|
96
|
+
function readAgentTool(env = process.env) {
|
|
97
|
+
if (readAgentShellMarker(env) === null) return null;
|
|
98
|
+
return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.tool ?? "agent";
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
88
101
|
* Every exit code this tool promises, and what it means — the capabilities
|
|
89
102
|
* manifest's `exitCodes` reads this rather than naming the codes again. `0` and
|
|
90
103
|
* `1` are not declared above: they need no name of their own, success and "I
|
|
@@ -258,6 +271,7 @@ const HUMAN_ONLY_ACTIONS = [
|
|
|
258
271
|
"return",
|
|
259
272
|
"sprint close",
|
|
260
273
|
"columns rules",
|
|
274
|
+
"columns flow",
|
|
261
275
|
"columns add",
|
|
262
276
|
"columns rename",
|
|
263
277
|
"columns move",
|
|
@@ -271,6 +285,7 @@ const HUMAN_ONLY_MESSAGES = {
|
|
|
271
285
|
"return": describeReturnRefusal,
|
|
272
286
|
"sprint close": describeSprintCloseRefusal,
|
|
273
287
|
"columns rules": describeColumnRulesRefusal,
|
|
288
|
+
"columns flow": describeColumnRulesRefusal,
|
|
274
289
|
"columns add": describeColumnChangesRefusal,
|
|
275
290
|
"columns rename": describeColumnChangesRefusal,
|
|
276
291
|
"columns move": describeColumnChangesRefusal,
|
|
@@ -280,11 +295,15 @@ const HUMAN_ONLY_MESSAGES = {
|
|
|
280
295
|
"run clear-session": describeRunSessionRefRefusal,
|
|
281
296
|
"run attach-session --replace": describeRunSessionRefRefusal
|
|
282
297
|
};
|
|
283
|
-
/**
|
|
298
|
+
/**
|
|
299
|
+
* The actor every board write from this tool is filed under: `KANBO_ACTOR_ID`,
|
|
300
|
+
* else the agent tool of an agent's shell (`claude`, `codex`, ...), else the
|
|
301
|
+
* operating system's user.
|
|
302
|
+
*/
|
|
284
303
|
function createCliActor() {
|
|
285
304
|
return {
|
|
286
305
|
kind: "external",
|
|
287
|
-
id: process.env.KANBO_ACTOR_ID?.trim() || safeUsername(),
|
|
306
|
+
id: process.env.KANBO_ACTOR_ID?.trim() || readAgentTool() || safeUsername(),
|
|
288
307
|
name: hostname()
|
|
289
308
|
};
|
|
290
309
|
}
|
|
@@ -322,6 +341,14 @@ function createPlacementActor() {
|
|
|
322
341
|
placesForPerson: true
|
|
323
342
|
};
|
|
324
343
|
}
|
|
344
|
+
/** Who the board page writes as: the person at this machine — `KANBO_ACTOR_ID` in a person's shell, else the OS user; never an agent tool, since the page is read and typed in by a person. */
|
|
345
|
+
function createServeWriter() {
|
|
346
|
+
return {
|
|
347
|
+
kind: "external",
|
|
348
|
+
id: (isAgentShell() ? "" : process.env.KANBO_ACTOR_ID?.trim() ?? "") || safeUsername(),
|
|
349
|
+
name: hostname()
|
|
350
|
+
};
|
|
351
|
+
}
|
|
325
352
|
/**
|
|
326
353
|
* The person who owns this shell, as the board files a person. Only for a
|
|
327
354
|
* caller that has already asked `isAgentShell` — `requireHumanActor` for a
|
|
@@ -739,6 +766,14 @@ const HIDDEN_ACTIVITY_FIELDS = /* @__PURE__ */ new Set([
|
|
|
739
766
|
"order",
|
|
740
767
|
"waitingFor"
|
|
741
768
|
]);
|
|
769
|
+
/**
|
|
770
|
+
* The column a card was created in, recorded as a change from no column at the
|
|
771
|
+
* card's own creation time. The feed's `created` item already stands for it,
|
|
772
|
+
* and the field history keeps it.
|
|
773
|
+
*/
|
|
774
|
+
function isStartingColumn(change, issue) {
|
|
775
|
+
return change.field === "statusId" && change.fromValue === null && change.createdAt === issue.createdAt;
|
|
776
|
+
}
|
|
742
777
|
const ACTIVITY_FIELD_BY_RAW_FIELD = {
|
|
743
778
|
assigneeId: "assignee",
|
|
744
779
|
description: "description",
|
|
@@ -779,7 +814,7 @@ async function listActivity(store, issueId, resolver) {
|
|
|
779
814
|
workspaceById: resolver.workspaceById
|
|
780
815
|
};
|
|
781
816
|
const commentItems = await Promise.all((await store.comments.listByIssue(issueId)).map(async (comment) => await toCommentActivityItem(comment, resolver)));
|
|
782
|
-
const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
|
|
817
|
+
const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).filter((change) => !isStartingColumn(change, issue)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
|
|
783
818
|
return [
|
|
784
819
|
{
|
|
785
820
|
id: `${issue.id}:created`,
|
|
@@ -1322,7 +1357,9 @@ const UNMET_ENTRY_RULE_REASONS = [
|
|
|
1322
1357
|
"ci_red",
|
|
1323
1358
|
"approval_pending",
|
|
1324
1359
|
"returned",
|
|
1325
|
-
"not_approved"
|
|
1360
|
+
"not_approved",
|
|
1361
|
+
"not_allowed_from",
|
|
1362
|
+
"column_full"
|
|
1326
1363
|
];
|
|
1327
1364
|
const entryRulesSchema = z.array(z.enum(ENTRY_RULES));
|
|
1328
1365
|
/**
|
|
@@ -1469,6 +1506,65 @@ function describeColumnRefusal(details) {
|
|
|
1469
1506
|
return [`board_column_rules_unmet: ${details.issueId ? `${details.issueId} cannot enter` : "A new card cannot be created in"} "${details.column}" yet:`, ...details.unmet.map((item) => `- ${item.rule}: ${item.detail}`)].join("\n");
|
|
1470
1507
|
}
|
|
1471
1508
|
//#endregion
|
|
1509
|
+
//#region src/domain/flow-rules.ts
|
|
1510
|
+
/** The two flow rules, as an unmet entry names them. */
|
|
1511
|
+
const FLOW_RULES = ["allowed_from", "wip_limit"];
|
|
1512
|
+
/** The column the standard flow guards, and the columns it lets cards in from, by slug. */
|
|
1513
|
+
const STANDARD_FLOW_TARGET = "done";
|
|
1514
|
+
const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
|
|
1515
|
+
/** A column's flow rules, out of what the row stores. */
|
|
1516
|
+
function readFlowRules(column) {
|
|
1517
|
+
return {
|
|
1518
|
+
allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
|
|
1519
|
+
wipLimit: column.wipLimit
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
/** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
|
|
1523
|
+
function serializeAllowedFrom(allowedFrom) {
|
|
1524
|
+
return allowedFrom === null ? null : JSON.stringify(allowedFrom);
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* The flow rules a card entering `column` does not meet, where it may come from
|
|
1528
|
+
* first. Pure: what the board holds arrives in `facts`. A card being created
|
|
1529
|
+
* comes from no column, so a column that names where cards come from does not
|
|
1530
|
+
* take it.
|
|
1531
|
+
*/
|
|
1532
|
+
function evaluateFlowRules(card, column, rules, facts) {
|
|
1533
|
+
const unmet = [];
|
|
1534
|
+
if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
|
|
1535
|
+
rule: "allowed_from",
|
|
1536
|
+
reason: "not_allowed_from",
|
|
1537
|
+
detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
|
|
1538
|
+
});
|
|
1539
|
+
if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
|
|
1540
|
+
rule: "wip_limit",
|
|
1541
|
+
reason: "column_full",
|
|
1542
|
+
detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
|
|
1543
|
+
});
|
|
1544
|
+
return unmet;
|
|
1545
|
+
}
|
|
1546
|
+
function describeAllowedFrom(name, allowedFrom, columnNames) {
|
|
1547
|
+
const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
|
|
1548
|
+
return names.length === 0 ? `${name} accepts cards from no column; only a person may put a card here` : `${name} accepts cards from ${joinWords(names)} only`;
|
|
1549
|
+
}
|
|
1550
|
+
/** `A`, `A and B`, `A, B and C`. */
|
|
1551
|
+
function joinWords(words) {
|
|
1552
|
+
return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1555
|
+
* The standard flow on these columns: Done, and the columns it takes cards from
|
|
1556
|
+
* — In Review, and QA when there is one — in board order. `null` when the board
|
|
1557
|
+
* has no Done, or neither of the two.
|
|
1558
|
+
*/
|
|
1559
|
+
function standardFlowOf(columns) {
|
|
1560
|
+
const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
|
|
1561
|
+
const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
|
|
1562
|
+
return target && sources.length > 0 ? {
|
|
1563
|
+
target,
|
|
1564
|
+
sources
|
|
1565
|
+
} : null;
|
|
1566
|
+
}
|
|
1567
|
+
//#endregion
|
|
1472
1568
|
//#region src/ops/columns.ts
|
|
1473
1569
|
/** The columns of one workspace, in board order. */
|
|
1474
1570
|
async function listColumns(store, workspaceId) {
|
|
@@ -1538,6 +1634,7 @@ async function applyColumnTemplate(store, workspaceId, columns, options, scope)
|
|
|
1538
1634
|
workspaceId,
|
|
1539
1635
|
...column
|
|
1540
1636
|
}, order);
|
|
1637
|
+
await writeStandardFlow(tx, await listColumns(tx, workspaceId));
|
|
1541
1638
|
return {
|
|
1542
1639
|
statuses: await listColumns(tx, workspaceId),
|
|
1543
1640
|
added: columns.map((column) => column.name)
|
|
@@ -1647,6 +1744,80 @@ async function setColumnEntryRules(store, workspaceId, nameOrId, entryRules, act
|
|
|
1647
1744
|
return await updateColumn(tx, (await requireColumn(tx, workspaceId, nameOrId)).id, { entryRules }, actor, { tx });
|
|
1648
1745
|
}, scope);
|
|
1649
1746
|
}
|
|
1747
|
+
/** The largest WIP limit: what a Postgres `integer` column holds. */
|
|
1748
|
+
const MAX_WIP_LIMIT = 2147483647;
|
|
1749
|
+
/**
|
|
1750
|
+
* Set where a card may enter the column from and how many cards it holds:
|
|
1751
|
+
* `allowedFrom` the ids of other columns of its board (`null` for any, `[]` for
|
|
1752
|
+
* none), kept once each in board order; `wipLimit` a whole number from 1 to 2147483647, or `null`. A
|
|
1753
|
+
* person's to set, like the entry rules: an agent that could open a column
|
|
1754
|
+
* could walk any card into it.
|
|
1755
|
+
*/
|
|
1756
|
+
async function setColumnFlowRules(store, columnId, rules, actor, scope) {
|
|
1757
|
+
assertPersonSetsEntryRules(actor, { statusId: columnId });
|
|
1758
|
+
if (rules.wipLimit !== null && !(Number.isSafeInteger(rules.wipLimit) && rules.wipLimit >= 1 && rules.wipLimit <= MAX_WIP_LIMIT)) throw new BoardError("board_flow_rule_invalid", {
|
|
1759
|
+
statusId: columnId,
|
|
1760
|
+
wipLimit: rules.wipLimit
|
|
1761
|
+
});
|
|
1762
|
+
return await runBoardWrite(store, async ({ tx }) => {
|
|
1763
|
+
const column = await tx.statuses.findById(columnId);
|
|
1764
|
+
if (!column) throw new BoardError("issue_status_not_found", { statusId: columnId });
|
|
1765
|
+
const allowedFrom = rules.allowedFrom === null ? null : allowedFromInBoardOrder(await listColumns(tx, column.workspaceId), column, rules.allowedFrom);
|
|
1766
|
+
await tx.statuses.update(columnId, {
|
|
1767
|
+
allowedFrom: serializeAllowedFrom(allowedFrom),
|
|
1768
|
+
wipLimit: rules.wipLimit
|
|
1769
|
+
});
|
|
1770
|
+
return await tx.statuses.findById(columnId);
|
|
1771
|
+
}, scope);
|
|
1772
|
+
}
|
|
1773
|
+
/** The ids asked for, once each and in board order — or the refusal naming those that are not another column of this board. */
|
|
1774
|
+
function allowedFromInBoardOrder(columns, column, requested) {
|
|
1775
|
+
const others = columns.filter((other) => other.id !== column.id);
|
|
1776
|
+
const invalid = requested.filter((id) => !others.some((other) => other.id === id));
|
|
1777
|
+
if (invalid.length > 0) throw new BoardError("board_flow_rule_invalid", {
|
|
1778
|
+
statusId: column.id,
|
|
1779
|
+
allowedFrom: invalid
|
|
1780
|
+
});
|
|
1781
|
+
return others.filter((other) => requested.includes(other.id)).map((other) => other.id);
|
|
1782
|
+
}
|
|
1783
|
+
/**
|
|
1784
|
+
* Put the standard flow on a board: Done accepts cards from In Review only —
|
|
1785
|
+
* and from QA, when the board has it — so nothing reaches Done but through
|
|
1786
|
+
* review. Done's WIP limit and every other column stay as they are. A board
|
|
1787
|
+
* without Done, or with neither In Review nor QA, has nothing to apply it to.
|
|
1788
|
+
*/
|
|
1789
|
+
async function applyStandardFlow(store, workspaceId, actor, scope) {
|
|
1790
|
+
assertPersonSetsEntryRules(actor, { workspaceId });
|
|
1791
|
+
return await runBoardWrite(store, async ({ tx }) => {
|
|
1792
|
+
const columns = await listColumns(tx, workspaceId);
|
|
1793
|
+
if (!await writeStandardFlow(tx, columns)) {
|
|
1794
|
+
const hasDone = columns.some((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
|
|
1795
|
+
throw new BoardError("issue_status_not_found", {
|
|
1796
|
+
workspaceId,
|
|
1797
|
+
statusName: hasDone ? "In Review" : "Done"
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
return await listColumns(tx, workspaceId);
|
|
1801
|
+
}, scope);
|
|
1802
|
+
}
|
|
1803
|
+
/** Done takes cards from the board's In Review and QA; `false`, with nothing written, when the board has no such columns. */
|
|
1804
|
+
async function writeStandardFlow(store, columns) {
|
|
1805
|
+
const flow = standardFlowOf(columns);
|
|
1806
|
+
if (!flow) return false;
|
|
1807
|
+
await store.statuses.update(flow.target.id, { allowedFrom: serializeAllowedFrom(flow.sources.map((column) => column.id)) });
|
|
1808
|
+
return true;
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* Take a deleted column out of every list of where cards may come from. A list
|
|
1812
|
+
* it empties stays empty: a column that took cards only from it does not open
|
|
1813
|
+
* to every column.
|
|
1814
|
+
*/
|
|
1815
|
+
async function dropFromAllowedFrom(store, workspaceId, statusId) {
|
|
1816
|
+
for (const other of await listColumns(store, workspaceId)) {
|
|
1817
|
+
const { allowedFrom } = readFlowRules(other);
|
|
1818
|
+
if (allowedFrom?.includes(statusId)) await store.statuses.update(other.id, { allowedFrom: serializeAllowedFrom(allowedFrom.filter((id) => id !== statusId)) });
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1650
1821
|
/**
|
|
1651
1822
|
* Take an empty column off the board, and number the columns left 0..n-1 in
|
|
1652
1823
|
* the same write.
|
|
@@ -1673,6 +1844,7 @@ async function deleteColumn(store, statusId, scope, actor) {
|
|
|
1673
1844
|
hint: "Move its cards to another column first, or remove it with removeColumn and moveCardsTo."
|
|
1674
1845
|
});
|
|
1675
1846
|
await tx.statuses.delete(statusId);
|
|
1847
|
+
await dropFromAllowedFrom(tx, column.workspaceId, statusId);
|
|
1676
1848
|
await renumberColumns(tx, column.workspaceId);
|
|
1677
1849
|
}, scope);
|
|
1678
1850
|
}
|
|
@@ -1952,24 +2124,46 @@ function pullRequestFactKeys({ owner, repo, number }) {
|
|
|
1952
2124
|
//#endregion
|
|
1953
2125
|
//#region src/ops/entry-rules.ts
|
|
1954
2126
|
/**
|
|
1955
|
-
* The column's entry rules the card does not meet
|
|
1956
|
-
*
|
|
1957
|
-
*
|
|
2127
|
+
* The column's entry rules and flow rules the card does not meet, entry rules
|
|
2128
|
+
* first (ruling 6-2; board-workflow spec, Part 2). A column with no rules
|
|
2129
|
+
* answers at once, without reading anything; otherwise only the facts its
|
|
2130
|
+
* rules need are read. `entering` counts the cards this same write already let
|
|
2131
|
+
* into the column, so a bulk move is held to the WIP limit as a whole.
|
|
1958
2132
|
*/
|
|
1959
|
-
async function checkColumnEntry(store, card, column) {
|
|
2133
|
+
async function checkColumnEntry(store, card, column, entering) {
|
|
1960
2134
|
const rules = readEntryRules(column);
|
|
1961
|
-
|
|
1962
|
-
|
|
2135
|
+
const unmet = rules.length === 0 ? [] : evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
|
|
2136
|
+
const flow = readFlowRules(column);
|
|
2137
|
+
if (flow.allowedFrom === null && flow.wipLimit === null) return unmet;
|
|
2138
|
+
return [...unmet, ...evaluateFlowRules(card, column, flow, await readFlowRuleFacts(store, column, flow, entering))];
|
|
2139
|
+
}
|
|
2140
|
+
/** What the flow rules need: the board's column names when the column names where cards come from, and its card count when it has a limit. */
|
|
2141
|
+
async function readFlowRuleFacts(store, column, flow, entering) {
|
|
2142
|
+
const columnNames = flow.allowedFrom === null ? /* @__PURE__ */ new Map() : new Map((await store.statuses.listByWorkspace(column.workspaceId)).map((other) => [other.id, other.name]));
|
|
2143
|
+
if (flow.wipLimit === null) return {
|
|
2144
|
+
columnNames,
|
|
2145
|
+
cardsInColumn: 0
|
|
2146
|
+
};
|
|
2147
|
+
await store.statuses.lockById(column.id);
|
|
2148
|
+
return {
|
|
2149
|
+
columnNames,
|
|
2150
|
+
cardsInColumn: (await store.issues.listPage({
|
|
2151
|
+
workspaceId: column.workspaceId,
|
|
2152
|
+
statusIds: [column.id],
|
|
2153
|
+
limit: 1
|
|
2154
|
+
})).total + entering
|
|
2155
|
+
};
|
|
1963
2156
|
}
|
|
1964
2157
|
/**
|
|
1965
|
-
* Hold a card at the door of a column that asks for more than it has
|
|
1966
|
-
* 6-3)
|
|
1967
|
-
*
|
|
1968
|
-
*
|
|
1969
|
-
*
|
|
2158
|
+
* Hold a card at the door of a column that asks for more than it has — entry
|
|
2159
|
+
* rules (ruling 6-3), or flow rules: a column it may not come from, or a column
|
|
2160
|
+
* that is full. Anyone but a person — or a person's own terminal,
|
|
2161
|
+
* `placesForPerson` (ruling 6-6) — is refused with `board_column_rules_unmet`,
|
|
2162
|
+
* which names the column and everything unmet; a person is let through, and
|
|
2163
|
+
* the unmet list comes back so the caller can warn them.
|
|
1970
2164
|
*/
|
|
1971
|
-
async function enforceColumnEntry(store, card, column, actor) {
|
|
1972
|
-
const unmet = await checkColumnEntry(store, card, column);
|
|
2165
|
+
async function enforceColumnEntry(store, card, column, actor, entering = 0) {
|
|
2166
|
+
const unmet = await checkColumnEntry(store, card, column, entering);
|
|
1973
2167
|
if (unmet.length > 0 && actor.kind !== "user" && !actor.placesForPerson) throw new BoardError("board_column_rules_unmet", {
|
|
1974
2168
|
issueId: card.id,
|
|
1975
2169
|
column: column.name,
|
|
@@ -1982,14 +2176,17 @@ async function enforceColumnEntry(store, card, column, actor) {
|
|
|
1982
2176
|
* The same door for several cards entering one column at once — a bulk status
|
|
1983
2177
|
* change. All or nothing: the first card an agent may not move refuses the
|
|
1984
2178
|
* whole change. A person gets back the cards that entered with rules unmet.
|
|
2179
|
+
* Each card counts against the column's WIP limit as the cards before it in the same change entered it.
|
|
1985
2180
|
*/
|
|
1986
2181
|
async function enforceColumnEntryForCards(store, issueIds, statusId, actor) {
|
|
1987
2182
|
const column = await store.statuses.findById(statusId);
|
|
1988
2183
|
if (!column) throw new BoardError("issue_status_not_found", { statusId });
|
|
1989
2184
|
const entered = [];
|
|
2185
|
+
let entering = 0;
|
|
1990
2186
|
for (const card of await store.issues.listByIds(issueIds)) {
|
|
1991
2187
|
if (card.statusId === column.id) continue;
|
|
1992
|
-
const unmet = await enforceColumnEntry(store, card, column, actor);
|
|
2188
|
+
const unmet = await enforceColumnEntry(store, card, column, actor, entering);
|
|
2189
|
+
entering++;
|
|
1993
2190
|
if (unmet.length > 0) entered.push({
|
|
1994
2191
|
issueId: card.id,
|
|
1995
2192
|
unmet
|
|
@@ -2144,11 +2341,24 @@ const STOPPED_BY_YOU_STATUS_LINE = "stopped by you";
|
|
|
2144
2341
|
* the board reads the same whether the agent was launched by an app or from
|
|
2145
2342
|
* somewhere else entirely. The attempt number is simply how many runs the card
|
|
2146
2343
|
* has had — a card whose runs were all launched elsewhere still counts them.
|
|
2344
|
+
*
|
|
2345
|
+
* A card has one run going at a time. A start on a card whose run is still going
|
|
2346
|
+
* is refused with `board_card_run_active`, naming that run — whichever path it
|
|
2347
|
+
* came by, an app's launch, `kanbo run start`, MCP, or a second app on a shared
|
|
2348
|
+
* Postgres board. The card's row is locked first, so two starts racing for it
|
|
2349
|
+
* are settled one after the other.
|
|
2147
2350
|
*/
|
|
2148
2351
|
async function startRun(store, issueId, input, actor, scope) {
|
|
2149
2352
|
const externalSessionRef = readExternalSessionRef(input.externalSessionRef) ?? null;
|
|
2150
2353
|
return await runBoardWrite(store, async ({ tx }) => {
|
|
2151
|
-
const card = await
|
|
2354
|
+
const card = await tx.issues.lockById(issueId);
|
|
2355
|
+
if (!card) throw new BoardError("issue_not_found", { issueId });
|
|
2356
|
+
const [active] = await tx.runs.listRunning(issueId);
|
|
2357
|
+
if (active) throw new BoardError("board_card_run_active", {
|
|
2358
|
+
issueId,
|
|
2359
|
+
runId: active.id,
|
|
2360
|
+
agentName: active.agentName
|
|
2361
|
+
});
|
|
2152
2362
|
const attempt = await tx.runs.countByIssue(issueId) + 1;
|
|
2153
2363
|
const executionMode = input.executionMode ?? card.executionMode;
|
|
2154
2364
|
const run = await tx.runs.create({
|
|
@@ -2414,7 +2624,8 @@ async function createCard(store, input, actor, scope) {
|
|
|
2414
2624
|
const column = statusId === null ? null : await tx.statuses.findById(statusId);
|
|
2415
2625
|
const unmet = column ? await enforceColumnEntry(tx, {
|
|
2416
2626
|
id: null,
|
|
2417
|
-
description: input.description ?? null
|
|
2627
|
+
description: input.description ?? null,
|
|
2628
|
+
statusId: null
|
|
2418
2629
|
}, column, actor) : [];
|
|
2419
2630
|
const milestoneId = await requireMilestoneInWorkspace(tx, workspaceId, input.milestoneId ?? null);
|
|
2420
2631
|
const parentIssueId = await requireParentInWorkspace(tx, workspaceId, input.parentIssueId ?? null);
|
|
@@ -2422,8 +2633,9 @@ async function createCard(store, input, actor, scope) {
|
|
|
2422
2633
|
const now = currentUnixSeconds();
|
|
2423
2634
|
for (let attempt = 1;; attempt++) {
|
|
2424
2635
|
const identity = await nextIssueIdentity(input.workspace, tx);
|
|
2636
|
+
let created;
|
|
2425
2637
|
try {
|
|
2426
|
-
|
|
2638
|
+
created = await tx.issues.create({
|
|
2427
2639
|
id: identity.id,
|
|
2428
2640
|
workspaceId,
|
|
2429
2641
|
title: input.title?.trim() || identity.id,
|
|
@@ -2447,12 +2659,25 @@ async function createCard(store, input, actor, scope) {
|
|
|
2447
2659
|
order,
|
|
2448
2660
|
createdAt: now,
|
|
2449
2661
|
updatedAt: now
|
|
2450
|
-
})
|
|
2662
|
+
});
|
|
2451
2663
|
} catch (error) {
|
|
2452
2664
|
const numberTaken = await tx.issues.findByNumber(workspaceId, identity.number);
|
|
2453
2665
|
const keyTaken = await tx.issues.findById(identity.id);
|
|
2454
2666
|
if (attempt >= NUMBERING_ATTEMPTS || !numberTaken && !keyTaken) throw error;
|
|
2667
|
+
continue;
|
|
2455
2668
|
}
|
|
2669
|
+
if (statusId !== null) await tx.fieldChanges.create({
|
|
2670
|
+
id: randomUUID(),
|
|
2671
|
+
issueId: created.id,
|
|
2672
|
+
field: "statusId",
|
|
2673
|
+
fromValue: null,
|
|
2674
|
+
toValue: statusId,
|
|
2675
|
+
actorKind: toStoredActorKind(actor),
|
|
2676
|
+
actorId: actor.id ?? null,
|
|
2677
|
+
sourceChatSessionId: actor.chatSessionId ?? null,
|
|
2678
|
+
createdAt: now
|
|
2679
|
+
});
|
|
2680
|
+
return withUnmetRules(created, unmet);
|
|
2456
2681
|
}
|
|
2457
2682
|
}, scope);
|
|
2458
2683
|
}
|
|
@@ -2508,7 +2733,8 @@ async function updateCard(store, issueId, input, actor, scope) {
|
|
|
2508
2733
|
const description = "description" in updates ? updates.description ?? null : card.description;
|
|
2509
2734
|
unmet = await enforceColumnEntry(tx, {
|
|
2510
2735
|
id: card.id,
|
|
2511
|
-
description
|
|
2736
|
+
description,
|
|
2737
|
+
statusId: card.statusId
|
|
2512
2738
|
}, column, actor);
|
|
2513
2739
|
}
|
|
2514
2740
|
if ("assigneeKind" in input) updates.assigneeKind = input.assigneeKind ?? null;
|
|
@@ -2811,12 +3037,33 @@ async function requireParentInWorkspace(store, workspaceId, parentIssueId, issue
|
|
|
2811
3037
|
issueId,
|
|
2812
3038
|
parentIssueId
|
|
2813
3039
|
});
|
|
2814
|
-
|
|
3040
|
+
const parent = await store.issues.findInWorkspace(workspaceId, parentIssueId);
|
|
3041
|
+
if (!parent) throw new BoardError("issue_parent_not_found", {
|
|
2815
3042
|
workspaceId,
|
|
2816
3043
|
parentIssueId
|
|
2817
3044
|
});
|
|
3045
|
+
if (issueId !== void 0 && await isUnder(store, parent, issueId)) throw new BoardError("issue_parent_cycle", {
|
|
3046
|
+
issueId,
|
|
3047
|
+
parentIssueId
|
|
3048
|
+
});
|
|
2818
3049
|
return parentIssueId;
|
|
2819
3050
|
}
|
|
3051
|
+
/**
|
|
3052
|
+
* Is the card somewhere under `ancestorId` — its parent, its parent's parent,
|
|
3053
|
+
* and so on up? A card put under one of its own sub-cards would make a loop no
|
|
3054
|
+
* reader of the board can walk to the top of. The cards already seen end the
|
|
3055
|
+
* walk, so a loop the data holds from before cannot hang it.
|
|
3056
|
+
*/
|
|
3057
|
+
async function isUnder(store, card, ancestorId) {
|
|
3058
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3059
|
+
let id = card.parentIssueId;
|
|
3060
|
+
while (id !== null && !seen.has(id)) {
|
|
3061
|
+
if (id === ancestorId) return true;
|
|
3062
|
+
seen.add(id);
|
|
3063
|
+
id = (await store.issues.findById(id))?.parentIssueId ?? null;
|
|
3064
|
+
}
|
|
3065
|
+
return false;
|
|
3066
|
+
}
|
|
2820
3067
|
/** Is the card's own number already taken by another card in the target workspace? */
|
|
2821
3068
|
async function numberTakenByAnother(store, workspaceId, card) {
|
|
2822
3069
|
const holder = await store.issues.findByNumber(workspaceId, card.number);
|
|
@@ -2886,6 +3133,160 @@ async function reorderCards(store, workspaceId, orderedIds, scope) {
|
|
|
2886
3133
|
}, scope);
|
|
2887
3134
|
}
|
|
2888
3135
|
//#endregion
|
|
3136
|
+
//#region src/ops/ready.ts
|
|
3137
|
+
/** The column an agent takes its next card from. */
|
|
3138
|
+
const READY_COLUMN_SLUG = "to_do";
|
|
3139
|
+
/**
|
|
3140
|
+
* The cards an agent may pick up right now: spelled out, nobody working on them,
|
|
3141
|
+
* nobody's turn but the agent's.
|
|
3142
|
+
*
|
|
3143
|
+
* "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
|
|
3144
|
+
* run is taken, and a card waiting for a person belongs to that person until
|
|
3145
|
+
* they answer. What is left is the queue — in board order, so the card someone
|
|
3146
|
+
* dragged to the top is the one an agent asking for work gets.
|
|
3147
|
+
*/
|
|
3148
|
+
async function listReady(store, input) {
|
|
3149
|
+
return (await queryReady(store, input)).cards;
|
|
3150
|
+
}
|
|
3151
|
+
/**
|
|
3152
|
+
* One page of the ready cards, how many are ready in all, and the columns read
|
|
3153
|
+
* on the way — the rule above, asked of the database in one statement rather
|
|
3154
|
+
* than of every card on the board.
|
|
3155
|
+
*/
|
|
3156
|
+
async function queryReady(store, input) {
|
|
3157
|
+
const columns = await listColumns(store, input.workspaceId);
|
|
3158
|
+
const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
|
|
3159
|
+
if (!column) return {
|
|
3160
|
+
cards: [],
|
|
3161
|
+
total: 0,
|
|
3162
|
+
columns
|
|
3163
|
+
};
|
|
3164
|
+
return {
|
|
3165
|
+
...await store.issues.listPage({
|
|
3166
|
+
workspaceId: input.workspaceId,
|
|
3167
|
+
statusIds: [column.id],
|
|
3168
|
+
waitingForPerson: false,
|
|
3169
|
+
hasActiveRun: false,
|
|
3170
|
+
limit: input.limit,
|
|
3171
|
+
offset: input.offset
|
|
3172
|
+
}),
|
|
3173
|
+
columns
|
|
3174
|
+
};
|
|
3175
|
+
}
|
|
3176
|
+
//#endregion
|
|
3177
|
+
//#region src/ops/claim.ts
|
|
3178
|
+
/**
|
|
3179
|
+
* Take a card to work on, in one board write: pick it, start its run, and move
|
|
3180
|
+
* it into the board's first `started` column (In Progress on a standard
|
|
3181
|
+
* board), its entry and flow rules checked on the way in. Anything refused
|
|
3182
|
+
* leaves nothing written.
|
|
3183
|
+
*
|
|
3184
|
+
* Two claimers never get the same card. On SQLite the write holds the file
|
|
3185
|
+
* from its first read (`begin immediate`), so the second claimer reads the
|
|
3186
|
+
* board after the first one's commit. On Postgres the picked card's row is
|
|
3187
|
+
* locked (`select … for update`): a claimer that waited on the lock finds the
|
|
3188
|
+
* card taken, and moves on to the next ready one. The target's row is locked
|
|
3189
|
+
* first, always, in the order a move into it locks (column, then card), so a
|
|
3190
|
+
* claim and a move never wait on each other in a circle.
|
|
3191
|
+
*/
|
|
3192
|
+
async function claimCard(store, input, actor, scope) {
|
|
3193
|
+
return await runBoardWrite(store, async ({ tx }) => {
|
|
3194
|
+
const columns = await listColumns(tx, input.workspaceId);
|
|
3195
|
+
const target = columns.find((column) => column.category === "started");
|
|
3196
|
+
if (!target) throw new BoardError("issue_status_not_found", {
|
|
3197
|
+
workspaceId: input.workspaceId,
|
|
3198
|
+
category: "started"
|
|
3199
|
+
});
|
|
3200
|
+
await tx.statuses.lockById(target.id);
|
|
3201
|
+
const toDo = columns.find((column) => normalizeStatusName(column.name) === "to_do") ?? null;
|
|
3202
|
+
const card = input.issueId === void 0 ? await takeFirstReady(tx, input.workspaceId, toDo) : await takeNamed(tx, input.workspaceId, input.issueId, toDo);
|
|
3203
|
+
const run = await startRun(tx, card.id, input.run, actor, { tx });
|
|
3204
|
+
return {
|
|
3205
|
+
card: await updateCard(tx, card.id, { statusId: target.id }, actor, { tx }),
|
|
3206
|
+
run,
|
|
3207
|
+
column: target
|
|
3208
|
+
};
|
|
3209
|
+
}, scope);
|
|
3210
|
+
}
|
|
3211
|
+
/**
|
|
3212
|
+
* The first ready card, locked. A card another claimer took while this one
|
|
3213
|
+
* waited for its lock is passed over, and the next read no longer lists it; the
|
|
3214
|
+
* cards passed over are counted so the loop ends with the list.
|
|
3215
|
+
*/
|
|
3216
|
+
async function takeFirstReady(store, workspaceId, toDo) {
|
|
3217
|
+
const passed = /* @__PURE__ */ new Set();
|
|
3218
|
+
for (;;) {
|
|
3219
|
+
const { cards } = await queryReady(store, {
|
|
3220
|
+
workspaceId,
|
|
3221
|
+
limit: passed.size + 1
|
|
3222
|
+
});
|
|
3223
|
+
const candidate = cards.find((card) => !passed.has(card.id));
|
|
3224
|
+
if (!candidate) throw new BoardError("board_nothing_ready", { workspaceId });
|
|
3225
|
+
const locked = await store.issues.lockById(candidate.id);
|
|
3226
|
+
if (locked && await notReadyReason(store, locked, toDo) === null) return locked;
|
|
3227
|
+
passed.add(candidate.id);
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
/** The named card, locked, when it is on this board and ready — or why not. */
|
|
3231
|
+
async function takeNamed(store, workspaceId, issueId, toDo) {
|
|
3232
|
+
const card = await store.issues.lockById(issueId);
|
|
3233
|
+
if (!card || card.workspaceId !== workspaceId) throw new BoardError("issue_not_found", { issueId });
|
|
3234
|
+
const reason = await notReadyReason(store, card, toDo);
|
|
3235
|
+
if (reason) throw new BoardError("board_card_not_ready", {
|
|
3236
|
+
issueId,
|
|
3237
|
+
reason
|
|
3238
|
+
});
|
|
3239
|
+
return card;
|
|
3240
|
+
}
|
|
3241
|
+
/** `queryReady`'s rule for one card already read: nobody on it, nobody waited for, in To Do. */
|
|
3242
|
+
async function notReadyReason(store, card, toDo) {
|
|
3243
|
+
if ((await store.runs.listRunning(card.id)).length > 0) return "run_active";
|
|
3244
|
+
if (card.waitingFor === "human") return "waiting_for_person";
|
|
3245
|
+
return toDo !== null && card.statusId === toDo.id ? null : "not_in_to_do";
|
|
3246
|
+
}
|
|
3247
|
+
//#endregion
|
|
3248
|
+
//#region src/ops/column-entries.ts
|
|
3249
|
+
/**
|
|
3250
|
+
* Every entry into a column on the named boards at or after `since`, in order
|
|
3251
|
+
* of `(at, changeId)` — the one signal an app dispatching work on column
|
|
3252
|
+
* changes needs. It sees every writer, an outside `kanbo` on the same database
|
|
3253
|
+
* included, because it reads the cards' own history.
|
|
3254
|
+
*
|
|
3255
|
+
* A page never ends inside a second: when `limit` cuts into one, the rest of
|
|
3256
|
+
* that second comes too, so a page may be longer than `limit`. A reader keeps
|
|
3257
|
+
* its cursor at the last `at` it read after a short page — a write in that
|
|
3258
|
+
* second may still commit — and drops the `changeId`s it has seen; after a page
|
|
3259
|
+
* of `limit` or more it moves on to the last `at` + 1, which a second holding
|
|
3260
|
+
* more entries than `limit` would otherwise never let it pass.
|
|
3261
|
+
*/
|
|
3262
|
+
async function listColumnEntries(store, input) {
|
|
3263
|
+
const page = await store.fieldChanges.listByFieldSince("statusId", input);
|
|
3264
|
+
return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
|
|
3265
|
+
}
|
|
3266
|
+
/** The page with every change of its last second, not only those `limit` let in. */
|
|
3267
|
+
async function withWholeLastSecond(store, input, page) {
|
|
3268
|
+
const last = page.at(-1).createdAt;
|
|
3269
|
+
const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
|
|
3270
|
+
workspaceIds: input.workspaceIds,
|
|
3271
|
+
since: last,
|
|
3272
|
+
until: last
|
|
3273
|
+
});
|
|
3274
|
+
return [...page.filter((change) => change.createdAt < last), ...lastSecond];
|
|
3275
|
+
}
|
|
3276
|
+
/** A status change as an entry; a change that took the card out of every column enters none. */
|
|
3277
|
+
function toColumnEntry(change) {
|
|
3278
|
+
return change.toValue === null ? [] : [{
|
|
3279
|
+
changeId: change.id,
|
|
3280
|
+
issueId: change.issueId,
|
|
3281
|
+
workspaceId: change.workspaceId,
|
|
3282
|
+
fromStatusId: change.fromValue,
|
|
3283
|
+
toStatusId: change.toValue,
|
|
3284
|
+
actorKind: change.actorKind,
|
|
3285
|
+
actorId: change.actorId,
|
|
3286
|
+
at: change.createdAt
|
|
3287
|
+
}];
|
|
3288
|
+
}
|
|
3289
|
+
//#endregion
|
|
2889
3290
|
//#region src/ops/context-refs.ts
|
|
2890
3291
|
const ContextRefsJsonSchema = z.string().transform((raw) => JSON.parse(raw)).pipe(z.array(z.string()));
|
|
2891
3292
|
/** Add a context ref at the end of the card's list, recording the change in its history. */
|
|
@@ -3070,6 +3471,7 @@ function subtaskSplitSentence(parentReference) {
|
|
|
3070
3471
|
}
|
|
3071
3472
|
const SUBTASK_RULE = `${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("the parent card's id")}`;
|
|
3072
3473
|
const PULL_REQUEST_RULE = "Opened a pull request? Link it to the card. Its state and CI checks then reach the card as comments on their own — nothing else tells the board a pull request exists.";
|
|
3474
|
+
const CLAIM_RULE = "Taking a card from To Do yourself? Claim it instead of starting a run and moving it: the claim does both in one step, and no other agent gets the same card. Finish the run when you stop.";
|
|
3073
3475
|
const OWN_SESSION_RULE = "Started the work yourself, not launched by an app that tracks the run for you? Record the run when you start and finish it when you stop. kanbo fills in the Claude Code or Codex session you are in; never invent a session id.";
|
|
3074
3476
|
/** Said by the `kanbo init` block and the MCP instructions, which name the comment command or tool beside it. */
|
|
3075
3477
|
const COMMENT_RULE = "Findings, results, decisions and questions go on the card as comments.";
|
|
@@ -3093,6 +3495,7 @@ const BOARD_RULES = [
|
|
|
3093
3495
|
EXECUTION_MODE_RULE,
|
|
3094
3496
|
SUBTASK_RULE,
|
|
3095
3497
|
PULL_REQUEST_RULE,
|
|
3498
|
+
CLAIM_RULE,
|
|
3096
3499
|
OWN_SESSION_RULE,
|
|
3097
3500
|
"Read the column descriptions above before moving a card, and do all of this through the board tools or commands you have — a statement in your reply changes nothing."
|
|
3098
3501
|
];
|
|
@@ -3108,12 +3511,14 @@ const BOARD_RULES = [
|
|
|
3108
3511
|
async function buildPrimeText(store, workspaceId) {
|
|
3109
3512
|
const columns = await listColumns(store, workspaceId);
|
|
3110
3513
|
const ruled = columns.some((column) => readEntryRules(column).length > 0);
|
|
3514
|
+
const flowing = columns.some((column) => hasFlowRules(column));
|
|
3111
3515
|
return [
|
|
3112
3516
|
...await describeReturnedCards(store, workspaceId, columns),
|
|
3113
3517
|
"Columns of this board, in board order:",
|
|
3114
3518
|
"",
|
|
3115
|
-
...columns.length > 0 ? columns.map(describeColumnLine) : ["- (this board has no columns yet)"],
|
|
3519
|
+
...columns.length > 0 ? columns.map((column) => describeColumnLine(column, columns)) : ["- (this board has no columns yet)"],
|
|
3116
3520
|
...ruled ? ["", ENTRY_RULES_NOTE] : [],
|
|
3521
|
+
...flowing ? ["", FLOW_RULES_NOTE] : [],
|
|
3117
3522
|
"",
|
|
3118
3523
|
"How this board works:",
|
|
3119
3524
|
"",
|
|
@@ -3155,11 +3560,22 @@ async function describeReturnedCards(store, workspaceId, columns) {
|
|
|
3155
3560
|
* without any reads exactly as it did before they existed.
|
|
3156
3561
|
*/
|
|
3157
3562
|
const ENTRY_RULES_NOTE = "A column that lists requirements refuses a card that does not meet them yet, with the list of what is missing: do that work first, then move the card. A person may still move it.";
|
|
3158
|
-
|
|
3563
|
+
/** Said under the columns only when one of them has flow rules. */
|
|
3564
|
+
const FLOW_RULES_NOTE = "A column that says which columns it accepts cards from, or how many cards it holds, refuses a move that breaks that and says why: move the card through a column it accepts, or wait until a card leaves. A person may still move it.";
|
|
3565
|
+
function hasFlowRules(column) {
|
|
3566
|
+
const { allowedFrom, wipLimit } = readFlowRules(column);
|
|
3567
|
+
return allowedFrom !== null || wipLimit !== null;
|
|
3568
|
+
}
|
|
3569
|
+
function describeColumnLine(column, columns) {
|
|
3159
3570
|
const slug = normalizeStatusName(column.name);
|
|
3160
3571
|
const rules = readEntryRules(column);
|
|
3161
|
-
const
|
|
3162
|
-
return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${
|
|
3572
|
+
const said = `${rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : ""}${describeFlow(column, columns)}`;
|
|
3573
|
+
return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${said}` : `- ${column.name} (\`${slug}\`)${said ? ` —${said}` : ""}`;
|
|
3574
|
+
}
|
|
3575
|
+
/** Where a column takes cards from and how many it holds, as the prime text says it; nothing for a column without flow rules. */
|
|
3576
|
+
function describeFlow(column, columns) {
|
|
3577
|
+
const { allowedFrom, wipLimit } = readFlowRules(column);
|
|
3578
|
+
return `${allowedFrom === null ? "" : allowedFrom.length === 0 ? " Accepts no card from another column: only a person puts a card here." : ` Accepts cards from ${joinWords(columns.filter((other) => allowedFrom.includes(other.id)).map((other) => `\`${normalizeStatusName(other.name)}\``))} only.`}${wipLimit === null ? "" : ` Holds at most ${wipLimit} ${wipLimit === 1 ? "card" : "cards"}.`}`;
|
|
3163
3579
|
}
|
|
3164
3580
|
//#endregion
|
|
3165
3581
|
//#region src/domain/pull-request-ref.ts
|
|
@@ -3329,47 +3745,6 @@ async function queryCards(store, input) {
|
|
|
3329
3745
|
};
|
|
3330
3746
|
}
|
|
3331
3747
|
//#endregion
|
|
3332
|
-
//#region src/ops/ready.ts
|
|
3333
|
-
/** The column an agent takes its next card from. */
|
|
3334
|
-
const READY_COLUMN_SLUG = "to_do";
|
|
3335
|
-
/**
|
|
3336
|
-
* The cards an agent may pick up right now: spelled out, nobody working on them,
|
|
3337
|
-
* nobody's turn but the agent's.
|
|
3338
|
-
*
|
|
3339
|
-
* "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
|
|
3340
|
-
* run is taken, and a card waiting for a person belongs to that person until
|
|
3341
|
-
* they answer. What is left is the queue — in board order, so the card someone
|
|
3342
|
-
* dragged to the top is the one an agent asking for work gets.
|
|
3343
|
-
*/
|
|
3344
|
-
async function listReady(store, input) {
|
|
3345
|
-
return (await queryReady(store, input)).cards;
|
|
3346
|
-
}
|
|
3347
|
-
/**
|
|
3348
|
-
* One page of the ready cards, how many are ready in all, and the columns read
|
|
3349
|
-
* on the way — the rule above, asked of the database in one statement rather
|
|
3350
|
-
* than of every card on the board.
|
|
3351
|
-
*/
|
|
3352
|
-
async function queryReady(store, input) {
|
|
3353
|
-
const columns = await listColumns(store, input.workspaceId);
|
|
3354
|
-
const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
|
|
3355
|
-
if (!column) return {
|
|
3356
|
-
cards: [],
|
|
3357
|
-
total: 0,
|
|
3358
|
-
columns
|
|
3359
|
-
};
|
|
3360
|
-
return {
|
|
3361
|
-
...await store.issues.listPage({
|
|
3362
|
-
workspaceId: input.workspaceId,
|
|
3363
|
-
statusIds: [column.id],
|
|
3364
|
-
waitingForPerson: false,
|
|
3365
|
-
hasActiveRun: false,
|
|
3366
|
-
limit: input.limit,
|
|
3367
|
-
offset: input.offset
|
|
3368
|
-
}),
|
|
3369
|
-
columns
|
|
3370
|
-
};
|
|
3371
|
-
}
|
|
3372
|
-
//#endregion
|
|
3373
3748
|
//#region src/ops/relations.ts
|
|
3374
3749
|
/**
|
|
3375
3750
|
* Every edge touching the card, in either direction, each with the card on its
|
|
@@ -3659,6 +4034,8 @@ function createBoardOps(store) {
|
|
|
3659
4034
|
updateColumn: async (statusId, patch, actor, scope) => await updateColumn(store, statusId, patch, actor, scope),
|
|
3660
4035
|
describeColumn: async (workspaceId, nameOrId, description, scope) => await describeColumn(store, workspaceId, nameOrId, description, scope),
|
|
3661
4036
|
setColumnEntryRules: async (workspaceId, nameOrId, entryRules, actor, scope) => await setColumnEntryRules(store, workspaceId, nameOrId, entryRules, actor, scope),
|
|
4037
|
+
setColumnFlowRules: async (columnId, rules, actor, scope) => await setColumnFlowRules(store, columnId, rules, actor, scope),
|
|
4038
|
+
applyStandardFlow: async (workspaceId, actor, scope) => await applyStandardFlow(store, workspaceId, actor, scope),
|
|
3662
4039
|
enforceColumnEntryForCards: async (issueIds, statusId, actor) => await enforceColumnEntryForCards(store, issueIds, statusId, actor),
|
|
3663
4040
|
deleteColumn: async (statusId, scope, actor) => await deleteColumn(store, statusId, scope, actor),
|
|
3664
4041
|
reorderColumns: async (workspaceId, orderedIds, scope, actor) => await reorderColumns(store, workspaceId, orderedIds, scope, actor),
|
|
@@ -3669,6 +4046,7 @@ function createBoardOps(store) {
|
|
|
3669
4046
|
moveColumn: async (workspaceId, nameOrId, position, actor, scope) => await moveColumn$1(store, workspaceId, nameOrId, position, actor, scope),
|
|
3670
4047
|
removeColumn: async (workspaceId, nameOrId, options, actor, scope) => await removeColumn$1(store, workspaceId, nameOrId, options, actor, scope),
|
|
3671
4048
|
startRun: async (issueId, input, actor, scope) => await startRun(store, issueId, input, actor, scope),
|
|
4049
|
+
claimCard: async (input, actor, scope) => await claimCard(store, input, actor, scope),
|
|
3672
4050
|
attachRunExecution: async (runId, input, scope) => await attachRunExecution(store, runId, input, scope),
|
|
3673
4051
|
recordRunSessionRef: async (runId, ref, scope) => await recordRunSessionRef(store, runId, ref, scope),
|
|
3674
4052
|
clearRunSessionRef: async (runId, actor, options, scope) => await clearRunSessionRef(store, runId, actor, options, scope),
|
|
@@ -3681,66 +4059,11 @@ function createBoardOps(store) {
|
|
|
3681
4059
|
readBoardProjectionForIssue: async (issueId) => await readBoardProjectionForIssue(store, issueId),
|
|
3682
4060
|
listReady: async (input) => await listReady(store, input),
|
|
3683
4061
|
queryReady: async (input) => await queryReady(store, input),
|
|
4062
|
+
listColumnEntries: async (input) => await listColumnEntries(store, input),
|
|
3684
4063
|
buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId)
|
|
3685
4064
|
};
|
|
3686
4065
|
}
|
|
3687
4066
|
//#endregion
|
|
3688
|
-
//#region src/domain/database-url.ts
|
|
3689
|
-
/**
|
|
3690
|
-
* A board's connection string, as it may be said out loud.
|
|
3691
|
-
*
|
|
3692
|
-
* Everything a person needs to recognise which board they are talking to is
|
|
3693
|
-
* the host and the database name. The password is the one part that must never
|
|
3694
|
-
* reach a terminal, a log, a `--json` result or a tool's answer — and it is not
|
|
3695
|
-
* the only part: a connection string carries options in its query, and a
|
|
3696
|
-
* `?password=…` there is as much a secret as the one in front of the host.
|
|
3697
|
-
*
|
|
3698
|
-
* It is a fact about the value rather than about the command line, which is why
|
|
3699
|
-
* it lives here: the MCP tools hand back messages they did not write either,
|
|
3700
|
-
* and they must not be made to carry the board tool's own runtime to say them
|
|
3701
|
-
* safely.
|
|
3702
|
-
*/
|
|
3703
|
-
/** What a connection string is called when there is nothing safe to print of it. */
|
|
3704
|
-
const MASKED_UNREADABLE_URL = "postgres://***";
|
|
3705
|
-
/** What is printed in place of a password, and of a query that may hide one. */
|
|
3706
|
-
const MASK = "***";
|
|
3707
|
-
/**
|
|
3708
|
-
* A connection string inside a longer line. Anything but whitespace and the
|
|
3709
|
-
* quotes a message may wrap it in belongs to the URL — a password is allowed
|
|
3710
|
-
* every character a URL is, so the match has to be greedy to be safe.
|
|
3711
|
-
*/
|
|
3712
|
-
const DATABASE_URL_PATTERN = /\bpostgres(?:ql)?:\/\/[^\s'"`]+/gi;
|
|
3713
|
-
/**
|
|
3714
|
-
* The connection string with everything secret taken out of it.
|
|
3715
|
-
*
|
|
3716
|
-
* A string this cannot take apart is not examined to find out why — it is
|
|
3717
|
-
* replaced entirely, because whatever is wrong with it, the secret is still
|
|
3718
|
-
* somewhere inside. A missing scheme counts as that: `user:secret@host/db`
|
|
3719
|
-
* parses, as a `user:` URL whose whole password is in the path, and printing it
|
|
3720
|
-
* back would say the secret out loud.
|
|
3721
|
-
*/
|
|
3722
|
-
function maskDatabaseUrl(url) {
|
|
3723
|
-
try {
|
|
3724
|
-
const parsed = new URL(url);
|
|
3725
|
-
if (!parsed.host) return MASKED_UNREADABLE_URL;
|
|
3726
|
-
if (parsed.password) parsed.password = MASK;
|
|
3727
|
-
if (parsed.search) parsed.search = MASK;
|
|
3728
|
-
return parsed.toString();
|
|
3729
|
-
} catch {
|
|
3730
|
-
return MASKED_UNREADABLE_URL;
|
|
3731
|
-
}
|
|
3732
|
-
}
|
|
3733
|
-
/**
|
|
3734
|
-
* Every connection string inside a line of text, masked.
|
|
3735
|
-
*
|
|
3736
|
-
* A message this package did not write — a driver's, a migrator's — may carry
|
|
3737
|
-
* the whole connection string in the middle of a sentence, and it reaches the
|
|
3738
|
-
* same terminal, or the same agent, as everything else.
|
|
3739
|
-
*/
|
|
3740
|
-
function maskDatabaseUrls(text) {
|
|
3741
|
-
return text.replace(DATABASE_URL_PATTERN, (url) => maskDatabaseUrl(url));
|
|
3742
|
-
}
|
|
3743
|
-
//#endregion
|
|
3744
4067
|
//#region src/cli/setup/fs-retry.ts
|
|
3745
4068
|
/**
|
|
3746
4069
|
* A file operation tried again while something else holds the file.
|
|
@@ -4020,6 +4343,62 @@ function ignoreBoardFile(projectDir, absoluteDbPath) {
|
|
|
4020
4343
|
writeFileSync(path, kept ? `${kept}\n${missing.join("\n")}\n` : `${missing.join("\n")}\n`);
|
|
4021
4344
|
}
|
|
4022
4345
|
//#endregion
|
|
4346
|
+
//#region src/domain/database-url.ts
|
|
4347
|
+
/**
|
|
4348
|
+
* A board's connection string, as it may be said out loud.
|
|
4349
|
+
*
|
|
4350
|
+
* Everything a person needs to recognise which board they are talking to is
|
|
4351
|
+
* the host and the database name. The password is the one part that must never
|
|
4352
|
+
* reach a terminal, a log, a `--json` result or a tool's answer — and it is not
|
|
4353
|
+
* the only part: a connection string carries options in its query, and a
|
|
4354
|
+
* `?password=…` there is as much a secret as the one in front of the host.
|
|
4355
|
+
*
|
|
4356
|
+
* It is a fact about the value rather than about the command line, which is why
|
|
4357
|
+
* it lives here: the MCP tools hand back messages they did not write either,
|
|
4358
|
+
* and they must not be made to carry the board tool's own runtime to say them
|
|
4359
|
+
* safely.
|
|
4360
|
+
*/
|
|
4361
|
+
/** What a connection string is called when there is nothing safe to print of it. */
|
|
4362
|
+
const MASKED_UNREADABLE_URL = "postgres://***";
|
|
4363
|
+
/** What is printed in place of a password, and of a query that may hide one. */
|
|
4364
|
+
const MASK = "***";
|
|
4365
|
+
/**
|
|
4366
|
+
* A connection string inside a longer line. Anything but whitespace and the
|
|
4367
|
+
* quotes a message may wrap it in belongs to the URL — a password is allowed
|
|
4368
|
+
* every character a URL is, so the match has to be greedy to be safe.
|
|
4369
|
+
*/
|
|
4370
|
+
const DATABASE_URL_PATTERN = /\bpostgres(?:ql)?:\/\/[^\s'"`]+/gi;
|
|
4371
|
+
/**
|
|
4372
|
+
* The connection string with everything secret taken out of it.
|
|
4373
|
+
*
|
|
4374
|
+
* A string this cannot take apart is not examined to find out why — it is
|
|
4375
|
+
* replaced entirely, because whatever is wrong with it, the secret is still
|
|
4376
|
+
* somewhere inside. A missing scheme counts as that: `user:secret@host/db`
|
|
4377
|
+
* parses, as a `user:` URL whose whole password is in the path, and printing it
|
|
4378
|
+
* back would say the secret out loud.
|
|
4379
|
+
*/
|
|
4380
|
+
function maskDatabaseUrl(url) {
|
|
4381
|
+
try {
|
|
4382
|
+
const parsed = new URL(url);
|
|
4383
|
+
if (!parsed.host) return MASKED_UNREADABLE_URL;
|
|
4384
|
+
if (parsed.password) parsed.password = MASK;
|
|
4385
|
+
if (parsed.search) parsed.search = MASK;
|
|
4386
|
+
return parsed.toString();
|
|
4387
|
+
} catch {
|
|
4388
|
+
return MASKED_UNREADABLE_URL;
|
|
4389
|
+
}
|
|
4390
|
+
}
|
|
4391
|
+
/**
|
|
4392
|
+
* Every connection string inside a line of text, masked.
|
|
4393
|
+
*
|
|
4394
|
+
* A message this package did not write — a driver's, a migrator's — may carry
|
|
4395
|
+
* the whole connection string in the middle of a sentence, and it reaches the
|
|
4396
|
+
* same terminal, or the same agent, as everything else.
|
|
4397
|
+
*/
|
|
4398
|
+
function maskDatabaseUrls(text) {
|
|
4399
|
+
return text.replace(DATABASE_URL_PATTERN, (url) => maskDatabaseUrl(url));
|
|
4400
|
+
}
|
|
4401
|
+
//#endregion
|
|
4023
4402
|
//#region src/cli/db-target.ts
|
|
4024
4403
|
/**
|
|
4025
4404
|
* Which board the command works on.
|
|
@@ -4268,6 +4647,111 @@ async function openWithPostgresJs(url) {
|
|
|
4268
4647
|
};
|
|
4269
4648
|
}
|
|
4270
4649
|
//#endregion
|
|
4650
|
+
//#region src/cli/project-registry.ts
|
|
4651
|
+
/**
|
|
4652
|
+
* Which project folders kanbo has opened a board in.
|
|
4653
|
+
*
|
|
4654
|
+
* Every project keeps its board in its own folder, so nothing else knows which
|
|
4655
|
+
* other projects exist. This list, in the user's home, is what lets one board
|
|
4656
|
+
* page offer them all. It holds a folder and a time — never a connection
|
|
4657
|
+
* string, never board data — and deleting it is safe: it fills again as the
|
|
4658
|
+
* projects are used.
|
|
4659
|
+
*/
|
|
4660
|
+
/** The file's name inside kanbo's per-user folder. */
|
|
4661
|
+
const REGISTRY_FILE_NAME = "projects.json";
|
|
4662
|
+
/** The file is private to the user, as a binding is. */
|
|
4663
|
+
const REGISTRY_FILE_MODE = 384;
|
|
4664
|
+
/** The folder holding it is private to the user too. */
|
|
4665
|
+
const REGISTRY_DIRECTORY_MODE = 448;
|
|
4666
|
+
/**
|
|
4667
|
+
* A project noted within this many seconds is not noted again, so an ordinary
|
|
4668
|
+
* command does not rewrite the file: a day is fresh enough for "recently used".
|
|
4669
|
+
*/
|
|
4670
|
+
const RECENTLY_NOTED_SECONDS = 86400;
|
|
4671
|
+
const RegisteredProjectSchema = z.object({
|
|
4672
|
+
path: z.string().min(1),
|
|
4673
|
+
lastSeenAt: z.number().int().nonnegative()
|
|
4674
|
+
});
|
|
4675
|
+
const RegistrySchema = z.object({
|
|
4676
|
+
schemaVersion: z.literal(1),
|
|
4677
|
+
projects: z.array(RegisteredProjectSchema)
|
|
4678
|
+
});
|
|
4679
|
+
/** `$KANBO_HOME/projects.json`, else `~/.kanbo/projects.json`. */
|
|
4680
|
+
function projectRegistryPath(env = process.env) {
|
|
4681
|
+
const home = env.KANBO_HOME?.trim();
|
|
4682
|
+
return join(home || join(homedir(), ".kanbo"), REGISTRY_FILE_NAME);
|
|
4683
|
+
}
|
|
4684
|
+
/** The projects, most recently seen first; `[]` when the file is missing or does not parse. */
|
|
4685
|
+
function readProjectRegistry(path = projectRegistryPath()) {
|
|
4686
|
+
return readEntries(path).sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
4687
|
+
}
|
|
4688
|
+
/**
|
|
4689
|
+
* Note that `projectDir` was just used. Writes only when the folder is new or
|
|
4690
|
+
* was last noted more than a day ago, so ordinary commands do not rewrite the file.
|
|
4691
|
+
* Drops entries whose `.kanbo/binding.json` is gone.
|
|
4692
|
+
*/
|
|
4693
|
+
function rememberProject(projectDir, options = {}) {
|
|
4694
|
+
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
4695
|
+
const path = options.path ?? projectRegistryPath();
|
|
4696
|
+
const canonical = canonicalPath$1(projectDir);
|
|
4697
|
+
const entries = readEntries(path);
|
|
4698
|
+
const known = entries.find((entry) => entry.path === canonical);
|
|
4699
|
+
if (known && now - known.lastSeenAt <= RECENTLY_NOTED_SECONDS) return;
|
|
4700
|
+
const kept = entries.filter((entry) => entry.path !== canonical && existsSync(join(entry.path, BINDING_FILE_PATH)));
|
|
4701
|
+
kept.push({
|
|
4702
|
+
path: canonical,
|
|
4703
|
+
lastSeenAt: now
|
|
4704
|
+
});
|
|
4705
|
+
writeEntries(path, kept);
|
|
4706
|
+
}
|
|
4707
|
+
/**
|
|
4708
|
+
* `rememberProject`, for the places that must not fail because of it: a home
|
|
4709
|
+
* directory that cannot be written is not a reason for `kanbo card list` to fail.
|
|
4710
|
+
* With `KANBO_DEBUG=1` the reason is printed on stderr.
|
|
4711
|
+
*/
|
|
4712
|
+
function rememberProjectQuietly(projectDir) {
|
|
4713
|
+
const path = projectRegistryPath();
|
|
4714
|
+
try {
|
|
4715
|
+
rememberProject(projectDir, { path });
|
|
4716
|
+
} catch (error) {
|
|
4717
|
+
if (process.env.KANBO_DEBUG === "1") console.error(`kanbo: could not note this project in ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4718
|
+
}
|
|
4719
|
+
}
|
|
4720
|
+
function canonicalPath$1(projectDir) {
|
|
4721
|
+
const absolute = resolve(projectDir);
|
|
4722
|
+
try {
|
|
4723
|
+
return realpathSync(absolute);
|
|
4724
|
+
} catch {
|
|
4725
|
+
return absolute;
|
|
4726
|
+
}
|
|
4727
|
+
}
|
|
4728
|
+
function readEntries(path) {
|
|
4729
|
+
try {
|
|
4730
|
+
const parsed = RegistrySchema.safeParse(JSON.parse(readFileSync(path, "utf8")));
|
|
4731
|
+
return parsed.success ? parsed.data.projects : [];
|
|
4732
|
+
} catch {
|
|
4733
|
+
return [];
|
|
4734
|
+
}
|
|
4735
|
+
}
|
|
4736
|
+
function writeEntries(path, projects) {
|
|
4737
|
+
mkdirSync(dirname(path), {
|
|
4738
|
+
recursive: true,
|
|
4739
|
+
mode: REGISTRY_DIRECTORY_MODE
|
|
4740
|
+
});
|
|
4741
|
+
const temporaryPath = `${path}.${process.pid}.tmp`;
|
|
4742
|
+
try {
|
|
4743
|
+
writeFileSync(temporaryPath, `${JSON.stringify(RegistrySchema.parse({
|
|
4744
|
+
schemaVersion: 1,
|
|
4745
|
+
projects
|
|
4746
|
+
}), null, 2)}\n`, { mode: REGISTRY_FILE_MODE });
|
|
4747
|
+
chmodSync(temporaryPath, REGISTRY_FILE_MODE);
|
|
4748
|
+
renameWithRetry(temporaryPath, path);
|
|
4749
|
+
} catch (error) {
|
|
4750
|
+
rmSync(temporaryPath, { force: true });
|
|
4751
|
+
throw error;
|
|
4752
|
+
}
|
|
4753
|
+
}
|
|
4754
|
+
//#endregion
|
|
4271
4755
|
//#region src/cli/schema-guard.ts
|
|
4272
4756
|
const SCHEMA_OUTDATED_MESSAGE = "The app's database is older than this kanbo. Let the app it belongs to update it first.";
|
|
4273
4757
|
/** The same, for a board file this package created and migrates itself. */
|
|
@@ -4281,9 +4765,10 @@ const SCHEMA_NOT_INSTALLED_MESSAGE = "This Postgres database has no kanbo board
|
|
|
4281
4765
|
* caller's own risk but never written to — a write shaped by this build's idea
|
|
4282
4766
|
* of the schema is how a person's board gets a column full of nothing. A file
|
|
4283
4767
|
* of this package's own is this package's to migrate, so the same refusal
|
|
4284
|
-
* points at `kanbo migrate` instead of at the app. Reads
|
|
4285
|
-
*
|
|
4286
|
-
* their cards
|
|
4768
|
+
* points at `kanbo migrate` instead of at the app. Reads are held to the lesser
|
|
4769
|
+
* bar of `assertReadableBoard`: a file only a stamp behind is still read, so a
|
|
4770
|
+
* person who cannot migrate right now can look at their cards, but one missing
|
|
4771
|
+
* a table or column this build reads is refused with the same sentence.
|
|
4287
4772
|
*/
|
|
4288
4773
|
function assertWritableBoard(database, owner) {
|
|
4289
4774
|
try {
|
|
@@ -4294,6 +4779,26 @@ function assertWritableBoard(database, owner) {
|
|
|
4294
4779
|
}
|
|
4295
4780
|
}
|
|
4296
4781
|
/**
|
|
4782
|
+
* Refuse a read of a board file that lacks a table or a column this build
|
|
4783
|
+
* reads — a file a migration behind — with the sentence a write gets, instead
|
|
4784
|
+
* of letting the first statement fail on what is missing. A file whose only
|
|
4785
|
+
* difference is its schema stamp is still read: every statement this build
|
|
4786
|
+
* makes works on it, and a person who cannot migrate now can still look at
|
|
4787
|
+
* their cards.
|
|
4788
|
+
*/
|
|
4789
|
+
function assertReadableBoard(database, owner) {
|
|
4790
|
+
try {
|
|
4791
|
+
assertBoardSchema$1(database);
|
|
4792
|
+
} catch (error) {
|
|
4793
|
+
if (!(error instanceof BoardError && error.code === "board_schema_outdated")) throw error;
|
|
4794
|
+
if (lacksWhatThisBuildReads(error.details)) throw new CliError(3, owner === "kanbo" ? FILE_SCHEMA_OUTDATED_MESSAGE : SCHEMA_OUTDATED_MESSAGE);
|
|
4795
|
+
}
|
|
4796
|
+
}
|
|
4797
|
+
/** Whether `assertBoardSchema` found a table or a column missing, rather than only another stamp. */
|
|
4798
|
+
function lacksWhatThisBuildReads(details) {
|
|
4799
|
+
return details !== void 0 && ("missingTables" in details || "missingAddedTables" in details || "missingColumns" in details);
|
|
4800
|
+
}
|
|
4801
|
+
/**
|
|
4297
4802
|
* Refuse an external board this build cannot speak for — before reading it as
|
|
4298
4803
|
* well as before writing to it.
|
|
4299
4804
|
*
|
|
@@ -4513,15 +5018,17 @@ function withBoardOptions(command) {
|
|
|
4513
5018
|
* as a client is talking to it. A failure on the way out closes the board first
|
|
4514
5019
|
* — a caller that never received a session has nothing to close.
|
|
4515
5020
|
*/
|
|
4516
|
-
async function openBoardSession(options, mode) {
|
|
4517
|
-
const cwd = process.cwd();
|
|
5021
|
+
async function openBoardSession(options, mode, cwd = process.cwd()) {
|
|
4518
5022
|
const target = resolveDbTarget({
|
|
4519
5023
|
explicitPath: options.db,
|
|
4520
5024
|
explicitUrl: options.databaseUrl,
|
|
4521
5025
|
asAgent: options.asAgent,
|
|
4522
5026
|
cwd
|
|
4523
5027
|
});
|
|
4524
|
-
|
|
5028
|
+
const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
|
|
5029
|
+
const found = findBinding(cwd);
|
|
5030
|
+
if (found) rememberProjectQuietly(found.projectDir);
|
|
5031
|
+
return session;
|
|
4525
5032
|
}
|
|
4526
5033
|
async function openFileSession(target, options, mode, cwd) {
|
|
4527
5034
|
const board = await openBoardDatabase(target.path);
|
|
@@ -4530,6 +5037,7 @@ async function openFileSession(target, options, mode, cwd) {
|
|
|
4530
5037
|
assertWritableBoard(board.database, target.owner);
|
|
4531
5038
|
};
|
|
4532
5039
|
if (mode === "write") assertWritableBoard(board.database, target.owner);
|
|
5040
|
+
else assertReadableBoard(board.database, target.owner);
|
|
4533
5041
|
const workspace = resolveWorkspace(target.owner === "kanbo" ? { kind: "file" } : {
|
|
4534
5042
|
kind: "sqlite",
|
|
4535
5043
|
database: board.database
|
|
@@ -4553,8 +5061,9 @@ async function openFileSession(target, options, mode, cwd) {
|
|
|
4553
5061
|
/**
|
|
4554
5062
|
* The same, on a board that is a database of its own.
|
|
4555
5063
|
*
|
|
4556
|
-
* The guard runs whatever the command came to do. A board file
|
|
4557
|
-
*
|
|
5064
|
+
* The guard runs whatever the command came to do. A board file whose only
|
|
5065
|
+
* difference from this build is its schema stamp is still a board worth
|
|
5066
|
+
* reading; a connection string pointing
|
|
4558
5067
|
* at a database with no board in it is nothing to read at all, and every
|
|
4559
5068
|
* command would otherwise fail one statement later with the driver's own words.
|
|
4560
5069
|
*/
|
|
@@ -4852,7 +5361,9 @@ function projectCardActiveRun(run) {
|
|
|
4852
5361
|
startedAt: run.startedAt
|
|
4853
5362
|
};
|
|
4854
5363
|
}
|
|
4855
|
-
|
|
5364
|
+
/** A column as the command line prints it; `board` is every column, to name the ones it accepts cards from. */
|
|
5365
|
+
function projectColumn(column, board) {
|
|
5366
|
+
const { allowedFrom, wipLimit } = readFlowRules(column);
|
|
4856
5367
|
return {
|
|
4857
5368
|
id: column.id,
|
|
4858
5369
|
name: column.name,
|
|
@@ -4860,7 +5371,9 @@ function projectColumn(column) {
|
|
|
4860
5371
|
description: column.description,
|
|
4861
5372
|
category: column.category,
|
|
4862
5373
|
order: column.order,
|
|
4863
|
-
entryRules: readEntryRules(column)
|
|
5374
|
+
entryRules: readEntryRules(column),
|
|
5375
|
+
allowedFrom: allowedFrom === null ? null : board.filter((other) => allowedFrom.includes(other.id)).map((other) => normalizeStatusName(other.name)),
|
|
5376
|
+
wipLimit
|
|
4864
5377
|
};
|
|
4865
5378
|
}
|
|
4866
5379
|
function projectSprint(sprint) {
|
|
@@ -4932,7 +5445,9 @@ function describeColumns(columns) {
|
|
|
4932
5445
|
return columns.map((column) => [
|
|
4933
5446
|
`${padToWidth(sanitizeTerminalText(column.slug), slugWidth)} ${sanitizeTerminalText(column.name)}`,
|
|
4934
5447
|
column.description ? ` — ${sanitizeTerminalText(column.description)}` : "",
|
|
4935
|
-
column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : ""
|
|
5448
|
+
column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : "",
|
|
5449
|
+
column.allowedFrom === null ? "" : ` [from: ${column.allowedFrom.length > 0 ? column.allowedFrom.join(", ") : "no column"}]`,
|
|
5450
|
+
column.wipLimit === null ? "" : ` [wip: ${column.wipLimit}]`
|
|
4936
5451
|
].join("")).join("\n");
|
|
4937
5452
|
}
|
|
4938
5453
|
/** A unix-seconds date as the UTC day it falls on, `—` for none. */
|
|
@@ -5254,6 +5769,52 @@ function registerCapabilitiesCommand(program) {
|
|
|
5254
5769
|
});
|
|
5255
5770
|
}
|
|
5256
5771
|
//#endregion
|
|
5772
|
+
//#region src/cli/commands/claim.ts
|
|
5773
|
+
/**
|
|
5774
|
+
* `kanbo claim [card]` — take a card to work on, in one step.
|
|
5775
|
+
*
|
|
5776
|
+
* The run starts and the card moves out of To Do into the board's first
|
|
5777
|
+
* work-in-progress column in one write, so two agents in two terminals never
|
|
5778
|
+
* take the same card. Filed as a placement: in a person's own terminal the
|
|
5779
|
+
* column's rules warn, in an agent's shell they refuse.
|
|
5780
|
+
*/
|
|
5781
|
+
function registerClaimCommand(program) {
|
|
5782
|
+
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) => {
|
|
5783
|
+
await runBoardCommand(options, "write", async (session) => {
|
|
5784
|
+
const actor = createPlacementActor();
|
|
5785
|
+
const named = reference === void 0 ? void 0 : (await requireCard(session, reference)).id;
|
|
5786
|
+
const claimed = await session.ops.claimCard({
|
|
5787
|
+
workspaceId: session.workspace.id,
|
|
5788
|
+
issueId: named,
|
|
5789
|
+
run: {
|
|
5790
|
+
agentName: options.agent ?? actor.id ?? "agent",
|
|
5791
|
+
externalSessionRef: options.session ?? sessionRefFromEnvironment() ?? void 0,
|
|
5792
|
+
launchedByKind: "external"
|
|
5793
|
+
}
|
|
5794
|
+
}, actor);
|
|
5795
|
+
const [columns, projection, runs] = await Promise.all([
|
|
5796
|
+
session.ops.listColumns(session.workspace.id),
|
|
5797
|
+
session.ops.readBoardProjectionForIssue(claimed.card.id),
|
|
5798
|
+
session.ops.listRuns(claimed.card.id)
|
|
5799
|
+
]);
|
|
5800
|
+
const card = projectCard(claimed.card, columns, projection);
|
|
5801
|
+
for (const item of claimed.card.unmetRules ?? []) console.error(`kanbo: warning: ${card.id} entered "${claimed.column.name}" without ${item.rule}: ${item.detail}`);
|
|
5802
|
+
const run = projectRun({
|
|
5803
|
+
...claimed.run,
|
|
5804
|
+
attempt: runs.find((each) => each.id === claimed.run.id)?.attempt ?? runs.length
|
|
5805
|
+
});
|
|
5806
|
+
return {
|
|
5807
|
+
value: {
|
|
5808
|
+
card,
|
|
5809
|
+
run,
|
|
5810
|
+
column: projectColumn(claimed.column, columns)
|
|
5811
|
+
},
|
|
5812
|
+
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`
|
|
5813
|
+
};
|
|
5814
|
+
});
|
|
5815
|
+
});
|
|
5816
|
+
}
|
|
5817
|
+
//#endregion
|
|
5257
5818
|
//#region src/mcp/transport.ts
|
|
5258
5819
|
/** What `kanbo_card_get` may bring along with the card. */
|
|
5259
5820
|
const KANBO_CARD_INCLUDES = [
|
|
@@ -5370,16 +5931,22 @@ function projectKanboSprint(sprint) {
|
|
|
5370
5931
|
doneCards: sprint.cards.done
|
|
5371
5932
|
};
|
|
5372
5933
|
}
|
|
5373
|
-
/** A column of the board, from the row either transport holds. */
|
|
5374
|
-
function projectKanboColumn(column) {
|
|
5934
|
+
/** A column of the board, from the row either transport holds; `board` is every column, to name the ones it accepts cards from. */
|
|
5935
|
+
function projectKanboColumn(column, board) {
|
|
5375
5936
|
const stored = column.entryRules;
|
|
5937
|
+
const allowedIds = typeof column.allowedFrom === "string" ? readFlowRules({
|
|
5938
|
+
allowedFrom: column.allowedFrom,
|
|
5939
|
+
wipLimit: null
|
|
5940
|
+
}).allowedFrom : column.allowedFrom ?? null;
|
|
5376
5941
|
return {
|
|
5377
5942
|
id: column.id,
|
|
5378
5943
|
name: column.name,
|
|
5379
5944
|
slug: normalizeStatusName(column.name),
|
|
5380
5945
|
description: column.description,
|
|
5381
5946
|
order: column.order,
|
|
5382
|
-
entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null })
|
|
5947
|
+
entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null }),
|
|
5948
|
+
allowedFrom: allowedIds === null ? null : board.filter((other) => allowedIds.includes(other.id)).map((other) => normalizeStatusName(other.name)),
|
|
5949
|
+
wipLimit: column.wipLimit ?? null
|
|
5383
5950
|
};
|
|
5384
5951
|
}
|
|
5385
5952
|
/**
|
|
@@ -5406,9 +5973,14 @@ function pageOf(cards, input) {
|
|
|
5406
5973
|
}
|
|
5407
5974
|
/** A comment as a card read lists it, from the row either transport holds. */
|
|
5408
5975
|
function projectKanboCardComment(comment) {
|
|
5976
|
+
const decision = comment.authorKind === "system.approved" || comment.authorKind === "system.returned";
|
|
5977
|
+
const author = decision ? "user" : normalizeCommentAuthorKind(comment.authorKind);
|
|
5978
|
+
const named = author !== "system" || decision;
|
|
5979
|
+
const outside = author === "system" && isOutsideWriter(comment) && comment.authorKind === "system";
|
|
5409
5980
|
return {
|
|
5410
5981
|
id: comment.id,
|
|
5411
|
-
author
|
|
5982
|
+
author,
|
|
5983
|
+
by: named || outside ? comment.authorId ?? author : "kanbo",
|
|
5412
5984
|
content: comment.content,
|
|
5413
5985
|
createdAt: comment.createdAt
|
|
5414
5986
|
};
|
|
@@ -5479,7 +6051,7 @@ function describeCardExtras(extras) {
|
|
|
5479
6051
|
})))].join("\n"));
|
|
5480
6052
|
if (extras.comments && extras.comments.length > 0) {
|
|
5481
6053
|
const heading = extras.commentCount === extras.comments.length ? "Comments:" : `Comments (last ${extras.comments.length} of ${extras.commentCount}):`;
|
|
5482
|
-
sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${comment.author}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
|
|
6054
|
+
sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${sanitizeTerminalText(comment.by)}${comment.author === "user" ? " (person)" : ""}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
|
|
5483
6055
|
}
|
|
5484
6056
|
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"));
|
|
5485
6057
|
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"));
|
|
@@ -5577,10 +6149,11 @@ function registerCardCommands(program) {
|
|
|
5577
6149
|
}, createPlacementActor()));
|
|
5578
6150
|
});
|
|
5579
6151
|
});
|
|
5580
|
-
withBoardOptions(card.command("update").description("Change a card's title, description, priority or
|
|
6152
|
+
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) => {
|
|
5581
6153
|
await runBoardCommand(options, "write", async (session) => {
|
|
5582
6154
|
const input = readUpdateInput(options);
|
|
5583
6155
|
const existing = await requireCard(session, reference);
|
|
6156
|
+
if (options.parent !== void 0) input.parentIssueId = options.parent === "none" ? null : (await requireCard(session, options.parent)).id;
|
|
5584
6157
|
return await presentCard(session, await session.ops.updateCard(existing.id, input, createPlacementActor()));
|
|
5585
6158
|
});
|
|
5586
6159
|
});
|
|
@@ -5721,9 +6294,10 @@ async function presentCard(session, card) {
|
|
|
5721
6294
|
};
|
|
5722
6295
|
}
|
|
5723
6296
|
/**
|
|
5724
|
-
* The fields `card update` was asked to change
|
|
5725
|
-
*
|
|
5726
|
-
*
|
|
6297
|
+
* The fields `card update` was asked to change — all but the parent, a card
|
|
6298
|
+
* of the board, looked up there once the card itself is. A command that names
|
|
6299
|
+
* none of them is refused rather than obeyed: an empty update still bumps the
|
|
6300
|
+
* board version, and every reader would be told the board changed when it did not.
|
|
5727
6301
|
*/
|
|
5728
6302
|
function readUpdateInput(options) {
|
|
5729
6303
|
const input = {};
|
|
@@ -5732,7 +6306,7 @@ function readUpdateInput(options) {
|
|
|
5732
6306
|
if (options.priority !== void 0) input.priority = options.priority;
|
|
5733
6307
|
if (options.labels !== void 0) input.labels = options.labels;
|
|
5734
6308
|
if (options.executionMode !== void 0) input.executionMode = options.executionMode;
|
|
5735
|
-
if (Object.keys(input).length === 0) throw new CliError(1, "Nothing to update. Pass --title, --description, --priority, --labels
|
|
6309
|
+
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.");
|
|
5736
6310
|
return input;
|
|
5737
6311
|
}
|
|
5738
6312
|
function parsePriority(value) {
|
|
@@ -5802,10 +6376,11 @@ const COLUMN_CATEGORIES = [
|
|
|
5802
6376
|
];
|
|
5803
6377
|
/** The whole board's columns after a change, and the sentence that says what changed. */
|
|
5804
6378
|
async function changed(session, message, extra = {}) {
|
|
5805
|
-
const
|
|
6379
|
+
const board = await session.ops.listColumns(session.workspace.id);
|
|
6380
|
+
const views = board.map((column) => projectColumn(column, board));
|
|
5806
6381
|
return {
|
|
5807
6382
|
value: {
|
|
5808
|
-
...extra,
|
|
6383
|
+
...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, typeof value === "number" || Array.isArray(value) ? value : projectColumn(value, board)])),
|
|
5809
6384
|
columns: views
|
|
5810
6385
|
},
|
|
5811
6386
|
text: `${message}\n\n${describeColumns(views)}`
|
|
@@ -5834,14 +6409,14 @@ async function addColumn(session, request, actor) {
|
|
|
5834
6409
|
category: request.category ?? spec.category,
|
|
5835
6410
|
position: anchor ? request.after !== void 0 ? { after: anchor.id } : { before: anchor.id } : void 0
|
|
5836
6411
|
}, actor);
|
|
5837
|
-
return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added:
|
|
6412
|
+
return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: created });
|
|
5838
6413
|
}
|
|
5839
6414
|
/** Said after a rename: agents' instructions name no column but To Do, and `kanbo prime` gives them the rest. */
|
|
5840
6415
|
const RENAME_NOTE = "Agents read column names from kanbo prime; rename is picked up there.";
|
|
5841
6416
|
async function renameColumn(session, column, newName, actor) {
|
|
5842
6417
|
const before = await requireNamedColumn(session, column);
|
|
5843
6418
|
const renamed = await session.ops.renameColumn(session.workspace.id, before.id, newName, actor);
|
|
5844
|
-
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
|
|
6419
|
+
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 });
|
|
5845
6420
|
}
|
|
5846
6421
|
/** `--first`, `--last`, `--before <column>` or `--after <column>` — exactly one. */
|
|
5847
6422
|
function readColumnPosition(options) {
|
|
@@ -5873,7 +6448,7 @@ async function removeColumn(session, column, moveCardsTo, actor) {
|
|
|
5873
6448
|
const result = await session.ops.removeColumn(session.workspace.id, removing.id, { moveCardsTo }, actor);
|
|
5874
6449
|
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}`);
|
|
5875
6450
|
return await changed(session, result.movedTo ? `Removed ${result.removed.name} and moved ${describeCount$1(result.movedCards)} to ${result.movedTo.name}.` : `Removed ${result.removed.name}.`, {
|
|
5876
|
-
removed:
|
|
6451
|
+
removed: result.removed,
|
|
5877
6452
|
movedCards: result.movedCards
|
|
5878
6453
|
});
|
|
5879
6454
|
}
|
|
@@ -5917,6 +6492,14 @@ const BOARD_ERROR_TEXTS = {
|
|
|
5917
6492
|
text: "Only a person can approve a card.",
|
|
5918
6493
|
next: AS_A_PERSON
|
|
5919
6494
|
}),
|
|
6495
|
+
board_card_not_ready: (details) => ({
|
|
6496
|
+
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.`,
|
|
6497
|
+
next: "kanbo ready"
|
|
6498
|
+
}),
|
|
6499
|
+
board_card_run_active: (details) => ({
|
|
6500
|
+
text: `${named(details.issueId)} already has a run going: ${named(details.agentName)}'s, run ${named(details.runId)}.`,
|
|
6501
|
+
next: `kanbo run finish ${named(details.runId)} --state stopped`
|
|
6502
|
+
}),
|
|
5920
6503
|
board_column_name_taken: (details) => ({
|
|
5921
6504
|
text: `Another column, ${named(details.takenBy)}, already has that name (same slug). Pick another name.`,
|
|
5922
6505
|
next: "kanbo columns list"
|
|
@@ -5947,6 +6530,14 @@ const BOARD_ERROR_TEXTS = {
|
|
|
5947
6530
|
}),
|
|
5948
6531
|
board_entry_rule_invalid: (details) => ({ text: `Unknown column rule: ${list(details.rules)}. Use any of ${list(details.allowed)}.` }),
|
|
5949
6532
|
board_execution_mode_locked: (details) => ({ text: `${named(details.issueId)} has been started, so where it is worked (${named(details.executionMode)}) can't change now.` }),
|
|
6533
|
+
board_flow_rule_invalid: (details) => details.wipLimit !== void 0 ? { text: `A column holds at least 1 card; ${named(details.wipLimit)} is not a limit. Use none for no limit.` } : {
|
|
6534
|
+
text: `A column accepts cards only from other columns of its own board, and ${list(details.allowedFrom)} is not one.`,
|
|
6535
|
+
next: "kanbo columns list"
|
|
6536
|
+
},
|
|
6537
|
+
board_nothing_ready: () => ({
|
|
6538
|
+
text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
|
|
6539
|
+
next: "kanbo ready"
|
|
6540
|
+
}),
|
|
5950
6541
|
board_pull_request_invalid: (details) => ({ text: `"${named(details.value)}" is not a GitHub pull request. Give its link or owner/repo#number.` }),
|
|
5951
6542
|
board_pull_request_not_found: (details) => ({
|
|
5952
6543
|
text: `${named(details.issueId)} has no pull request link "${named(details.linkId)}".`,
|
|
@@ -6002,6 +6593,7 @@ const BOARD_ERROR_TEXTS = {
|
|
|
6002
6593
|
text: `No card "${named(details.issueId)}" here.`,
|
|
6003
6594
|
next: "kanbo card list"
|
|
6004
6595
|
}),
|
|
6596
|
+
issue_parent_cycle: () => ({ text: "A card can't be put under one of its own sub-cards." }),
|
|
6005
6597
|
issue_parent_not_found: (details) => ({
|
|
6006
6598
|
text: `No card "${named(details.parentIssueId)}" here to put this one under.`,
|
|
6007
6599
|
next: "kanbo card list"
|
|
@@ -6011,7 +6603,7 @@ const BOARD_ERROR_TEXTS = {
|
|
|
6011
6603
|
issue_relation_self_reference: () => ({ text: "A card can't be linked to itself." }),
|
|
6012
6604
|
issue_status_name_empty: () => ({ text: "Give the column a name." }),
|
|
6013
6605
|
issue_status_not_found: (details) => ({
|
|
6014
|
-
text: `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
|
|
6606
|
+
text: details.category !== void 0 ? `This board has no ${named(details.category)} column for a claimed card to go to.` : `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
|
|
6015
6607
|
next: "kanbo columns list"
|
|
6016
6608
|
}),
|
|
6017
6609
|
issue_status_reference_conflict: () => ({ text: "Name the column once: by its id or by its name, not both." }),
|
|
@@ -6215,7 +6807,7 @@ async function runColumnsMenu(ui) {
|
|
|
6215
6807
|
if (showList) say$1(ui, [
|
|
6216
6808
|
"",
|
|
6217
6809
|
"Columns of this board:",
|
|
6218
|
-
describeColumns(columns.map(projectColumn))
|
|
6810
|
+
describeColumns(columns.map((column) => projectColumn(column, columns)))
|
|
6219
6811
|
].join("\n"));
|
|
6220
6812
|
let choice;
|
|
6221
6813
|
try {
|
|
@@ -6443,7 +7035,8 @@ function registerColumnsCommands(program) {
|
|
|
6443
7035
|
const columns = program.command("columns").description("See and change the board's columns (alone: a menu)");
|
|
6444
7036
|
withBoardOptions(columns.command("list").description("List the columns in order, with the short name to move cards by")).action(async (options) => {
|
|
6445
7037
|
await runBoardCommand(options, "read", async (session) => {
|
|
6446
|
-
const
|
|
7038
|
+
const board = await session.ops.listColumns(session.workspace.id);
|
|
7039
|
+
const views = board.map((column) => projectColumn(column, board));
|
|
6447
7040
|
return {
|
|
6448
7041
|
value: views,
|
|
6449
7042
|
text: describeColumns(views)
|
|
@@ -6452,7 +7045,7 @@ function registerColumnsCommands(program) {
|
|
|
6452
7045
|
});
|
|
6453
7046
|
withBoardOptions(columns.command("describe").description("Say in one line when a card belongs in a column").argument("<column>", "the column, by slug, name or id").requiredOption("--text <text>", "what the column means")).action(async (column, options) => {
|
|
6454
7047
|
await runBoardCommand(options, "write", async (session) => {
|
|
6455
|
-
const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text));
|
|
7048
|
+
const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text), await session.ops.listColumns(session.workspace.id));
|
|
6456
7049
|
return {
|
|
6457
7050
|
value: view,
|
|
6458
7051
|
text: describeColumns([view])
|
|
@@ -6463,10 +7056,31 @@ function registerColumnsCommands(program) {
|
|
|
6463
7056
|
const actor = requireHumanActor("columns rules", typedWords(command));
|
|
6464
7057
|
const rules = readRequestedRules(options);
|
|
6465
7058
|
await runBoardCommand(options, "write", async (session) => {
|
|
6466
|
-
const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor));
|
|
7059
|
+
const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor), await session.ops.listColumns(session.workspace.id));
|
|
7060
|
+
return {
|
|
7061
|
+
value: view,
|
|
7062
|
+
text: describeColumns([view])
|
|
7063
|
+
};
|
|
7064
|
+
});
|
|
7065
|
+
});
|
|
7066
|
+
withBoardOptions(columns.command("flow").description("Set which columns a card may come from, and how many cards a column holds").argument("<column>", "the column, by slug, name or id").option("--from <columns>", "comma-separated columns a card may enter it from, or any").option("--wip <count>", "the most cards it holds, or none")).action(async (column, options, command) => {
|
|
7067
|
+
const actor = requireHumanActor("columns flow", typedWords(command));
|
|
7068
|
+
if (options.from === void 0 && options.wip === void 0) throw new CliError(1, "Pass --from <columns|any>, --wip <count|none>, or both.");
|
|
7069
|
+
const wipLimit = options.wip === void 0 ? void 0 : readWipLimit(options.wip);
|
|
7070
|
+
await runBoardCommand(options, "write", async (session) => {
|
|
7071
|
+
const workspaceId = session.workspace.id;
|
|
7072
|
+
const board = await session.ops.listColumns(workspaceId);
|
|
7073
|
+
const target = await session.ops.requireColumn(workspaceId, column);
|
|
7074
|
+
const current = readFlowRules(target);
|
|
7075
|
+
await session.ops.setColumnFlowRules(target.id, {
|
|
7076
|
+
allowedFrom: options.from === void 0 ? current.allowedFrom : readAllowedFrom(board, workspaceId, options.from),
|
|
7077
|
+
wipLimit: wipLimit === void 0 ? current.wipLimit : wipLimit
|
|
7078
|
+
}, actor);
|
|
7079
|
+
const after = await session.ops.listColumns(workspaceId);
|
|
7080
|
+
const views = after.map((each) => projectColumn(each, after));
|
|
6467
7081
|
return {
|
|
6468
|
-
value: view,
|
|
6469
|
-
text: describeColumns(
|
|
7082
|
+
value: views.find((view) => view.id === target.id),
|
|
7083
|
+
text: describeColumns(views)
|
|
6470
7084
|
};
|
|
6471
7085
|
});
|
|
6472
7086
|
});
|
|
@@ -6511,7 +7125,7 @@ function registerColumnsCommands(program) {
|
|
|
6511
7125
|
mode: "add-missing",
|
|
6512
7126
|
actor
|
|
6513
7127
|
});
|
|
6514
|
-
const views = statuses.map(projectColumn);
|
|
7128
|
+
const views = statuses.map((column) => projectColumn(column, statuses));
|
|
6515
7129
|
return {
|
|
6516
7130
|
value: {
|
|
6517
7131
|
added,
|
|
@@ -6535,7 +7149,8 @@ function registerColumnsCommands(program) {
|
|
|
6535
7149
|
return;
|
|
6536
7150
|
}
|
|
6537
7151
|
await runBoardCommand({}, "read", async (session) => {
|
|
6538
|
-
const
|
|
7152
|
+
const board = await session.ops.listColumns(session.workspace.id);
|
|
7153
|
+
const views = board.map((column) => projectColumn(column, board));
|
|
6539
7154
|
return {
|
|
6540
7155
|
value: views,
|
|
6541
7156
|
text: describeColumns(views)
|
|
@@ -6565,6 +7180,26 @@ function readRequestedRules(options) {
|
|
|
6565
7180
|
if (options.require === void 0) throw new CliError(1, `Pass --require ${ENTRY_RULES.join(",")} (any of them) or --clear.`);
|
|
6566
7181
|
return options.require.split(",").map((rule) => rule.trim()).filter(Boolean);
|
|
6567
7182
|
}
|
|
7183
|
+
/** `--from`: `any` for every column, else the columns named, by slug, name or id. */
|
|
7184
|
+
function readAllowedFrom(board, workspaceId, value) {
|
|
7185
|
+
if (value.trim().toLowerCase() === "any") return null;
|
|
7186
|
+
const names = value.split(",").map((name) => name.trim()).filter(Boolean);
|
|
7187
|
+
if (names.length === 0) throw new CliError(1, "Name the columns a card may come from (--from in_review,qa), or --from any.");
|
|
7188
|
+
return names.map((name) => {
|
|
7189
|
+
const found = matchColumn(board, workspaceId, name);
|
|
7190
|
+
if (!found) throw new BoardError("issue_status_not_found", {
|
|
7191
|
+
workspaceId,
|
|
7192
|
+
statusName: name
|
|
7193
|
+
});
|
|
7194
|
+
return found.id;
|
|
7195
|
+
});
|
|
7196
|
+
}
|
|
7197
|
+
/** `--wip`: `none` for no limit, else a whole number of cards, 1 or more. */
|
|
7198
|
+
function readWipLimit(value) {
|
|
7199
|
+
if (value.trim().toLowerCase() === "none") return null;
|
|
7200
|
+
if (!/^\d+$/.test(value.trim()) || Number(value) < 1) throw new CliError(1, "--wip takes a whole number of cards, 1 or more, or none.");
|
|
7201
|
+
return Number(value);
|
|
7202
|
+
}
|
|
6568
7203
|
//#endregion
|
|
6569
7204
|
//#region src/package-version.ts
|
|
6570
7205
|
dirname(fileURLToPath(import.meta.url));
|
|
@@ -6574,7 +7209,7 @@ dirname(fileURLToPath(import.meta.url));
|
|
|
6574
7209
|
* `package.version`. A build carries it as a literal; running from source
|
|
6575
7210
|
* (tests, `tsx`) reads the package's own `package.json` instead.
|
|
6576
7211
|
*/
|
|
6577
|
-
const KANBO_PACKAGE_VERSION = "0.
|
|
7212
|
+
const KANBO_PACKAGE_VERSION = "0.5.0";
|
|
6578
7213
|
//#endregion
|
|
6579
7214
|
//#region src/cli/setup/paths.ts
|
|
6580
7215
|
/**
|
|
@@ -6923,7 +7558,7 @@ const INSTRUCTION_END_PATTERN = /<!-- KANBO_END\s*-->/g;
|
|
|
6923
7558
|
const POINTER_KINDS = /* @__PURE__ */ new Set(["short", "global"]);
|
|
6924
7559
|
/** The commands an agent uses, spelled once for the guide and the `kanbo prime` cheat sheet. */
|
|
6925
7560
|
const COMMAND = {
|
|
6926
|
-
|
|
7561
|
+
claim: "kanbo claim",
|
|
6927
7562
|
move: "kanbo card move <id> <column>",
|
|
6928
7563
|
statusLine: "kanbo card status-line <id> --text \"...\"",
|
|
6929
7564
|
subtask: "kanbo card create --description \"...\" --parent <id>",
|
|
@@ -6942,7 +7577,7 @@ const AGENT_GUIDE_TEXT = [
|
|
|
6942
7577
|
"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.",
|
|
6943
7578
|
"",
|
|
6944
7579
|
...[
|
|
6945
|
-
`- Take a card
|
|
7580
|
+
`- 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.`,
|
|
6946
7581
|
`- ${MOVE_CARD_RULE} (\`${COMMAND.move}\`)`,
|
|
6947
7582
|
`- ${STATUS_LINE_RULE} (\`${COMMAND.statusLine}\`)`,
|
|
6948
7583
|
`- ${SUBTASK_RULE} (\`${COMMAND.subtask}\`)`,
|
|
@@ -6970,13 +7605,15 @@ const COMMAND_SHEET = [
|
|
|
6970
7605
|
"- `kanbo card status-line TST-5 --text \"Running the tests\"` — what is happening on the card right now",
|
|
6971
7606
|
"- `kanbo card comment TST-5 --content \"Added slugify with a test\"` — a finding, result, decision or question",
|
|
6972
7607
|
"- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
|
|
7608
|
+
"- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
|
|
6973
7609
|
"- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
|
|
6974
|
-
"- `kanbo
|
|
7610
|
+
"- `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",
|
|
7611
|
+
"- `kanbo run start TST-5 --agent claude` — record a run on a card you did not claim; it prints the finish command",
|
|
6975
7612
|
"- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
|
|
6976
7613
|
`- \`${COMMAND.capabilities}\` — everything else, rarely needed`
|
|
6977
7614
|
].join("\n");
|
|
6978
7615
|
/** How to take work and who approves it — said by both blocks after their first sentence. */
|
|
6979
|
-
const POINTER_TAIL = `Take work with \`${COMMAND.
|
|
7616
|
+
const POINTER_TAIL = `Take work with \`${COMMAND.claim}\`. ${PERSON_ONLY_RULE}`;
|
|
6980
7617
|
/** The body of the project block: what `kanbo instructions short` prints. */
|
|
6981
7618
|
const PROJECT_BODY = [
|
|
6982
7619
|
"## Kanbo board",
|
|
@@ -6996,7 +7633,7 @@ const GLOBAL_BODY = [
|
|
|
6996
7633
|
* The block, markers included, with this body's version and hash on the start
|
|
6997
7634
|
* marker — and, when given, the kind of text it is (`k=orchestrator`).
|
|
6998
7635
|
*/
|
|
6999
|
-
function wrapInstructionBlock(body, version =
|
|
7636
|
+
function wrapInstructionBlock(body, version = 3, kind) {
|
|
7000
7637
|
return `<!-- KANBO_START v${version}${kind ? ` k=${kind}` : ""} h=${hashBody(body)} -->\n${body}\n${INSTRUCTION_END}`;
|
|
7001
7638
|
}
|
|
7002
7639
|
/** The block `kanbo init --instructions …` writes into a project's own file. */
|
|
@@ -7025,7 +7662,7 @@ function inspectBlock(text, current) {
|
|
|
7025
7662
|
kind
|
|
7026
7663
|
});
|
|
7027
7664
|
if (fields.trim() === "") return inspection(LEGACY_BLOCK_BODIES.includes(body) ? "legacy" : "edited");
|
|
7028
|
-
if (version !== void 0 && Number(version) >
|
|
7665
|
+
if (version !== void 0 && Number(version) > 3) return inspection("newer");
|
|
7029
7666
|
if (kind !== null && !POINTER_KINDS.has(kind)) return inspection("chosen");
|
|
7030
7667
|
if (version === void 0 || hash === void 0 || hashBody(body) !== hash) return inspection("edited");
|
|
7031
7668
|
return inspection(normalized.slice(found.start, found.end) === current ? "current" : "outdated");
|
|
@@ -8055,7 +8692,7 @@ function startUpdateCheck(deps = {}) {
|
|
|
8055
8692
|
setTimeout: globalThis.setTimeout,
|
|
8056
8693
|
clearTimeout: globalThis.clearTimeout
|
|
8057
8694
|
};
|
|
8058
|
-
const current = deps.currentVersion ?? "0.
|
|
8695
|
+
const current = deps.currentVersion ?? "0.5.0";
|
|
8059
8696
|
const controller = new AbortController();
|
|
8060
8697
|
let found = null;
|
|
8061
8698
|
const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
|
|
@@ -9292,6 +9929,7 @@ async function applyInitPlan(plan) {
|
|
|
9292
9929
|
}
|
|
9293
9930
|
if (plan.target.kind === "postgres" && plan.migrate) columns = await migrateSharedBoard(plan.target.url, plan.workspace.id, plan.columns);
|
|
9294
9931
|
const bindingPath = writeBinding(plan.projectDir, plan.binding);
|
|
9932
|
+
rememberProjectQuietly(plan.projectDir);
|
|
9295
9933
|
const files = plan.fileChanges.map(({ kind, change }) => ({
|
|
9296
9934
|
kind,
|
|
9297
9935
|
...applyFileChange(change)
|
|
@@ -10039,7 +10677,7 @@ async function planAgents(projectDir, agents, options) {
|
|
|
10039
10677
|
}
|
|
10040
10678
|
/** The board this project is bound to, and the workspace on it. */
|
|
10041
10679
|
async function resolveBoard(projectDir, options) {
|
|
10042
|
-
if (options.file !== void 0 || namesNoBoard(projectDir, options)) return await resolveOwnFile(projectDir, options);
|
|
10680
|
+
if (options.file !== void 0 || namesNoBoard$1(projectDir, options)) return await resolveOwnFile(projectDir, options);
|
|
10043
10681
|
const target = resolveInitTarget(projectDir, options);
|
|
10044
10682
|
if (target.kind === "postgres") return {
|
|
10045
10683
|
target,
|
|
@@ -10058,7 +10696,7 @@ async function resolveBoard(projectDir, options) {
|
|
|
10058
10696
|
* does — a person on a machine with nothing else installed gets a board, not a
|
|
10059
10697
|
* refusal that names a flag.
|
|
10060
10698
|
*/
|
|
10061
|
-
function namesNoBoard(projectDir, options) {
|
|
10699
|
+
function namesNoBoard$1(projectDir, options) {
|
|
10062
10700
|
return !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DB_PATH?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(projectDir) === null;
|
|
10063
10701
|
}
|
|
10064
10702
|
/** The workspace a host database — or the board file this project is already bound to — says this project is. */
|
|
@@ -10486,11 +11124,12 @@ const KANBO_MCP_INSTRUCTIONS = [
|
|
|
10486
11124
|
"kanbo is a kanban board shared by people and agents; these tools read and change its cards.",
|
|
10487
11125
|
"",
|
|
10488
11126
|
`- At the start of a session call ${tool("kanbo_prime")} — it returns this board's columns and what each one means.`,
|
|
10489
|
-
`- Take work from ${tool("kanbo_ready")}.`,
|
|
11127
|
+
`- 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.`,
|
|
10490
11128
|
`- ${MOVE_CARD_RULE} (${tool("kanbo_card_move")})`,
|
|
10491
11129
|
`- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
|
|
10492
11130
|
`- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
|
|
10493
11131
|
`- ${SUBTASK_RULE} (${tool("kanbo_card_create")} with \`parent\`)`,
|
|
11132
|
+
`- A card at the wrong level is moved with ${tool("kanbo_card_update")} and \`parent\` (\`"none"\` for the top level).`,
|
|
10494
11133
|
`- ${WAIT_FOR_PERSON_RULE} (${tool("kanbo_wait_approval")})`,
|
|
10495
11134
|
`- ${PERSON_ONLY_RULE}`,
|
|
10496
11135
|
`- Every tool, rule and canonical string: the resource \`${KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI}\`.`
|
|
@@ -10576,7 +11215,7 @@ const ORCHESTRATOR_GUIDE = [
|
|
|
10576
11215
|
"## Start of every session",
|
|
10577
11216
|
"1. Run `kanbo prime` (tool: `kanbo_prime`) and read the column descriptions. On this board the description is the contract, not the name.",
|
|
10578
11217
|
"2. Run `kanbo ready` (`kanbo_ready`). If nothing is ready, say so and stop.",
|
|
10579
|
-
"3. Take the first card
|
|
11218
|
+
"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.",
|
|
10580
11219
|
"",
|
|
10581
11220
|
"## While working",
|
|
10582
11221
|
"- 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.",
|
|
@@ -10603,7 +11242,7 @@ const ORCHESTRATOR_GUIDE = [
|
|
|
10603
11242
|
"- Returned: the reason is the newest comment. The card is back in an earlier column; start a new run and address the reason first.",
|
|
10604
11243
|
"",
|
|
10605
11244
|
"## Never",
|
|
10606
|
-
"- Never run `kanbo approve`, `kanbo return`, `kanbo sprint close`, `kanbo run clear-session`, or anything that changes the columns: `kanbo columns add`, `kanbo columns rename`, `kanbo columns move`, `kanbo columns remove`, `kanbo columns template`, `kanbo columns add-standard`, `kanbo columns rules`. They are for a person and will fail in your shell anyway.",
|
|
11245
|
+
"- 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.",
|
|
10607
11246
|
"- Never delete or edit the board's database directly."
|
|
10608
11247
|
].join("\n");
|
|
10609
11248
|
//#endregion
|
|
@@ -10648,7 +11287,7 @@ function registerInstructionsCommand(program, dependencies = {}) {
|
|
|
10648
11287
|
const found = parseKind(kind);
|
|
10649
11288
|
const body = await readInstructionText(found);
|
|
10650
11289
|
const pointer = found === "short" || found === "global";
|
|
10651
|
-
const text = options.markers ? wrapInstructionBlock(body,
|
|
11290
|
+
const text = options.markers ? wrapInstructionBlock(body, 3, pointer ? void 0 : found) : body;
|
|
10652
11291
|
const hints = process.stderr.isTTY === true;
|
|
10653
11292
|
if (hints) process.stderr.write(`${HEADER}\n\n`);
|
|
10654
11293
|
console.log(text);
|
|
@@ -10870,61 +11509,6 @@ const cardCommentTool = defineKanboTool({
|
|
|
10870
11509
|
},
|
|
10871
11510
|
run: async (transport, input) => await transport.cardComment(input)
|
|
10872
11511
|
});
|
|
10873
|
-
//#endregion
|
|
10874
|
-
//#region src/mcp/tools/card-create.ts
|
|
10875
|
-
/** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
|
|
10876
|
-
const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pass `parent` = that card's id (its key, e.g. `MAN-012`).";
|
|
10877
|
-
/** A new card, or a sub-card of the one being worked on. */
|
|
10878
|
-
const cardCreateTool = defineKanboTool({
|
|
10879
|
-
name: "kanbo_card_create",
|
|
10880
|
-
title: "Create a card",
|
|
10881
|
-
description: `Put a new card on this board. ${SUBTASK_SENTENCE} ${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("pass `parent`")}`,
|
|
10882
|
-
inputSchema: {
|
|
10883
|
-
title: z.string().optional().describe("What the card is called. Leave it out and the card is titled with its own key; the description then says what the task is."),
|
|
10884
|
-
description: z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
|
|
10885
|
-
column: z.string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
|
|
10886
|
-
parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
|
|
10887
|
-
executionMode: executionModeArgument.optional()
|
|
10888
|
-
},
|
|
10889
|
-
example: {
|
|
10890
|
-
description: "Add slugify(text) with a test",
|
|
10891
|
-
parent: "TST-1"
|
|
10892
|
-
},
|
|
10893
|
-
run: async (transport, input) => await transport.cardCreate(input)
|
|
10894
|
-
});
|
|
10895
|
-
//#endregion
|
|
10896
|
-
//#region src/mcp/tools/card-get.ts
|
|
10897
|
-
/** One card in full, with what an agent reads next to it. */
|
|
10898
|
-
const cardGetTool = defineKanboTool({
|
|
10899
|
-
name: "kanbo_card_get",
|
|
10900
|
-
title: "Read a card",
|
|
10901
|
-
description: `Read one card in full — description, column, status line, whose turn it is, parent (\`parentIssueId\`) — with its last 10 comments and its subtasks (\`subCards\`), in one call. Its runs, field history and pull requests too: include: ["comments","subCards","runs","history","prs"].`,
|
|
10902
|
-
inputSchema: {
|
|
10903
|
-
card: cardArgument,
|
|
10904
|
-
include: z.array(z.enum(KANBO_CARD_INCLUDES)).optional().describe("What to read with the card; [\"comments\",\"subCards\"] when absent, [] for the card alone."),
|
|
10905
|
-
commentLimit: z.number().int().positive().optional().describe(`How many of the latest comments to include; 10 when absent.`)
|
|
10906
|
-
},
|
|
10907
|
-
example: { card: "TST-5" },
|
|
10908
|
-
run: async (transport, input) => await transport.cardGet(input)
|
|
10909
|
-
});
|
|
10910
|
-
//#endregion
|
|
10911
|
-
//#region src/mcp/tools/card-link-pr.ts
|
|
10912
|
-
/** A pull request the card's work went into, named on the card so its facts reach it. */
|
|
10913
|
-
const cardLinkPrTool = defineKanboTool({
|
|
10914
|
-
name: "kanbo_card_link_pr",
|
|
10915
|
-
title: "Link a pull request to a card",
|
|
10916
|
-
description: "Opened a pull request for this card? Link it here. Its state and CI results then reach the card as comments, without you writing them. Linking the same pull request again changes nothing.",
|
|
10917
|
-
inputSchema: {
|
|
10918
|
-
card: cardArgument,
|
|
10919
|
-
url: z.string().min(1).describe("The pull request, as https://github.com/<owner>/<repo>/pull/<number> or <owner>/<repo>#<number>.")
|
|
10920
|
-
},
|
|
10921
|
-
needs: { url: "the pull request" },
|
|
10922
|
-
example: {
|
|
10923
|
-
card: "TST-5",
|
|
10924
|
-
url: "https://github.com/owner/repo/pull/12"
|
|
10925
|
-
},
|
|
10926
|
-
run: async (transport, input) => await transport.cardLinkPullRequest(input)
|
|
10927
|
-
});
|
|
10928
11512
|
/** The `detail` and `fields` arguments the list tools share. */
|
|
10929
11513
|
const cardListViewArguments = {
|
|
10930
11514
|
detail: z.enum(["compact", "full"]).optional().describe("compact (default): id, title, column slug, status line, waiting, parent, run, updatedAt; returned, and the last comment of a waiting or returned card. full: every field, description included."),
|
|
@@ -10963,6 +11547,20 @@ function compactKanboCard(card) {
|
|
|
10963
11547
|
...card.lastComment ? { lastComment: card.lastComment } : {}
|
|
10964
11548
|
};
|
|
10965
11549
|
}
|
|
11550
|
+
/** The `detail` argument of the tools that write a card: a list's own word, for the one card a write answers with. */
|
|
11551
|
+
const writtenCardDetailArgument = z.enum(["compact", "full"]).optional().describe("compact (default): the card as kanbo_card_list prints it. full: every field, description included.");
|
|
11552
|
+
/** What each tool that writes a card says about its answer, in its description. */
|
|
11553
|
+
const WRITTEN_CARD_ANSWER_SENTENCE = "Answers the card compact, as kanbo_card_list prints it; detail: \"full\" for every field.";
|
|
11554
|
+
/**
|
|
11555
|
+
* The card a write hands back, as the tool answers with it: compact and on one
|
|
11556
|
+
* line, as a list prints it. An agent writes its card at every step — a status
|
|
11557
|
+
* line, a move, an update — and reads each answer again on every later turn,
|
|
11558
|
+
* so a whole card, description included, would cost it again and again.
|
|
11559
|
+
* `detail: "full"` answers every field, as `kanbo_card_get` prints the card alone.
|
|
11560
|
+
*/
|
|
11561
|
+
function formatWrittenKanboCard(card, detail) {
|
|
11562
|
+
return detail === "full" ? JSON.stringify(card, null, 2) : JSON.stringify(compactKanboCard(card));
|
|
11563
|
+
}
|
|
10966
11564
|
function viewCard(card, view) {
|
|
10967
11565
|
if (view.fields) return Object.fromEntries([["id", card.id], ...view.fields.map((field) => [field, card[field]])]);
|
|
10968
11566
|
return view.detail === "full" ? card : compactKanboCard(card);
|
|
@@ -10986,6 +11584,62 @@ function formatKanboCardPage(page, view, returned) {
|
|
|
10986
11584
|
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)}`;
|
|
10987
11585
|
return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
|
|
10988
11586
|
}
|
|
11587
|
+
//#endregion
|
|
11588
|
+
//#region src/mcp/tools/card-create.ts
|
|
11589
|
+
/** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
|
|
11590
|
+
const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pass `parent` = that card's id (its key, e.g. `MAN-012`).";
|
|
11591
|
+
/** A new card, or a sub-card of the one being worked on. */
|
|
11592
|
+
const cardCreateTool = defineKanboTool({
|
|
11593
|
+
name: "kanbo_card_create",
|
|
11594
|
+
title: "Create a card",
|
|
11595
|
+
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.`,
|
|
11596
|
+
inputSchema: {
|
|
11597
|
+
title: z.string().optional().describe("What the card is called. Leave it out and the card is titled with its own key; the description then says what the task is."),
|
|
11598
|
+
description: z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
|
|
11599
|
+
column: z.string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
|
|
11600
|
+
parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
|
|
11601
|
+
executionMode: executionModeArgument.optional(),
|
|
11602
|
+
detail: writtenCardDetailArgument
|
|
11603
|
+
},
|
|
11604
|
+
example: {
|
|
11605
|
+
description: "Add slugify(text) with a test",
|
|
11606
|
+
parent: "TST-1"
|
|
11607
|
+
},
|
|
11608
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardCreate(input), detail)
|
|
11609
|
+
});
|
|
11610
|
+
//#endregion
|
|
11611
|
+
//#region src/mcp/tools/card-get.ts
|
|
11612
|
+
/** One card in full, with what an agent reads next to it. */
|
|
11613
|
+
const cardGetTool = defineKanboTool({
|
|
11614
|
+
name: "kanbo_card_get",
|
|
11615
|
+
title: "Read a card",
|
|
11616
|
+
description: `Read one card in full — description, column, status line, whose turn it is, parent (\`parentIssueId\`) — with its last 10 comments and its subtasks (\`subCards\`), in one call. Its runs, field history and pull requests too: include: ["comments","subCards","runs","history","prs"].`,
|
|
11617
|
+
inputSchema: {
|
|
11618
|
+
card: cardArgument,
|
|
11619
|
+
include: z.array(z.enum(KANBO_CARD_INCLUDES)).optional().describe("What to read with the card; [\"comments\",\"subCards\"] when absent, [] for the card alone."),
|
|
11620
|
+
commentLimit: z.number().int().positive().optional().describe(`How many of the latest comments to include; 10 when absent.`)
|
|
11621
|
+
},
|
|
11622
|
+
example: { card: "TST-5" },
|
|
11623
|
+
run: async (transport, input) => await transport.cardGet(input)
|
|
11624
|
+
});
|
|
11625
|
+
//#endregion
|
|
11626
|
+
//#region src/mcp/tools/card-link-pr.ts
|
|
11627
|
+
/** A pull request the card's work went into, named on the card so its facts reach it. */
|
|
11628
|
+
const cardLinkPrTool = defineKanboTool({
|
|
11629
|
+
name: "kanbo_card_link_pr",
|
|
11630
|
+
title: "Link a pull request to a card",
|
|
11631
|
+
description: "Opened a pull request for this card? Link it here. Its state and CI results then reach the card as comments, without you writing them. Linking the same pull request again changes nothing.",
|
|
11632
|
+
inputSchema: {
|
|
11633
|
+
card: cardArgument,
|
|
11634
|
+
url: z.string().min(1).describe("The pull request, as https://github.com/<owner>/<repo>/pull/<number> or <owner>/<repo>#<number>.")
|
|
11635
|
+
},
|
|
11636
|
+
needs: { url: "the pull request" },
|
|
11637
|
+
example: {
|
|
11638
|
+
card: "TST-5",
|
|
11639
|
+
url: "https://github.com/owner/repo/pull/12"
|
|
11640
|
+
},
|
|
11641
|
+
run: async (transport, input) => await transport.cardLinkPullRequest(input)
|
|
11642
|
+
});
|
|
10989
11643
|
/** The cards on the board a question picks, one page of them. */
|
|
10990
11644
|
const cardListTool = defineKanboTool({
|
|
10991
11645
|
name: "kanbo_card_list",
|
|
@@ -11040,10 +11694,11 @@ function readMoment(value) {
|
|
|
11040
11694
|
const cardMoveTool = defineKanboTool({
|
|
11041
11695
|
name: "kanbo_card_move",
|
|
11042
11696
|
title: "Move a card",
|
|
11043
|
-
description:
|
|
11697
|
+
description: `Move a card to another column the moment its real state changes. Read kanbo_columns first: a card in the wrong column is a lie, and a column refuses a card that does not meet its entryRules yet, that comes from a column it does not accept (allowedFrom), or that would overfill it (wipLimit), listing what is missing. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
|
|
11044
11698
|
inputSchema: {
|
|
11045
11699
|
card: cardArgument,
|
|
11046
|
-
column: z.string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`.")
|
|
11700
|
+
column: z.string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`."),
|
|
11701
|
+
detail: writtenCardDetailArgument
|
|
11047
11702
|
},
|
|
11048
11703
|
aliases: { column: ["to"] },
|
|
11049
11704
|
needs: { column: "the column slug, e.g. in_progress" },
|
|
@@ -11051,7 +11706,7 @@ const cardMoveTool = defineKanboTool({
|
|
|
11051
11706
|
card: "TST-5",
|
|
11052
11707
|
column: "in_progress"
|
|
11053
11708
|
},
|
|
11054
|
-
run: async (transport, input) => await transport.cardMove(input)
|
|
11709
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardMove(input), detail)
|
|
11055
11710
|
});
|
|
11056
11711
|
//#endregion
|
|
11057
11712
|
//#region src/mcp/tools/card-pull-requests.ts
|
|
@@ -11066,11 +11721,11 @@ const cardPullRequestsTool = defineKanboTool({
|
|
|
11066
11721
|
});
|
|
11067
11722
|
//#endregion
|
|
11068
11723
|
//#region src/mcp/tools/card-update.ts
|
|
11069
|
-
/** What a card says
|
|
11724
|
+
/** What a card says — its title, what it is about, how urgent it is, how it is labelled — and which card it sits under. */
|
|
11070
11725
|
const cardUpdateTool = defineKanboTool({
|
|
11071
11726
|
name: "kanbo_card_update",
|
|
11072
11727
|
title: "Update a card",
|
|
11073
|
-
description:
|
|
11728
|
+
description: `Change what a card says: its title, its description, its priority, its labels or where its work happens. A card at the wrong level moves with \`parent\`: another card's id, or "none" for the top level. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
|
|
11074
11729
|
inputSchema: {
|
|
11075
11730
|
card: cardArgument,
|
|
11076
11731
|
title: z.string().min(1).optional().describe("What the card is called."),
|
|
@@ -11083,18 +11738,26 @@ const cardUpdateTool = defineKanboTool({
|
|
|
11083
11738
|
"urgent"
|
|
11084
11739
|
]).optional(),
|
|
11085
11740
|
labels: z.array(z.string()).optional().describe("The labels the card carries, replacing the ones it has."),
|
|
11086
|
-
executionMode: executionModeArgument.optional()
|
|
11741
|
+
executionMode: executionModeArgument.optional(),
|
|
11742
|
+
parent: cardArgument.optional().describe("The card to put this one under, by its id or key, e.g. MAN-012; \"none\" makes it a top-level card."),
|
|
11743
|
+
detail: writtenCardDetailArgument
|
|
11087
11744
|
},
|
|
11088
11745
|
example: {
|
|
11089
11746
|
card: "TST-5",
|
|
11090
11747
|
title: "Add slugify"
|
|
11091
11748
|
},
|
|
11092
|
-
|
|
11093
|
-
|
|
11094
|
-
return
|
|
11749
|
+
prepare: (input) => {
|
|
11750
|
+
const { parent, ...rest } = input;
|
|
11751
|
+
return parent === null ? rest : input;
|
|
11752
|
+
},
|
|
11753
|
+
run: async (transport, { card, detail, parent, ...patch }) => {
|
|
11754
|
+
if (Object.keys(patch).length === 0 && parent === void 0) throw new Error("Nothing to update. Pass title, description, priority, labels, executionMode or parent.");
|
|
11755
|
+
const placement = parent === void 0 ? {} : { parent: parent === "none" ? null : parent };
|
|
11756
|
+
return formatWrittenKanboCard(await transport.cardUpdate({
|
|
11095
11757
|
card,
|
|
11096
|
-
...patch
|
|
11097
|
-
|
|
11758
|
+
...patch,
|
|
11759
|
+
...placement
|
|
11760
|
+
}), detail);
|
|
11098
11761
|
}
|
|
11099
11762
|
});
|
|
11100
11763
|
//#endregion
|
|
@@ -11103,7 +11766,7 @@ const cardUpdateTool = defineKanboTool({
|
|
|
11103
11766
|
const columnsTool = defineKanboTool({
|
|
11104
11767
|
name: "kanbo_columns",
|
|
11105
11768
|
title: "Columns of the board",
|
|
11106
|
-
description: "List this board's columns in board order, each with the slug kanbo_card_move takes, what the column means here,
|
|
11769
|
+
description: "List this board's columns in board order, each with the slug kanbo_card_move takes, what the column means here, the entryRules a card must meet before you move it in, the columns it accepts cards from (allowedFrom, slugs; null for any) and the most cards it holds (wipLimit).",
|
|
11107
11770
|
inputSchema: {},
|
|
11108
11771
|
run: async (transport) => await transport.columns()
|
|
11109
11772
|
});
|
|
@@ -11117,6 +11780,38 @@ const primeTool = defineKanboTool({
|
|
|
11117
11780
|
inputSchema: {},
|
|
11118
11781
|
run: async (transport) => (await transport.prime()).text
|
|
11119
11782
|
});
|
|
11783
|
+
//#endregion
|
|
11784
|
+
//#region src/mcp/tools/card-claim.ts
|
|
11785
|
+
/**
|
|
11786
|
+
* Taking a card to work on, in one step: the run starts and the card moves out
|
|
11787
|
+
* of To Do into the board's first work-in-progress column in the same write,
|
|
11788
|
+
* so two agents never get the same card. Published to every host, including
|
|
11789
|
+
* one that records the runs of the agents it launches: a claim is how an agent
|
|
11790
|
+
* in any terminal takes work.
|
|
11791
|
+
*/
|
|
11792
|
+
const cardClaimTool = defineKanboTool({
|
|
11793
|
+
name: "kanbo_card_claim",
|
|
11794
|
+
title: "Claim a card",
|
|
11795
|
+
description: "Take a card to work on, in one step: it starts your run and moves the card from To Do into the board's first work-in-progress column. Without `card`, takes the first ready card. Two agents never get the same card: the one that loses is told board_nothing_ready or board_card_not_ready. Answers the card compact, the run id, the column, and the kanbo_run_finish call that ends the run.",
|
|
11796
|
+
inputSchema: {
|
|
11797
|
+
card: cardArgument.optional(),
|
|
11798
|
+
agent: z.string().min(1).optional().describe("What to call whoever works the card; who this server writes as when absent.")
|
|
11799
|
+
},
|
|
11800
|
+
aliases: {
|
|
11801
|
+
card: [...CARD_ALIASES, "issue"],
|
|
11802
|
+
agent: ["agentName"]
|
|
11803
|
+
},
|
|
11804
|
+
example: { card: "TST-5" },
|
|
11805
|
+
run: async (transport, input) => {
|
|
11806
|
+
const claimed = await transport.cardClaim(input);
|
|
11807
|
+
return {
|
|
11808
|
+
card: compactKanboCard(claimed.card),
|
|
11809
|
+
run: claimed.run.id,
|
|
11810
|
+
column: claimed.column.slug,
|
|
11811
|
+
finishWith: `kanbo_run_finish {"run":"${claimed.run.id}","state":"finished"}`
|
|
11812
|
+
};
|
|
11813
|
+
}
|
|
11814
|
+
});
|
|
11120
11815
|
/** The cards an agent may pick up right now. */
|
|
11121
11816
|
const readyTool = defineKanboTool({
|
|
11122
11817
|
name: "kanbo_ready",
|
|
@@ -11205,13 +11900,14 @@ const runStartTool = defineKanboTool({
|
|
|
11205
11900
|
/**
|
|
11206
11901
|
* Every tool the board has, in the order it declares them.
|
|
11207
11902
|
*
|
|
11208
|
-
* The list is the board's, not a server's: the same
|
|
11903
|
+
* The list is the board's, not a server's: the same seventeen are registered
|
|
11209
11904
|
* whether they run against a server or against the board directly, and the
|
|
11210
11905
|
* only thing a host decides is which of them it publishes.
|
|
11211
11906
|
*/
|
|
11212
11907
|
const KANBO_TOOLS = [
|
|
11213
11908
|
primeTool,
|
|
11214
11909
|
readyTool,
|
|
11910
|
+
cardClaimTool,
|
|
11215
11911
|
columnsTool,
|
|
11216
11912
|
defineKanboTool({
|
|
11217
11913
|
name: "kanbo_sprints",
|
|
@@ -11231,10 +11927,11 @@ const KANBO_TOOLS = [
|
|
|
11231
11927
|
defineKanboTool({
|
|
11232
11928
|
name: "kanbo_status_line",
|
|
11233
11929
|
title: "Set the status line",
|
|
11234
|
-
description: "Say what the card is doing right now: `card` and `text` (one sentence). Write one at every step, including before and after anything long-running.",
|
|
11930
|
+
description: "Say what the card is doing right now: `card` and `text` (one sentence). Write one at every step, including before and after anything long-running. " + WRITTEN_CARD_ANSWER_SENTENCE,
|
|
11235
11931
|
inputSchema: {
|
|
11236
11932
|
card: cardArgument,
|
|
11237
|
-
text: z.string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`.")
|
|
11933
|
+
text: z.string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`."),
|
|
11934
|
+
detail: writtenCardDetailArgument
|
|
11238
11935
|
},
|
|
11239
11936
|
aliases: { text: ["content"] },
|
|
11240
11937
|
needs: { text: "the status line, one sentence" },
|
|
@@ -11242,22 +11939,23 @@ const KANBO_TOOLS = [
|
|
|
11242
11939
|
card: "TST-5",
|
|
11243
11940
|
text: "Running the tests"
|
|
11244
11941
|
},
|
|
11245
|
-
run: async (transport, input) => await transport.statusLine(input)
|
|
11942
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.statusLine(input), detail)
|
|
11246
11943
|
}),
|
|
11247
11944
|
defineKanboTool({
|
|
11248
11945
|
name: "kanbo_wait_approval",
|
|
11249
11946
|
title: "Hand the card to a person",
|
|
11250
|
-
description:
|
|
11947
|
+
description: `Mark the card as waiting for a person and end your turn: \`card\`, and \`text\` for the status line the person reads. A person approves or returns it; you will be told the answer. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
|
|
11251
11948
|
inputSchema: {
|
|
11252
11949
|
card: cardArgument,
|
|
11253
|
-
text: z.string().min(1).optional().describe("The status line to leave, saying what the person is being asked to look at. Also accepted as `content`.")
|
|
11950
|
+
text: z.string().min(1).optional().describe("The status line to leave, saying what the person is being asked to look at. Also accepted as `content`."),
|
|
11951
|
+
detail: writtenCardDetailArgument
|
|
11254
11952
|
},
|
|
11255
11953
|
aliases: { text: ["content"] },
|
|
11256
11954
|
example: {
|
|
11257
11955
|
card: "TST-5",
|
|
11258
11956
|
text: "Ready for review: slugify added with a test"
|
|
11259
11957
|
},
|
|
11260
|
-
run: async (transport, input) => await transport.waitApproval(input)
|
|
11958
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.waitApproval(input), detail)
|
|
11261
11959
|
}),
|
|
11262
11960
|
runStartTool,
|
|
11263
11961
|
runFinishTool
|
|
@@ -11341,7 +12039,8 @@ async function createDbTransport(input) {
|
|
|
11341
12039
|
const workspaceId = workspace.id;
|
|
11342
12040
|
const actor = input.actor;
|
|
11343
12041
|
async function readColumns() {
|
|
11344
|
-
|
|
12042
|
+
const rows = await ops.listColumns(workspaceId);
|
|
12043
|
+
return rows.map((row) => projectKanboColumn(row, rows));
|
|
11345
12044
|
}
|
|
11346
12045
|
/** One card with what the run registry says about it, in the shape a tool prints. */
|
|
11347
12046
|
async function project(card, columns) {
|
|
@@ -11391,7 +12090,7 @@ async function createDbTransport(input) {
|
|
|
11391
12090
|
* the waiting and returned ones last said at most two more.
|
|
11392
12091
|
*/
|
|
11393
12092
|
async function projectPage(page, { attend = true } = {}) {
|
|
11394
|
-
const columns = page.columns.map(projectKanboColumn);
|
|
12093
|
+
const columns = page.columns.map((column) => projectKanboColumn(column, page.columns));
|
|
11395
12094
|
const runs = await ops.readBoardProjectionForIssues(page.cards.map((card) => card.id));
|
|
11396
12095
|
const attention = attend ? await ops.readCardAttention(page.cards, runs.map((run) => run.activeRun !== null)) : [];
|
|
11397
12096
|
return page.cards.map((card, index) => projectKanboCard({
|
|
@@ -11458,10 +12157,14 @@ async function createDbTransport(input) {
|
|
|
11458
12157
|
executionMode
|
|
11459
12158
|
}, actor));
|
|
11460
12159
|
},
|
|
11461
|
-
cardUpdate: async ({ card, ...patch }) => {
|
|
12160
|
+
cardUpdate: async ({ card, parent, ...patch }) => {
|
|
11462
12161
|
await session.assertWritable();
|
|
11463
12162
|
const existing = await resolveCard(card);
|
|
11464
|
-
|
|
12163
|
+
const placement = parent === void 0 ? {} : { parentIssueId: parent === null ? null : (await resolveCard(parent)).id };
|
|
12164
|
+
return await project(await ops.updateCard(existing.id, {
|
|
12165
|
+
...patch,
|
|
12166
|
+
...placement
|
|
12167
|
+
}, actor));
|
|
11465
12168
|
},
|
|
11466
12169
|
cardMove: async ({ card, column }) => {
|
|
11467
12170
|
await session.assertWritable();
|
|
@@ -11519,6 +12222,25 @@ async function createDbTransport(input) {
|
|
|
11519
12222
|
state,
|
|
11520
12223
|
errorText
|
|
11521
12224
|
}, actor));
|
|
12225
|
+
},
|
|
12226
|
+
cardClaim: async ({ card, agent }) => {
|
|
12227
|
+
await session.assertWritable();
|
|
12228
|
+
const named = card === void 0 ? void 0 : (await resolveCard(card)).id;
|
|
12229
|
+
const claimed = await ops.claimCard({
|
|
12230
|
+
workspaceId,
|
|
12231
|
+
issueId: named,
|
|
12232
|
+
run: {
|
|
12233
|
+
agentName: agent ?? actor.id ?? "agent",
|
|
12234
|
+
externalSessionRef: sessionRefFromEnvironment() ?? void 0,
|
|
12235
|
+
launchedByKind: "external"
|
|
12236
|
+
}
|
|
12237
|
+
}, actor);
|
|
12238
|
+
const columns = await readColumns();
|
|
12239
|
+
return {
|
|
12240
|
+
card: await project(claimed.card, columns),
|
|
12241
|
+
run: projectKanboRun(claimed.run),
|
|
12242
|
+
column: columns.find((column) => column.id === claimed.column.id)
|
|
12243
|
+
};
|
|
11522
12244
|
}
|
|
11523
12245
|
};
|
|
11524
12246
|
}
|
|
@@ -11560,7 +12282,8 @@ const TRANSPORT_METHODS = {
|
|
|
11560
12282
|
statusLine: true,
|
|
11561
12283
|
waitApproval: true,
|
|
11562
12284
|
runStart: true,
|
|
11563
|
-
runFinish: true
|
|
12285
|
+
runFinish: true,
|
|
12286
|
+
cardClaim: true
|
|
11564
12287
|
};
|
|
11565
12288
|
async function openMcpBoard(input) {
|
|
11566
12289
|
try {
|
|
@@ -11615,13 +12338,40 @@ function withUpdateNote(board, peek) {
|
|
|
11615
12338
|
}
|
|
11616
12339
|
//#endregion
|
|
11617
12340
|
//#region src/cli/commands/mcp.ts
|
|
12341
|
+
/**
|
|
12342
|
+
* The actor of a `kanbo mcp` session, which is always an agent's: `KANBO_ACTOR_ID`,
|
|
12343
|
+
* else the agent tool, else `agent` until the client says its name (see
|
|
12344
|
+
* `nameActorAfterClient`).
|
|
12345
|
+
*/
|
|
12346
|
+
function createMcpActor() {
|
|
12347
|
+
const tool = readAgentTool();
|
|
12348
|
+
const namedByShell = Boolean(process.env.KANBO_ACTOR_ID?.trim() || tool && tool !== "agent");
|
|
12349
|
+
const actor = createCliActor();
|
|
12350
|
+
if (!namedByShell) actor.id = "agent";
|
|
12351
|
+
return {
|
|
12352
|
+
actor,
|
|
12353
|
+
namedByShell
|
|
12354
|
+
};
|
|
12355
|
+
}
|
|
12356
|
+
/**
|
|
12357
|
+
* Files the session's writes under the client's own name (`Test Client` ->
|
|
12358
|
+
* `test-client`) once it has initialized. `actor` is the object the board
|
|
12359
|
+
* holds, so later writes see the new id.
|
|
12360
|
+
*/
|
|
12361
|
+
function nameActorAfterClient(server, actor) {
|
|
12362
|
+
server.server.oninitialized = () => {
|
|
12363
|
+
const id = (server.server.getClientVersion()?.name)?.trim().toLowerCase().replace(/\s+/g, "-").slice(0, 40);
|
|
12364
|
+
if (id) actor.id = id;
|
|
12365
|
+
};
|
|
12366
|
+
}
|
|
11618
12367
|
function registerMcpCommand(program) {
|
|
11619
12368
|
program.command("mcp").description("Run the board's MCP server for an agent (it starts this itself)").option("--db <path>", "the board file to open").option("--database-url <url>", "a shared Postgres board to use instead of a board file").option("--workspace <nameOrId>", "the project, when this folder is not bound to one").action(async (options) => {
|
|
12369
|
+
const { actor, namedByShell } = createMcpActor();
|
|
11620
12370
|
const board = await openMcpBoard({
|
|
11621
12371
|
dbPath: options.db,
|
|
11622
12372
|
databaseUrl: options.databaseUrl,
|
|
11623
12373
|
workspaceId: options.workspace,
|
|
11624
|
-
actor
|
|
12374
|
+
actor
|
|
11625
12375
|
});
|
|
11626
12376
|
let closing;
|
|
11627
12377
|
const close = () => closing ??= board.close().catch((error) => {
|
|
@@ -11630,6 +12380,7 @@ function registerMcpCommand(program) {
|
|
|
11630
12380
|
const check = isUpdateCheckDisabled() ? null : startUpdateCheck();
|
|
11631
12381
|
const server = createKanboMcpServer(check ? withUpdateNote(board, check.peek) : board, { includeRunTools: true });
|
|
11632
12382
|
server.server.onclose = () => void close();
|
|
12383
|
+
if (!namedByShell) nameActorAfterClient(server, actor);
|
|
11633
12384
|
try {
|
|
11634
12385
|
await server.connect(new StdioServerTransport());
|
|
11635
12386
|
} catch (error) {
|
|
@@ -12138,7 +12889,7 @@ function sendBoardPage(response, pathname, assets) {
|
|
|
12138
12889
|
* answering for a person — approving a card, sending it back or closing a
|
|
12139
12890
|
* sprint — as anyone but one is 403, dropping a pull request link somebody else made is the same 403
|
|
12140
12891
|
* for the same reason, and acting on a run that has already ended is 409,
|
|
12141
|
-
* because the answer would have been yes a moment earlier. An agent putting a
|
|
12892
|
+
* because the answer would have been yes a moment earlier. Starting a run on a card that has one going, and claiming a card nobody can take right now, are 409 for the same reason. An agent putting a
|
|
12142
12893
|
* card in a column whose entry rules it does not meet yet is 409 for the same
|
|
12143
12894
|
* reason turned around: the answer will be yes once the card meets them, and
|
|
12144
12895
|
* `details.unmet` says what is missing. Deleting a column that still holds
|
|
@@ -12156,6 +12907,14 @@ const BOARD_ERROR_RESPONSES = {
|
|
|
12156
12907
|
message: "Only a person can approve a card",
|
|
12157
12908
|
code: "issue_approval_requires_user"
|
|
12158
12909
|
},
|
|
12910
|
+
board_card_not_ready: {
|
|
12911
|
+
status: 409,
|
|
12912
|
+
message: "That card is not there to take: it is not in To Do, waits for a person, or has a run going"
|
|
12913
|
+
},
|
|
12914
|
+
board_card_run_active: {
|
|
12915
|
+
status: 409,
|
|
12916
|
+
message: "The card already has a run going"
|
|
12917
|
+
},
|
|
12159
12918
|
board_column_name_taken: {
|
|
12160
12919
|
status: 400,
|
|
12161
12920
|
message: "Another column already has that name"
|
|
@@ -12195,6 +12954,14 @@ const BOARD_ERROR_RESPONSES = {
|
|
|
12195
12954
|
status: 400,
|
|
12196
12955
|
message: "Execution mode cannot change once the card has been launched"
|
|
12197
12956
|
},
|
|
12957
|
+
board_flow_rule_invalid: {
|
|
12958
|
+
status: 400,
|
|
12959
|
+
message: "A column accepts cards from other columns of its own board, and holds at least one card"
|
|
12960
|
+
},
|
|
12961
|
+
board_nothing_ready: {
|
|
12962
|
+
status: 409,
|
|
12963
|
+
message: "No card in To Do is ready to take"
|
|
12964
|
+
},
|
|
12198
12965
|
board_pull_request_invalid: {
|
|
12199
12966
|
status: 400,
|
|
12200
12967
|
message: "Not a GitHub pull request: give its URL or owner/repo#number"
|
|
@@ -12274,6 +13041,10 @@ const BOARD_ERROR_RESPONSES = {
|
|
|
12274
13041
|
status: 404,
|
|
12275
13042
|
message: "Issue not found"
|
|
12276
13043
|
},
|
|
13044
|
+
issue_parent_cycle: {
|
|
13045
|
+
status: 400,
|
|
13046
|
+
message: "Issue cannot be put under one of its own sub-issues"
|
|
13047
|
+
},
|
|
12277
13048
|
issue_parent_not_found: {
|
|
12278
13049
|
status: 404,
|
|
12279
13050
|
message: "Parent issue not found"
|
|
@@ -12606,6 +13377,14 @@ const startRunBody = z.object({
|
|
|
12606
13377
|
]).optional(),
|
|
12607
13378
|
externalSessionRef: z.string().nullable().optional()
|
|
12608
13379
|
});
|
|
13380
|
+
const claimBody = z.object({
|
|
13381
|
+
workspaceId: id,
|
|
13382
|
+
issueId: id.optional(),
|
|
13383
|
+
agentName: id.optional(),
|
|
13384
|
+
branch: z.string().nullable().optional(),
|
|
13385
|
+
executionMode: executionMode.optional(),
|
|
13386
|
+
externalSessionRef: z.string().nullable().optional()
|
|
13387
|
+
});
|
|
12609
13388
|
const finishRunBody = z.object({
|
|
12610
13389
|
state: z.enum([
|
|
12611
13390
|
"finished",
|
|
@@ -12652,13 +13431,24 @@ async function toWrittenCardView(ops, card) {
|
|
|
12652
13431
|
function toStatusView(status) {
|
|
12653
13432
|
return {
|
|
12654
13433
|
...status,
|
|
12655
|
-
entryRules: readEntryRules(status)
|
|
13434
|
+
entryRules: readEntryRules(status),
|
|
13435
|
+
allowedFrom: readFlowRules(status).allowedFrom
|
|
12656
13436
|
};
|
|
12657
13437
|
}
|
|
12658
|
-
function toCommentView(comment) {
|
|
13438
|
+
function toCommentView(comment, personWriterId = null) {
|
|
13439
|
+
if (comment.authorKind === "system.approved" || comment.authorKind === "system.returned") return {
|
|
13440
|
+
...comment,
|
|
13441
|
+
author: {
|
|
13442
|
+
kind: "user",
|
|
13443
|
+
id: comment.authorId ?? "__self__",
|
|
13444
|
+
displayName: "You",
|
|
13445
|
+
avatarUrl: null,
|
|
13446
|
+
label: null
|
|
13447
|
+
}
|
|
13448
|
+
};
|
|
12659
13449
|
return {
|
|
12660
13450
|
...comment,
|
|
12661
|
-
author: resolveActor(commentAuthorReference(comment))
|
|
13451
|
+
author: resolveActor(commentAuthorReference(comment), personWriterId)
|
|
12662
13452
|
};
|
|
12663
13453
|
}
|
|
12664
13454
|
/**
|
|
@@ -12666,23 +13456,34 @@ function toCommentView(comment) {
|
|
|
12666
13456
|
*
|
|
12667
13457
|
* For the kinds that need nobody else's tables: an outside writer is shown
|
|
12668
13458
|
* under the name it gave, the board writing for itself is `kanbo`, and a person
|
|
12669
|
-
* is `You`.
|
|
13459
|
+
* is `You`. So is an outside writer that is the person who started this
|
|
13460
|
+
* server (`personWriterId`): their own writes carry the name their shell files
|
|
13461
|
+
* them under. An agent and a provider target are rows of whatever app launched
|
|
12670
13462
|
* them, which this server cannot read, so they are shown by the id the board
|
|
12671
13463
|
* holds for them.
|
|
12672
13464
|
*/
|
|
12673
|
-
function resolveActor(actor) {
|
|
13465
|
+
function resolveActor(actor, personWriterId) {
|
|
12674
13466
|
if (actor.kind === "system") {
|
|
12675
13467
|
const provenance = {
|
|
12676
13468
|
authorId: actor.id,
|
|
12677
13469
|
sourceChatSessionId: actor.sourceChatSessionId
|
|
12678
13470
|
};
|
|
12679
|
-
if (isOutsideWriter(provenance))
|
|
12680
|
-
|
|
12681
|
-
|
|
12682
|
-
|
|
12683
|
-
|
|
12684
|
-
|
|
12685
|
-
|
|
13471
|
+
if (isOutsideWriter(provenance)) {
|
|
13472
|
+
if (personWriterId !== null && provenance.authorId === personWriterId) return {
|
|
13473
|
+
kind: "user",
|
|
13474
|
+
id: provenance.authorId,
|
|
13475
|
+
displayName: "You",
|
|
13476
|
+
avatarUrl: null,
|
|
13477
|
+
label: null
|
|
13478
|
+
};
|
|
13479
|
+
return {
|
|
13480
|
+
kind: "system",
|
|
13481
|
+
id: provenance.authorId,
|
|
13482
|
+
displayName: provenance.authorId,
|
|
13483
|
+
avatarUrl: null,
|
|
13484
|
+
label: "External"
|
|
13485
|
+
};
|
|
13486
|
+
}
|
|
12686
13487
|
return {
|
|
12687
13488
|
kind: "system",
|
|
12688
13489
|
id: null,
|
|
@@ -12718,13 +13519,13 @@ function resolveActor(actor) {
|
|
|
12718
13519
|
* serves, named by its identity; actors as `resolveActor` reads them; and an
|
|
12719
13520
|
* agent assignee by its id.
|
|
12720
13521
|
*/
|
|
12721
|
-
function createActivityResolver(workspace) {
|
|
13522
|
+
function createActivityResolver(workspace, personWriterId = null) {
|
|
12722
13523
|
return {
|
|
12723
13524
|
workspaceById: /* @__PURE__ */ new Map([[workspace.id, {
|
|
12724
13525
|
id: workspace.id,
|
|
12725
13526
|
name: workspace.name
|
|
12726
13527
|
}]]),
|
|
12727
|
-
resolveActor: async (actor) => resolveActor(actor),
|
|
13528
|
+
resolveActor: async (actor) => resolveActor(actor, personWriterId),
|
|
12728
13529
|
resolveAgentName: async (agentId) => agentId
|
|
12729
13530
|
};
|
|
12730
13531
|
}
|
|
@@ -12745,10 +13546,15 @@ function createActivityResolver(workspace) {
|
|
|
12745
13546
|
const OK = { ok: true };
|
|
12746
13547
|
/** How many cards a search answers with when the caller names no limit. */
|
|
12747
13548
|
const DEFAULT_SEARCH_LIMIT = 20;
|
|
12748
|
-
|
|
13549
|
+
/**
|
|
13550
|
+
* `personWriterId` is the id the person who started the server is filed under;
|
|
13551
|
+
* their writes read as `You`, everyone else's by name.
|
|
13552
|
+
*/
|
|
13553
|
+
function createIssueRoutes(session, options = {}) {
|
|
13554
|
+
const personWriterId = options.personWriterId ?? null;
|
|
12749
13555
|
const { ops, store, workspace } = session;
|
|
12750
13556
|
const workspaceId = workspace.id;
|
|
12751
|
-
const activityResolver = createActivityResolver(workspace);
|
|
13557
|
+
const activityResolver = createActivityResolver(workspace, personWriterId);
|
|
12752
13558
|
/** The served workspace, when that is the one the caller named — or named none. */
|
|
12753
13559
|
function requireWorkspace(named) {
|
|
12754
13560
|
if (named !== void 0 && named !== workspaceId) throw new BoardError("issue_workspace_not_found", { workspaceId: named });
|
|
@@ -13033,6 +13839,15 @@ function createIssueRoutes(session) {
|
|
|
13033
13839
|
return updated;
|
|
13034
13840
|
}
|
|
13035
13841
|
}),
|
|
13842
|
+
route({
|
|
13843
|
+
method: "GET",
|
|
13844
|
+
path: "/issues/workspace",
|
|
13845
|
+
handle: async () => ({
|
|
13846
|
+
id: workspace.id,
|
|
13847
|
+
identifier: workspace.identifier,
|
|
13848
|
+
name: workspace.name
|
|
13849
|
+
})
|
|
13850
|
+
}),
|
|
13036
13851
|
route({
|
|
13037
13852
|
method: "GET",
|
|
13038
13853
|
path: "/issues/:id",
|
|
@@ -13052,6 +13867,31 @@ function createIssueRoutes(session) {
|
|
|
13052
13867
|
}, actors.placement));
|
|
13053
13868
|
}
|
|
13054
13869
|
}),
|
|
13870
|
+
route({
|
|
13871
|
+
method: "POST",
|
|
13872
|
+
path: "/issues/claim",
|
|
13873
|
+
body: claimBody,
|
|
13874
|
+
write: true,
|
|
13875
|
+
handle: async ({ body, actors }) => {
|
|
13876
|
+
const named = requireWorkspace(body.workspaceId);
|
|
13877
|
+
if (body.issueId !== void 0) await requireCard(body.issueId);
|
|
13878
|
+
const claimed = await ops.claimCard({
|
|
13879
|
+
workspaceId: named,
|
|
13880
|
+
issueId: body.issueId,
|
|
13881
|
+
run: {
|
|
13882
|
+
agentName: body.agentName ?? actors.writer.id ?? "agent",
|
|
13883
|
+
branch: body.branch,
|
|
13884
|
+
executionMode: body.executionMode,
|
|
13885
|
+
externalSessionRef: body.externalSessionRef
|
|
13886
|
+
}
|
|
13887
|
+
}, actors.placement);
|
|
13888
|
+
return {
|
|
13889
|
+
card: await toWrittenCardView(ops, claimed.card),
|
|
13890
|
+
run: await toRunView(claimed.run),
|
|
13891
|
+
column: toStatusView(claimed.column)
|
|
13892
|
+
};
|
|
13893
|
+
}
|
|
13894
|
+
}),
|
|
13055
13895
|
route({
|
|
13056
13896
|
method: "PATCH",
|
|
13057
13897
|
path: "/issues/bulk",
|
|
@@ -13186,7 +14026,7 @@ function createIssueRoutes(session) {
|
|
|
13186
14026
|
path: "/issues/:id/comments",
|
|
13187
14027
|
handle: async ({ params }) => {
|
|
13188
14028
|
await requireCard(params.id);
|
|
13189
|
-
return (await ops.listComments(params.id)).map(toCommentView);
|
|
14029
|
+
return (await ops.listComments(params.id)).map((comment) => toCommentView(comment, personWriterId));
|
|
13190
14030
|
}
|
|
13191
14031
|
}),
|
|
13192
14032
|
route({
|
|
@@ -13199,7 +14039,7 @@ function createIssueRoutes(session) {
|
|
|
13199
14039
|
return toCommentView(await ops.addComment({
|
|
13200
14040
|
issueId: params.id,
|
|
13201
14041
|
content: body.content
|
|
13202
|
-
}, actors.
|
|
14042
|
+
}, actors.person), personWriterId);
|
|
13203
14043
|
}
|
|
13204
14044
|
}),
|
|
13205
14045
|
route({
|
|
@@ -13328,7 +14168,7 @@ function assertServeBinding(host, token) {
|
|
|
13328
14168
|
}
|
|
13329
14169
|
async function startKanboServer(options) {
|
|
13330
14170
|
assertServeBinding(options.host, options.token);
|
|
13331
|
-
const routes = createIssueRoutes(options.session);
|
|
14171
|
+
const routes = createIssueRoutes(options.session, { personWriterId: options.actors.writer.id });
|
|
13332
14172
|
const listenHost = stripBrackets(options.host);
|
|
13333
14173
|
let boundPort = 0;
|
|
13334
14174
|
const handle = createRequestHandler(routes, options, () => boundPort);
|
|
@@ -13374,6 +14214,11 @@ const SAFE_METHODS = /* @__PURE__ */ new Set([
|
|
|
13374
14214
|
"HEAD",
|
|
13375
14215
|
"OPTIONS"
|
|
13376
14216
|
]);
|
|
14217
|
+
/** What the board page tells the server about itself. */
|
|
14218
|
+
const PRESENCE_BODY = z.object({
|
|
14219
|
+
tabId: z.string().min(1).max(64).regex(/^[A-Za-z0-9-]+$/),
|
|
14220
|
+
state: z.enum(["open", "closed"])
|
|
14221
|
+
});
|
|
13377
14222
|
function createRequestHandler(routes, options, boundPort) {
|
|
13378
14223
|
const token = options.token || null;
|
|
13379
14224
|
const corsOrigins = new Set(options.corsOrigins ?? []);
|
|
@@ -13434,17 +14279,41 @@ function createRequestHandler(routes, options, boundPort) {
|
|
|
13434
14279
|
throw new ServeError(401, "unauthorized", "A valid bearer token is required");
|
|
13435
14280
|
}
|
|
13436
14281
|
if (!SAFE_METHODS.has(request.method ?? "GET") && !isJsonContentType(request.headers["content-type"])) throw new ServeError(415, "unsupported_media_type", "Send the request as content-type: application/json");
|
|
13437
|
-
|
|
13438
|
-
|
|
14282
|
+
if (url.pathname === "/page/presence" && request.method === "POST") {
|
|
14283
|
+
if (!options.presence) {
|
|
14284
|
+
response.writeHead(204).end();
|
|
14285
|
+
return;
|
|
14286
|
+
}
|
|
14287
|
+
const parsed = PRESENCE_BODY.safeParse(await readJsonBody(request));
|
|
14288
|
+
if (!parsed.success) throw validationError("body", parsed.error);
|
|
14289
|
+
if (parsed.data.state === "open") options.presence.seen(parsed.data.tabId);
|
|
14290
|
+
else options.presence.closed(parsed.data.tabId);
|
|
14291
|
+
sendJson(response, 200, { ok: true });
|
|
14292
|
+
return;
|
|
14293
|
+
}
|
|
14294
|
+
const projectPath = readProjectPath(url.pathname);
|
|
14295
|
+
if (url.pathname === "/projects" && request.method === "GET" && options.projects) {
|
|
14296
|
+
sendJson(response, 200, options.projects.list());
|
|
14297
|
+
return;
|
|
14298
|
+
}
|
|
14299
|
+
const matched = projectPath ? null : matchRoute(routes, request.method ?? "GET", url.pathname);
|
|
14300
|
+
if (!projectPath && !matched) throw new ServeError(404, "not_found", "Not Found");
|
|
14301
|
+
if (projectPath && !options.projects) throw new ServeError(404, "not_found", "Not Found");
|
|
13439
14302
|
const body = await readJsonBody(request);
|
|
13440
14303
|
const actors = resolveRequestActors(options.actors, {
|
|
13441
14304
|
actorHeader: request.headers[ACTOR_HEADER],
|
|
13442
14305
|
authenticated: token !== null
|
|
13443
14306
|
});
|
|
13444
14307
|
const result = queue.then(async () => {
|
|
13445
|
-
|
|
13446
|
-
|
|
13447
|
-
|
|
14308
|
+
const target = projectPath && options.projects ? await options.projects.resolve(projectPath.projectId) : {
|
|
14309
|
+
session: options.session,
|
|
14310
|
+
routes
|
|
14311
|
+
};
|
|
14312
|
+
const found = matched ?? matchRoute(target.routes, request.method ?? "GET", projectPath?.rest ?? url.pathname);
|
|
14313
|
+
if (!found) throw new ServeError(404, "not_found", "Not Found");
|
|
14314
|
+
if (found.route.write) await target.session.assertWritable();
|
|
14315
|
+
return await found.route.run({
|
|
14316
|
+
params: found.params,
|
|
13448
14317
|
query: readQuery(url.searchParams),
|
|
13449
14318
|
body,
|
|
13450
14319
|
actors
|
|
@@ -13470,6 +14339,21 @@ function createRequestHandler(routes, options, boundPort) {
|
|
|
13470
14339
|
}
|
|
13471
14340
|
};
|
|
13472
14341
|
}
|
|
14342
|
+
/** `/projects/<id>/issues/...` as the project's id and the `/issues/...` path under it; `null` for any other path. */
|
|
14343
|
+
function readProjectPath(pathname) {
|
|
14344
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
14345
|
+
if (segments[0] !== "projects" || segments.length < 3 || segments[2] !== "issues") return null;
|
|
14346
|
+
let projectId;
|
|
14347
|
+
try {
|
|
14348
|
+
projectId = decodeURIComponent(segments[1]);
|
|
14349
|
+
} catch {
|
|
14350
|
+
return null;
|
|
14351
|
+
}
|
|
14352
|
+
return {
|
|
14353
|
+
projectId,
|
|
14354
|
+
rest: `/${segments.slice(2).join("/")}`
|
|
14355
|
+
};
|
|
14356
|
+
}
|
|
13473
14357
|
function isJsonContentType(value) {
|
|
13474
14358
|
return value?.split(";")[0].trim().toLowerCase() === "application/json";
|
|
13475
14359
|
}
|
|
@@ -13613,6 +14497,146 @@ function readTextOrNull(path) {
|
|
|
13613
14497
|
}
|
|
13614
14498
|
}
|
|
13615
14499
|
//#endregion
|
|
14500
|
+
//#region src/serve/page-presence.ts
|
|
14501
|
+
function createPagePresence(options) {
|
|
14502
|
+
const closedGraceMs = options.closedGraceMs ?? 5e3;
|
|
14503
|
+
const staleMs = options.staleMs ?? 12e4;
|
|
14504
|
+
const tickMs = options.tickMs ?? 1e4;
|
|
14505
|
+
const now = options.now ?? Date.now;
|
|
14506
|
+
const tabs = /* @__PURE__ */ new Map();
|
|
14507
|
+
let finished = false;
|
|
14508
|
+
let graceTimer = null;
|
|
14509
|
+
let lastTick = now();
|
|
14510
|
+
function stop() {
|
|
14511
|
+
finished = true;
|
|
14512
|
+
clearInterval(ticker);
|
|
14513
|
+
if (graceTimer) {
|
|
14514
|
+
clearTimeout(graceTimer);
|
|
14515
|
+
graceTimer = null;
|
|
14516
|
+
}
|
|
14517
|
+
}
|
|
14518
|
+
function idle() {
|
|
14519
|
+
if (finished) return;
|
|
14520
|
+
stop();
|
|
14521
|
+
options.onIdle();
|
|
14522
|
+
}
|
|
14523
|
+
const ticker = setInterval(() => {
|
|
14524
|
+
const current = now();
|
|
14525
|
+
const asleep = current - lastTick > 3 * tickMs;
|
|
14526
|
+
lastTick = current;
|
|
14527
|
+
if (asleep) {
|
|
14528
|
+
for (const id of tabs.keys()) tabs.set(id, current);
|
|
14529
|
+
return;
|
|
14530
|
+
}
|
|
14531
|
+
if (tabs.size === 0) return;
|
|
14532
|
+
for (const [id, lastSeen] of tabs) if (current - lastSeen > staleMs) tabs.delete(id);
|
|
14533
|
+
if (tabs.size === 0) idle();
|
|
14534
|
+
}, tickMs);
|
|
14535
|
+
ticker.unref();
|
|
14536
|
+
return {
|
|
14537
|
+
seen: (tabId) => {
|
|
14538
|
+
if (!finished) tabs.set(tabId, now());
|
|
14539
|
+
},
|
|
14540
|
+
closed: (tabId) => {
|
|
14541
|
+
if (finished || !tabs.delete(tabId) || tabs.size > 0) return;
|
|
14542
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
14543
|
+
graceTimer = setTimeout(() => {
|
|
14544
|
+
graceTimer = null;
|
|
14545
|
+
if (tabs.size === 0) idle();
|
|
14546
|
+
}, closedGraceMs);
|
|
14547
|
+
graceTimer.unref();
|
|
14548
|
+
},
|
|
14549
|
+
stop
|
|
14550
|
+
};
|
|
14551
|
+
}
|
|
14552
|
+
//#endregion
|
|
14553
|
+
//#region src/serve/projects.ts
|
|
14554
|
+
function createServeProjects(input) {
|
|
14555
|
+
const routesOptions = { personWriterId: input.personWriterId ?? null };
|
|
14556
|
+
const opened = /* @__PURE__ */ new Map();
|
|
14557
|
+
let defaultOpened = null;
|
|
14558
|
+
/** Every project that can be offered right now, default first, with the folder each lives in. */
|
|
14559
|
+
function entries() {
|
|
14560
|
+
const defaultDir = input.defaultProject?.dir ?? null;
|
|
14561
|
+
const dirs = [...defaultDir === null ? [] : [defaultDir], ...input.readRegistry().map((project) => project.path).filter((path) => path !== defaultDir)];
|
|
14562
|
+
const found = [];
|
|
14563
|
+
for (const dir of dirs) {
|
|
14564
|
+
const binding = input.readBinding(dir);
|
|
14565
|
+
if (binding === null && dir !== defaultDir) continue;
|
|
14566
|
+
found.push({
|
|
14567
|
+
view: viewOf(dir, binding, dir === defaultDir),
|
|
14568
|
+
dir
|
|
14569
|
+
});
|
|
14570
|
+
}
|
|
14571
|
+
return found;
|
|
14572
|
+
}
|
|
14573
|
+
return {
|
|
14574
|
+
list: () => entries().map((entry) => entry.view),
|
|
14575
|
+
resolve: async (projectId) => {
|
|
14576
|
+
const entry = entries().find((candidate) => candidate.view.id === projectId);
|
|
14577
|
+
if (!entry) throw new ServeError(404, "project_not_found", "No project with that id is on this computer's list");
|
|
14578
|
+
if (entry.view.isDefault && input.defaultProject) {
|
|
14579
|
+
defaultOpened ??= {
|
|
14580
|
+
session: input.defaultProject.session,
|
|
14581
|
+
routes: createIssueRoutes(input.defaultProject.session, routesOptions)
|
|
14582
|
+
};
|
|
14583
|
+
return defaultOpened;
|
|
14584
|
+
}
|
|
14585
|
+
const cached = opened.get(projectId);
|
|
14586
|
+
if (cached) return cached;
|
|
14587
|
+
let session;
|
|
14588
|
+
try {
|
|
14589
|
+
session = await input.openSession(entry.dir);
|
|
14590
|
+
} catch (error) {
|
|
14591
|
+
throw new ServeError(503, "project_unavailable", describeFailure(error).message);
|
|
14592
|
+
}
|
|
14593
|
+
const project = {
|
|
14594
|
+
session,
|
|
14595
|
+
routes: createIssueRoutes(session, routesOptions)
|
|
14596
|
+
};
|
|
14597
|
+
opened.set(projectId, project);
|
|
14598
|
+
return project;
|
|
14599
|
+
},
|
|
14600
|
+
close: async () => {
|
|
14601
|
+
const sessions = [...opened.values()].map((project) => project.session);
|
|
14602
|
+
opened.clear();
|
|
14603
|
+
await Promise.allSettled(sessions.map(async (session) => await session.close()));
|
|
14604
|
+
}
|
|
14605
|
+
};
|
|
14606
|
+
}
|
|
14607
|
+
function projectId(dir) {
|
|
14608
|
+
return createHash("sha256").update(dir).digest("hex").slice(0, 12);
|
|
14609
|
+
}
|
|
14610
|
+
function viewOf(dir, binding, isDefault) {
|
|
14611
|
+
return {
|
|
14612
|
+
id: projectId(dir),
|
|
14613
|
+
name: dir.split(/[\\/]/).filter(Boolean).pop() ?? dir,
|
|
14614
|
+
folder: tildify(dir),
|
|
14615
|
+
storage: storageOf(dir, binding),
|
|
14616
|
+
isDefault
|
|
14617
|
+
};
|
|
14618
|
+
}
|
|
14619
|
+
/** Where the board lives. The connection string is read here to name its host, and goes no further. */
|
|
14620
|
+
function storageOf(dir, binding) {
|
|
14621
|
+
if (binding?.databaseUrl) return {
|
|
14622
|
+
kind: "postgres",
|
|
14623
|
+
where: hostOf(maskDatabaseUrl(binding.databaseUrl))
|
|
14624
|
+
};
|
|
14625
|
+
return {
|
|
14626
|
+
kind: "file",
|
|
14627
|
+
path: tildify(resolve(dir, binding?.dbPath ?? ".kanbo/board.db"))
|
|
14628
|
+
};
|
|
14629
|
+
}
|
|
14630
|
+
/** The host of an already masked connection string; a string that is not one names no host. */
|
|
14631
|
+
function hostOf(maskedUrl) {
|
|
14632
|
+
try {
|
|
14633
|
+
const { host } = new URL(maskedUrl);
|
|
14634
|
+
return host && !host.includes("*") ? host : "a Postgres database";
|
|
14635
|
+
} catch {
|
|
14636
|
+
return "a Postgres database";
|
|
14637
|
+
}
|
|
14638
|
+
}
|
|
14639
|
+
//#endregion
|
|
13616
14640
|
//#region src/cli/commands/serve.ts
|
|
13617
14641
|
function registerServeCommand(program) {
|
|
13618
14642
|
program.command("serve").description("Open the board in your browser").option("--port <port>", "port to listen on", String(DEFAULT_SERVE_PORT)).option("--host <host>", "address to listen on; anything but this computer needs a token", DEFAULT_SERVE_HOST).option("--db <path>", "the board file to open").option("--database-url <url>", "a shared Postgres board to use instead of a board file").option("--workspace <nameOrId>", "the project, when this folder is not bound to one").option("--token <token>", "a password every request must carry (or set KANBO_SERVE_TOKEN); made up for you when left out").option("--cors-origin <origin>", "let pages from this web address call the board; repeat for more", collect, []).option("--no-open", "print the board page link without opening it in the browser").action(async (options) => {
|
|
@@ -13620,7 +14644,7 @@ function registerServeCommand(program) {
|
|
|
13620
14644
|
status: (line) => console.error(line),
|
|
13621
14645
|
page: (line) => console.log(line)
|
|
13622
14646
|
});
|
|
13623
|
-
await
|
|
14647
|
+
if (await waitForStop(running.idle) === "idle") console.error("kanbo serve: the last board tab was closed — stopped.");
|
|
13624
14648
|
try {
|
|
13625
14649
|
await running.close();
|
|
13626
14650
|
process.exit(0);
|
|
@@ -13632,26 +14656,46 @@ function registerServeCommand(program) {
|
|
|
13632
14656
|
}
|
|
13633
14657
|
/**
|
|
13634
14658
|
* Open the board and serve it — everything `kanbo serve` does but wait. The
|
|
13635
|
-
* command waits for Ctrl-C
|
|
13636
|
-
*
|
|
14659
|
+
* command waits for Ctrl-C (or the last board tab closing) and ends the
|
|
14660
|
+
* process; the home menu waits the same and comes back to its menu.
|
|
13637
14661
|
*/
|
|
13638
14662
|
async function startServe(options, output) {
|
|
13639
14663
|
const port = parsePort(options.port);
|
|
13640
14664
|
const access = resolveServeAccess(options.host, options.token?.trim() || process.env.KANBO_SERVE_TOKEN?.trim() || null);
|
|
14665
|
+
const here = findBinding(process.cwd());
|
|
14666
|
+
const mostRecent = here === null && namesNoBoard(options) ? readProjectRegistry()[0] : void 0;
|
|
14667
|
+
const defaultDir = here?.projectDir ?? mostRecent?.path ?? null;
|
|
13641
14668
|
const session = await openBoardSession({
|
|
13642
14669
|
db: options.db,
|
|
13643
14670
|
databaseUrl: options.databaseUrl,
|
|
13644
14671
|
workspace: options.workspace
|
|
13645
|
-
}, "read");
|
|
14672
|
+
}, "read", mostRecent?.path);
|
|
13646
14673
|
const agentShell = isAgentShell();
|
|
13647
14674
|
const actors = {
|
|
13648
|
-
writer:
|
|
14675
|
+
writer: createServeWriter(),
|
|
13649
14676
|
person: agentShell ? null : createPersonActor()
|
|
13650
14677
|
};
|
|
14678
|
+
const projects = createServeProjects({
|
|
14679
|
+
defaultProject: defaultDir === null ? null : {
|
|
14680
|
+
dir: defaultDir,
|
|
14681
|
+
session
|
|
14682
|
+
},
|
|
14683
|
+
readRegistry: () => readProjectRegistry(),
|
|
14684
|
+
readBinding: (dir) => readProjectBinding(dir),
|
|
14685
|
+
openSession: async (dir) => await openBoardSession({}, "read", dir),
|
|
14686
|
+
personWriterId: actors.writer.id
|
|
14687
|
+
});
|
|
14688
|
+
const opensBrowser = !agentShell && process.stdout.isTTY === true && options.open;
|
|
14689
|
+
let presence;
|
|
14690
|
+
const idle = new Promise((resolve) => {
|
|
14691
|
+
presence = opensBrowser ? createPagePresence({ onIdle: resolve }) : void 0;
|
|
14692
|
+
});
|
|
13651
14693
|
let server;
|
|
13652
14694
|
try {
|
|
13653
14695
|
server = await startKanboServer({
|
|
13654
14696
|
session,
|
|
14697
|
+
projects,
|
|
14698
|
+
presence,
|
|
13655
14699
|
actors,
|
|
13656
14700
|
host: options.host,
|
|
13657
14701
|
port,
|
|
@@ -13659,10 +14703,12 @@ async function startServe(options, output) {
|
|
|
13659
14703
|
corsOrigins: options.corsOrigin
|
|
13660
14704
|
});
|
|
13661
14705
|
} catch (error) {
|
|
14706
|
+
presence?.stop();
|
|
13662
14707
|
await session.close();
|
|
13663
14708
|
throw error;
|
|
13664
14709
|
}
|
|
13665
|
-
|
|
14710
|
+
const projectCount = projects.list().length;
|
|
14711
|
+
output.status(`kanbo serve: ${server.url} — workspace ${session.workspace.id}, ${projectCount} ${projectCount === 1 ? "project" : "projects"}, bearer token required${access.generated ? " (generated for this run)" : ""}`);
|
|
13666
14712
|
output.status(`kanbo serve: ${describePersonRights(actors)}`);
|
|
13667
14713
|
presentServePage({
|
|
13668
14714
|
url: server.url,
|
|
@@ -13676,25 +14722,40 @@ async function startServe(options, output) {
|
|
|
13676
14722
|
});
|
|
13677
14723
|
return {
|
|
13678
14724
|
link: access.generated ? `${server.url}/#token=${access.token}` : `${server.url}/`,
|
|
14725
|
+
idle,
|
|
13679
14726
|
close: async () => {
|
|
14727
|
+
presence?.stop();
|
|
13680
14728
|
try {
|
|
13681
14729
|
await server.close();
|
|
13682
14730
|
} finally {
|
|
13683
|
-
|
|
14731
|
+
try {
|
|
14732
|
+
await projects.close();
|
|
14733
|
+
} finally {
|
|
14734
|
+
await session.close();
|
|
14735
|
+
}
|
|
13684
14736
|
}
|
|
13685
14737
|
}
|
|
13686
14738
|
};
|
|
13687
14739
|
}
|
|
13688
|
-
/**
|
|
13689
|
-
function
|
|
14740
|
+
/** Whether neither a flag nor the environment says which board to open. */
|
|
14741
|
+
function namesNoBoard(options) {
|
|
14742
|
+
return !options.db && !options.databaseUrl && !options.workspace && !process.env.KANBO_DB_PATH && !process.env.KANBO_DATABASE_URL && !process.env.KANBO_WORKSPACE_ID;
|
|
14743
|
+
}
|
|
14744
|
+
/**
|
|
14745
|
+
* Settles on the first Ctrl-C (SIGINT) or SIGTERM, or when `idle` settles, and
|
|
14746
|
+
* stops listening for both signals whichever it was.
|
|
14747
|
+
*/
|
|
14748
|
+
function waitForStop(idle) {
|
|
13690
14749
|
return new Promise((resolve) => {
|
|
13691
|
-
const
|
|
13692
|
-
process.off("SIGINT",
|
|
13693
|
-
process.off("SIGTERM",
|
|
13694
|
-
resolve();
|
|
14750
|
+
const finish = (why) => {
|
|
14751
|
+
process.off("SIGINT", onSignal);
|
|
14752
|
+
process.off("SIGTERM", onSignal);
|
|
14753
|
+
resolve(why);
|
|
13695
14754
|
};
|
|
13696
|
-
|
|
13697
|
-
process.on("
|
|
14755
|
+
const onSignal = () => finish("interrupt");
|
|
14756
|
+
process.on("SIGINT", onSignal);
|
|
14757
|
+
process.on("SIGTERM", onSignal);
|
|
14758
|
+
idle.then(() => finish("idle"));
|
|
13698
14759
|
});
|
|
13699
14760
|
}
|
|
13700
14761
|
function collect(value, previous) {
|
|
@@ -13965,6 +15026,7 @@ function registerKanboCommands(program, context = {}) {
|
|
|
13965
15026
|
registerServeCommand(program);
|
|
13966
15027
|
program.commandsGroup("For agents and integrations:");
|
|
13967
15028
|
registerMcpCommand(program);
|
|
15029
|
+
registerClaimCommand(program);
|
|
13968
15030
|
registerRunCommands(program);
|
|
13969
15031
|
registerCapabilitiesCommand(program);
|
|
13970
15032
|
program.commandsGroup("Shared Postgres boards:");
|
|
@@ -13988,6 +15050,7 @@ function registerKanboCommands(program, context = {}) {
|
|
|
13988
15050
|
const KANBO_TOOL_NAMES = [
|
|
13989
15051
|
"kanbo_prime",
|
|
13990
15052
|
"kanbo_ready",
|
|
15053
|
+
"kanbo_card_claim",
|
|
13991
15054
|
"kanbo_columns",
|
|
13992
15055
|
"kanbo_sprints",
|
|
13993
15056
|
"kanbo_card_get",
|
|
@@ -14013,12 +15076,13 @@ const KANBO_TOOL_NAMES = [
|
|
|
14013
15076
|
const KANBO_TOOL_CLI_EQUIVALENTS = {
|
|
14014
15077
|
kanbo_prime: { kanbo: "kanbo prime" },
|
|
14015
15078
|
kanbo_ready: { kanbo: "kanbo ready" },
|
|
15079
|
+
kanbo_card_claim: { kanbo: "kanbo claim [card] [--agent <name>]" },
|
|
14016
15080
|
kanbo_columns: { kanbo: "kanbo columns list" },
|
|
14017
15081
|
kanbo_sprints: { kanbo: "kanbo sprint list" },
|
|
14018
15082
|
kanbo_card_get: { kanbo: "kanbo card get <id>" },
|
|
14019
15083
|
kanbo_card_list: { kanbo: "kanbo card list" },
|
|
14020
15084
|
kanbo_card_create: { kanbo: "kanbo card create --description <text> [--title <title>] [--parent <id>]" },
|
|
14021
|
-
kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>]" },
|
|
15085
|
+
kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>] [--parent <id>]" },
|
|
14022
15086
|
kanbo_card_move: { kanbo: "kanbo card move <id> <column>" },
|
|
14023
15087
|
kanbo_card_comment: { kanbo: "kanbo card comment <id> --content <text>" },
|
|
14024
15088
|
kanbo_card_link_pr: { kanbo: "kanbo card pr add <id> <url>" },
|
|
@@ -14160,13 +15224,15 @@ function buildCapabilitiesManifest() {
|
|
|
14160
15224
|
"Only a person may close a sprint. `kanbo sprint close` refuses an agent's shell (exit 4), and the operation refuses any actor but a person; there is no MCP tool that closes one — `kanbo_sprints` only reads. Unlike approval, the database role does not enforce this.",
|
|
14161
15225
|
`The agent role (\`${BOARD_AGENT_ROLE}\`) may not delete a card at all — it holds select, insert and update on \`issues\` and nothing more, even though it may delete freely on every other board table.`,
|
|
14162
15226
|
"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.",
|
|
15227
|
+
"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.",
|
|
15228
|
+
"A claim (`kanbo claim`, `kanbo_card_claim`, `POST /issues/claim`) takes a ready card from To Do, starts its run and moves it into the board's first `started` column in one write; two claimers never get the same card, and the one that loses is told `board_nothing_ready` or `board_card_not_ready`. A card has one run going at a time: starting a second is refused with `board_card_run_active`, on every path.",
|
|
14163
15229
|
"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.",
|
|
14164
15230
|
"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).",
|
|
14165
15231
|
"`kanbo migrate` and `kanbo roles apply` refuse a host app's own database file: the app migrates it, and this package never touches it.",
|
|
14166
|
-
"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)
|
|
15232
|
+
"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.",
|
|
14167
15233
|
"There is no MCP tool that approves a card, and there never will be: `kanbo_wait_approval` hands the card to a person and ends the turn; only a person takes it back."
|
|
14168
15234
|
]
|
|
14169
15235
|
};
|
|
14170
15236
|
}
|
|
14171
15237
|
//#endregion
|
|
14172
|
-
export {
|
|
15238
|
+
export { serializeEntryRules as $, projectKanboSprint as A, createBoardOps as B, pageOf as C, toIssueView as Ct, projectKanboFieldChange as D, nextIssueOrder as Dt, projectKanboColumn as E, nextIssueNumber as Et, BINDING_FILE_PATH as F, FLOW_RULES as G, formatExternalSessionRef as H, ignoreBoardFile as I, ENTRY_RULE_DESCRIPTIONS as J, readFlowRules as K, readBinding as L, renderCapabilitiesMarkdown as M, maskDatabaseUrl as N, projectKanboPullRequest as O, readIssuePrefix as Ot, maskDatabaseUrls as P, readEntryRules as Q, readProjectBinding as R, matchesKanboCardQuery as S, normalizeCommentAuthorKind as St, projectKanboCardComment as T, nextIssueIdentity as Tt, parseExternalSessionRef as U, cardDisplayTitle as V, pullRequestFactKeys as W, evaluateEntryRules as X, UNMET_ENTRY_RULE_REASONS as Y, readChecklist as Z, suggestCardKey as _, formatPlainActivityValue as _t, BOARD_ERROR_RESPONSES as a, DEFAULT_STATUSES as at, DEFAULT_KANBO_COMMENT_LIMIT as b, readSystemCommentKind as bt, registerKanboTools as c, diffFieldChanges as ct, KANBO_MCP_INSTRUCTIONS as d, currentUnixSeconds as dt, COLUMN_CATALOGUE as et, KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI as f, activityKindOrder as ft, isValidCardKey as g, formatActivityIssueReference as gt, CARD_KEY_PATTERN as h, commentAuthorReference as ht, KANBO_TOOL_NAMES as i, runBoardWrite as it, projectKanboSubCard as j, projectKanboRun as k, KANBO_TOOLS as l, previewComment as lt, registerKanboCapabilitiesResources as m, activityToken as mt, buildCapabilitiesManifest as n, COLUMN_TEMPLATE_IDS as nt, KANBO_MCP_SERVER_NAME as o, normalizeStatusName as ot, KANBO_CAPABILITIES_RESOURCE_URI as p, activityText as pt, ENTRY_RULES as q, KANBO_TOOL_CLI_EQUIVALENTS as r, readChangeSeq as rt, createKanboMcpServer as s, TRACKED_FIELDS as st, CAPABILITIES_MANIFEST_VERSION as t, COLUMN_TEMPLATES as tt, buildKanboTools as u, BOARD_ACTOR as ut, DEFAULT_BOARD_FILE_PATH as v, isEmptyActivityValue as vt, projectKanboCard as w, formatIssueId as wt, KANBO_CARD_INCLUDES as x, isOutsideWriter as xt, DEFAULT_KANBO_CARD_INCLUDES as y, parseActivityStringArray as yt, writeBinding as z };
|