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
|
@@ -21,8 +21,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
21
21
|
}) : target, mod));
|
|
22
22
|
//#endregion
|
|
23
23
|
const require_migrations_path = require("./migrations-path-DBCE17dT.cjs");
|
|
24
|
-
const require_roles_sql = require("./roles.sql-
|
|
25
|
-
const require_migrate = require("./migrate-
|
|
24
|
+
const require_roles_sql = require("./roles.sql-CBFxSHy7.cjs");
|
|
25
|
+
const require_migrate = require("./migrate-BoGrqK9m.cjs");
|
|
26
26
|
let commander = require("commander");
|
|
27
27
|
let zod = require("zod");
|
|
28
28
|
let node_os = require("node:os");
|
|
@@ -77,22 +77,26 @@ const AGENT_SHELL_MARKERS = [
|
|
|
77
77
|
{
|
|
78
78
|
variable: "CLAUDECODE",
|
|
79
79
|
matches: (value) => value === "1",
|
|
80
|
-
label: "CLAUDECODE=1"
|
|
80
|
+
label: "CLAUDECODE=1",
|
|
81
|
+
tool: "claude"
|
|
81
82
|
},
|
|
82
83
|
{
|
|
83
84
|
variable: "GEMINI_CLI",
|
|
84
85
|
matches: (value) => value === "1",
|
|
85
|
-
label: "GEMINI_CLI=1"
|
|
86
|
+
label: "GEMINI_CLI=1",
|
|
87
|
+
tool: "gemini"
|
|
86
88
|
},
|
|
87
89
|
{
|
|
88
90
|
variable: "CURSOR_AGENT",
|
|
89
91
|
matches: (value) => value !== "",
|
|
90
|
-
label: "CURSOR_AGENT"
|
|
92
|
+
label: "CURSOR_AGENT",
|
|
93
|
+
tool: "cursor"
|
|
91
94
|
},
|
|
92
95
|
{
|
|
93
96
|
variable: "CODEX_THREAD_ID",
|
|
94
97
|
matches: (value) => value !== "",
|
|
95
|
-
label: "CODEX_THREAD_ID"
|
|
98
|
+
label: "CODEX_THREAD_ID",
|
|
99
|
+
tool: "codex"
|
|
96
100
|
}
|
|
97
101
|
];
|
|
98
102
|
/**
|
|
@@ -110,6 +114,15 @@ function readAgentShellMarker(env = process.env) {
|
|
|
110
114
|
return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.label ?? null;
|
|
111
115
|
}
|
|
112
116
|
/**
|
|
117
|
+
* The name of the agent tool this shell belongs to (`claude`, `gemini`,
|
|
118
|
+
* `cursor`, `codex`), `agent` when only `KANBO_ACTOR_KIND=agent` says so, or
|
|
119
|
+
* `null` for a person's shell. Same precedence as `readAgentShellMarker`.
|
|
120
|
+
*/
|
|
121
|
+
function readAgentTool(env = process.env) {
|
|
122
|
+
if (readAgentShellMarker(env) === null) return null;
|
|
123
|
+
return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.tool ?? "agent";
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
113
126
|
* Every exit code this tool promises, and what it means — the capabilities
|
|
114
127
|
* manifest's `exitCodes` reads this rather than naming the codes again. `0` and
|
|
115
128
|
* `1` are not declared above: they need no name of their own, success and "I
|
|
@@ -283,6 +296,7 @@ const HUMAN_ONLY_ACTIONS = [
|
|
|
283
296
|
"return",
|
|
284
297
|
"sprint close",
|
|
285
298
|
"columns rules",
|
|
299
|
+
"columns flow",
|
|
286
300
|
"columns add",
|
|
287
301
|
"columns rename",
|
|
288
302
|
"columns move",
|
|
@@ -296,6 +310,7 @@ const HUMAN_ONLY_MESSAGES = {
|
|
|
296
310
|
"return": describeReturnRefusal,
|
|
297
311
|
"sprint close": describeSprintCloseRefusal,
|
|
298
312
|
"columns rules": describeColumnRulesRefusal,
|
|
313
|
+
"columns flow": describeColumnRulesRefusal,
|
|
299
314
|
"columns add": describeColumnChangesRefusal,
|
|
300
315
|
"columns rename": describeColumnChangesRefusal,
|
|
301
316
|
"columns move": describeColumnChangesRefusal,
|
|
@@ -305,11 +320,15 @@ const HUMAN_ONLY_MESSAGES = {
|
|
|
305
320
|
"run clear-session": describeRunSessionRefRefusal,
|
|
306
321
|
"run attach-session --replace": describeRunSessionRefRefusal
|
|
307
322
|
};
|
|
308
|
-
/**
|
|
323
|
+
/**
|
|
324
|
+
* The actor every board write from this tool is filed under: `KANBO_ACTOR_ID`,
|
|
325
|
+
* else the agent tool of an agent's shell (`claude`, `codex`, ...), else the
|
|
326
|
+
* operating system's user.
|
|
327
|
+
*/
|
|
309
328
|
function createCliActor() {
|
|
310
329
|
return {
|
|
311
330
|
kind: "external",
|
|
312
|
-
id: process.env.KANBO_ACTOR_ID?.trim() || safeUsername(),
|
|
331
|
+
id: process.env.KANBO_ACTOR_ID?.trim() || readAgentTool() || safeUsername(),
|
|
313
332
|
name: (0, node_os.hostname)()
|
|
314
333
|
};
|
|
315
334
|
}
|
|
@@ -347,6 +366,14 @@ function createPlacementActor() {
|
|
|
347
366
|
placesForPerson: true
|
|
348
367
|
};
|
|
349
368
|
}
|
|
369
|
+
/** 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. */
|
|
370
|
+
function createServeWriter() {
|
|
371
|
+
return {
|
|
372
|
+
kind: "external",
|
|
373
|
+
id: (isAgentShell() ? "" : process.env.KANBO_ACTOR_ID?.trim() ?? "") || safeUsername(),
|
|
374
|
+
name: (0, node_os.hostname)()
|
|
375
|
+
};
|
|
376
|
+
}
|
|
350
377
|
/**
|
|
351
378
|
* The person who owns this shell, as the board files a person. Only for a
|
|
352
379
|
* caller that has already asked `isAgentShell` — `requireHumanActor` for a
|
|
@@ -764,6 +791,14 @@ const HIDDEN_ACTIVITY_FIELDS = /* @__PURE__ */ new Set([
|
|
|
764
791
|
"order",
|
|
765
792
|
"waitingFor"
|
|
766
793
|
]);
|
|
794
|
+
/**
|
|
795
|
+
* The column a card was created in, recorded as a change from no column at the
|
|
796
|
+
* card's own creation time. The feed's `created` item already stands for it,
|
|
797
|
+
* and the field history keeps it.
|
|
798
|
+
*/
|
|
799
|
+
function isStartingColumn(change, issue) {
|
|
800
|
+
return change.field === "statusId" && change.fromValue === null && change.createdAt === issue.createdAt;
|
|
801
|
+
}
|
|
767
802
|
const ACTIVITY_FIELD_BY_RAW_FIELD = {
|
|
768
803
|
assigneeId: "assignee",
|
|
769
804
|
description: "description",
|
|
@@ -804,7 +839,7 @@ async function listActivity(store, issueId, resolver) {
|
|
|
804
839
|
workspaceById: resolver.workspaceById
|
|
805
840
|
};
|
|
806
841
|
const commentItems = await Promise.all((await store.comments.listByIssue(issueId)).map(async (comment) => await toCommentActivityItem(comment, resolver)));
|
|
807
|
-
const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
|
|
842
|
+
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);
|
|
808
843
|
return [
|
|
809
844
|
{
|
|
810
845
|
id: `${issue.id}:created`,
|
|
@@ -1347,7 +1382,9 @@ const UNMET_ENTRY_RULE_REASONS = [
|
|
|
1347
1382
|
"ci_red",
|
|
1348
1383
|
"approval_pending",
|
|
1349
1384
|
"returned",
|
|
1350
|
-
"not_approved"
|
|
1385
|
+
"not_approved",
|
|
1386
|
+
"not_allowed_from",
|
|
1387
|
+
"column_full"
|
|
1351
1388
|
];
|
|
1352
1389
|
const entryRulesSchema = zod.z.array(zod.z.enum(ENTRY_RULES));
|
|
1353
1390
|
/**
|
|
@@ -1494,6 +1531,65 @@ function describeColumnRefusal(details) {
|
|
|
1494
1531
|
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");
|
|
1495
1532
|
}
|
|
1496
1533
|
//#endregion
|
|
1534
|
+
//#region src/domain/flow-rules.ts
|
|
1535
|
+
/** The two flow rules, as an unmet entry names them. */
|
|
1536
|
+
const FLOW_RULES = ["allowed_from", "wip_limit"];
|
|
1537
|
+
/** The column the standard flow guards, and the columns it lets cards in from, by slug. */
|
|
1538
|
+
const STANDARD_FLOW_TARGET = "done";
|
|
1539
|
+
const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
|
|
1540
|
+
/** A column's flow rules, out of what the row stores. */
|
|
1541
|
+
function readFlowRules(column) {
|
|
1542
|
+
return {
|
|
1543
|
+
allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
|
|
1544
|
+
wipLimit: column.wipLimit
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
/** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
|
|
1548
|
+
function serializeAllowedFrom(allowedFrom) {
|
|
1549
|
+
return allowedFrom === null ? null : JSON.stringify(allowedFrom);
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* The flow rules a card entering `column` does not meet, where it may come from
|
|
1553
|
+
* first. Pure: what the board holds arrives in `facts`. A card being created
|
|
1554
|
+
* comes from no column, so a column that names where cards come from does not
|
|
1555
|
+
* take it.
|
|
1556
|
+
*/
|
|
1557
|
+
function evaluateFlowRules(card, column, rules, facts) {
|
|
1558
|
+
const unmet = [];
|
|
1559
|
+
if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
|
|
1560
|
+
rule: "allowed_from",
|
|
1561
|
+
reason: "not_allowed_from",
|
|
1562
|
+
detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
|
|
1563
|
+
});
|
|
1564
|
+
if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
|
|
1565
|
+
rule: "wip_limit",
|
|
1566
|
+
reason: "column_full",
|
|
1567
|
+
detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
|
|
1568
|
+
});
|
|
1569
|
+
return unmet;
|
|
1570
|
+
}
|
|
1571
|
+
function describeAllowedFrom(name, allowedFrom, columnNames) {
|
|
1572
|
+
const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
|
|
1573
|
+
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`;
|
|
1574
|
+
}
|
|
1575
|
+
/** `A`, `A and B`, `A, B and C`. */
|
|
1576
|
+
function joinWords(words) {
|
|
1577
|
+
return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* The standard flow on these columns: Done, and the columns it takes cards from
|
|
1581
|
+
* — In Review, and QA when there is one — in board order. `null` when the board
|
|
1582
|
+
* has no Done, or neither of the two.
|
|
1583
|
+
*/
|
|
1584
|
+
function standardFlowOf(columns) {
|
|
1585
|
+
const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
|
|
1586
|
+
const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
|
|
1587
|
+
return target && sources.length > 0 ? {
|
|
1588
|
+
target,
|
|
1589
|
+
sources
|
|
1590
|
+
} : null;
|
|
1591
|
+
}
|
|
1592
|
+
//#endregion
|
|
1497
1593
|
//#region src/ops/columns.ts
|
|
1498
1594
|
/** The columns of one workspace, in board order. */
|
|
1499
1595
|
async function listColumns(store, workspaceId) {
|
|
@@ -1563,6 +1659,7 @@ async function applyColumnTemplate(store, workspaceId, columns, options, scope)
|
|
|
1563
1659
|
workspaceId,
|
|
1564
1660
|
...column
|
|
1565
1661
|
}, order);
|
|
1662
|
+
await writeStandardFlow(tx, await listColumns(tx, workspaceId));
|
|
1566
1663
|
return {
|
|
1567
1664
|
statuses: await listColumns(tx, workspaceId),
|
|
1568
1665
|
added: columns.map((column) => column.name)
|
|
@@ -1672,6 +1769,80 @@ async function setColumnEntryRules(store, workspaceId, nameOrId, entryRules, act
|
|
|
1672
1769
|
return await updateColumn(tx, (await requireColumn(tx, workspaceId, nameOrId)).id, { entryRules }, actor, { tx });
|
|
1673
1770
|
}, scope);
|
|
1674
1771
|
}
|
|
1772
|
+
/** The largest WIP limit: what a Postgres `integer` column holds. */
|
|
1773
|
+
const MAX_WIP_LIMIT = 2147483647;
|
|
1774
|
+
/**
|
|
1775
|
+
* Set where a card may enter the column from and how many cards it holds:
|
|
1776
|
+
* `allowedFrom` the ids of other columns of its board (`null` for any, `[]` for
|
|
1777
|
+
* none), kept once each in board order; `wipLimit` a whole number from 1 to 2147483647, or `null`. A
|
|
1778
|
+
* person's to set, like the entry rules: an agent that could open a column
|
|
1779
|
+
* could walk any card into it.
|
|
1780
|
+
*/
|
|
1781
|
+
async function setColumnFlowRules(store, columnId, rules, actor, scope) {
|
|
1782
|
+
assertPersonSetsEntryRules(actor, { statusId: columnId });
|
|
1783
|
+
if (rules.wipLimit !== null && !(Number.isSafeInteger(rules.wipLimit) && rules.wipLimit >= 1 && rules.wipLimit <= MAX_WIP_LIMIT)) throw new require_migrations_path.BoardError("board_flow_rule_invalid", {
|
|
1784
|
+
statusId: columnId,
|
|
1785
|
+
wipLimit: rules.wipLimit
|
|
1786
|
+
});
|
|
1787
|
+
return await runBoardWrite(store, async ({ tx }) => {
|
|
1788
|
+
const column = await tx.statuses.findById(columnId);
|
|
1789
|
+
if (!column) throw new require_migrations_path.BoardError("issue_status_not_found", { statusId: columnId });
|
|
1790
|
+
const allowedFrom = rules.allowedFrom === null ? null : allowedFromInBoardOrder(await listColumns(tx, column.workspaceId), column, rules.allowedFrom);
|
|
1791
|
+
await tx.statuses.update(columnId, {
|
|
1792
|
+
allowedFrom: serializeAllowedFrom(allowedFrom),
|
|
1793
|
+
wipLimit: rules.wipLimit
|
|
1794
|
+
});
|
|
1795
|
+
return await tx.statuses.findById(columnId);
|
|
1796
|
+
}, scope);
|
|
1797
|
+
}
|
|
1798
|
+
/** The ids asked for, once each and in board order — or the refusal naming those that are not another column of this board. */
|
|
1799
|
+
function allowedFromInBoardOrder(columns, column, requested) {
|
|
1800
|
+
const others = columns.filter((other) => other.id !== column.id);
|
|
1801
|
+
const invalid = requested.filter((id) => !others.some((other) => other.id === id));
|
|
1802
|
+
if (invalid.length > 0) throw new require_migrations_path.BoardError("board_flow_rule_invalid", {
|
|
1803
|
+
statusId: column.id,
|
|
1804
|
+
allowedFrom: invalid
|
|
1805
|
+
});
|
|
1806
|
+
return others.filter((other) => requested.includes(other.id)).map((other) => other.id);
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* Put the standard flow on a board: Done accepts cards from In Review only —
|
|
1810
|
+
* and from QA, when the board has it — so nothing reaches Done but through
|
|
1811
|
+
* review. Done's WIP limit and every other column stay as they are. A board
|
|
1812
|
+
* without Done, or with neither In Review nor QA, has nothing to apply it to.
|
|
1813
|
+
*/
|
|
1814
|
+
async function applyStandardFlow(store, workspaceId, actor, scope) {
|
|
1815
|
+
assertPersonSetsEntryRules(actor, { workspaceId });
|
|
1816
|
+
return await runBoardWrite(store, async ({ tx }) => {
|
|
1817
|
+
const columns = await listColumns(tx, workspaceId);
|
|
1818
|
+
if (!await writeStandardFlow(tx, columns)) {
|
|
1819
|
+
const hasDone = columns.some((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
|
|
1820
|
+
throw new require_migrations_path.BoardError("issue_status_not_found", {
|
|
1821
|
+
workspaceId,
|
|
1822
|
+
statusName: hasDone ? "In Review" : "Done"
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
return await listColumns(tx, workspaceId);
|
|
1826
|
+
}, scope);
|
|
1827
|
+
}
|
|
1828
|
+
/** Done takes cards from the board's In Review and QA; `false`, with nothing written, when the board has no such columns. */
|
|
1829
|
+
async function writeStandardFlow(store, columns) {
|
|
1830
|
+
const flow = standardFlowOf(columns);
|
|
1831
|
+
if (!flow) return false;
|
|
1832
|
+
await store.statuses.update(flow.target.id, { allowedFrom: serializeAllowedFrom(flow.sources.map((column) => column.id)) });
|
|
1833
|
+
return true;
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Take a deleted column out of every list of where cards may come from. A list
|
|
1837
|
+
* it empties stays empty: a column that took cards only from it does not open
|
|
1838
|
+
* to every column.
|
|
1839
|
+
*/
|
|
1840
|
+
async function dropFromAllowedFrom(store, workspaceId, statusId) {
|
|
1841
|
+
for (const other of await listColumns(store, workspaceId)) {
|
|
1842
|
+
const { allowedFrom } = readFlowRules(other);
|
|
1843
|
+
if (allowedFrom?.includes(statusId)) await store.statuses.update(other.id, { allowedFrom: serializeAllowedFrom(allowedFrom.filter((id) => id !== statusId)) });
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1675
1846
|
/**
|
|
1676
1847
|
* Take an empty column off the board, and number the columns left 0..n-1 in
|
|
1677
1848
|
* the same write.
|
|
@@ -1698,6 +1869,7 @@ async function deleteColumn(store, statusId, scope, actor) {
|
|
|
1698
1869
|
hint: "Move its cards to another column first, or remove it with removeColumn and moveCardsTo."
|
|
1699
1870
|
});
|
|
1700
1871
|
await tx.statuses.delete(statusId);
|
|
1872
|
+
await dropFromAllowedFrom(tx, column.workspaceId, statusId);
|
|
1701
1873
|
await renumberColumns(tx, column.workspaceId);
|
|
1702
1874
|
}, scope);
|
|
1703
1875
|
}
|
|
@@ -1977,24 +2149,46 @@ function pullRequestFactKeys({ owner, repo, number }) {
|
|
|
1977
2149
|
//#endregion
|
|
1978
2150
|
//#region src/ops/entry-rules.ts
|
|
1979
2151
|
/**
|
|
1980
|
-
* The column's entry rules the card does not meet
|
|
1981
|
-
*
|
|
1982
|
-
*
|
|
2152
|
+
* The column's entry rules and flow rules the card does not meet, entry rules
|
|
2153
|
+
* first (ruling 6-2; board-workflow spec, Part 2). A column with no rules
|
|
2154
|
+
* answers at once, without reading anything; otherwise only the facts its
|
|
2155
|
+
* rules need are read. `entering` counts the cards this same write already let
|
|
2156
|
+
* into the column, so a bulk move is held to the WIP limit as a whole.
|
|
1983
2157
|
*/
|
|
1984
|
-
async function checkColumnEntry(store, card, column) {
|
|
2158
|
+
async function checkColumnEntry(store, card, column, entering) {
|
|
1985
2159
|
const rules = readEntryRules(column);
|
|
1986
|
-
|
|
1987
|
-
|
|
2160
|
+
const unmet = rules.length === 0 ? [] : evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
|
|
2161
|
+
const flow = readFlowRules(column);
|
|
2162
|
+
if (flow.allowedFrom === null && flow.wipLimit === null) return unmet;
|
|
2163
|
+
return [...unmet, ...evaluateFlowRules(card, column, flow, await readFlowRuleFacts(store, column, flow, entering))];
|
|
2164
|
+
}
|
|
2165
|
+
/** 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. */
|
|
2166
|
+
async function readFlowRuleFacts(store, column, flow, entering) {
|
|
2167
|
+
const columnNames = flow.allowedFrom === null ? /* @__PURE__ */ new Map() : new Map((await store.statuses.listByWorkspace(column.workspaceId)).map((other) => [other.id, other.name]));
|
|
2168
|
+
if (flow.wipLimit === null) return {
|
|
2169
|
+
columnNames,
|
|
2170
|
+
cardsInColumn: 0
|
|
2171
|
+
};
|
|
2172
|
+
await store.statuses.lockById(column.id);
|
|
2173
|
+
return {
|
|
2174
|
+
columnNames,
|
|
2175
|
+
cardsInColumn: (await store.issues.listPage({
|
|
2176
|
+
workspaceId: column.workspaceId,
|
|
2177
|
+
statusIds: [column.id],
|
|
2178
|
+
limit: 1
|
|
2179
|
+
})).total + entering
|
|
2180
|
+
};
|
|
1988
2181
|
}
|
|
1989
2182
|
/**
|
|
1990
|
-
* Hold a card at the door of a column that asks for more than it has
|
|
1991
|
-
* 6-3)
|
|
1992
|
-
*
|
|
1993
|
-
*
|
|
1994
|
-
*
|
|
2183
|
+
* Hold a card at the door of a column that asks for more than it has — entry
|
|
2184
|
+
* rules (ruling 6-3), or flow rules: a column it may not come from, or a column
|
|
2185
|
+
* that is full. Anyone but a person — or a person's own terminal,
|
|
2186
|
+
* `placesForPerson` (ruling 6-6) — is refused with `board_column_rules_unmet`,
|
|
2187
|
+
* which names the column and everything unmet; a person is let through, and
|
|
2188
|
+
* the unmet list comes back so the caller can warn them.
|
|
1995
2189
|
*/
|
|
1996
|
-
async function enforceColumnEntry(store, card, column, actor) {
|
|
1997
|
-
const unmet = await checkColumnEntry(store, card, column);
|
|
2190
|
+
async function enforceColumnEntry(store, card, column, actor, entering = 0) {
|
|
2191
|
+
const unmet = await checkColumnEntry(store, card, column, entering);
|
|
1998
2192
|
if (unmet.length > 0 && actor.kind !== "user" && !actor.placesForPerson) throw new require_migrations_path.BoardError("board_column_rules_unmet", {
|
|
1999
2193
|
issueId: card.id,
|
|
2000
2194
|
column: column.name,
|
|
@@ -2007,14 +2201,17 @@ async function enforceColumnEntry(store, card, column, actor) {
|
|
|
2007
2201
|
* The same door for several cards entering one column at once — a bulk status
|
|
2008
2202
|
* change. All or nothing: the first card an agent may not move refuses the
|
|
2009
2203
|
* whole change. A person gets back the cards that entered with rules unmet.
|
|
2204
|
+
* Each card counts against the column's WIP limit as the cards before it in the same change entered it.
|
|
2010
2205
|
*/
|
|
2011
2206
|
async function enforceColumnEntryForCards(store, issueIds, statusId, actor) {
|
|
2012
2207
|
const column = await store.statuses.findById(statusId);
|
|
2013
2208
|
if (!column) throw new require_migrations_path.BoardError("issue_status_not_found", { statusId });
|
|
2014
2209
|
const entered = [];
|
|
2210
|
+
let entering = 0;
|
|
2015
2211
|
for (const card of await store.issues.listByIds(issueIds)) {
|
|
2016
2212
|
if (card.statusId === column.id) continue;
|
|
2017
|
-
const unmet = await enforceColumnEntry(store, card, column, actor);
|
|
2213
|
+
const unmet = await enforceColumnEntry(store, card, column, actor, entering);
|
|
2214
|
+
entering++;
|
|
2018
2215
|
if (unmet.length > 0) entered.push({
|
|
2019
2216
|
issueId: card.id,
|
|
2020
2217
|
unmet
|
|
@@ -2169,11 +2366,24 @@ const STOPPED_BY_YOU_STATUS_LINE = "stopped by you";
|
|
|
2169
2366
|
* the board reads the same whether the agent was launched by an app or from
|
|
2170
2367
|
* somewhere else entirely. The attempt number is simply how many runs the card
|
|
2171
2368
|
* has had — a card whose runs were all launched elsewhere still counts them.
|
|
2369
|
+
*
|
|
2370
|
+
* A card has one run going at a time. A start on a card whose run is still going
|
|
2371
|
+
* is refused with `board_card_run_active`, naming that run — whichever path it
|
|
2372
|
+
* came by, an app's launch, `kanbo run start`, MCP, or a second app on a shared
|
|
2373
|
+
* Postgres board. The card's row is locked first, so two starts racing for it
|
|
2374
|
+
* are settled one after the other.
|
|
2172
2375
|
*/
|
|
2173
2376
|
async function startRun(store, issueId, input, actor, scope) {
|
|
2174
2377
|
const externalSessionRef = readExternalSessionRef(input.externalSessionRef) ?? null;
|
|
2175
2378
|
return await runBoardWrite(store, async ({ tx }) => {
|
|
2176
|
-
const card = await
|
|
2379
|
+
const card = await tx.issues.lockById(issueId);
|
|
2380
|
+
if (!card) throw new require_migrations_path.BoardError("issue_not_found", { issueId });
|
|
2381
|
+
const [active] = await tx.runs.listRunning(issueId);
|
|
2382
|
+
if (active) throw new require_migrations_path.BoardError("board_card_run_active", {
|
|
2383
|
+
issueId,
|
|
2384
|
+
runId: active.id,
|
|
2385
|
+
agentName: active.agentName
|
|
2386
|
+
});
|
|
2177
2387
|
const attempt = await tx.runs.countByIssue(issueId) + 1;
|
|
2178
2388
|
const executionMode = input.executionMode ?? card.executionMode;
|
|
2179
2389
|
const run = await tx.runs.create({
|
|
@@ -2439,7 +2649,8 @@ async function createCard(store, input, actor, scope) {
|
|
|
2439
2649
|
const column = statusId === null ? null : await tx.statuses.findById(statusId);
|
|
2440
2650
|
const unmet = column ? await enforceColumnEntry(tx, {
|
|
2441
2651
|
id: null,
|
|
2442
|
-
description: input.description ?? null
|
|
2652
|
+
description: input.description ?? null,
|
|
2653
|
+
statusId: null
|
|
2443
2654
|
}, column, actor) : [];
|
|
2444
2655
|
const milestoneId = await requireMilestoneInWorkspace(tx, workspaceId, input.milestoneId ?? null);
|
|
2445
2656
|
const parentIssueId = await requireParentInWorkspace(tx, workspaceId, input.parentIssueId ?? null);
|
|
@@ -2447,8 +2658,9 @@ async function createCard(store, input, actor, scope) {
|
|
|
2447
2658
|
const now = currentUnixSeconds();
|
|
2448
2659
|
for (let attempt = 1;; attempt++) {
|
|
2449
2660
|
const identity = await nextIssueIdentity(input.workspace, tx);
|
|
2661
|
+
let created;
|
|
2450
2662
|
try {
|
|
2451
|
-
|
|
2663
|
+
created = await tx.issues.create({
|
|
2452
2664
|
id: identity.id,
|
|
2453
2665
|
workspaceId,
|
|
2454
2666
|
title: input.title?.trim() || identity.id,
|
|
@@ -2472,12 +2684,25 @@ async function createCard(store, input, actor, scope) {
|
|
|
2472
2684
|
order,
|
|
2473
2685
|
createdAt: now,
|
|
2474
2686
|
updatedAt: now
|
|
2475
|
-
})
|
|
2687
|
+
});
|
|
2476
2688
|
} catch (error) {
|
|
2477
2689
|
const numberTaken = await tx.issues.findByNumber(workspaceId, identity.number);
|
|
2478
2690
|
const keyTaken = await tx.issues.findById(identity.id);
|
|
2479
2691
|
if (attempt >= NUMBERING_ATTEMPTS || !numberTaken && !keyTaken) throw error;
|
|
2692
|
+
continue;
|
|
2480
2693
|
}
|
|
2694
|
+
if (statusId !== null) await tx.fieldChanges.create({
|
|
2695
|
+
id: (0, node_crypto.randomUUID)(),
|
|
2696
|
+
issueId: created.id,
|
|
2697
|
+
field: "statusId",
|
|
2698
|
+
fromValue: null,
|
|
2699
|
+
toValue: statusId,
|
|
2700
|
+
actorKind: toStoredActorKind(actor),
|
|
2701
|
+
actorId: actor.id ?? null,
|
|
2702
|
+
sourceChatSessionId: actor.chatSessionId ?? null,
|
|
2703
|
+
createdAt: now
|
|
2704
|
+
});
|
|
2705
|
+
return withUnmetRules(created, unmet);
|
|
2481
2706
|
}
|
|
2482
2707
|
}, scope);
|
|
2483
2708
|
}
|
|
@@ -2533,7 +2758,8 @@ async function updateCard(store, issueId, input, actor, scope) {
|
|
|
2533
2758
|
const description = "description" in updates ? updates.description ?? null : card.description;
|
|
2534
2759
|
unmet = await enforceColumnEntry(tx, {
|
|
2535
2760
|
id: card.id,
|
|
2536
|
-
description
|
|
2761
|
+
description,
|
|
2762
|
+
statusId: card.statusId
|
|
2537
2763
|
}, column, actor);
|
|
2538
2764
|
}
|
|
2539
2765
|
if ("assigneeKind" in input) updates.assigneeKind = input.assigneeKind ?? null;
|
|
@@ -2836,12 +3062,33 @@ async function requireParentInWorkspace(store, workspaceId, parentIssueId, issue
|
|
|
2836
3062
|
issueId,
|
|
2837
3063
|
parentIssueId
|
|
2838
3064
|
});
|
|
2839
|
-
|
|
3065
|
+
const parent = await store.issues.findInWorkspace(workspaceId, parentIssueId);
|
|
3066
|
+
if (!parent) throw new require_migrations_path.BoardError("issue_parent_not_found", {
|
|
2840
3067
|
workspaceId,
|
|
2841
3068
|
parentIssueId
|
|
2842
3069
|
});
|
|
3070
|
+
if (issueId !== void 0 && await isUnder(store, parent, issueId)) throw new require_migrations_path.BoardError("issue_parent_cycle", {
|
|
3071
|
+
issueId,
|
|
3072
|
+
parentIssueId
|
|
3073
|
+
});
|
|
2843
3074
|
return parentIssueId;
|
|
2844
3075
|
}
|
|
3076
|
+
/**
|
|
3077
|
+
* Is the card somewhere under `ancestorId` — its parent, its parent's parent,
|
|
3078
|
+
* and so on up? A card put under one of its own sub-cards would make a loop no
|
|
3079
|
+
* reader of the board can walk to the top of. The cards already seen end the
|
|
3080
|
+
* walk, so a loop the data holds from before cannot hang it.
|
|
3081
|
+
*/
|
|
3082
|
+
async function isUnder(store, card, ancestorId) {
|
|
3083
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3084
|
+
let id = card.parentIssueId;
|
|
3085
|
+
while (id !== null && !seen.has(id)) {
|
|
3086
|
+
if (id === ancestorId) return true;
|
|
3087
|
+
seen.add(id);
|
|
3088
|
+
id = (await store.issues.findById(id))?.parentIssueId ?? null;
|
|
3089
|
+
}
|
|
3090
|
+
return false;
|
|
3091
|
+
}
|
|
2845
3092
|
/** Is the card's own number already taken by another card in the target workspace? */
|
|
2846
3093
|
async function numberTakenByAnother(store, workspaceId, card) {
|
|
2847
3094
|
const holder = await store.issues.findByNumber(workspaceId, card.number);
|
|
@@ -2911,6 +3158,160 @@ async function reorderCards(store, workspaceId, orderedIds, scope) {
|
|
|
2911
3158
|
}, scope);
|
|
2912
3159
|
}
|
|
2913
3160
|
//#endregion
|
|
3161
|
+
//#region src/ops/ready.ts
|
|
3162
|
+
/** The column an agent takes its next card from. */
|
|
3163
|
+
const READY_COLUMN_SLUG = "to_do";
|
|
3164
|
+
/**
|
|
3165
|
+
* The cards an agent may pick up right now: spelled out, nobody working on them,
|
|
3166
|
+
* nobody's turn but the agent's.
|
|
3167
|
+
*
|
|
3168
|
+
* "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
|
|
3169
|
+
* run is taken, and a card waiting for a person belongs to that person until
|
|
3170
|
+
* they answer. What is left is the queue — in board order, so the card someone
|
|
3171
|
+
* dragged to the top is the one an agent asking for work gets.
|
|
3172
|
+
*/
|
|
3173
|
+
async function listReady(store, input) {
|
|
3174
|
+
return (await queryReady(store, input)).cards;
|
|
3175
|
+
}
|
|
3176
|
+
/**
|
|
3177
|
+
* One page of the ready cards, how many are ready in all, and the columns read
|
|
3178
|
+
* on the way — the rule above, asked of the database in one statement rather
|
|
3179
|
+
* than of every card on the board.
|
|
3180
|
+
*/
|
|
3181
|
+
async function queryReady(store, input) {
|
|
3182
|
+
const columns = await listColumns(store, input.workspaceId);
|
|
3183
|
+
const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
|
|
3184
|
+
if (!column) return {
|
|
3185
|
+
cards: [],
|
|
3186
|
+
total: 0,
|
|
3187
|
+
columns
|
|
3188
|
+
};
|
|
3189
|
+
return {
|
|
3190
|
+
...await store.issues.listPage({
|
|
3191
|
+
workspaceId: input.workspaceId,
|
|
3192
|
+
statusIds: [column.id],
|
|
3193
|
+
waitingForPerson: false,
|
|
3194
|
+
hasActiveRun: false,
|
|
3195
|
+
limit: input.limit,
|
|
3196
|
+
offset: input.offset
|
|
3197
|
+
}),
|
|
3198
|
+
columns
|
|
3199
|
+
};
|
|
3200
|
+
}
|
|
3201
|
+
//#endregion
|
|
3202
|
+
//#region src/ops/claim.ts
|
|
3203
|
+
/**
|
|
3204
|
+
* Take a card to work on, in one board write: pick it, start its run, and move
|
|
3205
|
+
* it into the board's first `started` column (In Progress on a standard
|
|
3206
|
+
* board), its entry and flow rules checked on the way in. Anything refused
|
|
3207
|
+
* leaves nothing written.
|
|
3208
|
+
*
|
|
3209
|
+
* Two claimers never get the same card. On SQLite the write holds the file
|
|
3210
|
+
* from its first read (`begin immediate`), so the second claimer reads the
|
|
3211
|
+
* board after the first one's commit. On Postgres the picked card's row is
|
|
3212
|
+
* locked (`select … for update`): a claimer that waited on the lock finds the
|
|
3213
|
+
* card taken, and moves on to the next ready one. The target's row is locked
|
|
3214
|
+
* first, always, in the order a move into it locks (column, then card), so a
|
|
3215
|
+
* claim and a move never wait on each other in a circle.
|
|
3216
|
+
*/
|
|
3217
|
+
async function claimCard(store, input, actor, scope) {
|
|
3218
|
+
return await runBoardWrite(store, async ({ tx }) => {
|
|
3219
|
+
const columns = await listColumns(tx, input.workspaceId);
|
|
3220
|
+
const target = columns.find((column) => column.category === "started");
|
|
3221
|
+
if (!target) throw new require_migrations_path.BoardError("issue_status_not_found", {
|
|
3222
|
+
workspaceId: input.workspaceId,
|
|
3223
|
+
category: "started"
|
|
3224
|
+
});
|
|
3225
|
+
await tx.statuses.lockById(target.id);
|
|
3226
|
+
const toDo = columns.find((column) => normalizeStatusName(column.name) === "to_do") ?? null;
|
|
3227
|
+
const card = input.issueId === void 0 ? await takeFirstReady(tx, input.workspaceId, toDo) : await takeNamed(tx, input.workspaceId, input.issueId, toDo);
|
|
3228
|
+
const run = await startRun(tx, card.id, input.run, actor, { tx });
|
|
3229
|
+
return {
|
|
3230
|
+
card: await updateCard(tx, card.id, { statusId: target.id }, actor, { tx }),
|
|
3231
|
+
run,
|
|
3232
|
+
column: target
|
|
3233
|
+
};
|
|
3234
|
+
}, scope);
|
|
3235
|
+
}
|
|
3236
|
+
/**
|
|
3237
|
+
* The first ready card, locked. A card another claimer took while this one
|
|
3238
|
+
* waited for its lock is passed over, and the next read no longer lists it; the
|
|
3239
|
+
* cards passed over are counted so the loop ends with the list.
|
|
3240
|
+
*/
|
|
3241
|
+
async function takeFirstReady(store, workspaceId, toDo) {
|
|
3242
|
+
const passed = /* @__PURE__ */ new Set();
|
|
3243
|
+
for (;;) {
|
|
3244
|
+
const { cards } = await queryReady(store, {
|
|
3245
|
+
workspaceId,
|
|
3246
|
+
limit: passed.size + 1
|
|
3247
|
+
});
|
|
3248
|
+
const candidate = cards.find((card) => !passed.has(card.id));
|
|
3249
|
+
if (!candidate) throw new require_migrations_path.BoardError("board_nothing_ready", { workspaceId });
|
|
3250
|
+
const locked = await store.issues.lockById(candidate.id);
|
|
3251
|
+
if (locked && await notReadyReason(store, locked, toDo) === null) return locked;
|
|
3252
|
+
passed.add(candidate.id);
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
/** The named card, locked, when it is on this board and ready — or why not. */
|
|
3256
|
+
async function takeNamed(store, workspaceId, issueId, toDo) {
|
|
3257
|
+
const card = await store.issues.lockById(issueId);
|
|
3258
|
+
if (!card || card.workspaceId !== workspaceId) throw new require_migrations_path.BoardError("issue_not_found", { issueId });
|
|
3259
|
+
const reason = await notReadyReason(store, card, toDo);
|
|
3260
|
+
if (reason) throw new require_migrations_path.BoardError("board_card_not_ready", {
|
|
3261
|
+
issueId,
|
|
3262
|
+
reason
|
|
3263
|
+
});
|
|
3264
|
+
return card;
|
|
3265
|
+
}
|
|
3266
|
+
/** `queryReady`'s rule for one card already read: nobody on it, nobody waited for, in To Do. */
|
|
3267
|
+
async function notReadyReason(store, card, toDo) {
|
|
3268
|
+
if ((await store.runs.listRunning(card.id)).length > 0) return "run_active";
|
|
3269
|
+
if (card.waitingFor === "human") return "waiting_for_person";
|
|
3270
|
+
return toDo !== null && card.statusId === toDo.id ? null : "not_in_to_do";
|
|
3271
|
+
}
|
|
3272
|
+
//#endregion
|
|
3273
|
+
//#region src/ops/column-entries.ts
|
|
3274
|
+
/**
|
|
3275
|
+
* Every entry into a column on the named boards at or after `since`, in order
|
|
3276
|
+
* of `(at, changeId)` — the one signal an app dispatching work on column
|
|
3277
|
+
* changes needs. It sees every writer, an outside `kanbo` on the same database
|
|
3278
|
+
* included, because it reads the cards' own history.
|
|
3279
|
+
*
|
|
3280
|
+
* A page never ends inside a second: when `limit` cuts into one, the rest of
|
|
3281
|
+
* that second comes too, so a page may be longer than `limit`. A reader keeps
|
|
3282
|
+
* its cursor at the last `at` it read after a short page — a write in that
|
|
3283
|
+
* second may still commit — and drops the `changeId`s it has seen; after a page
|
|
3284
|
+
* of `limit` or more it moves on to the last `at` + 1, which a second holding
|
|
3285
|
+
* more entries than `limit` would otherwise never let it pass.
|
|
3286
|
+
*/
|
|
3287
|
+
async function listColumnEntries(store, input) {
|
|
3288
|
+
const page = await store.fieldChanges.listByFieldSince("statusId", input);
|
|
3289
|
+
return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
|
|
3290
|
+
}
|
|
3291
|
+
/** The page with every change of its last second, not only those `limit` let in. */
|
|
3292
|
+
async function withWholeLastSecond(store, input, page) {
|
|
3293
|
+
const last = page.at(-1).createdAt;
|
|
3294
|
+
const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
|
|
3295
|
+
workspaceIds: input.workspaceIds,
|
|
3296
|
+
since: last,
|
|
3297
|
+
until: last
|
|
3298
|
+
});
|
|
3299
|
+
return [...page.filter((change) => change.createdAt < last), ...lastSecond];
|
|
3300
|
+
}
|
|
3301
|
+
/** A status change as an entry; a change that took the card out of every column enters none. */
|
|
3302
|
+
function toColumnEntry(change) {
|
|
3303
|
+
return change.toValue === null ? [] : [{
|
|
3304
|
+
changeId: change.id,
|
|
3305
|
+
issueId: change.issueId,
|
|
3306
|
+
workspaceId: change.workspaceId,
|
|
3307
|
+
fromStatusId: change.fromValue,
|
|
3308
|
+
toStatusId: change.toValue,
|
|
3309
|
+
actorKind: change.actorKind,
|
|
3310
|
+
actorId: change.actorId,
|
|
3311
|
+
at: change.createdAt
|
|
3312
|
+
}];
|
|
3313
|
+
}
|
|
3314
|
+
//#endregion
|
|
2914
3315
|
//#region src/ops/context-refs.ts
|
|
2915
3316
|
const ContextRefsJsonSchema = zod.z.string().transform((raw) => JSON.parse(raw)).pipe(zod.z.array(zod.z.string()));
|
|
2916
3317
|
/** Add a context ref at the end of the card's list, recording the change in its history. */
|
|
@@ -3095,6 +3496,7 @@ function subtaskSplitSentence(parentReference) {
|
|
|
3095
3496
|
}
|
|
3096
3497
|
const SUBTASK_RULE = `${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("the parent card's id")}`;
|
|
3097
3498
|
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.";
|
|
3499
|
+
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.";
|
|
3098
3500
|
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.";
|
|
3099
3501
|
/** Said by the `kanbo init` block and the MCP instructions, which name the comment command or tool beside it. */
|
|
3100
3502
|
const COMMENT_RULE = "Findings, results, decisions and questions go on the card as comments.";
|
|
@@ -3118,6 +3520,7 @@ const BOARD_RULES = [
|
|
|
3118
3520
|
EXECUTION_MODE_RULE,
|
|
3119
3521
|
SUBTASK_RULE,
|
|
3120
3522
|
PULL_REQUEST_RULE,
|
|
3523
|
+
CLAIM_RULE,
|
|
3121
3524
|
OWN_SESSION_RULE,
|
|
3122
3525
|
"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."
|
|
3123
3526
|
];
|
|
@@ -3133,12 +3536,14 @@ const BOARD_RULES = [
|
|
|
3133
3536
|
async function buildPrimeText(store, workspaceId) {
|
|
3134
3537
|
const columns = await listColumns(store, workspaceId);
|
|
3135
3538
|
const ruled = columns.some((column) => readEntryRules(column).length > 0);
|
|
3539
|
+
const flowing = columns.some((column) => hasFlowRules(column));
|
|
3136
3540
|
return [
|
|
3137
3541
|
...await describeReturnedCards(store, workspaceId, columns),
|
|
3138
3542
|
"Columns of this board, in board order:",
|
|
3139
3543
|
"",
|
|
3140
|
-
...columns.length > 0 ? columns.map(describeColumnLine) : ["- (this board has no columns yet)"],
|
|
3544
|
+
...columns.length > 0 ? columns.map((column) => describeColumnLine(column, columns)) : ["- (this board has no columns yet)"],
|
|
3141
3545
|
...ruled ? ["", ENTRY_RULES_NOTE] : [],
|
|
3546
|
+
...flowing ? ["", FLOW_RULES_NOTE] : [],
|
|
3142
3547
|
"",
|
|
3143
3548
|
"How this board works:",
|
|
3144
3549
|
"",
|
|
@@ -3180,11 +3585,22 @@ async function describeReturnedCards(store, workspaceId, columns) {
|
|
|
3180
3585
|
* without any reads exactly as it did before they existed.
|
|
3181
3586
|
*/
|
|
3182
3587
|
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.";
|
|
3183
|
-
|
|
3588
|
+
/** Said under the columns only when one of them has flow rules. */
|
|
3589
|
+
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.";
|
|
3590
|
+
function hasFlowRules(column) {
|
|
3591
|
+
const { allowedFrom, wipLimit } = readFlowRules(column);
|
|
3592
|
+
return allowedFrom !== null || wipLimit !== null;
|
|
3593
|
+
}
|
|
3594
|
+
function describeColumnLine(column, columns) {
|
|
3184
3595
|
const slug = normalizeStatusName(column.name);
|
|
3185
3596
|
const rules = readEntryRules(column);
|
|
3186
|
-
const
|
|
3187
|
-
return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${
|
|
3597
|
+
const said = `${rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : ""}${describeFlow(column, columns)}`;
|
|
3598
|
+
return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${said}` : `- ${column.name} (\`${slug}\`)${said ? ` —${said}` : ""}`;
|
|
3599
|
+
}
|
|
3600
|
+
/** Where a column takes cards from and how many it holds, as the prime text says it; nothing for a column without flow rules. */
|
|
3601
|
+
function describeFlow(column, columns) {
|
|
3602
|
+
const { allowedFrom, wipLimit } = readFlowRules(column);
|
|
3603
|
+
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"}.`}`;
|
|
3188
3604
|
}
|
|
3189
3605
|
//#endregion
|
|
3190
3606
|
//#region src/domain/pull-request-ref.ts
|
|
@@ -3354,47 +3770,6 @@ async function queryCards(store, input) {
|
|
|
3354
3770
|
};
|
|
3355
3771
|
}
|
|
3356
3772
|
//#endregion
|
|
3357
|
-
//#region src/ops/ready.ts
|
|
3358
|
-
/** The column an agent takes its next card from. */
|
|
3359
|
-
const READY_COLUMN_SLUG = "to_do";
|
|
3360
|
-
/**
|
|
3361
|
-
* The cards an agent may pick up right now: spelled out, nobody working on them,
|
|
3362
|
-
* nobody's turn but the agent's.
|
|
3363
|
-
*
|
|
3364
|
-
* "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
|
|
3365
|
-
* run is taken, and a card waiting for a person belongs to that person until
|
|
3366
|
-
* they answer. What is left is the queue — in board order, so the card someone
|
|
3367
|
-
* dragged to the top is the one an agent asking for work gets.
|
|
3368
|
-
*/
|
|
3369
|
-
async function listReady(store, input) {
|
|
3370
|
-
return (await queryReady(store, input)).cards;
|
|
3371
|
-
}
|
|
3372
|
-
/**
|
|
3373
|
-
* One page of the ready cards, how many are ready in all, and the columns read
|
|
3374
|
-
* on the way — the rule above, asked of the database in one statement rather
|
|
3375
|
-
* than of every card on the board.
|
|
3376
|
-
*/
|
|
3377
|
-
async function queryReady(store, input) {
|
|
3378
|
-
const columns = await listColumns(store, input.workspaceId);
|
|
3379
|
-
const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
|
|
3380
|
-
if (!column) return {
|
|
3381
|
-
cards: [],
|
|
3382
|
-
total: 0,
|
|
3383
|
-
columns
|
|
3384
|
-
};
|
|
3385
|
-
return {
|
|
3386
|
-
...await store.issues.listPage({
|
|
3387
|
-
workspaceId: input.workspaceId,
|
|
3388
|
-
statusIds: [column.id],
|
|
3389
|
-
waitingForPerson: false,
|
|
3390
|
-
hasActiveRun: false,
|
|
3391
|
-
limit: input.limit,
|
|
3392
|
-
offset: input.offset
|
|
3393
|
-
}),
|
|
3394
|
-
columns
|
|
3395
|
-
};
|
|
3396
|
-
}
|
|
3397
|
-
//#endregion
|
|
3398
3773
|
//#region src/ops/relations.ts
|
|
3399
3774
|
/**
|
|
3400
3775
|
* Every edge touching the card, in either direction, each with the card on its
|
|
@@ -3684,6 +4059,8 @@ function createBoardOps(store) {
|
|
|
3684
4059
|
updateColumn: async (statusId, patch, actor, scope) => await updateColumn(store, statusId, patch, actor, scope),
|
|
3685
4060
|
describeColumn: async (workspaceId, nameOrId, description, scope) => await describeColumn(store, workspaceId, nameOrId, description, scope),
|
|
3686
4061
|
setColumnEntryRules: async (workspaceId, nameOrId, entryRules, actor, scope) => await setColumnEntryRules(store, workspaceId, nameOrId, entryRules, actor, scope),
|
|
4062
|
+
setColumnFlowRules: async (columnId, rules, actor, scope) => await setColumnFlowRules(store, columnId, rules, actor, scope),
|
|
4063
|
+
applyStandardFlow: async (workspaceId, actor, scope) => await applyStandardFlow(store, workspaceId, actor, scope),
|
|
3687
4064
|
enforceColumnEntryForCards: async (issueIds, statusId, actor) => await enforceColumnEntryForCards(store, issueIds, statusId, actor),
|
|
3688
4065
|
deleteColumn: async (statusId, scope, actor) => await deleteColumn(store, statusId, scope, actor),
|
|
3689
4066
|
reorderColumns: async (workspaceId, orderedIds, scope, actor) => await reorderColumns(store, workspaceId, orderedIds, scope, actor),
|
|
@@ -3694,6 +4071,7 @@ function createBoardOps(store) {
|
|
|
3694
4071
|
moveColumn: async (workspaceId, nameOrId, position, actor, scope) => await moveColumn$1(store, workspaceId, nameOrId, position, actor, scope),
|
|
3695
4072
|
removeColumn: async (workspaceId, nameOrId, options, actor, scope) => await removeColumn$1(store, workspaceId, nameOrId, options, actor, scope),
|
|
3696
4073
|
startRun: async (issueId, input, actor, scope) => await startRun(store, issueId, input, actor, scope),
|
|
4074
|
+
claimCard: async (input, actor, scope) => await claimCard(store, input, actor, scope),
|
|
3697
4075
|
attachRunExecution: async (runId, input, scope) => await attachRunExecution(store, runId, input, scope),
|
|
3698
4076
|
recordRunSessionRef: async (runId, ref, scope) => await recordRunSessionRef(store, runId, ref, scope),
|
|
3699
4077
|
clearRunSessionRef: async (runId, actor, options, scope) => await clearRunSessionRef(store, runId, actor, options, scope),
|
|
@@ -3706,66 +4084,11 @@ function createBoardOps(store) {
|
|
|
3706
4084
|
readBoardProjectionForIssue: async (issueId) => await readBoardProjectionForIssue(store, issueId),
|
|
3707
4085
|
listReady: async (input) => await listReady(store, input),
|
|
3708
4086
|
queryReady: async (input) => await queryReady(store, input),
|
|
4087
|
+
listColumnEntries: async (input) => await listColumnEntries(store, input),
|
|
3709
4088
|
buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId)
|
|
3710
4089
|
};
|
|
3711
4090
|
}
|
|
3712
4091
|
//#endregion
|
|
3713
|
-
//#region src/domain/database-url.ts
|
|
3714
|
-
/**
|
|
3715
|
-
* A board's connection string, as it may be said out loud.
|
|
3716
|
-
*
|
|
3717
|
-
* Everything a person needs to recognise which board they are talking to is
|
|
3718
|
-
* the host and the database name. The password is the one part that must never
|
|
3719
|
-
* reach a terminal, a log, a `--json` result or a tool's answer — and it is not
|
|
3720
|
-
* the only part: a connection string carries options in its query, and a
|
|
3721
|
-
* `?password=…` there is as much a secret as the one in front of the host.
|
|
3722
|
-
*
|
|
3723
|
-
* It is a fact about the value rather than about the command line, which is why
|
|
3724
|
-
* it lives here: the MCP tools hand back messages they did not write either,
|
|
3725
|
-
* and they must not be made to carry the board tool's own runtime to say them
|
|
3726
|
-
* safely.
|
|
3727
|
-
*/
|
|
3728
|
-
/** What a connection string is called when there is nothing safe to print of it. */
|
|
3729
|
-
const MASKED_UNREADABLE_URL = "postgres://***";
|
|
3730
|
-
/** What is printed in place of a password, and of a query that may hide one. */
|
|
3731
|
-
const MASK = "***";
|
|
3732
|
-
/**
|
|
3733
|
-
* A connection string inside a longer line. Anything but whitespace and the
|
|
3734
|
-
* quotes a message may wrap it in belongs to the URL — a password is allowed
|
|
3735
|
-
* every character a URL is, so the match has to be greedy to be safe.
|
|
3736
|
-
*/
|
|
3737
|
-
const DATABASE_URL_PATTERN = /\bpostgres(?:ql)?:\/\/[^\s'"`]+/gi;
|
|
3738
|
-
/**
|
|
3739
|
-
* The connection string with everything secret taken out of it.
|
|
3740
|
-
*
|
|
3741
|
-
* A string this cannot take apart is not examined to find out why — it is
|
|
3742
|
-
* replaced entirely, because whatever is wrong with it, the secret is still
|
|
3743
|
-
* somewhere inside. A missing scheme counts as that: `user:secret@host/db`
|
|
3744
|
-
* parses, as a `user:` URL whose whole password is in the path, and printing it
|
|
3745
|
-
* back would say the secret out loud.
|
|
3746
|
-
*/
|
|
3747
|
-
function maskDatabaseUrl(url) {
|
|
3748
|
-
try {
|
|
3749
|
-
const parsed = new URL(url);
|
|
3750
|
-
if (!parsed.host) return MASKED_UNREADABLE_URL;
|
|
3751
|
-
if (parsed.password) parsed.password = MASK;
|
|
3752
|
-
if (parsed.search) parsed.search = MASK;
|
|
3753
|
-
return parsed.toString();
|
|
3754
|
-
} catch {
|
|
3755
|
-
return MASKED_UNREADABLE_URL;
|
|
3756
|
-
}
|
|
3757
|
-
}
|
|
3758
|
-
/**
|
|
3759
|
-
* Every connection string inside a line of text, masked.
|
|
3760
|
-
*
|
|
3761
|
-
* A message this package did not write — a driver's, a migrator's — may carry
|
|
3762
|
-
* the whole connection string in the middle of a sentence, and it reaches the
|
|
3763
|
-
* same terminal, or the same agent, as everything else.
|
|
3764
|
-
*/
|
|
3765
|
-
function maskDatabaseUrls(text) {
|
|
3766
|
-
return text.replace(DATABASE_URL_PATTERN, (url) => maskDatabaseUrl(url));
|
|
3767
|
-
}
|
|
3768
|
-
//#endregion
|
|
3769
4092
|
//#region src/cli/setup/fs-retry.ts
|
|
3770
4093
|
/**
|
|
3771
4094
|
* A file operation tried again while something else holds the file.
|
|
@@ -4045,6 +4368,62 @@ function ignoreBoardFile(projectDir, absoluteDbPath) {
|
|
|
4045
4368
|
(0, node_fs.writeFileSync)(path, kept ? `${kept}\n${missing.join("\n")}\n` : `${missing.join("\n")}\n`);
|
|
4046
4369
|
}
|
|
4047
4370
|
//#endregion
|
|
4371
|
+
//#region src/domain/database-url.ts
|
|
4372
|
+
/**
|
|
4373
|
+
* A board's connection string, as it may be said out loud.
|
|
4374
|
+
*
|
|
4375
|
+
* Everything a person needs to recognise which board they are talking to is
|
|
4376
|
+
* the host and the database name. The password is the one part that must never
|
|
4377
|
+
* reach a terminal, a log, a `--json` result or a tool's answer — and it is not
|
|
4378
|
+
* the only part: a connection string carries options in its query, and a
|
|
4379
|
+
* `?password=…` there is as much a secret as the one in front of the host.
|
|
4380
|
+
*
|
|
4381
|
+
* It is a fact about the value rather than about the command line, which is why
|
|
4382
|
+
* it lives here: the MCP tools hand back messages they did not write either,
|
|
4383
|
+
* and they must not be made to carry the board tool's own runtime to say them
|
|
4384
|
+
* safely.
|
|
4385
|
+
*/
|
|
4386
|
+
/** What a connection string is called when there is nothing safe to print of it. */
|
|
4387
|
+
const MASKED_UNREADABLE_URL = "postgres://***";
|
|
4388
|
+
/** What is printed in place of a password, and of a query that may hide one. */
|
|
4389
|
+
const MASK = "***";
|
|
4390
|
+
/**
|
|
4391
|
+
* A connection string inside a longer line. Anything but whitespace and the
|
|
4392
|
+
* quotes a message may wrap it in belongs to the URL — a password is allowed
|
|
4393
|
+
* every character a URL is, so the match has to be greedy to be safe.
|
|
4394
|
+
*/
|
|
4395
|
+
const DATABASE_URL_PATTERN = /\bpostgres(?:ql)?:\/\/[^\s'"`]+/gi;
|
|
4396
|
+
/**
|
|
4397
|
+
* The connection string with everything secret taken out of it.
|
|
4398
|
+
*
|
|
4399
|
+
* A string this cannot take apart is not examined to find out why — it is
|
|
4400
|
+
* replaced entirely, because whatever is wrong with it, the secret is still
|
|
4401
|
+
* somewhere inside. A missing scheme counts as that: `user:secret@host/db`
|
|
4402
|
+
* parses, as a `user:` URL whose whole password is in the path, and printing it
|
|
4403
|
+
* back would say the secret out loud.
|
|
4404
|
+
*/
|
|
4405
|
+
function maskDatabaseUrl(url) {
|
|
4406
|
+
try {
|
|
4407
|
+
const parsed = new URL(url);
|
|
4408
|
+
if (!parsed.host) return MASKED_UNREADABLE_URL;
|
|
4409
|
+
if (parsed.password) parsed.password = MASK;
|
|
4410
|
+
if (parsed.search) parsed.search = MASK;
|
|
4411
|
+
return parsed.toString();
|
|
4412
|
+
} catch {
|
|
4413
|
+
return MASKED_UNREADABLE_URL;
|
|
4414
|
+
}
|
|
4415
|
+
}
|
|
4416
|
+
/**
|
|
4417
|
+
* Every connection string inside a line of text, masked.
|
|
4418
|
+
*
|
|
4419
|
+
* A message this package did not write — a driver's, a migrator's — may carry
|
|
4420
|
+
* the whole connection string in the middle of a sentence, and it reaches the
|
|
4421
|
+
* same terminal, or the same agent, as everything else.
|
|
4422
|
+
*/
|
|
4423
|
+
function maskDatabaseUrls(text) {
|
|
4424
|
+
return text.replace(DATABASE_URL_PATTERN, (url) => maskDatabaseUrl(url));
|
|
4425
|
+
}
|
|
4426
|
+
//#endregion
|
|
4048
4427
|
//#region src/cli/db-target.ts
|
|
4049
4428
|
/**
|
|
4050
4429
|
* Which board the command works on.
|
|
@@ -4293,6 +4672,111 @@ async function openWithPostgresJs(url) {
|
|
|
4293
4672
|
};
|
|
4294
4673
|
}
|
|
4295
4674
|
//#endregion
|
|
4675
|
+
//#region src/cli/project-registry.ts
|
|
4676
|
+
/**
|
|
4677
|
+
* Which project folders kanbo has opened a board in.
|
|
4678
|
+
*
|
|
4679
|
+
* Every project keeps its board in its own folder, so nothing else knows which
|
|
4680
|
+
* other projects exist. This list, in the user's home, is what lets one board
|
|
4681
|
+
* page offer them all. It holds a folder and a time — never a connection
|
|
4682
|
+
* string, never board data — and deleting it is safe: it fills again as the
|
|
4683
|
+
* projects are used.
|
|
4684
|
+
*/
|
|
4685
|
+
/** The file's name inside kanbo's per-user folder. */
|
|
4686
|
+
const REGISTRY_FILE_NAME = "projects.json";
|
|
4687
|
+
/** The file is private to the user, as a binding is. */
|
|
4688
|
+
const REGISTRY_FILE_MODE = 384;
|
|
4689
|
+
/** The folder holding it is private to the user too. */
|
|
4690
|
+
const REGISTRY_DIRECTORY_MODE = 448;
|
|
4691
|
+
/**
|
|
4692
|
+
* A project noted within this many seconds is not noted again, so an ordinary
|
|
4693
|
+
* command does not rewrite the file: a day is fresh enough for "recently used".
|
|
4694
|
+
*/
|
|
4695
|
+
const RECENTLY_NOTED_SECONDS = 86400;
|
|
4696
|
+
const RegisteredProjectSchema = zod.z.object({
|
|
4697
|
+
path: zod.z.string().min(1),
|
|
4698
|
+
lastSeenAt: zod.z.number().int().nonnegative()
|
|
4699
|
+
});
|
|
4700
|
+
const RegistrySchema = zod.z.object({
|
|
4701
|
+
schemaVersion: zod.z.literal(1),
|
|
4702
|
+
projects: zod.z.array(RegisteredProjectSchema)
|
|
4703
|
+
});
|
|
4704
|
+
/** `$KANBO_HOME/projects.json`, else `~/.kanbo/projects.json`. */
|
|
4705
|
+
function projectRegistryPath(env = process.env) {
|
|
4706
|
+
const home = env.KANBO_HOME?.trim();
|
|
4707
|
+
return (0, node_path.join)(home || (0, node_path.join)((0, node_os.homedir)(), ".kanbo"), REGISTRY_FILE_NAME);
|
|
4708
|
+
}
|
|
4709
|
+
/** The projects, most recently seen first; `[]` when the file is missing or does not parse. */
|
|
4710
|
+
function readProjectRegistry(path = projectRegistryPath()) {
|
|
4711
|
+
return readEntries(path).sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
4712
|
+
}
|
|
4713
|
+
/**
|
|
4714
|
+
* Note that `projectDir` was just used. Writes only when the folder is new or
|
|
4715
|
+
* was last noted more than a day ago, so ordinary commands do not rewrite the file.
|
|
4716
|
+
* Drops entries whose `.kanbo/binding.json` is gone.
|
|
4717
|
+
*/
|
|
4718
|
+
function rememberProject(projectDir, options = {}) {
|
|
4719
|
+
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
4720
|
+
const path = options.path ?? projectRegistryPath();
|
|
4721
|
+
const canonical = canonicalPath$1(projectDir);
|
|
4722
|
+
const entries = readEntries(path);
|
|
4723
|
+
const known = entries.find((entry) => entry.path === canonical);
|
|
4724
|
+
if (known && now - known.lastSeenAt <= RECENTLY_NOTED_SECONDS) return;
|
|
4725
|
+
const kept = entries.filter((entry) => entry.path !== canonical && (0, node_fs.existsSync)((0, node_path.join)(entry.path, BINDING_FILE_PATH)));
|
|
4726
|
+
kept.push({
|
|
4727
|
+
path: canonical,
|
|
4728
|
+
lastSeenAt: now
|
|
4729
|
+
});
|
|
4730
|
+
writeEntries(path, kept);
|
|
4731
|
+
}
|
|
4732
|
+
/**
|
|
4733
|
+
* `rememberProject`, for the places that must not fail because of it: a home
|
|
4734
|
+
* directory that cannot be written is not a reason for `kanbo card list` to fail.
|
|
4735
|
+
* With `KANBO_DEBUG=1` the reason is printed on stderr.
|
|
4736
|
+
*/
|
|
4737
|
+
function rememberProjectQuietly(projectDir) {
|
|
4738
|
+
const path = projectRegistryPath();
|
|
4739
|
+
try {
|
|
4740
|
+
rememberProject(projectDir, { path });
|
|
4741
|
+
} catch (error) {
|
|
4742
|
+
if (process.env.KANBO_DEBUG === "1") console.error(`kanbo: could not note this project in ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4743
|
+
}
|
|
4744
|
+
}
|
|
4745
|
+
function canonicalPath$1(projectDir) {
|
|
4746
|
+
const absolute = (0, node_path.resolve)(projectDir);
|
|
4747
|
+
try {
|
|
4748
|
+
return (0, node_fs.realpathSync)(absolute);
|
|
4749
|
+
} catch {
|
|
4750
|
+
return absolute;
|
|
4751
|
+
}
|
|
4752
|
+
}
|
|
4753
|
+
function readEntries(path) {
|
|
4754
|
+
try {
|
|
4755
|
+
const parsed = RegistrySchema.safeParse(JSON.parse((0, node_fs.readFileSync)(path, "utf8")));
|
|
4756
|
+
return parsed.success ? parsed.data.projects : [];
|
|
4757
|
+
} catch {
|
|
4758
|
+
return [];
|
|
4759
|
+
}
|
|
4760
|
+
}
|
|
4761
|
+
function writeEntries(path, projects) {
|
|
4762
|
+
(0, node_fs.mkdirSync)((0, node_path.dirname)(path), {
|
|
4763
|
+
recursive: true,
|
|
4764
|
+
mode: REGISTRY_DIRECTORY_MODE
|
|
4765
|
+
});
|
|
4766
|
+
const temporaryPath = `${path}.${process.pid}.tmp`;
|
|
4767
|
+
try {
|
|
4768
|
+
(0, node_fs.writeFileSync)(temporaryPath, `${JSON.stringify(RegistrySchema.parse({
|
|
4769
|
+
schemaVersion: 1,
|
|
4770
|
+
projects
|
|
4771
|
+
}), null, 2)}\n`, { mode: REGISTRY_FILE_MODE });
|
|
4772
|
+
(0, node_fs.chmodSync)(temporaryPath, REGISTRY_FILE_MODE);
|
|
4773
|
+
renameWithRetry(temporaryPath, path);
|
|
4774
|
+
} catch (error) {
|
|
4775
|
+
(0, node_fs.rmSync)(temporaryPath, { force: true });
|
|
4776
|
+
throw error;
|
|
4777
|
+
}
|
|
4778
|
+
}
|
|
4779
|
+
//#endregion
|
|
4296
4780
|
//#region src/cli/schema-guard.ts
|
|
4297
4781
|
const SCHEMA_OUTDATED_MESSAGE = "The app's database is older than this kanbo. Let the app it belongs to update it first.";
|
|
4298
4782
|
/** The same, for a board file this package created and migrates itself. */
|
|
@@ -4306,9 +4790,10 @@ const SCHEMA_NOT_INSTALLED_MESSAGE = "This Postgres database has no kanbo board
|
|
|
4306
4790
|
* caller's own risk but never written to — a write shaped by this build's idea
|
|
4307
4791
|
* of the schema is how a person's board gets a column full of nothing. A file
|
|
4308
4792
|
* of this package's own is this package's to migrate, so the same refusal
|
|
4309
|
-
* points at `kanbo migrate` instead of at the app. Reads
|
|
4310
|
-
*
|
|
4311
|
-
* their cards
|
|
4793
|
+
* points at `kanbo migrate` instead of at the app. Reads are held to the lesser
|
|
4794
|
+
* bar of `assertReadableBoard`: a file only a stamp behind is still read, so a
|
|
4795
|
+
* person who cannot migrate right now can look at their cards, but one missing
|
|
4796
|
+
* a table or column this build reads is refused with the same sentence.
|
|
4312
4797
|
*/
|
|
4313
4798
|
function assertWritableBoard(database, owner) {
|
|
4314
4799
|
try {
|
|
@@ -4319,6 +4804,26 @@ function assertWritableBoard(database, owner) {
|
|
|
4319
4804
|
}
|
|
4320
4805
|
}
|
|
4321
4806
|
/**
|
|
4807
|
+
* Refuse a read of a board file that lacks a table or a column this build
|
|
4808
|
+
* reads — a file a migration behind — with the sentence a write gets, instead
|
|
4809
|
+
* of letting the first statement fail on what is missing. A file whose only
|
|
4810
|
+
* difference is its schema stamp is still read: every statement this build
|
|
4811
|
+
* makes works on it, and a person who cannot migrate now can still look at
|
|
4812
|
+
* their cards.
|
|
4813
|
+
*/
|
|
4814
|
+
function assertReadableBoard(database, owner) {
|
|
4815
|
+
try {
|
|
4816
|
+
require_migrate.assertBoardSchema(database);
|
|
4817
|
+
} catch (error) {
|
|
4818
|
+
if (!(error instanceof require_migrations_path.BoardError && error.code === "board_schema_outdated")) throw error;
|
|
4819
|
+
if (lacksWhatThisBuildReads(error.details)) throw new CliError(3, owner === "kanbo" ? FILE_SCHEMA_OUTDATED_MESSAGE : SCHEMA_OUTDATED_MESSAGE);
|
|
4820
|
+
}
|
|
4821
|
+
}
|
|
4822
|
+
/** Whether `assertBoardSchema` found a table or a column missing, rather than only another stamp. */
|
|
4823
|
+
function lacksWhatThisBuildReads(details) {
|
|
4824
|
+
return details !== void 0 && ("missingTables" in details || "missingAddedTables" in details || "missingColumns" in details);
|
|
4825
|
+
}
|
|
4826
|
+
/**
|
|
4322
4827
|
* Refuse an external board this build cannot speak for — before reading it as
|
|
4323
4828
|
* well as before writing to it.
|
|
4324
4829
|
*
|
|
@@ -4538,15 +5043,17 @@ function withBoardOptions(command) {
|
|
|
4538
5043
|
* as a client is talking to it. A failure on the way out closes the board first
|
|
4539
5044
|
* — a caller that never received a session has nothing to close.
|
|
4540
5045
|
*/
|
|
4541
|
-
async function openBoardSession(options, mode) {
|
|
4542
|
-
const cwd = process.cwd();
|
|
5046
|
+
async function openBoardSession(options, mode, cwd = process.cwd()) {
|
|
4543
5047
|
const target = resolveDbTarget({
|
|
4544
5048
|
explicitPath: options.db,
|
|
4545
5049
|
explicitUrl: options.databaseUrl,
|
|
4546
5050
|
asAgent: options.asAgent,
|
|
4547
5051
|
cwd
|
|
4548
5052
|
});
|
|
4549
|
-
|
|
5053
|
+
const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
|
|
5054
|
+
const found = findBinding(cwd);
|
|
5055
|
+
if (found) rememberProjectQuietly(found.projectDir);
|
|
5056
|
+
return session;
|
|
4550
5057
|
}
|
|
4551
5058
|
async function openFileSession(target, options, mode, cwd) {
|
|
4552
5059
|
const board = await require_migrate.openBoardDatabase(target.path);
|
|
@@ -4555,6 +5062,7 @@ async function openFileSession(target, options, mode, cwd) {
|
|
|
4555
5062
|
assertWritableBoard(board.database, target.owner);
|
|
4556
5063
|
};
|
|
4557
5064
|
if (mode === "write") assertWritableBoard(board.database, target.owner);
|
|
5065
|
+
else assertReadableBoard(board.database, target.owner);
|
|
4558
5066
|
const workspace = resolveWorkspace(target.owner === "kanbo" ? { kind: "file" } : {
|
|
4559
5067
|
kind: "sqlite",
|
|
4560
5068
|
database: board.database
|
|
@@ -4578,8 +5086,9 @@ async function openFileSession(target, options, mode, cwd) {
|
|
|
4578
5086
|
/**
|
|
4579
5087
|
* The same, on a board that is a database of its own.
|
|
4580
5088
|
*
|
|
4581
|
-
* The guard runs whatever the command came to do. A board file
|
|
4582
|
-
*
|
|
5089
|
+
* The guard runs whatever the command came to do. A board file whose only
|
|
5090
|
+
* difference from this build is its schema stamp is still a board worth
|
|
5091
|
+
* reading; a connection string pointing
|
|
4583
5092
|
* at a database with no board in it is nothing to read at all, and every
|
|
4584
5093
|
* command would otherwise fail one statement later with the driver's own words.
|
|
4585
5094
|
*/
|
|
@@ -4877,7 +5386,9 @@ function projectCardActiveRun(run) {
|
|
|
4877
5386
|
startedAt: run.startedAt
|
|
4878
5387
|
};
|
|
4879
5388
|
}
|
|
4880
|
-
|
|
5389
|
+
/** A column as the command line prints it; `board` is every column, to name the ones it accepts cards from. */
|
|
5390
|
+
function projectColumn(column, board) {
|
|
5391
|
+
const { allowedFrom, wipLimit } = readFlowRules(column);
|
|
4881
5392
|
return {
|
|
4882
5393
|
id: column.id,
|
|
4883
5394
|
name: column.name,
|
|
@@ -4885,7 +5396,9 @@ function projectColumn(column) {
|
|
|
4885
5396
|
description: column.description,
|
|
4886
5397
|
category: column.category,
|
|
4887
5398
|
order: column.order,
|
|
4888
|
-
entryRules: readEntryRules(column)
|
|
5399
|
+
entryRules: readEntryRules(column),
|
|
5400
|
+
allowedFrom: allowedFrom === null ? null : board.filter((other) => allowedFrom.includes(other.id)).map((other) => normalizeStatusName(other.name)),
|
|
5401
|
+
wipLimit
|
|
4889
5402
|
};
|
|
4890
5403
|
}
|
|
4891
5404
|
function projectSprint(sprint) {
|
|
@@ -4957,7 +5470,9 @@ function describeColumns(columns) {
|
|
|
4957
5470
|
return columns.map((column) => [
|
|
4958
5471
|
`${padToWidth(sanitizeTerminalText(column.slug), slugWidth)} ${sanitizeTerminalText(column.name)}`,
|
|
4959
5472
|
column.description ? ` — ${sanitizeTerminalText(column.description)}` : "",
|
|
4960
|
-
column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : ""
|
|
5473
|
+
column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : "",
|
|
5474
|
+
column.allowedFrom === null ? "" : ` [from: ${column.allowedFrom.length > 0 ? column.allowedFrom.join(", ") : "no column"}]`,
|
|
5475
|
+
column.wipLimit === null ? "" : ` [wip: ${column.wipLimit}]`
|
|
4961
5476
|
].join("")).join("\n");
|
|
4962
5477
|
}
|
|
4963
5478
|
/** A unix-seconds date as the UTC day it falls on, `—` for none. */
|
|
@@ -5279,6 +5794,52 @@ function registerCapabilitiesCommand(program) {
|
|
|
5279
5794
|
});
|
|
5280
5795
|
}
|
|
5281
5796
|
//#endregion
|
|
5797
|
+
//#region src/cli/commands/claim.ts
|
|
5798
|
+
/**
|
|
5799
|
+
* `kanbo claim [card]` — take a card to work on, in one step.
|
|
5800
|
+
*
|
|
5801
|
+
* The run starts and the card moves out of To Do into the board's first
|
|
5802
|
+
* work-in-progress column in one write, so two agents in two terminals never
|
|
5803
|
+
* take the same card. Filed as a placement: in a person's own terminal the
|
|
5804
|
+
* column's rules warn, in an agent's shell they refuse.
|
|
5805
|
+
*/
|
|
5806
|
+
function registerClaimCommand(program) {
|
|
5807
|
+
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) => {
|
|
5808
|
+
await runBoardCommand(options, "write", async (session) => {
|
|
5809
|
+
const actor = createPlacementActor();
|
|
5810
|
+
const named = reference === void 0 ? void 0 : (await requireCard(session, reference)).id;
|
|
5811
|
+
const claimed = await session.ops.claimCard({
|
|
5812
|
+
workspaceId: session.workspace.id,
|
|
5813
|
+
issueId: named,
|
|
5814
|
+
run: {
|
|
5815
|
+
agentName: options.agent ?? actor.id ?? "agent",
|
|
5816
|
+
externalSessionRef: options.session ?? sessionRefFromEnvironment() ?? void 0,
|
|
5817
|
+
launchedByKind: "external"
|
|
5818
|
+
}
|
|
5819
|
+
}, actor);
|
|
5820
|
+
const [columns, projection, runs] = await Promise.all([
|
|
5821
|
+
session.ops.listColumns(session.workspace.id),
|
|
5822
|
+
session.ops.readBoardProjectionForIssue(claimed.card.id),
|
|
5823
|
+
session.ops.listRuns(claimed.card.id)
|
|
5824
|
+
]);
|
|
5825
|
+
const card = projectCard(claimed.card, columns, projection);
|
|
5826
|
+
for (const item of claimed.card.unmetRules ?? []) console.error(`kanbo: warning: ${card.id} entered "${claimed.column.name}" without ${item.rule}: ${item.detail}`);
|
|
5827
|
+
const run = projectRun({
|
|
5828
|
+
...claimed.run,
|
|
5829
|
+
attempt: runs.find((each) => each.id === claimed.run.id)?.attempt ?? runs.length
|
|
5830
|
+
});
|
|
5831
|
+
return {
|
|
5832
|
+
value: {
|
|
5833
|
+
card,
|
|
5834
|
+
run,
|
|
5835
|
+
column: projectColumn(claimed.column, columns)
|
|
5836
|
+
},
|
|
5837
|
+
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`
|
|
5838
|
+
};
|
|
5839
|
+
});
|
|
5840
|
+
});
|
|
5841
|
+
}
|
|
5842
|
+
//#endregion
|
|
5282
5843
|
//#region src/mcp/transport.ts
|
|
5283
5844
|
/** What `kanbo_card_get` may bring along with the card. */
|
|
5284
5845
|
const KANBO_CARD_INCLUDES = [
|
|
@@ -5395,16 +5956,22 @@ function projectKanboSprint(sprint) {
|
|
|
5395
5956
|
doneCards: sprint.cards.done
|
|
5396
5957
|
};
|
|
5397
5958
|
}
|
|
5398
|
-
/** A column of the board, from the row either transport holds. */
|
|
5399
|
-
function projectKanboColumn(column) {
|
|
5959
|
+
/** A column of the board, from the row either transport holds; `board` is every column, to name the ones it accepts cards from. */
|
|
5960
|
+
function projectKanboColumn(column, board) {
|
|
5400
5961
|
const stored = column.entryRules;
|
|
5962
|
+
const allowedIds = typeof column.allowedFrom === "string" ? readFlowRules({
|
|
5963
|
+
allowedFrom: column.allowedFrom,
|
|
5964
|
+
wipLimit: null
|
|
5965
|
+
}).allowedFrom : column.allowedFrom ?? null;
|
|
5401
5966
|
return {
|
|
5402
5967
|
id: column.id,
|
|
5403
5968
|
name: column.name,
|
|
5404
5969
|
slug: normalizeStatusName(column.name),
|
|
5405
5970
|
description: column.description,
|
|
5406
5971
|
order: column.order,
|
|
5407
|
-
entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null })
|
|
5972
|
+
entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null }),
|
|
5973
|
+
allowedFrom: allowedIds === null ? null : board.filter((other) => allowedIds.includes(other.id)).map((other) => normalizeStatusName(other.name)),
|
|
5974
|
+
wipLimit: column.wipLimit ?? null
|
|
5408
5975
|
};
|
|
5409
5976
|
}
|
|
5410
5977
|
/**
|
|
@@ -5431,9 +5998,14 @@ function pageOf(cards, input) {
|
|
|
5431
5998
|
}
|
|
5432
5999
|
/** A comment as a card read lists it, from the row either transport holds. */
|
|
5433
6000
|
function projectKanboCardComment(comment) {
|
|
6001
|
+
const decision = comment.authorKind === "system.approved" || comment.authorKind === "system.returned";
|
|
6002
|
+
const author = decision ? "user" : normalizeCommentAuthorKind(comment.authorKind);
|
|
6003
|
+
const named = author !== "system" || decision;
|
|
6004
|
+
const outside = author === "system" && isOutsideWriter(comment) && comment.authorKind === "system";
|
|
5434
6005
|
return {
|
|
5435
6006
|
id: comment.id,
|
|
5436
|
-
author
|
|
6007
|
+
author,
|
|
6008
|
+
by: named || outside ? comment.authorId ?? author : "kanbo",
|
|
5437
6009
|
content: comment.content,
|
|
5438
6010
|
createdAt: comment.createdAt
|
|
5439
6011
|
};
|
|
@@ -5504,7 +6076,7 @@ function describeCardExtras(extras) {
|
|
|
5504
6076
|
})))].join("\n"));
|
|
5505
6077
|
if (extras.comments && extras.comments.length > 0) {
|
|
5506
6078
|
const heading = extras.commentCount === extras.comments.length ? "Comments:" : `Comments (last ${extras.comments.length} of ${extras.commentCount}):`;
|
|
5507
|
-
sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${comment.author}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
|
|
6079
|
+
sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${sanitizeTerminalText(comment.by)}${comment.author === "user" ? " (person)" : ""}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
|
|
5508
6080
|
}
|
|
5509
6081
|
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"));
|
|
5510
6082
|
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"));
|
|
@@ -5602,10 +6174,11 @@ function registerCardCommands(program) {
|
|
|
5602
6174
|
}, createPlacementActor()));
|
|
5603
6175
|
});
|
|
5604
6176
|
});
|
|
5605
|
-
withBoardOptions(card.command("update").description("Change a card's title, description, priority or
|
|
6177
|
+
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) => {
|
|
5606
6178
|
await runBoardCommand(options, "write", async (session) => {
|
|
5607
6179
|
const input = readUpdateInput(options);
|
|
5608
6180
|
const existing = await requireCard(session, reference);
|
|
6181
|
+
if (options.parent !== void 0) input.parentIssueId = options.parent === "none" ? null : (await requireCard(session, options.parent)).id;
|
|
5609
6182
|
return await presentCard(session, await session.ops.updateCard(existing.id, input, createPlacementActor()));
|
|
5610
6183
|
});
|
|
5611
6184
|
});
|
|
@@ -5746,9 +6319,10 @@ async function presentCard(session, card) {
|
|
|
5746
6319
|
};
|
|
5747
6320
|
}
|
|
5748
6321
|
/**
|
|
5749
|
-
* The fields `card update` was asked to change
|
|
5750
|
-
*
|
|
5751
|
-
*
|
|
6322
|
+
* The fields `card update` was asked to change — all but the parent, a card
|
|
6323
|
+
* of the board, looked up there once the card itself is. A command that names
|
|
6324
|
+
* none of them is refused rather than obeyed: an empty update still bumps the
|
|
6325
|
+
* board version, and every reader would be told the board changed when it did not.
|
|
5752
6326
|
*/
|
|
5753
6327
|
function readUpdateInput(options) {
|
|
5754
6328
|
const input = {};
|
|
@@ -5757,7 +6331,7 @@ function readUpdateInput(options) {
|
|
|
5757
6331
|
if (options.priority !== void 0) input.priority = options.priority;
|
|
5758
6332
|
if (options.labels !== void 0) input.labels = options.labels;
|
|
5759
6333
|
if (options.executionMode !== void 0) input.executionMode = options.executionMode;
|
|
5760
|
-
if (Object.keys(input).length === 0) throw new CliError(1, "Nothing to update. Pass --title, --description, --priority, --labels
|
|
6334
|
+
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.");
|
|
5761
6335
|
return input;
|
|
5762
6336
|
}
|
|
5763
6337
|
function parsePriority(value) {
|
|
@@ -5827,10 +6401,11 @@ const COLUMN_CATEGORIES = [
|
|
|
5827
6401
|
];
|
|
5828
6402
|
/** The whole board's columns after a change, and the sentence that says what changed. */
|
|
5829
6403
|
async function changed(session, message, extra = {}) {
|
|
5830
|
-
const
|
|
6404
|
+
const board = await session.ops.listColumns(session.workspace.id);
|
|
6405
|
+
const views = board.map((column) => projectColumn(column, board));
|
|
5831
6406
|
return {
|
|
5832
6407
|
value: {
|
|
5833
|
-
...extra,
|
|
6408
|
+
...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, typeof value === "number" || Array.isArray(value) ? value : projectColumn(value, board)])),
|
|
5834
6409
|
columns: views
|
|
5835
6410
|
},
|
|
5836
6411
|
text: `${message}\n\n${describeColumns(views)}`
|
|
@@ -5859,14 +6434,14 @@ async function addColumn(session, request, actor) {
|
|
|
5859
6434
|
category: request.category ?? spec.category,
|
|
5860
6435
|
position: anchor ? request.after !== void 0 ? { after: anchor.id } : { before: anchor.id } : void 0
|
|
5861
6436
|
}, actor);
|
|
5862
|
-
return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added:
|
|
6437
|
+
return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: created });
|
|
5863
6438
|
}
|
|
5864
6439
|
/** Said after a rename: agents' instructions name no column but To Do, and `kanbo prime` gives them the rest. */
|
|
5865
6440
|
const RENAME_NOTE = "Agents read column names from kanbo prime; rename is picked up there.";
|
|
5866
6441
|
async function renameColumn(session, column, newName, actor) {
|
|
5867
6442
|
const before = await requireNamedColumn(session, column);
|
|
5868
6443
|
const renamed = await session.ops.renameColumn(session.workspace.id, before.id, newName, actor);
|
|
5869
|
-
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
|
|
6444
|
+
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 });
|
|
5870
6445
|
}
|
|
5871
6446
|
/** `--first`, `--last`, `--before <column>` or `--after <column>` — exactly one. */
|
|
5872
6447
|
function readColumnPosition(options) {
|
|
@@ -5898,7 +6473,7 @@ async function removeColumn(session, column, moveCardsTo, actor) {
|
|
|
5898
6473
|
const result = await session.ops.removeColumn(session.workspace.id, removing.id, { moveCardsTo }, actor);
|
|
5899
6474
|
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}`);
|
|
5900
6475
|
return await changed(session, result.movedTo ? `Removed ${result.removed.name} and moved ${describeCount$1(result.movedCards)} to ${result.movedTo.name}.` : `Removed ${result.removed.name}.`, {
|
|
5901
|
-
removed:
|
|
6476
|
+
removed: result.removed,
|
|
5902
6477
|
movedCards: result.movedCards
|
|
5903
6478
|
});
|
|
5904
6479
|
}
|
|
@@ -5942,6 +6517,14 @@ const BOARD_ERROR_TEXTS = {
|
|
|
5942
6517
|
text: "Only a person can approve a card.",
|
|
5943
6518
|
next: AS_A_PERSON
|
|
5944
6519
|
}),
|
|
6520
|
+
board_card_not_ready: (details) => ({
|
|
6521
|
+
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.`,
|
|
6522
|
+
next: "kanbo ready"
|
|
6523
|
+
}),
|
|
6524
|
+
board_card_run_active: (details) => ({
|
|
6525
|
+
text: `${named(details.issueId)} already has a run going: ${named(details.agentName)}'s, run ${named(details.runId)}.`,
|
|
6526
|
+
next: `kanbo run finish ${named(details.runId)} --state stopped`
|
|
6527
|
+
}),
|
|
5945
6528
|
board_column_name_taken: (details) => ({
|
|
5946
6529
|
text: `Another column, ${named(details.takenBy)}, already has that name (same slug). Pick another name.`,
|
|
5947
6530
|
next: "kanbo columns list"
|
|
@@ -5972,6 +6555,14 @@ const BOARD_ERROR_TEXTS = {
|
|
|
5972
6555
|
}),
|
|
5973
6556
|
board_entry_rule_invalid: (details) => ({ text: `Unknown column rule: ${list(details.rules)}. Use any of ${list(details.allowed)}.` }),
|
|
5974
6557
|
board_execution_mode_locked: (details) => ({ text: `${named(details.issueId)} has been started, so where it is worked (${named(details.executionMode)}) can't change now.` }),
|
|
6558
|
+
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.` } : {
|
|
6559
|
+
text: `A column accepts cards only from other columns of its own board, and ${list(details.allowedFrom)} is not one.`,
|
|
6560
|
+
next: "kanbo columns list"
|
|
6561
|
+
},
|
|
6562
|
+
board_nothing_ready: () => ({
|
|
6563
|
+
text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
|
|
6564
|
+
next: "kanbo ready"
|
|
6565
|
+
}),
|
|
5975
6566
|
board_pull_request_invalid: (details) => ({ text: `"${named(details.value)}" is not a GitHub pull request. Give its link or owner/repo#number.` }),
|
|
5976
6567
|
board_pull_request_not_found: (details) => ({
|
|
5977
6568
|
text: `${named(details.issueId)} has no pull request link "${named(details.linkId)}".`,
|
|
@@ -6027,6 +6618,7 @@ const BOARD_ERROR_TEXTS = {
|
|
|
6027
6618
|
text: `No card "${named(details.issueId)}" here.`,
|
|
6028
6619
|
next: "kanbo card list"
|
|
6029
6620
|
}),
|
|
6621
|
+
issue_parent_cycle: () => ({ text: "A card can't be put under one of its own sub-cards." }),
|
|
6030
6622
|
issue_parent_not_found: (details) => ({
|
|
6031
6623
|
text: `No card "${named(details.parentIssueId)}" here to put this one under.`,
|
|
6032
6624
|
next: "kanbo card list"
|
|
@@ -6036,7 +6628,7 @@ const BOARD_ERROR_TEXTS = {
|
|
|
6036
6628
|
issue_relation_self_reference: () => ({ text: "A card can't be linked to itself." }),
|
|
6037
6629
|
issue_status_name_empty: () => ({ text: "Give the column a name." }),
|
|
6038
6630
|
issue_status_not_found: (details) => ({
|
|
6039
|
-
text: `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
|
|
6631
|
+
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.`,
|
|
6040
6632
|
next: "kanbo columns list"
|
|
6041
6633
|
}),
|
|
6042
6634
|
issue_status_reference_conflict: () => ({ text: "Name the column once: by its id or by its name, not both." }),
|
|
@@ -6240,7 +6832,7 @@ async function runColumnsMenu(ui) {
|
|
|
6240
6832
|
if (showList) say$1(ui, [
|
|
6241
6833
|
"",
|
|
6242
6834
|
"Columns of this board:",
|
|
6243
|
-
describeColumns(columns.map(projectColumn))
|
|
6835
|
+
describeColumns(columns.map((column) => projectColumn(column, columns)))
|
|
6244
6836
|
].join("\n"));
|
|
6245
6837
|
let choice;
|
|
6246
6838
|
try {
|
|
@@ -6468,7 +7060,8 @@ function registerColumnsCommands(program) {
|
|
|
6468
7060
|
const columns = program.command("columns").description("See and change the board's columns (alone: a menu)");
|
|
6469
7061
|
withBoardOptions(columns.command("list").description("List the columns in order, with the short name to move cards by")).action(async (options) => {
|
|
6470
7062
|
await runBoardCommand(options, "read", async (session) => {
|
|
6471
|
-
const
|
|
7063
|
+
const board = await session.ops.listColumns(session.workspace.id);
|
|
7064
|
+
const views = board.map((column) => projectColumn(column, board));
|
|
6472
7065
|
return {
|
|
6473
7066
|
value: views,
|
|
6474
7067
|
text: describeColumns(views)
|
|
@@ -6477,7 +7070,7 @@ function registerColumnsCommands(program) {
|
|
|
6477
7070
|
});
|
|
6478
7071
|
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) => {
|
|
6479
7072
|
await runBoardCommand(options, "write", async (session) => {
|
|
6480
|
-
const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text));
|
|
7073
|
+
const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text), await session.ops.listColumns(session.workspace.id));
|
|
6481
7074
|
return {
|
|
6482
7075
|
value: view,
|
|
6483
7076
|
text: describeColumns([view])
|
|
@@ -6488,13 +7081,34 @@ function registerColumnsCommands(program) {
|
|
|
6488
7081
|
const actor = requireHumanActor("columns rules", typedWords(command));
|
|
6489
7082
|
const rules = readRequestedRules(options);
|
|
6490
7083
|
await runBoardCommand(options, "write", async (session) => {
|
|
6491
|
-
const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor));
|
|
7084
|
+
const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor), await session.ops.listColumns(session.workspace.id));
|
|
6492
7085
|
return {
|
|
6493
7086
|
value: view,
|
|
6494
7087
|
text: describeColumns([view])
|
|
6495
7088
|
};
|
|
6496
7089
|
});
|
|
6497
7090
|
});
|
|
7091
|
+
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) => {
|
|
7092
|
+
const actor = requireHumanActor("columns flow", typedWords(command));
|
|
7093
|
+
if (options.from === void 0 && options.wip === void 0) throw new CliError(1, "Pass --from <columns|any>, --wip <count|none>, or both.");
|
|
7094
|
+
const wipLimit = options.wip === void 0 ? void 0 : readWipLimit(options.wip);
|
|
7095
|
+
await runBoardCommand(options, "write", async (session) => {
|
|
7096
|
+
const workspaceId = session.workspace.id;
|
|
7097
|
+
const board = await session.ops.listColumns(workspaceId);
|
|
7098
|
+
const target = await session.ops.requireColumn(workspaceId, column);
|
|
7099
|
+
const current = readFlowRules(target);
|
|
7100
|
+
await session.ops.setColumnFlowRules(target.id, {
|
|
7101
|
+
allowedFrom: options.from === void 0 ? current.allowedFrom : readAllowedFrom(board, workspaceId, options.from),
|
|
7102
|
+
wipLimit: wipLimit === void 0 ? current.wipLimit : wipLimit
|
|
7103
|
+
}, actor);
|
|
7104
|
+
const after = await session.ops.listColumns(workspaceId);
|
|
7105
|
+
const views = after.map((each) => projectColumn(each, after));
|
|
7106
|
+
return {
|
|
7107
|
+
value: views.find((view) => view.id === target.id),
|
|
7108
|
+
text: describeColumns(views)
|
|
7109
|
+
};
|
|
7110
|
+
});
|
|
7111
|
+
});
|
|
6498
7112
|
withBoardOptions(columns.command("add").description("Add a column (before Done unless you say where)").argument("<name>", "what the column is called").option("--after <column>", "put it right after this column").option("--before <column>", "put it right before this column").option("--description <text>", "when a card belongs in it, in one line agents read").addOption(new commander.Option("--category <category>", "what kind of column it is").choices(COLUMN_CATEGORIES))).action(async (name, options, command) => {
|
|
6499
7113
|
const actor = requireHumanActor("columns add", typedWords(command));
|
|
6500
7114
|
await runBoardCommand(options, "write", async (session) => await addColumn(session, {
|
|
@@ -6536,7 +7150,7 @@ function registerColumnsCommands(program) {
|
|
|
6536
7150
|
mode: "add-missing",
|
|
6537
7151
|
actor
|
|
6538
7152
|
});
|
|
6539
|
-
const views = statuses.map(projectColumn);
|
|
7153
|
+
const views = statuses.map((column) => projectColumn(column, statuses));
|
|
6540
7154
|
return {
|
|
6541
7155
|
value: {
|
|
6542
7156
|
added,
|
|
@@ -6560,7 +7174,8 @@ function registerColumnsCommands(program) {
|
|
|
6560
7174
|
return;
|
|
6561
7175
|
}
|
|
6562
7176
|
await runBoardCommand({}, "read", async (session) => {
|
|
6563
|
-
const
|
|
7177
|
+
const board = await session.ops.listColumns(session.workspace.id);
|
|
7178
|
+
const views = board.map((column) => projectColumn(column, board));
|
|
6564
7179
|
return {
|
|
6565
7180
|
value: views,
|
|
6566
7181
|
text: describeColumns(views)
|
|
@@ -6590,6 +7205,26 @@ function readRequestedRules(options) {
|
|
|
6590
7205
|
if (options.require === void 0) throw new CliError(1, `Pass --require ${ENTRY_RULES.join(",")} (any of them) or --clear.`);
|
|
6591
7206
|
return options.require.split(",").map((rule) => rule.trim()).filter(Boolean);
|
|
6592
7207
|
}
|
|
7208
|
+
/** `--from`: `any` for every column, else the columns named, by slug, name or id. */
|
|
7209
|
+
function readAllowedFrom(board, workspaceId, value) {
|
|
7210
|
+
if (value.trim().toLowerCase() === "any") return null;
|
|
7211
|
+
const names = value.split(",").map((name) => name.trim()).filter(Boolean);
|
|
7212
|
+
if (names.length === 0) throw new CliError(1, "Name the columns a card may come from (--from in_review,qa), or --from any.");
|
|
7213
|
+
return names.map((name) => {
|
|
7214
|
+
const found = matchColumn(board, workspaceId, name);
|
|
7215
|
+
if (!found) throw new require_migrations_path.BoardError("issue_status_not_found", {
|
|
7216
|
+
workspaceId,
|
|
7217
|
+
statusName: name
|
|
7218
|
+
});
|
|
7219
|
+
return found.id;
|
|
7220
|
+
});
|
|
7221
|
+
}
|
|
7222
|
+
/** `--wip`: `none` for no limit, else a whole number of cards, 1 or more. */
|
|
7223
|
+
function readWipLimit(value) {
|
|
7224
|
+
if (value.trim().toLowerCase() === "none") return null;
|
|
7225
|
+
if (!/^\d+$/.test(value.trim()) || Number(value) < 1) throw new CliError(1, "--wip takes a whole number of cards, 1 or more, or none.");
|
|
7226
|
+
return Number(value);
|
|
7227
|
+
}
|
|
6593
7228
|
//#endregion
|
|
6594
7229
|
//#region src/package-version.ts
|
|
6595
7230
|
(0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
|
|
@@ -6599,7 +7234,7 @@ function readRequestedRules(options) {
|
|
|
6599
7234
|
* `package.version`. A build carries it as a literal; running from source
|
|
6600
7235
|
* (tests, `tsx`) reads the package's own `package.json` instead.
|
|
6601
7236
|
*/
|
|
6602
|
-
const KANBO_PACKAGE_VERSION = "0.
|
|
7237
|
+
const KANBO_PACKAGE_VERSION = "0.5.0";
|
|
6603
7238
|
//#endregion
|
|
6604
7239
|
//#region src/cli/setup/paths.ts
|
|
6605
7240
|
/**
|
|
@@ -6948,7 +7583,7 @@ const INSTRUCTION_END_PATTERN = /<!-- KANBO_END\s*-->/g;
|
|
|
6948
7583
|
const POINTER_KINDS = /* @__PURE__ */ new Set(["short", "global"]);
|
|
6949
7584
|
/** The commands an agent uses, spelled once for the guide and the `kanbo prime` cheat sheet. */
|
|
6950
7585
|
const COMMAND = {
|
|
6951
|
-
|
|
7586
|
+
claim: "kanbo claim",
|
|
6952
7587
|
move: "kanbo card move <id> <column>",
|
|
6953
7588
|
statusLine: "kanbo card status-line <id> --text \"...\"",
|
|
6954
7589
|
subtask: "kanbo card create --description \"...\" --parent <id>",
|
|
@@ -6967,7 +7602,7 @@ const AGENT_GUIDE_TEXT = [
|
|
|
6967
7602
|
"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.",
|
|
6968
7603
|
"",
|
|
6969
7604
|
...[
|
|
6970
|
-
`- Take a card
|
|
7605
|
+
`- 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.`,
|
|
6971
7606
|
`- ${MOVE_CARD_RULE} (\`${COMMAND.move}\`)`,
|
|
6972
7607
|
`- ${STATUS_LINE_RULE} (\`${COMMAND.statusLine}\`)`,
|
|
6973
7608
|
`- ${SUBTASK_RULE} (\`${COMMAND.subtask}\`)`,
|
|
@@ -6995,13 +7630,15 @@ const COMMAND_SHEET = [
|
|
|
6995
7630
|
"- `kanbo card status-line TST-5 --text \"Running the tests\"` — what is happening on the card right now",
|
|
6996
7631
|
"- `kanbo card comment TST-5 --content \"Added slugify with a test\"` — a finding, result, decision or question",
|
|
6997
7632
|
"- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
|
|
7633
|
+
"- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
|
|
6998
7634
|
"- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
|
|
6999
|
-
"- `kanbo
|
|
7635
|
+
"- `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",
|
|
7636
|
+
"- `kanbo run start TST-5 --agent claude` — record a run on a card you did not claim; it prints the finish command",
|
|
7000
7637
|
"- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
|
|
7001
7638
|
`- \`${COMMAND.capabilities}\` — everything else, rarely needed`
|
|
7002
7639
|
].join("\n");
|
|
7003
7640
|
/** How to take work and who approves it — said by both blocks after their first sentence. */
|
|
7004
|
-
const POINTER_TAIL = `Take work with \`${COMMAND.
|
|
7641
|
+
const POINTER_TAIL = `Take work with \`${COMMAND.claim}\`. ${PERSON_ONLY_RULE}`;
|
|
7005
7642
|
/** The body of the project block: what `kanbo instructions short` prints. */
|
|
7006
7643
|
const PROJECT_BODY = [
|
|
7007
7644
|
"## Kanbo board",
|
|
@@ -7021,7 +7658,7 @@ const GLOBAL_BODY = [
|
|
|
7021
7658
|
* The block, markers included, with this body's version and hash on the start
|
|
7022
7659
|
* marker — and, when given, the kind of text it is (`k=orchestrator`).
|
|
7023
7660
|
*/
|
|
7024
|
-
function wrapInstructionBlock(body, version =
|
|
7661
|
+
function wrapInstructionBlock(body, version = 3, kind) {
|
|
7025
7662
|
return `<!-- KANBO_START v${version}${kind ? ` k=${kind}` : ""} h=${hashBody(body)} -->\n${body}\n${INSTRUCTION_END}`;
|
|
7026
7663
|
}
|
|
7027
7664
|
/** The block `kanbo init --instructions …` writes into a project's own file. */
|
|
@@ -7050,7 +7687,7 @@ function inspectBlock(text, current) {
|
|
|
7050
7687
|
kind
|
|
7051
7688
|
});
|
|
7052
7689
|
if (fields.trim() === "") return inspection(LEGACY_BLOCK_BODIES.includes(body) ? "legacy" : "edited");
|
|
7053
|
-
if (version !== void 0 && Number(version) >
|
|
7690
|
+
if (version !== void 0 && Number(version) > 3) return inspection("newer");
|
|
7054
7691
|
if (kind !== null && !POINTER_KINDS.has(kind)) return inspection("chosen");
|
|
7055
7692
|
if (version === void 0 || hash === void 0 || hashBody(body) !== hash) return inspection("edited");
|
|
7056
7693
|
return inspection(normalized.slice(found.start, found.end) === current ? "current" : "outdated");
|
|
@@ -8080,7 +8717,7 @@ function startUpdateCheck(deps = {}) {
|
|
|
8080
8717
|
setTimeout: globalThis.setTimeout,
|
|
8081
8718
|
clearTimeout: globalThis.clearTimeout
|
|
8082
8719
|
};
|
|
8083
|
-
const current = deps.currentVersion ?? "0.
|
|
8720
|
+
const current = deps.currentVersion ?? "0.5.0";
|
|
8084
8721
|
const controller = new AbortController();
|
|
8085
8722
|
let found = null;
|
|
8086
8723
|
const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
|
|
@@ -9317,6 +9954,7 @@ async function applyInitPlan(plan) {
|
|
|
9317
9954
|
}
|
|
9318
9955
|
if (plan.target.kind === "postgres" && plan.migrate) columns = await migrateSharedBoard(plan.target.url, plan.workspace.id, plan.columns);
|
|
9319
9956
|
const bindingPath = writeBinding(plan.projectDir, plan.binding);
|
|
9957
|
+
rememberProjectQuietly(plan.projectDir);
|
|
9320
9958
|
const files = plan.fileChanges.map(({ kind, change }) => ({
|
|
9321
9959
|
kind,
|
|
9322
9960
|
...applyFileChange(change)
|
|
@@ -10064,7 +10702,7 @@ async function planAgents(projectDir, agents, options) {
|
|
|
10064
10702
|
}
|
|
10065
10703
|
/** The board this project is bound to, and the workspace on it. */
|
|
10066
10704
|
async function resolveBoard(projectDir, options) {
|
|
10067
|
-
if (options.file !== void 0 || namesNoBoard(projectDir, options)) return await resolveOwnFile(projectDir, options);
|
|
10705
|
+
if (options.file !== void 0 || namesNoBoard$1(projectDir, options)) return await resolveOwnFile(projectDir, options);
|
|
10068
10706
|
const target = resolveInitTarget(projectDir, options);
|
|
10069
10707
|
if (target.kind === "postgres") return {
|
|
10070
10708
|
target,
|
|
@@ -10083,7 +10721,7 @@ async function resolveBoard(projectDir, options) {
|
|
|
10083
10721
|
* does — a person on a machine with nothing else installed gets a board, not a
|
|
10084
10722
|
* refusal that names a flag.
|
|
10085
10723
|
*/
|
|
10086
|
-
function namesNoBoard(projectDir, options) {
|
|
10724
|
+
function namesNoBoard$1(projectDir, options) {
|
|
10087
10725
|
return !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DB_PATH?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(projectDir) === null;
|
|
10088
10726
|
}
|
|
10089
10727
|
/** The workspace a host database — or the board file this project is already bound to — says this project is. */
|
|
@@ -10511,11 +11149,12 @@ const KANBO_MCP_INSTRUCTIONS = [
|
|
|
10511
11149
|
"kanbo is a kanban board shared by people and agents; these tools read and change its cards.",
|
|
10512
11150
|
"",
|
|
10513
11151
|
`- At the start of a session call ${tool("kanbo_prime")} — it returns this board's columns and what each one means.`,
|
|
10514
|
-
`- Take work from ${tool("kanbo_ready")}.`,
|
|
11152
|
+
`- 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.`,
|
|
10515
11153
|
`- ${MOVE_CARD_RULE} (${tool("kanbo_card_move")})`,
|
|
10516
11154
|
`- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
|
|
10517
11155
|
`- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
|
|
10518
11156
|
`- ${SUBTASK_RULE} (${tool("kanbo_card_create")} with \`parent\`)`,
|
|
11157
|
+
`- A card at the wrong level is moved with ${tool("kanbo_card_update")} and \`parent\` (\`"none"\` for the top level).`,
|
|
10519
11158
|
`- ${WAIT_FOR_PERSON_RULE} (${tool("kanbo_wait_approval")})`,
|
|
10520
11159
|
`- ${PERSON_ONLY_RULE}`,
|
|
10521
11160
|
`- Every tool, rule and canonical string: the resource \`${KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI}\`.`
|
|
@@ -10601,7 +11240,7 @@ const ORCHESTRATOR_GUIDE = [
|
|
|
10601
11240
|
"## Start of every session",
|
|
10602
11241
|
"1. Run `kanbo prime` (tool: `kanbo_prime`) and read the column descriptions. On this board the description is the contract, not the name.",
|
|
10603
11242
|
"2. Run `kanbo ready` (`kanbo_ready`). If nothing is ready, say so and stop.",
|
|
10604
|
-
"3. Take the first card
|
|
11243
|
+
"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.",
|
|
10605
11244
|
"",
|
|
10606
11245
|
"## While working",
|
|
10607
11246
|
"- 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.",
|
|
@@ -10628,7 +11267,7 @@ const ORCHESTRATOR_GUIDE = [
|
|
|
10628
11267
|
"- Returned: the reason is the newest comment. The card is back in an earlier column; start a new run and address the reason first.",
|
|
10629
11268
|
"",
|
|
10630
11269
|
"## Never",
|
|
10631
|
-
"- 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.",
|
|
11270
|
+
"- 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.",
|
|
10632
11271
|
"- Never delete or edit the board's database directly."
|
|
10633
11272
|
].join("\n");
|
|
10634
11273
|
//#endregion
|
|
@@ -10673,7 +11312,7 @@ function registerInstructionsCommand(program, dependencies = {}) {
|
|
|
10673
11312
|
const found = parseKind(kind);
|
|
10674
11313
|
const body = await readInstructionText(found);
|
|
10675
11314
|
const pointer = found === "short" || found === "global";
|
|
10676
|
-
const text = options.markers ? wrapInstructionBlock(body,
|
|
11315
|
+
const text = options.markers ? wrapInstructionBlock(body, 3, pointer ? void 0 : found) : body;
|
|
10677
11316
|
const hints = process.stderr.isTTY === true;
|
|
10678
11317
|
if (hints) process.stderr.write(`${HEADER}\n\n`);
|
|
10679
11318
|
console.log(text);
|
|
@@ -10895,61 +11534,6 @@ const cardCommentTool = defineKanboTool({
|
|
|
10895
11534
|
},
|
|
10896
11535
|
run: async (transport, input) => await transport.cardComment(input)
|
|
10897
11536
|
});
|
|
10898
|
-
//#endregion
|
|
10899
|
-
//#region src/mcp/tools/card-create.ts
|
|
10900
|
-
/** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
|
|
10901
|
-
const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pass `parent` = that card's id (its key, e.g. `MAN-012`).";
|
|
10902
|
-
/** A new card, or a sub-card of the one being worked on. */
|
|
10903
|
-
const cardCreateTool = defineKanboTool({
|
|
10904
|
-
name: "kanbo_card_create",
|
|
10905
|
-
title: "Create a card",
|
|
10906
|
-
description: `Put a new card on this board. ${SUBTASK_SENTENCE} ${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("pass `parent`")}`,
|
|
10907
|
-
inputSchema: {
|
|
10908
|
-
title: zod.z.string().optional().describe("What the card is called. Leave it out and the card is titled with its own key; the description then says what the task is."),
|
|
10909
|
-
description: zod.z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
|
|
10910
|
-
column: zod.z.string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
|
|
10911
|
-
parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
|
|
10912
|
-
executionMode: executionModeArgument.optional()
|
|
10913
|
-
},
|
|
10914
|
-
example: {
|
|
10915
|
-
description: "Add slugify(text) with a test",
|
|
10916
|
-
parent: "TST-1"
|
|
10917
|
-
},
|
|
10918
|
-
run: async (transport, input) => await transport.cardCreate(input)
|
|
10919
|
-
});
|
|
10920
|
-
//#endregion
|
|
10921
|
-
//#region src/mcp/tools/card-get.ts
|
|
10922
|
-
/** One card in full, with what an agent reads next to it. */
|
|
10923
|
-
const cardGetTool = defineKanboTool({
|
|
10924
|
-
name: "kanbo_card_get",
|
|
10925
|
-
title: "Read a card",
|
|
10926
|
-
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"].`,
|
|
10927
|
-
inputSchema: {
|
|
10928
|
-
card: cardArgument,
|
|
10929
|
-
include: zod.z.array(zod.z.enum(KANBO_CARD_INCLUDES)).optional().describe("What to read with the card; [\"comments\",\"subCards\"] when absent, [] for the card alone."),
|
|
10930
|
-
commentLimit: zod.z.number().int().positive().optional().describe(`How many of the latest comments to include; 10 when absent.`)
|
|
10931
|
-
},
|
|
10932
|
-
example: { card: "TST-5" },
|
|
10933
|
-
run: async (transport, input) => await transport.cardGet(input)
|
|
10934
|
-
});
|
|
10935
|
-
//#endregion
|
|
10936
|
-
//#region src/mcp/tools/card-link-pr.ts
|
|
10937
|
-
/** A pull request the card's work went into, named on the card so its facts reach it. */
|
|
10938
|
-
const cardLinkPrTool = defineKanboTool({
|
|
10939
|
-
name: "kanbo_card_link_pr",
|
|
10940
|
-
title: "Link a pull request to a card",
|
|
10941
|
-
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.",
|
|
10942
|
-
inputSchema: {
|
|
10943
|
-
card: cardArgument,
|
|
10944
|
-
url: zod.z.string().min(1).describe("The pull request, as https://github.com/<owner>/<repo>/pull/<number> or <owner>/<repo>#<number>.")
|
|
10945
|
-
},
|
|
10946
|
-
needs: { url: "the pull request" },
|
|
10947
|
-
example: {
|
|
10948
|
-
card: "TST-5",
|
|
10949
|
-
url: "https://github.com/owner/repo/pull/12"
|
|
10950
|
-
},
|
|
10951
|
-
run: async (transport, input) => await transport.cardLinkPullRequest(input)
|
|
10952
|
-
});
|
|
10953
11537
|
/** The `detail` and `fields` arguments the list tools share. */
|
|
10954
11538
|
const cardListViewArguments = {
|
|
10955
11539
|
detail: zod.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."),
|
|
@@ -10988,6 +11572,20 @@ function compactKanboCard(card) {
|
|
|
10988
11572
|
...card.lastComment ? { lastComment: card.lastComment } : {}
|
|
10989
11573
|
};
|
|
10990
11574
|
}
|
|
11575
|
+
/** The `detail` argument of the tools that write a card: a list's own word, for the one card a write answers with. */
|
|
11576
|
+
const writtenCardDetailArgument = zod.z.enum(["compact", "full"]).optional().describe("compact (default): the card as kanbo_card_list prints it. full: every field, description included.");
|
|
11577
|
+
/** What each tool that writes a card says about its answer, in its description. */
|
|
11578
|
+
const WRITTEN_CARD_ANSWER_SENTENCE = "Answers the card compact, as kanbo_card_list prints it; detail: \"full\" for every field.";
|
|
11579
|
+
/**
|
|
11580
|
+
* The card a write hands back, as the tool answers with it: compact and on one
|
|
11581
|
+
* line, as a list prints it. An agent writes its card at every step — a status
|
|
11582
|
+
* line, a move, an update — and reads each answer again on every later turn,
|
|
11583
|
+
* so a whole card, description included, would cost it again and again.
|
|
11584
|
+
* `detail: "full"` answers every field, as `kanbo_card_get` prints the card alone.
|
|
11585
|
+
*/
|
|
11586
|
+
function formatWrittenKanboCard(card, detail) {
|
|
11587
|
+
return detail === "full" ? JSON.stringify(card, null, 2) : JSON.stringify(compactKanboCard(card));
|
|
11588
|
+
}
|
|
10991
11589
|
function viewCard(card, view) {
|
|
10992
11590
|
if (view.fields) return Object.fromEntries([["id", card.id], ...view.fields.map((field) => [field, card[field]])]);
|
|
10993
11591
|
return view.detail === "full" ? card : compactKanboCard(card);
|
|
@@ -11011,6 +11609,62 @@ function formatKanboCardPage(page, view, returned) {
|
|
|
11011
11609
|
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)}`;
|
|
11012
11610
|
return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
|
|
11013
11611
|
}
|
|
11612
|
+
//#endregion
|
|
11613
|
+
//#region src/mcp/tools/card-create.ts
|
|
11614
|
+
/** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
|
|
11615
|
+
const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pass `parent` = that card's id (its key, e.g. `MAN-012`).";
|
|
11616
|
+
/** A new card, or a sub-card of the one being worked on. */
|
|
11617
|
+
const cardCreateTool = defineKanboTool({
|
|
11618
|
+
name: "kanbo_card_create",
|
|
11619
|
+
title: "Create a card",
|
|
11620
|
+
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.`,
|
|
11621
|
+
inputSchema: {
|
|
11622
|
+
title: zod.z.string().optional().describe("What the card is called. Leave it out and the card is titled with its own key; the description then says what the task is."),
|
|
11623
|
+
description: zod.z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
|
|
11624
|
+
column: zod.z.string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
|
|
11625
|
+
parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
|
|
11626
|
+
executionMode: executionModeArgument.optional(),
|
|
11627
|
+
detail: writtenCardDetailArgument
|
|
11628
|
+
},
|
|
11629
|
+
example: {
|
|
11630
|
+
description: "Add slugify(text) with a test",
|
|
11631
|
+
parent: "TST-1"
|
|
11632
|
+
},
|
|
11633
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardCreate(input), detail)
|
|
11634
|
+
});
|
|
11635
|
+
//#endregion
|
|
11636
|
+
//#region src/mcp/tools/card-get.ts
|
|
11637
|
+
/** One card in full, with what an agent reads next to it. */
|
|
11638
|
+
const cardGetTool = defineKanboTool({
|
|
11639
|
+
name: "kanbo_card_get",
|
|
11640
|
+
title: "Read a card",
|
|
11641
|
+
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"].`,
|
|
11642
|
+
inputSchema: {
|
|
11643
|
+
card: cardArgument,
|
|
11644
|
+
include: zod.z.array(zod.z.enum(KANBO_CARD_INCLUDES)).optional().describe("What to read with the card; [\"comments\",\"subCards\"] when absent, [] for the card alone."),
|
|
11645
|
+
commentLimit: zod.z.number().int().positive().optional().describe(`How many of the latest comments to include; 10 when absent.`)
|
|
11646
|
+
},
|
|
11647
|
+
example: { card: "TST-5" },
|
|
11648
|
+
run: async (transport, input) => await transport.cardGet(input)
|
|
11649
|
+
});
|
|
11650
|
+
//#endregion
|
|
11651
|
+
//#region src/mcp/tools/card-link-pr.ts
|
|
11652
|
+
/** A pull request the card's work went into, named on the card so its facts reach it. */
|
|
11653
|
+
const cardLinkPrTool = defineKanboTool({
|
|
11654
|
+
name: "kanbo_card_link_pr",
|
|
11655
|
+
title: "Link a pull request to a card",
|
|
11656
|
+
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.",
|
|
11657
|
+
inputSchema: {
|
|
11658
|
+
card: cardArgument,
|
|
11659
|
+
url: zod.z.string().min(1).describe("The pull request, as https://github.com/<owner>/<repo>/pull/<number> or <owner>/<repo>#<number>.")
|
|
11660
|
+
},
|
|
11661
|
+
needs: { url: "the pull request" },
|
|
11662
|
+
example: {
|
|
11663
|
+
card: "TST-5",
|
|
11664
|
+
url: "https://github.com/owner/repo/pull/12"
|
|
11665
|
+
},
|
|
11666
|
+
run: async (transport, input) => await transport.cardLinkPullRequest(input)
|
|
11667
|
+
});
|
|
11014
11668
|
/** The cards on the board a question picks, one page of them. */
|
|
11015
11669
|
const cardListTool = defineKanboTool({
|
|
11016
11670
|
name: "kanbo_card_list",
|
|
@@ -11065,10 +11719,11 @@ function readMoment(value) {
|
|
|
11065
11719
|
const cardMoveTool = defineKanboTool({
|
|
11066
11720
|
name: "kanbo_card_move",
|
|
11067
11721
|
title: "Move a card",
|
|
11068
|
-
description:
|
|
11722
|
+
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}`,
|
|
11069
11723
|
inputSchema: {
|
|
11070
11724
|
card: cardArgument,
|
|
11071
|
-
column: zod.z.string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`.")
|
|
11725
|
+
column: zod.z.string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`."),
|
|
11726
|
+
detail: writtenCardDetailArgument
|
|
11072
11727
|
},
|
|
11073
11728
|
aliases: { column: ["to"] },
|
|
11074
11729
|
needs: { column: "the column slug, e.g. in_progress" },
|
|
@@ -11076,7 +11731,7 @@ const cardMoveTool = defineKanboTool({
|
|
|
11076
11731
|
card: "TST-5",
|
|
11077
11732
|
column: "in_progress"
|
|
11078
11733
|
},
|
|
11079
|
-
run: async (transport, input) => await transport.cardMove(input)
|
|
11734
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardMove(input), detail)
|
|
11080
11735
|
});
|
|
11081
11736
|
//#endregion
|
|
11082
11737
|
//#region src/mcp/tools/card-pull-requests.ts
|
|
@@ -11091,11 +11746,11 @@ const cardPullRequestsTool = defineKanboTool({
|
|
|
11091
11746
|
});
|
|
11092
11747
|
//#endregion
|
|
11093
11748
|
//#region src/mcp/tools/card-update.ts
|
|
11094
|
-
/** What a card says
|
|
11749
|
+
/** What a card says — its title, what it is about, how urgent it is, how it is labelled — and which card it sits under. */
|
|
11095
11750
|
const cardUpdateTool = defineKanboTool({
|
|
11096
11751
|
name: "kanbo_card_update",
|
|
11097
11752
|
title: "Update a card",
|
|
11098
|
-
description:
|
|
11753
|
+
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}`,
|
|
11099
11754
|
inputSchema: {
|
|
11100
11755
|
card: cardArgument,
|
|
11101
11756
|
title: zod.z.string().min(1).optional().describe("What the card is called."),
|
|
@@ -11108,18 +11763,26 @@ const cardUpdateTool = defineKanboTool({
|
|
|
11108
11763
|
"urgent"
|
|
11109
11764
|
]).optional(),
|
|
11110
11765
|
labels: zod.z.array(zod.z.string()).optional().describe("The labels the card carries, replacing the ones it has."),
|
|
11111
|
-
executionMode: executionModeArgument.optional()
|
|
11766
|
+
executionMode: executionModeArgument.optional(),
|
|
11767
|
+
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."),
|
|
11768
|
+
detail: writtenCardDetailArgument
|
|
11112
11769
|
},
|
|
11113
11770
|
example: {
|
|
11114
11771
|
card: "TST-5",
|
|
11115
11772
|
title: "Add slugify"
|
|
11116
11773
|
},
|
|
11117
|
-
|
|
11118
|
-
|
|
11119
|
-
return
|
|
11774
|
+
prepare: (input) => {
|
|
11775
|
+
const { parent, ...rest } = input;
|
|
11776
|
+
return parent === null ? rest : input;
|
|
11777
|
+
},
|
|
11778
|
+
run: async (transport, { card, detail, parent, ...patch }) => {
|
|
11779
|
+
if (Object.keys(patch).length === 0 && parent === void 0) throw new Error("Nothing to update. Pass title, description, priority, labels, executionMode or parent.");
|
|
11780
|
+
const placement = parent === void 0 ? {} : { parent: parent === "none" ? null : parent };
|
|
11781
|
+
return formatWrittenKanboCard(await transport.cardUpdate({
|
|
11120
11782
|
card,
|
|
11121
|
-
...patch
|
|
11122
|
-
|
|
11783
|
+
...patch,
|
|
11784
|
+
...placement
|
|
11785
|
+
}), detail);
|
|
11123
11786
|
}
|
|
11124
11787
|
});
|
|
11125
11788
|
//#endregion
|
|
@@ -11128,7 +11791,7 @@ const cardUpdateTool = defineKanboTool({
|
|
|
11128
11791
|
const columnsTool = defineKanboTool({
|
|
11129
11792
|
name: "kanbo_columns",
|
|
11130
11793
|
title: "Columns of the board",
|
|
11131
|
-
description: "List this board's columns in board order, each with the slug kanbo_card_move takes, what the column means here,
|
|
11794
|
+
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).",
|
|
11132
11795
|
inputSchema: {},
|
|
11133
11796
|
run: async (transport) => await transport.columns()
|
|
11134
11797
|
});
|
|
@@ -11142,6 +11805,38 @@ const primeTool = defineKanboTool({
|
|
|
11142
11805
|
inputSchema: {},
|
|
11143
11806
|
run: async (transport) => (await transport.prime()).text
|
|
11144
11807
|
});
|
|
11808
|
+
//#endregion
|
|
11809
|
+
//#region src/mcp/tools/card-claim.ts
|
|
11810
|
+
/**
|
|
11811
|
+
* Taking a card to work on, in one step: the run starts and the card moves out
|
|
11812
|
+
* of To Do into the board's first work-in-progress column in the same write,
|
|
11813
|
+
* so two agents never get the same card. Published to every host, including
|
|
11814
|
+
* one that records the runs of the agents it launches: a claim is how an agent
|
|
11815
|
+
* in any terminal takes work.
|
|
11816
|
+
*/
|
|
11817
|
+
const cardClaimTool = defineKanboTool({
|
|
11818
|
+
name: "kanbo_card_claim",
|
|
11819
|
+
title: "Claim a card",
|
|
11820
|
+
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.",
|
|
11821
|
+
inputSchema: {
|
|
11822
|
+
card: cardArgument.optional(),
|
|
11823
|
+
agent: zod.z.string().min(1).optional().describe("What to call whoever works the card; who this server writes as when absent.")
|
|
11824
|
+
},
|
|
11825
|
+
aliases: {
|
|
11826
|
+
card: [...CARD_ALIASES, "issue"],
|
|
11827
|
+
agent: ["agentName"]
|
|
11828
|
+
},
|
|
11829
|
+
example: { card: "TST-5" },
|
|
11830
|
+
run: async (transport, input) => {
|
|
11831
|
+
const claimed = await transport.cardClaim(input);
|
|
11832
|
+
return {
|
|
11833
|
+
card: compactKanboCard(claimed.card),
|
|
11834
|
+
run: claimed.run.id,
|
|
11835
|
+
column: claimed.column.slug,
|
|
11836
|
+
finishWith: `kanbo_run_finish {"run":"${claimed.run.id}","state":"finished"}`
|
|
11837
|
+
};
|
|
11838
|
+
}
|
|
11839
|
+
});
|
|
11145
11840
|
/** The cards an agent may pick up right now. */
|
|
11146
11841
|
const readyTool = defineKanboTool({
|
|
11147
11842
|
name: "kanbo_ready",
|
|
@@ -11230,13 +11925,14 @@ const runStartTool = defineKanboTool({
|
|
|
11230
11925
|
/**
|
|
11231
11926
|
* Every tool the board has, in the order it declares them.
|
|
11232
11927
|
*
|
|
11233
|
-
* The list is the board's, not a server's: the same
|
|
11928
|
+
* The list is the board's, not a server's: the same seventeen are registered
|
|
11234
11929
|
* whether they run against a server or against the board directly, and the
|
|
11235
11930
|
* only thing a host decides is which of them it publishes.
|
|
11236
11931
|
*/
|
|
11237
11932
|
const KANBO_TOOLS = [
|
|
11238
11933
|
primeTool,
|
|
11239
11934
|
readyTool,
|
|
11935
|
+
cardClaimTool,
|
|
11240
11936
|
columnsTool,
|
|
11241
11937
|
defineKanboTool({
|
|
11242
11938
|
name: "kanbo_sprints",
|
|
@@ -11256,10 +11952,11 @@ const KANBO_TOOLS = [
|
|
|
11256
11952
|
defineKanboTool({
|
|
11257
11953
|
name: "kanbo_status_line",
|
|
11258
11954
|
title: "Set the status line",
|
|
11259
|
-
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.",
|
|
11955
|
+
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,
|
|
11260
11956
|
inputSchema: {
|
|
11261
11957
|
card: cardArgument,
|
|
11262
|
-
text: zod.z.string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`.")
|
|
11958
|
+
text: zod.z.string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`."),
|
|
11959
|
+
detail: writtenCardDetailArgument
|
|
11263
11960
|
},
|
|
11264
11961
|
aliases: { text: ["content"] },
|
|
11265
11962
|
needs: { text: "the status line, one sentence" },
|
|
@@ -11267,22 +11964,23 @@ const KANBO_TOOLS = [
|
|
|
11267
11964
|
card: "TST-5",
|
|
11268
11965
|
text: "Running the tests"
|
|
11269
11966
|
},
|
|
11270
|
-
run: async (transport, input) => await transport.statusLine(input)
|
|
11967
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.statusLine(input), detail)
|
|
11271
11968
|
}),
|
|
11272
11969
|
defineKanboTool({
|
|
11273
11970
|
name: "kanbo_wait_approval",
|
|
11274
11971
|
title: "Hand the card to a person",
|
|
11275
|
-
description:
|
|
11972
|
+
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}`,
|
|
11276
11973
|
inputSchema: {
|
|
11277
11974
|
card: cardArgument,
|
|
11278
|
-
text: zod.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`.")
|
|
11975
|
+
text: zod.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`."),
|
|
11976
|
+
detail: writtenCardDetailArgument
|
|
11279
11977
|
},
|
|
11280
11978
|
aliases: { text: ["content"] },
|
|
11281
11979
|
example: {
|
|
11282
11980
|
card: "TST-5",
|
|
11283
11981
|
text: "Ready for review: slugify added with a test"
|
|
11284
11982
|
},
|
|
11285
|
-
run: async (transport, input) => await transport.waitApproval(input)
|
|
11983
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.waitApproval(input), detail)
|
|
11286
11984
|
}),
|
|
11287
11985
|
runStartTool,
|
|
11288
11986
|
runFinishTool
|
|
@@ -11366,7 +12064,8 @@ async function createDbTransport(input) {
|
|
|
11366
12064
|
const workspaceId = workspace.id;
|
|
11367
12065
|
const actor = input.actor;
|
|
11368
12066
|
async function readColumns() {
|
|
11369
|
-
|
|
12067
|
+
const rows = await ops.listColumns(workspaceId);
|
|
12068
|
+
return rows.map((row) => projectKanboColumn(row, rows));
|
|
11370
12069
|
}
|
|
11371
12070
|
/** One card with what the run registry says about it, in the shape a tool prints. */
|
|
11372
12071
|
async function project(card, columns) {
|
|
@@ -11416,7 +12115,7 @@ async function createDbTransport(input) {
|
|
|
11416
12115
|
* the waiting and returned ones last said at most two more.
|
|
11417
12116
|
*/
|
|
11418
12117
|
async function projectPage(page, { attend = true } = {}) {
|
|
11419
|
-
const columns = page.columns.map(projectKanboColumn);
|
|
12118
|
+
const columns = page.columns.map((column) => projectKanboColumn(column, page.columns));
|
|
11420
12119
|
const runs = await ops.readBoardProjectionForIssues(page.cards.map((card) => card.id));
|
|
11421
12120
|
const attention = attend ? await ops.readCardAttention(page.cards, runs.map((run) => run.activeRun !== null)) : [];
|
|
11422
12121
|
return page.cards.map((card, index) => projectKanboCard({
|
|
@@ -11483,10 +12182,14 @@ async function createDbTransport(input) {
|
|
|
11483
12182
|
executionMode
|
|
11484
12183
|
}, actor));
|
|
11485
12184
|
},
|
|
11486
|
-
cardUpdate: async ({ card, ...patch }) => {
|
|
12185
|
+
cardUpdate: async ({ card, parent, ...patch }) => {
|
|
11487
12186
|
await session.assertWritable();
|
|
11488
12187
|
const existing = await resolveCard(card);
|
|
11489
|
-
|
|
12188
|
+
const placement = parent === void 0 ? {} : { parentIssueId: parent === null ? null : (await resolveCard(parent)).id };
|
|
12189
|
+
return await project(await ops.updateCard(existing.id, {
|
|
12190
|
+
...patch,
|
|
12191
|
+
...placement
|
|
12192
|
+
}, actor));
|
|
11490
12193
|
},
|
|
11491
12194
|
cardMove: async ({ card, column }) => {
|
|
11492
12195
|
await session.assertWritable();
|
|
@@ -11544,6 +12247,25 @@ async function createDbTransport(input) {
|
|
|
11544
12247
|
state,
|
|
11545
12248
|
errorText
|
|
11546
12249
|
}, actor));
|
|
12250
|
+
},
|
|
12251
|
+
cardClaim: async ({ card, agent }) => {
|
|
12252
|
+
await session.assertWritable();
|
|
12253
|
+
const named = card === void 0 ? void 0 : (await resolveCard(card)).id;
|
|
12254
|
+
const claimed = await ops.claimCard({
|
|
12255
|
+
workspaceId,
|
|
12256
|
+
issueId: named,
|
|
12257
|
+
run: {
|
|
12258
|
+
agentName: agent ?? actor.id ?? "agent",
|
|
12259
|
+
externalSessionRef: sessionRefFromEnvironment() ?? void 0,
|
|
12260
|
+
launchedByKind: "external"
|
|
12261
|
+
}
|
|
12262
|
+
}, actor);
|
|
12263
|
+
const columns = await readColumns();
|
|
12264
|
+
return {
|
|
12265
|
+
card: await project(claimed.card, columns),
|
|
12266
|
+
run: projectKanboRun(claimed.run),
|
|
12267
|
+
column: columns.find((column) => column.id === claimed.column.id)
|
|
12268
|
+
};
|
|
11547
12269
|
}
|
|
11548
12270
|
};
|
|
11549
12271
|
}
|
|
@@ -11585,7 +12307,8 @@ const TRANSPORT_METHODS = {
|
|
|
11585
12307
|
statusLine: true,
|
|
11586
12308
|
waitApproval: true,
|
|
11587
12309
|
runStart: true,
|
|
11588
|
-
runFinish: true
|
|
12310
|
+
runFinish: true,
|
|
12311
|
+
cardClaim: true
|
|
11589
12312
|
};
|
|
11590
12313
|
async function openMcpBoard(input) {
|
|
11591
12314
|
try {
|
|
@@ -11640,13 +12363,40 @@ function withUpdateNote(board, peek) {
|
|
|
11640
12363
|
}
|
|
11641
12364
|
//#endregion
|
|
11642
12365
|
//#region src/cli/commands/mcp.ts
|
|
12366
|
+
/**
|
|
12367
|
+
* The actor of a `kanbo mcp` session, which is always an agent's: `KANBO_ACTOR_ID`,
|
|
12368
|
+
* else the agent tool, else `agent` until the client says its name (see
|
|
12369
|
+
* `nameActorAfterClient`).
|
|
12370
|
+
*/
|
|
12371
|
+
function createMcpActor() {
|
|
12372
|
+
const tool = readAgentTool();
|
|
12373
|
+
const namedByShell = Boolean(process.env.KANBO_ACTOR_ID?.trim() || tool && tool !== "agent");
|
|
12374
|
+
const actor = createCliActor();
|
|
12375
|
+
if (!namedByShell) actor.id = "agent";
|
|
12376
|
+
return {
|
|
12377
|
+
actor,
|
|
12378
|
+
namedByShell
|
|
12379
|
+
};
|
|
12380
|
+
}
|
|
12381
|
+
/**
|
|
12382
|
+
* Files the session's writes under the client's own name (`Test Client` ->
|
|
12383
|
+
* `test-client`) once it has initialized. `actor` is the object the board
|
|
12384
|
+
* holds, so later writes see the new id.
|
|
12385
|
+
*/
|
|
12386
|
+
function nameActorAfterClient(server, actor) {
|
|
12387
|
+
server.server.oninitialized = () => {
|
|
12388
|
+
const id = (server.server.getClientVersion()?.name)?.trim().toLowerCase().replace(/\s+/g, "-").slice(0, 40);
|
|
12389
|
+
if (id) actor.id = id;
|
|
12390
|
+
};
|
|
12391
|
+
}
|
|
11643
12392
|
function registerMcpCommand(program) {
|
|
11644
12393
|
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) => {
|
|
12394
|
+
const { actor, namedByShell } = createMcpActor();
|
|
11645
12395
|
const board = await openMcpBoard({
|
|
11646
12396
|
dbPath: options.db,
|
|
11647
12397
|
databaseUrl: options.databaseUrl,
|
|
11648
12398
|
workspaceId: options.workspace,
|
|
11649
|
-
actor
|
|
12399
|
+
actor
|
|
11650
12400
|
});
|
|
11651
12401
|
let closing;
|
|
11652
12402
|
const close = () => closing ??= board.close().catch((error) => {
|
|
@@ -11655,6 +12405,7 @@ function registerMcpCommand(program) {
|
|
|
11655
12405
|
const check = isUpdateCheckDisabled() ? null : startUpdateCheck();
|
|
11656
12406
|
const server = createKanboMcpServer(check ? withUpdateNote(board, check.peek) : board, { includeRunTools: true });
|
|
11657
12407
|
server.server.onclose = () => void close();
|
|
12408
|
+
if (!namedByShell) nameActorAfterClient(server, actor);
|
|
11658
12409
|
try {
|
|
11659
12410
|
await server.connect(new _modelcontextprotocol_sdk_server_stdio_js.StdioServerTransport());
|
|
11660
12411
|
} catch (error) {
|
|
@@ -12163,7 +12914,7 @@ function sendBoardPage(response, pathname, assets) {
|
|
|
12163
12914
|
* answering for a person — approving a card, sending it back or closing a
|
|
12164
12915
|
* sprint — as anyone but one is 403, dropping a pull request link somebody else made is the same 403
|
|
12165
12916
|
* for the same reason, and acting on a run that has already ended is 409,
|
|
12166
|
-
* because the answer would have been yes a moment earlier. An agent putting a
|
|
12917
|
+
* 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
|
|
12167
12918
|
* card in a column whose entry rules it does not meet yet is 409 for the same
|
|
12168
12919
|
* reason turned around: the answer will be yes once the card meets them, and
|
|
12169
12920
|
* `details.unmet` says what is missing. Deleting a column that still holds
|
|
@@ -12181,6 +12932,14 @@ const BOARD_ERROR_RESPONSES = {
|
|
|
12181
12932
|
message: "Only a person can approve a card",
|
|
12182
12933
|
code: "issue_approval_requires_user"
|
|
12183
12934
|
},
|
|
12935
|
+
board_card_not_ready: {
|
|
12936
|
+
status: 409,
|
|
12937
|
+
message: "That card is not there to take: it is not in To Do, waits for a person, or has a run going"
|
|
12938
|
+
},
|
|
12939
|
+
board_card_run_active: {
|
|
12940
|
+
status: 409,
|
|
12941
|
+
message: "The card already has a run going"
|
|
12942
|
+
},
|
|
12184
12943
|
board_column_name_taken: {
|
|
12185
12944
|
status: 400,
|
|
12186
12945
|
message: "Another column already has that name"
|
|
@@ -12220,6 +12979,14 @@ const BOARD_ERROR_RESPONSES = {
|
|
|
12220
12979
|
status: 400,
|
|
12221
12980
|
message: "Execution mode cannot change once the card has been launched"
|
|
12222
12981
|
},
|
|
12982
|
+
board_flow_rule_invalid: {
|
|
12983
|
+
status: 400,
|
|
12984
|
+
message: "A column accepts cards from other columns of its own board, and holds at least one card"
|
|
12985
|
+
},
|
|
12986
|
+
board_nothing_ready: {
|
|
12987
|
+
status: 409,
|
|
12988
|
+
message: "No card in To Do is ready to take"
|
|
12989
|
+
},
|
|
12223
12990
|
board_pull_request_invalid: {
|
|
12224
12991
|
status: 400,
|
|
12225
12992
|
message: "Not a GitHub pull request: give its URL or owner/repo#number"
|
|
@@ -12299,6 +13066,10 @@ const BOARD_ERROR_RESPONSES = {
|
|
|
12299
13066
|
status: 404,
|
|
12300
13067
|
message: "Issue not found"
|
|
12301
13068
|
},
|
|
13069
|
+
issue_parent_cycle: {
|
|
13070
|
+
status: 400,
|
|
13071
|
+
message: "Issue cannot be put under one of its own sub-issues"
|
|
13072
|
+
},
|
|
12302
13073
|
issue_parent_not_found: {
|
|
12303
13074
|
status: 404,
|
|
12304
13075
|
message: "Parent issue not found"
|
|
@@ -12631,6 +13402,14 @@ const startRunBody = zod.z.object({
|
|
|
12631
13402
|
]).optional(),
|
|
12632
13403
|
externalSessionRef: zod.z.string().nullable().optional()
|
|
12633
13404
|
});
|
|
13405
|
+
const claimBody = zod.z.object({
|
|
13406
|
+
workspaceId: id,
|
|
13407
|
+
issueId: id.optional(),
|
|
13408
|
+
agentName: id.optional(),
|
|
13409
|
+
branch: zod.z.string().nullable().optional(),
|
|
13410
|
+
executionMode: executionMode.optional(),
|
|
13411
|
+
externalSessionRef: zod.z.string().nullable().optional()
|
|
13412
|
+
});
|
|
12634
13413
|
const finishRunBody = zod.z.object({
|
|
12635
13414
|
state: zod.z.enum([
|
|
12636
13415
|
"finished",
|
|
@@ -12677,13 +13456,24 @@ async function toWrittenCardView(ops, card) {
|
|
|
12677
13456
|
function toStatusView(status) {
|
|
12678
13457
|
return {
|
|
12679
13458
|
...status,
|
|
12680
|
-
entryRules: readEntryRules(status)
|
|
13459
|
+
entryRules: readEntryRules(status),
|
|
13460
|
+
allowedFrom: readFlowRules(status).allowedFrom
|
|
12681
13461
|
};
|
|
12682
13462
|
}
|
|
12683
|
-
function toCommentView(comment) {
|
|
13463
|
+
function toCommentView(comment, personWriterId = null) {
|
|
13464
|
+
if (comment.authorKind === "system.approved" || comment.authorKind === "system.returned") return {
|
|
13465
|
+
...comment,
|
|
13466
|
+
author: {
|
|
13467
|
+
kind: "user",
|
|
13468
|
+
id: comment.authorId ?? "__self__",
|
|
13469
|
+
displayName: "You",
|
|
13470
|
+
avatarUrl: null,
|
|
13471
|
+
label: null
|
|
13472
|
+
}
|
|
13473
|
+
};
|
|
12684
13474
|
return {
|
|
12685
13475
|
...comment,
|
|
12686
|
-
author: resolveActor(commentAuthorReference(comment))
|
|
13476
|
+
author: resolveActor(commentAuthorReference(comment), personWriterId)
|
|
12687
13477
|
};
|
|
12688
13478
|
}
|
|
12689
13479
|
/**
|
|
@@ -12691,23 +13481,34 @@ function toCommentView(comment) {
|
|
|
12691
13481
|
*
|
|
12692
13482
|
* For the kinds that need nobody else's tables: an outside writer is shown
|
|
12693
13483
|
* under the name it gave, the board writing for itself is `kanbo`, and a person
|
|
12694
|
-
* is `You`.
|
|
13484
|
+
* is `You`. So is an outside writer that is the person who started this
|
|
13485
|
+
* server (`personWriterId`): their own writes carry the name their shell files
|
|
13486
|
+
* them under. An agent and a provider target are rows of whatever app launched
|
|
12695
13487
|
* them, which this server cannot read, so they are shown by the id the board
|
|
12696
13488
|
* holds for them.
|
|
12697
13489
|
*/
|
|
12698
|
-
function resolveActor(actor) {
|
|
13490
|
+
function resolveActor(actor, personWriterId) {
|
|
12699
13491
|
if (actor.kind === "system") {
|
|
12700
13492
|
const provenance = {
|
|
12701
13493
|
authorId: actor.id,
|
|
12702
13494
|
sourceChatSessionId: actor.sourceChatSessionId
|
|
12703
13495
|
};
|
|
12704
|
-
if (isOutsideWriter(provenance))
|
|
12705
|
-
|
|
12706
|
-
|
|
12707
|
-
|
|
12708
|
-
|
|
12709
|
-
|
|
12710
|
-
|
|
13496
|
+
if (isOutsideWriter(provenance)) {
|
|
13497
|
+
if (personWriterId !== null && provenance.authorId === personWriterId) return {
|
|
13498
|
+
kind: "user",
|
|
13499
|
+
id: provenance.authorId,
|
|
13500
|
+
displayName: "You",
|
|
13501
|
+
avatarUrl: null,
|
|
13502
|
+
label: null
|
|
13503
|
+
};
|
|
13504
|
+
return {
|
|
13505
|
+
kind: "system",
|
|
13506
|
+
id: provenance.authorId,
|
|
13507
|
+
displayName: provenance.authorId,
|
|
13508
|
+
avatarUrl: null,
|
|
13509
|
+
label: "External"
|
|
13510
|
+
};
|
|
13511
|
+
}
|
|
12711
13512
|
return {
|
|
12712
13513
|
kind: "system",
|
|
12713
13514
|
id: null,
|
|
@@ -12743,13 +13544,13 @@ function resolveActor(actor) {
|
|
|
12743
13544
|
* serves, named by its identity; actors as `resolveActor` reads them; and an
|
|
12744
13545
|
* agent assignee by its id.
|
|
12745
13546
|
*/
|
|
12746
|
-
function createActivityResolver(workspace) {
|
|
13547
|
+
function createActivityResolver(workspace, personWriterId = null) {
|
|
12747
13548
|
return {
|
|
12748
13549
|
workspaceById: /* @__PURE__ */ new Map([[workspace.id, {
|
|
12749
13550
|
id: workspace.id,
|
|
12750
13551
|
name: workspace.name
|
|
12751
13552
|
}]]),
|
|
12752
|
-
resolveActor: async (actor) => resolveActor(actor),
|
|
13553
|
+
resolveActor: async (actor) => resolveActor(actor, personWriterId),
|
|
12753
13554
|
resolveAgentName: async (agentId) => agentId
|
|
12754
13555
|
};
|
|
12755
13556
|
}
|
|
@@ -12770,10 +13571,15 @@ function createActivityResolver(workspace) {
|
|
|
12770
13571
|
const OK = { ok: true };
|
|
12771
13572
|
/** How many cards a search answers with when the caller names no limit. */
|
|
12772
13573
|
const DEFAULT_SEARCH_LIMIT = 20;
|
|
12773
|
-
|
|
13574
|
+
/**
|
|
13575
|
+
* `personWriterId` is the id the person who started the server is filed under;
|
|
13576
|
+
* their writes read as `You`, everyone else's by name.
|
|
13577
|
+
*/
|
|
13578
|
+
function createIssueRoutes(session, options = {}) {
|
|
13579
|
+
const personWriterId = options.personWriterId ?? null;
|
|
12774
13580
|
const { ops, store, workspace } = session;
|
|
12775
13581
|
const workspaceId = workspace.id;
|
|
12776
|
-
const activityResolver = createActivityResolver(workspace);
|
|
13582
|
+
const activityResolver = createActivityResolver(workspace, personWriterId);
|
|
12777
13583
|
/** The served workspace, when that is the one the caller named — or named none. */
|
|
12778
13584
|
function requireWorkspace(named) {
|
|
12779
13585
|
if (named !== void 0 && named !== workspaceId) throw new require_migrations_path.BoardError("issue_workspace_not_found", { workspaceId: named });
|
|
@@ -13058,6 +13864,15 @@ function createIssueRoutes(session) {
|
|
|
13058
13864
|
return updated;
|
|
13059
13865
|
}
|
|
13060
13866
|
}),
|
|
13867
|
+
route({
|
|
13868
|
+
method: "GET",
|
|
13869
|
+
path: "/issues/workspace",
|
|
13870
|
+
handle: async () => ({
|
|
13871
|
+
id: workspace.id,
|
|
13872
|
+
identifier: workspace.identifier,
|
|
13873
|
+
name: workspace.name
|
|
13874
|
+
})
|
|
13875
|
+
}),
|
|
13061
13876
|
route({
|
|
13062
13877
|
method: "GET",
|
|
13063
13878
|
path: "/issues/:id",
|
|
@@ -13077,6 +13892,31 @@ function createIssueRoutes(session) {
|
|
|
13077
13892
|
}, actors.placement));
|
|
13078
13893
|
}
|
|
13079
13894
|
}),
|
|
13895
|
+
route({
|
|
13896
|
+
method: "POST",
|
|
13897
|
+
path: "/issues/claim",
|
|
13898
|
+
body: claimBody,
|
|
13899
|
+
write: true,
|
|
13900
|
+
handle: async ({ body, actors }) => {
|
|
13901
|
+
const named = requireWorkspace(body.workspaceId);
|
|
13902
|
+
if (body.issueId !== void 0) await requireCard(body.issueId);
|
|
13903
|
+
const claimed = await ops.claimCard({
|
|
13904
|
+
workspaceId: named,
|
|
13905
|
+
issueId: body.issueId,
|
|
13906
|
+
run: {
|
|
13907
|
+
agentName: body.agentName ?? actors.writer.id ?? "agent",
|
|
13908
|
+
branch: body.branch,
|
|
13909
|
+
executionMode: body.executionMode,
|
|
13910
|
+
externalSessionRef: body.externalSessionRef
|
|
13911
|
+
}
|
|
13912
|
+
}, actors.placement);
|
|
13913
|
+
return {
|
|
13914
|
+
card: await toWrittenCardView(ops, claimed.card),
|
|
13915
|
+
run: await toRunView(claimed.run),
|
|
13916
|
+
column: toStatusView(claimed.column)
|
|
13917
|
+
};
|
|
13918
|
+
}
|
|
13919
|
+
}),
|
|
13080
13920
|
route({
|
|
13081
13921
|
method: "PATCH",
|
|
13082
13922
|
path: "/issues/bulk",
|
|
@@ -13211,7 +14051,7 @@ function createIssueRoutes(session) {
|
|
|
13211
14051
|
path: "/issues/:id/comments",
|
|
13212
14052
|
handle: async ({ params }) => {
|
|
13213
14053
|
await requireCard(params.id);
|
|
13214
|
-
return (await ops.listComments(params.id)).map(toCommentView);
|
|
14054
|
+
return (await ops.listComments(params.id)).map((comment) => toCommentView(comment, personWriterId));
|
|
13215
14055
|
}
|
|
13216
14056
|
}),
|
|
13217
14057
|
route({
|
|
@@ -13224,7 +14064,7 @@ function createIssueRoutes(session) {
|
|
|
13224
14064
|
return toCommentView(await ops.addComment({
|
|
13225
14065
|
issueId: params.id,
|
|
13226
14066
|
content: body.content
|
|
13227
|
-
}, actors.
|
|
14067
|
+
}, actors.person), personWriterId);
|
|
13228
14068
|
}
|
|
13229
14069
|
}),
|
|
13230
14070
|
route({
|
|
@@ -13353,7 +14193,7 @@ function assertServeBinding(host, token) {
|
|
|
13353
14193
|
}
|
|
13354
14194
|
async function startKanboServer(options) {
|
|
13355
14195
|
assertServeBinding(options.host, options.token);
|
|
13356
|
-
const routes = createIssueRoutes(options.session);
|
|
14196
|
+
const routes = createIssueRoutes(options.session, { personWriterId: options.actors.writer.id });
|
|
13357
14197
|
const listenHost = stripBrackets(options.host);
|
|
13358
14198
|
let boundPort = 0;
|
|
13359
14199
|
const handle = createRequestHandler(routes, options, () => boundPort);
|
|
@@ -13399,6 +14239,11 @@ const SAFE_METHODS = /* @__PURE__ */ new Set([
|
|
|
13399
14239
|
"HEAD",
|
|
13400
14240
|
"OPTIONS"
|
|
13401
14241
|
]);
|
|
14242
|
+
/** What the board page tells the server about itself. */
|
|
14243
|
+
const PRESENCE_BODY = zod.z.object({
|
|
14244
|
+
tabId: zod.z.string().min(1).max(64).regex(/^[A-Za-z0-9-]+$/),
|
|
14245
|
+
state: zod.z.enum(["open", "closed"])
|
|
14246
|
+
});
|
|
13402
14247
|
function createRequestHandler(routes, options, boundPort) {
|
|
13403
14248
|
const token = options.token || null;
|
|
13404
14249
|
const corsOrigins = new Set(options.corsOrigins ?? []);
|
|
@@ -13459,17 +14304,41 @@ function createRequestHandler(routes, options, boundPort) {
|
|
|
13459
14304
|
throw new ServeError(401, "unauthorized", "A valid bearer token is required");
|
|
13460
14305
|
}
|
|
13461
14306
|
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");
|
|
13462
|
-
|
|
13463
|
-
|
|
14307
|
+
if (url.pathname === "/page/presence" && request.method === "POST") {
|
|
14308
|
+
if (!options.presence) {
|
|
14309
|
+
response.writeHead(204).end();
|
|
14310
|
+
return;
|
|
14311
|
+
}
|
|
14312
|
+
const parsed = PRESENCE_BODY.safeParse(await readJsonBody(request));
|
|
14313
|
+
if (!parsed.success) throw validationError("body", parsed.error);
|
|
14314
|
+
if (parsed.data.state === "open") options.presence.seen(parsed.data.tabId);
|
|
14315
|
+
else options.presence.closed(parsed.data.tabId);
|
|
14316
|
+
sendJson(response, 200, { ok: true });
|
|
14317
|
+
return;
|
|
14318
|
+
}
|
|
14319
|
+
const projectPath = readProjectPath(url.pathname);
|
|
14320
|
+
if (url.pathname === "/projects" && request.method === "GET" && options.projects) {
|
|
14321
|
+
sendJson(response, 200, options.projects.list());
|
|
14322
|
+
return;
|
|
14323
|
+
}
|
|
14324
|
+
const matched = projectPath ? null : matchRoute(routes, request.method ?? "GET", url.pathname);
|
|
14325
|
+
if (!projectPath && !matched) throw new ServeError(404, "not_found", "Not Found");
|
|
14326
|
+
if (projectPath && !options.projects) throw new ServeError(404, "not_found", "Not Found");
|
|
13464
14327
|
const body = await readJsonBody(request);
|
|
13465
14328
|
const actors = resolveRequestActors(options.actors, {
|
|
13466
14329
|
actorHeader: request.headers[ACTOR_HEADER],
|
|
13467
14330
|
authenticated: token !== null
|
|
13468
14331
|
});
|
|
13469
14332
|
const result = queue.then(async () => {
|
|
13470
|
-
|
|
13471
|
-
|
|
13472
|
-
|
|
14333
|
+
const target = projectPath && options.projects ? await options.projects.resolve(projectPath.projectId) : {
|
|
14334
|
+
session: options.session,
|
|
14335
|
+
routes
|
|
14336
|
+
};
|
|
14337
|
+
const found = matched ?? matchRoute(target.routes, request.method ?? "GET", projectPath?.rest ?? url.pathname);
|
|
14338
|
+
if (!found) throw new ServeError(404, "not_found", "Not Found");
|
|
14339
|
+
if (found.route.write) await target.session.assertWritable();
|
|
14340
|
+
return await found.route.run({
|
|
14341
|
+
params: found.params,
|
|
13473
14342
|
query: readQuery(url.searchParams),
|
|
13474
14343
|
body,
|
|
13475
14344
|
actors
|
|
@@ -13495,6 +14364,21 @@ function createRequestHandler(routes, options, boundPort) {
|
|
|
13495
14364
|
}
|
|
13496
14365
|
};
|
|
13497
14366
|
}
|
|
14367
|
+
/** `/projects/<id>/issues/...` as the project's id and the `/issues/...` path under it; `null` for any other path. */
|
|
14368
|
+
function readProjectPath(pathname) {
|
|
14369
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
14370
|
+
if (segments[0] !== "projects" || segments.length < 3 || segments[2] !== "issues") return null;
|
|
14371
|
+
let projectId;
|
|
14372
|
+
try {
|
|
14373
|
+
projectId = decodeURIComponent(segments[1]);
|
|
14374
|
+
} catch {
|
|
14375
|
+
return null;
|
|
14376
|
+
}
|
|
14377
|
+
return {
|
|
14378
|
+
projectId,
|
|
14379
|
+
rest: `/${segments.slice(2).join("/")}`
|
|
14380
|
+
};
|
|
14381
|
+
}
|
|
13498
14382
|
function isJsonContentType(value) {
|
|
13499
14383
|
return value?.split(";")[0].trim().toLowerCase() === "application/json";
|
|
13500
14384
|
}
|
|
@@ -13638,6 +14522,146 @@ function readTextOrNull(path) {
|
|
|
13638
14522
|
}
|
|
13639
14523
|
}
|
|
13640
14524
|
//#endregion
|
|
14525
|
+
//#region src/serve/page-presence.ts
|
|
14526
|
+
function createPagePresence(options) {
|
|
14527
|
+
const closedGraceMs = options.closedGraceMs ?? 5e3;
|
|
14528
|
+
const staleMs = options.staleMs ?? 12e4;
|
|
14529
|
+
const tickMs = options.tickMs ?? 1e4;
|
|
14530
|
+
const now = options.now ?? Date.now;
|
|
14531
|
+
const tabs = /* @__PURE__ */ new Map();
|
|
14532
|
+
let finished = false;
|
|
14533
|
+
let graceTimer = null;
|
|
14534
|
+
let lastTick = now();
|
|
14535
|
+
function stop() {
|
|
14536
|
+
finished = true;
|
|
14537
|
+
clearInterval(ticker);
|
|
14538
|
+
if (graceTimer) {
|
|
14539
|
+
clearTimeout(graceTimer);
|
|
14540
|
+
graceTimer = null;
|
|
14541
|
+
}
|
|
14542
|
+
}
|
|
14543
|
+
function idle() {
|
|
14544
|
+
if (finished) return;
|
|
14545
|
+
stop();
|
|
14546
|
+
options.onIdle();
|
|
14547
|
+
}
|
|
14548
|
+
const ticker = setInterval(() => {
|
|
14549
|
+
const current = now();
|
|
14550
|
+
const asleep = current - lastTick > 3 * tickMs;
|
|
14551
|
+
lastTick = current;
|
|
14552
|
+
if (asleep) {
|
|
14553
|
+
for (const id of tabs.keys()) tabs.set(id, current);
|
|
14554
|
+
return;
|
|
14555
|
+
}
|
|
14556
|
+
if (tabs.size === 0) return;
|
|
14557
|
+
for (const [id, lastSeen] of tabs) if (current - lastSeen > staleMs) tabs.delete(id);
|
|
14558
|
+
if (tabs.size === 0) idle();
|
|
14559
|
+
}, tickMs);
|
|
14560
|
+
ticker.unref();
|
|
14561
|
+
return {
|
|
14562
|
+
seen: (tabId) => {
|
|
14563
|
+
if (!finished) tabs.set(tabId, now());
|
|
14564
|
+
},
|
|
14565
|
+
closed: (tabId) => {
|
|
14566
|
+
if (finished || !tabs.delete(tabId) || tabs.size > 0) return;
|
|
14567
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
14568
|
+
graceTimer = setTimeout(() => {
|
|
14569
|
+
graceTimer = null;
|
|
14570
|
+
if (tabs.size === 0) idle();
|
|
14571
|
+
}, closedGraceMs);
|
|
14572
|
+
graceTimer.unref();
|
|
14573
|
+
},
|
|
14574
|
+
stop
|
|
14575
|
+
};
|
|
14576
|
+
}
|
|
14577
|
+
//#endregion
|
|
14578
|
+
//#region src/serve/projects.ts
|
|
14579
|
+
function createServeProjects(input) {
|
|
14580
|
+
const routesOptions = { personWriterId: input.personWriterId ?? null };
|
|
14581
|
+
const opened = /* @__PURE__ */ new Map();
|
|
14582
|
+
let defaultOpened = null;
|
|
14583
|
+
/** Every project that can be offered right now, default first, with the folder each lives in. */
|
|
14584
|
+
function entries() {
|
|
14585
|
+
const defaultDir = input.defaultProject?.dir ?? null;
|
|
14586
|
+
const dirs = [...defaultDir === null ? [] : [defaultDir], ...input.readRegistry().map((project) => project.path).filter((path) => path !== defaultDir)];
|
|
14587
|
+
const found = [];
|
|
14588
|
+
for (const dir of dirs) {
|
|
14589
|
+
const binding = input.readBinding(dir);
|
|
14590
|
+
if (binding === null && dir !== defaultDir) continue;
|
|
14591
|
+
found.push({
|
|
14592
|
+
view: viewOf(dir, binding, dir === defaultDir),
|
|
14593
|
+
dir
|
|
14594
|
+
});
|
|
14595
|
+
}
|
|
14596
|
+
return found;
|
|
14597
|
+
}
|
|
14598
|
+
return {
|
|
14599
|
+
list: () => entries().map((entry) => entry.view),
|
|
14600
|
+
resolve: async (projectId) => {
|
|
14601
|
+
const entry = entries().find((candidate) => candidate.view.id === projectId);
|
|
14602
|
+
if (!entry) throw new ServeError(404, "project_not_found", "No project with that id is on this computer's list");
|
|
14603
|
+
if (entry.view.isDefault && input.defaultProject) {
|
|
14604
|
+
defaultOpened ??= {
|
|
14605
|
+
session: input.defaultProject.session,
|
|
14606
|
+
routes: createIssueRoutes(input.defaultProject.session, routesOptions)
|
|
14607
|
+
};
|
|
14608
|
+
return defaultOpened;
|
|
14609
|
+
}
|
|
14610
|
+
const cached = opened.get(projectId);
|
|
14611
|
+
if (cached) return cached;
|
|
14612
|
+
let session;
|
|
14613
|
+
try {
|
|
14614
|
+
session = await input.openSession(entry.dir);
|
|
14615
|
+
} catch (error) {
|
|
14616
|
+
throw new ServeError(503, "project_unavailable", describeFailure(error).message);
|
|
14617
|
+
}
|
|
14618
|
+
const project = {
|
|
14619
|
+
session,
|
|
14620
|
+
routes: createIssueRoutes(session, routesOptions)
|
|
14621
|
+
};
|
|
14622
|
+
opened.set(projectId, project);
|
|
14623
|
+
return project;
|
|
14624
|
+
},
|
|
14625
|
+
close: async () => {
|
|
14626
|
+
const sessions = [...opened.values()].map((project) => project.session);
|
|
14627
|
+
opened.clear();
|
|
14628
|
+
await Promise.allSettled(sessions.map(async (session) => await session.close()));
|
|
14629
|
+
}
|
|
14630
|
+
};
|
|
14631
|
+
}
|
|
14632
|
+
function projectId(dir) {
|
|
14633
|
+
return (0, node_crypto.createHash)("sha256").update(dir).digest("hex").slice(0, 12);
|
|
14634
|
+
}
|
|
14635
|
+
function viewOf(dir, binding, isDefault) {
|
|
14636
|
+
return {
|
|
14637
|
+
id: projectId(dir),
|
|
14638
|
+
name: dir.split(/[\\/]/).filter(Boolean).pop() ?? dir,
|
|
14639
|
+
folder: tildify(dir),
|
|
14640
|
+
storage: storageOf(dir, binding),
|
|
14641
|
+
isDefault
|
|
14642
|
+
};
|
|
14643
|
+
}
|
|
14644
|
+
/** Where the board lives. The connection string is read here to name its host, and goes no further. */
|
|
14645
|
+
function storageOf(dir, binding) {
|
|
14646
|
+
if (binding?.databaseUrl) return {
|
|
14647
|
+
kind: "postgres",
|
|
14648
|
+
where: hostOf(maskDatabaseUrl(binding.databaseUrl))
|
|
14649
|
+
};
|
|
14650
|
+
return {
|
|
14651
|
+
kind: "file",
|
|
14652
|
+
path: tildify((0, node_path.resolve)(dir, binding?.dbPath ?? ".kanbo/board.db"))
|
|
14653
|
+
};
|
|
14654
|
+
}
|
|
14655
|
+
/** The host of an already masked connection string; a string that is not one names no host. */
|
|
14656
|
+
function hostOf(maskedUrl) {
|
|
14657
|
+
try {
|
|
14658
|
+
const { host } = new URL(maskedUrl);
|
|
14659
|
+
return host && !host.includes("*") ? host : "a Postgres database";
|
|
14660
|
+
} catch {
|
|
14661
|
+
return "a Postgres database";
|
|
14662
|
+
}
|
|
14663
|
+
}
|
|
14664
|
+
//#endregion
|
|
13641
14665
|
//#region src/cli/commands/serve.ts
|
|
13642
14666
|
function registerServeCommand(program) {
|
|
13643
14667
|
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) => {
|
|
@@ -13645,7 +14669,7 @@ function registerServeCommand(program) {
|
|
|
13645
14669
|
status: (line) => console.error(line),
|
|
13646
14670
|
page: (line) => console.log(line)
|
|
13647
14671
|
});
|
|
13648
|
-
await
|
|
14672
|
+
if (await waitForStop(running.idle) === "idle") console.error("kanbo serve: the last board tab was closed — stopped.");
|
|
13649
14673
|
try {
|
|
13650
14674
|
await running.close();
|
|
13651
14675
|
process.exit(0);
|
|
@@ -13657,26 +14681,46 @@ function registerServeCommand(program) {
|
|
|
13657
14681
|
}
|
|
13658
14682
|
/**
|
|
13659
14683
|
* Open the board and serve it — everything `kanbo serve` does but wait. The
|
|
13660
|
-
* command waits for Ctrl-C
|
|
13661
|
-
*
|
|
14684
|
+
* command waits for Ctrl-C (or the last board tab closing) and ends the
|
|
14685
|
+
* process; the home menu waits the same and comes back to its menu.
|
|
13662
14686
|
*/
|
|
13663
14687
|
async function startServe(options, output) {
|
|
13664
14688
|
const port = parsePort(options.port);
|
|
13665
14689
|
const access = resolveServeAccess(options.host, options.token?.trim() || process.env.KANBO_SERVE_TOKEN?.trim() || null);
|
|
14690
|
+
const here = findBinding(process.cwd());
|
|
14691
|
+
const mostRecent = here === null && namesNoBoard(options) ? readProjectRegistry()[0] : void 0;
|
|
14692
|
+
const defaultDir = here?.projectDir ?? mostRecent?.path ?? null;
|
|
13666
14693
|
const session = await openBoardSession({
|
|
13667
14694
|
db: options.db,
|
|
13668
14695
|
databaseUrl: options.databaseUrl,
|
|
13669
14696
|
workspace: options.workspace
|
|
13670
|
-
}, "read");
|
|
14697
|
+
}, "read", mostRecent?.path);
|
|
13671
14698
|
const agentShell = isAgentShell();
|
|
13672
14699
|
const actors = {
|
|
13673
|
-
writer:
|
|
14700
|
+
writer: createServeWriter(),
|
|
13674
14701
|
person: agentShell ? null : createPersonActor()
|
|
13675
14702
|
};
|
|
14703
|
+
const projects = createServeProjects({
|
|
14704
|
+
defaultProject: defaultDir === null ? null : {
|
|
14705
|
+
dir: defaultDir,
|
|
14706
|
+
session
|
|
14707
|
+
},
|
|
14708
|
+
readRegistry: () => readProjectRegistry(),
|
|
14709
|
+
readBinding: (dir) => readProjectBinding(dir),
|
|
14710
|
+
openSession: async (dir) => await openBoardSession({}, "read", dir),
|
|
14711
|
+
personWriterId: actors.writer.id
|
|
14712
|
+
});
|
|
14713
|
+
const opensBrowser = !agentShell && process.stdout.isTTY === true && options.open;
|
|
14714
|
+
let presence;
|
|
14715
|
+
const idle = new Promise((resolve) => {
|
|
14716
|
+
presence = opensBrowser ? createPagePresence({ onIdle: resolve }) : void 0;
|
|
14717
|
+
});
|
|
13676
14718
|
let server;
|
|
13677
14719
|
try {
|
|
13678
14720
|
server = await startKanboServer({
|
|
13679
14721
|
session,
|
|
14722
|
+
projects,
|
|
14723
|
+
presence,
|
|
13680
14724
|
actors,
|
|
13681
14725
|
host: options.host,
|
|
13682
14726
|
port,
|
|
@@ -13684,10 +14728,12 @@ async function startServe(options, output) {
|
|
|
13684
14728
|
corsOrigins: options.corsOrigin
|
|
13685
14729
|
});
|
|
13686
14730
|
} catch (error) {
|
|
14731
|
+
presence?.stop();
|
|
13687
14732
|
await session.close();
|
|
13688
14733
|
throw error;
|
|
13689
14734
|
}
|
|
13690
|
-
|
|
14735
|
+
const projectCount = projects.list().length;
|
|
14736
|
+
output.status(`kanbo serve: ${server.url} — workspace ${session.workspace.id}, ${projectCount} ${projectCount === 1 ? "project" : "projects"}, bearer token required${access.generated ? " (generated for this run)" : ""}`);
|
|
13691
14737
|
output.status(`kanbo serve: ${describePersonRights(actors)}`);
|
|
13692
14738
|
presentServePage({
|
|
13693
14739
|
url: server.url,
|
|
@@ -13701,25 +14747,40 @@ async function startServe(options, output) {
|
|
|
13701
14747
|
});
|
|
13702
14748
|
return {
|
|
13703
14749
|
link: access.generated ? `${server.url}/#token=${access.token}` : `${server.url}/`,
|
|
14750
|
+
idle,
|
|
13704
14751
|
close: async () => {
|
|
14752
|
+
presence?.stop();
|
|
13705
14753
|
try {
|
|
13706
14754
|
await server.close();
|
|
13707
14755
|
} finally {
|
|
13708
|
-
|
|
14756
|
+
try {
|
|
14757
|
+
await projects.close();
|
|
14758
|
+
} finally {
|
|
14759
|
+
await session.close();
|
|
14760
|
+
}
|
|
13709
14761
|
}
|
|
13710
14762
|
}
|
|
13711
14763
|
};
|
|
13712
14764
|
}
|
|
13713
|
-
/**
|
|
13714
|
-
function
|
|
14765
|
+
/** Whether neither a flag nor the environment says which board to open. */
|
|
14766
|
+
function namesNoBoard(options) {
|
|
14767
|
+
return !options.db && !options.databaseUrl && !options.workspace && !process.env.KANBO_DB_PATH && !process.env.KANBO_DATABASE_URL && !process.env.KANBO_WORKSPACE_ID;
|
|
14768
|
+
}
|
|
14769
|
+
/**
|
|
14770
|
+
* Settles on the first Ctrl-C (SIGINT) or SIGTERM, or when `idle` settles, and
|
|
14771
|
+
* stops listening for both signals whichever it was.
|
|
14772
|
+
*/
|
|
14773
|
+
function waitForStop(idle) {
|
|
13715
14774
|
return new Promise((resolve) => {
|
|
13716
|
-
const
|
|
13717
|
-
process.off("SIGINT",
|
|
13718
|
-
process.off("SIGTERM",
|
|
13719
|
-
resolve();
|
|
14775
|
+
const finish = (why) => {
|
|
14776
|
+
process.off("SIGINT", onSignal);
|
|
14777
|
+
process.off("SIGTERM", onSignal);
|
|
14778
|
+
resolve(why);
|
|
13720
14779
|
};
|
|
13721
|
-
|
|
13722
|
-
process.on("
|
|
14780
|
+
const onSignal = () => finish("interrupt");
|
|
14781
|
+
process.on("SIGINT", onSignal);
|
|
14782
|
+
process.on("SIGTERM", onSignal);
|
|
14783
|
+
idle.then(() => finish("idle"));
|
|
13723
14784
|
});
|
|
13724
14785
|
}
|
|
13725
14786
|
function collect(value, previous) {
|
|
@@ -13990,6 +15051,7 @@ function registerKanboCommands(program, context = {}) {
|
|
|
13990
15051
|
registerServeCommand(program);
|
|
13991
15052
|
program.commandsGroup("For agents and integrations:");
|
|
13992
15053
|
registerMcpCommand(program);
|
|
15054
|
+
registerClaimCommand(program);
|
|
13993
15055
|
registerRunCommands(program);
|
|
13994
15056
|
registerCapabilitiesCommand(program);
|
|
13995
15057
|
program.commandsGroup("Shared Postgres boards:");
|
|
@@ -14013,6 +15075,7 @@ function registerKanboCommands(program, context = {}) {
|
|
|
14013
15075
|
const KANBO_TOOL_NAMES = [
|
|
14014
15076
|
"kanbo_prime",
|
|
14015
15077
|
"kanbo_ready",
|
|
15078
|
+
"kanbo_card_claim",
|
|
14016
15079
|
"kanbo_columns",
|
|
14017
15080
|
"kanbo_sprints",
|
|
14018
15081
|
"kanbo_card_get",
|
|
@@ -14038,12 +15101,13 @@ const KANBO_TOOL_NAMES = [
|
|
|
14038
15101
|
const KANBO_TOOL_CLI_EQUIVALENTS = {
|
|
14039
15102
|
kanbo_prime: { kanbo: "kanbo prime" },
|
|
14040
15103
|
kanbo_ready: { kanbo: "kanbo ready" },
|
|
15104
|
+
kanbo_card_claim: { kanbo: "kanbo claim [card] [--agent <name>]" },
|
|
14041
15105
|
kanbo_columns: { kanbo: "kanbo columns list" },
|
|
14042
15106
|
kanbo_sprints: { kanbo: "kanbo sprint list" },
|
|
14043
15107
|
kanbo_card_get: { kanbo: "kanbo card get <id>" },
|
|
14044
15108
|
kanbo_card_list: { kanbo: "kanbo card list" },
|
|
14045
15109
|
kanbo_card_create: { kanbo: "kanbo card create --description <text> [--title <title>] [--parent <id>]" },
|
|
14046
|
-
kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>]" },
|
|
15110
|
+
kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>] [--parent <id>]" },
|
|
14047
15111
|
kanbo_card_move: { kanbo: "kanbo card move <id> <column>" },
|
|
14048
15112
|
kanbo_card_comment: { kanbo: "kanbo card comment <id> --content <text>" },
|
|
14049
15113
|
kanbo_card_link_pr: { kanbo: "kanbo card pr add <id> <url>" },
|
|
@@ -14185,10 +15249,12 @@ function buildCapabilitiesManifest() {
|
|
|
14185
15249
|
"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.",
|
|
14186
15250
|
`The agent role (\`${require_roles_sql.BOARD_AGENT_ROLE}\`) may not delete a card at all — it holds select, insert and update on \`issues\` and nothing more, even though it may delete freely on every other board table.`,
|
|
14187
15251
|
"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.",
|
|
15252
|
+
"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.",
|
|
15253
|
+
"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.",
|
|
14188
15254
|
"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.",
|
|
14189
15255
|
"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).",
|
|
14190
15256
|
"`kanbo migrate` and `kanbo roles apply` refuse a host app's own database file: the app migrates it, and this package never touches it.",
|
|
14191
|
-
"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)
|
|
15257
|
+
"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.",
|
|
14192
15258
|
"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."
|
|
14193
15259
|
]
|
|
14194
15260
|
};
|
|
@@ -14278,6 +15344,12 @@ Object.defineProperty(exports, "ENTRY_RULE_DESCRIPTIONS", {
|
|
|
14278
15344
|
return ENTRY_RULE_DESCRIPTIONS;
|
|
14279
15345
|
}
|
|
14280
15346
|
});
|
|
15347
|
+
Object.defineProperty(exports, "FLOW_RULES", {
|
|
15348
|
+
enumerable: true,
|
|
15349
|
+
get: function() {
|
|
15350
|
+
return FLOW_RULES;
|
|
15351
|
+
}
|
|
15352
|
+
});
|
|
14281
15353
|
Object.defineProperty(exports, "KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI", {
|
|
14282
15354
|
enumerable: true,
|
|
14283
15355
|
get: function() {
|
|
@@ -14608,6 +15680,12 @@ Object.defineProperty(exports, "readEntryRules", {
|
|
|
14608
15680
|
return readEntryRules;
|
|
14609
15681
|
}
|
|
14610
15682
|
});
|
|
15683
|
+
Object.defineProperty(exports, "readFlowRules", {
|
|
15684
|
+
enumerable: true,
|
|
15685
|
+
get: function() {
|
|
15686
|
+
return readFlowRules;
|
|
15687
|
+
}
|
|
15688
|
+
});
|
|
14611
15689
|
Object.defineProperty(exports, "readIssuePrefix", {
|
|
14612
15690
|
enumerable: true,
|
|
14613
15691
|
get: function() {
|