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
package/dist/cli-main.cjs
CHANGED
|
@@ -272,6 +272,14 @@ const BOARD_ERROR_TEXTS = {
|
|
|
272
272
|
text: "Only a person can approve a card.",
|
|
273
273
|
next: AS_A_PERSON
|
|
274
274
|
}),
|
|
275
|
+
board_card_not_ready: (details) => ({
|
|
276
|
+
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.`,
|
|
277
|
+
next: "kanbo ready"
|
|
278
|
+
}),
|
|
279
|
+
board_card_run_active: (details) => ({
|
|
280
|
+
text: `${named(details.issueId)} already has a run going: ${named(details.agentName)}'s, run ${named(details.runId)}.`,
|
|
281
|
+
next: `kanbo run finish ${named(details.runId)} --state stopped`
|
|
282
|
+
}),
|
|
275
283
|
board_column_name_taken: (details) => ({
|
|
276
284
|
text: `Another column, ${named(details.takenBy)}, already has that name (same slug). Pick another name.`,
|
|
277
285
|
next: "kanbo columns list"
|
|
@@ -302,6 +310,14 @@ const BOARD_ERROR_TEXTS = {
|
|
|
302
310
|
}),
|
|
303
311
|
board_entry_rule_invalid: (details) => ({ text: `Unknown column rule: ${list(details.rules)}. Use any of ${list(details.allowed)}.` }),
|
|
304
312
|
board_execution_mode_locked: (details) => ({ text: `${named(details.issueId)} has been started, so where it is worked (${named(details.executionMode)}) can't change now.` }),
|
|
313
|
+
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.` } : {
|
|
314
|
+
text: `A column accepts cards only from other columns of its own board, and ${list(details.allowedFrom)} is not one.`,
|
|
315
|
+
next: "kanbo columns list"
|
|
316
|
+
},
|
|
317
|
+
board_nothing_ready: () => ({
|
|
318
|
+
text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
|
|
319
|
+
next: "kanbo ready"
|
|
320
|
+
}),
|
|
305
321
|
board_pull_request_invalid: (details) => ({ text: `"${named(details.value)}" is not a GitHub pull request. Give its link or owner/repo#number.` }),
|
|
306
322
|
board_pull_request_not_found: (details) => ({
|
|
307
323
|
text: `${named(details.issueId)} has no pull request link "${named(details.linkId)}".`,
|
|
@@ -367,7 +383,7 @@ const BOARD_ERROR_TEXTS = {
|
|
|
367
383
|
issue_relation_self_reference: () => ({ text: "A card can't be linked to itself." }),
|
|
368
384
|
issue_status_name_empty: () => ({ text: "Give the column a name." }),
|
|
369
385
|
issue_status_not_found: (details) => ({
|
|
370
|
-
text: `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
|
|
386
|
+
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.`,
|
|
371
387
|
next: "kanbo columns list"
|
|
372
388
|
}),
|
|
373
389
|
issue_status_reference_conflict: () => ({ text: "Name the column once: by its id or by its name, not both." }),
|
|
@@ -3561,7 +3577,7 @@ new Command();
|
|
|
3561
3577
|
* `package.version`. A build carries it as a literal; running from source
|
|
3562
3578
|
* (tests, `tsx`) reads the package's own `package.json` instead.
|
|
3563
3579
|
*/
|
|
3564
|
-
const KANBO_PACKAGE_VERSION = "0.
|
|
3580
|
+
const KANBO_PACKAGE_VERSION = "0.5.0";
|
|
3565
3581
|
//#endregion
|
|
3566
3582
|
//#region src/cli/agent-shell.ts
|
|
3567
3583
|
/**
|
|
@@ -3700,6 +3716,7 @@ const HUMAN_ONLY_ACTIONS = [
|
|
|
3700
3716
|
"return",
|
|
3701
3717
|
"sprint close",
|
|
3702
3718
|
"columns rules",
|
|
3719
|
+
"columns flow",
|
|
3703
3720
|
"columns add",
|
|
3704
3721
|
"columns rename",
|
|
3705
3722
|
"columns move",
|
|
@@ -3713,6 +3730,7 @@ const HUMAN_ONLY_MESSAGES = {
|
|
|
3713
3730
|
"return": describeReturnRefusal,
|
|
3714
3731
|
"sprint close": describeSprintCloseRefusal,
|
|
3715
3732
|
"columns rules": describeColumnRulesRefusal,
|
|
3733
|
+
"columns flow": describeColumnRulesRefusal,
|
|
3716
3734
|
"columns add": describeColumnChangesRefusal,
|
|
3717
3735
|
"columns rename": describeColumnChangesRefusal,
|
|
3718
3736
|
"columns move": describeColumnChangesRefusal,
|
|
@@ -10014,6 +10032,14 @@ const HIDDEN_ACTIVITY_FIELDS = /* @__PURE__ */ new Set([
|
|
|
10014
10032
|
"order",
|
|
10015
10033
|
"waitingFor"
|
|
10016
10034
|
]);
|
|
10035
|
+
/**
|
|
10036
|
+
* The column a card was created in, recorded as a change from no column at the
|
|
10037
|
+
* card's own creation time. The feed's `created` item already stands for it,
|
|
10038
|
+
* and the field history keeps it.
|
|
10039
|
+
*/
|
|
10040
|
+
function isStartingColumn(change, issue) {
|
|
10041
|
+
return change.field === "statusId" && change.fromValue === null && change.createdAt === issue.createdAt;
|
|
10042
|
+
}
|
|
10017
10043
|
const ACTIVITY_FIELD_BY_RAW_FIELD = {
|
|
10018
10044
|
assigneeId: "assignee",
|
|
10019
10045
|
description: "description",
|
|
@@ -10054,7 +10080,7 @@ async function listActivity(store, issueId, resolver) {
|
|
|
10054
10080
|
workspaceById: resolver.workspaceById
|
|
10055
10081
|
};
|
|
10056
10082
|
const commentItems = await Promise.all((await store.comments.listByIssue(issueId)).map(async (comment) => await toCommentActivityItem(comment, resolver)));
|
|
10057
|
-
const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
|
|
10083
|
+
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);
|
|
10058
10084
|
return [
|
|
10059
10085
|
{
|
|
10060
10086
|
id: `${issue.id}:created`,
|
|
@@ -10678,6 +10704,63 @@ function describeColumnRefusal(details) {
|
|
|
10678
10704
|
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");
|
|
10679
10705
|
}
|
|
10680
10706
|
//#endregion
|
|
10707
|
+
//#region src/domain/flow-rules.ts
|
|
10708
|
+
/** The column the standard flow guards, and the columns it lets cards in from, by slug. */
|
|
10709
|
+
const STANDARD_FLOW_TARGET = "done";
|
|
10710
|
+
const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
|
|
10711
|
+
/** A column's flow rules, out of what the row stores. */
|
|
10712
|
+
function readFlowRules(column) {
|
|
10713
|
+
return {
|
|
10714
|
+
allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
|
|
10715
|
+
wipLimit: column.wipLimit
|
|
10716
|
+
};
|
|
10717
|
+
}
|
|
10718
|
+
/** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
|
|
10719
|
+
function serializeAllowedFrom(allowedFrom) {
|
|
10720
|
+
return allowedFrom === null ? null : JSON.stringify(allowedFrom);
|
|
10721
|
+
}
|
|
10722
|
+
/**
|
|
10723
|
+
* The flow rules a card entering `column` does not meet, where it may come from
|
|
10724
|
+
* first. Pure: what the board holds arrives in `facts`. A card being created
|
|
10725
|
+
* comes from no column, so a column that names where cards come from does not
|
|
10726
|
+
* take it.
|
|
10727
|
+
*/
|
|
10728
|
+
function evaluateFlowRules(card, column, rules, facts) {
|
|
10729
|
+
const unmet = [];
|
|
10730
|
+
if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
|
|
10731
|
+
rule: "allowed_from",
|
|
10732
|
+
reason: "not_allowed_from",
|
|
10733
|
+
detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
|
|
10734
|
+
});
|
|
10735
|
+
if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
|
|
10736
|
+
rule: "wip_limit",
|
|
10737
|
+
reason: "column_full",
|
|
10738
|
+
detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
|
|
10739
|
+
});
|
|
10740
|
+
return unmet;
|
|
10741
|
+
}
|
|
10742
|
+
function describeAllowedFrom(name, allowedFrom, columnNames) {
|
|
10743
|
+
const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
|
|
10744
|
+
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`;
|
|
10745
|
+
}
|
|
10746
|
+
/** `A`, `A and B`, `A, B and C`. */
|
|
10747
|
+
function joinWords(words) {
|
|
10748
|
+
return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
|
|
10749
|
+
}
|
|
10750
|
+
/**
|
|
10751
|
+
* The standard flow on these columns: Done, and the columns it takes cards from
|
|
10752
|
+
* — In Review, and QA when there is one — in board order. `null` when the board
|
|
10753
|
+
* has no Done, or neither of the two.
|
|
10754
|
+
*/
|
|
10755
|
+
function standardFlowOf(columns) {
|
|
10756
|
+
const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
|
|
10757
|
+
const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
|
|
10758
|
+
return target && sources.length > 0 ? {
|
|
10759
|
+
target,
|
|
10760
|
+
sources
|
|
10761
|
+
} : null;
|
|
10762
|
+
}
|
|
10763
|
+
//#endregion
|
|
10681
10764
|
//#region src/ops/columns.ts
|
|
10682
10765
|
/** The columns of one workspace, in board order. */
|
|
10683
10766
|
async function listColumns(store, workspaceId) {
|
|
@@ -10747,6 +10830,7 @@ async function applyColumnTemplate(store, workspaceId, columns, options, scope)
|
|
|
10747
10830
|
workspaceId,
|
|
10748
10831
|
...column
|
|
10749
10832
|
}, order);
|
|
10833
|
+
await writeStandardFlow(tx, await listColumns(tx, workspaceId));
|
|
10750
10834
|
return {
|
|
10751
10835
|
statuses: await listColumns(tx, workspaceId),
|
|
10752
10836
|
added: columns.map((column) => column.name)
|
|
@@ -10856,6 +10940,77 @@ async function setColumnEntryRules(store, workspaceId, nameOrId, entryRules, act
|
|
|
10856
10940
|
return await updateColumn(tx, (await requireColumn(tx, workspaceId, nameOrId)).id, { entryRules }, actor, { tx });
|
|
10857
10941
|
}, scope);
|
|
10858
10942
|
}
|
|
10943
|
+
/** The largest WIP limit: what a Postgres `integer` column holds. */
|
|
10944
|
+
const MAX_WIP_LIMIT = 2147483647;
|
|
10945
|
+
/**
|
|
10946
|
+
* Set where a card may enter the column from and how many cards it holds:
|
|
10947
|
+
* `allowedFrom` the ids of other columns of its board (`null` for any, `[]` for
|
|
10948
|
+
* none), kept once each in board order; `wipLimit` a whole number from 1 to 2147483647, or `null`. A
|
|
10949
|
+
* person's to set, like the entry rules: an agent that could open a column
|
|
10950
|
+
* could walk any card into it.
|
|
10951
|
+
*/
|
|
10952
|
+
async function setColumnFlowRules(store, columnId, rules, actor, scope) {
|
|
10953
|
+
assertPersonSetsEntryRules(actor, { statusId: columnId });
|
|
10954
|
+
if (rules.wipLimit !== null && !(Number.isSafeInteger(rules.wipLimit) && rules.wipLimit >= 1 && rules.wipLimit <= MAX_WIP_LIMIT)) throw new BoardError("board_flow_rule_invalid", {
|
|
10955
|
+
statusId: columnId,
|
|
10956
|
+
wipLimit: rules.wipLimit
|
|
10957
|
+
});
|
|
10958
|
+
return await runBoardWrite(store, async ({ tx }) => {
|
|
10959
|
+
const column = await tx.statuses.findById(columnId);
|
|
10960
|
+
if (!column) throw new BoardError("issue_status_not_found", { statusId: columnId });
|
|
10961
|
+
const allowedFrom = rules.allowedFrom === null ? null : allowedFromInBoardOrder(await listColumns(tx, column.workspaceId), column, rules.allowedFrom);
|
|
10962
|
+
await tx.statuses.update(columnId, {
|
|
10963
|
+
allowedFrom: serializeAllowedFrom(allowedFrom),
|
|
10964
|
+
wipLimit: rules.wipLimit
|
|
10965
|
+
});
|
|
10966
|
+
return await tx.statuses.findById(columnId);
|
|
10967
|
+
}, scope);
|
|
10968
|
+
}
|
|
10969
|
+
/** The ids asked for, once each and in board order — or the refusal naming those that are not another column of this board. */
|
|
10970
|
+
function allowedFromInBoardOrder(columns, column, requested) {
|
|
10971
|
+
const others = columns.filter((other) => other.id !== column.id);
|
|
10972
|
+
const invalid = requested.filter((id) => !others.some((other) => other.id === id));
|
|
10973
|
+
if (invalid.length > 0) throw new BoardError("board_flow_rule_invalid", {
|
|
10974
|
+
statusId: column.id,
|
|
10975
|
+
allowedFrom: invalid
|
|
10976
|
+
});
|
|
10977
|
+
return others.filter((other) => requested.includes(other.id)).map((other) => other.id);
|
|
10978
|
+
}
|
|
10979
|
+
/**
|
|
10980
|
+
* Put the standard flow on a board: Done accepts cards from In Review only —
|
|
10981
|
+
* and from QA, when the board has it — so nothing reaches Done but through
|
|
10982
|
+
* review. Done's WIP limit and every other column stay as they are. A board
|
|
10983
|
+
* without Done, or with neither In Review nor QA, has nothing to apply it to.
|
|
10984
|
+
*/
|
|
10985
|
+
async function applyStandardFlow(store, workspaceId, actor, scope) {
|
|
10986
|
+
assertPersonSetsEntryRules(actor, { workspaceId });
|
|
10987
|
+
return await runBoardWrite(store, async ({ tx }) => {
|
|
10988
|
+
const columns = await listColumns(tx, workspaceId);
|
|
10989
|
+
if (!await writeStandardFlow(tx, columns)) throw new BoardError("issue_status_not_found", {
|
|
10990
|
+
workspaceId,
|
|
10991
|
+
statusName: columns.some((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET) ? "In Review" : "Done"
|
|
10992
|
+
});
|
|
10993
|
+
return await listColumns(tx, workspaceId);
|
|
10994
|
+
}, scope);
|
|
10995
|
+
}
|
|
10996
|
+
/** Done takes cards from the board's In Review and QA; `false`, with nothing written, when the board has no such columns. */
|
|
10997
|
+
async function writeStandardFlow(store, columns) {
|
|
10998
|
+
const flow = standardFlowOf(columns);
|
|
10999
|
+
if (!flow) return false;
|
|
11000
|
+
await store.statuses.update(flow.target.id, { allowedFrom: serializeAllowedFrom(flow.sources.map((column) => column.id)) });
|
|
11001
|
+
return true;
|
|
11002
|
+
}
|
|
11003
|
+
/**
|
|
11004
|
+
* Take a deleted column out of every list of where cards may come from. A list
|
|
11005
|
+
* it empties stays empty: a column that took cards only from it does not open
|
|
11006
|
+
* to every column.
|
|
11007
|
+
*/
|
|
11008
|
+
async function dropFromAllowedFrom(store, workspaceId, statusId) {
|
|
11009
|
+
for (const other of await listColumns(store, workspaceId)) {
|
|
11010
|
+
const { allowedFrom } = readFlowRules(other);
|
|
11011
|
+
if (allowedFrom?.includes(statusId)) await store.statuses.update(other.id, { allowedFrom: serializeAllowedFrom(allowedFrom.filter((id) => id !== statusId)) });
|
|
11012
|
+
}
|
|
11013
|
+
}
|
|
10859
11014
|
/**
|
|
10860
11015
|
* Take an empty column off the board, and number the columns left 0..n-1 in
|
|
10861
11016
|
* the same write.
|
|
@@ -10882,6 +11037,7 @@ async function deleteColumn(store, statusId, scope, actor) {
|
|
|
10882
11037
|
hint: "Move its cards to another column first, or remove it with removeColumn and moveCardsTo."
|
|
10883
11038
|
});
|
|
10884
11039
|
await tx.statuses.delete(statusId);
|
|
11040
|
+
await dropFromAllowedFrom(tx, column.workspaceId, statusId);
|
|
10885
11041
|
await renumberColumns(tx, column.workspaceId);
|
|
10886
11042
|
}, scope);
|
|
10887
11043
|
}
|
|
@@ -11161,24 +11317,46 @@ function pullRequestFactKeys({ owner, repo, number }) {
|
|
|
11161
11317
|
//#endregion
|
|
11162
11318
|
//#region src/ops/entry-rules.ts
|
|
11163
11319
|
/**
|
|
11164
|
-
* The column's entry rules the card does not meet
|
|
11165
|
-
*
|
|
11166
|
-
*
|
|
11320
|
+
* The column's entry rules and flow rules the card does not meet, entry rules
|
|
11321
|
+
* first (ruling 6-2; board-workflow spec, Part 2). A column with no rules
|
|
11322
|
+
* answers at once, without reading anything; otherwise only the facts its
|
|
11323
|
+
* rules need are read. `entering` counts the cards this same write already let
|
|
11324
|
+
* into the column, so a bulk move is held to the WIP limit as a whole.
|
|
11167
11325
|
*/
|
|
11168
|
-
async function checkColumnEntry(store, card, column) {
|
|
11326
|
+
async function checkColumnEntry(store, card, column, entering) {
|
|
11169
11327
|
const rules = readEntryRules(column);
|
|
11170
|
-
|
|
11171
|
-
|
|
11328
|
+
const unmet = rules.length === 0 ? [] : evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
|
|
11329
|
+
const flow = readFlowRules(column);
|
|
11330
|
+
if (flow.allowedFrom === null && flow.wipLimit === null) return unmet;
|
|
11331
|
+
return [...unmet, ...evaluateFlowRules(card, column, flow, await readFlowRuleFacts(store, column, flow, entering))];
|
|
11332
|
+
}
|
|
11333
|
+
/** 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. */
|
|
11334
|
+
async function readFlowRuleFacts(store, column, flow, entering) {
|
|
11335
|
+
const columnNames = flow.allowedFrom === null ? /* @__PURE__ */ new Map() : new Map((await store.statuses.listByWorkspace(column.workspaceId)).map((other) => [other.id, other.name]));
|
|
11336
|
+
if (flow.wipLimit === null) return {
|
|
11337
|
+
columnNames,
|
|
11338
|
+
cardsInColumn: 0
|
|
11339
|
+
};
|
|
11340
|
+
await store.statuses.lockById(column.id);
|
|
11341
|
+
return {
|
|
11342
|
+
columnNames,
|
|
11343
|
+
cardsInColumn: (await store.issues.listPage({
|
|
11344
|
+
workspaceId: column.workspaceId,
|
|
11345
|
+
statusIds: [column.id],
|
|
11346
|
+
limit: 1
|
|
11347
|
+
})).total + entering
|
|
11348
|
+
};
|
|
11172
11349
|
}
|
|
11173
11350
|
/**
|
|
11174
|
-
* Hold a card at the door of a column that asks for more than it has
|
|
11175
|
-
* 6-3)
|
|
11176
|
-
*
|
|
11177
|
-
*
|
|
11178
|
-
*
|
|
11351
|
+
* Hold a card at the door of a column that asks for more than it has — entry
|
|
11352
|
+
* rules (ruling 6-3), or flow rules: a column it may not come from, or a column
|
|
11353
|
+
* that is full. Anyone but a person — or a person's own terminal,
|
|
11354
|
+
* `placesForPerson` (ruling 6-6) — is refused with `board_column_rules_unmet`,
|
|
11355
|
+
* which names the column and everything unmet; a person is let through, and
|
|
11356
|
+
* the unmet list comes back so the caller can warn them.
|
|
11179
11357
|
*/
|
|
11180
|
-
async function enforceColumnEntry(store, card, column, actor) {
|
|
11181
|
-
const unmet = await checkColumnEntry(store, card, column);
|
|
11358
|
+
async function enforceColumnEntry(store, card, column, actor, entering = 0) {
|
|
11359
|
+
const unmet = await checkColumnEntry(store, card, column, entering);
|
|
11182
11360
|
if (unmet.length > 0 && actor.kind !== "user" && !actor.placesForPerson) throw new BoardError("board_column_rules_unmet", {
|
|
11183
11361
|
issueId: card.id,
|
|
11184
11362
|
column: column.name,
|
|
@@ -11191,14 +11369,17 @@ async function enforceColumnEntry(store, card, column, actor) {
|
|
|
11191
11369
|
* The same door for several cards entering one column at once — a bulk status
|
|
11192
11370
|
* change. All or nothing: the first card an agent may not move refuses the
|
|
11193
11371
|
* whole change. A person gets back the cards that entered with rules unmet.
|
|
11372
|
+
* Each card counts against the column's WIP limit as the cards before it in the same change entered it.
|
|
11194
11373
|
*/
|
|
11195
11374
|
async function enforceColumnEntryForCards(store, issueIds, statusId, actor) {
|
|
11196
11375
|
const column = await store.statuses.findById(statusId);
|
|
11197
11376
|
if (!column) throw new BoardError("issue_status_not_found", { statusId });
|
|
11198
11377
|
const entered = [];
|
|
11378
|
+
let entering = 0;
|
|
11199
11379
|
for (const card of await store.issues.listByIds(issueIds)) {
|
|
11200
11380
|
if (card.statusId === column.id) continue;
|
|
11201
|
-
const unmet = await enforceColumnEntry(store, card, column, actor);
|
|
11381
|
+
const unmet = await enforceColumnEntry(store, card, column, actor, entering);
|
|
11382
|
+
entering++;
|
|
11202
11383
|
if (unmet.length > 0) entered.push({
|
|
11203
11384
|
issueId: card.id,
|
|
11204
11385
|
unmet
|
|
@@ -11353,11 +11534,24 @@ const STOPPED_BY_YOU_STATUS_LINE = "stopped by you";
|
|
|
11353
11534
|
* the board reads the same whether the agent was launched by an app or from
|
|
11354
11535
|
* somewhere else entirely. The attempt number is simply how many runs the card
|
|
11355
11536
|
* has had — a card whose runs were all launched elsewhere still counts them.
|
|
11537
|
+
*
|
|
11538
|
+
* A card has one run going at a time. A start on a card whose run is still going
|
|
11539
|
+
* is refused with `board_card_run_active`, naming that run — whichever path it
|
|
11540
|
+
* came by, an app's launch, `kanbo run start`, MCP, or a second app on a shared
|
|
11541
|
+
* Postgres board. The card's row is locked first, so two starts racing for it
|
|
11542
|
+
* are settled one after the other.
|
|
11356
11543
|
*/
|
|
11357
11544
|
async function startRun(store, issueId, input, actor, scope) {
|
|
11358
11545
|
const externalSessionRef = readExternalSessionRef(input.externalSessionRef) ?? null;
|
|
11359
11546
|
return await runBoardWrite(store, async ({ tx }) => {
|
|
11360
|
-
const card = await
|
|
11547
|
+
const card = await tx.issues.lockById(issueId);
|
|
11548
|
+
if (!card) throw new BoardError("issue_not_found", { issueId });
|
|
11549
|
+
const [active] = await tx.runs.listRunning(issueId);
|
|
11550
|
+
if (active) throw new BoardError("board_card_run_active", {
|
|
11551
|
+
issueId,
|
|
11552
|
+
runId: active.id,
|
|
11553
|
+
agentName: active.agentName
|
|
11554
|
+
});
|
|
11361
11555
|
const attempt = await tx.runs.countByIssue(issueId) + 1;
|
|
11362
11556
|
const executionMode = input.executionMode ?? card.executionMode;
|
|
11363
11557
|
const run = await tx.runs.create({
|
|
@@ -11623,7 +11817,8 @@ async function createCard(store, input, actor, scope) {
|
|
|
11623
11817
|
const column = statusId === null ? null : await tx.statuses.findById(statusId);
|
|
11624
11818
|
const unmet = column ? await enforceColumnEntry(tx, {
|
|
11625
11819
|
id: null,
|
|
11626
|
-
description: input.description ?? null
|
|
11820
|
+
description: input.description ?? null,
|
|
11821
|
+
statusId: null
|
|
11627
11822
|
}, column, actor) : [];
|
|
11628
11823
|
const milestoneId = await requireMilestoneInWorkspace(tx, workspaceId, input.milestoneId ?? null);
|
|
11629
11824
|
const parentIssueId = await requireParentInWorkspace(tx, workspaceId, input.parentIssueId ?? null);
|
|
@@ -11631,8 +11826,9 @@ async function createCard(store, input, actor, scope) {
|
|
|
11631
11826
|
const now = currentUnixSeconds();
|
|
11632
11827
|
for (let attempt = 1;; attempt++) {
|
|
11633
11828
|
const identity = await nextIssueIdentity(input.workspace, tx);
|
|
11829
|
+
let created;
|
|
11634
11830
|
try {
|
|
11635
|
-
|
|
11831
|
+
created = await tx.issues.create({
|
|
11636
11832
|
id: identity.id,
|
|
11637
11833
|
workspaceId,
|
|
11638
11834
|
title: input.title?.trim() || identity.id,
|
|
@@ -11656,12 +11852,25 @@ async function createCard(store, input, actor, scope) {
|
|
|
11656
11852
|
order,
|
|
11657
11853
|
createdAt: now,
|
|
11658
11854
|
updatedAt: now
|
|
11659
|
-
})
|
|
11855
|
+
});
|
|
11660
11856
|
} catch (error) {
|
|
11661
11857
|
const numberTaken = await tx.issues.findByNumber(workspaceId, identity.number);
|
|
11662
11858
|
const keyTaken = await tx.issues.findById(identity.id);
|
|
11663
11859
|
if (attempt >= NUMBERING_ATTEMPTS || !numberTaken && !keyTaken) throw error;
|
|
11860
|
+
continue;
|
|
11664
11861
|
}
|
|
11862
|
+
if (statusId !== null) await tx.fieldChanges.create({
|
|
11863
|
+
id: (0, node_crypto.randomUUID)(),
|
|
11864
|
+
issueId: created.id,
|
|
11865
|
+
field: "statusId",
|
|
11866
|
+
fromValue: null,
|
|
11867
|
+
toValue: statusId,
|
|
11868
|
+
actorKind: toStoredActorKind(actor),
|
|
11869
|
+
actorId: actor.id ?? null,
|
|
11870
|
+
sourceChatSessionId: actor.chatSessionId ?? null,
|
|
11871
|
+
createdAt: now
|
|
11872
|
+
});
|
|
11873
|
+
return withUnmetRules(created, unmet);
|
|
11665
11874
|
}
|
|
11666
11875
|
}, scope);
|
|
11667
11876
|
}
|
|
@@ -11717,7 +11926,8 @@ async function updateCard(store, issueId, input, actor, scope) {
|
|
|
11717
11926
|
const description = "description" in updates ? updates.description ?? null : card.description;
|
|
11718
11927
|
unmet = await enforceColumnEntry(tx, {
|
|
11719
11928
|
id: card.id,
|
|
11720
|
-
description
|
|
11929
|
+
description,
|
|
11930
|
+
statusId: card.statusId
|
|
11721
11931
|
}, column, actor);
|
|
11722
11932
|
}
|
|
11723
11933
|
if ("assigneeKind" in input) updates.assigneeKind = input.assigneeKind ?? null;
|
|
@@ -12116,6 +12326,160 @@ async function reorderCards(store, workspaceId, orderedIds, scope) {
|
|
|
12116
12326
|
}, scope);
|
|
12117
12327
|
}
|
|
12118
12328
|
//#endregion
|
|
12329
|
+
//#region src/ops/ready.ts
|
|
12330
|
+
/** The column an agent takes its next card from. */
|
|
12331
|
+
const READY_COLUMN_SLUG = "to_do";
|
|
12332
|
+
/**
|
|
12333
|
+
* The cards an agent may pick up right now: spelled out, nobody working on them,
|
|
12334
|
+
* nobody's turn but the agent's.
|
|
12335
|
+
*
|
|
12336
|
+
* "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
|
|
12337
|
+
* run is taken, and a card waiting for a person belongs to that person until
|
|
12338
|
+
* they answer. What is left is the queue — in board order, so the card someone
|
|
12339
|
+
* dragged to the top is the one an agent asking for work gets.
|
|
12340
|
+
*/
|
|
12341
|
+
async function listReady(store, input) {
|
|
12342
|
+
return (await queryReady(store, input)).cards;
|
|
12343
|
+
}
|
|
12344
|
+
/**
|
|
12345
|
+
* One page of the ready cards, how many are ready in all, and the columns read
|
|
12346
|
+
* on the way — the rule above, asked of the database in one statement rather
|
|
12347
|
+
* than of every card on the board.
|
|
12348
|
+
*/
|
|
12349
|
+
async function queryReady(store, input) {
|
|
12350
|
+
const columns = await listColumns(store, input.workspaceId);
|
|
12351
|
+
const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
|
|
12352
|
+
if (!column) return {
|
|
12353
|
+
cards: [],
|
|
12354
|
+
total: 0,
|
|
12355
|
+
columns
|
|
12356
|
+
};
|
|
12357
|
+
return {
|
|
12358
|
+
...await store.issues.listPage({
|
|
12359
|
+
workspaceId: input.workspaceId,
|
|
12360
|
+
statusIds: [column.id],
|
|
12361
|
+
waitingForPerson: false,
|
|
12362
|
+
hasActiveRun: false,
|
|
12363
|
+
limit: input.limit,
|
|
12364
|
+
offset: input.offset
|
|
12365
|
+
}),
|
|
12366
|
+
columns
|
|
12367
|
+
};
|
|
12368
|
+
}
|
|
12369
|
+
//#endregion
|
|
12370
|
+
//#region src/ops/claim.ts
|
|
12371
|
+
/**
|
|
12372
|
+
* Take a card to work on, in one board write: pick it, start its run, and move
|
|
12373
|
+
* it into the board's first `started` column (In Progress on a standard
|
|
12374
|
+
* board), its entry and flow rules checked on the way in. Anything refused
|
|
12375
|
+
* leaves nothing written.
|
|
12376
|
+
*
|
|
12377
|
+
* Two claimers never get the same card. On SQLite the write holds the file
|
|
12378
|
+
* from its first read (`begin immediate`), so the second claimer reads the
|
|
12379
|
+
* board after the first one's commit. On Postgres the picked card's row is
|
|
12380
|
+
* locked (`select … for update`): a claimer that waited on the lock finds the
|
|
12381
|
+
* card taken, and moves on to the next ready one. The target's row is locked
|
|
12382
|
+
* first, always, in the order a move into it locks (column, then card), so a
|
|
12383
|
+
* claim and a move never wait on each other in a circle.
|
|
12384
|
+
*/
|
|
12385
|
+
async function claimCard(store, input, actor, scope) {
|
|
12386
|
+
return await runBoardWrite(store, async ({ tx }) => {
|
|
12387
|
+
const columns = await listColumns(tx, input.workspaceId);
|
|
12388
|
+
const target = columns.find((column) => column.category === "started");
|
|
12389
|
+
if (!target) throw new BoardError("issue_status_not_found", {
|
|
12390
|
+
workspaceId: input.workspaceId,
|
|
12391
|
+
category: "started"
|
|
12392
|
+
});
|
|
12393
|
+
await tx.statuses.lockById(target.id);
|
|
12394
|
+
const toDo = columns.find((column) => normalizeStatusName(column.name) === "to_do") ?? null;
|
|
12395
|
+
const card = input.issueId === void 0 ? await takeFirstReady(tx, input.workspaceId, toDo) : await takeNamed(tx, input.workspaceId, input.issueId, toDo);
|
|
12396
|
+
const run = await startRun(tx, card.id, input.run, actor, { tx });
|
|
12397
|
+
return {
|
|
12398
|
+
card: await updateCard(tx, card.id, { statusId: target.id }, actor, { tx }),
|
|
12399
|
+
run,
|
|
12400
|
+
column: target
|
|
12401
|
+
};
|
|
12402
|
+
}, scope);
|
|
12403
|
+
}
|
|
12404
|
+
/**
|
|
12405
|
+
* The first ready card, locked. A card another claimer took while this one
|
|
12406
|
+
* waited for its lock is passed over, and the next read no longer lists it; the
|
|
12407
|
+
* cards passed over are counted so the loop ends with the list.
|
|
12408
|
+
*/
|
|
12409
|
+
async function takeFirstReady(store, workspaceId, toDo) {
|
|
12410
|
+
const passed = /* @__PURE__ */ new Set();
|
|
12411
|
+
for (;;) {
|
|
12412
|
+
const { cards } = await queryReady(store, {
|
|
12413
|
+
workspaceId,
|
|
12414
|
+
limit: passed.size + 1
|
|
12415
|
+
});
|
|
12416
|
+
const candidate = cards.find((card) => !passed.has(card.id));
|
|
12417
|
+
if (!candidate) throw new BoardError("board_nothing_ready", { workspaceId });
|
|
12418
|
+
const locked = await store.issues.lockById(candidate.id);
|
|
12419
|
+
if (locked && await notReadyReason(store, locked, toDo) === null) return locked;
|
|
12420
|
+
passed.add(candidate.id);
|
|
12421
|
+
}
|
|
12422
|
+
}
|
|
12423
|
+
/** The named card, locked, when it is on this board and ready — or why not. */
|
|
12424
|
+
async function takeNamed(store, workspaceId, issueId, toDo) {
|
|
12425
|
+
const card = await store.issues.lockById(issueId);
|
|
12426
|
+
if (!card || card.workspaceId !== workspaceId) throw new BoardError("issue_not_found", { issueId });
|
|
12427
|
+
const reason = await notReadyReason(store, card, toDo);
|
|
12428
|
+
if (reason) throw new BoardError("board_card_not_ready", {
|
|
12429
|
+
issueId,
|
|
12430
|
+
reason
|
|
12431
|
+
});
|
|
12432
|
+
return card;
|
|
12433
|
+
}
|
|
12434
|
+
/** `queryReady`'s rule for one card already read: nobody on it, nobody waited for, in To Do. */
|
|
12435
|
+
async function notReadyReason(store, card, toDo) {
|
|
12436
|
+
if ((await store.runs.listRunning(card.id)).length > 0) return "run_active";
|
|
12437
|
+
if (card.waitingFor === "human") return "waiting_for_person";
|
|
12438
|
+
return toDo !== null && card.statusId === toDo.id ? null : "not_in_to_do";
|
|
12439
|
+
}
|
|
12440
|
+
//#endregion
|
|
12441
|
+
//#region src/ops/column-entries.ts
|
|
12442
|
+
/**
|
|
12443
|
+
* Every entry into a column on the named boards at or after `since`, in order
|
|
12444
|
+
* of `(at, changeId)` — the one signal an app dispatching work on column
|
|
12445
|
+
* changes needs. It sees every writer, an outside `kanbo` on the same database
|
|
12446
|
+
* included, because it reads the cards' own history.
|
|
12447
|
+
*
|
|
12448
|
+
* A page never ends inside a second: when `limit` cuts into one, the rest of
|
|
12449
|
+
* that second comes too, so a page may be longer than `limit`. A reader keeps
|
|
12450
|
+
* its cursor at the last `at` it read after a short page — a write in that
|
|
12451
|
+
* second may still commit — and drops the `changeId`s it has seen; after a page
|
|
12452
|
+
* of `limit` or more it moves on to the last `at` + 1, which a second holding
|
|
12453
|
+
* more entries than `limit` would otherwise never let it pass.
|
|
12454
|
+
*/
|
|
12455
|
+
async function listColumnEntries(store, input) {
|
|
12456
|
+
const page = await store.fieldChanges.listByFieldSince("statusId", input);
|
|
12457
|
+
return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
|
|
12458
|
+
}
|
|
12459
|
+
/** The page with every change of its last second, not only those `limit` let in. */
|
|
12460
|
+
async function withWholeLastSecond(store, input, page) {
|
|
12461
|
+
const last = page.at(-1).createdAt;
|
|
12462
|
+
const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
|
|
12463
|
+
workspaceIds: input.workspaceIds,
|
|
12464
|
+
since: last,
|
|
12465
|
+
until: last
|
|
12466
|
+
});
|
|
12467
|
+
return [...page.filter((change) => change.createdAt < last), ...lastSecond];
|
|
12468
|
+
}
|
|
12469
|
+
/** A status change as an entry; a change that took the card out of every column enters none. */
|
|
12470
|
+
function toColumnEntry(change) {
|
|
12471
|
+
return change.toValue === null ? [] : [{
|
|
12472
|
+
changeId: change.id,
|
|
12473
|
+
issueId: change.issueId,
|
|
12474
|
+
workspaceId: change.workspaceId,
|
|
12475
|
+
fromStatusId: change.fromValue,
|
|
12476
|
+
toStatusId: change.toValue,
|
|
12477
|
+
actorKind: change.actorKind,
|
|
12478
|
+
actorId: change.actorId,
|
|
12479
|
+
at: change.createdAt
|
|
12480
|
+
}];
|
|
12481
|
+
}
|
|
12482
|
+
//#endregion
|
|
12119
12483
|
//#region src/ops/context-refs.ts
|
|
12120
12484
|
const ContextRefsJsonSchema = string().transform((raw) => JSON.parse(raw)).pipe(array(string()));
|
|
12121
12485
|
/** Add a context ref at the end of the card's list, recording the change in its history. */
|
|
@@ -12300,6 +12664,7 @@ function subtaskSplitSentence(parentReference) {
|
|
|
12300
12664
|
}
|
|
12301
12665
|
const SUBTASK_RULE = `${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("the parent card's id")}`;
|
|
12302
12666
|
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.";
|
|
12667
|
+
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.";
|
|
12303
12668
|
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.";
|
|
12304
12669
|
/** Said by the `kanbo init` block and the MCP instructions, which name the comment command or tool beside it. */
|
|
12305
12670
|
const COMMENT_RULE = "Findings, results, decisions and questions go on the card as comments.";
|
|
@@ -12323,6 +12688,7 @@ const BOARD_RULES = [
|
|
|
12323
12688
|
EXECUTION_MODE_RULE,
|
|
12324
12689
|
SUBTASK_RULE,
|
|
12325
12690
|
PULL_REQUEST_RULE,
|
|
12691
|
+
CLAIM_RULE,
|
|
12326
12692
|
OWN_SESSION_RULE,
|
|
12327
12693
|
"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."
|
|
12328
12694
|
];
|
|
@@ -12338,12 +12704,14 @@ const BOARD_RULES = [
|
|
|
12338
12704
|
async function buildPrimeText(store, workspaceId) {
|
|
12339
12705
|
const columns = await listColumns(store, workspaceId);
|
|
12340
12706
|
const ruled = columns.some((column) => readEntryRules(column).length > 0);
|
|
12707
|
+
const flowing = columns.some((column) => hasFlowRules(column));
|
|
12341
12708
|
return [
|
|
12342
12709
|
...await describeReturnedCards(store, workspaceId, columns),
|
|
12343
12710
|
"Columns of this board, in board order:",
|
|
12344
12711
|
"",
|
|
12345
|
-
...columns.length > 0 ? columns.map(describeColumnLine) : ["- (this board has no columns yet)"],
|
|
12712
|
+
...columns.length > 0 ? columns.map((column) => describeColumnLine(column, columns)) : ["- (this board has no columns yet)"],
|
|
12346
12713
|
...ruled ? ["", ENTRY_RULES_NOTE] : [],
|
|
12714
|
+
...flowing ? ["", FLOW_RULES_NOTE] : [],
|
|
12347
12715
|
"",
|
|
12348
12716
|
"How this board works:",
|
|
12349
12717
|
"",
|
|
@@ -12385,11 +12753,22 @@ async function describeReturnedCards(store, workspaceId, columns) {
|
|
|
12385
12753
|
* without any reads exactly as it did before they existed.
|
|
12386
12754
|
*/
|
|
12387
12755
|
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.";
|
|
12388
|
-
|
|
12756
|
+
/** Said under the columns only when one of them has flow rules. */
|
|
12757
|
+
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.";
|
|
12758
|
+
function hasFlowRules(column) {
|
|
12759
|
+
const { allowedFrom, wipLimit } = readFlowRules(column);
|
|
12760
|
+
return allowedFrom !== null || wipLimit !== null;
|
|
12761
|
+
}
|
|
12762
|
+
function describeColumnLine(column, columns) {
|
|
12389
12763
|
const slug = normalizeStatusName(column.name);
|
|
12390
12764
|
const rules = readEntryRules(column);
|
|
12391
|
-
const
|
|
12392
|
-
return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${
|
|
12765
|
+
const said = `${rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : ""}${describeFlow(column, columns)}`;
|
|
12766
|
+
return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${said}` : `- ${column.name} (\`${slug}\`)${said ? ` —${said}` : ""}`;
|
|
12767
|
+
}
|
|
12768
|
+
/** Where a column takes cards from and how many it holds, as the prime text says it; nothing for a column without flow rules. */
|
|
12769
|
+
function describeFlow(column, columns) {
|
|
12770
|
+
const { allowedFrom, wipLimit } = readFlowRules(column);
|
|
12771
|
+
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"}.`}`;
|
|
12393
12772
|
}
|
|
12394
12773
|
//#endregion
|
|
12395
12774
|
//#region src/domain/pull-request-ref.ts
|
|
@@ -12559,47 +12938,6 @@ async function queryCards(store, input) {
|
|
|
12559
12938
|
};
|
|
12560
12939
|
}
|
|
12561
12940
|
//#endregion
|
|
12562
|
-
//#region src/ops/ready.ts
|
|
12563
|
-
/** The column an agent takes its next card from. */
|
|
12564
|
-
const READY_COLUMN_SLUG = "to_do";
|
|
12565
|
-
/**
|
|
12566
|
-
* The cards an agent may pick up right now: spelled out, nobody working on them,
|
|
12567
|
-
* nobody's turn but the agent's.
|
|
12568
|
-
*
|
|
12569
|
-
* "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
|
|
12570
|
-
* run is taken, and a card waiting for a person belongs to that person until
|
|
12571
|
-
* they answer. What is left is the queue — in board order, so the card someone
|
|
12572
|
-
* dragged to the top is the one an agent asking for work gets.
|
|
12573
|
-
*/
|
|
12574
|
-
async function listReady(store, input) {
|
|
12575
|
-
return (await queryReady(store, input)).cards;
|
|
12576
|
-
}
|
|
12577
|
-
/**
|
|
12578
|
-
* One page of the ready cards, how many are ready in all, and the columns read
|
|
12579
|
-
* on the way — the rule above, asked of the database in one statement rather
|
|
12580
|
-
* than of every card on the board.
|
|
12581
|
-
*/
|
|
12582
|
-
async function queryReady(store, input) {
|
|
12583
|
-
const columns = await listColumns(store, input.workspaceId);
|
|
12584
|
-
const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
|
|
12585
|
-
if (!column) return {
|
|
12586
|
-
cards: [],
|
|
12587
|
-
total: 0,
|
|
12588
|
-
columns
|
|
12589
|
-
};
|
|
12590
|
-
return {
|
|
12591
|
-
...await store.issues.listPage({
|
|
12592
|
-
workspaceId: input.workspaceId,
|
|
12593
|
-
statusIds: [column.id],
|
|
12594
|
-
waitingForPerson: false,
|
|
12595
|
-
hasActiveRun: false,
|
|
12596
|
-
limit: input.limit,
|
|
12597
|
-
offset: input.offset
|
|
12598
|
-
}),
|
|
12599
|
-
columns
|
|
12600
|
-
};
|
|
12601
|
-
}
|
|
12602
|
-
//#endregion
|
|
12603
12941
|
//#region src/ops/relations.ts
|
|
12604
12942
|
/**
|
|
12605
12943
|
* Every edge touching the card, in either direction, each with the card on its
|
|
@@ -12889,6 +13227,8 @@ function createBoardOps(store) {
|
|
|
12889
13227
|
updateColumn: async (statusId, patch, actor, scope) => await updateColumn(store, statusId, patch, actor, scope),
|
|
12890
13228
|
describeColumn: async (workspaceId, nameOrId, description, scope) => await describeColumn(store, workspaceId, nameOrId, description, scope),
|
|
12891
13229
|
setColumnEntryRules: async (workspaceId, nameOrId, entryRules, actor, scope) => await setColumnEntryRules(store, workspaceId, nameOrId, entryRules, actor, scope),
|
|
13230
|
+
setColumnFlowRules: async (columnId, rules, actor, scope) => await setColumnFlowRules(store, columnId, rules, actor, scope),
|
|
13231
|
+
applyStandardFlow: async (workspaceId, actor, scope) => await applyStandardFlow(store, workspaceId, actor, scope),
|
|
12892
13232
|
enforceColumnEntryForCards: async (issueIds, statusId, actor) => await enforceColumnEntryForCards(store, issueIds, statusId, actor),
|
|
12893
13233
|
deleteColumn: async (statusId, scope, actor) => await deleteColumn(store, statusId, scope, actor),
|
|
12894
13234
|
reorderColumns: async (workspaceId, orderedIds, scope, actor) => await reorderColumns(store, workspaceId, orderedIds, scope, actor),
|
|
@@ -12899,6 +13239,7 @@ function createBoardOps(store) {
|
|
|
12899
13239
|
moveColumn: async (workspaceId, nameOrId, position, actor, scope) => await moveColumn$1(store, workspaceId, nameOrId, position, actor, scope),
|
|
12900
13240
|
removeColumn: async (workspaceId, nameOrId, options, actor, scope) => await removeColumn$1(store, workspaceId, nameOrId, options, actor, scope),
|
|
12901
13241
|
startRun: async (issueId, input, actor, scope) => await startRun(store, issueId, input, actor, scope),
|
|
13242
|
+
claimCard: async (input, actor, scope) => await claimCard(store, input, actor, scope),
|
|
12902
13243
|
attachRunExecution: async (runId, input, scope) => await attachRunExecution(store, runId, input, scope),
|
|
12903
13244
|
recordRunSessionRef: async (runId, ref, scope) => await recordRunSessionRef(store, runId, ref, scope),
|
|
12904
13245
|
clearRunSessionRef: async (runId, actor, options, scope) => await clearRunSessionRef(store, runId, actor, options, scope),
|
|
@@ -12911,6 +13252,7 @@ function createBoardOps(store) {
|
|
|
12911
13252
|
readBoardProjectionForIssue: async (issueId) => await readBoardProjectionForIssue(store, issueId),
|
|
12912
13253
|
listReady: async (input) => await listReady(store, input),
|
|
12913
13254
|
queryReady: async (input) => await queryReady(store, input),
|
|
13255
|
+
listColumnEntries: async (input) => await listColumnEntries(store, input),
|
|
12914
13256
|
buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId)
|
|
12915
13257
|
};
|
|
12916
13258
|
}
|
|
@@ -18777,6 +19119,10 @@ const issueStatuses$1 = pgTable("issue_statuses", {
|
|
|
18777
19119
|
order: integer$1("order").notNull().default(0),
|
|
18778
19120
|
/** A JSON array of `ENTRY_RULES`, or `null` for none — see the SQLite schema. */
|
|
18779
19121
|
entryRules: text$1("entry_rules"),
|
|
19122
|
+
/** A JSON array of the status ids a card may enter from, or `null` for any — see the SQLite schema. */
|
|
19123
|
+
allowedFrom: text$1("allowed_from"),
|
|
19124
|
+
/** The most cards the column holds, or `null` — see the SQLite schema. */
|
|
19125
|
+
wipLimit: integer$1("wip_limit"),
|
|
18780
19126
|
...createdAt$1()
|
|
18781
19127
|
}, (table) => [index$1("issue_statuses_workspace_id_idx").on(table.workspaceId), uniqueIndex$1("issue_statuses_workspace_name_unique").on(table.workspaceId, table.name)]);
|
|
18782
19128
|
const issueMilestones$1 = pgTable("issue_milestones", {
|
|
@@ -18924,7 +19270,7 @@ const issueFieldChanges$1 = pgTable("issue_field_changes", {
|
|
|
18924
19270
|
...createdAt$1(),
|
|
18925
19271
|
/** Insertion order, standing in for the `rowid` the SQLite board orders changes by. */
|
|
18926
19272
|
seq: bigserial("seq", { mode: "number" }).notNull()
|
|
18927
|
-
}, (table) => [index$1("issue_field_changes_issue_id_idx").on(table.issueId)]);
|
|
19273
|
+
}, (table) => [index$1("issue_field_changes_issue_id_idx").on(table.issueId), index$1("issue_field_changes_field_created_at_idx").on(table.field, table.createdAt)]);
|
|
18928
19274
|
/** A pull request someone said belongs to a card — see the SQLite twin for what the row is and is not. */
|
|
18929
19275
|
const issuePullRequests$1 = pgTable("issue_pull_requests", {
|
|
18930
19276
|
id: textPk$1(),
|
|
@@ -19000,6 +19346,10 @@ function toIssueComment({ seq: _seq, ...comment }) {
|
|
|
19000
19346
|
function toIssueFieldChange({ seq: _seq, ...change }) {
|
|
19001
19347
|
return change;
|
|
19002
19348
|
}
|
|
19349
|
+
/** A field change with its card's workspace, without `seq`, for the same reason. */
|
|
19350
|
+
function toWorkspaceFieldChange({ seq: _seq, ...change }) {
|
|
19351
|
+
return change;
|
|
19352
|
+
}
|
|
19003
19353
|
/** The pull-request link without `seq`, for the same reason. */
|
|
19004
19354
|
function toIssuePullRequest({ seq: _seq, ...link }) {
|
|
19005
19355
|
return link;
|
|
@@ -19206,6 +19556,10 @@ function createStoreOver(database) {
|
|
|
19206
19556
|
const [row] = await database.select().from(issues$1).where(eq(issues$1.id, issueId)).limit(1);
|
|
19207
19557
|
return row ?? null;
|
|
19208
19558
|
},
|
|
19559
|
+
lockById: async (issueId) => {
|
|
19560
|
+
const [row] = await database.select().from(issues$1).where(eq(issues$1.id, issueId)).limit(1).for("update");
|
|
19561
|
+
return row ?? null;
|
|
19562
|
+
},
|
|
19209
19563
|
findInWorkspace: async (workspaceId, issueId) => {
|
|
19210
19564
|
const [row] = await database.select().from(issues$1).where(and(eq(issues$1.id, issueId), eq(issues$1.workspaceId, workspaceId))).limit(1);
|
|
19211
19565
|
return row ?? null;
|
|
@@ -19357,6 +19711,10 @@ function createStoreOver(database) {
|
|
|
19357
19711
|
fieldChanges: {
|
|
19358
19712
|
listByIssue: async (issueId) => (await database.select().from(issueFieldChanges$1).where(eq(issueFieldChanges$1.issueId, issueId)).orderBy(asc(issueFieldChanges$1.createdAt), asc(issueFieldChanges$1.seq))).map(toIssueFieldChange),
|
|
19359
19713
|
listByIssueField: async (issueId, field) => (await database.select().from(issueFieldChanges$1).where(and(eq(issueFieldChanges$1.issueId, issueId), eq(issueFieldChanges$1.field, field))).orderBy(asc(issueFieldChanges$1.createdAt), asc(issueFieldChanges$1.seq))).map(toIssueFieldChange),
|
|
19714
|
+
listByFieldSince: async (field, query) => query.workspaceIds.length === 0 ? [] : (await database.select({
|
|
19715
|
+
...getTableColumns(issueFieldChanges$1),
|
|
19716
|
+
workspaceId: issues$1.workspaceId
|
|
19717
|
+
}).from(issueFieldChanges$1).innerJoin(issues$1, eq(issues$1.id, issueFieldChanges$1.issueId)).where(and(eq(issueFieldChanges$1.field, field), isNotNull(issueFieldChanges$1.toValue), gte(issueFieldChanges$1.createdAt, query.since), query.until === void 0 ? void 0 : lte(issueFieldChanges$1.createdAt, query.until), inArray(issues$1.workspaceId, [...query.workspaceIds]))).orderBy(asc(issueFieldChanges$1.createdAt), asc(issueFieldChanges$1.id)).limit(query.limit ?? Number.MAX_SAFE_INTEGER)).map(toWorkspaceFieldChange),
|
|
19360
19718
|
create: async (values) => {
|
|
19361
19719
|
await database.insert(issueFieldChanges$1).values(values);
|
|
19362
19720
|
}
|
|
@@ -20174,6 +20532,14 @@ const issueStatuses = sqliteTable("issue_statuses", {
|
|
|
20174
20532
|
* through `readEntryRules`, never by hand.
|
|
20175
20533
|
*/
|
|
20176
20534
|
entryRules: text("entry_rules"),
|
|
20535
|
+
/**
|
|
20536
|
+
* The columns a card may enter this one from — a JSON array of status ids;
|
|
20537
|
+
* `null` for any column, `[]` for none (only a person puts a card here). Read
|
|
20538
|
+
* it through `readFlowRules`, never by hand.
|
|
20539
|
+
*/
|
|
20540
|
+
allowedFrom: text("allowed_from"),
|
|
20541
|
+
/** The most cards this column holds, 1 or more; `null` for no limit. */
|
|
20542
|
+
wipLimit: int("wip_limit"),
|
|
20177
20543
|
...createdAt()
|
|
20178
20544
|
}, (table) => ({
|
|
20179
20545
|
byWorkspace: index("issue_statuses_workspace_id_idx").on(table.workspaceId),
|
|
@@ -20321,7 +20687,11 @@ const issueFieldChanges = sqliteTable("issue_field_changes", {
|
|
|
20321
20687
|
actorId: text("actor_id"),
|
|
20322
20688
|
sourceChatSessionId: text("source_chat_session_id"),
|
|
20323
20689
|
...createdAt()
|
|
20324
|
-
}, (table) => ({
|
|
20690
|
+
}, (table) => ({
|
|
20691
|
+
byIssue: index("issue_field_changes_issue_id_idx").on(table.issueId),
|
|
20692
|
+
/** The column-entry feed reads one field across a board by time. */
|
|
20693
|
+
byFieldCreatedAt: index("issue_field_changes_field_created_at_idx").on(table.field, table.createdAt)
|
|
20694
|
+
}));
|
|
20325
20695
|
/**
|
|
20326
20696
|
* A pull request someone said belongs to a card.
|
|
20327
20697
|
*
|
|
@@ -20664,6 +21034,7 @@ function createSqliteBoardStoreCore(resolveDatabase) {
|
|
|
20664
21034
|
}).from(issues).where(and(eq(issues.workspaceId, workspaceId), isNotNull(issues.milestoneId))).groupBy(issues.milestoneId, issues.statusId).all(),
|
|
20665
21035
|
listByIds: (issueIds) => resolveDatabase().select().from(issues).where(inArray(issues.id, issueIds)).all(),
|
|
20666
21036
|
findById: (issueId) => resolveDatabase().select().from(issues).where(eq(issues.id, issueId)).get() ?? null,
|
|
21037
|
+
lockById: (issueId) => resolveDatabase().select().from(issues).where(eq(issues.id, issueId)).get() ?? null,
|
|
20667
21038
|
findInWorkspace: (workspaceId, issueId) => resolveDatabase().select().from(issues).where(and(eq(issues.id, issueId), eq(issues.workspaceId, workspaceId))).get() ?? null,
|
|
20668
21039
|
findByNumber: (workspaceId, number) => resolveDatabase().select().from(issues).where(and(eq(issues.workspaceId, workspaceId), eq(issues.number, number))).get() ?? null,
|
|
20669
21040
|
maxNumber: (workspaceId) => {
|
|
@@ -20761,6 +21132,10 @@ function createSqliteBoardStoreCore(resolveDatabase) {
|
|
|
20761
21132
|
fieldChanges: {
|
|
20762
21133
|
listByIssue: (issueId) => resolveDatabase().select().from(issueFieldChanges).where(eq(issueFieldChanges.issueId, issueId)).orderBy(asc(issueFieldChanges.createdAt), FIELD_CHANGE_INSERTION_ORDER).all(),
|
|
20763
21134
|
listByIssueField: (issueId, field) => resolveDatabase().select().from(issueFieldChanges).where(and(eq(issueFieldChanges.issueId, issueId), eq(issueFieldChanges.field, field))).orderBy(asc(issueFieldChanges.createdAt), FIELD_CHANGE_INSERTION_ORDER).all(),
|
|
21135
|
+
listByFieldSince: (field, query) => query.workspaceIds.length === 0 ? [] : resolveDatabase().select({
|
|
21136
|
+
...getTableColumns(issueFieldChanges),
|
|
21137
|
+
workspaceId: issues.workspaceId
|
|
21138
|
+
}).from(issueFieldChanges).innerJoin(issues, eq(issues.id, issueFieldChanges.issueId)).where(and(eq(issueFieldChanges.field, field), isNotNull(issueFieldChanges.toValue), gte(issueFieldChanges.createdAt, query.since), query.until === void 0 ? void 0 : lte(issueFieldChanges.createdAt, query.until), inArray(issues.workspaceId, [...query.workspaceIds]))).orderBy(asc(issueFieldChanges.createdAt), asc(issueFieldChanges.id)).limit(query.limit ?? Number.MAX_SAFE_INTEGER).all(),
|
|
20764
21139
|
create: (values) => {
|
|
20765
21140
|
resolveDatabase().insert(issueFieldChanges).values(values).run();
|
|
20766
21141
|
}
|
|
@@ -20841,6 +21216,7 @@ function createSqliteBoardStore(options) {
|
|
|
20841
21216
|
countByMilestone: async (workspaceId) => core.issues.countByMilestone(workspaceId),
|
|
20842
21217
|
listByIds: async (issueIds) => core.issues.listByIds(issueIds),
|
|
20843
21218
|
findById: async (issueId) => core.issues.findById(issueId),
|
|
21219
|
+
lockById: async (issueId) => core.issues.lockById(issueId),
|
|
20844
21220
|
findInWorkspace: async (workspaceId, issueId) => core.issues.findInWorkspace(workspaceId, issueId),
|
|
20845
21221
|
findByNumber: async (workspaceId, number) => core.issues.findByNumber(workspaceId, number),
|
|
20846
21222
|
maxNumber: async (workspaceId) => core.issues.maxNumber(workspaceId),
|
|
@@ -20886,6 +21262,7 @@ function createSqliteBoardStore(options) {
|
|
|
20886
21262
|
fieldChanges: {
|
|
20887
21263
|
listByIssue: async (issueId) => core.fieldChanges.listByIssue(issueId),
|
|
20888
21264
|
listByIssueField: async (issueId, field) => core.fieldChanges.listByIssueField(issueId, field),
|
|
21265
|
+
listByFieldSince: async (field, query) => core.fieldChanges.listByFieldSince(field, query),
|
|
20889
21266
|
create: async (values) => core.fieldChanges.create(values)
|
|
20890
21267
|
},
|
|
20891
21268
|
pullRequests: {
|
|
@@ -24007,9 +24384,10 @@ const SCHEMA_NOT_INSTALLED_MESSAGE = "This Postgres database has no kanbo board
|
|
|
24007
24384
|
* caller's own risk but never written to — a write shaped by this build's idea
|
|
24008
24385
|
* of the schema is how a person's board gets a column full of nothing. A file
|
|
24009
24386
|
* of this package's own is this package's to migrate, so the same refusal
|
|
24010
|
-
* points at `kanbo migrate` instead of at the app. Reads
|
|
24011
|
-
*
|
|
24012
|
-
* their cards
|
|
24387
|
+
* points at `kanbo migrate` instead of at the app. Reads are held to the lesser
|
|
24388
|
+
* bar of `assertReadableBoard`: a file only a stamp behind is still read, so a
|
|
24389
|
+
* person who cannot migrate right now can look at their cards, but one missing
|
|
24390
|
+
* a table or column this build reads is refused with the same sentence.
|
|
24013
24391
|
*/
|
|
24014
24392
|
function assertWritableBoard(database, owner) {
|
|
24015
24393
|
try {
|
|
@@ -24020,6 +24398,26 @@ function assertWritableBoard(database, owner) {
|
|
|
24020
24398
|
}
|
|
24021
24399
|
}
|
|
24022
24400
|
/**
|
|
24401
|
+
* Refuse a read of a board file that lacks a table or a column this build
|
|
24402
|
+
* reads — a file a migration behind — with the sentence a write gets, instead
|
|
24403
|
+
* of letting the first statement fail on what is missing. A file whose only
|
|
24404
|
+
* difference is its schema stamp is still read: every statement this build
|
|
24405
|
+
* makes works on it, and a person who cannot migrate now can still look at
|
|
24406
|
+
* their cards.
|
|
24407
|
+
*/
|
|
24408
|
+
function assertReadableBoard(database, owner) {
|
|
24409
|
+
try {
|
|
24410
|
+
assertBoardSchema$1(database);
|
|
24411
|
+
} catch (error) {
|
|
24412
|
+
if (!(error instanceof BoardError && error.code === "board_schema_outdated")) throw error;
|
|
24413
|
+
if (lacksWhatThisBuildReads(error.details)) throw new CliError(3, owner === "kanbo" ? FILE_SCHEMA_OUTDATED_MESSAGE : SCHEMA_OUTDATED_MESSAGE);
|
|
24414
|
+
}
|
|
24415
|
+
}
|
|
24416
|
+
/** Whether `assertBoardSchema` found a table or a column missing, rather than only another stamp. */
|
|
24417
|
+
function lacksWhatThisBuildReads(details) {
|
|
24418
|
+
return details !== void 0 && ("missingTables" in details || "missingAddedTables" in details || "missingColumns" in details);
|
|
24419
|
+
}
|
|
24420
|
+
/**
|
|
24023
24421
|
* Refuse an external board this build cannot speak for — before reading it as
|
|
24024
24422
|
* well as before writing to it.
|
|
24025
24423
|
*
|
|
@@ -24258,6 +24656,7 @@ async function openFileSession(target, options, mode, cwd) {
|
|
|
24258
24656
|
assertWritableBoard(board.database, target.owner);
|
|
24259
24657
|
};
|
|
24260
24658
|
if (mode === "write") assertWritableBoard(board.database, target.owner);
|
|
24659
|
+
else assertReadableBoard(board.database, target.owner);
|
|
24261
24660
|
const workspace = resolveWorkspace(target.owner === "kanbo" ? { kind: "file" } : {
|
|
24262
24661
|
kind: "sqlite",
|
|
24263
24662
|
database: board.database
|
|
@@ -24281,8 +24680,9 @@ async function openFileSession(target, options, mode, cwd) {
|
|
|
24281
24680
|
/**
|
|
24282
24681
|
* The same, on a board that is a database of its own.
|
|
24283
24682
|
*
|
|
24284
|
-
* The guard runs whatever the command came to do. A board file
|
|
24285
|
-
*
|
|
24683
|
+
* The guard runs whatever the command came to do. A board file whose only
|
|
24684
|
+
* difference from this build is its schema stamp is still a board worth
|
|
24685
|
+
* reading; a connection string pointing
|
|
24286
24686
|
* at a database with no board in it is nothing to read at all, and every
|
|
24287
24687
|
* command would otherwise fail one statement later with the driver's own words.
|
|
24288
24688
|
*/
|
|
@@ -24580,7 +24980,9 @@ function projectCardActiveRun(run) {
|
|
|
24580
24980
|
startedAt: run.startedAt
|
|
24581
24981
|
};
|
|
24582
24982
|
}
|
|
24583
|
-
|
|
24983
|
+
/** A column as the command line prints it; `board` is every column, to name the ones it accepts cards from. */
|
|
24984
|
+
function projectColumn(column, board) {
|
|
24985
|
+
const { allowedFrom, wipLimit } = readFlowRules(column);
|
|
24584
24986
|
return {
|
|
24585
24987
|
id: column.id,
|
|
24586
24988
|
name: column.name,
|
|
@@ -24588,7 +24990,9 @@ function projectColumn(column) {
|
|
|
24588
24990
|
description: column.description,
|
|
24589
24991
|
category: column.category,
|
|
24590
24992
|
order: column.order,
|
|
24591
|
-
entryRules: readEntryRules(column)
|
|
24993
|
+
entryRules: readEntryRules(column),
|
|
24994
|
+
allowedFrom: allowedFrom === null ? null : board.filter((other) => allowedFrom.includes(other.id)).map((other) => normalizeStatusName(other.name)),
|
|
24995
|
+
wipLimit
|
|
24592
24996
|
};
|
|
24593
24997
|
}
|
|
24594
24998
|
function projectSprint(sprint) {
|
|
@@ -24660,7 +25064,9 @@ function describeColumns(columns) {
|
|
|
24660
25064
|
return columns.map((column) => [
|
|
24661
25065
|
`${padToWidth(sanitizeTerminalText(column.slug), slugWidth)} ${sanitizeTerminalText(column.name)}`,
|
|
24662
25066
|
column.description ? ` — ${sanitizeTerminalText(column.description)}` : "",
|
|
24663
|
-
column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : ""
|
|
25067
|
+
column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : "",
|
|
25068
|
+
column.allowedFrom === null ? "" : ` [from: ${column.allowedFrom.length > 0 ? column.allowedFrom.join(", ") : "no column"}]`,
|
|
25069
|
+
column.wipLimit === null ? "" : ` [wip: ${column.wipLimit}]`
|
|
24664
25070
|
].join("")).join("\n");
|
|
24665
25071
|
}
|
|
24666
25072
|
/** A unix-seconds date as the UTC day it falls on, `—` for none. */
|
|
@@ -24854,6 +25260,7 @@ function colorsWanted() {
|
|
|
24854
25260
|
const KANBO_TOOL_CLI_EQUIVALENTS = {
|
|
24855
25261
|
kanbo_prime: { kanbo: "kanbo prime" },
|
|
24856
25262
|
kanbo_ready: { kanbo: "kanbo ready" },
|
|
25263
|
+
kanbo_card_claim: { kanbo: "kanbo claim [card] [--agent <name>]" },
|
|
24857
25264
|
kanbo_columns: { kanbo: "kanbo columns list" },
|
|
24858
25265
|
kanbo_sprints: { kanbo: "kanbo sprint list" },
|
|
24859
25266
|
kanbo_card_get: { kanbo: "kanbo card get <id>" },
|
|
@@ -25270,16 +25677,22 @@ function projectKanboSprint(sprint) {
|
|
|
25270
25677
|
doneCards: sprint.cards.done
|
|
25271
25678
|
};
|
|
25272
25679
|
}
|
|
25273
|
-
/** A column of the board, from the row either transport holds. */
|
|
25274
|
-
function projectKanboColumn(column) {
|
|
25680
|
+
/** A column of the board, from the row either transport holds; `board` is every column, to name the ones it accepts cards from. */
|
|
25681
|
+
function projectKanboColumn(column, board) {
|
|
25275
25682
|
const stored = column.entryRules;
|
|
25683
|
+
const allowedIds = typeof column.allowedFrom === "string" ? readFlowRules({
|
|
25684
|
+
allowedFrom: column.allowedFrom,
|
|
25685
|
+
wipLimit: null
|
|
25686
|
+
}).allowedFrom : column.allowedFrom ?? null;
|
|
25276
25687
|
return {
|
|
25277
25688
|
id: column.id,
|
|
25278
25689
|
name: column.name,
|
|
25279
25690
|
slug: normalizeStatusName(column.name),
|
|
25280
25691
|
description: column.description,
|
|
25281
25692
|
order: column.order,
|
|
25282
|
-
entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null })
|
|
25693
|
+
entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null }),
|
|
25694
|
+
allowedFrom: allowedIds === null ? null : board.filter((other) => allowedIds.includes(other.id)).map((other) => normalizeStatusName(other.name)),
|
|
25695
|
+
wipLimit: column.wipLimit ?? null
|
|
25283
25696
|
};
|
|
25284
25697
|
}
|
|
25285
25698
|
/** A comment as a card read lists it, from the row either transport holds. */
|
|
@@ -25393,7 +25806,7 @@ function readMoment(value) {
|
|
|
25393
25806
|
const cardMoveTool = defineKanboTool({
|
|
25394
25807
|
name: "kanbo_card_move",
|
|
25395
25808
|
title: "Move a card",
|
|
25396
|
-
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
|
|
25809
|
+
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}`,
|
|
25397
25810
|
inputSchema: {
|
|
25398
25811
|
card: cardArgument,
|
|
25399
25812
|
column: string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`."),
|
|
@@ -25465,7 +25878,7 @@ const cardUpdateTool = defineKanboTool({
|
|
|
25465
25878
|
const columnsTool = defineKanboTool({
|
|
25466
25879
|
name: "kanbo_columns",
|
|
25467
25880
|
title: "Columns of the board",
|
|
25468
|
-
description: "List this board's columns in board order, each with the slug kanbo_card_move takes, what the column means here,
|
|
25881
|
+
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).",
|
|
25469
25882
|
inputSchema: {},
|
|
25470
25883
|
run: async (transport) => await transport.columns()
|
|
25471
25884
|
});
|
|
@@ -25479,6 +25892,38 @@ const primeTool = defineKanboTool({
|
|
|
25479
25892
|
inputSchema: {},
|
|
25480
25893
|
run: async (transport) => (await transport.prime()).text
|
|
25481
25894
|
});
|
|
25895
|
+
//#endregion
|
|
25896
|
+
//#region src/mcp/tools/card-claim.ts
|
|
25897
|
+
/**
|
|
25898
|
+
* Taking a card to work on, in one step: the run starts and the card moves out
|
|
25899
|
+
* of To Do into the board's first work-in-progress column in the same write,
|
|
25900
|
+
* so two agents never get the same card. Published to every host, including
|
|
25901
|
+
* one that records the runs of the agents it launches: a claim is how an agent
|
|
25902
|
+
* in any terminal takes work.
|
|
25903
|
+
*/
|
|
25904
|
+
const cardClaimTool = defineKanboTool({
|
|
25905
|
+
name: "kanbo_card_claim",
|
|
25906
|
+
title: "Claim a card",
|
|
25907
|
+
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.",
|
|
25908
|
+
inputSchema: {
|
|
25909
|
+
card: cardArgument.optional(),
|
|
25910
|
+
agent: string().min(1).optional().describe("What to call whoever works the card; who this server writes as when absent.")
|
|
25911
|
+
},
|
|
25912
|
+
aliases: {
|
|
25913
|
+
card: [...CARD_ALIASES, "issue"],
|
|
25914
|
+
agent: ["agentName"]
|
|
25915
|
+
},
|
|
25916
|
+
example: { card: "TST-5" },
|
|
25917
|
+
run: async (transport, input) => {
|
|
25918
|
+
const claimed = await transport.cardClaim(input);
|
|
25919
|
+
return {
|
|
25920
|
+
card: compactKanboCard(claimed.card),
|
|
25921
|
+
run: claimed.run.id,
|
|
25922
|
+
column: claimed.column.slug,
|
|
25923
|
+
finishWith: `kanbo_run_finish {"run":"${claimed.run.id}","state":"finished"}`
|
|
25924
|
+
};
|
|
25925
|
+
}
|
|
25926
|
+
});
|
|
25482
25927
|
/** The cards an agent may pick up right now. */
|
|
25483
25928
|
const readyTool = defineKanboTool({
|
|
25484
25929
|
name: "kanbo_ready",
|
|
@@ -25567,13 +26012,14 @@ const runStartTool = defineKanboTool({
|
|
|
25567
26012
|
/**
|
|
25568
26013
|
* Every tool the board has, in the order it declares them.
|
|
25569
26014
|
*
|
|
25570
|
-
* The list is the board's, not a server's: the same
|
|
26015
|
+
* The list is the board's, not a server's: the same seventeen are registered
|
|
25571
26016
|
* whether they run against a server or against the board directly, and the
|
|
25572
26017
|
* only thing a host decides is which of them it publishes.
|
|
25573
26018
|
*/
|
|
25574
26019
|
const KANBO_TOOLS = [
|
|
25575
26020
|
primeTool,
|
|
25576
26021
|
readyTool,
|
|
26022
|
+
cardClaimTool,
|
|
25577
26023
|
columnsTool,
|
|
25578
26024
|
defineKanboTool({
|
|
25579
26025
|
name: "kanbo_sprints",
|
|
@@ -25963,10 +26409,12 @@ function buildCapabilitiesManifest() {
|
|
|
25963
26409
|
"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.",
|
|
25964
26410
|
`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.`,
|
|
25965
26411
|
"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.",
|
|
26412
|
+
"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.",
|
|
26413
|
+
"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.",
|
|
25966
26414
|
"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.",
|
|
25967
26415
|
"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).",
|
|
25968
26416
|
"`kanbo migrate` and `kanbo roles apply` refuse a host app's own database file: the app migrates it, and this package never touches it.",
|
|
25969
|
-
"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)
|
|
26417
|
+
"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.",
|
|
25970
26418
|
"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."
|
|
25971
26419
|
]
|
|
25972
26420
|
};
|
|
@@ -26111,6 +26559,52 @@ function registerCapabilitiesCommand(program) {
|
|
|
26111
26559
|
});
|
|
26112
26560
|
}
|
|
26113
26561
|
//#endregion
|
|
26562
|
+
//#region src/cli/commands/claim.ts
|
|
26563
|
+
/**
|
|
26564
|
+
* `kanbo claim [card]` — take a card to work on, in one step.
|
|
26565
|
+
*
|
|
26566
|
+
* The run starts and the card moves out of To Do into the board's first
|
|
26567
|
+
* work-in-progress column in one write, so two agents in two terminals never
|
|
26568
|
+
* take the same card. Filed as a placement: in a person's own terminal the
|
|
26569
|
+
* column's rules warn, in an agent's shell they refuse.
|
|
26570
|
+
*/
|
|
26571
|
+
function registerClaimCommand(program) {
|
|
26572
|
+
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) => {
|
|
26573
|
+
await runBoardCommand(options, "write", async (session) => {
|
|
26574
|
+
const actor = createPlacementActor();
|
|
26575
|
+
const named = reference === void 0 ? void 0 : (await requireCard(session, reference)).id;
|
|
26576
|
+
const claimed = await session.ops.claimCard({
|
|
26577
|
+
workspaceId: session.workspace.id,
|
|
26578
|
+
issueId: named,
|
|
26579
|
+
run: {
|
|
26580
|
+
agentName: options.agent ?? actor.id ?? "agent",
|
|
26581
|
+
externalSessionRef: options.session ?? sessionRefFromEnvironment() ?? void 0,
|
|
26582
|
+
launchedByKind: "external"
|
|
26583
|
+
}
|
|
26584
|
+
}, actor);
|
|
26585
|
+
const [columns, projection, runs] = await Promise.all([
|
|
26586
|
+
session.ops.listColumns(session.workspace.id),
|
|
26587
|
+
session.ops.readBoardProjectionForIssue(claimed.card.id),
|
|
26588
|
+
session.ops.listRuns(claimed.card.id)
|
|
26589
|
+
]);
|
|
26590
|
+
const card = projectCard(claimed.card, columns, projection);
|
|
26591
|
+
for (const item of claimed.card.unmetRules ?? []) console.error(`kanbo: warning: ${card.id} entered "${claimed.column.name}" without ${item.rule}: ${item.detail}`);
|
|
26592
|
+
const run = projectRun({
|
|
26593
|
+
...claimed.run,
|
|
26594
|
+
attempt: runs.find((each) => each.id === claimed.run.id)?.attempt ?? runs.length
|
|
26595
|
+
});
|
|
26596
|
+
return {
|
|
26597
|
+
value: {
|
|
26598
|
+
card,
|
|
26599
|
+
run,
|
|
26600
|
+
column: projectColumn(claimed.column, columns)
|
|
26601
|
+
},
|
|
26602
|
+
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`
|
|
26603
|
+
};
|
|
26604
|
+
});
|
|
26605
|
+
});
|
|
26606
|
+
}
|
|
26607
|
+
//#endregion
|
|
26114
26608
|
//#region src/cli/card-extras.ts
|
|
26115
26609
|
async function readCardExtras(session, card, columns, include, commentLimit) {
|
|
26116
26610
|
const wants = new Set(include);
|
|
@@ -26491,10 +26985,11 @@ const COLUMN_CATEGORIES = [
|
|
|
26491
26985
|
];
|
|
26492
26986
|
/** The whole board's columns after a change, and the sentence that says what changed. */
|
|
26493
26987
|
async function changed(session, message, extra = {}) {
|
|
26494
|
-
const
|
|
26988
|
+
const board = await session.ops.listColumns(session.workspace.id);
|
|
26989
|
+
const views = board.map((column) => projectColumn(column, board));
|
|
26495
26990
|
return {
|
|
26496
26991
|
value: {
|
|
26497
|
-
...extra,
|
|
26992
|
+
...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, typeof value === "number" || Array.isArray(value) ? value : projectColumn(value, board)])),
|
|
26498
26993
|
columns: views
|
|
26499
26994
|
},
|
|
26500
26995
|
text: `${message}\n\n${describeColumns(views)}`
|
|
@@ -26523,14 +27018,14 @@ async function addColumn(session, request, actor) {
|
|
|
26523
27018
|
category: request.category ?? spec.category,
|
|
26524
27019
|
position: anchor ? request.after !== void 0 ? { after: anchor.id } : { before: anchor.id } : void 0
|
|
26525
27020
|
}, actor);
|
|
26526
|
-
return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added:
|
|
27021
|
+
return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: created });
|
|
26527
27022
|
}
|
|
26528
27023
|
/** Said after a rename: agents' instructions name no column but To Do, and `kanbo prime` gives them the rest. */
|
|
26529
27024
|
const RENAME_NOTE = "Agents read column names from kanbo prime; rename is picked up there.";
|
|
26530
27025
|
async function renameColumn(session, column, newName, actor) {
|
|
26531
27026
|
const before = await requireNamedColumn(session, column);
|
|
26532
27027
|
const renamed = await session.ops.renameColumn(session.workspace.id, before.id, newName, actor);
|
|
26533
|
-
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
|
|
27028
|
+
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 });
|
|
26534
27029
|
}
|
|
26535
27030
|
/** `--first`, `--last`, `--before <column>` or `--after <column>` — exactly one. */
|
|
26536
27031
|
function readColumnPosition(options) {
|
|
@@ -26562,7 +27057,7 @@ async function removeColumn(session, column, moveCardsTo, actor) {
|
|
|
26562
27057
|
const result = await session.ops.removeColumn(session.workspace.id, removing.id, { moveCardsTo }, actor);
|
|
26563
27058
|
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}`);
|
|
26564
27059
|
return await changed(session, result.movedTo ? `Removed ${result.removed.name} and moved ${describeCount(result.movedCards)} to ${result.movedTo.name}.` : `Removed ${result.removed.name}.`, {
|
|
26565
|
-
removed:
|
|
27060
|
+
removed: result.removed,
|
|
26566
27061
|
movedCards: result.movedCards
|
|
26567
27062
|
});
|
|
26568
27063
|
}
|
|
@@ -26686,7 +27181,7 @@ async function runColumnsMenu(ui) {
|
|
|
26686
27181
|
if (showList) say$2(ui, [
|
|
26687
27182
|
"",
|
|
26688
27183
|
"Columns of this board:",
|
|
26689
|
-
describeColumns(columns.map(projectColumn))
|
|
27184
|
+
describeColumns(columns.map((column) => projectColumn(column, columns)))
|
|
26690
27185
|
].join("\n"));
|
|
26691
27186
|
let choice;
|
|
26692
27187
|
try {
|
|
@@ -26914,7 +27409,8 @@ function registerColumnsCommands(program) {
|
|
|
26914
27409
|
const columns = program.command("columns").description("See and change the board's columns (alone: a menu)");
|
|
26915
27410
|
withBoardOptions(columns.command("list").description("List the columns in order, with the short name to move cards by")).action(async (options) => {
|
|
26916
27411
|
await runBoardCommand(options, "read", async (session) => {
|
|
26917
|
-
const
|
|
27412
|
+
const board = await session.ops.listColumns(session.workspace.id);
|
|
27413
|
+
const views = board.map((column) => projectColumn(column, board));
|
|
26918
27414
|
return {
|
|
26919
27415
|
value: views,
|
|
26920
27416
|
text: describeColumns(views)
|
|
@@ -26923,7 +27419,7 @@ function registerColumnsCommands(program) {
|
|
|
26923
27419
|
});
|
|
26924
27420
|
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) => {
|
|
26925
27421
|
await runBoardCommand(options, "write", async (session) => {
|
|
26926
|
-
const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text));
|
|
27422
|
+
const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text), await session.ops.listColumns(session.workspace.id));
|
|
26927
27423
|
return {
|
|
26928
27424
|
value: view,
|
|
26929
27425
|
text: describeColumns([view])
|
|
@@ -26934,13 +27430,34 @@ function registerColumnsCommands(program) {
|
|
|
26934
27430
|
const actor = requireHumanActor("columns rules", typedWords(command));
|
|
26935
27431
|
const rules = readRequestedRules(options);
|
|
26936
27432
|
await runBoardCommand(options, "write", async (session) => {
|
|
26937
|
-
const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor));
|
|
27433
|
+
const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor), await session.ops.listColumns(session.workspace.id));
|
|
26938
27434
|
return {
|
|
26939
27435
|
value: view,
|
|
26940
27436
|
text: describeColumns([view])
|
|
26941
27437
|
};
|
|
26942
27438
|
});
|
|
26943
27439
|
});
|
|
27440
|
+
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) => {
|
|
27441
|
+
const actor = requireHumanActor("columns flow", typedWords(command));
|
|
27442
|
+
if (options.from === void 0 && options.wip === void 0) throw new CliError(1, "Pass --from <columns|any>, --wip <count|none>, or both.");
|
|
27443
|
+
const wipLimit = options.wip === void 0 ? void 0 : readWipLimit(options.wip);
|
|
27444
|
+
await runBoardCommand(options, "write", async (session) => {
|
|
27445
|
+
const workspaceId = session.workspace.id;
|
|
27446
|
+
const board = await session.ops.listColumns(workspaceId);
|
|
27447
|
+
const target = await session.ops.requireColumn(workspaceId, column);
|
|
27448
|
+
const current = readFlowRules(target);
|
|
27449
|
+
await session.ops.setColumnFlowRules(target.id, {
|
|
27450
|
+
allowedFrom: options.from === void 0 ? current.allowedFrom : readAllowedFrom(board, workspaceId, options.from),
|
|
27451
|
+
wipLimit: wipLimit === void 0 ? current.wipLimit : wipLimit
|
|
27452
|
+
}, actor);
|
|
27453
|
+
const after = await session.ops.listColumns(workspaceId);
|
|
27454
|
+
const views = after.map((each) => projectColumn(each, after));
|
|
27455
|
+
return {
|
|
27456
|
+
value: views.find((view) => view.id === target.id),
|
|
27457
|
+
text: describeColumns(views)
|
|
27458
|
+
};
|
|
27459
|
+
});
|
|
27460
|
+
});
|
|
26944
27461
|
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) => {
|
|
26945
27462
|
const actor = requireHumanActor("columns add", typedWords(command));
|
|
26946
27463
|
await runBoardCommand(options, "write", async (session) => await addColumn(session, {
|
|
@@ -26982,7 +27499,7 @@ function registerColumnsCommands(program) {
|
|
|
26982
27499
|
mode: "add-missing",
|
|
26983
27500
|
actor
|
|
26984
27501
|
});
|
|
26985
|
-
const views = statuses.map(projectColumn);
|
|
27502
|
+
const views = statuses.map((column) => projectColumn(column, statuses));
|
|
26986
27503
|
return {
|
|
26987
27504
|
value: {
|
|
26988
27505
|
added,
|
|
@@ -27006,7 +27523,8 @@ function registerColumnsCommands(program) {
|
|
|
27006
27523
|
return;
|
|
27007
27524
|
}
|
|
27008
27525
|
await runBoardCommand({}, "read", async (session) => {
|
|
27009
|
-
const
|
|
27526
|
+
const board = await session.ops.listColumns(session.workspace.id);
|
|
27527
|
+
const views = board.map((column) => projectColumn(column, board));
|
|
27010
27528
|
return {
|
|
27011
27529
|
value: views,
|
|
27012
27530
|
text: describeColumns(views)
|
|
@@ -27036,6 +27554,26 @@ function readRequestedRules(options) {
|
|
|
27036
27554
|
if (options.require === void 0) throw new CliError(1, `Pass --require ${ENTRY_RULES.join(",")} (any of them) or --clear.`);
|
|
27037
27555
|
return options.require.split(",").map((rule) => rule.trim()).filter(Boolean);
|
|
27038
27556
|
}
|
|
27557
|
+
/** `--from`: `any` for every column, else the columns named, by slug, name or id. */
|
|
27558
|
+
function readAllowedFrom(board, workspaceId, value) {
|
|
27559
|
+
if (value.trim().toLowerCase() === "any") return null;
|
|
27560
|
+
const names = value.split(",").map((name) => name.trim()).filter(Boolean);
|
|
27561
|
+
if (names.length === 0) throw new CliError(1, "Name the columns a card may come from (--from in_review,qa), or --from any.");
|
|
27562
|
+
return names.map((name) => {
|
|
27563
|
+
const found = matchColumn(board, workspaceId, name);
|
|
27564
|
+
if (!found) throw new BoardError("issue_status_not_found", {
|
|
27565
|
+
workspaceId,
|
|
27566
|
+
statusName: name
|
|
27567
|
+
});
|
|
27568
|
+
return found.id;
|
|
27569
|
+
});
|
|
27570
|
+
}
|
|
27571
|
+
/** `--wip`: `none` for no limit, else a whole number of cards, 1 or more. */
|
|
27572
|
+
function readWipLimit(value) {
|
|
27573
|
+
if (value.trim().toLowerCase() === "none") return null;
|
|
27574
|
+
if (!/^\d+$/.test(value.trim()) || Number(value) < 1) throw new CliError(1, "--wip takes a whole number of cards, 1 or more, or none.");
|
|
27575
|
+
return Number(value);
|
|
27576
|
+
}
|
|
27039
27577
|
//#endregion
|
|
27040
27578
|
//#region node_modules/zod/v3/helpers/util.js
|
|
27041
27579
|
var util;
|
|
@@ -42978,7 +43516,7 @@ const INSTRUCTION_END_PATTERN = /<!-- KANBO_END\s*-->/g;
|
|
|
42978
43516
|
const POINTER_KINDS = /* @__PURE__ */ new Set(["short", "global"]);
|
|
42979
43517
|
/** The commands an agent uses, spelled once for the guide and the `kanbo prime` cheat sheet. */
|
|
42980
43518
|
const COMMAND = {
|
|
42981
|
-
|
|
43519
|
+
claim: "kanbo claim",
|
|
42982
43520
|
move: "kanbo card move <id> <column>",
|
|
42983
43521
|
statusLine: "kanbo card status-line <id> --text \"...\"",
|
|
42984
43522
|
subtask: "kanbo card create --description \"...\" --parent <id>",
|
|
@@ -42997,7 +43535,7 @@ const AGENT_GUIDE_TEXT = [
|
|
|
42997
43535
|
"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.",
|
|
42998
43536
|
"",
|
|
42999
43537
|
...[
|
|
43000
|
-
`- Take a card
|
|
43538
|
+
`- 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.`,
|
|
43001
43539
|
`- ${MOVE_CARD_RULE} (\`${COMMAND.move}\`)`,
|
|
43002
43540
|
`- ${STATUS_LINE_RULE} (\`${COMMAND.statusLine}\`)`,
|
|
43003
43541
|
`- ${SUBTASK_RULE} (\`${COMMAND.subtask}\`)`,
|
|
@@ -43027,12 +43565,13 @@ const COMMAND_SHEET = [
|
|
|
43027
43565
|
"- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
|
|
43028
43566
|
"- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
|
|
43029
43567
|
"- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
|
|
43030
|
-
"- `kanbo
|
|
43568
|
+
"- `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",
|
|
43569
|
+
"- `kanbo run start TST-5 --agent claude` — record a run on a card you did not claim; it prints the finish command",
|
|
43031
43570
|
"- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
|
|
43032
43571
|
`- \`${COMMAND.capabilities}\` — everything else, rarely needed`
|
|
43033
43572
|
].join("\n");
|
|
43034
43573
|
/** How to take work and who approves it — said by both blocks after their first sentence. */
|
|
43035
|
-
const POINTER_TAIL = `Take work with \`${COMMAND.
|
|
43574
|
+
const POINTER_TAIL = `Take work with \`${COMMAND.claim}\`. ${PERSON_ONLY_RULE}`;
|
|
43036
43575
|
/** The body of the project block: what `kanbo instructions short` prints. */
|
|
43037
43576
|
const PROJECT_BODY = [
|
|
43038
43577
|
"## Kanbo board",
|
|
@@ -43052,7 +43591,7 @@ const GLOBAL_BODY = [
|
|
|
43052
43591
|
* The block, markers included, with this body's version and hash on the start
|
|
43053
43592
|
* marker — and, when given, the kind of text it is (`k=orchestrator`).
|
|
43054
43593
|
*/
|
|
43055
|
-
function wrapInstructionBlock(body, version =
|
|
43594
|
+
function wrapInstructionBlock(body, version = 3, kind) {
|
|
43056
43595
|
return `<!-- KANBO_START v${version}${kind ? ` k=${kind}` : ""} h=${hashBody(body)} -->\n${body}\n${INSTRUCTION_END}`;
|
|
43057
43596
|
}
|
|
43058
43597
|
/** The block `kanbo init --instructions …` writes into a project's own file. */
|
|
@@ -43081,7 +43620,7 @@ function inspectBlock(text, current) {
|
|
|
43081
43620
|
kind
|
|
43082
43621
|
});
|
|
43083
43622
|
if (fields.trim() === "") return inspection(LEGACY_BLOCK_BODIES.includes(body) ? "legacy" : "edited");
|
|
43084
|
-
if (version !== void 0 && Number(version) >
|
|
43623
|
+
if (version !== void 0 && Number(version) > 3) return inspection("newer");
|
|
43085
43624
|
if (kind !== null && !POINTER_KINDS.has(kind)) return inspection("chosen");
|
|
43086
43625
|
if (version === void 0 || hash === void 0 || hashBody(body) !== hash) return inspection("edited");
|
|
43087
43626
|
return inspection(normalized.slice(found.start, found.end) === current ? "current" : "outdated");
|
|
@@ -44153,7 +44692,7 @@ function startUpdateCheck(deps = {}) {
|
|
|
44153
44692
|
setTimeout: globalThis.setTimeout,
|
|
44154
44693
|
clearTimeout: globalThis.clearTimeout
|
|
44155
44694
|
};
|
|
44156
|
-
const current = deps.currentVersion ?? "0.
|
|
44695
|
+
const current = deps.currentVersion ?? "0.5.0";
|
|
44157
44696
|
const controller = new AbortController();
|
|
44158
44697
|
let found = null;
|
|
44159
44698
|
const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
|
|
@@ -46670,7 +47209,7 @@ const KANBO_MCP_INSTRUCTIONS = [
|
|
|
46670
47209
|
"kanbo is a kanban board shared by people and agents; these tools read and change its cards.",
|
|
46671
47210
|
"",
|
|
46672
47211
|
`- At the start of a session call ${tool("kanbo_prime")} — it returns this board's columns and what each one means.`,
|
|
46673
|
-
`- Take work from ${tool("kanbo_ready")}.`,
|
|
47212
|
+
`- 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.`,
|
|
46674
47213
|
`- ${MOVE_CARD_RULE} (${tool("kanbo_card_move")})`,
|
|
46675
47214
|
`- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
|
|
46676
47215
|
`- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
|
|
@@ -46761,7 +47300,7 @@ const ORCHESTRATOR_GUIDE = [
|
|
|
46761
47300
|
"## Start of every session",
|
|
46762
47301
|
"1. Run `kanbo prime` (tool: `kanbo_prime`) and read the column descriptions. On this board the description is the contract, not the name.",
|
|
46763
47302
|
"2. Run `kanbo ready` (`kanbo_ready`). If nothing is ready, say so and stop.",
|
|
46764
|
-
"3. Take the first card
|
|
47303
|
+
"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.",
|
|
46765
47304
|
"",
|
|
46766
47305
|
"## While working",
|
|
46767
47306
|
"- 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.",
|
|
@@ -46788,7 +47327,7 @@ const ORCHESTRATOR_GUIDE = [
|
|
|
46788
47327
|
"- Returned: the reason is the newest comment. The card is back in an earlier column; start a new run and address the reason first.",
|
|
46789
47328
|
"",
|
|
46790
47329
|
"## Never",
|
|
46791
|
-
"- 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.",
|
|
47330
|
+
"- 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.",
|
|
46792
47331
|
"- Never delete or edit the board's database directly."
|
|
46793
47332
|
].join("\n");
|
|
46794
47333
|
//#endregion
|
|
@@ -46833,7 +47372,7 @@ function registerInstructionsCommand(program, dependencies = {}) {
|
|
|
46833
47372
|
const found = parseKind(kind);
|
|
46834
47373
|
const body = await readInstructionText(found);
|
|
46835
47374
|
const pointer = found === "short" || found === "global";
|
|
46836
|
-
const text = options.markers ? wrapInstructionBlock(body,
|
|
47375
|
+
const text = options.markers ? wrapInstructionBlock(body, 3, pointer ? void 0 : found) : body;
|
|
46837
47376
|
const hints = process.stderr.isTTY === true;
|
|
46838
47377
|
if (hints) process.stderr.write(`${HEADER}\n\n`);
|
|
46839
47378
|
console.log(text);
|
|
@@ -48266,7 +48805,8 @@ async function createDbTransport(input) {
|
|
|
48266
48805
|
const workspaceId = workspace.id;
|
|
48267
48806
|
const actor = input.actor;
|
|
48268
48807
|
async function readColumns() {
|
|
48269
|
-
|
|
48808
|
+
const rows = await ops.listColumns(workspaceId);
|
|
48809
|
+
return rows.map((row) => projectKanboColumn(row, rows));
|
|
48270
48810
|
}
|
|
48271
48811
|
/** One card with what the run registry says about it, in the shape a tool prints. */
|
|
48272
48812
|
async function project(card, columns) {
|
|
@@ -48316,7 +48856,7 @@ async function createDbTransport(input) {
|
|
|
48316
48856
|
* the waiting and returned ones last said at most two more.
|
|
48317
48857
|
*/
|
|
48318
48858
|
async function projectPage(page, { attend = true } = {}) {
|
|
48319
|
-
const columns = page.columns.map(projectKanboColumn);
|
|
48859
|
+
const columns = page.columns.map((column) => projectKanboColumn(column, page.columns));
|
|
48320
48860
|
const runs = await ops.readBoardProjectionForIssues(page.cards.map((card) => card.id));
|
|
48321
48861
|
const attention = attend ? await ops.readCardAttention(page.cards, runs.map((run) => run.activeRun !== null)) : [];
|
|
48322
48862
|
return page.cards.map((card, index) => projectKanboCard({
|
|
@@ -48448,6 +48988,25 @@ async function createDbTransport(input) {
|
|
|
48448
48988
|
state,
|
|
48449
48989
|
errorText
|
|
48450
48990
|
}, actor));
|
|
48991
|
+
},
|
|
48992
|
+
cardClaim: async ({ card, agent }) => {
|
|
48993
|
+
await session.assertWritable();
|
|
48994
|
+
const named = card === void 0 ? void 0 : (await resolveCard(card)).id;
|
|
48995
|
+
const claimed = await ops.claimCard({
|
|
48996
|
+
workspaceId,
|
|
48997
|
+
issueId: named,
|
|
48998
|
+
run: {
|
|
48999
|
+
agentName: agent ?? actor.id ?? "agent",
|
|
49000
|
+
externalSessionRef: sessionRefFromEnvironment() ?? void 0,
|
|
49001
|
+
launchedByKind: "external"
|
|
49002
|
+
}
|
|
49003
|
+
}, actor);
|
|
49004
|
+
const columns = await readColumns();
|
|
49005
|
+
return {
|
|
49006
|
+
card: await project(claimed.card, columns),
|
|
49007
|
+
run: projectKanboRun(claimed.run),
|
|
49008
|
+
column: columns.find((column) => column.id === claimed.column.id)
|
|
49009
|
+
};
|
|
48451
49010
|
}
|
|
48452
49011
|
};
|
|
48453
49012
|
}
|
|
@@ -48489,7 +49048,8 @@ const TRANSPORT_METHODS = {
|
|
|
48489
49048
|
statusLine: true,
|
|
48490
49049
|
waitApproval: true,
|
|
48491
49050
|
runStart: true,
|
|
48492
|
-
runFinish: true
|
|
49051
|
+
runFinish: true,
|
|
49052
|
+
cardClaim: true
|
|
48493
49053
|
};
|
|
48494
49054
|
async function openMcpBoard(input) {
|
|
48495
49055
|
try {
|
|
@@ -49095,7 +49655,7 @@ function sendBoardPage(response, pathname, assets) {
|
|
|
49095
49655
|
* answering for a person — approving a card, sending it back or closing a
|
|
49096
49656
|
* sprint — as anyone but one is 403, dropping a pull request link somebody else made is the same 403
|
|
49097
49657
|
* for the same reason, and acting on a run that has already ended is 409,
|
|
49098
|
-
* because the answer would have been yes a moment earlier. An agent putting a
|
|
49658
|
+
* 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
|
|
49099
49659
|
* card in a column whose entry rules it does not meet yet is 409 for the same
|
|
49100
49660
|
* reason turned around: the answer will be yes once the card meets them, and
|
|
49101
49661
|
* `details.unmet` says what is missing. Deleting a column that still holds
|
|
@@ -49113,6 +49673,14 @@ const BOARD_ERROR_RESPONSES = {
|
|
|
49113
49673
|
message: "Only a person can approve a card",
|
|
49114
49674
|
code: "issue_approval_requires_user"
|
|
49115
49675
|
},
|
|
49676
|
+
board_card_not_ready: {
|
|
49677
|
+
status: 409,
|
|
49678
|
+
message: "That card is not there to take: it is not in To Do, waits for a person, or has a run going"
|
|
49679
|
+
},
|
|
49680
|
+
board_card_run_active: {
|
|
49681
|
+
status: 409,
|
|
49682
|
+
message: "The card already has a run going"
|
|
49683
|
+
},
|
|
49116
49684
|
board_column_name_taken: {
|
|
49117
49685
|
status: 400,
|
|
49118
49686
|
message: "Another column already has that name"
|
|
@@ -49152,6 +49720,14 @@ const BOARD_ERROR_RESPONSES = {
|
|
|
49152
49720
|
status: 400,
|
|
49153
49721
|
message: "Execution mode cannot change once the card has been launched"
|
|
49154
49722
|
},
|
|
49723
|
+
board_flow_rule_invalid: {
|
|
49724
|
+
status: 400,
|
|
49725
|
+
message: "A column accepts cards from other columns of its own board, and holds at least one card"
|
|
49726
|
+
},
|
|
49727
|
+
board_nothing_ready: {
|
|
49728
|
+
status: 409,
|
|
49729
|
+
message: "No card in To Do is ready to take"
|
|
49730
|
+
},
|
|
49155
49731
|
board_pull_request_invalid: {
|
|
49156
49732
|
status: 400,
|
|
49157
49733
|
message: "Not a GitHub pull request: give its URL or owner/repo#number"
|
|
@@ -49567,6 +50143,14 @@ const startRunBody = object$1({
|
|
|
49567
50143
|
]).optional(),
|
|
49568
50144
|
externalSessionRef: string().nullable().optional()
|
|
49569
50145
|
});
|
|
50146
|
+
const claimBody = object$1({
|
|
50147
|
+
workspaceId: id,
|
|
50148
|
+
issueId: id.optional(),
|
|
50149
|
+
agentName: id.optional(),
|
|
50150
|
+
branch: string().nullable().optional(),
|
|
50151
|
+
executionMode: executionMode.optional(),
|
|
50152
|
+
externalSessionRef: string().nullable().optional()
|
|
50153
|
+
});
|
|
49570
50154
|
const finishRunBody = object$1({
|
|
49571
50155
|
state: _enum([
|
|
49572
50156
|
"finished",
|
|
@@ -49613,7 +50197,8 @@ async function toWrittenCardView(ops, card) {
|
|
|
49613
50197
|
function toStatusView(status) {
|
|
49614
50198
|
return {
|
|
49615
50199
|
...status,
|
|
49616
|
-
entryRules: readEntryRules(status)
|
|
50200
|
+
entryRules: readEntryRules(status),
|
|
50201
|
+
allowedFrom: readFlowRules(status).allowedFrom
|
|
49617
50202
|
};
|
|
49618
50203
|
}
|
|
49619
50204
|
function toCommentView(comment, personWriterId = null) {
|
|
@@ -50048,6 +50633,31 @@ function createIssueRoutes(session, options = {}) {
|
|
|
50048
50633
|
}, actors.placement));
|
|
50049
50634
|
}
|
|
50050
50635
|
}),
|
|
50636
|
+
route({
|
|
50637
|
+
method: "POST",
|
|
50638
|
+
path: "/issues/claim",
|
|
50639
|
+
body: claimBody,
|
|
50640
|
+
write: true,
|
|
50641
|
+
handle: async ({ body, actors }) => {
|
|
50642
|
+
const named = requireWorkspace(body.workspaceId);
|
|
50643
|
+
if (body.issueId !== void 0) await requireCard(body.issueId);
|
|
50644
|
+
const claimed = await ops.claimCard({
|
|
50645
|
+
workspaceId: named,
|
|
50646
|
+
issueId: body.issueId,
|
|
50647
|
+
run: {
|
|
50648
|
+
agentName: body.agentName ?? actors.writer.id ?? "agent",
|
|
50649
|
+
branch: body.branch,
|
|
50650
|
+
executionMode: body.executionMode,
|
|
50651
|
+
externalSessionRef: body.externalSessionRef
|
|
50652
|
+
}
|
|
50653
|
+
}, actors.placement);
|
|
50654
|
+
return {
|
|
50655
|
+
card: await toWrittenCardView(ops, claimed.card),
|
|
50656
|
+
run: await toRunView(claimed.run),
|
|
50657
|
+
column: toStatusView(claimed.column)
|
|
50658
|
+
};
|
|
50659
|
+
}
|
|
50660
|
+
}),
|
|
50051
50661
|
route({
|
|
50052
50662
|
method: "PATCH",
|
|
50053
50663
|
path: "/issues/bulk",
|
|
@@ -51189,6 +51799,7 @@ function registerKanboCommands(program, context = {}) {
|
|
|
51189
51799
|
registerServeCommand(program);
|
|
51190
51800
|
program.commandsGroup("For agents and integrations:");
|
|
51191
51801
|
registerMcpCommand(program);
|
|
51802
|
+
registerClaimCommand(program);
|
|
51192
51803
|
registerRunCommands(program);
|
|
51193
51804
|
registerCapabilitiesCommand(program);
|
|
51194
51805
|
program.commandsGroup("Shared Postgres boards:");
|