kanbo-cli 0.4.0 → 0.6.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 +30 -0
- package/dist/cli-main.cjs +1322 -154
- 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-BjhiH9TZ.d.mts} +38 -2
- package/dist/lib/{board-store-Dx-mw2I6.d.cts → board-store-CsyBXBwH.d.cts} +38 -2
- package/dist/lib/index.cjs +9 -1
- package/dist/lib/index.d.cts +302 -6
- package/dist/lib/index.d.mts +302 -6
- package/dist/lib/index.mjs +2 -2
- package/dist/lib/{manifest-DcIMYvWk.cjs → manifest-BFI4HGPh.cjs} +1410 -225
- package/dist/lib/{manifest-Dmo6IlWk.mjs → manifest-DHI1fkoW.mjs} +1395 -258
- 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-Dca5WdYd.cjs → migrate-36aT0rV0.cjs} +17 -0
- package/dist/lib/{migrate-DtBeW3Fs.mjs → migrate-Bgj_aABY.mjs} +18 -1
- 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-9OkYvsNM.mjs} +26 -2
- package/dist/lib/{roles.sql-E58x7mLS.cjs → roles.sql-BC7k_zPh.cjs} +25 -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
|
@@ -212,6 +212,26 @@ var BoardError = class extends Error {
|
|
|
212
212
|
}
|
|
213
213
|
};
|
|
214
214
|
//#endregion
|
|
215
|
+
//#region src/domain/iso-week.ts
|
|
216
|
+
const DAY_SECONDS$2 = 86400;
|
|
217
|
+
/** The ISO week a moment falls in, read in UTC like every board date (ruling 5x-6). */
|
|
218
|
+
function isoWeekOf(seconds) {
|
|
219
|
+
const day = Math.floor(seconds / DAY_SECONDS$2);
|
|
220
|
+
const monday = day - (day + 3) % 7;
|
|
221
|
+
const thursday = monday + 3;
|
|
222
|
+
const year = (/* @__PURE__ */ new Date(thursday * DAY_SECONDS$2 * 1e3)).getUTCFullYear();
|
|
223
|
+
const januaryFirst = Date.UTC(year, 0, 1) / 1e3 / DAY_SECONDS$2;
|
|
224
|
+
const week = Math.floor((thursday - januaryFirst) / 7) + 1;
|
|
225
|
+
return {
|
|
226
|
+
week: `${year}-W${String(week).padStart(2, "0")}`,
|
|
227
|
+
start: monday * DAY_SECONDS$2
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/** The UTC day a moment falls on, as `2026-01-05`. */
|
|
231
|
+
function utcDayLabel(seconds) {
|
|
232
|
+
return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
|
|
233
|
+
}
|
|
234
|
+
//#endregion
|
|
215
235
|
//#region src/domain/status-name.ts
|
|
216
236
|
/**
|
|
217
237
|
* The columns a workspace gets the first time someone opens its board, in board
|
|
@@ -272,6 +292,14 @@ const BOARD_ERROR_TEXTS = {
|
|
|
272
292
|
text: "Only a person can approve a card.",
|
|
273
293
|
next: AS_A_PERSON
|
|
274
294
|
}),
|
|
295
|
+
board_card_not_ready: (details) => ({
|
|
296
|
+
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.`,
|
|
297
|
+
next: "kanbo ready"
|
|
298
|
+
}),
|
|
299
|
+
board_card_run_active: (details) => ({
|
|
300
|
+
text: `${named(details.issueId)} already has a run going: ${named(details.agentName)}'s, run ${named(details.runId)}.`,
|
|
301
|
+
next: `kanbo run finish ${named(details.runId)} --state stopped`
|
|
302
|
+
}),
|
|
275
303
|
board_column_name_taken: (details) => ({
|
|
276
304
|
text: `Another column, ${named(details.takenBy)}, already has that name (same slug). Pick another name.`,
|
|
277
305
|
next: "kanbo columns list"
|
|
@@ -302,6 +330,15 @@ const BOARD_ERROR_TEXTS = {
|
|
|
302
330
|
}),
|
|
303
331
|
board_entry_rule_invalid: (details) => ({ text: `Unknown column rule: ${list(details.rules)}. Use any of ${list(details.allowed)}.` }),
|
|
304
332
|
board_execution_mode_locked: (details) => ({ text: `${named(details.issueId)} has been started, so where it is worked (${named(details.executionMode)}) can't change now.` }),
|
|
333
|
+
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.` } : {
|
|
334
|
+
text: `A column accepts cards only from other columns of its own board, and ${list(details.allowedFrom)} is not one.`,
|
|
335
|
+
next: "kanbo columns list"
|
|
336
|
+
},
|
|
337
|
+
board_metrics_range_invalid: (details) => typeof details.from === "number" && typeof details.to === "number" ? { text: `The range starts after it ends: it starts on ${utcDayLabel(details.from)} and ends on ${utcDayLabel(details.to)}.` } : { text: "The range starts after it ends." },
|
|
338
|
+
board_nothing_ready: () => ({
|
|
339
|
+
text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
|
|
340
|
+
next: "kanbo ready"
|
|
341
|
+
}),
|
|
305
342
|
board_pull_request_invalid: (details) => ({ text: `"${named(details.value)}" is not a GitHub pull request. Give its link or owner/repo#number.` }),
|
|
306
343
|
board_pull_request_not_found: (details) => ({
|
|
307
344
|
text: `${named(details.issueId)} has no pull request link "${named(details.linkId)}".`,
|
|
@@ -367,7 +404,7 @@ const BOARD_ERROR_TEXTS = {
|
|
|
367
404
|
issue_relation_self_reference: () => ({ text: "A card can't be linked to itself." }),
|
|
368
405
|
issue_status_name_empty: () => ({ text: "Give the column a name." }),
|
|
369
406
|
issue_status_not_found: (details) => ({
|
|
370
|
-
text: `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
|
|
407
|
+
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
408
|
next: "kanbo columns list"
|
|
372
409
|
}),
|
|
373
410
|
issue_status_reference_conflict: () => ({ text: "Name the column once: by its id or by its name, not both." }),
|
|
@@ -3561,7 +3598,7 @@ new Command();
|
|
|
3561
3598
|
* `package.version`. A build carries it as a literal; running from source
|
|
3562
3599
|
* (tests, `tsx`) reads the package's own `package.json` instead.
|
|
3563
3600
|
*/
|
|
3564
|
-
const KANBO_PACKAGE_VERSION = "0.
|
|
3601
|
+
const KANBO_PACKAGE_VERSION = "0.6.0";
|
|
3565
3602
|
//#endregion
|
|
3566
3603
|
//#region src/cli/agent-shell.ts
|
|
3567
3604
|
/**
|
|
@@ -3700,6 +3737,7 @@ const HUMAN_ONLY_ACTIONS = [
|
|
|
3700
3737
|
"return",
|
|
3701
3738
|
"sprint close",
|
|
3702
3739
|
"columns rules",
|
|
3740
|
+
"columns flow",
|
|
3703
3741
|
"columns add",
|
|
3704
3742
|
"columns rename",
|
|
3705
3743
|
"columns move",
|
|
@@ -3713,6 +3751,7 @@ const HUMAN_ONLY_MESSAGES = {
|
|
|
3713
3751
|
"return": describeReturnRefusal,
|
|
3714
3752
|
"sprint close": describeSprintCloseRefusal,
|
|
3715
3753
|
"columns rules": describeColumnRulesRefusal,
|
|
3754
|
+
"columns flow": describeColumnRulesRefusal,
|
|
3716
3755
|
"columns add": describeColumnChangesRefusal,
|
|
3717
3756
|
"columns rename": describeColumnChangesRefusal,
|
|
3718
3757
|
"columns move": describeColumnChangesRefusal,
|
|
@@ -9833,18 +9872,18 @@ function activityToken(token) {
|
|
|
9833
9872
|
function currentUnixSeconds() {
|
|
9834
9873
|
return Math.floor(Date.now() / 1e3);
|
|
9835
9874
|
}
|
|
9836
|
-
const DAY_SECONDS = 86400;
|
|
9875
|
+
const DAY_SECONDS$1 = 86400;
|
|
9837
9876
|
/**
|
|
9838
9877
|
* A board date is a calendar day (ruling 5x-6): a start is kept as the first
|
|
9839
9878
|
* second of its UTC day, a due date as the last, so a sprint runs through its
|
|
9840
9879
|
* due day wherever the person reading it is.
|
|
9841
9880
|
*/
|
|
9842
9881
|
function utcDayStart(seconds) {
|
|
9843
|
-
return Math.floor(seconds / DAY_SECONDS) * DAY_SECONDS;
|
|
9882
|
+
return Math.floor(seconds / DAY_SECONDS$1) * DAY_SECONDS$1;
|
|
9844
9883
|
}
|
|
9845
9884
|
/** The last second of the UTC day `seconds` falls on — see `utcDayStart`. */
|
|
9846
9885
|
function utcDayEnd(seconds) {
|
|
9847
|
-
return utcDayStart(seconds) + DAY_SECONDS - 1;
|
|
9886
|
+
return utcDayStart(seconds) + DAY_SECONDS$1 - 1;
|
|
9848
9887
|
}
|
|
9849
9888
|
/** The largest unix seconds a moment is read as: past it, the number is milliseconds (year 5138 onwards). */
|
|
9850
9889
|
const MAX_UNIX_SECONDS = 1e11;
|
|
@@ -10014,6 +10053,14 @@ const HIDDEN_ACTIVITY_FIELDS = /* @__PURE__ */ new Set([
|
|
|
10014
10053
|
"order",
|
|
10015
10054
|
"waitingFor"
|
|
10016
10055
|
]);
|
|
10056
|
+
/**
|
|
10057
|
+
* The column a card was created in, recorded as a change from no column at the
|
|
10058
|
+
* card's own creation time. The feed's `created` item already stands for it,
|
|
10059
|
+
* and the field history keeps it.
|
|
10060
|
+
*/
|
|
10061
|
+
function isStartingColumn(change, issue) {
|
|
10062
|
+
return change.field === "statusId" && change.fromValue === null && change.createdAt === issue.createdAt;
|
|
10063
|
+
}
|
|
10017
10064
|
const ACTIVITY_FIELD_BY_RAW_FIELD = {
|
|
10018
10065
|
assigneeId: "assignee",
|
|
10019
10066
|
description: "description",
|
|
@@ -10054,7 +10101,7 @@ async function listActivity(store, issueId, resolver) {
|
|
|
10054
10101
|
workspaceById: resolver.workspaceById
|
|
10055
10102
|
};
|
|
10056
10103
|
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);
|
|
10104
|
+
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
10105
|
return [
|
|
10059
10106
|
{
|
|
10060
10107
|
id: `${issue.id}:created`,
|
|
@@ -10252,6 +10299,536 @@ function previewComment(comment) {
|
|
|
10252
10299
|
};
|
|
10253
10300
|
}
|
|
10254
10301
|
//#endregion
|
|
10302
|
+
//#region src/domain/card-display-title.ts
|
|
10303
|
+
/**
|
|
10304
|
+
* What a card is called wherever a person or an agent reads it by name.
|
|
10305
|
+
*
|
|
10306
|
+
* A card written as a description alone is titled with its own key (every
|
|
10307
|
+
* writer does that). Named by its key, such a card says nothing — so its name
|
|
10308
|
+
* is the first line of its description instead. A browser bundle that does not
|
|
10309
|
+
* load this package keeps the same rule on its own, tested with the same
|
|
10310
|
+
* examples.
|
|
10311
|
+
*/
|
|
10312
|
+
/** How long a description line may run before it is cut with an ellipsis. */
|
|
10313
|
+
const DESCRIPTION_LINE_LIMIT = 120;
|
|
10314
|
+
function cardDisplayTitle(card) {
|
|
10315
|
+
if (card.title !== card.id) return card.title;
|
|
10316
|
+
return descriptionFirstLine(card.description) ?? card.title;
|
|
10317
|
+
}
|
|
10318
|
+
function descriptionFirstLine(description) {
|
|
10319
|
+
for (const rawLine of (description ?? "").split("\n")) {
|
|
10320
|
+
const line = stripMarkdown(rawLine);
|
|
10321
|
+
if (line) return line.length > DESCRIPTION_LINE_LIMIT ? `${line.slice(0, 119).trimEnd()}…` : line;
|
|
10322
|
+
}
|
|
10323
|
+
return null;
|
|
10324
|
+
}
|
|
10325
|
+
function stripMarkdown(line) {
|
|
10326
|
+
return line.replace(/^\s*(?:>\s*)+/, "").replace(/^\s*#{1,6}\s+/, "").replace(/^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ x]\]\s+)?/i, "").replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<[^>]+>/g, "").replace(/[*_~`]/g, "").replace(/\s+/g, " ").trim();
|
|
10327
|
+
}
|
|
10328
|
+
//#endregion
|
|
10329
|
+
//#region src/domain/flow-rules.ts
|
|
10330
|
+
/** The column the standard flow guards, and the columns it lets cards in from, by slug. */
|
|
10331
|
+
const STANDARD_FLOW_TARGET = "done";
|
|
10332
|
+
const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
|
|
10333
|
+
/** A column's flow rules, out of what the row stores. */
|
|
10334
|
+
function readFlowRules(column) {
|
|
10335
|
+
return {
|
|
10336
|
+
allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
|
|
10337
|
+
wipLimit: column.wipLimit
|
|
10338
|
+
};
|
|
10339
|
+
}
|
|
10340
|
+
/** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
|
|
10341
|
+
function serializeAllowedFrom(allowedFrom) {
|
|
10342
|
+
return allowedFrom === null ? null : JSON.stringify(allowedFrom);
|
|
10343
|
+
}
|
|
10344
|
+
/**
|
|
10345
|
+
* The flow rules a card entering `column` does not meet, where it may come from
|
|
10346
|
+
* first. Pure: what the board holds arrives in `facts`. A card being created
|
|
10347
|
+
* comes from no column, so a column that names where cards come from does not
|
|
10348
|
+
* take it.
|
|
10349
|
+
*/
|
|
10350
|
+
function evaluateFlowRules(card, column, rules, facts) {
|
|
10351
|
+
const unmet = [];
|
|
10352
|
+
if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
|
|
10353
|
+
rule: "allowed_from",
|
|
10354
|
+
reason: "not_allowed_from",
|
|
10355
|
+
detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
|
|
10356
|
+
});
|
|
10357
|
+
if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
|
|
10358
|
+
rule: "wip_limit",
|
|
10359
|
+
reason: "column_full",
|
|
10360
|
+
detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
|
|
10361
|
+
});
|
|
10362
|
+
return unmet;
|
|
10363
|
+
}
|
|
10364
|
+
function describeAllowedFrom(name, allowedFrom, columnNames) {
|
|
10365
|
+
const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
|
|
10366
|
+
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`;
|
|
10367
|
+
}
|
|
10368
|
+
/** `A`, `A and B`, `A, B and C`. */
|
|
10369
|
+
function joinWords(words) {
|
|
10370
|
+
return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
|
|
10371
|
+
}
|
|
10372
|
+
/**
|
|
10373
|
+
* The standard flow on these columns: Done, and the columns it takes cards from
|
|
10374
|
+
* — In Review, and QA when there is one — in board order. `null` when the board
|
|
10375
|
+
* has no Done, or neither of the two.
|
|
10376
|
+
*/
|
|
10377
|
+
function standardFlowOf(columns) {
|
|
10378
|
+
const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
|
|
10379
|
+
const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
|
|
10380
|
+
return target && sources.length > 0 ? {
|
|
10381
|
+
target,
|
|
10382
|
+
sources
|
|
10383
|
+
} : null;
|
|
10384
|
+
}
|
|
10385
|
+
//#endregion
|
|
10386
|
+
//#region src/ops/card-timeline.ts
|
|
10387
|
+
/**
|
|
10388
|
+
* A card's columns over time, oldest first, from its creation on: the column
|
|
10389
|
+
* it was created in, then one stretch per move. A card created before 0.5.0
|
|
10390
|
+
* carries no entry for its creation; one created since carries `null → column`
|
|
10391
|
+
* at its creation, which leaves a stretch of no length in no column.
|
|
10392
|
+
*
|
|
10393
|
+
* The feed lists moves by time and then by change id, and a change id is
|
|
10394
|
+
* random, so two moves of one card in the same second can come back in either
|
|
10395
|
+
* order. Within one second the moves are put back in the order they chain —
|
|
10396
|
+
* each leaves the column the one before it entered — which is the order they
|
|
10397
|
+
* were made in (ruling R012-5). Moves that do not chain keep the feed's order.
|
|
10398
|
+
*/
|
|
10399
|
+
function buildCardTimeline(card, entries) {
|
|
10400
|
+
const ordered = chainSameSecond(entries.toSorted((left, right) => left.at - right.at || (left.changeId < right.changeId ? -1 : left.changeId > right.changeId ? 1 : 0)));
|
|
10401
|
+
const stretches = [];
|
|
10402
|
+
const first = ordered[0];
|
|
10403
|
+
let current = {
|
|
10404
|
+
statusId: first ? first.fromStatusId : card.statusId,
|
|
10405
|
+
from: card.createdAt,
|
|
10406
|
+
to: null
|
|
10407
|
+
};
|
|
10408
|
+
for (const entry of ordered) {
|
|
10409
|
+
stretches.push({
|
|
10410
|
+
...current,
|
|
10411
|
+
to: entry.at
|
|
10412
|
+
});
|
|
10413
|
+
current = {
|
|
10414
|
+
statusId: entry.toStatusId,
|
|
10415
|
+
from: entry.at,
|
|
10416
|
+
to: null
|
|
10417
|
+
};
|
|
10418
|
+
}
|
|
10419
|
+
stretches.push(current);
|
|
10420
|
+
return stretches;
|
|
10421
|
+
}
|
|
10422
|
+
/** The column the card was in at `at` — a move at `at` has already happened — or `undefined` before the card existed. */
|
|
10423
|
+
function statusAt(stretches, at) {
|
|
10424
|
+
return stretches.find((stretch) => stretch.from <= at && (stretch.to === null || at < stretch.to))?.statusId;
|
|
10425
|
+
}
|
|
10426
|
+
function chainSameSecond(entries) {
|
|
10427
|
+
const ordered = [];
|
|
10428
|
+
let start = 0;
|
|
10429
|
+
while (start < entries.length) {
|
|
10430
|
+
let end = start;
|
|
10431
|
+
while (end < entries.length && entries[end].at === entries[start].at) end++;
|
|
10432
|
+
const group = entries.slice(start, end);
|
|
10433
|
+
const previous = ordered.at(-1);
|
|
10434
|
+
let current = previous ? previous.toStatusId : headOf(group)?.fromStatusId;
|
|
10435
|
+
while (group.length > 0) {
|
|
10436
|
+
let next = group.findIndex((entry) => entry.fromStatusId === current);
|
|
10437
|
+
if (next < 0) next = Math.max(group.findIndex((entry) => entry === headOf(group)), 0);
|
|
10438
|
+
const [picked] = group.splice(next, 1);
|
|
10439
|
+
ordered.push(picked);
|
|
10440
|
+
current = picked.toStatusId;
|
|
10441
|
+
}
|
|
10442
|
+
start = end;
|
|
10443
|
+
}
|
|
10444
|
+
return ordered;
|
|
10445
|
+
}
|
|
10446
|
+
/** The move nothing else in the group feeds into. */
|
|
10447
|
+
function headOf(group) {
|
|
10448
|
+
return group.find((entry) => !group.some((other) => other !== entry && other.toStatusId === entry.fromStatusId));
|
|
10449
|
+
}
|
|
10450
|
+
//#endregion
|
|
10451
|
+
//#region src/ops/board-metrics.ts
|
|
10452
|
+
const DAY_SECONDS = 86400;
|
|
10453
|
+
const WEEK_SECONDS$1 = 7 * DAY_SECONDS;
|
|
10454
|
+
/**
|
|
10455
|
+
* The value at `percent` of `values` by nearest rank (ruling R012-2): the
|
|
10456
|
+
* ⌈percent·n/100⌉-th in ascending order. Always a value that occurred; `null`
|
|
10457
|
+
* for none.
|
|
10458
|
+
*/
|
|
10459
|
+
function nearestRank(values, percent) {
|
|
10460
|
+
if (values.length === 0) return null;
|
|
10461
|
+
const sorted = values.toSorted((left, right) => left - right);
|
|
10462
|
+
return sorted[Math.max(Math.ceil(percent * sorted.length / 100), 1) - 1];
|
|
10463
|
+
}
|
|
10464
|
+
/** Who a run counts under in the agent rows: the host's agent id when the run names one, else the name it was started under. */
|
|
10465
|
+
function boardMetricsAgentKey(run) {
|
|
10466
|
+
return run.agentId ?? run.agentName;
|
|
10467
|
+
}
|
|
10468
|
+
function scopeOf(history, input) {
|
|
10469
|
+
const workspaceIds = new Set(input.workspaceIds);
|
|
10470
|
+
const milestoneId = input.milestoneId ?? null;
|
|
10471
|
+
const statusById = new Map(history.statuses.filter((status) => workspaceIds.has(status.workspaceId)).map((status) => [status.id, status]));
|
|
10472
|
+
const cards = history.cards.filter((card) => workspaceIds.has(card.workspaceId)).filter((card) => milestoneId === null || card.milestoneId === milestoneId);
|
|
10473
|
+
const isCanceled = (card) => card.statusId !== null && statusById.get(card.statusId)?.category === "canceled";
|
|
10474
|
+
return {
|
|
10475
|
+
statusById,
|
|
10476
|
+
counted: cards.filter((card) => !isCanceled(card)),
|
|
10477
|
+
canceled: cards.filter(isCanceled)
|
|
10478
|
+
};
|
|
10479
|
+
}
|
|
10480
|
+
/** Who made a return, as `returns.byActorKind` counts it: a person, an agent (a provider target stands in for one), or a writer from outside. */
|
|
10481
|
+
function returnerOf(entry) {
|
|
10482
|
+
if (entry.actorKind === "user") return "user";
|
|
10483
|
+
return entry.actorKind === "system" ? "external" : "agent";
|
|
10484
|
+
}
|
|
10485
|
+
/**
|
|
10486
|
+
* The metrics of `input` over rows already read. Pure: the same rows give the
|
|
10487
|
+
* same answer in any order, so a host may put several boards' rows together
|
|
10488
|
+
* and compute once — percentiles of parts do not add up to the whole's.
|
|
10489
|
+
*/
|
|
10490
|
+
function computeBoardMetrics(history, input, now) {
|
|
10491
|
+
if (input.from > input.to) throw new BoardError("board_metrics_range_invalid", {
|
|
10492
|
+
from: input.from,
|
|
10493
|
+
to: input.to
|
|
10494
|
+
});
|
|
10495
|
+
const milestoneId = input.milestoneId ?? null;
|
|
10496
|
+
if (milestoneId !== null && !history.milestones.some((milestone) => milestone.id === milestoneId && input.workspaceIds.includes(milestone.workspaceId))) throw new BoardError("issue_milestone_not_found", { milestoneId });
|
|
10497
|
+
const from = input.from;
|
|
10498
|
+
const to = Math.min(input.to, now);
|
|
10499
|
+
const inRange = (at) => from <= at && at <= to;
|
|
10500
|
+
const { statusById, counted, canceled } = scopeOf(history, input);
|
|
10501
|
+
const columns = groupColumns(input.workspaceIds, [...statusById.values()]);
|
|
10502
|
+
const slugOf = (statusId) => {
|
|
10503
|
+
const status = statusId === null ? void 0 : statusById.get(statusId);
|
|
10504
|
+
return status ? normalizeStatusName(status.name) : null;
|
|
10505
|
+
};
|
|
10506
|
+
const categoryOf = (statusId) => statusId === null ? null : statusById.get(statusId)?.category ?? null;
|
|
10507
|
+
const isReturn = (entry) => {
|
|
10508
|
+
const source = entry.fromStatusId === null ? void 0 : statusById.get(entry.fromStatusId);
|
|
10509
|
+
const target = statusById.get(entry.toStatusId);
|
|
10510
|
+
return source !== void 0 && target !== void 0 && source.workspaceId === target.workspaceId && target.order < source.order && (target.category === "unstarted" || target.category === "started");
|
|
10511
|
+
};
|
|
10512
|
+
const entriesByCard = groupBy(history.entries, (entry) => entry.issueId);
|
|
10513
|
+
const timelineOf = (card) => buildCardTimeline(card, entriesByCard.get(card.id) ?? []);
|
|
10514
|
+
const countedIds = new Set(counted.map((card) => card.id));
|
|
10515
|
+
const facts = counted.map((card) => {
|
|
10516
|
+
const stretches = timelineOf(card);
|
|
10517
|
+
return {
|
|
10518
|
+
card,
|
|
10519
|
+
stretches,
|
|
10520
|
+
startedAt: stretches.find((stretch) => categoryOf(stretch.statusId) === "started")?.from ?? null
|
|
10521
|
+
};
|
|
10522
|
+
});
|
|
10523
|
+
const finished = facts.filter((fact) => categoryOf(fact.card.statusId) === "completed").map((fact) => ({
|
|
10524
|
+
...fact,
|
|
10525
|
+
finishedAt: fact.stretches.at(-1).from
|
|
10526
|
+
})).filter((fact) => inRange(fact.finishedAt)).toSorted((left, right) => left.finishedAt - right.finishedAt || left.card.id.localeCompare(right.card.id));
|
|
10527
|
+
const runsByCard = groupBy(history.runs.filter((run) => countedIds.has(run.issueId)), (run) => run.issueId);
|
|
10528
|
+
const returnsByCard = groupBy(history.entries.filter((entry) => countedIds.has(entry.issueId) && isReturn(entry)), (entry) => entry.issueId);
|
|
10529
|
+
const finishedCards = finished.map((fact) => ({
|
|
10530
|
+
...cardOf(fact.card),
|
|
10531
|
+
startedAt: fact.startedAt,
|
|
10532
|
+
finishedAt: fact.finishedAt,
|
|
10533
|
+
cycleSeconds: fact.startedAt === null ? null : fact.finishedAt - fact.startedAt,
|
|
10534
|
+
leadSeconds: fact.finishedAt - fact.card.createdAt,
|
|
10535
|
+
returns: returnsByCard.get(fact.card.id)?.length ?? 0,
|
|
10536
|
+
runs: runsByCard.get(fact.card.id)?.length ?? 0
|
|
10537
|
+
}));
|
|
10538
|
+
const cycleTime = durations(finishedCards.flatMap((card) => card.cycleSeconds === null ? [] : [card.cycleSeconds]));
|
|
10539
|
+
const leadTime = durations(finishedCards.map((card) => card.leadSeconds));
|
|
10540
|
+
const throughput = [];
|
|
10541
|
+
for (let start = isoWeekOf(from).start; from <= to && start <= to; start += WEEK_SECONDS$1) throughput.push({
|
|
10542
|
+
week: isoWeekOf(start).week,
|
|
10543
|
+
start,
|
|
10544
|
+
finished: finishedCards.filter((card) => card.finishedAt >= start && card.finishedAt < start + WEEK_SECONDS$1).length
|
|
10545
|
+
});
|
|
10546
|
+
const inProgress = facts.filter((fact) => categoryOf(fact.card.statusId) === "started");
|
|
10547
|
+
const wip = columns.filter((column) => column.category === "started").map((column) => {
|
|
10548
|
+
const limits = [...statusById.values()].filter((status) => normalizeStatusName(status.name) === column.slug).map((status) => readFlowRules(status).wipLimit);
|
|
10549
|
+
return {
|
|
10550
|
+
slug: column.slug,
|
|
10551
|
+
cards: inProgress.filter((fact) => slugOf(fact.card.statusId) === column.slug).length,
|
|
10552
|
+
limit: limits.every((limit) => limit !== null) ? limits.reduce((sum, limit) => sum + limit, 0) : null
|
|
10553
|
+
};
|
|
10554
|
+
});
|
|
10555
|
+
const aging = inProgress.map((fact) => {
|
|
10556
|
+
const current = fact.stretches.at(-1);
|
|
10557
|
+
const startedAt = fact.startedAt ?? current.from;
|
|
10558
|
+
return {
|
|
10559
|
+
...cardOf(fact.card),
|
|
10560
|
+
slug: slugOf(fact.card.statusId),
|
|
10561
|
+
startedAt,
|
|
10562
|
+
ageSeconds: now - startedAt,
|
|
10563
|
+
inColumnSeconds: now - current.from,
|
|
10564
|
+
overCycleP85: cycleTime.p85 !== null && now - startedAt > cycleTime.p85
|
|
10565
|
+
};
|
|
10566
|
+
}).toSorted((left, right) => right.ageSeconds - left.ageSeconds || left.issueId.localeCompare(right.issueId));
|
|
10567
|
+
const columnSeconds = new Map(columns.map((column) => [column.slug, 0]));
|
|
10568
|
+
for (const fact of finished) for (const stretch of fact.stretches) {
|
|
10569
|
+
const slug = slugOf(stretch.statusId);
|
|
10570
|
+
const end = Math.min(stretch.to ?? fact.finishedAt, fact.finishedAt);
|
|
10571
|
+
if (slug !== null && end > stretch.from) columnSeconds.set(slug, (columnSeconds.get(slug) ?? 0) + end - stretch.from);
|
|
10572
|
+
}
|
|
10573
|
+
const cumulativeFlow = [];
|
|
10574
|
+
for (let dayStart = utcDayStart(from); from <= to && dayStart <= to; dayStart += DAY_SECONDS) {
|
|
10575
|
+
const at = Math.min(dayStart + DAY_SECONDS - 1, to);
|
|
10576
|
+
const cards = Object.fromEntries(columns.map((column) => [column.slug, 0]));
|
|
10577
|
+
for (const fact of facts) {
|
|
10578
|
+
const statusId = statusAt(fact.stretches, at);
|
|
10579
|
+
const slug = statusId === void 0 ? null : slugOf(statusId);
|
|
10580
|
+
if (slug !== null) cards[slug] = (cards[slug] ?? 0) + 1;
|
|
10581
|
+
}
|
|
10582
|
+
cumulativeFlow.push({
|
|
10583
|
+
day: utcDayLabel(dayStart),
|
|
10584
|
+
at,
|
|
10585
|
+
cards
|
|
10586
|
+
});
|
|
10587
|
+
}
|
|
10588
|
+
const finishedReturns = finished.flatMap((fact) => returnsByCard.get(fact.card.id) ?? []);
|
|
10589
|
+
const byActorKind = {
|
|
10590
|
+
user: 0,
|
|
10591
|
+
agent: 0,
|
|
10592
|
+
external: 0
|
|
10593
|
+
};
|
|
10594
|
+
for (const entry of finishedReturns) byActorKind[returnerOf(entry)]++;
|
|
10595
|
+
const runTotal = finishedCards.reduce((sum, card) => sum + card.runs, 0);
|
|
10596
|
+
const share = (part, whole) => whole === 0 ? null : part / whole;
|
|
10597
|
+
return {
|
|
10598
|
+
range: {
|
|
10599
|
+
from,
|
|
10600
|
+
to
|
|
10601
|
+
},
|
|
10602
|
+
now,
|
|
10603
|
+
milestoneId,
|
|
10604
|
+
columns,
|
|
10605
|
+
cycleTime,
|
|
10606
|
+
leadTime,
|
|
10607
|
+
throughput,
|
|
10608
|
+
wip,
|
|
10609
|
+
aging,
|
|
10610
|
+
timeInColumn: columns.map((column) => ({
|
|
10611
|
+
slug: column.slug,
|
|
10612
|
+
seconds: columnSeconds.get(column.slug) ?? 0
|
|
10613
|
+
})),
|
|
10614
|
+
cumulativeFlow,
|
|
10615
|
+
finished: finishedCards,
|
|
10616
|
+
returns: {
|
|
10617
|
+
total: finishedReturns.length,
|
|
10618
|
+
perFinishedCard: share(finishedReturns.length, finishedCards.length),
|
|
10619
|
+
firstPassRate: share(finishedCards.filter((card) => card.returns === 0).length, finishedCards.length),
|
|
10620
|
+
byActorKind
|
|
10621
|
+
},
|
|
10622
|
+
attempts: {
|
|
10623
|
+
runs: runTotal,
|
|
10624
|
+
perFinishedCard: share(runTotal, finishedCards.length)
|
|
10625
|
+
},
|
|
10626
|
+
canceled: canceled.filter((card) => inRange(timelineOf(card).at(-1).from)).length,
|
|
10627
|
+
agents: agentRows(history.runs.filter((run) => countedIds.has(run.issueId)), finishedCards, inRange),
|
|
10628
|
+
people: personRows(counted, history, countedIds, inRange, isReturn)
|
|
10629
|
+
};
|
|
10630
|
+
}
|
|
10631
|
+
function agentRows(runs, finished, inRange) {
|
|
10632
|
+
const finishedIds = new Set(finished.map((card) => card.issueId));
|
|
10633
|
+
const returned = new Set(finished.filter((card) => card.returns > 0).map((card) => card.issueId));
|
|
10634
|
+
const rows = [];
|
|
10635
|
+
for (const [key, own] of groupBy(runs, boardMetricsAgentKey)) {
|
|
10636
|
+
const ranged = own.filter((run) => inRange(run.startedAt));
|
|
10637
|
+
const worked = new Set(own.map((run) => run.issueId).filter((issueId) => finishedIds.has(issueId)));
|
|
10638
|
+
if (ranged.length === 0 && worked.size === 0) continue;
|
|
10639
|
+
const newest = own.toSorted((left, right) => right.startedAt - left.startedAt || right.id.localeCompare(left.id))[0];
|
|
10640
|
+
const ended = ranged.flatMap((run) => run.endedAt === null ? [] : [run.endedAt - run.startedAt]);
|
|
10641
|
+
rows.push({
|
|
10642
|
+
key,
|
|
10643
|
+
agentId: newest.agentId,
|
|
10644
|
+
agentName: newest.agentName,
|
|
10645
|
+
runs: {
|
|
10646
|
+
total: ranged.length,
|
|
10647
|
+
finished: ranged.filter((run) => run.state === "finished").length,
|
|
10648
|
+
failed: ranged.filter((run) => run.state === "failed").length,
|
|
10649
|
+
stopped: ranged.filter((run) => run.state === "stopped").length,
|
|
10650
|
+
running: ranged.filter((run) => run.state === "running").length
|
|
10651
|
+
},
|
|
10652
|
+
medianRunSeconds: nearestRank(ended, 50),
|
|
10653
|
+
cardsFinished: worked.size,
|
|
10654
|
+
returnRate: worked.size === 0 ? null : [...worked].filter((issueId) => returned.has(issueId)).length / worked.size
|
|
10655
|
+
});
|
|
10656
|
+
}
|
|
10657
|
+
return rows.toSorted((left, right) => right.runs.total - left.runs.total || left.key.localeCompare(right.key));
|
|
10658
|
+
}
|
|
10659
|
+
function personRows(counted, history, countedIds, inRange, isReturn) {
|
|
10660
|
+
const rows = /* @__PURE__ */ new Map();
|
|
10661
|
+
const row = (id) => {
|
|
10662
|
+
let found = rows.get(id);
|
|
10663
|
+
if (!found) {
|
|
10664
|
+
found = {
|
|
10665
|
+
id,
|
|
10666
|
+
created: 0,
|
|
10667
|
+
approvals: 0,
|
|
10668
|
+
returns: 0,
|
|
10669
|
+
moves: 0
|
|
10670
|
+
};
|
|
10671
|
+
rows.set(id, found);
|
|
10672
|
+
}
|
|
10673
|
+
return found;
|
|
10674
|
+
};
|
|
10675
|
+
for (const card of counted) if (card.createdByKind === "user" && inRange(card.createdAt)) row(card.createdById).created++;
|
|
10676
|
+
for (const comment of history.approvals) if (comment.authorKind === "system.approved" && countedIds.has(comment.issueId) && inRange(comment.createdAt)) row(comment.authorId).approvals++;
|
|
10677
|
+
for (const entry of history.entries) if (entry.actorKind === "user" && entry.fromStatusId !== null && countedIds.has(entry.issueId) && inRange(entry.at)) {
|
|
10678
|
+
const person = row(entry.actorId);
|
|
10679
|
+
person.moves++;
|
|
10680
|
+
if (isReturn(entry)) person.returns++;
|
|
10681
|
+
}
|
|
10682
|
+
const activity = (person) => person.created + person.approvals + person.returns + person.moves;
|
|
10683
|
+
return [...rows.values()].toSorted((left, right) => activity(right) - activity(left) || (left.id ?? "").localeCompare(right.id ?? ""));
|
|
10684
|
+
}
|
|
10685
|
+
/**
|
|
10686
|
+
* The workspaces' columns as one board shows them, one per slug (ruling
|
|
10687
|
+
* R012-8): the first workspace's columns set the order, and a column only a
|
|
10688
|
+
* later one has goes right after the column before it there. Name and category
|
|
10689
|
+
* are the first workspace's that has it.
|
|
10690
|
+
*/
|
|
10691
|
+
function groupColumns(workspaceIds, statuses) {
|
|
10692
|
+
const columns = [];
|
|
10693
|
+
for (const workspaceId of new Set(workspaceIds)) {
|
|
10694
|
+
let previous = -1;
|
|
10695
|
+
const own = statuses.filter((status) => status.workspaceId === workspaceId).toSorted((left, right) => left.order - right.order || left.id.localeCompare(right.id));
|
|
10696
|
+
for (const status of own) {
|
|
10697
|
+
const slug = normalizeStatusName(status.name);
|
|
10698
|
+
let index = columns.findIndex((column) => column.slug === slug);
|
|
10699
|
+
if (index === -1) {
|
|
10700
|
+
index = previous + 1;
|
|
10701
|
+
columns.splice(index, 0, {
|
|
10702
|
+
slug,
|
|
10703
|
+
name: status.name,
|
|
10704
|
+
category: status.category
|
|
10705
|
+
});
|
|
10706
|
+
}
|
|
10707
|
+
previous = index;
|
|
10708
|
+
}
|
|
10709
|
+
}
|
|
10710
|
+
return columns;
|
|
10711
|
+
}
|
|
10712
|
+
function durations(values) {
|
|
10713
|
+
return {
|
|
10714
|
+
cards: values.length,
|
|
10715
|
+
p50: nearestRank(values, 50),
|
|
10716
|
+
p85: nearestRank(values, 85)
|
|
10717
|
+
};
|
|
10718
|
+
}
|
|
10719
|
+
function cardOf(card) {
|
|
10720
|
+
return {
|
|
10721
|
+
issueId: card.id,
|
|
10722
|
+
workspaceId: card.workspaceId,
|
|
10723
|
+
title: cardDisplayTitle(card)
|
|
10724
|
+
};
|
|
10725
|
+
}
|
|
10726
|
+
function groupBy(items, keyOf) {
|
|
10727
|
+
const groups = /* @__PURE__ */ new Map();
|
|
10728
|
+
for (const item of items) {
|
|
10729
|
+
const key = keyOf(item);
|
|
10730
|
+
const group = groups.get(key);
|
|
10731
|
+
if (group) group.push(item);
|
|
10732
|
+
else groups.set(key, [item]);
|
|
10733
|
+
}
|
|
10734
|
+
return groups;
|
|
10735
|
+
}
|
|
10736
|
+
//#endregion
|
|
10737
|
+
//#region src/ops/column-entries.ts
|
|
10738
|
+
/**
|
|
10739
|
+
* Every entry into a column on the named boards at or after `since`, in order
|
|
10740
|
+
* of `(at, changeId)` — the one signal an app dispatching work on column
|
|
10741
|
+
* changes needs. It sees every writer, an outside `kanbo` on the same database
|
|
10742
|
+
* included, because it reads the cards' own history.
|
|
10743
|
+
*
|
|
10744
|
+
* A page never ends inside a second: when `limit` cuts into one, the rest of
|
|
10745
|
+
* that second comes too, so a page may be longer than `limit`. A reader keeps
|
|
10746
|
+
* its cursor at the last `at` it read after a short page — a write in that
|
|
10747
|
+
* second may still commit — and drops the `changeId`s it has seen; after a page
|
|
10748
|
+
* of `limit` or more it moves on to the last `at` + 1, which a second holding
|
|
10749
|
+
* more entries than `limit` would otherwise never let it pass.
|
|
10750
|
+
*/
|
|
10751
|
+
async function listColumnEntries(store, input) {
|
|
10752
|
+
const page = await store.fieldChanges.listByFieldSince("statusId", input);
|
|
10753
|
+
return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
|
|
10754
|
+
}
|
|
10755
|
+
/** The page with every change of its last second, not only those `limit` let in. */
|
|
10756
|
+
async function withWholeLastSecond(store, input, page) {
|
|
10757
|
+
const last = page.at(-1).createdAt;
|
|
10758
|
+
const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
|
|
10759
|
+
workspaceIds: input.workspaceIds,
|
|
10760
|
+
since: last,
|
|
10761
|
+
until: last
|
|
10762
|
+
});
|
|
10763
|
+
return [...page.filter((change) => change.createdAt < last), ...lastSecond];
|
|
10764
|
+
}
|
|
10765
|
+
/** A status change as an entry; a change that took the card out of every column enters none. */
|
|
10766
|
+
function toColumnEntry(change) {
|
|
10767
|
+
return change.toValue === null ? [] : [{
|
|
10768
|
+
changeId: change.id,
|
|
10769
|
+
issueId: change.issueId,
|
|
10770
|
+
workspaceId: change.workspaceId,
|
|
10771
|
+
fromStatusId: change.fromValue,
|
|
10772
|
+
toStatusId: change.toValue,
|
|
10773
|
+
actorKind: change.actorKind,
|
|
10774
|
+
actorId: change.actorId,
|
|
10775
|
+
at: change.createdAt
|
|
10776
|
+
}];
|
|
10777
|
+
}
|
|
10778
|
+
//#endregion
|
|
10779
|
+
//#region src/ops/board-metrics-read.ts
|
|
10780
|
+
/** The comment kind the metrics read: a person's approvals. Returns are moves (R012-7). */
|
|
10781
|
+
const APPROVAL_KINDS = ["system.approved"];
|
|
10782
|
+
/** `listColumnEntries` from the board's first column change on (drift check, item 3). */
|
|
10783
|
+
const FROM_THE_FIRST_CHANGE = 0;
|
|
10784
|
+
/**
|
|
10785
|
+
* Every row the metrics of these workspaces are computed from: three
|
|
10786
|
+
* statements per workspace (columns, milestones, cards) plus three across all
|
|
10787
|
+
* of them (column changes through the `(field, created_at)` index, runs,
|
|
10788
|
+
* approvals); none per card.
|
|
10789
|
+
*
|
|
10790
|
+
* The rows are read and the metrics computed in TypeScript rather than in SQL
|
|
10791
|
+
* (ruling R012-1): a board is thousands of rows, not millions; percentiles, ISO
|
|
10792
|
+
* weeks and a day-by-day replay would otherwise be written once per dialect;
|
|
10793
|
+
* and a host whose workspaces live on several boards can put their rows
|
|
10794
|
+
* together and compute once.
|
|
10795
|
+
*/
|
|
10796
|
+
async function readBoardMetricsHistory(store, workspaceIds) {
|
|
10797
|
+
const ids = [...new Set(workspaceIds)];
|
|
10798
|
+
if (ids.length === 0) return {
|
|
10799
|
+
statuses: [],
|
|
10800
|
+
milestones: [],
|
|
10801
|
+
cards: [],
|
|
10802
|
+
entries: [],
|
|
10803
|
+
runs: [],
|
|
10804
|
+
approvals: []
|
|
10805
|
+
};
|
|
10806
|
+
const perWorkspace = async (read) => (await Promise.all(ids.map(read))).flat();
|
|
10807
|
+
const [statuses, milestones, cards, entries, runs, approvals] = await Promise.all([
|
|
10808
|
+
perWorkspace(async (id) => await store.statuses.listByWorkspace(id)),
|
|
10809
|
+
perWorkspace(async (id) => await store.milestones.listByWorkspace(id)),
|
|
10810
|
+
perWorkspace(async (id) => await store.issues.listByWorkspace(id)),
|
|
10811
|
+
listColumnEntries(store, {
|
|
10812
|
+
workspaceIds: ids,
|
|
10813
|
+
since: FROM_THE_FIRST_CHANGE
|
|
10814
|
+
}),
|
|
10815
|
+
store.runs.listByWorkspaces(ids),
|
|
10816
|
+
store.comments.listByWorkspaces(ids, APPROVAL_KINDS)
|
|
10817
|
+
]);
|
|
10818
|
+
return {
|
|
10819
|
+
statuses,
|
|
10820
|
+
milestones,
|
|
10821
|
+
cards,
|
|
10822
|
+
entries,
|
|
10823
|
+
runs,
|
|
10824
|
+
approvals
|
|
10825
|
+
};
|
|
10826
|
+
}
|
|
10827
|
+
/** How work flows on the board over `input`'s range (`computeBoardMetrics`), read from the store; without cost. */
|
|
10828
|
+
async function boardMetrics(store, input, now = currentUnixSeconds()) {
|
|
10829
|
+
return computeBoardMetrics(await readBoardMetricsHistory(store, input.workspaceIds), input, now);
|
|
10830
|
+
}
|
|
10831
|
+
//#endregion
|
|
10255
10832
|
//#region src/domain/field-changes.ts
|
|
10256
10833
|
/**
|
|
10257
10834
|
* The issue columns whose edits are written to the audit trail.
|
|
@@ -10747,6 +11324,7 @@ async function applyColumnTemplate(store, workspaceId, columns, options, scope)
|
|
|
10747
11324
|
workspaceId,
|
|
10748
11325
|
...column
|
|
10749
11326
|
}, order);
|
|
11327
|
+
await writeStandardFlow(tx, await listColumns(tx, workspaceId));
|
|
10750
11328
|
return {
|
|
10751
11329
|
statuses: await listColumns(tx, workspaceId),
|
|
10752
11330
|
added: columns.map((column) => column.name)
|
|
@@ -10856,6 +11434,77 @@ async function setColumnEntryRules(store, workspaceId, nameOrId, entryRules, act
|
|
|
10856
11434
|
return await updateColumn(tx, (await requireColumn(tx, workspaceId, nameOrId)).id, { entryRules }, actor, { tx });
|
|
10857
11435
|
}, scope);
|
|
10858
11436
|
}
|
|
11437
|
+
/** The largest WIP limit: what a Postgres `integer` column holds. */
|
|
11438
|
+
const MAX_WIP_LIMIT = 2147483647;
|
|
11439
|
+
/**
|
|
11440
|
+
* Set where a card may enter the column from and how many cards it holds:
|
|
11441
|
+
* `allowedFrom` the ids of other columns of its board (`null` for any, `[]` for
|
|
11442
|
+
* none), kept once each in board order; `wipLimit` a whole number from 1 to 2147483647, or `null`. A
|
|
11443
|
+
* person's to set, like the entry rules: an agent that could open a column
|
|
11444
|
+
* could walk any card into it.
|
|
11445
|
+
*/
|
|
11446
|
+
async function setColumnFlowRules(store, columnId, rules, actor, scope) {
|
|
11447
|
+
assertPersonSetsEntryRules(actor, { statusId: columnId });
|
|
11448
|
+
if (rules.wipLimit !== null && !(Number.isSafeInteger(rules.wipLimit) && rules.wipLimit >= 1 && rules.wipLimit <= MAX_WIP_LIMIT)) throw new BoardError("board_flow_rule_invalid", {
|
|
11449
|
+
statusId: columnId,
|
|
11450
|
+
wipLimit: rules.wipLimit
|
|
11451
|
+
});
|
|
11452
|
+
return await runBoardWrite(store, async ({ tx }) => {
|
|
11453
|
+
const column = await tx.statuses.findById(columnId);
|
|
11454
|
+
if (!column) throw new BoardError("issue_status_not_found", { statusId: columnId });
|
|
11455
|
+
const allowedFrom = rules.allowedFrom === null ? null : allowedFromInBoardOrder(await listColumns(tx, column.workspaceId), column, rules.allowedFrom);
|
|
11456
|
+
await tx.statuses.update(columnId, {
|
|
11457
|
+
allowedFrom: serializeAllowedFrom(allowedFrom),
|
|
11458
|
+
wipLimit: rules.wipLimit
|
|
11459
|
+
});
|
|
11460
|
+
return await tx.statuses.findById(columnId);
|
|
11461
|
+
}, scope);
|
|
11462
|
+
}
|
|
11463
|
+
/** The ids asked for, once each and in board order — or the refusal naming those that are not another column of this board. */
|
|
11464
|
+
function allowedFromInBoardOrder(columns, column, requested) {
|
|
11465
|
+
const others = columns.filter((other) => other.id !== column.id);
|
|
11466
|
+
const invalid = requested.filter((id) => !others.some((other) => other.id === id));
|
|
11467
|
+
if (invalid.length > 0) throw new BoardError("board_flow_rule_invalid", {
|
|
11468
|
+
statusId: column.id,
|
|
11469
|
+
allowedFrom: invalid
|
|
11470
|
+
});
|
|
11471
|
+
return others.filter((other) => requested.includes(other.id)).map((other) => other.id);
|
|
11472
|
+
}
|
|
11473
|
+
/**
|
|
11474
|
+
* Put the standard flow on a board: Done accepts cards from In Review only —
|
|
11475
|
+
* and from QA, when the board has it — so nothing reaches Done but through
|
|
11476
|
+
* review. Done's WIP limit and every other column stay as they are. A board
|
|
11477
|
+
* without Done, or with neither In Review nor QA, has nothing to apply it to.
|
|
11478
|
+
*/
|
|
11479
|
+
async function applyStandardFlow(store, workspaceId, actor, scope) {
|
|
11480
|
+
assertPersonSetsEntryRules(actor, { workspaceId });
|
|
11481
|
+
return await runBoardWrite(store, async ({ tx }) => {
|
|
11482
|
+
const columns = await listColumns(tx, workspaceId);
|
|
11483
|
+
if (!await writeStandardFlow(tx, columns)) throw new BoardError("issue_status_not_found", {
|
|
11484
|
+
workspaceId,
|
|
11485
|
+
statusName: columns.some((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET) ? "In Review" : "Done"
|
|
11486
|
+
});
|
|
11487
|
+
return await listColumns(tx, workspaceId);
|
|
11488
|
+
}, scope);
|
|
11489
|
+
}
|
|
11490
|
+
/** Done takes cards from the board's In Review and QA; `false`, with nothing written, when the board has no such columns. */
|
|
11491
|
+
async function writeStandardFlow(store, columns) {
|
|
11492
|
+
const flow = standardFlowOf(columns);
|
|
11493
|
+
if (!flow) return false;
|
|
11494
|
+
await store.statuses.update(flow.target.id, { allowedFrom: serializeAllowedFrom(flow.sources.map((column) => column.id)) });
|
|
11495
|
+
return true;
|
|
11496
|
+
}
|
|
11497
|
+
/**
|
|
11498
|
+
* Take a deleted column out of every list of where cards may come from. A list
|
|
11499
|
+
* it empties stays empty: a column that took cards only from it does not open
|
|
11500
|
+
* to every column.
|
|
11501
|
+
*/
|
|
11502
|
+
async function dropFromAllowedFrom(store, workspaceId, statusId) {
|
|
11503
|
+
for (const other of await listColumns(store, workspaceId)) {
|
|
11504
|
+
const { allowedFrom } = readFlowRules(other);
|
|
11505
|
+
if (allowedFrom?.includes(statusId)) await store.statuses.update(other.id, { allowedFrom: serializeAllowedFrom(allowedFrom.filter((id) => id !== statusId)) });
|
|
11506
|
+
}
|
|
11507
|
+
}
|
|
10859
11508
|
/**
|
|
10860
11509
|
* Take an empty column off the board, and number the columns left 0..n-1 in
|
|
10861
11510
|
* the same write.
|
|
@@ -10882,6 +11531,7 @@ async function deleteColumn(store, statusId, scope, actor) {
|
|
|
10882
11531
|
hint: "Move its cards to another column first, or remove it with removeColumn and moveCardsTo."
|
|
10883
11532
|
});
|
|
10884
11533
|
await tx.statuses.delete(statusId);
|
|
11534
|
+
await dropFromAllowedFrom(tx, column.workspaceId, statusId);
|
|
10885
11535
|
await renumberColumns(tx, column.workspaceId);
|
|
10886
11536
|
}, scope);
|
|
10887
11537
|
}
|
|
@@ -11161,24 +11811,46 @@ function pullRequestFactKeys({ owner, repo, number }) {
|
|
|
11161
11811
|
//#endregion
|
|
11162
11812
|
//#region src/ops/entry-rules.ts
|
|
11163
11813
|
/**
|
|
11164
|
-
* The column's entry rules the card does not meet
|
|
11165
|
-
*
|
|
11166
|
-
*
|
|
11814
|
+
* The column's entry rules and flow rules the card does not meet, entry rules
|
|
11815
|
+
* first (ruling 6-2; board-workflow spec, Part 2). A column with no rules
|
|
11816
|
+
* answers at once, without reading anything; otherwise only the facts its
|
|
11817
|
+
* rules need are read. `entering` counts the cards this same write already let
|
|
11818
|
+
* into the column, so a bulk move is held to the WIP limit as a whole.
|
|
11167
11819
|
*/
|
|
11168
|
-
async function checkColumnEntry(store, card, column) {
|
|
11820
|
+
async function checkColumnEntry(store, card, column, entering) {
|
|
11169
11821
|
const rules = readEntryRules(column);
|
|
11170
|
-
|
|
11171
|
-
|
|
11822
|
+
const unmet = rules.length === 0 ? [] : evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
|
|
11823
|
+
const flow = readFlowRules(column);
|
|
11824
|
+
if (flow.allowedFrom === null && flow.wipLimit === null) return unmet;
|
|
11825
|
+
return [...unmet, ...evaluateFlowRules(card, column, flow, await readFlowRuleFacts(store, column, flow, entering))];
|
|
11826
|
+
}
|
|
11827
|
+
/** 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. */
|
|
11828
|
+
async function readFlowRuleFacts(store, column, flow, entering) {
|
|
11829
|
+
const columnNames = flow.allowedFrom === null ? /* @__PURE__ */ new Map() : new Map((await store.statuses.listByWorkspace(column.workspaceId)).map((other) => [other.id, other.name]));
|
|
11830
|
+
if (flow.wipLimit === null) return {
|
|
11831
|
+
columnNames,
|
|
11832
|
+
cardsInColumn: 0
|
|
11833
|
+
};
|
|
11834
|
+
await store.statuses.lockById(column.id);
|
|
11835
|
+
return {
|
|
11836
|
+
columnNames,
|
|
11837
|
+
cardsInColumn: (await store.issues.listPage({
|
|
11838
|
+
workspaceId: column.workspaceId,
|
|
11839
|
+
statusIds: [column.id],
|
|
11840
|
+
limit: 1
|
|
11841
|
+
})).total + entering
|
|
11842
|
+
};
|
|
11172
11843
|
}
|
|
11173
11844
|
/**
|
|
11174
|
-
* Hold a card at the door of a column that asks for more than it has
|
|
11175
|
-
* 6-3)
|
|
11176
|
-
*
|
|
11177
|
-
*
|
|
11178
|
-
*
|
|
11845
|
+
* Hold a card at the door of a column that asks for more than it has — entry
|
|
11846
|
+
* rules (ruling 6-3), or flow rules: a column it may not come from, or a column
|
|
11847
|
+
* that is full. Anyone but a person — or a person's own terminal,
|
|
11848
|
+
* `placesForPerson` (ruling 6-6) — is refused with `board_column_rules_unmet`,
|
|
11849
|
+
* which names the column and everything unmet; a person is let through, and
|
|
11850
|
+
* the unmet list comes back so the caller can warn them.
|
|
11179
11851
|
*/
|
|
11180
|
-
async function enforceColumnEntry(store, card, column, actor) {
|
|
11181
|
-
const unmet = await checkColumnEntry(store, card, column);
|
|
11852
|
+
async function enforceColumnEntry(store, card, column, actor, entering = 0) {
|
|
11853
|
+
const unmet = await checkColumnEntry(store, card, column, entering);
|
|
11182
11854
|
if (unmet.length > 0 && actor.kind !== "user" && !actor.placesForPerson) throw new BoardError("board_column_rules_unmet", {
|
|
11183
11855
|
issueId: card.id,
|
|
11184
11856
|
column: column.name,
|
|
@@ -11191,14 +11863,17 @@ async function enforceColumnEntry(store, card, column, actor) {
|
|
|
11191
11863
|
* The same door for several cards entering one column at once — a bulk status
|
|
11192
11864
|
* change. All or nothing: the first card an agent may not move refuses the
|
|
11193
11865
|
* whole change. A person gets back the cards that entered with rules unmet.
|
|
11866
|
+
* Each card counts against the column's WIP limit as the cards before it in the same change entered it.
|
|
11194
11867
|
*/
|
|
11195
11868
|
async function enforceColumnEntryForCards(store, issueIds, statusId, actor) {
|
|
11196
11869
|
const column = await store.statuses.findById(statusId);
|
|
11197
11870
|
if (!column) throw new BoardError("issue_status_not_found", { statusId });
|
|
11198
11871
|
const entered = [];
|
|
11872
|
+
let entering = 0;
|
|
11199
11873
|
for (const card of await store.issues.listByIds(issueIds)) {
|
|
11200
11874
|
if (card.statusId === column.id) continue;
|
|
11201
|
-
const unmet = await enforceColumnEntry(store, card, column, actor);
|
|
11875
|
+
const unmet = await enforceColumnEntry(store, card, column, actor, entering);
|
|
11876
|
+
entering++;
|
|
11202
11877
|
if (unmet.length > 0) entered.push({
|
|
11203
11878
|
issueId: card.id,
|
|
11204
11879
|
unmet
|
|
@@ -11353,11 +12028,24 @@ const STOPPED_BY_YOU_STATUS_LINE = "stopped by you";
|
|
|
11353
12028
|
* the board reads the same whether the agent was launched by an app or from
|
|
11354
12029
|
* somewhere else entirely. The attempt number is simply how many runs the card
|
|
11355
12030
|
* has had — a card whose runs were all launched elsewhere still counts them.
|
|
12031
|
+
*
|
|
12032
|
+
* A card has one run going at a time. A start on a card whose run is still going
|
|
12033
|
+
* is refused with `board_card_run_active`, naming that run — whichever path it
|
|
12034
|
+
* came by, an app's launch, `kanbo run start`, MCP, or a second app on a shared
|
|
12035
|
+
* Postgres board. The card's row is locked first, so two starts racing for it
|
|
12036
|
+
* are settled one after the other.
|
|
11356
12037
|
*/
|
|
11357
12038
|
async function startRun(store, issueId, input, actor, scope) {
|
|
11358
12039
|
const externalSessionRef = readExternalSessionRef(input.externalSessionRef) ?? null;
|
|
11359
12040
|
return await runBoardWrite(store, async ({ tx }) => {
|
|
11360
|
-
const card = await
|
|
12041
|
+
const card = await tx.issues.lockById(issueId);
|
|
12042
|
+
if (!card) throw new BoardError("issue_not_found", { issueId });
|
|
12043
|
+
const [active] = await tx.runs.listRunning(issueId);
|
|
12044
|
+
if (active) throw new BoardError("board_card_run_active", {
|
|
12045
|
+
issueId,
|
|
12046
|
+
runId: active.id,
|
|
12047
|
+
agentName: active.agentName
|
|
12048
|
+
});
|
|
11361
12049
|
const attempt = await tx.runs.countByIssue(issueId) + 1;
|
|
11362
12050
|
const executionMode = input.executionMode ?? card.executionMode;
|
|
11363
12051
|
const run = await tx.runs.create({
|
|
@@ -11623,7 +12311,8 @@ async function createCard(store, input, actor, scope) {
|
|
|
11623
12311
|
const column = statusId === null ? null : await tx.statuses.findById(statusId);
|
|
11624
12312
|
const unmet = column ? await enforceColumnEntry(tx, {
|
|
11625
12313
|
id: null,
|
|
11626
|
-
description: input.description ?? null
|
|
12314
|
+
description: input.description ?? null,
|
|
12315
|
+
statusId: null
|
|
11627
12316
|
}, column, actor) : [];
|
|
11628
12317
|
const milestoneId = await requireMilestoneInWorkspace(tx, workspaceId, input.milestoneId ?? null);
|
|
11629
12318
|
const parentIssueId = await requireParentInWorkspace(tx, workspaceId, input.parentIssueId ?? null);
|
|
@@ -11631,8 +12320,9 @@ async function createCard(store, input, actor, scope) {
|
|
|
11631
12320
|
const now = currentUnixSeconds();
|
|
11632
12321
|
for (let attempt = 1;; attempt++) {
|
|
11633
12322
|
const identity = await nextIssueIdentity(input.workspace, tx);
|
|
12323
|
+
let created;
|
|
11634
12324
|
try {
|
|
11635
|
-
|
|
12325
|
+
created = await tx.issues.create({
|
|
11636
12326
|
id: identity.id,
|
|
11637
12327
|
workspaceId,
|
|
11638
12328
|
title: input.title?.trim() || identity.id,
|
|
@@ -11656,12 +12346,25 @@ async function createCard(store, input, actor, scope) {
|
|
|
11656
12346
|
order,
|
|
11657
12347
|
createdAt: now,
|
|
11658
12348
|
updatedAt: now
|
|
11659
|
-
})
|
|
12349
|
+
});
|
|
11660
12350
|
} catch (error) {
|
|
11661
12351
|
const numberTaken = await tx.issues.findByNumber(workspaceId, identity.number);
|
|
11662
12352
|
const keyTaken = await tx.issues.findById(identity.id);
|
|
11663
12353
|
if (attempt >= NUMBERING_ATTEMPTS || !numberTaken && !keyTaken) throw error;
|
|
12354
|
+
continue;
|
|
11664
12355
|
}
|
|
12356
|
+
if (statusId !== null) await tx.fieldChanges.create({
|
|
12357
|
+
id: (0, node_crypto.randomUUID)(),
|
|
12358
|
+
issueId: created.id,
|
|
12359
|
+
field: "statusId",
|
|
12360
|
+
fromValue: null,
|
|
12361
|
+
toValue: statusId,
|
|
12362
|
+
actorKind: toStoredActorKind(actor),
|
|
12363
|
+
actorId: actor.id ?? null,
|
|
12364
|
+
sourceChatSessionId: actor.chatSessionId ?? null,
|
|
12365
|
+
createdAt: now
|
|
12366
|
+
});
|
|
12367
|
+
return withUnmetRules(created, unmet);
|
|
11665
12368
|
}
|
|
11666
12369
|
}, scope);
|
|
11667
12370
|
}
|
|
@@ -11717,7 +12420,8 @@ async function updateCard(store, issueId, input, actor, scope) {
|
|
|
11717
12420
|
const description = "description" in updates ? updates.description ?? null : card.description;
|
|
11718
12421
|
unmet = await enforceColumnEntry(tx, {
|
|
11719
12422
|
id: card.id,
|
|
11720
|
-
description
|
|
12423
|
+
description,
|
|
12424
|
+
statusId: card.statusId
|
|
11721
12425
|
}, column, actor);
|
|
11722
12426
|
}
|
|
11723
12427
|
if ("assigneeKind" in input) updates.assigneeKind = input.assigneeKind ?? null;
|
|
@@ -12116,6 +12820,118 @@ async function reorderCards(store, workspaceId, orderedIds, scope) {
|
|
|
12116
12820
|
}, scope);
|
|
12117
12821
|
}
|
|
12118
12822
|
//#endregion
|
|
12823
|
+
//#region src/ops/ready.ts
|
|
12824
|
+
/** The column an agent takes its next card from. */
|
|
12825
|
+
const READY_COLUMN_SLUG = "to_do";
|
|
12826
|
+
/**
|
|
12827
|
+
* The cards an agent may pick up right now: spelled out, nobody working on them,
|
|
12828
|
+
* nobody's turn but the agent's.
|
|
12829
|
+
*
|
|
12830
|
+
* "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
|
|
12831
|
+
* run is taken, and a card waiting for a person belongs to that person until
|
|
12832
|
+
* they answer. What is left is the queue — in board order, so the card someone
|
|
12833
|
+
* dragged to the top is the one an agent asking for work gets.
|
|
12834
|
+
*/
|
|
12835
|
+
async function listReady(store, input) {
|
|
12836
|
+
return (await queryReady(store, input)).cards;
|
|
12837
|
+
}
|
|
12838
|
+
/**
|
|
12839
|
+
* One page of the ready cards, how many are ready in all, and the columns read
|
|
12840
|
+
* on the way — the rule above, asked of the database in one statement rather
|
|
12841
|
+
* than of every card on the board.
|
|
12842
|
+
*/
|
|
12843
|
+
async function queryReady(store, input) {
|
|
12844
|
+
const columns = await listColumns(store, input.workspaceId);
|
|
12845
|
+
const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
|
|
12846
|
+
if (!column) return {
|
|
12847
|
+
cards: [],
|
|
12848
|
+
total: 0,
|
|
12849
|
+
columns
|
|
12850
|
+
};
|
|
12851
|
+
return {
|
|
12852
|
+
...await store.issues.listPage({
|
|
12853
|
+
workspaceId: input.workspaceId,
|
|
12854
|
+
statusIds: [column.id],
|
|
12855
|
+
waitingForPerson: false,
|
|
12856
|
+
hasActiveRun: false,
|
|
12857
|
+
limit: input.limit,
|
|
12858
|
+
offset: input.offset
|
|
12859
|
+
}),
|
|
12860
|
+
columns
|
|
12861
|
+
};
|
|
12862
|
+
}
|
|
12863
|
+
//#endregion
|
|
12864
|
+
//#region src/ops/claim.ts
|
|
12865
|
+
/**
|
|
12866
|
+
* Take a card to work on, in one board write: pick it, start its run, and move
|
|
12867
|
+
* it into the board's first `started` column (In Progress on a standard
|
|
12868
|
+
* board), its entry and flow rules checked on the way in. Anything refused
|
|
12869
|
+
* leaves nothing written.
|
|
12870
|
+
*
|
|
12871
|
+
* Two claimers never get the same card. On SQLite the write holds the file
|
|
12872
|
+
* from its first read (`begin immediate`), so the second claimer reads the
|
|
12873
|
+
* board after the first one's commit. On Postgres the picked card's row is
|
|
12874
|
+
* locked (`select … for update`): a claimer that waited on the lock finds the
|
|
12875
|
+
* card taken, and moves on to the next ready one. The target's row is locked
|
|
12876
|
+
* first, always, in the order a move into it locks (column, then card), so a
|
|
12877
|
+
* claim and a move never wait on each other in a circle.
|
|
12878
|
+
*/
|
|
12879
|
+
async function claimCard(store, input, actor, scope) {
|
|
12880
|
+
return await runBoardWrite(store, async ({ tx }) => {
|
|
12881
|
+
const columns = await listColumns(tx, input.workspaceId);
|
|
12882
|
+
const target = columns.find((column) => column.category === "started");
|
|
12883
|
+
if (!target) throw new BoardError("issue_status_not_found", {
|
|
12884
|
+
workspaceId: input.workspaceId,
|
|
12885
|
+
category: "started"
|
|
12886
|
+
});
|
|
12887
|
+
await tx.statuses.lockById(target.id);
|
|
12888
|
+
const toDo = columns.find((column) => normalizeStatusName(column.name) === "to_do") ?? null;
|
|
12889
|
+
const card = input.issueId === void 0 ? await takeFirstReady(tx, input.workspaceId, toDo) : await takeNamed(tx, input.workspaceId, input.issueId, toDo);
|
|
12890
|
+
const run = await startRun(tx, card.id, input.run, actor, { tx });
|
|
12891
|
+
return {
|
|
12892
|
+
card: await updateCard(tx, card.id, { statusId: target.id }, actor, { tx }),
|
|
12893
|
+
run,
|
|
12894
|
+
column: target
|
|
12895
|
+
};
|
|
12896
|
+
}, scope);
|
|
12897
|
+
}
|
|
12898
|
+
/**
|
|
12899
|
+
* The first ready card, locked. A card another claimer took while this one
|
|
12900
|
+
* waited for its lock is passed over, and the next read no longer lists it; the
|
|
12901
|
+
* cards passed over are counted so the loop ends with the list.
|
|
12902
|
+
*/
|
|
12903
|
+
async function takeFirstReady(store, workspaceId, toDo) {
|
|
12904
|
+
const passed = /* @__PURE__ */ new Set();
|
|
12905
|
+
for (;;) {
|
|
12906
|
+
const { cards } = await queryReady(store, {
|
|
12907
|
+
workspaceId,
|
|
12908
|
+
limit: passed.size + 1
|
|
12909
|
+
});
|
|
12910
|
+
const candidate = cards.find((card) => !passed.has(card.id));
|
|
12911
|
+
if (!candidate) throw new BoardError("board_nothing_ready", { workspaceId });
|
|
12912
|
+
const locked = await store.issues.lockById(candidate.id);
|
|
12913
|
+
if (locked && await notReadyReason(store, locked, toDo) === null) return locked;
|
|
12914
|
+
passed.add(candidate.id);
|
|
12915
|
+
}
|
|
12916
|
+
}
|
|
12917
|
+
/** The named card, locked, when it is on this board and ready — or why not. */
|
|
12918
|
+
async function takeNamed(store, workspaceId, issueId, toDo) {
|
|
12919
|
+
const card = await store.issues.lockById(issueId);
|
|
12920
|
+
if (!card || card.workspaceId !== workspaceId) throw new BoardError("issue_not_found", { issueId });
|
|
12921
|
+
const reason = await notReadyReason(store, card, toDo);
|
|
12922
|
+
if (reason) throw new BoardError("board_card_not_ready", {
|
|
12923
|
+
issueId,
|
|
12924
|
+
reason
|
|
12925
|
+
});
|
|
12926
|
+
return card;
|
|
12927
|
+
}
|
|
12928
|
+
/** `queryReady`'s rule for one card already read: nobody on it, nobody waited for, in To Do. */
|
|
12929
|
+
async function notReadyReason(store, card, toDo) {
|
|
12930
|
+
if ((await store.runs.listRunning(card.id)).length > 0) return "run_active";
|
|
12931
|
+
if (card.waitingFor === "human") return "waiting_for_person";
|
|
12932
|
+
return toDo !== null && card.statusId === toDo.id ? null : "not_in_to_do";
|
|
12933
|
+
}
|
|
12934
|
+
//#endregion
|
|
12119
12935
|
//#region src/ops/context-refs.ts
|
|
12120
12936
|
const ContextRefsJsonSchema = string().transform((raw) => JSON.parse(raw)).pipe(array(string()));
|
|
12121
12937
|
/** Add a context ref at the end of the card's list, recording the change in its history. */
|
|
@@ -12246,33 +13062,6 @@ async function migrateCards(store, input) {
|
|
|
12246
13062
|
};
|
|
12247
13063
|
}
|
|
12248
13064
|
//#endregion
|
|
12249
|
-
//#region src/domain/card-display-title.ts
|
|
12250
|
-
/**
|
|
12251
|
-
* What a card is called wherever a person or an agent reads it by name.
|
|
12252
|
-
*
|
|
12253
|
-
* A card written as a description alone is titled with its own key (every
|
|
12254
|
-
* writer does that). Named by its key, such a card says nothing — so its name
|
|
12255
|
-
* is the first line of its description instead. A browser bundle that does not
|
|
12256
|
-
* load this package keeps the same rule on its own, tested with the same
|
|
12257
|
-
* examples.
|
|
12258
|
-
*/
|
|
12259
|
-
/** How long a description line may run before it is cut with an ellipsis. */
|
|
12260
|
-
const DESCRIPTION_LINE_LIMIT = 120;
|
|
12261
|
-
function cardDisplayTitle(card) {
|
|
12262
|
-
if (card.title !== card.id) return card.title;
|
|
12263
|
-
return descriptionFirstLine(card.description) ?? card.title;
|
|
12264
|
-
}
|
|
12265
|
-
function descriptionFirstLine(description) {
|
|
12266
|
-
for (const rawLine of (description ?? "").split("\n")) {
|
|
12267
|
-
const line = stripMarkdown(rawLine);
|
|
12268
|
-
if (line) return line.length > DESCRIPTION_LINE_LIMIT ? `${line.slice(0, 119).trimEnd()}…` : line;
|
|
12269
|
-
}
|
|
12270
|
-
return null;
|
|
12271
|
-
}
|
|
12272
|
-
function stripMarkdown(line) {
|
|
12273
|
-
return line.replace(/^\s*(?:>\s*)+/, "").replace(/^\s*#{1,6}\s+/, "").replace(/^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ x]\]\s+)?/i, "").replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<[^>]+>/g, "").replace(/[*_~`]/g, "").replace(/\s+/g, " ").trim();
|
|
12274
|
-
}
|
|
12275
|
-
//#endregion
|
|
12276
13065
|
//#region src/ops/agent-rules.ts
|
|
12277
13066
|
/**
|
|
12278
13067
|
* The sentences that tell an agent how to work this board, written once.
|
|
@@ -12300,6 +13089,7 @@ function subtaskSplitSentence(parentReference) {
|
|
|
12300
13089
|
}
|
|
12301
13090
|
const SUBTASK_RULE = `${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("the parent card's id")}`;
|
|
12302
13091
|
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.";
|
|
13092
|
+
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
13093
|
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
13094
|
/** Said by the `kanbo init` block and the MCP instructions, which name the comment command or tool beside it. */
|
|
12305
13095
|
const COMMENT_RULE = "Findings, results, decisions and questions go on the card as comments.";
|
|
@@ -12323,6 +13113,7 @@ const BOARD_RULES = [
|
|
|
12323
13113
|
EXECUTION_MODE_RULE,
|
|
12324
13114
|
SUBTASK_RULE,
|
|
12325
13115
|
PULL_REQUEST_RULE,
|
|
13116
|
+
CLAIM_RULE,
|
|
12326
13117
|
OWN_SESSION_RULE,
|
|
12327
13118
|
"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
13119
|
];
|
|
@@ -12338,12 +13129,14 @@ const BOARD_RULES = [
|
|
|
12338
13129
|
async function buildPrimeText(store, workspaceId) {
|
|
12339
13130
|
const columns = await listColumns(store, workspaceId);
|
|
12340
13131
|
const ruled = columns.some((column) => readEntryRules(column).length > 0);
|
|
13132
|
+
const flowing = columns.some((column) => hasFlowRules(column));
|
|
12341
13133
|
return [
|
|
12342
13134
|
...await describeReturnedCards(store, workspaceId, columns),
|
|
12343
13135
|
"Columns of this board, in board order:",
|
|
12344
13136
|
"",
|
|
12345
|
-
...columns.length > 0 ? columns.map(describeColumnLine) : ["- (this board has no columns yet)"],
|
|
13137
|
+
...columns.length > 0 ? columns.map((column) => describeColumnLine(column, columns)) : ["- (this board has no columns yet)"],
|
|
12346
13138
|
...ruled ? ["", ENTRY_RULES_NOTE] : [],
|
|
13139
|
+
...flowing ? ["", FLOW_RULES_NOTE] : [],
|
|
12347
13140
|
"",
|
|
12348
13141
|
"How this board works:",
|
|
12349
13142
|
"",
|
|
@@ -12385,11 +13178,22 @@ async function describeReturnedCards(store, workspaceId, columns) {
|
|
|
12385
13178
|
* without any reads exactly as it did before they existed.
|
|
12386
13179
|
*/
|
|
12387
13180
|
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
|
-
|
|
13181
|
+
/** Said under the columns only when one of them has flow rules. */
|
|
13182
|
+
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.";
|
|
13183
|
+
function hasFlowRules(column) {
|
|
13184
|
+
const { allowedFrom, wipLimit } = readFlowRules(column);
|
|
13185
|
+
return allowedFrom !== null || wipLimit !== null;
|
|
13186
|
+
}
|
|
13187
|
+
function describeColumnLine(column, columns) {
|
|
12389
13188
|
const slug = normalizeStatusName(column.name);
|
|
12390
13189
|
const rules = readEntryRules(column);
|
|
12391
|
-
const
|
|
12392
|
-
return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${
|
|
13190
|
+
const said = `${rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : ""}${describeFlow(column, columns)}`;
|
|
13191
|
+
return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${said}` : `- ${column.name} (\`${slug}\`)${said ? ` —${said}` : ""}`;
|
|
13192
|
+
}
|
|
13193
|
+
/** Where a column takes cards from and how many it holds, as the prime text says it; nothing for a column without flow rules. */
|
|
13194
|
+
function describeFlow(column, columns) {
|
|
13195
|
+
const { allowedFrom, wipLimit } = readFlowRules(column);
|
|
13196
|
+
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
13197
|
}
|
|
12394
13198
|
//#endregion
|
|
12395
13199
|
//#region src/domain/pull-request-ref.ts
|
|
@@ -12559,47 +13363,6 @@ async function queryCards(store, input) {
|
|
|
12559
13363
|
};
|
|
12560
13364
|
}
|
|
12561
13365
|
//#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
13366
|
//#region src/ops/relations.ts
|
|
12604
13367
|
/**
|
|
12605
13368
|
* Every edge touching the card, in either direction, each with the card on its
|
|
@@ -12889,6 +13652,8 @@ function createBoardOps(store) {
|
|
|
12889
13652
|
updateColumn: async (statusId, patch, actor, scope) => await updateColumn(store, statusId, patch, actor, scope),
|
|
12890
13653
|
describeColumn: async (workspaceId, nameOrId, description, scope) => await describeColumn(store, workspaceId, nameOrId, description, scope),
|
|
12891
13654
|
setColumnEntryRules: async (workspaceId, nameOrId, entryRules, actor, scope) => await setColumnEntryRules(store, workspaceId, nameOrId, entryRules, actor, scope),
|
|
13655
|
+
setColumnFlowRules: async (columnId, rules, actor, scope) => await setColumnFlowRules(store, columnId, rules, actor, scope),
|
|
13656
|
+
applyStandardFlow: async (workspaceId, actor, scope) => await applyStandardFlow(store, workspaceId, actor, scope),
|
|
12892
13657
|
enforceColumnEntryForCards: async (issueIds, statusId, actor) => await enforceColumnEntryForCards(store, issueIds, statusId, actor),
|
|
12893
13658
|
deleteColumn: async (statusId, scope, actor) => await deleteColumn(store, statusId, scope, actor),
|
|
12894
13659
|
reorderColumns: async (workspaceId, orderedIds, scope, actor) => await reorderColumns(store, workspaceId, orderedIds, scope, actor),
|
|
@@ -12899,6 +13664,7 @@ function createBoardOps(store) {
|
|
|
12899
13664
|
moveColumn: async (workspaceId, nameOrId, position, actor, scope) => await moveColumn$1(store, workspaceId, nameOrId, position, actor, scope),
|
|
12900
13665
|
removeColumn: async (workspaceId, nameOrId, options, actor, scope) => await removeColumn$1(store, workspaceId, nameOrId, options, actor, scope),
|
|
12901
13666
|
startRun: async (issueId, input, actor, scope) => await startRun(store, issueId, input, actor, scope),
|
|
13667
|
+
claimCard: async (input, actor, scope) => await claimCard(store, input, actor, scope),
|
|
12902
13668
|
attachRunExecution: async (runId, input, scope) => await attachRunExecution(store, runId, input, scope),
|
|
12903
13669
|
recordRunSessionRef: async (runId, ref, scope) => await recordRunSessionRef(store, runId, ref, scope),
|
|
12904
13670
|
clearRunSessionRef: async (runId, actor, options, scope) => await clearRunSessionRef(store, runId, actor, options, scope),
|
|
@@ -12911,7 +13677,10 @@ function createBoardOps(store) {
|
|
|
12911
13677
|
readBoardProjectionForIssue: async (issueId) => await readBoardProjectionForIssue(store, issueId),
|
|
12912
13678
|
listReady: async (input) => await listReady(store, input),
|
|
12913
13679
|
queryReady: async (input) => await queryReady(store, input),
|
|
12914
|
-
|
|
13680
|
+
listColumnEntries: async (input) => await listColumnEntries(store, input),
|
|
13681
|
+
buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId),
|
|
13682
|
+
readBoardMetricsHistory: async (workspaceIds) => await readBoardMetricsHistory(store, workspaceIds),
|
|
13683
|
+
boardMetrics: async (input, now) => await boardMetrics(store, input, now)
|
|
12915
13684
|
};
|
|
12916
13685
|
}
|
|
12917
13686
|
//#endregion
|
|
@@ -18777,6 +19546,10 @@ const issueStatuses$1 = pgTable("issue_statuses", {
|
|
|
18777
19546
|
order: integer$1("order").notNull().default(0),
|
|
18778
19547
|
/** A JSON array of `ENTRY_RULES`, or `null` for none — see the SQLite schema. */
|
|
18779
19548
|
entryRules: text$1("entry_rules"),
|
|
19549
|
+
/** A JSON array of the status ids a card may enter from, or `null` for any — see the SQLite schema. */
|
|
19550
|
+
allowedFrom: text$1("allowed_from"),
|
|
19551
|
+
/** The most cards the column holds, or `null` — see the SQLite schema. */
|
|
19552
|
+
wipLimit: integer$1("wip_limit"),
|
|
18780
19553
|
...createdAt$1()
|
|
18781
19554
|
}, (table) => [index$1("issue_statuses_workspace_id_idx").on(table.workspaceId), uniqueIndex$1("issue_statuses_workspace_name_unique").on(table.workspaceId, table.name)]);
|
|
18782
19555
|
const issueMilestones$1 = pgTable("issue_milestones", {
|
|
@@ -18924,7 +19697,7 @@ const issueFieldChanges$1 = pgTable("issue_field_changes", {
|
|
|
18924
19697
|
...createdAt$1(),
|
|
18925
19698
|
/** Insertion order, standing in for the `rowid` the SQLite board orders changes by. */
|
|
18926
19699
|
seq: bigserial("seq", { mode: "number" }).notNull()
|
|
18927
|
-
}, (table) => [index$1("issue_field_changes_issue_id_idx").on(table.issueId)]);
|
|
19700
|
+
}, (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
19701
|
/** A pull request someone said belongs to a card — see the SQLite twin for what the row is and is not. */
|
|
18929
19702
|
const issuePullRequests$1 = pgTable("issue_pull_requests", {
|
|
18930
19703
|
id: textPk$1(),
|
|
@@ -19000,6 +19773,10 @@ function toIssueComment({ seq: _seq, ...comment }) {
|
|
|
19000
19773
|
function toIssueFieldChange({ seq: _seq, ...change }) {
|
|
19001
19774
|
return change;
|
|
19002
19775
|
}
|
|
19776
|
+
/** A field change with its card's workspace, without `seq`, for the same reason. */
|
|
19777
|
+
function toWorkspaceFieldChange({ seq: _seq, ...change }) {
|
|
19778
|
+
return change;
|
|
19779
|
+
}
|
|
19003
19780
|
/** The pull-request link without `seq`, for the same reason. */
|
|
19004
19781
|
function toIssuePullRequest({ seq: _seq, ...link }) {
|
|
19005
19782
|
return link;
|
|
@@ -19206,6 +19983,10 @@ function createStoreOver(database) {
|
|
|
19206
19983
|
const [row] = await database.select().from(issues$1).where(eq(issues$1.id, issueId)).limit(1);
|
|
19207
19984
|
return row ?? null;
|
|
19208
19985
|
},
|
|
19986
|
+
lockById: async (issueId) => {
|
|
19987
|
+
const [row] = await database.select().from(issues$1).where(eq(issues$1.id, issueId)).limit(1).for("update");
|
|
19988
|
+
return row ?? null;
|
|
19989
|
+
},
|
|
19209
19990
|
findInWorkspace: async (workspaceId, issueId) => {
|
|
19210
19991
|
const [row] = await database.select().from(issues$1).where(and(eq(issues$1.id, issueId), eq(issues$1.workspaceId, workspaceId))).limit(1);
|
|
19211
19992
|
return row ?? null;
|
|
@@ -19270,6 +20051,10 @@ function createStoreOver(database) {
|
|
|
19270
20051
|
},
|
|
19271
20052
|
comments: {
|
|
19272
20053
|
listByIssue: async (issueId) => (await database.select().from(issueComments$1).where(eq(issueComments$1.issueId, issueId)).orderBy(asc(issueComments$1.createdAt), asc(issueComments$1.seq))).map(toIssueComment),
|
|
20054
|
+
listByWorkspaces: async (workspaceIds, authorKinds) => {
|
|
20055
|
+
if (workspaceIds.length === 0 || authorKinds.length === 0) return [];
|
|
20056
|
+
return (await database.select(getTableColumns(issueComments$1)).from(issueComments$1).innerJoin(issues$1, eq(issues$1.id, issueComments$1.issueId)).where(and(inArray(issues$1.workspaceId, [...workspaceIds]), inArray(issueComments$1.authorKind, [...authorKinds]))).orderBy(asc(issueComments$1.createdAt), asc(issueComments$1.seq))).map(toIssueComment);
|
|
20057
|
+
},
|
|
19273
20058
|
listLatestByIssues: async (issueIds, authorKinds) => {
|
|
19274
20059
|
if (issueIds.length === 0 || authorKinds?.length === 0) return [];
|
|
19275
20060
|
const ranked = database.select({
|
|
@@ -19306,6 +20091,10 @@ function createStoreOver(database) {
|
|
|
19306
20091
|
listByIssue: async (issueId) => (await database.select().from(issueRuns$1).where(eq(issueRuns$1.issueId, issueId)).orderBy(asc(issueRuns$1.startedAt), asc(issueRuns$1.seq))).map(toIssueRun),
|
|
19307
20092
|
listRunning: async (issueId) => (await database.select().from(issueRuns$1).where(and(eq(issueRuns$1.issueId, issueId), eq(issueRuns$1.state, "running"))).orderBy(asc(issueRuns$1.startedAt), asc(issueRuns$1.seq))).map(toIssueRun),
|
|
19308
20093
|
listRunningWithSession: async () => (await database.select().from(issueRuns$1).where(and(eq(issueRuns$1.state, "running"), isNotNull(issueRuns$1.chatSessionId))).orderBy(asc(issueRuns$1.startedAt), asc(issueRuns$1.seq))).map(toIssueRun),
|
|
20094
|
+
listByWorkspaces: async (workspaceIds) => {
|
|
20095
|
+
if (workspaceIds.length === 0) return [];
|
|
20096
|
+
return (await database.select(getTableColumns(issueRuns$1)).from(issueRuns$1).innerJoin(issues$1, eq(issues$1.id, issueRuns$1.issueId)).where(inArray(issues$1.workspaceId, [...workspaceIds])).orderBy(asc(issueRuns$1.startedAt), asc(issueRuns$1.seq))).map(toIssueRun);
|
|
20097
|
+
},
|
|
19309
20098
|
findById: async (runId) => {
|
|
19310
20099
|
const [row] = await database.select().from(issueRuns$1).where(eq(issueRuns$1.id, runId)).limit(1);
|
|
19311
20100
|
return row ? toIssueRun(row) : null;
|
|
@@ -19357,6 +20146,10 @@ function createStoreOver(database) {
|
|
|
19357
20146
|
fieldChanges: {
|
|
19358
20147
|
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
20148
|
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),
|
|
20149
|
+
listByFieldSince: async (field, query) => query.workspaceIds.length === 0 ? [] : (await database.select({
|
|
20150
|
+
...getTableColumns(issueFieldChanges$1),
|
|
20151
|
+
workspaceId: issues$1.workspaceId
|
|
20152
|
+
}).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
20153
|
create: async (values) => {
|
|
19361
20154
|
await database.insert(issueFieldChanges$1).values(values);
|
|
19362
20155
|
}
|
|
@@ -20174,6 +20967,14 @@ const issueStatuses = sqliteTable("issue_statuses", {
|
|
|
20174
20967
|
* through `readEntryRules`, never by hand.
|
|
20175
20968
|
*/
|
|
20176
20969
|
entryRules: text("entry_rules"),
|
|
20970
|
+
/**
|
|
20971
|
+
* The columns a card may enter this one from — a JSON array of status ids;
|
|
20972
|
+
* `null` for any column, `[]` for none (only a person puts a card here). Read
|
|
20973
|
+
* it through `readFlowRules`, never by hand.
|
|
20974
|
+
*/
|
|
20975
|
+
allowedFrom: text("allowed_from"),
|
|
20976
|
+
/** The most cards this column holds, 1 or more; `null` for no limit. */
|
|
20977
|
+
wipLimit: int("wip_limit"),
|
|
20177
20978
|
...createdAt()
|
|
20178
20979
|
}, (table) => ({
|
|
20179
20980
|
byWorkspace: index("issue_statuses_workspace_id_idx").on(table.workspaceId),
|
|
@@ -20321,7 +21122,11 @@ const issueFieldChanges = sqliteTable("issue_field_changes", {
|
|
|
20321
21122
|
actorId: text("actor_id"),
|
|
20322
21123
|
sourceChatSessionId: text("source_chat_session_id"),
|
|
20323
21124
|
...createdAt()
|
|
20324
|
-
}, (table) => ({
|
|
21125
|
+
}, (table) => ({
|
|
21126
|
+
byIssue: index("issue_field_changes_issue_id_idx").on(table.issueId),
|
|
21127
|
+
/** The column-entry feed reads one field across a board by time. */
|
|
21128
|
+
byFieldCreatedAt: index("issue_field_changes_field_created_at_idx").on(table.field, table.createdAt)
|
|
21129
|
+
}));
|
|
20325
21130
|
/**
|
|
20326
21131
|
* A pull request someone said belongs to a card.
|
|
20327
21132
|
*
|
|
@@ -20664,6 +21469,7 @@ function createSqliteBoardStoreCore(resolveDatabase) {
|
|
|
20664
21469
|
}).from(issues).where(and(eq(issues.workspaceId, workspaceId), isNotNull(issues.milestoneId))).groupBy(issues.milestoneId, issues.statusId).all(),
|
|
20665
21470
|
listByIds: (issueIds) => resolveDatabase().select().from(issues).where(inArray(issues.id, issueIds)).all(),
|
|
20666
21471
|
findById: (issueId) => resolveDatabase().select().from(issues).where(eq(issues.id, issueId)).get() ?? null,
|
|
21472
|
+
lockById: (issueId) => resolveDatabase().select().from(issues).where(eq(issues.id, issueId)).get() ?? null,
|
|
20667
21473
|
findInWorkspace: (workspaceId, issueId) => resolveDatabase().select().from(issues).where(and(eq(issues.id, issueId), eq(issues.workspaceId, workspaceId))).get() ?? null,
|
|
20668
21474
|
findByNumber: (workspaceId, number) => resolveDatabase().select().from(issues).where(and(eq(issues.workspaceId, workspaceId), eq(issues.number, number))).get() ?? null,
|
|
20669
21475
|
maxNumber: (workspaceId) => {
|
|
@@ -20700,6 +21506,10 @@ function createSqliteBoardStoreCore(resolveDatabase) {
|
|
|
20700
21506
|
},
|
|
20701
21507
|
comments: {
|
|
20702
21508
|
listByIssue: (issueId) => resolveDatabase().select().from(issueComments).where(eq(issueComments.issueId, issueId)).orderBy(asc(issueComments.createdAt), COMMENT_INSERTION_ORDER).all(),
|
|
21509
|
+
listByWorkspaces: (workspaceIds, authorKinds) => {
|
|
21510
|
+
if (workspaceIds.length === 0 || authorKinds.length === 0) return [];
|
|
21511
|
+
return resolveDatabase().select(getTableColumns(issueComments)).from(issueComments).innerJoin(issues, eq(issues.id, issueComments.issueId)).where(and(inArray(issues.workspaceId, [...workspaceIds]), inArray(issueComments.authorKind, [...authorKinds]))).orderBy(asc(issueComments.createdAt), COMMENT_INSERTION_ORDER).all();
|
|
21512
|
+
},
|
|
20703
21513
|
listLatestByIssues: (issueIds, authorKinds) => {
|
|
20704
21514
|
if (issueIds.length === 0 || authorKinds?.length === 0) return [];
|
|
20705
21515
|
const database = resolveDatabase();
|
|
@@ -20729,6 +21539,10 @@ function createSqliteBoardStoreCore(resolveDatabase) {
|
|
|
20729
21539
|
listByIssue: (issueId) => resolveDatabase().select().from(issueRuns).where(eq(issueRuns.issueId, issueId)).orderBy(asc(issueRuns.startedAt), RUN_INSERTION_ORDER).all(),
|
|
20730
21540
|
listRunning: (issueId) => resolveDatabase().select().from(issueRuns).where(and(eq(issueRuns.issueId, issueId), eq(issueRuns.state, "running"))).orderBy(asc(issueRuns.startedAt), RUN_INSERTION_ORDER).all(),
|
|
20731
21541
|
listRunningWithSession: () => resolveDatabase().select().from(issueRuns).where(and(eq(issueRuns.state, "running"), isNotNull(issueRuns.chatSessionId))).orderBy(asc(issueRuns.startedAt), RUN_INSERTION_ORDER).all(),
|
|
21542
|
+
listByWorkspaces: (workspaceIds) => {
|
|
21543
|
+
if (workspaceIds.length === 0) return [];
|
|
21544
|
+
return resolveDatabase().select(getTableColumns(issueRuns)).from(issueRuns).innerJoin(issues, eq(issues.id, issueRuns.issueId)).where(inArray(issues.workspaceId, [...workspaceIds])).orderBy(asc(issueRuns.startedAt), RUN_INSERTION_ORDER).all();
|
|
21545
|
+
},
|
|
20732
21546
|
findById: (runId) => resolveDatabase().select().from(issueRuns).where(eq(issueRuns.id, runId)).get() ?? null,
|
|
20733
21547
|
findByChatSessionId: (chatSessionId) => resolveDatabase().select().from(issueRuns).where(eq(issueRuns.chatSessionId, chatSessionId)).orderBy(desc(issueRuns.startedAt), sql`${issueRuns}.rowid desc`).get() ?? null,
|
|
20734
21548
|
countByIssue: (issueId) => {
|
|
@@ -20761,6 +21575,10 @@ function createSqliteBoardStoreCore(resolveDatabase) {
|
|
|
20761
21575
|
fieldChanges: {
|
|
20762
21576
|
listByIssue: (issueId) => resolveDatabase().select().from(issueFieldChanges).where(eq(issueFieldChanges.issueId, issueId)).orderBy(asc(issueFieldChanges.createdAt), FIELD_CHANGE_INSERTION_ORDER).all(),
|
|
20763
21577
|
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(),
|
|
21578
|
+
listByFieldSince: (field, query) => query.workspaceIds.length === 0 ? [] : resolveDatabase().select({
|
|
21579
|
+
...getTableColumns(issueFieldChanges),
|
|
21580
|
+
workspaceId: issues.workspaceId
|
|
21581
|
+
}).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
21582
|
create: (values) => {
|
|
20765
21583
|
resolveDatabase().insert(issueFieldChanges).values(values).run();
|
|
20766
21584
|
}
|
|
@@ -20841,6 +21659,7 @@ function createSqliteBoardStore(options) {
|
|
|
20841
21659
|
countByMilestone: async (workspaceId) => core.issues.countByMilestone(workspaceId),
|
|
20842
21660
|
listByIds: async (issueIds) => core.issues.listByIds(issueIds),
|
|
20843
21661
|
findById: async (issueId) => core.issues.findById(issueId),
|
|
21662
|
+
lockById: async (issueId) => core.issues.lockById(issueId),
|
|
20844
21663
|
findInWorkspace: async (workspaceId, issueId) => core.issues.findInWorkspace(workspaceId, issueId),
|
|
20845
21664
|
findByNumber: async (workspaceId, number) => core.issues.findByNumber(workspaceId, number),
|
|
20846
21665
|
maxNumber: async (workspaceId) => core.issues.maxNumber(workspaceId),
|
|
@@ -20856,6 +21675,7 @@ function createSqliteBoardStore(options) {
|
|
|
20856
21675
|
comments: {
|
|
20857
21676
|
listByIssue: async (issueId) => core.comments.listByIssue(issueId),
|
|
20858
21677
|
listLatestByIssues: async (issueIds, authorKinds) => core.comments.listLatestByIssues(issueIds, authorKinds),
|
|
21678
|
+
listByWorkspaces: async (workspaceIds, authorKinds) => core.comments.listByWorkspaces(workspaceIds, authorKinds),
|
|
20859
21679
|
findById: async (commentId) => core.comments.findById(commentId),
|
|
20860
21680
|
findByDedupeKey: async (issueId, dedupeKey) => core.comments.findByDedupeKey(issueId, dedupeKey),
|
|
20861
21681
|
listByDedupeKeyPrefix: async (issueId, prefix) => core.comments.listByDedupeKeyPrefix(issueId, prefix),
|
|
@@ -20867,6 +21687,7 @@ function createSqliteBoardStore(options) {
|
|
|
20867
21687
|
listByIssue: async (issueId) => core.runs.listByIssue(issueId),
|
|
20868
21688
|
listRunning: async (issueId) => core.runs.listRunning(issueId),
|
|
20869
21689
|
listRunningWithSession: async () => core.runs.listRunningWithSession(),
|
|
21690
|
+
listByWorkspaces: async (workspaceIds) => core.runs.listByWorkspaces(workspaceIds),
|
|
20870
21691
|
findById: async (runId) => core.runs.findById(runId),
|
|
20871
21692
|
findByChatSessionId: async (chatSessionId) => core.runs.findByChatSessionId(chatSessionId),
|
|
20872
21693
|
countByIssue: async (issueId) => core.runs.countByIssue(issueId),
|
|
@@ -20886,6 +21707,7 @@ function createSqliteBoardStore(options) {
|
|
|
20886
21707
|
fieldChanges: {
|
|
20887
21708
|
listByIssue: async (issueId) => core.fieldChanges.listByIssue(issueId),
|
|
20888
21709
|
listByIssueField: async (issueId, field) => core.fieldChanges.listByIssueField(issueId, field),
|
|
21710
|
+
listByFieldSince: async (field, query) => core.fieldChanges.listByFieldSince(field, query),
|
|
20889
21711
|
create: async (values) => core.fieldChanges.create(values)
|
|
20890
21712
|
},
|
|
20891
21713
|
pullRequests: {
|
|
@@ -24007,9 +24829,10 @@ const SCHEMA_NOT_INSTALLED_MESSAGE = "This Postgres database has no kanbo board
|
|
|
24007
24829
|
* caller's own risk but never written to — a write shaped by this build's idea
|
|
24008
24830
|
* of the schema is how a person's board gets a column full of nothing. A file
|
|
24009
24831
|
* 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
|
|
24832
|
+
* points at `kanbo migrate` instead of at the app. Reads are held to the lesser
|
|
24833
|
+
* bar of `assertReadableBoard`: a file only a stamp behind is still read, so a
|
|
24834
|
+
* person who cannot migrate right now can look at their cards, but one missing
|
|
24835
|
+
* a table or column this build reads is refused with the same sentence.
|
|
24013
24836
|
*/
|
|
24014
24837
|
function assertWritableBoard(database, owner) {
|
|
24015
24838
|
try {
|
|
@@ -24020,6 +24843,26 @@ function assertWritableBoard(database, owner) {
|
|
|
24020
24843
|
}
|
|
24021
24844
|
}
|
|
24022
24845
|
/**
|
|
24846
|
+
* Refuse a read of a board file that lacks a table or a column this build
|
|
24847
|
+
* reads — a file a migration behind — with the sentence a write gets, instead
|
|
24848
|
+
* of letting the first statement fail on what is missing. A file whose only
|
|
24849
|
+
* difference is its schema stamp is still read: every statement this build
|
|
24850
|
+
* makes works on it, and a person who cannot migrate now can still look at
|
|
24851
|
+
* their cards.
|
|
24852
|
+
*/
|
|
24853
|
+
function assertReadableBoard(database, owner) {
|
|
24854
|
+
try {
|
|
24855
|
+
assertBoardSchema$1(database);
|
|
24856
|
+
} catch (error) {
|
|
24857
|
+
if (!(error instanceof BoardError && error.code === "board_schema_outdated")) throw error;
|
|
24858
|
+
if (lacksWhatThisBuildReads(error.details)) throw new CliError(3, owner === "kanbo" ? FILE_SCHEMA_OUTDATED_MESSAGE : SCHEMA_OUTDATED_MESSAGE);
|
|
24859
|
+
}
|
|
24860
|
+
}
|
|
24861
|
+
/** Whether `assertBoardSchema` found a table or a column missing, rather than only another stamp. */
|
|
24862
|
+
function lacksWhatThisBuildReads(details) {
|
|
24863
|
+
return details !== void 0 && ("missingTables" in details || "missingAddedTables" in details || "missingColumns" in details);
|
|
24864
|
+
}
|
|
24865
|
+
/**
|
|
24023
24866
|
* Refuse an external board this build cannot speak for — before reading it as
|
|
24024
24867
|
* well as before writing to it.
|
|
24025
24868
|
*
|
|
@@ -24258,6 +25101,7 @@ async function openFileSession(target, options, mode, cwd) {
|
|
|
24258
25101
|
assertWritableBoard(board.database, target.owner);
|
|
24259
25102
|
};
|
|
24260
25103
|
if (mode === "write") assertWritableBoard(board.database, target.owner);
|
|
25104
|
+
else assertReadableBoard(board.database, target.owner);
|
|
24261
25105
|
const workspace = resolveWorkspace(target.owner === "kanbo" ? { kind: "file" } : {
|
|
24262
25106
|
kind: "sqlite",
|
|
24263
25107
|
database: board.database
|
|
@@ -24281,8 +25125,9 @@ async function openFileSession(target, options, mode, cwd) {
|
|
|
24281
25125
|
/**
|
|
24282
25126
|
* The same, on a board that is a database of its own.
|
|
24283
25127
|
*
|
|
24284
|
-
* The guard runs whatever the command came to do. A board file
|
|
24285
|
-
*
|
|
25128
|
+
* The guard runs whatever the command came to do. A board file whose only
|
|
25129
|
+
* difference from this build is its schema stamp is still a board worth
|
|
25130
|
+
* reading; a connection string pointing
|
|
24286
25131
|
* at a database with no board in it is nothing to read at all, and every
|
|
24287
25132
|
* command would otherwise fail one statement later with the driver's own words.
|
|
24288
25133
|
*/
|
|
@@ -24339,6 +25184,17 @@ function parseCount(value) {
|
|
|
24339
25184
|
return count;
|
|
24340
25185
|
}
|
|
24341
25186
|
/**
|
|
25187
|
+
* A date as the command line takes it: `YYYY-MM-DD`, read as a UTC day, or a
|
|
25188
|
+
* unix-seconds number taken as it is. `end` turns a day into its last second,
|
|
25189
|
+
* so a range or a sprint ending on a day runs through that day.
|
|
25190
|
+
*/
|
|
25191
|
+
function parseCalendarDay(value, end) {
|
|
25192
|
+
if (/^\d+$/.test(value)) return Number(value);
|
|
25193
|
+
const day = /^\d{4}-\d{2}-\d{2}$/.test(value) ? Date.parse(`${value}T00:00:00Z`) : NaN;
|
|
25194
|
+
if (Number.isNaN(day) || new Date(day).toISOString().slice(0, 10) !== value) throw new CliError(1, `Expected a date as YYYY-MM-DD or unix seconds, got "${value}".`);
|
|
25195
|
+
return day / 1e3 + (end ? 86399 : 0);
|
|
25196
|
+
}
|
|
25197
|
+
/**
|
|
24342
25198
|
* The card a caller named, in the workspace the command is about.
|
|
24343
25199
|
*
|
|
24344
25200
|
* People and agents refer to a card by whatever they have in front of them: the
|
|
@@ -24580,7 +25436,9 @@ function projectCardActiveRun(run) {
|
|
|
24580
25436
|
startedAt: run.startedAt
|
|
24581
25437
|
};
|
|
24582
25438
|
}
|
|
24583
|
-
|
|
25439
|
+
/** A column as the command line prints it; `board` is every column, to name the ones it accepts cards from. */
|
|
25440
|
+
function projectColumn(column, board) {
|
|
25441
|
+
const { allowedFrom, wipLimit } = readFlowRules(column);
|
|
24584
25442
|
return {
|
|
24585
25443
|
id: column.id,
|
|
24586
25444
|
name: column.name,
|
|
@@ -24588,7 +25446,9 @@ function projectColumn(column) {
|
|
|
24588
25446
|
description: column.description,
|
|
24589
25447
|
category: column.category,
|
|
24590
25448
|
order: column.order,
|
|
24591
|
-
entryRules: readEntryRules(column)
|
|
25449
|
+
entryRules: readEntryRules(column),
|
|
25450
|
+
allowedFrom: allowedFrom === null ? null : board.filter((other) => allowedFrom.includes(other.id)).map((other) => normalizeStatusName(other.name)),
|
|
25451
|
+
wipLimit
|
|
24592
25452
|
};
|
|
24593
25453
|
}
|
|
24594
25454
|
function projectSprint(sprint) {
|
|
@@ -24660,7 +25520,9 @@ function describeColumns(columns) {
|
|
|
24660
25520
|
return columns.map((column) => [
|
|
24661
25521
|
`${padToWidth(sanitizeTerminalText(column.slug), slugWidth)} ${sanitizeTerminalText(column.name)}`,
|
|
24662
25522
|
column.description ? ` — ${sanitizeTerminalText(column.description)}` : "",
|
|
24663
|
-
column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : ""
|
|
25523
|
+
column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : "",
|
|
25524
|
+
column.allowedFrom === null ? "" : ` [from: ${column.allowedFrom.length > 0 ? column.allowedFrom.join(", ") : "no column"}]`,
|
|
25525
|
+
column.wipLimit === null ? "" : ` [wip: ${column.wipLimit}]`
|
|
24664
25526
|
].join("")).join("\n");
|
|
24665
25527
|
}
|
|
24666
25528
|
/** A unix-seconds date as the UTC day it falls on, `—` for none. */
|
|
@@ -24854,6 +25716,7 @@ function colorsWanted() {
|
|
|
24854
25716
|
const KANBO_TOOL_CLI_EQUIVALENTS = {
|
|
24855
25717
|
kanbo_prime: { kanbo: "kanbo prime" },
|
|
24856
25718
|
kanbo_ready: { kanbo: "kanbo ready" },
|
|
25719
|
+
kanbo_card_claim: { kanbo: "kanbo claim [card] [--agent <name>]" },
|
|
24857
25720
|
kanbo_columns: { kanbo: "kanbo columns list" },
|
|
24858
25721
|
kanbo_sprints: { kanbo: "kanbo sprint list" },
|
|
24859
25722
|
kanbo_card_get: { kanbo: "kanbo card get <id>" },
|
|
@@ -25270,16 +26133,22 @@ function projectKanboSprint(sprint) {
|
|
|
25270
26133
|
doneCards: sprint.cards.done
|
|
25271
26134
|
};
|
|
25272
26135
|
}
|
|
25273
|
-
/** A column of the board, from the row either transport holds. */
|
|
25274
|
-
function projectKanboColumn(column) {
|
|
26136
|
+
/** A column of the board, from the row either transport holds; `board` is every column, to name the ones it accepts cards from. */
|
|
26137
|
+
function projectKanboColumn(column, board) {
|
|
25275
26138
|
const stored = column.entryRules;
|
|
26139
|
+
const allowedIds = typeof column.allowedFrom === "string" ? readFlowRules({
|
|
26140
|
+
allowedFrom: column.allowedFrom,
|
|
26141
|
+
wipLimit: null
|
|
26142
|
+
}).allowedFrom : column.allowedFrom ?? null;
|
|
25276
26143
|
return {
|
|
25277
26144
|
id: column.id,
|
|
25278
26145
|
name: column.name,
|
|
25279
26146
|
slug: normalizeStatusName(column.name),
|
|
25280
26147
|
description: column.description,
|
|
25281
26148
|
order: column.order,
|
|
25282
|
-
entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null })
|
|
26149
|
+
entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null }),
|
|
26150
|
+
allowedFrom: allowedIds === null ? null : board.filter((other) => allowedIds.includes(other.id)).map((other) => normalizeStatusName(other.name)),
|
|
26151
|
+
wipLimit: column.wipLimit ?? null
|
|
25283
26152
|
};
|
|
25284
26153
|
}
|
|
25285
26154
|
/** A comment as a card read lists it, from the row either transport holds. */
|
|
@@ -25393,7 +26262,7 @@ function readMoment(value) {
|
|
|
25393
26262
|
const cardMoveTool = defineKanboTool({
|
|
25394
26263
|
name: "kanbo_card_move",
|
|
25395
26264
|
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
|
|
26265
|
+
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
26266
|
inputSchema: {
|
|
25398
26267
|
card: cardArgument,
|
|
25399
26268
|
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 +26334,7 @@ const cardUpdateTool = defineKanboTool({
|
|
|
25465
26334
|
const columnsTool = defineKanboTool({
|
|
25466
26335
|
name: "kanbo_columns",
|
|
25467
26336
|
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,
|
|
26337
|
+
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
26338
|
inputSchema: {},
|
|
25470
26339
|
run: async (transport) => await transport.columns()
|
|
25471
26340
|
});
|
|
@@ -25479,6 +26348,38 @@ const primeTool = defineKanboTool({
|
|
|
25479
26348
|
inputSchema: {},
|
|
25480
26349
|
run: async (transport) => (await transport.prime()).text
|
|
25481
26350
|
});
|
|
26351
|
+
//#endregion
|
|
26352
|
+
//#region src/mcp/tools/card-claim.ts
|
|
26353
|
+
/**
|
|
26354
|
+
* Taking a card to work on, in one step: the run starts and the card moves out
|
|
26355
|
+
* of To Do into the board's first work-in-progress column in the same write,
|
|
26356
|
+
* so two agents never get the same card. Published to every host, including
|
|
26357
|
+
* one that records the runs of the agents it launches: a claim is how an agent
|
|
26358
|
+
* in any terminal takes work.
|
|
26359
|
+
*/
|
|
26360
|
+
const cardClaimTool = defineKanboTool({
|
|
26361
|
+
name: "kanbo_card_claim",
|
|
26362
|
+
title: "Claim a card",
|
|
26363
|
+
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.",
|
|
26364
|
+
inputSchema: {
|
|
26365
|
+
card: cardArgument.optional(),
|
|
26366
|
+
agent: string().min(1).optional().describe("What to call whoever works the card; who this server writes as when absent.")
|
|
26367
|
+
},
|
|
26368
|
+
aliases: {
|
|
26369
|
+
card: [...CARD_ALIASES, "issue"],
|
|
26370
|
+
agent: ["agentName"]
|
|
26371
|
+
},
|
|
26372
|
+
example: { card: "TST-5" },
|
|
26373
|
+
run: async (transport, input) => {
|
|
26374
|
+
const claimed = await transport.cardClaim(input);
|
|
26375
|
+
return {
|
|
26376
|
+
card: compactKanboCard(claimed.card),
|
|
26377
|
+
run: claimed.run.id,
|
|
26378
|
+
column: claimed.column.slug,
|
|
26379
|
+
finishWith: `kanbo_run_finish {"run":"${claimed.run.id}","state":"finished"}`
|
|
26380
|
+
};
|
|
26381
|
+
}
|
|
26382
|
+
});
|
|
25482
26383
|
/** The cards an agent may pick up right now. */
|
|
25483
26384
|
const readyTool = defineKanboTool({
|
|
25484
26385
|
name: "kanbo_ready",
|
|
@@ -25567,13 +26468,14 @@ const runStartTool = defineKanboTool({
|
|
|
25567
26468
|
/**
|
|
25568
26469
|
* Every tool the board has, in the order it declares them.
|
|
25569
26470
|
*
|
|
25570
|
-
* The list is the board's, not a server's: the same
|
|
26471
|
+
* The list is the board's, not a server's: the same seventeen are registered
|
|
25571
26472
|
* whether they run against a server or against the board directly, and the
|
|
25572
26473
|
* only thing a host decides is which of them it publishes.
|
|
25573
26474
|
*/
|
|
25574
26475
|
const KANBO_TOOLS = [
|
|
25575
26476
|
primeTool,
|
|
25576
26477
|
readyTool,
|
|
26478
|
+
cardClaimTool,
|
|
25577
26479
|
columnsTool,
|
|
25578
26480
|
defineKanboTool({
|
|
25579
26481
|
name: "kanbo_sprints",
|
|
@@ -25963,10 +26865,12 @@ function buildCapabilitiesManifest() {
|
|
|
25963
26865
|
"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
26866
|
`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
26867
|
"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.",
|
|
26868
|
+
"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.",
|
|
26869
|
+
"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
26870
|
"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
26871
|
"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
26872
|
"`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)
|
|
26873
|
+
"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
26874
|
"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
26875
|
]
|
|
25972
26876
|
};
|
|
@@ -26111,6 +27015,52 @@ function registerCapabilitiesCommand(program) {
|
|
|
26111
27015
|
});
|
|
26112
27016
|
}
|
|
26113
27017
|
//#endregion
|
|
27018
|
+
//#region src/cli/commands/claim.ts
|
|
27019
|
+
/**
|
|
27020
|
+
* `kanbo claim [card]` — take a card to work on, in one step.
|
|
27021
|
+
*
|
|
27022
|
+
* The run starts and the card moves out of To Do into the board's first
|
|
27023
|
+
* work-in-progress column in one write, so two agents in two terminals never
|
|
27024
|
+
* take the same card. Filed as a placement: in a person's own terminal the
|
|
27025
|
+
* column's rules warn, in an agent's shell they refuse.
|
|
27026
|
+
*/
|
|
27027
|
+
function registerClaimCommand(program) {
|
|
27028
|
+
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) => {
|
|
27029
|
+
await runBoardCommand(options, "write", async (session) => {
|
|
27030
|
+
const actor = createPlacementActor();
|
|
27031
|
+
const named = reference === void 0 ? void 0 : (await requireCard(session, reference)).id;
|
|
27032
|
+
const claimed = await session.ops.claimCard({
|
|
27033
|
+
workspaceId: session.workspace.id,
|
|
27034
|
+
issueId: named,
|
|
27035
|
+
run: {
|
|
27036
|
+
agentName: options.agent ?? actor.id ?? "agent",
|
|
27037
|
+
externalSessionRef: options.session ?? sessionRefFromEnvironment() ?? void 0,
|
|
27038
|
+
launchedByKind: "external"
|
|
27039
|
+
}
|
|
27040
|
+
}, actor);
|
|
27041
|
+
const [columns, projection, runs] = await Promise.all([
|
|
27042
|
+
session.ops.listColumns(session.workspace.id),
|
|
27043
|
+
session.ops.readBoardProjectionForIssue(claimed.card.id),
|
|
27044
|
+
session.ops.listRuns(claimed.card.id)
|
|
27045
|
+
]);
|
|
27046
|
+
const card = projectCard(claimed.card, columns, projection);
|
|
27047
|
+
for (const item of claimed.card.unmetRules ?? []) console.error(`kanbo: warning: ${card.id} entered "${claimed.column.name}" without ${item.rule}: ${item.detail}`);
|
|
27048
|
+
const run = projectRun({
|
|
27049
|
+
...claimed.run,
|
|
27050
|
+
attempt: runs.find((each) => each.id === claimed.run.id)?.attempt ?? runs.length
|
|
27051
|
+
});
|
|
27052
|
+
return {
|
|
27053
|
+
value: {
|
|
27054
|
+
card,
|
|
27055
|
+
run,
|
|
27056
|
+
column: projectColumn(claimed.column, columns)
|
|
27057
|
+
},
|
|
27058
|
+
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`
|
|
27059
|
+
};
|
|
27060
|
+
});
|
|
27061
|
+
});
|
|
27062
|
+
}
|
|
27063
|
+
//#endregion
|
|
26114
27064
|
//#region src/cli/card-extras.ts
|
|
26115
27065
|
async function readCardExtras(session, card, columns, include, commentLimit) {
|
|
26116
27066
|
const wants = new Set(include);
|
|
@@ -26491,10 +27441,11 @@ const COLUMN_CATEGORIES = [
|
|
|
26491
27441
|
];
|
|
26492
27442
|
/** The whole board's columns after a change, and the sentence that says what changed. */
|
|
26493
27443
|
async function changed(session, message, extra = {}) {
|
|
26494
|
-
const
|
|
27444
|
+
const board = await session.ops.listColumns(session.workspace.id);
|
|
27445
|
+
const views = board.map((column) => projectColumn(column, board));
|
|
26495
27446
|
return {
|
|
26496
27447
|
value: {
|
|
26497
|
-
...extra,
|
|
27448
|
+
...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, typeof value === "number" || Array.isArray(value) ? value : projectColumn(value, board)])),
|
|
26498
27449
|
columns: views
|
|
26499
27450
|
},
|
|
26500
27451
|
text: `${message}\n\n${describeColumns(views)}`
|
|
@@ -26523,14 +27474,14 @@ async function addColumn(session, request, actor) {
|
|
|
26523
27474
|
category: request.category ?? spec.category,
|
|
26524
27475
|
position: anchor ? request.after !== void 0 ? { after: anchor.id } : { before: anchor.id } : void 0
|
|
26525
27476
|
}, actor);
|
|
26526
|
-
return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added:
|
|
27477
|
+
return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: created });
|
|
26527
27478
|
}
|
|
26528
27479
|
/** Said after a rename: agents' instructions name no column but To Do, and `kanbo prime` gives them the rest. */
|
|
26529
27480
|
const RENAME_NOTE = "Agents read column names from kanbo prime; rename is picked up there.";
|
|
26530
27481
|
async function renameColumn(session, column, newName, actor) {
|
|
26531
27482
|
const before = await requireNamedColumn(session, column);
|
|
26532
27483
|
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
|
|
27484
|
+
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
27485
|
}
|
|
26535
27486
|
/** `--first`, `--last`, `--before <column>` or `--after <column>` — exactly one. */
|
|
26536
27487
|
function readColumnPosition(options) {
|
|
@@ -26562,7 +27513,7 @@ async function removeColumn(session, column, moveCardsTo, actor) {
|
|
|
26562
27513
|
const result = await session.ops.removeColumn(session.workspace.id, removing.id, { moveCardsTo }, actor);
|
|
26563
27514
|
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
27515
|
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:
|
|
27516
|
+
removed: result.removed,
|
|
26566
27517
|
movedCards: result.movedCards
|
|
26567
27518
|
});
|
|
26568
27519
|
}
|
|
@@ -26686,7 +27637,7 @@ async function runColumnsMenu(ui) {
|
|
|
26686
27637
|
if (showList) say$2(ui, [
|
|
26687
27638
|
"",
|
|
26688
27639
|
"Columns of this board:",
|
|
26689
|
-
describeColumns(columns.map(projectColumn))
|
|
27640
|
+
describeColumns(columns.map((column) => projectColumn(column, columns)))
|
|
26690
27641
|
].join("\n"));
|
|
26691
27642
|
let choice;
|
|
26692
27643
|
try {
|
|
@@ -26914,7 +27865,8 @@ function registerColumnsCommands(program) {
|
|
|
26914
27865
|
const columns = program.command("columns").description("See and change the board's columns (alone: a menu)");
|
|
26915
27866
|
withBoardOptions(columns.command("list").description("List the columns in order, with the short name to move cards by")).action(async (options) => {
|
|
26916
27867
|
await runBoardCommand(options, "read", async (session) => {
|
|
26917
|
-
const
|
|
27868
|
+
const board = await session.ops.listColumns(session.workspace.id);
|
|
27869
|
+
const views = board.map((column) => projectColumn(column, board));
|
|
26918
27870
|
return {
|
|
26919
27871
|
value: views,
|
|
26920
27872
|
text: describeColumns(views)
|
|
@@ -26923,7 +27875,7 @@ function registerColumnsCommands(program) {
|
|
|
26923
27875
|
});
|
|
26924
27876
|
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
27877
|
await runBoardCommand(options, "write", async (session) => {
|
|
26926
|
-
const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text));
|
|
27878
|
+
const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text), await session.ops.listColumns(session.workspace.id));
|
|
26927
27879
|
return {
|
|
26928
27880
|
value: view,
|
|
26929
27881
|
text: describeColumns([view])
|
|
@@ -26934,13 +27886,34 @@ function registerColumnsCommands(program) {
|
|
|
26934
27886
|
const actor = requireHumanActor("columns rules", typedWords(command));
|
|
26935
27887
|
const rules = readRequestedRules(options);
|
|
26936
27888
|
await runBoardCommand(options, "write", async (session) => {
|
|
26937
|
-
const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor));
|
|
27889
|
+
const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor), await session.ops.listColumns(session.workspace.id));
|
|
26938
27890
|
return {
|
|
26939
27891
|
value: view,
|
|
26940
27892
|
text: describeColumns([view])
|
|
26941
27893
|
};
|
|
26942
27894
|
});
|
|
26943
27895
|
});
|
|
27896
|
+
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) => {
|
|
27897
|
+
const actor = requireHumanActor("columns flow", typedWords(command));
|
|
27898
|
+
if (options.from === void 0 && options.wip === void 0) throw new CliError(1, "Pass --from <columns|any>, --wip <count|none>, or both.");
|
|
27899
|
+
const wipLimit = options.wip === void 0 ? void 0 : readWipLimit(options.wip);
|
|
27900
|
+
await runBoardCommand(options, "write", async (session) => {
|
|
27901
|
+
const workspaceId = session.workspace.id;
|
|
27902
|
+
const board = await session.ops.listColumns(workspaceId);
|
|
27903
|
+
const target = await session.ops.requireColumn(workspaceId, column);
|
|
27904
|
+
const current = readFlowRules(target);
|
|
27905
|
+
await session.ops.setColumnFlowRules(target.id, {
|
|
27906
|
+
allowedFrom: options.from === void 0 ? current.allowedFrom : readAllowedFrom(board, workspaceId, options.from),
|
|
27907
|
+
wipLimit: wipLimit === void 0 ? current.wipLimit : wipLimit
|
|
27908
|
+
}, actor);
|
|
27909
|
+
const after = await session.ops.listColumns(workspaceId);
|
|
27910
|
+
const views = after.map((each) => projectColumn(each, after));
|
|
27911
|
+
return {
|
|
27912
|
+
value: views.find((view) => view.id === target.id),
|
|
27913
|
+
text: describeColumns(views)
|
|
27914
|
+
};
|
|
27915
|
+
});
|
|
27916
|
+
});
|
|
26944
27917
|
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
27918
|
const actor = requireHumanActor("columns add", typedWords(command));
|
|
26946
27919
|
await runBoardCommand(options, "write", async (session) => await addColumn(session, {
|
|
@@ -26982,7 +27955,7 @@ function registerColumnsCommands(program) {
|
|
|
26982
27955
|
mode: "add-missing",
|
|
26983
27956
|
actor
|
|
26984
27957
|
});
|
|
26985
|
-
const views = statuses.map(projectColumn);
|
|
27958
|
+
const views = statuses.map((column) => projectColumn(column, statuses));
|
|
26986
27959
|
return {
|
|
26987
27960
|
value: {
|
|
26988
27961
|
added,
|
|
@@ -27006,7 +27979,8 @@ function registerColumnsCommands(program) {
|
|
|
27006
27979
|
return;
|
|
27007
27980
|
}
|
|
27008
27981
|
await runBoardCommand({}, "read", async (session) => {
|
|
27009
|
-
const
|
|
27982
|
+
const board = await session.ops.listColumns(session.workspace.id);
|
|
27983
|
+
const views = board.map((column) => projectColumn(column, board));
|
|
27010
27984
|
return {
|
|
27011
27985
|
value: views,
|
|
27012
27986
|
text: describeColumns(views)
|
|
@@ -27036,6 +28010,26 @@ function readRequestedRules(options) {
|
|
|
27036
28010
|
if (options.require === void 0) throw new CliError(1, `Pass --require ${ENTRY_RULES.join(",")} (any of them) or --clear.`);
|
|
27037
28011
|
return options.require.split(",").map((rule) => rule.trim()).filter(Boolean);
|
|
27038
28012
|
}
|
|
28013
|
+
/** `--from`: `any` for every column, else the columns named, by slug, name or id. */
|
|
28014
|
+
function readAllowedFrom(board, workspaceId, value) {
|
|
28015
|
+
if (value.trim().toLowerCase() === "any") return null;
|
|
28016
|
+
const names = value.split(",").map((name) => name.trim()).filter(Boolean);
|
|
28017
|
+
if (names.length === 0) throw new CliError(1, "Name the columns a card may come from (--from in_review,qa), or --from any.");
|
|
28018
|
+
return names.map((name) => {
|
|
28019
|
+
const found = matchColumn(board, workspaceId, name);
|
|
28020
|
+
if (!found) throw new BoardError("issue_status_not_found", {
|
|
28021
|
+
workspaceId,
|
|
28022
|
+
statusName: name
|
|
28023
|
+
});
|
|
28024
|
+
return found.id;
|
|
28025
|
+
});
|
|
28026
|
+
}
|
|
28027
|
+
/** `--wip`: `none` for no limit, else a whole number of cards, 1 or more. */
|
|
28028
|
+
function readWipLimit(value) {
|
|
28029
|
+
if (value.trim().toLowerCase() === "none") return null;
|
|
28030
|
+
if (!/^\d+$/.test(value.trim()) || Number(value) < 1) throw new CliError(1, "--wip takes a whole number of cards, 1 or more, or none.");
|
|
28031
|
+
return Number(value);
|
|
28032
|
+
}
|
|
27039
28033
|
//#endregion
|
|
27040
28034
|
//#region node_modules/zod/v3/helpers/util.js
|
|
27041
28035
|
var util;
|
|
@@ -42978,7 +43972,7 @@ const INSTRUCTION_END_PATTERN = /<!-- KANBO_END\s*-->/g;
|
|
|
42978
43972
|
const POINTER_KINDS = /* @__PURE__ */ new Set(["short", "global"]);
|
|
42979
43973
|
/** The commands an agent uses, spelled once for the guide and the `kanbo prime` cheat sheet. */
|
|
42980
43974
|
const COMMAND = {
|
|
42981
|
-
|
|
43975
|
+
claim: "kanbo claim",
|
|
42982
43976
|
move: "kanbo card move <id> <column>",
|
|
42983
43977
|
statusLine: "kanbo card status-line <id> --text \"...\"",
|
|
42984
43978
|
subtask: "kanbo card create --description \"...\" --parent <id>",
|
|
@@ -42997,7 +43991,7 @@ const AGENT_GUIDE_TEXT = [
|
|
|
42997
43991
|
"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
43992
|
"",
|
|
42999
43993
|
...[
|
|
43000
|
-
`- Take a card
|
|
43994
|
+
`- 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
43995
|
`- ${MOVE_CARD_RULE} (\`${COMMAND.move}\`)`,
|
|
43002
43996
|
`- ${STATUS_LINE_RULE} (\`${COMMAND.statusLine}\`)`,
|
|
43003
43997
|
`- ${SUBTASK_RULE} (\`${COMMAND.subtask}\`)`,
|
|
@@ -43027,12 +44021,13 @@ const COMMAND_SHEET = [
|
|
|
43027
44021
|
"- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
|
|
43028
44022
|
"- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
|
|
43029
44023
|
"- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
|
|
43030
|
-
"- `kanbo
|
|
44024
|
+
"- `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",
|
|
44025
|
+
"- `kanbo run start TST-5 --agent claude` — record a run on a card you did not claim; it prints the finish command",
|
|
43031
44026
|
"- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
|
|
43032
44027
|
`- \`${COMMAND.capabilities}\` — everything else, rarely needed`
|
|
43033
44028
|
].join("\n");
|
|
43034
44029
|
/** How to take work and who approves it — said by both blocks after their first sentence. */
|
|
43035
|
-
const POINTER_TAIL = `Take work with \`${COMMAND.
|
|
44030
|
+
const POINTER_TAIL = `Take work with \`${COMMAND.claim}\`. ${PERSON_ONLY_RULE}`;
|
|
43036
44031
|
/** The body of the project block: what `kanbo instructions short` prints. */
|
|
43037
44032
|
const PROJECT_BODY = [
|
|
43038
44033
|
"## Kanbo board",
|
|
@@ -43052,7 +44047,7 @@ const GLOBAL_BODY = [
|
|
|
43052
44047
|
* The block, markers included, with this body's version and hash on the start
|
|
43053
44048
|
* marker — and, when given, the kind of text it is (`k=orchestrator`).
|
|
43054
44049
|
*/
|
|
43055
|
-
function wrapInstructionBlock(body, version =
|
|
44050
|
+
function wrapInstructionBlock(body, version = 3, kind) {
|
|
43056
44051
|
return `<!-- KANBO_START v${version}${kind ? ` k=${kind}` : ""} h=${hashBody(body)} -->\n${body}\n${INSTRUCTION_END}`;
|
|
43057
44052
|
}
|
|
43058
44053
|
/** The block `kanbo init --instructions …` writes into a project's own file. */
|
|
@@ -43081,7 +44076,7 @@ function inspectBlock(text, current) {
|
|
|
43081
44076
|
kind
|
|
43082
44077
|
});
|
|
43083
44078
|
if (fields.trim() === "") return inspection(LEGACY_BLOCK_BODIES.includes(body) ? "legacy" : "edited");
|
|
43084
|
-
if (version !== void 0 && Number(version) >
|
|
44079
|
+
if (version !== void 0 && Number(version) > 3) return inspection("newer");
|
|
43085
44080
|
if (kind !== null && !POINTER_KINDS.has(kind)) return inspection("chosen");
|
|
43086
44081
|
if (version === void 0 || hash === void 0 || hashBody(body) !== hash) return inspection("edited");
|
|
43087
44082
|
return inspection(normalized.slice(found.start, found.end) === current ? "current" : "outdated");
|
|
@@ -44153,7 +45148,7 @@ function startUpdateCheck(deps = {}) {
|
|
|
44153
45148
|
setTimeout: globalThis.setTimeout,
|
|
44154
45149
|
clearTimeout: globalThis.clearTimeout
|
|
44155
45150
|
};
|
|
44156
|
-
const current = deps.currentVersion ?? "0.
|
|
45151
|
+
const current = deps.currentVersion ?? "0.6.0";
|
|
44157
45152
|
const controller = new AbortController();
|
|
44158
45153
|
let found = null;
|
|
44159
45154
|
const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
|
|
@@ -46670,7 +47665,7 @@ const KANBO_MCP_INSTRUCTIONS = [
|
|
|
46670
47665
|
"kanbo is a kanban board shared by people and agents; these tools read and change its cards.",
|
|
46671
47666
|
"",
|
|
46672
47667
|
`- 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")}.`,
|
|
47668
|
+
`- 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
47669
|
`- ${MOVE_CARD_RULE} (${tool("kanbo_card_move")})`,
|
|
46675
47670
|
`- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
|
|
46676
47671
|
`- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
|
|
@@ -46761,7 +47756,7 @@ const ORCHESTRATOR_GUIDE = [
|
|
|
46761
47756
|
"## Start of every session",
|
|
46762
47757
|
"1. Run `kanbo prime` (tool: `kanbo_prime`) and read the column descriptions. On this board the description is the contract, not the name.",
|
|
46763
47758
|
"2. Run `kanbo ready` (`kanbo_ready`). If nothing is ready, say so and stop.",
|
|
46764
|
-
"3. Take the first card
|
|
47759
|
+
"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
47760
|
"",
|
|
46766
47761
|
"## While working",
|
|
46767
47762
|
"- 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 +47783,7 @@ const ORCHESTRATOR_GUIDE = [
|
|
|
46788
47783
|
"- 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
47784
|
"",
|
|
46790
47785
|
"## 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.",
|
|
47786
|
+
"- 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
47787
|
"- Never delete or edit the board's database directly."
|
|
46793
47788
|
].join("\n");
|
|
46794
47789
|
//#endregion
|
|
@@ -46833,7 +47828,7 @@ function registerInstructionsCommand(program, dependencies = {}) {
|
|
|
46833
47828
|
const found = parseKind(kind);
|
|
46834
47829
|
const body = await readInstructionText(found);
|
|
46835
47830
|
const pointer = found === "short" || found === "global";
|
|
46836
|
-
const text = options.markers ? wrapInstructionBlock(body,
|
|
47831
|
+
const text = options.markers ? wrapInstructionBlock(body, 3, pointer ? void 0 : found) : body;
|
|
46837
47832
|
const hints = process.stderr.isTTY === true;
|
|
46838
47833
|
if (hints) process.stderr.write(`${HEADER}\n\n`);
|
|
46839
47834
|
console.log(text);
|
|
@@ -48266,7 +49261,8 @@ async function createDbTransport(input) {
|
|
|
48266
49261
|
const workspaceId = workspace.id;
|
|
48267
49262
|
const actor = input.actor;
|
|
48268
49263
|
async function readColumns() {
|
|
48269
|
-
|
|
49264
|
+
const rows = await ops.listColumns(workspaceId);
|
|
49265
|
+
return rows.map((row) => projectKanboColumn(row, rows));
|
|
48270
49266
|
}
|
|
48271
49267
|
/** One card with what the run registry says about it, in the shape a tool prints. */
|
|
48272
49268
|
async function project(card, columns) {
|
|
@@ -48316,7 +49312,7 @@ async function createDbTransport(input) {
|
|
|
48316
49312
|
* the waiting and returned ones last said at most two more.
|
|
48317
49313
|
*/
|
|
48318
49314
|
async function projectPage(page, { attend = true } = {}) {
|
|
48319
|
-
const columns = page.columns.map(projectKanboColumn);
|
|
49315
|
+
const columns = page.columns.map((column) => projectKanboColumn(column, page.columns));
|
|
48320
49316
|
const runs = await ops.readBoardProjectionForIssues(page.cards.map((card) => card.id));
|
|
48321
49317
|
const attention = attend ? await ops.readCardAttention(page.cards, runs.map((run) => run.activeRun !== null)) : [];
|
|
48322
49318
|
return page.cards.map((card, index) => projectKanboCard({
|
|
@@ -48448,6 +49444,25 @@ async function createDbTransport(input) {
|
|
|
48448
49444
|
state,
|
|
48449
49445
|
errorText
|
|
48450
49446
|
}, actor));
|
|
49447
|
+
},
|
|
49448
|
+
cardClaim: async ({ card, agent }) => {
|
|
49449
|
+
await session.assertWritable();
|
|
49450
|
+
const named = card === void 0 ? void 0 : (await resolveCard(card)).id;
|
|
49451
|
+
const claimed = await ops.claimCard({
|
|
49452
|
+
workspaceId,
|
|
49453
|
+
issueId: named,
|
|
49454
|
+
run: {
|
|
49455
|
+
agentName: agent ?? actor.id ?? "agent",
|
|
49456
|
+
externalSessionRef: sessionRefFromEnvironment() ?? void 0,
|
|
49457
|
+
launchedByKind: "external"
|
|
49458
|
+
}
|
|
49459
|
+
}, actor);
|
|
49460
|
+
const columns = await readColumns();
|
|
49461
|
+
return {
|
|
49462
|
+
card: await project(claimed.card, columns),
|
|
49463
|
+
run: projectKanboRun(claimed.run),
|
|
49464
|
+
column: columns.find((column) => column.id === claimed.column.id)
|
|
49465
|
+
};
|
|
48451
49466
|
}
|
|
48452
49467
|
};
|
|
48453
49468
|
}
|
|
@@ -48489,7 +49504,8 @@ const TRANSPORT_METHODS = {
|
|
|
48489
49504
|
statusLine: true,
|
|
48490
49505
|
waitApproval: true,
|
|
48491
49506
|
runStart: true,
|
|
48492
|
-
runFinish: true
|
|
49507
|
+
runFinish: true,
|
|
49508
|
+
cardClaim: true
|
|
48493
49509
|
};
|
|
48494
49510
|
async function openMcpBoard(input) {
|
|
48495
49511
|
try {
|
|
@@ -49095,7 +50111,7 @@ function sendBoardPage(response, pathname, assets) {
|
|
|
49095
50111
|
* answering for a person — approving a card, sending it back or closing a
|
|
49096
50112
|
* sprint — as anyone but one is 403, dropping a pull request link somebody else made is the same 403
|
|
49097
50113
|
* 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
|
|
50114
|
+
* 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
50115
|
* card in a column whose entry rules it does not meet yet is 409 for the same
|
|
49100
50116
|
* reason turned around: the answer will be yes once the card meets them, and
|
|
49101
50117
|
* `details.unmet` says what is missing. Deleting a column that still holds
|
|
@@ -49113,6 +50129,14 @@ const BOARD_ERROR_RESPONSES = {
|
|
|
49113
50129
|
message: "Only a person can approve a card",
|
|
49114
50130
|
code: "issue_approval_requires_user"
|
|
49115
50131
|
},
|
|
50132
|
+
board_card_not_ready: {
|
|
50133
|
+
status: 409,
|
|
50134
|
+
message: "That card is not there to take: it is not in To Do, waits for a person, or has a run going"
|
|
50135
|
+
},
|
|
50136
|
+
board_card_run_active: {
|
|
50137
|
+
status: 409,
|
|
50138
|
+
message: "The card already has a run going"
|
|
50139
|
+
},
|
|
49116
50140
|
board_column_name_taken: {
|
|
49117
50141
|
status: 400,
|
|
49118
50142
|
message: "Another column already has that name"
|
|
@@ -49152,6 +50176,18 @@ const BOARD_ERROR_RESPONSES = {
|
|
|
49152
50176
|
status: 400,
|
|
49153
50177
|
message: "Execution mode cannot change once the card has been launched"
|
|
49154
50178
|
},
|
|
50179
|
+
board_flow_rule_invalid: {
|
|
50180
|
+
status: 400,
|
|
50181
|
+
message: "A column accepts cards from other columns of its own board, and holds at least one card"
|
|
50182
|
+
},
|
|
50183
|
+
board_metrics_range_invalid: {
|
|
50184
|
+
status: 400,
|
|
50185
|
+
message: "The range starts after it ends"
|
|
50186
|
+
},
|
|
50187
|
+
board_nothing_ready: {
|
|
50188
|
+
status: 409,
|
|
50189
|
+
message: "No card in To Do is ready to take"
|
|
50190
|
+
},
|
|
49155
50191
|
board_pull_request_invalid: {
|
|
49156
50192
|
status: 400,
|
|
49157
50193
|
message: "Not a GitHub pull request: give its URL or owner/repo#number"
|
|
@@ -49567,6 +50603,14 @@ const startRunBody = object$1({
|
|
|
49567
50603
|
]).optional(),
|
|
49568
50604
|
externalSessionRef: string().nullable().optional()
|
|
49569
50605
|
});
|
|
50606
|
+
const claimBody = object$1({
|
|
50607
|
+
workspaceId: id,
|
|
50608
|
+
issueId: id.optional(),
|
|
50609
|
+
agentName: id.optional(),
|
|
50610
|
+
branch: string().nullable().optional(),
|
|
50611
|
+
executionMode: executionMode.optional(),
|
|
50612
|
+
externalSessionRef: string().nullable().optional()
|
|
50613
|
+
});
|
|
49570
50614
|
const finishRunBody = object$1({
|
|
49571
50615
|
state: _enum([
|
|
49572
50616
|
"finished",
|
|
@@ -49613,7 +50657,8 @@ async function toWrittenCardView(ops, card) {
|
|
|
49613
50657
|
function toStatusView(status) {
|
|
49614
50658
|
return {
|
|
49615
50659
|
...status,
|
|
49616
|
-
entryRules: readEntryRules(status)
|
|
50660
|
+
entryRules: readEntryRules(status),
|
|
50661
|
+
allowedFrom: readFlowRules(status).allowedFrom
|
|
49617
50662
|
};
|
|
49618
50663
|
}
|
|
49619
50664
|
function toCommentView(comment, personWriterId = null) {
|
|
@@ -50048,6 +51093,31 @@ function createIssueRoutes(session, options = {}) {
|
|
|
50048
51093
|
}, actors.placement));
|
|
50049
51094
|
}
|
|
50050
51095
|
}),
|
|
51096
|
+
route({
|
|
51097
|
+
method: "POST",
|
|
51098
|
+
path: "/issues/claim",
|
|
51099
|
+
body: claimBody,
|
|
51100
|
+
write: true,
|
|
51101
|
+
handle: async ({ body, actors }) => {
|
|
51102
|
+
const named = requireWorkspace(body.workspaceId);
|
|
51103
|
+
if (body.issueId !== void 0) await requireCard(body.issueId);
|
|
51104
|
+
const claimed = await ops.claimCard({
|
|
51105
|
+
workspaceId: named,
|
|
51106
|
+
issueId: body.issueId,
|
|
51107
|
+
run: {
|
|
51108
|
+
agentName: body.agentName ?? actors.writer.id ?? "agent",
|
|
51109
|
+
branch: body.branch,
|
|
51110
|
+
executionMode: body.executionMode,
|
|
51111
|
+
externalSessionRef: body.externalSessionRef
|
|
51112
|
+
}
|
|
51113
|
+
}, actors.placement);
|
|
51114
|
+
return {
|
|
51115
|
+
card: await toWrittenCardView(ops, claimed.card),
|
|
51116
|
+
run: await toRunView(claimed.run),
|
|
51117
|
+
column: toStatusView(claimed.column)
|
|
51118
|
+
};
|
|
51119
|
+
}
|
|
51120
|
+
}),
|
|
50051
51121
|
route({
|
|
50052
51122
|
method: "PATCH",
|
|
50053
51123
|
path: "/issues/bulk",
|
|
@@ -50933,17 +52003,6 @@ function parsePort(value) {
|
|
|
50933
52003
|
//#endregion
|
|
50934
52004
|
//#region src/cli/commands/sprint.ts
|
|
50935
52005
|
/**
|
|
50936
|
-
* A date as the command line takes it: `YYYY-MM-DD`, read as a UTC day, or a
|
|
50937
|
-
* unix-seconds number taken as it is. `end` turns a day into its last second,
|
|
50938
|
-
* so a sprint due on a day is still current throughout that day.
|
|
50939
|
-
*/
|
|
50940
|
-
function parseDate(value, end) {
|
|
50941
|
-
if (/^\d+$/.test(value)) return Number(value);
|
|
50942
|
-
const day = /^\d{4}-\d{2}-\d{2}$/.test(value) ? Date.parse(`${value}T00:00:00Z`) : NaN;
|
|
50943
|
-
if (Number.isNaN(day)) throw new CliError(1, `Expected a date as YYYY-MM-DD or unix seconds, got "${value}".`);
|
|
50944
|
-
return day / 1e3 + (end ? 86399 : 0);
|
|
50945
|
-
}
|
|
50946
|
-
/**
|
|
50947
52006
|
* `kanbo sprint …` — milestones read as sprints (ruling 5x-3).
|
|
50948
52007
|
*
|
|
50949
52008
|
* Listing and creating are anybody's. Closing one is a person's decision
|
|
@@ -50963,7 +52022,7 @@ function registerSprintCommands(program) {
|
|
|
50963
52022
|
};
|
|
50964
52023
|
});
|
|
50965
52024
|
});
|
|
50966
|
-
withBoardOptions(sprint.command("create").description("Start a sprint with a first and a last day").requiredOption("--title <title>", "what the sprint is called").requiredOption("--start <date>", "first day, as YYYY-MM-DD (UTC) or unix seconds", (value) =>
|
|
52025
|
+
withBoardOptions(sprint.command("create").description("Start a sprint with a first and a last day").requiredOption("--title <title>", "what the sprint is called").requiredOption("--start <date>", "first day, as YYYY-MM-DD (UTC) or unix seconds", (value) => parseCalendarDay(value, false)).requiredOption("--due <date>", "last day, as YYYY-MM-DD (UTC, through its end) or unix seconds", (value) => parseCalendarDay(value, true)).option("--description <text>", "what the sprint is for")).action(async (options) => {
|
|
50967
52026
|
await runBoardCommand(options, "write", async (session) => {
|
|
50968
52027
|
const created = await session.ops.createMilestone({
|
|
50969
52028
|
workspaceId: session.workspace.id,
|
|
@@ -51006,6 +52065,113 @@ function registerSprintCommands(program) {
|
|
|
51006
52065
|
});
|
|
51007
52066
|
}
|
|
51008
52067
|
//#endregion
|
|
52068
|
+
//#region src/cli/metrics-view.ts
|
|
52069
|
+
/** Every field `kanbo stats --json` may name, in the order the value carries them. */
|
|
52070
|
+
const BOARD_METRICS_FIELDS = [
|
|
52071
|
+
"range",
|
|
52072
|
+
"now",
|
|
52073
|
+
"milestoneId",
|
|
52074
|
+
"columns",
|
|
52075
|
+
"cycleTime",
|
|
52076
|
+
"leadTime",
|
|
52077
|
+
"throughput",
|
|
52078
|
+
"wip",
|
|
52079
|
+
"aging",
|
|
52080
|
+
"timeInColumn",
|
|
52081
|
+
"cumulativeFlow",
|
|
52082
|
+
"finished",
|
|
52083
|
+
"returns",
|
|
52084
|
+
"attempts",
|
|
52085
|
+
"canceled",
|
|
52086
|
+
"agents",
|
|
52087
|
+
"people"
|
|
52088
|
+
];
|
|
52089
|
+
const LABEL_WIDTH = 16;
|
|
52090
|
+
/** A duration as the command line prints it: minutes under an hour, hours under a day, then days, to one decimal; a dash for none. */
|
|
52091
|
+
function formatDuration(seconds) {
|
|
52092
|
+
if (seconds === null) return "—";
|
|
52093
|
+
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
|
|
52094
|
+
if (seconds < 86400) return `${Number((seconds / 3600).toFixed(1))}h`;
|
|
52095
|
+
return `${Number((seconds / 86400).toFixed(1))}d`;
|
|
52096
|
+
}
|
|
52097
|
+
function percent(share) {
|
|
52098
|
+
return share === null ? "—" : `${Math.round(share * 100)}%`;
|
|
52099
|
+
}
|
|
52100
|
+
function decimal(value) {
|
|
52101
|
+
return value === null ? "—" : String(Number(value.toFixed(1)));
|
|
52102
|
+
}
|
|
52103
|
+
function count(n, noun) {
|
|
52104
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
52105
|
+
}
|
|
52106
|
+
function day(seconds) {
|
|
52107
|
+
return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
|
|
52108
|
+
}
|
|
52109
|
+
/** A labelled block: the first item beside the label, the rest under it; a dash when there is nothing. */
|
|
52110
|
+
function block(label, items) {
|
|
52111
|
+
const [first = "—", ...rest] = items;
|
|
52112
|
+
return [`${label.padEnd(LABEL_WIDTH)}${first}`, ...rest.map((item) => `${" ".repeat(LABEL_WIDTH)}${item}`)];
|
|
52113
|
+
}
|
|
52114
|
+
/** Items joined on one line, or none at all. */
|
|
52115
|
+
function oneLine(items) {
|
|
52116
|
+
return items.length === 0 ? [] : [items.join(" · ")];
|
|
52117
|
+
}
|
|
52118
|
+
/**
|
|
52119
|
+
* The metrics as a person reads them in a terminal: the range, then one block
|
|
52120
|
+
* per figure. The cumulative flow is a series per day and has no one-screen
|
|
52121
|
+
* form, so the last line says how to get it.
|
|
52122
|
+
*/
|
|
52123
|
+
function describeMetrics(metrics, sprint) {
|
|
52124
|
+
const column = (slug) => sanitizeTerminalText(metrics.columns.find((candidate) => candidate.slug === slug)?.name ?? slug);
|
|
52125
|
+
const by = metrics.returns.byActorKind;
|
|
52126
|
+
const lines = [`${day(metrics.range.from)} → ${day(metrics.range.to)}`];
|
|
52127
|
+
if (sprint) lines.push(`${sanitizeTerminalText(sprint.title)}: ${sprint.cards.open + sprint.cards.done} planned · ${sprint.cards.done} done`);
|
|
52128
|
+
if (metrics.range.from > metrics.range.to) return [...lines, "Nothing measured yet: the range starts after now."].join("\n");
|
|
52129
|
+
lines.push(...block("Cycle time", [`p50 ${formatDuration(metrics.cycleTime.p50)} · p85 ${formatDuration(metrics.cycleTime.p85)} (${count(metrics.cycleTime.cards, "card")})`]), ...block("Lead time", [`p50 ${formatDuration(metrics.leadTime.p50)} · p85 ${formatDuration(metrics.leadTime.p85)}`]), ...block("Throughput", oneLine(metrics.throughput.map((week) => `${week.week} ${week.finished}`))), ...block("In progress", oneLine(metrics.wip.map((wip) => `${column(wip.slug)} ${wip.cards}${wip.limit === null ? "" : `/${wip.limit}`}`))), ...block("First pass", [`${percent(metrics.returns.firstPassRate)} · attempts ${decimal(metrics.attempts.perFinishedCard)} per card`]), ...block("Returns", [`${metrics.returns.total} (people ${by.user} · agents ${by.agent} · outside ${by.external}) · ${decimal(metrics.returns.perFinishedCard)} per card`]), ...block("Canceled", [String(metrics.canceled)]), ...block("Aging", metrics.aging.map((card) => `${card.issueId} ${column(card.slug)} ${formatDuration(card.ageSeconds)}${card.overCycleP85 ? " (older than p85)" : ""}`)), ...block("Time in column", oneLine(metrics.timeInColumn.filter((time) => time.seconds > 0).map((time) => `${column(time.slug)} ${formatDuration(time.seconds)}`))), ...block("Agents", metrics.agents.map(describeAgent)), ...block("People", metrics.people.map(describePerson)), "Cumulative flow by day: kanbo stats --json cumulativeFlow");
|
|
52130
|
+
return lines.join("\n");
|
|
52131
|
+
}
|
|
52132
|
+
function describeAgent(agent) {
|
|
52133
|
+
const { runs } = agent;
|
|
52134
|
+
return `${sanitizeTerminalText(agent.agentName)}: ${count(runs.total, "run")} (${runs.finished} finished · ${runs.failed} failed · ${runs.stopped} stopped · ${runs.running} running) · median ${formatDuration(agent.medianRunSeconds)} · ${count(agent.cardsFinished, "card")} finished · ${percent(agent.returnRate)} returned`;
|
|
52135
|
+
}
|
|
52136
|
+
function describePerson(person) {
|
|
52137
|
+
return `${sanitizeTerminalText(person.id ?? "unnamed")}: ${person.created} created · ${person.approvals} approved · ${person.returns} returned · ${person.moves} moved`;
|
|
52138
|
+
}
|
|
52139
|
+
const WEEK_SECONDS = 604800;
|
|
52140
|
+
/** The Monday that makes the range `DEFAULT_STATS_WEEKS` ISO weeks long, ending with the week of `now`. */
|
|
52141
|
+
function defaultStatsFrom(now) {
|
|
52142
|
+
return isoWeekOf(now).start - 11 * WEEK_SECONDS;
|
|
52143
|
+
}
|
|
52144
|
+
/**
|
|
52145
|
+
* `kanbo stats` — how work flows on this board (spec 2026-11-05, Part 4).
|
|
52146
|
+
*
|
|
52147
|
+
* Reading only, so it is anybody's, an agent's shell included. A sprint sets
|
|
52148
|
+
* the range to its own dates and keeps its cards; `--from` and `--to` still
|
|
52149
|
+
* win over either end.
|
|
52150
|
+
*/
|
|
52151
|
+
function registerStatsCommand(program) {
|
|
52152
|
+
withBoardOptions(program.command("stats").description("How work flows on this board: cycle time, throughput, work in progress, returns, agents").option("--from <date>", `first day, YYYY-MM-DD (UTC) or unix seconds (12 weeks back when left out)`, (value) => parseCalendarDay(value, false)).option("--to <date>", "last day, YYYY-MM-DD (UTC, through its end) or unix seconds (now when left out)", (value) => parseCalendarDay(value, true)).option("--sprint <id>", "only the cards of this sprint, over its dates")).action(async (options) => {
|
|
52153
|
+
await runBoardCommand(options, "read", async (session) => {
|
|
52154
|
+
const now = currentUnixSeconds();
|
|
52155
|
+
const sprint = options.sprint === void 0 ? null : (await session.ops.listSprints(session.workspace.id, now)).find((candidate) => candidate.id === options.sprint) ?? null;
|
|
52156
|
+
if (options.sprint !== void 0 && sprint === null) throw new BoardError("issue_milestone_not_found", {
|
|
52157
|
+
milestoneId: options.sprint,
|
|
52158
|
+
workspaceId: session.workspace.id
|
|
52159
|
+
});
|
|
52160
|
+
const metrics = await session.ops.boardMetrics({
|
|
52161
|
+
workspaceIds: [session.workspace.id],
|
|
52162
|
+
from: options.from ?? sprint?.startDate ?? defaultStatsFrom(now),
|
|
52163
|
+
to: options.to ?? sprint?.dueDate ?? now,
|
|
52164
|
+
milestoneId: sprint?.id ?? null
|
|
52165
|
+
}, now);
|
|
52166
|
+
return {
|
|
52167
|
+
value: metrics,
|
|
52168
|
+
text: describeMetrics(metrics, sprint),
|
|
52169
|
+
fields: BOARD_METRICS_FIELDS
|
|
52170
|
+
};
|
|
52171
|
+
});
|
|
52172
|
+
});
|
|
52173
|
+
}
|
|
52174
|
+
//#endregion
|
|
51009
52175
|
//#region src/cli/commands/uninstall.ts
|
|
51010
52176
|
function registerUninstallCommand(program) {
|
|
51011
52177
|
program.command("uninstall").description("Disconnect kanbo from your agents; the board stays unless --purge").option("--project", "only this project's files").option("--global", "only your own files, for every project").option("--purge", "also remove this project's board settings, and its board file after you confirm").option("--yes", "do it without asking (never deletes a board file)").option("--json [fields]", "print what was done as JSON; name comma-separated fields to print only those").action(async (options) => {
|
|
@@ -51186,9 +52352,11 @@ function registerKanboCommands(program, context = {}) {
|
|
|
51186
52352
|
registerReadyCommand(program);
|
|
51187
52353
|
registerPrimeCommand(program);
|
|
51188
52354
|
registerSprintCommands(program);
|
|
52355
|
+
registerStatsCommand(program);
|
|
51189
52356
|
registerServeCommand(program);
|
|
51190
52357
|
program.commandsGroup("For agents and integrations:");
|
|
51191
52358
|
registerMcpCommand(program);
|
|
52359
|
+
registerClaimCommand(program);
|
|
51192
52360
|
registerRunCommands(program);
|
|
51193
52361
|
registerCapabilitiesCommand(program);
|
|
51194
52362
|
program.commandsGroup("Shared Postgres boards:");
|