kanbo-cli 0.4.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 +19 -0
- package/dist/cli-main.cjs +722 -111
- 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-DcIMYvWk.cjs → manifest-DUGRyC7A.cjs} +708 -112
- package/dist/lib/{manifest-Dmo6IlWk.mjs → manifest-t4LvFgzH.mjs} +697 -113
- package/dist/lib/mcp/index.cjs +24 -4
- package/dist/lib/mcp/index.d.cts +19 -4
- package/dist/lib/mcp/index.d.mts +19 -4
- package/dist/lib/mcp/index.mjs +24 -4
- 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/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { a as BoardError } from "./migrations-path-ClaYRNwk.mjs";
|
|
2
|
-
import { c as getBoardMigrationsPath, i as BOARD_PERSON_ROLE, l as createPostgresBoardStore, n as AGENT_WAITING_REFUSAL, o as BOARD_ROLE_SQL, r as BOARD_AGENT_ROLE, s as assertBoardSchema, t as AGENT_APPROVAL_COMMENT_REFUSAL } from "./roles.sql-
|
|
3
|
-
import { a as openBoardDatabase, c as boardQueryLogger, d as foldCase, i as markBoardFileOwnedByKanbo, l as createSqliteBoardStore, n as migrateBoardFile, o as readBoardFileContents, r as assertBoardSchema$1 } from "./migrate-
|
|
2
|
+
import { c as getBoardMigrationsPath, i as BOARD_PERSON_ROLE, l as createPostgresBoardStore, n as AGENT_WAITING_REFUSAL, o as BOARD_ROLE_SQL, r as BOARD_AGENT_ROLE, s as assertBoardSchema, t as AGENT_APPROVAL_COMMENT_REFUSAL } from "./roles.sql-BDmmNUdx.mjs";
|
|
3
|
+
import { a as openBoardDatabase, c as boardQueryLogger, d as foldCase, i as markBoardFileOwnedByKanbo, l as createSqliteBoardStore, n as migrateBoardFile, o as readBoardFileContents, r as assertBoardSchema$1 } from "./migrate-BUsaTJlj.mjs";
|
|
4
4
|
import { Command, Option } from "commander";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { homedir, hostname, userInfo } from "node:os";
|
|
@@ -271,6 +271,7 @@ const HUMAN_ONLY_ACTIONS = [
|
|
|
271
271
|
"return",
|
|
272
272
|
"sprint close",
|
|
273
273
|
"columns rules",
|
|
274
|
+
"columns flow",
|
|
274
275
|
"columns add",
|
|
275
276
|
"columns rename",
|
|
276
277
|
"columns move",
|
|
@@ -284,6 +285,7 @@ const HUMAN_ONLY_MESSAGES = {
|
|
|
284
285
|
"return": describeReturnRefusal,
|
|
285
286
|
"sprint close": describeSprintCloseRefusal,
|
|
286
287
|
"columns rules": describeColumnRulesRefusal,
|
|
288
|
+
"columns flow": describeColumnRulesRefusal,
|
|
287
289
|
"columns add": describeColumnChangesRefusal,
|
|
288
290
|
"columns rename": describeColumnChangesRefusal,
|
|
289
291
|
"columns move": describeColumnChangesRefusal,
|
|
@@ -764,6 +766,14 @@ const HIDDEN_ACTIVITY_FIELDS = /* @__PURE__ */ new Set([
|
|
|
764
766
|
"order",
|
|
765
767
|
"waitingFor"
|
|
766
768
|
]);
|
|
769
|
+
/**
|
|
770
|
+
* The column a card was created in, recorded as a change from no column at the
|
|
771
|
+
* card's own creation time. The feed's `created` item already stands for it,
|
|
772
|
+
* and the field history keeps it.
|
|
773
|
+
*/
|
|
774
|
+
function isStartingColumn(change, issue) {
|
|
775
|
+
return change.field === "statusId" && change.fromValue === null && change.createdAt === issue.createdAt;
|
|
776
|
+
}
|
|
767
777
|
const ACTIVITY_FIELD_BY_RAW_FIELD = {
|
|
768
778
|
assigneeId: "assignee",
|
|
769
779
|
description: "description",
|
|
@@ -804,7 +814,7 @@ async function listActivity(store, issueId, resolver) {
|
|
|
804
814
|
workspaceById: resolver.workspaceById
|
|
805
815
|
};
|
|
806
816
|
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);
|
|
817
|
+
const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).filter((change) => !isStartingColumn(change, issue)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
|
|
808
818
|
return [
|
|
809
819
|
{
|
|
810
820
|
id: `${issue.id}:created`,
|
|
@@ -1347,7 +1357,9 @@ const UNMET_ENTRY_RULE_REASONS = [
|
|
|
1347
1357
|
"ci_red",
|
|
1348
1358
|
"approval_pending",
|
|
1349
1359
|
"returned",
|
|
1350
|
-
"not_approved"
|
|
1360
|
+
"not_approved",
|
|
1361
|
+
"not_allowed_from",
|
|
1362
|
+
"column_full"
|
|
1351
1363
|
];
|
|
1352
1364
|
const entryRulesSchema = z.array(z.enum(ENTRY_RULES));
|
|
1353
1365
|
/**
|
|
@@ -1494,6 +1506,65 @@ function describeColumnRefusal(details) {
|
|
|
1494
1506
|
return [`board_column_rules_unmet: ${details.issueId ? `${details.issueId} cannot enter` : "A new card cannot be created in"} "${details.column}" yet:`, ...details.unmet.map((item) => `- ${item.rule}: ${item.detail}`)].join("\n");
|
|
1495
1507
|
}
|
|
1496
1508
|
//#endregion
|
|
1509
|
+
//#region src/domain/flow-rules.ts
|
|
1510
|
+
/** The two flow rules, as an unmet entry names them. */
|
|
1511
|
+
const FLOW_RULES = ["allowed_from", "wip_limit"];
|
|
1512
|
+
/** The column the standard flow guards, and the columns it lets cards in from, by slug. */
|
|
1513
|
+
const STANDARD_FLOW_TARGET = "done";
|
|
1514
|
+
const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
|
|
1515
|
+
/** A column's flow rules, out of what the row stores. */
|
|
1516
|
+
function readFlowRules(column) {
|
|
1517
|
+
return {
|
|
1518
|
+
allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
|
|
1519
|
+
wipLimit: column.wipLimit
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
/** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
|
|
1523
|
+
function serializeAllowedFrom(allowedFrom) {
|
|
1524
|
+
return allowedFrom === null ? null : JSON.stringify(allowedFrom);
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* The flow rules a card entering `column` does not meet, where it may come from
|
|
1528
|
+
* first. Pure: what the board holds arrives in `facts`. A card being created
|
|
1529
|
+
* comes from no column, so a column that names where cards come from does not
|
|
1530
|
+
* take it.
|
|
1531
|
+
*/
|
|
1532
|
+
function evaluateFlowRules(card, column, rules, facts) {
|
|
1533
|
+
const unmet = [];
|
|
1534
|
+
if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
|
|
1535
|
+
rule: "allowed_from",
|
|
1536
|
+
reason: "not_allowed_from",
|
|
1537
|
+
detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
|
|
1538
|
+
});
|
|
1539
|
+
if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
|
|
1540
|
+
rule: "wip_limit",
|
|
1541
|
+
reason: "column_full",
|
|
1542
|
+
detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
|
|
1543
|
+
});
|
|
1544
|
+
return unmet;
|
|
1545
|
+
}
|
|
1546
|
+
function describeAllowedFrom(name, allowedFrom, columnNames) {
|
|
1547
|
+
const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
|
|
1548
|
+
return names.length === 0 ? `${name} accepts cards from no column; only a person may put a card here` : `${name} accepts cards from ${joinWords(names)} only`;
|
|
1549
|
+
}
|
|
1550
|
+
/** `A`, `A and B`, `A, B and C`. */
|
|
1551
|
+
function joinWords(words) {
|
|
1552
|
+
return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1555
|
+
* The standard flow on these columns: Done, and the columns it takes cards from
|
|
1556
|
+
* — In Review, and QA when there is one — in board order. `null` when the board
|
|
1557
|
+
* has no Done, or neither of the two.
|
|
1558
|
+
*/
|
|
1559
|
+
function standardFlowOf(columns) {
|
|
1560
|
+
const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
|
|
1561
|
+
const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
|
|
1562
|
+
return target && sources.length > 0 ? {
|
|
1563
|
+
target,
|
|
1564
|
+
sources
|
|
1565
|
+
} : null;
|
|
1566
|
+
}
|
|
1567
|
+
//#endregion
|
|
1497
1568
|
//#region src/ops/columns.ts
|
|
1498
1569
|
/** The columns of one workspace, in board order. */
|
|
1499
1570
|
async function listColumns(store, workspaceId) {
|
|
@@ -1563,6 +1634,7 @@ async function applyColumnTemplate(store, workspaceId, columns, options, scope)
|
|
|
1563
1634
|
workspaceId,
|
|
1564
1635
|
...column
|
|
1565
1636
|
}, order);
|
|
1637
|
+
await writeStandardFlow(tx, await listColumns(tx, workspaceId));
|
|
1566
1638
|
return {
|
|
1567
1639
|
statuses: await listColumns(tx, workspaceId),
|
|
1568
1640
|
added: columns.map((column) => column.name)
|
|
@@ -1672,6 +1744,80 @@ async function setColumnEntryRules(store, workspaceId, nameOrId, entryRules, act
|
|
|
1672
1744
|
return await updateColumn(tx, (await requireColumn(tx, workspaceId, nameOrId)).id, { entryRules }, actor, { tx });
|
|
1673
1745
|
}, scope);
|
|
1674
1746
|
}
|
|
1747
|
+
/** The largest WIP limit: what a Postgres `integer` column holds. */
|
|
1748
|
+
const MAX_WIP_LIMIT = 2147483647;
|
|
1749
|
+
/**
|
|
1750
|
+
* Set where a card may enter the column from and how many cards it holds:
|
|
1751
|
+
* `allowedFrom` the ids of other columns of its board (`null` for any, `[]` for
|
|
1752
|
+
* none), kept once each in board order; `wipLimit` a whole number from 1 to 2147483647, or `null`. A
|
|
1753
|
+
* person's to set, like the entry rules: an agent that could open a column
|
|
1754
|
+
* could walk any card into it.
|
|
1755
|
+
*/
|
|
1756
|
+
async function setColumnFlowRules(store, columnId, rules, actor, scope) {
|
|
1757
|
+
assertPersonSetsEntryRules(actor, { statusId: columnId });
|
|
1758
|
+
if (rules.wipLimit !== null && !(Number.isSafeInteger(rules.wipLimit) && rules.wipLimit >= 1 && rules.wipLimit <= MAX_WIP_LIMIT)) throw new BoardError("board_flow_rule_invalid", {
|
|
1759
|
+
statusId: columnId,
|
|
1760
|
+
wipLimit: rules.wipLimit
|
|
1761
|
+
});
|
|
1762
|
+
return await runBoardWrite(store, async ({ tx }) => {
|
|
1763
|
+
const column = await tx.statuses.findById(columnId);
|
|
1764
|
+
if (!column) throw new BoardError("issue_status_not_found", { statusId: columnId });
|
|
1765
|
+
const allowedFrom = rules.allowedFrom === null ? null : allowedFromInBoardOrder(await listColumns(tx, column.workspaceId), column, rules.allowedFrom);
|
|
1766
|
+
await tx.statuses.update(columnId, {
|
|
1767
|
+
allowedFrom: serializeAllowedFrom(allowedFrom),
|
|
1768
|
+
wipLimit: rules.wipLimit
|
|
1769
|
+
});
|
|
1770
|
+
return await tx.statuses.findById(columnId);
|
|
1771
|
+
}, scope);
|
|
1772
|
+
}
|
|
1773
|
+
/** The ids asked for, once each and in board order — or the refusal naming those that are not another column of this board. */
|
|
1774
|
+
function allowedFromInBoardOrder(columns, column, requested) {
|
|
1775
|
+
const others = columns.filter((other) => other.id !== column.id);
|
|
1776
|
+
const invalid = requested.filter((id) => !others.some((other) => other.id === id));
|
|
1777
|
+
if (invalid.length > 0) throw new BoardError("board_flow_rule_invalid", {
|
|
1778
|
+
statusId: column.id,
|
|
1779
|
+
allowedFrom: invalid
|
|
1780
|
+
});
|
|
1781
|
+
return others.filter((other) => requested.includes(other.id)).map((other) => other.id);
|
|
1782
|
+
}
|
|
1783
|
+
/**
|
|
1784
|
+
* Put the standard flow on a board: Done accepts cards from In Review only —
|
|
1785
|
+
* and from QA, when the board has it — so nothing reaches Done but through
|
|
1786
|
+
* review. Done's WIP limit and every other column stay as they are. A board
|
|
1787
|
+
* without Done, or with neither In Review nor QA, has nothing to apply it to.
|
|
1788
|
+
*/
|
|
1789
|
+
async function applyStandardFlow(store, workspaceId, actor, scope) {
|
|
1790
|
+
assertPersonSetsEntryRules(actor, { workspaceId });
|
|
1791
|
+
return await runBoardWrite(store, async ({ tx }) => {
|
|
1792
|
+
const columns = await listColumns(tx, workspaceId);
|
|
1793
|
+
if (!await writeStandardFlow(tx, columns)) {
|
|
1794
|
+
const hasDone = columns.some((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
|
|
1795
|
+
throw new BoardError("issue_status_not_found", {
|
|
1796
|
+
workspaceId,
|
|
1797
|
+
statusName: hasDone ? "In Review" : "Done"
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
return await listColumns(tx, workspaceId);
|
|
1801
|
+
}, scope);
|
|
1802
|
+
}
|
|
1803
|
+
/** Done takes cards from the board's In Review and QA; `false`, with nothing written, when the board has no such columns. */
|
|
1804
|
+
async function writeStandardFlow(store, columns) {
|
|
1805
|
+
const flow = standardFlowOf(columns);
|
|
1806
|
+
if (!flow) return false;
|
|
1807
|
+
await store.statuses.update(flow.target.id, { allowedFrom: serializeAllowedFrom(flow.sources.map((column) => column.id)) });
|
|
1808
|
+
return true;
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* Take a deleted column out of every list of where cards may come from. A list
|
|
1812
|
+
* it empties stays empty: a column that took cards only from it does not open
|
|
1813
|
+
* to every column.
|
|
1814
|
+
*/
|
|
1815
|
+
async function dropFromAllowedFrom(store, workspaceId, statusId) {
|
|
1816
|
+
for (const other of await listColumns(store, workspaceId)) {
|
|
1817
|
+
const { allowedFrom } = readFlowRules(other);
|
|
1818
|
+
if (allowedFrom?.includes(statusId)) await store.statuses.update(other.id, { allowedFrom: serializeAllowedFrom(allowedFrom.filter((id) => id !== statusId)) });
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1675
1821
|
/**
|
|
1676
1822
|
* Take an empty column off the board, and number the columns left 0..n-1 in
|
|
1677
1823
|
* the same write.
|
|
@@ -1698,6 +1844,7 @@ async function deleteColumn(store, statusId, scope, actor) {
|
|
|
1698
1844
|
hint: "Move its cards to another column first, or remove it with removeColumn and moveCardsTo."
|
|
1699
1845
|
});
|
|
1700
1846
|
await tx.statuses.delete(statusId);
|
|
1847
|
+
await dropFromAllowedFrom(tx, column.workspaceId, statusId);
|
|
1701
1848
|
await renumberColumns(tx, column.workspaceId);
|
|
1702
1849
|
}, scope);
|
|
1703
1850
|
}
|
|
@@ -1977,24 +2124,46 @@ function pullRequestFactKeys({ owner, repo, number }) {
|
|
|
1977
2124
|
//#endregion
|
|
1978
2125
|
//#region src/ops/entry-rules.ts
|
|
1979
2126
|
/**
|
|
1980
|
-
* The column's entry rules the card does not meet
|
|
1981
|
-
*
|
|
1982
|
-
*
|
|
2127
|
+
* The column's entry rules and flow rules the card does not meet, entry rules
|
|
2128
|
+
* first (ruling 6-2; board-workflow spec, Part 2). A column with no rules
|
|
2129
|
+
* answers at once, without reading anything; otherwise only the facts its
|
|
2130
|
+
* rules need are read. `entering` counts the cards this same write already let
|
|
2131
|
+
* into the column, so a bulk move is held to the WIP limit as a whole.
|
|
1983
2132
|
*/
|
|
1984
|
-
async function checkColumnEntry(store, card, column) {
|
|
2133
|
+
async function checkColumnEntry(store, card, column, entering) {
|
|
1985
2134
|
const rules = readEntryRules(column);
|
|
1986
|
-
|
|
1987
|
-
|
|
2135
|
+
const unmet = rules.length === 0 ? [] : evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
|
|
2136
|
+
const flow = readFlowRules(column);
|
|
2137
|
+
if (flow.allowedFrom === null && flow.wipLimit === null) return unmet;
|
|
2138
|
+
return [...unmet, ...evaluateFlowRules(card, column, flow, await readFlowRuleFacts(store, column, flow, entering))];
|
|
2139
|
+
}
|
|
2140
|
+
/** What the flow rules need: the board's column names when the column names where cards come from, and its card count when it has a limit. */
|
|
2141
|
+
async function readFlowRuleFacts(store, column, flow, entering) {
|
|
2142
|
+
const columnNames = flow.allowedFrom === null ? /* @__PURE__ */ new Map() : new Map((await store.statuses.listByWorkspace(column.workspaceId)).map((other) => [other.id, other.name]));
|
|
2143
|
+
if (flow.wipLimit === null) return {
|
|
2144
|
+
columnNames,
|
|
2145
|
+
cardsInColumn: 0
|
|
2146
|
+
};
|
|
2147
|
+
await store.statuses.lockById(column.id);
|
|
2148
|
+
return {
|
|
2149
|
+
columnNames,
|
|
2150
|
+
cardsInColumn: (await store.issues.listPage({
|
|
2151
|
+
workspaceId: column.workspaceId,
|
|
2152
|
+
statusIds: [column.id],
|
|
2153
|
+
limit: 1
|
|
2154
|
+
})).total + entering
|
|
2155
|
+
};
|
|
1988
2156
|
}
|
|
1989
2157
|
/**
|
|
1990
|
-
* Hold a card at the door of a column that asks for more than it has
|
|
1991
|
-
* 6-3)
|
|
1992
|
-
*
|
|
1993
|
-
*
|
|
1994
|
-
*
|
|
2158
|
+
* Hold a card at the door of a column that asks for more than it has — entry
|
|
2159
|
+
* rules (ruling 6-3), or flow rules: a column it may not come from, or a column
|
|
2160
|
+
* that is full. Anyone but a person — or a person's own terminal,
|
|
2161
|
+
* `placesForPerson` (ruling 6-6) — is refused with `board_column_rules_unmet`,
|
|
2162
|
+
* which names the column and everything unmet; a person is let through, and
|
|
2163
|
+
* the unmet list comes back so the caller can warn them.
|
|
1995
2164
|
*/
|
|
1996
|
-
async function enforceColumnEntry(store, card, column, actor) {
|
|
1997
|
-
const unmet = await checkColumnEntry(store, card, column);
|
|
2165
|
+
async function enforceColumnEntry(store, card, column, actor, entering = 0) {
|
|
2166
|
+
const unmet = await checkColumnEntry(store, card, column, entering);
|
|
1998
2167
|
if (unmet.length > 0 && actor.kind !== "user" && !actor.placesForPerson) throw new BoardError("board_column_rules_unmet", {
|
|
1999
2168
|
issueId: card.id,
|
|
2000
2169
|
column: column.name,
|
|
@@ -2007,14 +2176,17 @@ async function enforceColumnEntry(store, card, column, actor) {
|
|
|
2007
2176
|
* The same door for several cards entering one column at once — a bulk status
|
|
2008
2177
|
* change. All or nothing: the first card an agent may not move refuses the
|
|
2009
2178
|
* whole change. A person gets back the cards that entered with rules unmet.
|
|
2179
|
+
* Each card counts against the column's WIP limit as the cards before it in the same change entered it.
|
|
2010
2180
|
*/
|
|
2011
2181
|
async function enforceColumnEntryForCards(store, issueIds, statusId, actor) {
|
|
2012
2182
|
const column = await store.statuses.findById(statusId);
|
|
2013
2183
|
if (!column) throw new BoardError("issue_status_not_found", { statusId });
|
|
2014
2184
|
const entered = [];
|
|
2185
|
+
let entering = 0;
|
|
2015
2186
|
for (const card of await store.issues.listByIds(issueIds)) {
|
|
2016
2187
|
if (card.statusId === column.id) continue;
|
|
2017
|
-
const unmet = await enforceColumnEntry(store, card, column, actor);
|
|
2188
|
+
const unmet = await enforceColumnEntry(store, card, column, actor, entering);
|
|
2189
|
+
entering++;
|
|
2018
2190
|
if (unmet.length > 0) entered.push({
|
|
2019
2191
|
issueId: card.id,
|
|
2020
2192
|
unmet
|
|
@@ -2169,11 +2341,24 @@ const STOPPED_BY_YOU_STATUS_LINE = "stopped by you";
|
|
|
2169
2341
|
* the board reads the same whether the agent was launched by an app or from
|
|
2170
2342
|
* somewhere else entirely. The attempt number is simply how many runs the card
|
|
2171
2343
|
* has had — a card whose runs were all launched elsewhere still counts them.
|
|
2344
|
+
*
|
|
2345
|
+
* A card has one run going at a time. A start on a card whose run is still going
|
|
2346
|
+
* is refused with `board_card_run_active`, naming that run — whichever path it
|
|
2347
|
+
* came by, an app's launch, `kanbo run start`, MCP, or a second app on a shared
|
|
2348
|
+
* Postgres board. The card's row is locked first, so two starts racing for it
|
|
2349
|
+
* are settled one after the other.
|
|
2172
2350
|
*/
|
|
2173
2351
|
async function startRun(store, issueId, input, actor, scope) {
|
|
2174
2352
|
const externalSessionRef = readExternalSessionRef(input.externalSessionRef) ?? null;
|
|
2175
2353
|
return await runBoardWrite(store, async ({ tx }) => {
|
|
2176
|
-
const card = await
|
|
2354
|
+
const card = await tx.issues.lockById(issueId);
|
|
2355
|
+
if (!card) throw new BoardError("issue_not_found", { issueId });
|
|
2356
|
+
const [active] = await tx.runs.listRunning(issueId);
|
|
2357
|
+
if (active) throw new BoardError("board_card_run_active", {
|
|
2358
|
+
issueId,
|
|
2359
|
+
runId: active.id,
|
|
2360
|
+
agentName: active.agentName
|
|
2361
|
+
});
|
|
2177
2362
|
const attempt = await tx.runs.countByIssue(issueId) + 1;
|
|
2178
2363
|
const executionMode = input.executionMode ?? card.executionMode;
|
|
2179
2364
|
const run = await tx.runs.create({
|
|
@@ -2439,7 +2624,8 @@ async function createCard(store, input, actor, scope) {
|
|
|
2439
2624
|
const column = statusId === null ? null : await tx.statuses.findById(statusId);
|
|
2440
2625
|
const unmet = column ? await enforceColumnEntry(tx, {
|
|
2441
2626
|
id: null,
|
|
2442
|
-
description: input.description ?? null
|
|
2627
|
+
description: input.description ?? null,
|
|
2628
|
+
statusId: null
|
|
2443
2629
|
}, column, actor) : [];
|
|
2444
2630
|
const milestoneId = await requireMilestoneInWorkspace(tx, workspaceId, input.milestoneId ?? null);
|
|
2445
2631
|
const parentIssueId = await requireParentInWorkspace(tx, workspaceId, input.parentIssueId ?? null);
|
|
@@ -2447,8 +2633,9 @@ async function createCard(store, input, actor, scope) {
|
|
|
2447
2633
|
const now = currentUnixSeconds();
|
|
2448
2634
|
for (let attempt = 1;; attempt++) {
|
|
2449
2635
|
const identity = await nextIssueIdentity(input.workspace, tx);
|
|
2636
|
+
let created;
|
|
2450
2637
|
try {
|
|
2451
|
-
|
|
2638
|
+
created = await tx.issues.create({
|
|
2452
2639
|
id: identity.id,
|
|
2453
2640
|
workspaceId,
|
|
2454
2641
|
title: input.title?.trim() || identity.id,
|
|
@@ -2472,12 +2659,25 @@ async function createCard(store, input, actor, scope) {
|
|
|
2472
2659
|
order,
|
|
2473
2660
|
createdAt: now,
|
|
2474
2661
|
updatedAt: now
|
|
2475
|
-
})
|
|
2662
|
+
});
|
|
2476
2663
|
} catch (error) {
|
|
2477
2664
|
const numberTaken = await tx.issues.findByNumber(workspaceId, identity.number);
|
|
2478
2665
|
const keyTaken = await tx.issues.findById(identity.id);
|
|
2479
2666
|
if (attempt >= NUMBERING_ATTEMPTS || !numberTaken && !keyTaken) throw error;
|
|
2667
|
+
continue;
|
|
2480
2668
|
}
|
|
2669
|
+
if (statusId !== null) await tx.fieldChanges.create({
|
|
2670
|
+
id: randomUUID(),
|
|
2671
|
+
issueId: created.id,
|
|
2672
|
+
field: "statusId",
|
|
2673
|
+
fromValue: null,
|
|
2674
|
+
toValue: statusId,
|
|
2675
|
+
actorKind: toStoredActorKind(actor),
|
|
2676
|
+
actorId: actor.id ?? null,
|
|
2677
|
+
sourceChatSessionId: actor.chatSessionId ?? null,
|
|
2678
|
+
createdAt: now
|
|
2679
|
+
});
|
|
2680
|
+
return withUnmetRules(created, unmet);
|
|
2481
2681
|
}
|
|
2482
2682
|
}, scope);
|
|
2483
2683
|
}
|
|
@@ -2533,7 +2733,8 @@ async function updateCard(store, issueId, input, actor, scope) {
|
|
|
2533
2733
|
const description = "description" in updates ? updates.description ?? null : card.description;
|
|
2534
2734
|
unmet = await enforceColumnEntry(tx, {
|
|
2535
2735
|
id: card.id,
|
|
2536
|
-
description
|
|
2736
|
+
description,
|
|
2737
|
+
statusId: card.statusId
|
|
2537
2738
|
}, column, actor);
|
|
2538
2739
|
}
|
|
2539
2740
|
if ("assigneeKind" in input) updates.assigneeKind = input.assigneeKind ?? null;
|
|
@@ -2932,6 +3133,160 @@ async function reorderCards(store, workspaceId, orderedIds, scope) {
|
|
|
2932
3133
|
}, scope);
|
|
2933
3134
|
}
|
|
2934
3135
|
//#endregion
|
|
3136
|
+
//#region src/ops/ready.ts
|
|
3137
|
+
/** The column an agent takes its next card from. */
|
|
3138
|
+
const READY_COLUMN_SLUG = "to_do";
|
|
3139
|
+
/**
|
|
3140
|
+
* The cards an agent may pick up right now: spelled out, nobody working on them,
|
|
3141
|
+
* nobody's turn but the agent's.
|
|
3142
|
+
*
|
|
3143
|
+
* "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
|
|
3144
|
+
* run is taken, and a card waiting for a person belongs to that person until
|
|
3145
|
+
* they answer. What is left is the queue — in board order, so the card someone
|
|
3146
|
+
* dragged to the top is the one an agent asking for work gets.
|
|
3147
|
+
*/
|
|
3148
|
+
async function listReady(store, input) {
|
|
3149
|
+
return (await queryReady(store, input)).cards;
|
|
3150
|
+
}
|
|
3151
|
+
/**
|
|
3152
|
+
* One page of the ready cards, how many are ready in all, and the columns read
|
|
3153
|
+
* on the way — the rule above, asked of the database in one statement rather
|
|
3154
|
+
* than of every card on the board.
|
|
3155
|
+
*/
|
|
3156
|
+
async function queryReady(store, input) {
|
|
3157
|
+
const columns = await listColumns(store, input.workspaceId);
|
|
3158
|
+
const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
|
|
3159
|
+
if (!column) return {
|
|
3160
|
+
cards: [],
|
|
3161
|
+
total: 0,
|
|
3162
|
+
columns
|
|
3163
|
+
};
|
|
3164
|
+
return {
|
|
3165
|
+
...await store.issues.listPage({
|
|
3166
|
+
workspaceId: input.workspaceId,
|
|
3167
|
+
statusIds: [column.id],
|
|
3168
|
+
waitingForPerson: false,
|
|
3169
|
+
hasActiveRun: false,
|
|
3170
|
+
limit: input.limit,
|
|
3171
|
+
offset: input.offset
|
|
3172
|
+
}),
|
|
3173
|
+
columns
|
|
3174
|
+
};
|
|
3175
|
+
}
|
|
3176
|
+
//#endregion
|
|
3177
|
+
//#region src/ops/claim.ts
|
|
3178
|
+
/**
|
|
3179
|
+
* Take a card to work on, in one board write: pick it, start its run, and move
|
|
3180
|
+
* it into the board's first `started` column (In Progress on a standard
|
|
3181
|
+
* board), its entry and flow rules checked on the way in. Anything refused
|
|
3182
|
+
* leaves nothing written.
|
|
3183
|
+
*
|
|
3184
|
+
* Two claimers never get the same card. On SQLite the write holds the file
|
|
3185
|
+
* from its first read (`begin immediate`), so the second claimer reads the
|
|
3186
|
+
* board after the first one's commit. On Postgres the picked card's row is
|
|
3187
|
+
* locked (`select … for update`): a claimer that waited on the lock finds the
|
|
3188
|
+
* card taken, and moves on to the next ready one. The target's row is locked
|
|
3189
|
+
* first, always, in the order a move into it locks (column, then card), so a
|
|
3190
|
+
* claim and a move never wait on each other in a circle.
|
|
3191
|
+
*/
|
|
3192
|
+
async function claimCard(store, input, actor, scope) {
|
|
3193
|
+
return await runBoardWrite(store, async ({ tx }) => {
|
|
3194
|
+
const columns = await listColumns(tx, input.workspaceId);
|
|
3195
|
+
const target = columns.find((column) => column.category === "started");
|
|
3196
|
+
if (!target) throw new BoardError("issue_status_not_found", {
|
|
3197
|
+
workspaceId: input.workspaceId,
|
|
3198
|
+
category: "started"
|
|
3199
|
+
});
|
|
3200
|
+
await tx.statuses.lockById(target.id);
|
|
3201
|
+
const toDo = columns.find((column) => normalizeStatusName(column.name) === "to_do") ?? null;
|
|
3202
|
+
const card = input.issueId === void 0 ? await takeFirstReady(tx, input.workspaceId, toDo) : await takeNamed(tx, input.workspaceId, input.issueId, toDo);
|
|
3203
|
+
const run = await startRun(tx, card.id, input.run, actor, { tx });
|
|
3204
|
+
return {
|
|
3205
|
+
card: await updateCard(tx, card.id, { statusId: target.id }, actor, { tx }),
|
|
3206
|
+
run,
|
|
3207
|
+
column: target
|
|
3208
|
+
};
|
|
3209
|
+
}, scope);
|
|
3210
|
+
}
|
|
3211
|
+
/**
|
|
3212
|
+
* The first ready card, locked. A card another claimer took while this one
|
|
3213
|
+
* waited for its lock is passed over, and the next read no longer lists it; the
|
|
3214
|
+
* cards passed over are counted so the loop ends with the list.
|
|
3215
|
+
*/
|
|
3216
|
+
async function takeFirstReady(store, workspaceId, toDo) {
|
|
3217
|
+
const passed = /* @__PURE__ */ new Set();
|
|
3218
|
+
for (;;) {
|
|
3219
|
+
const { cards } = await queryReady(store, {
|
|
3220
|
+
workspaceId,
|
|
3221
|
+
limit: passed.size + 1
|
|
3222
|
+
});
|
|
3223
|
+
const candidate = cards.find((card) => !passed.has(card.id));
|
|
3224
|
+
if (!candidate) throw new BoardError("board_nothing_ready", { workspaceId });
|
|
3225
|
+
const locked = await store.issues.lockById(candidate.id);
|
|
3226
|
+
if (locked && await notReadyReason(store, locked, toDo) === null) return locked;
|
|
3227
|
+
passed.add(candidate.id);
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
/** The named card, locked, when it is on this board and ready — or why not. */
|
|
3231
|
+
async function takeNamed(store, workspaceId, issueId, toDo) {
|
|
3232
|
+
const card = await store.issues.lockById(issueId);
|
|
3233
|
+
if (!card || card.workspaceId !== workspaceId) throw new BoardError("issue_not_found", { issueId });
|
|
3234
|
+
const reason = await notReadyReason(store, card, toDo);
|
|
3235
|
+
if (reason) throw new BoardError("board_card_not_ready", {
|
|
3236
|
+
issueId,
|
|
3237
|
+
reason
|
|
3238
|
+
});
|
|
3239
|
+
return card;
|
|
3240
|
+
}
|
|
3241
|
+
/** `queryReady`'s rule for one card already read: nobody on it, nobody waited for, in To Do. */
|
|
3242
|
+
async function notReadyReason(store, card, toDo) {
|
|
3243
|
+
if ((await store.runs.listRunning(card.id)).length > 0) return "run_active";
|
|
3244
|
+
if (card.waitingFor === "human") return "waiting_for_person";
|
|
3245
|
+
return toDo !== null && card.statusId === toDo.id ? null : "not_in_to_do";
|
|
3246
|
+
}
|
|
3247
|
+
//#endregion
|
|
3248
|
+
//#region src/ops/column-entries.ts
|
|
3249
|
+
/**
|
|
3250
|
+
* Every entry into a column on the named boards at or after `since`, in order
|
|
3251
|
+
* of `(at, changeId)` — the one signal an app dispatching work on column
|
|
3252
|
+
* changes needs. It sees every writer, an outside `kanbo` on the same database
|
|
3253
|
+
* included, because it reads the cards' own history.
|
|
3254
|
+
*
|
|
3255
|
+
* A page never ends inside a second: when `limit` cuts into one, the rest of
|
|
3256
|
+
* that second comes too, so a page may be longer than `limit`. A reader keeps
|
|
3257
|
+
* its cursor at the last `at` it read after a short page — a write in that
|
|
3258
|
+
* second may still commit — and drops the `changeId`s it has seen; after a page
|
|
3259
|
+
* of `limit` or more it moves on to the last `at` + 1, which a second holding
|
|
3260
|
+
* more entries than `limit` would otherwise never let it pass.
|
|
3261
|
+
*/
|
|
3262
|
+
async function listColumnEntries(store, input) {
|
|
3263
|
+
const page = await store.fieldChanges.listByFieldSince("statusId", input);
|
|
3264
|
+
return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
|
|
3265
|
+
}
|
|
3266
|
+
/** The page with every change of its last second, not only those `limit` let in. */
|
|
3267
|
+
async function withWholeLastSecond(store, input, page) {
|
|
3268
|
+
const last = page.at(-1).createdAt;
|
|
3269
|
+
const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
|
|
3270
|
+
workspaceIds: input.workspaceIds,
|
|
3271
|
+
since: last,
|
|
3272
|
+
until: last
|
|
3273
|
+
});
|
|
3274
|
+
return [...page.filter((change) => change.createdAt < last), ...lastSecond];
|
|
3275
|
+
}
|
|
3276
|
+
/** A status change as an entry; a change that took the card out of every column enters none. */
|
|
3277
|
+
function toColumnEntry(change) {
|
|
3278
|
+
return change.toValue === null ? [] : [{
|
|
3279
|
+
changeId: change.id,
|
|
3280
|
+
issueId: change.issueId,
|
|
3281
|
+
workspaceId: change.workspaceId,
|
|
3282
|
+
fromStatusId: change.fromValue,
|
|
3283
|
+
toStatusId: change.toValue,
|
|
3284
|
+
actorKind: change.actorKind,
|
|
3285
|
+
actorId: change.actorId,
|
|
3286
|
+
at: change.createdAt
|
|
3287
|
+
}];
|
|
3288
|
+
}
|
|
3289
|
+
//#endregion
|
|
2935
3290
|
//#region src/ops/context-refs.ts
|
|
2936
3291
|
const ContextRefsJsonSchema = z.string().transform((raw) => JSON.parse(raw)).pipe(z.array(z.string()));
|
|
2937
3292
|
/** Add a context ref at the end of the card's list, recording the change in its history. */
|
|
@@ -3116,6 +3471,7 @@ function subtaskSplitSentence(parentReference) {
|
|
|
3116
3471
|
}
|
|
3117
3472
|
const SUBTASK_RULE = `${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("the parent card's id")}`;
|
|
3118
3473
|
const PULL_REQUEST_RULE = "Opened a pull request? Link it to the card. Its state and CI checks then reach the card as comments on their own — nothing else tells the board a pull request exists.";
|
|
3474
|
+
const CLAIM_RULE = "Taking a card from To Do yourself? Claim it instead of starting a run and moving it: the claim does both in one step, and no other agent gets the same card. Finish the run when you stop.";
|
|
3119
3475
|
const OWN_SESSION_RULE = "Started the work yourself, not launched by an app that tracks the run for you? Record the run when you start and finish it when you stop. kanbo fills in the Claude Code or Codex session you are in; never invent a session id.";
|
|
3120
3476
|
/** Said by the `kanbo init` block and the MCP instructions, which name the comment command or tool beside it. */
|
|
3121
3477
|
const COMMENT_RULE = "Findings, results, decisions and questions go on the card as comments.";
|
|
@@ -3139,6 +3495,7 @@ const BOARD_RULES = [
|
|
|
3139
3495
|
EXECUTION_MODE_RULE,
|
|
3140
3496
|
SUBTASK_RULE,
|
|
3141
3497
|
PULL_REQUEST_RULE,
|
|
3498
|
+
CLAIM_RULE,
|
|
3142
3499
|
OWN_SESSION_RULE,
|
|
3143
3500
|
"Read the column descriptions above before moving a card, and do all of this through the board tools or commands you have — a statement in your reply changes nothing."
|
|
3144
3501
|
];
|
|
@@ -3154,12 +3511,14 @@ const BOARD_RULES = [
|
|
|
3154
3511
|
async function buildPrimeText(store, workspaceId) {
|
|
3155
3512
|
const columns = await listColumns(store, workspaceId);
|
|
3156
3513
|
const ruled = columns.some((column) => readEntryRules(column).length > 0);
|
|
3514
|
+
const flowing = columns.some((column) => hasFlowRules(column));
|
|
3157
3515
|
return [
|
|
3158
3516
|
...await describeReturnedCards(store, workspaceId, columns),
|
|
3159
3517
|
"Columns of this board, in board order:",
|
|
3160
3518
|
"",
|
|
3161
|
-
...columns.length > 0 ? columns.map(describeColumnLine) : ["- (this board has no columns yet)"],
|
|
3519
|
+
...columns.length > 0 ? columns.map((column) => describeColumnLine(column, columns)) : ["- (this board has no columns yet)"],
|
|
3162
3520
|
...ruled ? ["", ENTRY_RULES_NOTE] : [],
|
|
3521
|
+
...flowing ? ["", FLOW_RULES_NOTE] : [],
|
|
3163
3522
|
"",
|
|
3164
3523
|
"How this board works:",
|
|
3165
3524
|
"",
|
|
@@ -3201,11 +3560,22 @@ async function describeReturnedCards(store, workspaceId, columns) {
|
|
|
3201
3560
|
* without any reads exactly as it did before they existed.
|
|
3202
3561
|
*/
|
|
3203
3562
|
const ENTRY_RULES_NOTE = "A column that lists requirements refuses a card that does not meet them yet, with the list of what is missing: do that work first, then move the card. A person may still move it.";
|
|
3204
|
-
|
|
3563
|
+
/** Said under the columns only when one of them has flow rules. */
|
|
3564
|
+
const FLOW_RULES_NOTE = "A column that says which columns it accepts cards from, or how many cards it holds, refuses a move that breaks that and says why: move the card through a column it accepts, or wait until a card leaves. A person may still move it.";
|
|
3565
|
+
function hasFlowRules(column) {
|
|
3566
|
+
const { allowedFrom, wipLimit } = readFlowRules(column);
|
|
3567
|
+
return allowedFrom !== null || wipLimit !== null;
|
|
3568
|
+
}
|
|
3569
|
+
function describeColumnLine(column, columns) {
|
|
3205
3570
|
const slug = normalizeStatusName(column.name);
|
|
3206
3571
|
const rules = readEntryRules(column);
|
|
3207
|
-
const
|
|
3208
|
-
return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${
|
|
3572
|
+
const said = `${rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : ""}${describeFlow(column, columns)}`;
|
|
3573
|
+
return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${said}` : `- ${column.name} (\`${slug}\`)${said ? ` —${said}` : ""}`;
|
|
3574
|
+
}
|
|
3575
|
+
/** Where a column takes cards from and how many it holds, as the prime text says it; nothing for a column without flow rules. */
|
|
3576
|
+
function describeFlow(column, columns) {
|
|
3577
|
+
const { allowedFrom, wipLimit } = readFlowRules(column);
|
|
3578
|
+
return `${allowedFrom === null ? "" : allowedFrom.length === 0 ? " Accepts no card from another column: only a person puts a card here." : ` Accepts cards from ${joinWords(columns.filter((other) => allowedFrom.includes(other.id)).map((other) => `\`${normalizeStatusName(other.name)}\``))} only.`}${wipLimit === null ? "" : ` Holds at most ${wipLimit} ${wipLimit === 1 ? "card" : "cards"}.`}`;
|
|
3209
3579
|
}
|
|
3210
3580
|
//#endregion
|
|
3211
3581
|
//#region src/domain/pull-request-ref.ts
|
|
@@ -3375,47 +3745,6 @@ async function queryCards(store, input) {
|
|
|
3375
3745
|
};
|
|
3376
3746
|
}
|
|
3377
3747
|
//#endregion
|
|
3378
|
-
//#region src/ops/ready.ts
|
|
3379
|
-
/** The column an agent takes its next card from. */
|
|
3380
|
-
const READY_COLUMN_SLUG = "to_do";
|
|
3381
|
-
/**
|
|
3382
|
-
* The cards an agent may pick up right now: spelled out, nobody working on them,
|
|
3383
|
-
* nobody's turn but the agent's.
|
|
3384
|
-
*
|
|
3385
|
-
* "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
|
|
3386
|
-
* run is taken, and a card waiting for a person belongs to that person until
|
|
3387
|
-
* they answer. What is left is the queue — in board order, so the card someone
|
|
3388
|
-
* dragged to the top is the one an agent asking for work gets.
|
|
3389
|
-
*/
|
|
3390
|
-
async function listReady(store, input) {
|
|
3391
|
-
return (await queryReady(store, input)).cards;
|
|
3392
|
-
}
|
|
3393
|
-
/**
|
|
3394
|
-
* One page of the ready cards, how many are ready in all, and the columns read
|
|
3395
|
-
* on the way — the rule above, asked of the database in one statement rather
|
|
3396
|
-
* than of every card on the board.
|
|
3397
|
-
*/
|
|
3398
|
-
async function queryReady(store, input) {
|
|
3399
|
-
const columns = await listColumns(store, input.workspaceId);
|
|
3400
|
-
const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
|
|
3401
|
-
if (!column) return {
|
|
3402
|
-
cards: [],
|
|
3403
|
-
total: 0,
|
|
3404
|
-
columns
|
|
3405
|
-
};
|
|
3406
|
-
return {
|
|
3407
|
-
...await store.issues.listPage({
|
|
3408
|
-
workspaceId: input.workspaceId,
|
|
3409
|
-
statusIds: [column.id],
|
|
3410
|
-
waitingForPerson: false,
|
|
3411
|
-
hasActiveRun: false,
|
|
3412
|
-
limit: input.limit,
|
|
3413
|
-
offset: input.offset
|
|
3414
|
-
}),
|
|
3415
|
-
columns
|
|
3416
|
-
};
|
|
3417
|
-
}
|
|
3418
|
-
//#endregion
|
|
3419
3748
|
//#region src/ops/relations.ts
|
|
3420
3749
|
/**
|
|
3421
3750
|
* Every edge touching the card, in either direction, each with the card on its
|
|
@@ -3705,6 +4034,8 @@ function createBoardOps(store) {
|
|
|
3705
4034
|
updateColumn: async (statusId, patch, actor, scope) => await updateColumn(store, statusId, patch, actor, scope),
|
|
3706
4035
|
describeColumn: async (workspaceId, nameOrId, description, scope) => await describeColumn(store, workspaceId, nameOrId, description, scope),
|
|
3707
4036
|
setColumnEntryRules: async (workspaceId, nameOrId, entryRules, actor, scope) => await setColumnEntryRules(store, workspaceId, nameOrId, entryRules, actor, scope),
|
|
4037
|
+
setColumnFlowRules: async (columnId, rules, actor, scope) => await setColumnFlowRules(store, columnId, rules, actor, scope),
|
|
4038
|
+
applyStandardFlow: async (workspaceId, actor, scope) => await applyStandardFlow(store, workspaceId, actor, scope),
|
|
3708
4039
|
enforceColumnEntryForCards: async (issueIds, statusId, actor) => await enforceColumnEntryForCards(store, issueIds, statusId, actor),
|
|
3709
4040
|
deleteColumn: async (statusId, scope, actor) => await deleteColumn(store, statusId, scope, actor),
|
|
3710
4041
|
reorderColumns: async (workspaceId, orderedIds, scope, actor) => await reorderColumns(store, workspaceId, orderedIds, scope, actor),
|
|
@@ -3715,6 +4046,7 @@ function createBoardOps(store) {
|
|
|
3715
4046
|
moveColumn: async (workspaceId, nameOrId, position, actor, scope) => await moveColumn$1(store, workspaceId, nameOrId, position, actor, scope),
|
|
3716
4047
|
removeColumn: async (workspaceId, nameOrId, options, actor, scope) => await removeColumn$1(store, workspaceId, nameOrId, options, actor, scope),
|
|
3717
4048
|
startRun: async (issueId, input, actor, scope) => await startRun(store, issueId, input, actor, scope),
|
|
4049
|
+
claimCard: async (input, actor, scope) => await claimCard(store, input, actor, scope),
|
|
3718
4050
|
attachRunExecution: async (runId, input, scope) => await attachRunExecution(store, runId, input, scope),
|
|
3719
4051
|
recordRunSessionRef: async (runId, ref, scope) => await recordRunSessionRef(store, runId, ref, scope),
|
|
3720
4052
|
clearRunSessionRef: async (runId, actor, options, scope) => await clearRunSessionRef(store, runId, actor, options, scope),
|
|
@@ -3727,6 +4059,7 @@ function createBoardOps(store) {
|
|
|
3727
4059
|
readBoardProjectionForIssue: async (issueId) => await readBoardProjectionForIssue(store, issueId),
|
|
3728
4060
|
listReady: async (input) => await listReady(store, input),
|
|
3729
4061
|
queryReady: async (input) => await queryReady(store, input),
|
|
4062
|
+
listColumnEntries: async (input) => await listColumnEntries(store, input),
|
|
3730
4063
|
buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId)
|
|
3731
4064
|
};
|
|
3732
4065
|
}
|
|
@@ -4432,9 +4765,10 @@ const SCHEMA_NOT_INSTALLED_MESSAGE = "This Postgres database has no kanbo board
|
|
|
4432
4765
|
* caller's own risk but never written to — a write shaped by this build's idea
|
|
4433
4766
|
* of the schema is how a person's board gets a column full of nothing. A file
|
|
4434
4767
|
* of this package's own is this package's to migrate, so the same refusal
|
|
4435
|
-
* points at `kanbo migrate` instead of at the app. Reads
|
|
4436
|
-
*
|
|
4437
|
-
* their cards
|
|
4768
|
+
* points at `kanbo migrate` instead of at the app. Reads are held to the lesser
|
|
4769
|
+
* bar of `assertReadableBoard`: a file only a stamp behind is still read, so a
|
|
4770
|
+
* person who cannot migrate right now can look at their cards, but one missing
|
|
4771
|
+
* a table or column this build reads is refused with the same sentence.
|
|
4438
4772
|
*/
|
|
4439
4773
|
function assertWritableBoard(database, owner) {
|
|
4440
4774
|
try {
|
|
@@ -4445,6 +4779,26 @@ function assertWritableBoard(database, owner) {
|
|
|
4445
4779
|
}
|
|
4446
4780
|
}
|
|
4447
4781
|
/**
|
|
4782
|
+
* Refuse a read of a board file that lacks a table or a column this build
|
|
4783
|
+
* reads — a file a migration behind — with the sentence a write gets, instead
|
|
4784
|
+
* of letting the first statement fail on what is missing. A file whose only
|
|
4785
|
+
* difference is its schema stamp is still read: every statement this build
|
|
4786
|
+
* makes works on it, and a person who cannot migrate now can still look at
|
|
4787
|
+
* their cards.
|
|
4788
|
+
*/
|
|
4789
|
+
function assertReadableBoard(database, owner) {
|
|
4790
|
+
try {
|
|
4791
|
+
assertBoardSchema$1(database);
|
|
4792
|
+
} catch (error) {
|
|
4793
|
+
if (!(error instanceof BoardError && error.code === "board_schema_outdated")) throw error;
|
|
4794
|
+
if (lacksWhatThisBuildReads(error.details)) throw new CliError(3, owner === "kanbo" ? FILE_SCHEMA_OUTDATED_MESSAGE : SCHEMA_OUTDATED_MESSAGE);
|
|
4795
|
+
}
|
|
4796
|
+
}
|
|
4797
|
+
/** Whether `assertBoardSchema` found a table or a column missing, rather than only another stamp. */
|
|
4798
|
+
function lacksWhatThisBuildReads(details) {
|
|
4799
|
+
return details !== void 0 && ("missingTables" in details || "missingAddedTables" in details || "missingColumns" in details);
|
|
4800
|
+
}
|
|
4801
|
+
/**
|
|
4448
4802
|
* Refuse an external board this build cannot speak for — before reading it as
|
|
4449
4803
|
* well as before writing to it.
|
|
4450
4804
|
*
|
|
@@ -4683,6 +5037,7 @@ async function openFileSession(target, options, mode, cwd) {
|
|
|
4683
5037
|
assertWritableBoard(board.database, target.owner);
|
|
4684
5038
|
};
|
|
4685
5039
|
if (mode === "write") assertWritableBoard(board.database, target.owner);
|
|
5040
|
+
else assertReadableBoard(board.database, target.owner);
|
|
4686
5041
|
const workspace = resolveWorkspace(target.owner === "kanbo" ? { kind: "file" } : {
|
|
4687
5042
|
kind: "sqlite",
|
|
4688
5043
|
database: board.database
|
|
@@ -4706,8 +5061,9 @@ async function openFileSession(target, options, mode, cwd) {
|
|
|
4706
5061
|
/**
|
|
4707
5062
|
* The same, on a board that is a database of its own.
|
|
4708
5063
|
*
|
|
4709
|
-
* The guard runs whatever the command came to do. A board file
|
|
4710
|
-
*
|
|
5064
|
+
* The guard runs whatever the command came to do. A board file whose only
|
|
5065
|
+
* difference from this build is its schema stamp is still a board worth
|
|
5066
|
+
* reading; a connection string pointing
|
|
4711
5067
|
* at a database with no board in it is nothing to read at all, and every
|
|
4712
5068
|
* command would otherwise fail one statement later with the driver's own words.
|
|
4713
5069
|
*/
|
|
@@ -5005,7 +5361,9 @@ function projectCardActiveRun(run) {
|
|
|
5005
5361
|
startedAt: run.startedAt
|
|
5006
5362
|
};
|
|
5007
5363
|
}
|
|
5008
|
-
|
|
5364
|
+
/** A column as the command line prints it; `board` is every column, to name the ones it accepts cards from. */
|
|
5365
|
+
function projectColumn(column, board) {
|
|
5366
|
+
const { allowedFrom, wipLimit } = readFlowRules(column);
|
|
5009
5367
|
return {
|
|
5010
5368
|
id: column.id,
|
|
5011
5369
|
name: column.name,
|
|
@@ -5013,7 +5371,9 @@ function projectColumn(column) {
|
|
|
5013
5371
|
description: column.description,
|
|
5014
5372
|
category: column.category,
|
|
5015
5373
|
order: column.order,
|
|
5016
|
-
entryRules: readEntryRules(column)
|
|
5374
|
+
entryRules: readEntryRules(column),
|
|
5375
|
+
allowedFrom: allowedFrom === null ? null : board.filter((other) => allowedFrom.includes(other.id)).map((other) => normalizeStatusName(other.name)),
|
|
5376
|
+
wipLimit
|
|
5017
5377
|
};
|
|
5018
5378
|
}
|
|
5019
5379
|
function projectSprint(sprint) {
|
|
@@ -5085,7 +5445,9 @@ function describeColumns(columns) {
|
|
|
5085
5445
|
return columns.map((column) => [
|
|
5086
5446
|
`${padToWidth(sanitizeTerminalText(column.slug), slugWidth)} ${sanitizeTerminalText(column.name)}`,
|
|
5087
5447
|
column.description ? ` — ${sanitizeTerminalText(column.description)}` : "",
|
|
5088
|
-
column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : ""
|
|
5448
|
+
column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : "",
|
|
5449
|
+
column.allowedFrom === null ? "" : ` [from: ${column.allowedFrom.length > 0 ? column.allowedFrom.join(", ") : "no column"}]`,
|
|
5450
|
+
column.wipLimit === null ? "" : ` [wip: ${column.wipLimit}]`
|
|
5089
5451
|
].join("")).join("\n");
|
|
5090
5452
|
}
|
|
5091
5453
|
/** A unix-seconds date as the UTC day it falls on, `—` for none. */
|
|
@@ -5407,6 +5769,52 @@ function registerCapabilitiesCommand(program) {
|
|
|
5407
5769
|
});
|
|
5408
5770
|
}
|
|
5409
5771
|
//#endregion
|
|
5772
|
+
//#region src/cli/commands/claim.ts
|
|
5773
|
+
/**
|
|
5774
|
+
* `kanbo claim [card]` — take a card to work on, in one step.
|
|
5775
|
+
*
|
|
5776
|
+
* The run starts and the card moves out of To Do into the board's first
|
|
5777
|
+
* work-in-progress column in one write, so two agents in two terminals never
|
|
5778
|
+
* take the same card. Filed as a placement: in a person's own terminal the
|
|
5779
|
+
* column's rules warn, in an agent's shell they refuse.
|
|
5780
|
+
*/
|
|
5781
|
+
function registerClaimCommand(program) {
|
|
5782
|
+
withBoardOptions(program.command("claim").description("Take a card to work on: start your run and move it out of To Do, in one step").argument("[card]", "the card, as MAN-012, MAN-12 or 12 (default: the first ready card)").option("--agent <name>", "your name, e.g. claude (default: the name this shell writes as)").option("--session <ref>", "your session, to find it later: claude:<session id> or codex:<session id> (default: the one Claude Code or Codex names in the environment)")).action(async (reference, options) => {
|
|
5783
|
+
await runBoardCommand(options, "write", async (session) => {
|
|
5784
|
+
const actor = createPlacementActor();
|
|
5785
|
+
const named = reference === void 0 ? void 0 : (await requireCard(session, reference)).id;
|
|
5786
|
+
const claimed = await session.ops.claimCard({
|
|
5787
|
+
workspaceId: session.workspace.id,
|
|
5788
|
+
issueId: named,
|
|
5789
|
+
run: {
|
|
5790
|
+
agentName: options.agent ?? actor.id ?? "agent",
|
|
5791
|
+
externalSessionRef: options.session ?? sessionRefFromEnvironment() ?? void 0,
|
|
5792
|
+
launchedByKind: "external"
|
|
5793
|
+
}
|
|
5794
|
+
}, actor);
|
|
5795
|
+
const [columns, projection, runs] = await Promise.all([
|
|
5796
|
+
session.ops.listColumns(session.workspace.id),
|
|
5797
|
+
session.ops.readBoardProjectionForIssue(claimed.card.id),
|
|
5798
|
+
session.ops.listRuns(claimed.card.id)
|
|
5799
|
+
]);
|
|
5800
|
+
const card = projectCard(claimed.card, columns, projection);
|
|
5801
|
+
for (const item of claimed.card.unmetRules ?? []) console.error(`kanbo: warning: ${card.id} entered "${claimed.column.name}" without ${item.rule}: ${item.detail}`);
|
|
5802
|
+
const run = projectRun({
|
|
5803
|
+
...claimed.run,
|
|
5804
|
+
attempt: runs.find((each) => each.id === claimed.run.id)?.attempt ?? runs.length
|
|
5805
|
+
});
|
|
5806
|
+
return {
|
|
5807
|
+
value: {
|
|
5808
|
+
card,
|
|
5809
|
+
run,
|
|
5810
|
+
column: projectColumn(claimed.column, columns)
|
|
5811
|
+
},
|
|
5812
|
+
text: `Claimed ${card.id}: run ${run.id} started, card moved to ${claimed.column.name}\nWhen you are done: kanbo run finish ${run.id} --state finished`
|
|
5813
|
+
};
|
|
5814
|
+
});
|
|
5815
|
+
});
|
|
5816
|
+
}
|
|
5817
|
+
//#endregion
|
|
5410
5818
|
//#region src/mcp/transport.ts
|
|
5411
5819
|
/** What `kanbo_card_get` may bring along with the card. */
|
|
5412
5820
|
const KANBO_CARD_INCLUDES = [
|
|
@@ -5523,16 +5931,22 @@ function projectKanboSprint(sprint) {
|
|
|
5523
5931
|
doneCards: sprint.cards.done
|
|
5524
5932
|
};
|
|
5525
5933
|
}
|
|
5526
|
-
/** A column of the board, from the row either transport holds. */
|
|
5527
|
-
function projectKanboColumn(column) {
|
|
5934
|
+
/** A column of the board, from the row either transport holds; `board` is every column, to name the ones it accepts cards from. */
|
|
5935
|
+
function projectKanboColumn(column, board) {
|
|
5528
5936
|
const stored = column.entryRules;
|
|
5937
|
+
const allowedIds = typeof column.allowedFrom === "string" ? readFlowRules({
|
|
5938
|
+
allowedFrom: column.allowedFrom,
|
|
5939
|
+
wipLimit: null
|
|
5940
|
+
}).allowedFrom : column.allowedFrom ?? null;
|
|
5529
5941
|
return {
|
|
5530
5942
|
id: column.id,
|
|
5531
5943
|
name: column.name,
|
|
5532
5944
|
slug: normalizeStatusName(column.name),
|
|
5533
5945
|
description: column.description,
|
|
5534
5946
|
order: column.order,
|
|
5535
|
-
entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null })
|
|
5947
|
+
entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null }),
|
|
5948
|
+
allowedFrom: allowedIds === null ? null : board.filter((other) => allowedIds.includes(other.id)).map((other) => normalizeStatusName(other.name)),
|
|
5949
|
+
wipLimit: column.wipLimit ?? null
|
|
5536
5950
|
};
|
|
5537
5951
|
}
|
|
5538
5952
|
/**
|
|
@@ -5962,10 +6376,11 @@ const COLUMN_CATEGORIES = [
|
|
|
5962
6376
|
];
|
|
5963
6377
|
/** The whole board's columns after a change, and the sentence that says what changed. */
|
|
5964
6378
|
async function changed(session, message, extra = {}) {
|
|
5965
|
-
const
|
|
6379
|
+
const board = await session.ops.listColumns(session.workspace.id);
|
|
6380
|
+
const views = board.map((column) => projectColumn(column, board));
|
|
5966
6381
|
return {
|
|
5967
6382
|
value: {
|
|
5968
|
-
...extra,
|
|
6383
|
+
...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, typeof value === "number" || Array.isArray(value) ? value : projectColumn(value, board)])),
|
|
5969
6384
|
columns: views
|
|
5970
6385
|
},
|
|
5971
6386
|
text: `${message}\n\n${describeColumns(views)}`
|
|
@@ -5994,14 +6409,14 @@ async function addColumn(session, request, actor) {
|
|
|
5994
6409
|
category: request.category ?? spec.category,
|
|
5995
6410
|
position: anchor ? request.after !== void 0 ? { after: anchor.id } : { before: anchor.id } : void 0
|
|
5996
6411
|
}, actor);
|
|
5997
|
-
return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added:
|
|
6412
|
+
return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: created });
|
|
5998
6413
|
}
|
|
5999
6414
|
/** Said after a rename: agents' instructions name no column but To Do, and `kanbo prime` gives them the rest. */
|
|
6000
6415
|
const RENAME_NOTE = "Agents read column names from kanbo prime; rename is picked up there.";
|
|
6001
6416
|
async function renameColumn(session, column, newName, actor) {
|
|
6002
6417
|
const before = await requireNamedColumn(session, column);
|
|
6003
6418
|
const renamed = await session.ops.renameColumn(session.workspace.id, before.id, newName, actor);
|
|
6004
|
-
return await changed(session, before.name === renamed.name ? `${renamed.name} already has that name.` : `Renamed ${before.name} to ${renamed.name} (slug ${normalizeStatusName(renamed.name)}).\n${RENAME_NOTE}`, { renamed
|
|
6419
|
+
return await changed(session, before.name === renamed.name ? `${renamed.name} already has that name.` : `Renamed ${before.name} to ${renamed.name} (slug ${normalizeStatusName(renamed.name)}).\n${RENAME_NOTE}`, { renamed });
|
|
6005
6420
|
}
|
|
6006
6421
|
/** `--first`, `--last`, `--before <column>` or `--after <column>` — exactly one. */
|
|
6007
6422
|
function readColumnPosition(options) {
|
|
@@ -6033,7 +6448,7 @@ async function removeColumn(session, column, moveCardsTo, actor) {
|
|
|
6033
6448
|
const result = await session.ops.removeColumn(session.workspace.id, removing.id, { moveCardsTo }, actor);
|
|
6034
6449
|
for (const card of result.unmetRules ?? []) for (const item of card.unmet) console.error(`kanbo: warning: ${card.issueId} entered "${result.movedTo?.name ?? "—"}" without ${item.rule}: ${item.detail}`);
|
|
6035
6450
|
return await changed(session, result.movedTo ? `Removed ${result.removed.name} and moved ${describeCount$1(result.movedCards)} to ${result.movedTo.name}.` : `Removed ${result.removed.name}.`, {
|
|
6036
|
-
removed:
|
|
6451
|
+
removed: result.removed,
|
|
6037
6452
|
movedCards: result.movedCards
|
|
6038
6453
|
});
|
|
6039
6454
|
}
|
|
@@ -6077,6 +6492,14 @@ const BOARD_ERROR_TEXTS = {
|
|
|
6077
6492
|
text: "Only a person can approve a card.",
|
|
6078
6493
|
next: AS_A_PERSON
|
|
6079
6494
|
}),
|
|
6495
|
+
board_card_not_ready: (details) => ({
|
|
6496
|
+
text: details.reason === "run_active" ? `${named(details.issueId)} is taken: it has a run going.` : details.reason === "waiting_for_person" ? `${named(details.issueId)} is waiting for a person.` : `${named(details.issueId)} is not in To Do, so it is not there to take.`,
|
|
6497
|
+
next: "kanbo ready"
|
|
6498
|
+
}),
|
|
6499
|
+
board_card_run_active: (details) => ({
|
|
6500
|
+
text: `${named(details.issueId)} already has a run going: ${named(details.agentName)}'s, run ${named(details.runId)}.`,
|
|
6501
|
+
next: `kanbo run finish ${named(details.runId)} --state stopped`
|
|
6502
|
+
}),
|
|
6080
6503
|
board_column_name_taken: (details) => ({
|
|
6081
6504
|
text: `Another column, ${named(details.takenBy)}, already has that name (same slug). Pick another name.`,
|
|
6082
6505
|
next: "kanbo columns list"
|
|
@@ -6107,6 +6530,14 @@ const BOARD_ERROR_TEXTS = {
|
|
|
6107
6530
|
}),
|
|
6108
6531
|
board_entry_rule_invalid: (details) => ({ text: `Unknown column rule: ${list(details.rules)}. Use any of ${list(details.allowed)}.` }),
|
|
6109
6532
|
board_execution_mode_locked: (details) => ({ text: `${named(details.issueId)} has been started, so where it is worked (${named(details.executionMode)}) can't change now.` }),
|
|
6533
|
+
board_flow_rule_invalid: (details) => details.wipLimit !== void 0 ? { text: `A column holds at least 1 card; ${named(details.wipLimit)} is not a limit. Use none for no limit.` } : {
|
|
6534
|
+
text: `A column accepts cards only from other columns of its own board, and ${list(details.allowedFrom)} is not one.`,
|
|
6535
|
+
next: "kanbo columns list"
|
|
6536
|
+
},
|
|
6537
|
+
board_nothing_ready: () => ({
|
|
6538
|
+
text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
|
|
6539
|
+
next: "kanbo ready"
|
|
6540
|
+
}),
|
|
6110
6541
|
board_pull_request_invalid: (details) => ({ text: `"${named(details.value)}" is not a GitHub pull request. Give its link or owner/repo#number.` }),
|
|
6111
6542
|
board_pull_request_not_found: (details) => ({
|
|
6112
6543
|
text: `${named(details.issueId)} has no pull request link "${named(details.linkId)}".`,
|
|
@@ -6172,7 +6603,7 @@ const BOARD_ERROR_TEXTS = {
|
|
|
6172
6603
|
issue_relation_self_reference: () => ({ text: "A card can't be linked to itself." }),
|
|
6173
6604
|
issue_status_name_empty: () => ({ text: "Give the column a name." }),
|
|
6174
6605
|
issue_status_not_found: (details) => ({
|
|
6175
|
-
text: `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
|
|
6606
|
+
text: details.category !== void 0 ? `This board has no ${named(details.category)} column for a claimed card to go to.` : `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
|
|
6176
6607
|
next: "kanbo columns list"
|
|
6177
6608
|
}),
|
|
6178
6609
|
issue_status_reference_conflict: () => ({ text: "Name the column once: by its id or by its name, not both." }),
|
|
@@ -6376,7 +6807,7 @@ async function runColumnsMenu(ui) {
|
|
|
6376
6807
|
if (showList) say$1(ui, [
|
|
6377
6808
|
"",
|
|
6378
6809
|
"Columns of this board:",
|
|
6379
|
-
describeColumns(columns.map(projectColumn))
|
|
6810
|
+
describeColumns(columns.map((column) => projectColumn(column, columns)))
|
|
6380
6811
|
].join("\n"));
|
|
6381
6812
|
let choice;
|
|
6382
6813
|
try {
|
|
@@ -6604,7 +7035,8 @@ function registerColumnsCommands(program) {
|
|
|
6604
7035
|
const columns = program.command("columns").description("See and change the board's columns (alone: a menu)");
|
|
6605
7036
|
withBoardOptions(columns.command("list").description("List the columns in order, with the short name to move cards by")).action(async (options) => {
|
|
6606
7037
|
await runBoardCommand(options, "read", async (session) => {
|
|
6607
|
-
const
|
|
7038
|
+
const board = await session.ops.listColumns(session.workspace.id);
|
|
7039
|
+
const views = board.map((column) => projectColumn(column, board));
|
|
6608
7040
|
return {
|
|
6609
7041
|
value: views,
|
|
6610
7042
|
text: describeColumns(views)
|
|
@@ -6613,7 +7045,7 @@ function registerColumnsCommands(program) {
|
|
|
6613
7045
|
});
|
|
6614
7046
|
withBoardOptions(columns.command("describe").description("Say in one line when a card belongs in a column").argument("<column>", "the column, by slug, name or id").requiredOption("--text <text>", "what the column means")).action(async (column, options) => {
|
|
6615
7047
|
await runBoardCommand(options, "write", async (session) => {
|
|
6616
|
-
const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text));
|
|
7048
|
+
const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text), await session.ops.listColumns(session.workspace.id));
|
|
6617
7049
|
return {
|
|
6618
7050
|
value: view,
|
|
6619
7051
|
text: describeColumns([view])
|
|
@@ -6624,13 +7056,34 @@ function registerColumnsCommands(program) {
|
|
|
6624
7056
|
const actor = requireHumanActor("columns rules", typedWords(command));
|
|
6625
7057
|
const rules = readRequestedRules(options);
|
|
6626
7058
|
await runBoardCommand(options, "write", async (session) => {
|
|
6627
|
-
const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor));
|
|
7059
|
+
const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor), await session.ops.listColumns(session.workspace.id));
|
|
6628
7060
|
return {
|
|
6629
7061
|
value: view,
|
|
6630
7062
|
text: describeColumns([view])
|
|
6631
7063
|
};
|
|
6632
7064
|
});
|
|
6633
7065
|
});
|
|
7066
|
+
withBoardOptions(columns.command("flow").description("Set which columns a card may come from, and how many cards a column holds").argument("<column>", "the column, by slug, name or id").option("--from <columns>", "comma-separated columns a card may enter it from, or any").option("--wip <count>", "the most cards it holds, or none")).action(async (column, options, command) => {
|
|
7067
|
+
const actor = requireHumanActor("columns flow", typedWords(command));
|
|
7068
|
+
if (options.from === void 0 && options.wip === void 0) throw new CliError(1, "Pass --from <columns|any>, --wip <count|none>, or both.");
|
|
7069
|
+
const wipLimit = options.wip === void 0 ? void 0 : readWipLimit(options.wip);
|
|
7070
|
+
await runBoardCommand(options, "write", async (session) => {
|
|
7071
|
+
const workspaceId = session.workspace.id;
|
|
7072
|
+
const board = await session.ops.listColumns(workspaceId);
|
|
7073
|
+
const target = await session.ops.requireColumn(workspaceId, column);
|
|
7074
|
+
const current = readFlowRules(target);
|
|
7075
|
+
await session.ops.setColumnFlowRules(target.id, {
|
|
7076
|
+
allowedFrom: options.from === void 0 ? current.allowedFrom : readAllowedFrom(board, workspaceId, options.from),
|
|
7077
|
+
wipLimit: wipLimit === void 0 ? current.wipLimit : wipLimit
|
|
7078
|
+
}, actor);
|
|
7079
|
+
const after = await session.ops.listColumns(workspaceId);
|
|
7080
|
+
const views = after.map((each) => projectColumn(each, after));
|
|
7081
|
+
return {
|
|
7082
|
+
value: views.find((view) => view.id === target.id),
|
|
7083
|
+
text: describeColumns(views)
|
|
7084
|
+
};
|
|
7085
|
+
});
|
|
7086
|
+
});
|
|
6634
7087
|
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 Option("--category <category>", "what kind of column it is").choices(COLUMN_CATEGORIES))).action(async (name, options, command) => {
|
|
6635
7088
|
const actor = requireHumanActor("columns add", typedWords(command));
|
|
6636
7089
|
await runBoardCommand(options, "write", async (session) => await addColumn(session, {
|
|
@@ -6672,7 +7125,7 @@ function registerColumnsCommands(program) {
|
|
|
6672
7125
|
mode: "add-missing",
|
|
6673
7126
|
actor
|
|
6674
7127
|
});
|
|
6675
|
-
const views = statuses.map(projectColumn);
|
|
7128
|
+
const views = statuses.map((column) => projectColumn(column, statuses));
|
|
6676
7129
|
return {
|
|
6677
7130
|
value: {
|
|
6678
7131
|
added,
|
|
@@ -6696,7 +7149,8 @@ function registerColumnsCommands(program) {
|
|
|
6696
7149
|
return;
|
|
6697
7150
|
}
|
|
6698
7151
|
await runBoardCommand({}, "read", async (session) => {
|
|
6699
|
-
const
|
|
7152
|
+
const board = await session.ops.listColumns(session.workspace.id);
|
|
7153
|
+
const views = board.map((column) => projectColumn(column, board));
|
|
6700
7154
|
return {
|
|
6701
7155
|
value: views,
|
|
6702
7156
|
text: describeColumns(views)
|
|
@@ -6726,6 +7180,26 @@ function readRequestedRules(options) {
|
|
|
6726
7180
|
if (options.require === void 0) throw new CliError(1, `Pass --require ${ENTRY_RULES.join(",")} (any of them) or --clear.`);
|
|
6727
7181
|
return options.require.split(",").map((rule) => rule.trim()).filter(Boolean);
|
|
6728
7182
|
}
|
|
7183
|
+
/** `--from`: `any` for every column, else the columns named, by slug, name or id. */
|
|
7184
|
+
function readAllowedFrom(board, workspaceId, value) {
|
|
7185
|
+
if (value.trim().toLowerCase() === "any") return null;
|
|
7186
|
+
const names = value.split(",").map((name) => name.trim()).filter(Boolean);
|
|
7187
|
+
if (names.length === 0) throw new CliError(1, "Name the columns a card may come from (--from in_review,qa), or --from any.");
|
|
7188
|
+
return names.map((name) => {
|
|
7189
|
+
const found = matchColumn(board, workspaceId, name);
|
|
7190
|
+
if (!found) throw new BoardError("issue_status_not_found", {
|
|
7191
|
+
workspaceId,
|
|
7192
|
+
statusName: name
|
|
7193
|
+
});
|
|
7194
|
+
return found.id;
|
|
7195
|
+
});
|
|
7196
|
+
}
|
|
7197
|
+
/** `--wip`: `none` for no limit, else a whole number of cards, 1 or more. */
|
|
7198
|
+
function readWipLimit(value) {
|
|
7199
|
+
if (value.trim().toLowerCase() === "none") return null;
|
|
7200
|
+
if (!/^\d+$/.test(value.trim()) || Number(value) < 1) throw new CliError(1, "--wip takes a whole number of cards, 1 or more, or none.");
|
|
7201
|
+
return Number(value);
|
|
7202
|
+
}
|
|
6729
7203
|
//#endregion
|
|
6730
7204
|
//#region src/package-version.ts
|
|
6731
7205
|
dirname(fileURLToPath(import.meta.url));
|
|
@@ -6735,7 +7209,7 @@ dirname(fileURLToPath(import.meta.url));
|
|
|
6735
7209
|
* `package.version`. A build carries it as a literal; running from source
|
|
6736
7210
|
* (tests, `tsx`) reads the package's own `package.json` instead.
|
|
6737
7211
|
*/
|
|
6738
|
-
const KANBO_PACKAGE_VERSION = "0.
|
|
7212
|
+
const KANBO_PACKAGE_VERSION = "0.5.0";
|
|
6739
7213
|
//#endregion
|
|
6740
7214
|
//#region src/cli/setup/paths.ts
|
|
6741
7215
|
/**
|
|
@@ -7084,7 +7558,7 @@ const INSTRUCTION_END_PATTERN = /<!-- KANBO_END\s*-->/g;
|
|
|
7084
7558
|
const POINTER_KINDS = /* @__PURE__ */ new Set(["short", "global"]);
|
|
7085
7559
|
/** The commands an agent uses, spelled once for the guide and the `kanbo prime` cheat sheet. */
|
|
7086
7560
|
const COMMAND = {
|
|
7087
|
-
|
|
7561
|
+
claim: "kanbo claim",
|
|
7088
7562
|
move: "kanbo card move <id> <column>",
|
|
7089
7563
|
statusLine: "kanbo card status-line <id> --text \"...\"",
|
|
7090
7564
|
subtask: "kanbo card create --description \"...\" --parent <id>",
|
|
@@ -7103,7 +7577,7 @@ const AGENT_GUIDE_TEXT = [
|
|
|
7103
7577
|
"Work on this project is tracked on a kanbo board. Before starting a task run `kanbo prime` — it prints the board's columns and what each one means.",
|
|
7104
7578
|
"",
|
|
7105
7579
|
...[
|
|
7106
|
-
`- Take a card
|
|
7580
|
+
`- Take a card with \`${COMMAND.claim}\`: it starts your run and moves the card into work in one step, and no other agent gets the same card.`,
|
|
7107
7581
|
`- ${MOVE_CARD_RULE} (\`${COMMAND.move}\`)`,
|
|
7108
7582
|
`- ${STATUS_LINE_RULE} (\`${COMMAND.statusLine}\`)`,
|
|
7109
7583
|
`- ${SUBTASK_RULE} (\`${COMMAND.subtask}\`)`,
|
|
@@ -7133,12 +7607,13 @@ const COMMAND_SHEET = [
|
|
|
7133
7607
|
"- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
|
|
7134
7608
|
"- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
|
|
7135
7609
|
"- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
|
|
7136
|
-
"- `kanbo
|
|
7610
|
+
"- `kanbo claim` — take the first ready card: starts your run and moves it into work in one step (`kanbo claim TST-5` for that card); it prints the finish command",
|
|
7611
|
+
"- `kanbo run start TST-5 --agent claude` — record a run on a card you did not claim; it prints the finish command",
|
|
7137
7612
|
"- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
|
|
7138
7613
|
`- \`${COMMAND.capabilities}\` — everything else, rarely needed`
|
|
7139
7614
|
].join("\n");
|
|
7140
7615
|
/** How to take work and who approves it — said by both blocks after their first sentence. */
|
|
7141
|
-
const POINTER_TAIL = `Take work with \`${COMMAND.
|
|
7616
|
+
const POINTER_TAIL = `Take work with \`${COMMAND.claim}\`. ${PERSON_ONLY_RULE}`;
|
|
7142
7617
|
/** The body of the project block: what `kanbo instructions short` prints. */
|
|
7143
7618
|
const PROJECT_BODY = [
|
|
7144
7619
|
"## Kanbo board",
|
|
@@ -7158,7 +7633,7 @@ const GLOBAL_BODY = [
|
|
|
7158
7633
|
* The block, markers included, with this body's version and hash on the start
|
|
7159
7634
|
* marker — and, when given, the kind of text it is (`k=orchestrator`).
|
|
7160
7635
|
*/
|
|
7161
|
-
function wrapInstructionBlock(body, version =
|
|
7636
|
+
function wrapInstructionBlock(body, version = 3, kind) {
|
|
7162
7637
|
return `<!-- KANBO_START v${version}${kind ? ` k=${kind}` : ""} h=${hashBody(body)} -->\n${body}\n${INSTRUCTION_END}`;
|
|
7163
7638
|
}
|
|
7164
7639
|
/** The block `kanbo init --instructions …` writes into a project's own file. */
|
|
@@ -7187,7 +7662,7 @@ function inspectBlock(text, current) {
|
|
|
7187
7662
|
kind
|
|
7188
7663
|
});
|
|
7189
7664
|
if (fields.trim() === "") return inspection(LEGACY_BLOCK_BODIES.includes(body) ? "legacy" : "edited");
|
|
7190
|
-
if (version !== void 0 && Number(version) >
|
|
7665
|
+
if (version !== void 0 && Number(version) > 3) return inspection("newer");
|
|
7191
7666
|
if (kind !== null && !POINTER_KINDS.has(kind)) return inspection("chosen");
|
|
7192
7667
|
if (version === void 0 || hash === void 0 || hashBody(body) !== hash) return inspection("edited");
|
|
7193
7668
|
return inspection(normalized.slice(found.start, found.end) === current ? "current" : "outdated");
|
|
@@ -8217,7 +8692,7 @@ function startUpdateCheck(deps = {}) {
|
|
|
8217
8692
|
setTimeout: globalThis.setTimeout,
|
|
8218
8693
|
clearTimeout: globalThis.clearTimeout
|
|
8219
8694
|
};
|
|
8220
|
-
const current = deps.currentVersion ?? "0.
|
|
8695
|
+
const current = deps.currentVersion ?? "0.5.0";
|
|
8221
8696
|
const controller = new AbortController();
|
|
8222
8697
|
let found = null;
|
|
8223
8698
|
const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
|
|
@@ -10649,7 +11124,7 @@ const KANBO_MCP_INSTRUCTIONS = [
|
|
|
10649
11124
|
"kanbo is a kanban board shared by people and agents; these tools read and change its cards.",
|
|
10650
11125
|
"",
|
|
10651
11126
|
`- At the start of a session call ${tool("kanbo_prime")} — it returns this board's columns and what each one means.`,
|
|
10652
|
-
`- Take work from ${tool("kanbo_ready")}.`,
|
|
11127
|
+
`- Take work from ${tool("kanbo_ready")}, and claim the card with ${tool("kanbo_card_claim")} before you start: it starts your run and moves the card into work in one step, so no other agent takes it.`,
|
|
10653
11128
|
`- ${MOVE_CARD_RULE} (${tool("kanbo_card_move")})`,
|
|
10654
11129
|
`- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
|
|
10655
11130
|
`- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
|
|
@@ -10740,7 +11215,7 @@ const ORCHESTRATOR_GUIDE = [
|
|
|
10740
11215
|
"## Start of every session",
|
|
10741
11216
|
"1. Run `kanbo prime` (tool: `kanbo_prime`) and read the column descriptions. On this board the description is the contract, not the name.",
|
|
10742
11217
|
"2. Run `kanbo ready` (`kanbo_ready`). If nothing is ready, say so and stop.",
|
|
10743
|
-
"3. Take the first card
|
|
11218
|
+
"3. Take the first card with `kanbo claim --agent orchestrator` (`kanbo_card_claim`): it starts your run and moves the card out of To Do into the board's first work-in-progress column in one step, so no other agent takes it, and it prints the run id you finish later. If another agent got there first, run `kanbo ready` again.",
|
|
10744
11219
|
"",
|
|
10745
11220
|
"## While working",
|
|
10746
11221
|
"- Write a status line (`kanbo card status-line <card> --text \"…\"`, `kanbo_status_line`) at every step and before and after anything that takes more than a minute. One sentence, present tense.",
|
|
@@ -10767,7 +11242,7 @@ const ORCHESTRATOR_GUIDE = [
|
|
|
10767
11242
|
"- Returned: the reason is the newest comment. The card is back in an earlier column; start a new run and address the reason first.",
|
|
10768
11243
|
"",
|
|
10769
11244
|
"## Never",
|
|
10770
|
-
"- Never run `kanbo approve`, `kanbo return`, `kanbo sprint close`, `kanbo run clear-session`, or anything that changes the columns: `kanbo columns add`, `kanbo columns rename`, `kanbo columns move`, `kanbo columns remove`, `kanbo columns template`, `kanbo columns add-standard`, `kanbo columns rules`. They are for a person and will fail in your shell anyway.",
|
|
11245
|
+
"- Never run `kanbo approve`, `kanbo return`, `kanbo sprint close`, `kanbo run clear-session`, or anything that changes the columns: `kanbo columns add`, `kanbo columns rename`, `kanbo columns move`, `kanbo columns remove`, `kanbo columns template`, `kanbo columns add-standard`, `kanbo columns rules`, `kanbo columns flow`. They are for a person and will fail in your shell anyway.",
|
|
10771
11246
|
"- Never delete or edit the board's database directly."
|
|
10772
11247
|
].join("\n");
|
|
10773
11248
|
//#endregion
|
|
@@ -10812,7 +11287,7 @@ function registerInstructionsCommand(program, dependencies = {}) {
|
|
|
10812
11287
|
const found = parseKind(kind);
|
|
10813
11288
|
const body = await readInstructionText(found);
|
|
10814
11289
|
const pointer = found === "short" || found === "global";
|
|
10815
|
-
const text = options.markers ? wrapInstructionBlock(body,
|
|
11290
|
+
const text = options.markers ? wrapInstructionBlock(body, 3, pointer ? void 0 : found) : body;
|
|
10816
11291
|
const hints = process.stderr.isTTY === true;
|
|
10817
11292
|
if (hints) process.stderr.write(`${HEADER}\n\n`);
|
|
10818
11293
|
console.log(text);
|
|
@@ -11219,7 +11694,7 @@ function readMoment(value) {
|
|
|
11219
11694
|
const cardMoveTool = defineKanboTool({
|
|
11220
11695
|
name: "kanbo_card_move",
|
|
11221
11696
|
title: "Move a card",
|
|
11222
|
-
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
|
|
11697
|
+
description: `Move a card to another column the moment its real state changes. Read kanbo_columns first: a card in the wrong column is a lie, and a column refuses a card that does not meet its entryRules yet, that comes from a column it does not accept (allowedFrom), or that would overfill it (wipLimit), listing what is missing. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
|
|
11223
11698
|
inputSchema: {
|
|
11224
11699
|
card: cardArgument,
|
|
11225
11700
|
column: z.string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`."),
|
|
@@ -11291,7 +11766,7 @@ const cardUpdateTool = defineKanboTool({
|
|
|
11291
11766
|
const columnsTool = defineKanboTool({
|
|
11292
11767
|
name: "kanbo_columns",
|
|
11293
11768
|
title: "Columns of the board",
|
|
11294
|
-
description: "List this board's columns in board order, each with the slug kanbo_card_move takes, what the column means here,
|
|
11769
|
+
description: "List this board's columns in board order, each with the slug kanbo_card_move takes, what the column means here, the entryRules a card must meet before you move it in, the columns it accepts cards from (allowedFrom, slugs; null for any) and the most cards it holds (wipLimit).",
|
|
11295
11770
|
inputSchema: {},
|
|
11296
11771
|
run: async (transport) => await transport.columns()
|
|
11297
11772
|
});
|
|
@@ -11305,6 +11780,38 @@ const primeTool = defineKanboTool({
|
|
|
11305
11780
|
inputSchema: {},
|
|
11306
11781
|
run: async (transport) => (await transport.prime()).text
|
|
11307
11782
|
});
|
|
11783
|
+
//#endregion
|
|
11784
|
+
//#region src/mcp/tools/card-claim.ts
|
|
11785
|
+
/**
|
|
11786
|
+
* Taking a card to work on, in one step: the run starts and the card moves out
|
|
11787
|
+
* of To Do into the board's first work-in-progress column in the same write,
|
|
11788
|
+
* so two agents never get the same card. Published to every host, including
|
|
11789
|
+
* one that records the runs of the agents it launches: a claim is how an agent
|
|
11790
|
+
* in any terminal takes work.
|
|
11791
|
+
*/
|
|
11792
|
+
const cardClaimTool = defineKanboTool({
|
|
11793
|
+
name: "kanbo_card_claim",
|
|
11794
|
+
title: "Claim a card",
|
|
11795
|
+
description: "Take a card to work on, in one step: it starts your run and moves the card from To Do into the board's first work-in-progress column. Without `card`, takes the first ready card. Two agents never get the same card: the one that loses is told board_nothing_ready or board_card_not_ready. Answers the card compact, the run id, the column, and the kanbo_run_finish call that ends the run.",
|
|
11796
|
+
inputSchema: {
|
|
11797
|
+
card: cardArgument.optional(),
|
|
11798
|
+
agent: z.string().min(1).optional().describe("What to call whoever works the card; who this server writes as when absent.")
|
|
11799
|
+
},
|
|
11800
|
+
aliases: {
|
|
11801
|
+
card: [...CARD_ALIASES, "issue"],
|
|
11802
|
+
agent: ["agentName"]
|
|
11803
|
+
},
|
|
11804
|
+
example: { card: "TST-5" },
|
|
11805
|
+
run: async (transport, input) => {
|
|
11806
|
+
const claimed = await transport.cardClaim(input);
|
|
11807
|
+
return {
|
|
11808
|
+
card: compactKanboCard(claimed.card),
|
|
11809
|
+
run: claimed.run.id,
|
|
11810
|
+
column: claimed.column.slug,
|
|
11811
|
+
finishWith: `kanbo_run_finish {"run":"${claimed.run.id}","state":"finished"}`
|
|
11812
|
+
};
|
|
11813
|
+
}
|
|
11814
|
+
});
|
|
11308
11815
|
/** The cards an agent may pick up right now. */
|
|
11309
11816
|
const readyTool = defineKanboTool({
|
|
11310
11817
|
name: "kanbo_ready",
|
|
@@ -11393,13 +11900,14 @@ const runStartTool = defineKanboTool({
|
|
|
11393
11900
|
/**
|
|
11394
11901
|
* Every tool the board has, in the order it declares them.
|
|
11395
11902
|
*
|
|
11396
|
-
* The list is the board's, not a server's: the same
|
|
11903
|
+
* The list is the board's, not a server's: the same seventeen are registered
|
|
11397
11904
|
* whether they run against a server or against the board directly, and the
|
|
11398
11905
|
* only thing a host decides is which of them it publishes.
|
|
11399
11906
|
*/
|
|
11400
11907
|
const KANBO_TOOLS = [
|
|
11401
11908
|
primeTool,
|
|
11402
11909
|
readyTool,
|
|
11910
|
+
cardClaimTool,
|
|
11403
11911
|
columnsTool,
|
|
11404
11912
|
defineKanboTool({
|
|
11405
11913
|
name: "kanbo_sprints",
|
|
@@ -11531,7 +12039,8 @@ async function createDbTransport(input) {
|
|
|
11531
12039
|
const workspaceId = workspace.id;
|
|
11532
12040
|
const actor = input.actor;
|
|
11533
12041
|
async function readColumns() {
|
|
11534
|
-
|
|
12042
|
+
const rows = await ops.listColumns(workspaceId);
|
|
12043
|
+
return rows.map((row) => projectKanboColumn(row, rows));
|
|
11535
12044
|
}
|
|
11536
12045
|
/** One card with what the run registry says about it, in the shape a tool prints. */
|
|
11537
12046
|
async function project(card, columns) {
|
|
@@ -11581,7 +12090,7 @@ async function createDbTransport(input) {
|
|
|
11581
12090
|
* the waiting and returned ones last said at most two more.
|
|
11582
12091
|
*/
|
|
11583
12092
|
async function projectPage(page, { attend = true } = {}) {
|
|
11584
|
-
const columns = page.columns.map(projectKanboColumn);
|
|
12093
|
+
const columns = page.columns.map((column) => projectKanboColumn(column, page.columns));
|
|
11585
12094
|
const runs = await ops.readBoardProjectionForIssues(page.cards.map((card) => card.id));
|
|
11586
12095
|
const attention = attend ? await ops.readCardAttention(page.cards, runs.map((run) => run.activeRun !== null)) : [];
|
|
11587
12096
|
return page.cards.map((card, index) => projectKanboCard({
|
|
@@ -11713,6 +12222,25 @@ async function createDbTransport(input) {
|
|
|
11713
12222
|
state,
|
|
11714
12223
|
errorText
|
|
11715
12224
|
}, actor));
|
|
12225
|
+
},
|
|
12226
|
+
cardClaim: async ({ card, agent }) => {
|
|
12227
|
+
await session.assertWritable();
|
|
12228
|
+
const named = card === void 0 ? void 0 : (await resolveCard(card)).id;
|
|
12229
|
+
const claimed = await ops.claimCard({
|
|
12230
|
+
workspaceId,
|
|
12231
|
+
issueId: named,
|
|
12232
|
+
run: {
|
|
12233
|
+
agentName: agent ?? actor.id ?? "agent",
|
|
12234
|
+
externalSessionRef: sessionRefFromEnvironment() ?? void 0,
|
|
12235
|
+
launchedByKind: "external"
|
|
12236
|
+
}
|
|
12237
|
+
}, actor);
|
|
12238
|
+
const columns = await readColumns();
|
|
12239
|
+
return {
|
|
12240
|
+
card: await project(claimed.card, columns),
|
|
12241
|
+
run: projectKanboRun(claimed.run),
|
|
12242
|
+
column: columns.find((column) => column.id === claimed.column.id)
|
|
12243
|
+
};
|
|
11716
12244
|
}
|
|
11717
12245
|
};
|
|
11718
12246
|
}
|
|
@@ -11754,7 +12282,8 @@ const TRANSPORT_METHODS = {
|
|
|
11754
12282
|
statusLine: true,
|
|
11755
12283
|
waitApproval: true,
|
|
11756
12284
|
runStart: true,
|
|
11757
|
-
runFinish: true
|
|
12285
|
+
runFinish: true,
|
|
12286
|
+
cardClaim: true
|
|
11758
12287
|
};
|
|
11759
12288
|
async function openMcpBoard(input) {
|
|
11760
12289
|
try {
|
|
@@ -12360,7 +12889,7 @@ function sendBoardPage(response, pathname, assets) {
|
|
|
12360
12889
|
* answering for a person — approving a card, sending it back or closing a
|
|
12361
12890
|
* sprint — as anyone but one is 403, dropping a pull request link somebody else made is the same 403
|
|
12362
12891
|
* for the same reason, and acting on a run that has already ended is 409,
|
|
12363
|
-
* because the answer would have been yes a moment earlier. An agent putting a
|
|
12892
|
+
* because the answer would have been yes a moment earlier. Starting a run on a card that has one going, and claiming a card nobody can take right now, are 409 for the same reason. An agent putting a
|
|
12364
12893
|
* card in a column whose entry rules it does not meet yet is 409 for the same
|
|
12365
12894
|
* reason turned around: the answer will be yes once the card meets them, and
|
|
12366
12895
|
* `details.unmet` says what is missing. Deleting a column that still holds
|
|
@@ -12378,6 +12907,14 @@ const BOARD_ERROR_RESPONSES = {
|
|
|
12378
12907
|
message: "Only a person can approve a card",
|
|
12379
12908
|
code: "issue_approval_requires_user"
|
|
12380
12909
|
},
|
|
12910
|
+
board_card_not_ready: {
|
|
12911
|
+
status: 409,
|
|
12912
|
+
message: "That card is not there to take: it is not in To Do, waits for a person, or has a run going"
|
|
12913
|
+
},
|
|
12914
|
+
board_card_run_active: {
|
|
12915
|
+
status: 409,
|
|
12916
|
+
message: "The card already has a run going"
|
|
12917
|
+
},
|
|
12381
12918
|
board_column_name_taken: {
|
|
12382
12919
|
status: 400,
|
|
12383
12920
|
message: "Another column already has that name"
|
|
@@ -12417,6 +12954,14 @@ const BOARD_ERROR_RESPONSES = {
|
|
|
12417
12954
|
status: 400,
|
|
12418
12955
|
message: "Execution mode cannot change once the card has been launched"
|
|
12419
12956
|
},
|
|
12957
|
+
board_flow_rule_invalid: {
|
|
12958
|
+
status: 400,
|
|
12959
|
+
message: "A column accepts cards from other columns of its own board, and holds at least one card"
|
|
12960
|
+
},
|
|
12961
|
+
board_nothing_ready: {
|
|
12962
|
+
status: 409,
|
|
12963
|
+
message: "No card in To Do is ready to take"
|
|
12964
|
+
},
|
|
12420
12965
|
board_pull_request_invalid: {
|
|
12421
12966
|
status: 400,
|
|
12422
12967
|
message: "Not a GitHub pull request: give its URL or owner/repo#number"
|
|
@@ -12832,6 +13377,14 @@ const startRunBody = z.object({
|
|
|
12832
13377
|
]).optional(),
|
|
12833
13378
|
externalSessionRef: z.string().nullable().optional()
|
|
12834
13379
|
});
|
|
13380
|
+
const claimBody = z.object({
|
|
13381
|
+
workspaceId: id,
|
|
13382
|
+
issueId: id.optional(),
|
|
13383
|
+
agentName: id.optional(),
|
|
13384
|
+
branch: z.string().nullable().optional(),
|
|
13385
|
+
executionMode: executionMode.optional(),
|
|
13386
|
+
externalSessionRef: z.string().nullable().optional()
|
|
13387
|
+
});
|
|
12835
13388
|
const finishRunBody = z.object({
|
|
12836
13389
|
state: z.enum([
|
|
12837
13390
|
"finished",
|
|
@@ -12878,7 +13431,8 @@ async function toWrittenCardView(ops, card) {
|
|
|
12878
13431
|
function toStatusView(status) {
|
|
12879
13432
|
return {
|
|
12880
13433
|
...status,
|
|
12881
|
-
entryRules: readEntryRules(status)
|
|
13434
|
+
entryRules: readEntryRules(status),
|
|
13435
|
+
allowedFrom: readFlowRules(status).allowedFrom
|
|
12882
13436
|
};
|
|
12883
13437
|
}
|
|
12884
13438
|
function toCommentView(comment, personWriterId = null) {
|
|
@@ -13313,6 +13867,31 @@ function createIssueRoutes(session, options = {}) {
|
|
|
13313
13867
|
}, actors.placement));
|
|
13314
13868
|
}
|
|
13315
13869
|
}),
|
|
13870
|
+
route({
|
|
13871
|
+
method: "POST",
|
|
13872
|
+
path: "/issues/claim",
|
|
13873
|
+
body: claimBody,
|
|
13874
|
+
write: true,
|
|
13875
|
+
handle: async ({ body, actors }) => {
|
|
13876
|
+
const named = requireWorkspace(body.workspaceId);
|
|
13877
|
+
if (body.issueId !== void 0) await requireCard(body.issueId);
|
|
13878
|
+
const claimed = await ops.claimCard({
|
|
13879
|
+
workspaceId: named,
|
|
13880
|
+
issueId: body.issueId,
|
|
13881
|
+
run: {
|
|
13882
|
+
agentName: body.agentName ?? actors.writer.id ?? "agent",
|
|
13883
|
+
branch: body.branch,
|
|
13884
|
+
executionMode: body.executionMode,
|
|
13885
|
+
externalSessionRef: body.externalSessionRef
|
|
13886
|
+
}
|
|
13887
|
+
}, actors.placement);
|
|
13888
|
+
return {
|
|
13889
|
+
card: await toWrittenCardView(ops, claimed.card),
|
|
13890
|
+
run: await toRunView(claimed.run),
|
|
13891
|
+
column: toStatusView(claimed.column)
|
|
13892
|
+
};
|
|
13893
|
+
}
|
|
13894
|
+
}),
|
|
13316
13895
|
route({
|
|
13317
13896
|
method: "PATCH",
|
|
13318
13897
|
path: "/issues/bulk",
|
|
@@ -14447,6 +15026,7 @@ function registerKanboCommands(program, context = {}) {
|
|
|
14447
15026
|
registerServeCommand(program);
|
|
14448
15027
|
program.commandsGroup("For agents and integrations:");
|
|
14449
15028
|
registerMcpCommand(program);
|
|
15029
|
+
registerClaimCommand(program);
|
|
14450
15030
|
registerRunCommands(program);
|
|
14451
15031
|
registerCapabilitiesCommand(program);
|
|
14452
15032
|
program.commandsGroup("Shared Postgres boards:");
|
|
@@ -14470,6 +15050,7 @@ function registerKanboCommands(program, context = {}) {
|
|
|
14470
15050
|
const KANBO_TOOL_NAMES = [
|
|
14471
15051
|
"kanbo_prime",
|
|
14472
15052
|
"kanbo_ready",
|
|
15053
|
+
"kanbo_card_claim",
|
|
14473
15054
|
"kanbo_columns",
|
|
14474
15055
|
"kanbo_sprints",
|
|
14475
15056
|
"kanbo_card_get",
|
|
@@ -14495,6 +15076,7 @@ const KANBO_TOOL_NAMES = [
|
|
|
14495
15076
|
const KANBO_TOOL_CLI_EQUIVALENTS = {
|
|
14496
15077
|
kanbo_prime: { kanbo: "kanbo prime" },
|
|
14497
15078
|
kanbo_ready: { kanbo: "kanbo ready" },
|
|
15079
|
+
kanbo_card_claim: { kanbo: "kanbo claim [card] [--agent <name>]" },
|
|
14498
15080
|
kanbo_columns: { kanbo: "kanbo columns list" },
|
|
14499
15081
|
kanbo_sprints: { kanbo: "kanbo sprint list" },
|
|
14500
15082
|
kanbo_card_get: { kanbo: "kanbo card get <id>" },
|
|
@@ -14642,13 +15224,15 @@ function buildCapabilitiesManifest() {
|
|
|
14642
15224
|
"Only a person may close a sprint. `kanbo sprint close` refuses an agent's shell (exit 4), and the operation refuses any actor but a person; there is no MCP tool that closes one — `kanbo_sprints` only reads. Unlike approval, the database role does not enforce this.",
|
|
14643
15225
|
`The agent role (\`${BOARD_AGENT_ROLE}\`) may not delete a card at all — it holds select, insert and update on \`issues\` and nothing more, even though it may delete freely on every other board table.`,
|
|
14644
15226
|
"A column may ask a card to meet entry rules before it enters (`entryRules`). Anyone but a person moving, creating or bulk-moving a card into it early is refused with `board_column_rules_unmet` and one line per unmet rule (CLI exit 1); a person goes through and is told what is missing. Only a person sets the rules — `kanbo columns rules` refuses an agent's shell (exit 4). The database role does not enforce them.",
|
|
15227
|
+
"A column may name the columns it accepts cards from (`allowedFrom`) and the most cards it holds (`wipLimit`). Anyone but a person moving, creating, bulk-moving or claiming a card against them is refused with `board_column_rules_unmet` (`allowed_from`, `wip_limit`); a person goes through and is told. A new board's Done accepts cards from In Review only. Only a person sets them — `kanbo columns flow` refuses an agent's shell (exit 4). The database role does not enforce them.",
|
|
15228
|
+
"A claim (`kanbo claim`, `kanbo_card_claim`, `POST /issues/claim`) takes a ready card from To Do, starts its run and moves it into the board's first `started` column in one write; two claimers never get the same card, and the one that loses is told `board_nothing_ready` or `board_card_not_ready`. A card has one run going at a time: starting a second is refused with `board_card_run_active`, on every path.",
|
|
14645
15229
|
"Only a person changes the board's columns: `kanbo columns add`, `rename`, `move`, `remove`, `template` and `add-standard` refuse an agent's shell (exit 4), and the rename, move, remove and add-missing operations refuse any actor but a person (`board_column_structure_requires_user`); no MCP tool changes columns. To Do can be neither removed nor renamed away from its slug `to_do`: `kanbo ready` takes work from it.",
|
|
14646
15230
|
"No board tool or command deletes a card, agent role or not. Cancelling one is a move to another column. The one exception is `kanbo serve`'s `DELETE /issues/:id`, which it answers only for a request presenting its token, on a server a person started, that has not sent `x-kanbo-actor: agent` (403 `issue_delete_requires_user` otherwise).",
|
|
14647
15231
|
"`kanbo migrate` and `kanbo roles apply` refuse a host app's own database file: the app migrates it, and this package never touches it.",
|
|
14648
|
-
"A write to a board older than this build's schema epoch is refused (exit 3) until `kanbo migrate` runs (on a host app's database, until the app migrates it)
|
|
15232
|
+
"A write to a board older than this build's schema epoch is refused (exit 3) until `kanbo migrate` runs (on a host app's database, until the app migrates it), and so is a read of a board file that lacks a table or a column this build reads; a board whose only difference is its schema stamp stays readable.",
|
|
14649
15233
|
"There is no MCP tool that approves a card, and there never will be: `kanbo_wait_approval` hands the card to a person and ends the turn; only a person takes it back."
|
|
14650
15234
|
]
|
|
14651
15235
|
};
|
|
14652
15236
|
}
|
|
14653
15237
|
//#endregion
|
|
14654
|
-
export {
|
|
15238
|
+
export { serializeEntryRules as $, projectKanboSprint as A, createBoardOps as B, pageOf as C, toIssueView as Ct, projectKanboFieldChange as D, nextIssueOrder as Dt, projectKanboColumn as E, nextIssueNumber as Et, BINDING_FILE_PATH as F, FLOW_RULES as G, formatExternalSessionRef as H, ignoreBoardFile as I, ENTRY_RULE_DESCRIPTIONS as J, readFlowRules as K, readBinding as L, renderCapabilitiesMarkdown as M, maskDatabaseUrl as N, projectKanboPullRequest as O, readIssuePrefix as Ot, maskDatabaseUrls as P, readEntryRules as Q, readProjectBinding as R, matchesKanboCardQuery as S, normalizeCommentAuthorKind as St, projectKanboCardComment as T, nextIssueIdentity as Tt, parseExternalSessionRef as U, cardDisplayTitle as V, pullRequestFactKeys as W, evaluateEntryRules as X, UNMET_ENTRY_RULE_REASONS as Y, readChecklist as Z, suggestCardKey as _, formatPlainActivityValue as _t, BOARD_ERROR_RESPONSES as a, DEFAULT_STATUSES as at, DEFAULT_KANBO_COMMENT_LIMIT as b, readSystemCommentKind as bt, registerKanboTools as c, diffFieldChanges as ct, KANBO_MCP_INSTRUCTIONS as d, currentUnixSeconds as dt, COLUMN_CATALOGUE as et, KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI as f, activityKindOrder as ft, isValidCardKey as g, formatActivityIssueReference as gt, CARD_KEY_PATTERN as h, commentAuthorReference as ht, KANBO_TOOL_NAMES as i, runBoardWrite as it, projectKanboSubCard as j, projectKanboRun as k, KANBO_TOOLS as l, previewComment as lt, registerKanboCapabilitiesResources as m, activityToken as mt, buildCapabilitiesManifest as n, COLUMN_TEMPLATE_IDS as nt, KANBO_MCP_SERVER_NAME as o, normalizeStatusName as ot, KANBO_CAPABILITIES_RESOURCE_URI as p, activityText as pt, ENTRY_RULES as q, KANBO_TOOL_CLI_EQUIVALENTS as r, readChangeSeq as rt, createKanboMcpServer as s, TRACKED_FIELDS as st, CAPABILITIES_MANIFEST_VERSION as t, COLUMN_TEMPLATES as tt, buildKanboTools as u, BOARD_ACTOR as ut, DEFAULT_BOARD_FILE_PATH as v, isEmptyActivityValue as vt, projectKanboCard as w, formatIssueId as wt, KANBO_CARD_INCLUDES as x, isOutsideWriter as xt, DEFAULT_KANBO_CARD_INCLUDES as y, parseActivityStringArray as yt, writeBinding as z };
|